From 1d8e4435b38ce8f6eb2709667b7cb5d3cfc1a598 Mon Sep 17 00:00:00 2001 From: AutonetSellCar Deploy Date: Tue, 30 Dec 2025 18:56:28 +0900 Subject: [PATCH] Feature: Russian language support & Vehicle Requests improvements MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add Russian language support (title_ru, subtitle_ru) for hero banners - Add fuel/transmission translations for Mongolian (경유→Дизель, 오토→Автомат) - Improve Vehicle Requests admin page: - Display real request ID and user email - Show detailed request info (maker, grade, year, fuel, mileage) - Replace modal search with Cars page integration - Add "Add to Request" flow in Cars page for vehicle recommendations - Fix image URL handling in FilmStripSlider and car detail page 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- backend/app/api/hero_banners.py | 17 +- backend/app/api/vehicle_requests.py | 31 +- backend/app/models/hero_banner.py | 2 + backend/app/schemas/hero_banner.py | 4 + backend/app/schemas/vehicle_request.py | 2 + frontend/src/app/admin/cars/page.tsx | 158 +++++++++- .../src/app/admin/vehicle-requests/page.tsx | 297 ++++-------------- frontend/src/app/cars/[id]/page.tsx | 5 +- frontend/src/app/page.tsx | 29 +- frontend/src/components/FilmStripSlider.tsx | 22 +- frontend/src/lib/api.ts | 2 + frontend/src/lib/i18n.ts | 6 + frontend/src/types/index.ts | 2 + 13 files changed, 306 insertions(+), 271 deletions(-) diff --git a/backend/app/api/hero_banners.py b/backend/app/api/hero_banners.py index 40279b3..e1294a5 100644 --- a/backend/app/api/hero_banners.py +++ b/backend/app/api/hero_banners.py @@ -23,23 +23,24 @@ ALLOWED_EXTENSIONS = {".jpg", ".jpeg", ".png", ".gif", ".webp"} def get_localized_field(obj, field: str, lang: str) -> Optional[str]: - """Get localized field value with fallback to Korean then English""" + """Get localized field value with fallback to English""" + # 1. 선택된 언어의 필드 localized = getattr(obj, f"{field}_{lang}", None) if localized: return localized - # Fallback to Korean - ko_value = getattr(obj, f"{field}_ko", None) - if ko_value: - return ko_value - # Fallback to English - return getattr(obj, f"{field}_en", None) + # 2. 영어 폴백 + en_value = getattr(obj, f"{field}_en", None) + if en_value: + return en_value + # 3. 한국어 폴백 (마지막 수단) + return getattr(obj, f"{field}_ko", None) # ==================== Public Endpoints ==================== @router.get("/", response_model=List[HeroBannerLocalizedResponse]) def get_hero_banners( - lang: str = Query("ko", regex="^(ko|en|mn)$"), + lang: str = Query("ko", regex="^(ko|en|mn|ru)$"), db: Session = Depends(get_db) ): """활성 히어로 배너 목록 조회 (Public)""" diff --git a/backend/app/api/vehicle_requests.py b/backend/app/api/vehicle_requests.py index e79cf05..337bf76 100644 --- a/backend/app/api/vehicle_requests.py +++ b/backend/app/api/vehicle_requests.py @@ -143,7 +143,36 @@ def admin_get_all_requests( query = query.filter(VehicleRequest.status == status) requests = query.order_by(VehicleRequest.created_at.desc()).all() - return requests + + # User 정보 추가 + result = [] + for req in requests: + user = db.query(User).filter(User.id == req.user_id).first() + req_dict = { + "id": req.id, + "user_id": req.user_id, + "user_email": user.email if user else None, + "user_name": user.name if user else None, + "maker_code": req.maker_code, + "maker_name": req.maker_name, + "model_code": req.model_code, + "model_name": req.model_name, + "grade_code": req.grade_code, + "grade_name": req.grade_name, + "year_from": req.year_from, + "year_to": req.year_to, + "mileage_min": req.mileage_min, + "mileage_max": req.mileage_max, + "fuel": req.fuel, + "displacement_min": req.displacement_min, + "displacement_max": req.displacement_max, + "status": req.status, + "admin_reviewed_at": req.admin_reviewed_at, + "created_at": req.created_at, + } + result.append(req_dict) + + return result @router.get("/admin/{request_id}", response_model=VehicleRequestWithVehicles) diff --git a/backend/app/models/hero_banner.py b/backend/app/models/hero_banner.py index d1a9a7c..a49304b 100644 --- a/backend/app/models/hero_banner.py +++ b/backend/app/models/hero_banner.py @@ -38,11 +38,13 @@ class HeroBanner(Base): title_ko = Column(String(100)) title_en = Column(String(100)) title_mn = Column(String(100)) # 몽골어 + title_ru = Column(String(100)) # 러시아어 # 다국어 서브타이틀 subtitle_ko = Column(String(200)) subtitle_en = Column(String(200)) subtitle_mn = Column(String(200)) + subtitle_ru = Column(String(200)) # 러시아어 # 이미지 URL image_url = Column(String(500), nullable=False) diff --git a/backend/app/schemas/hero_banner.py b/backend/app/schemas/hero_banner.py index 7ce73a1..95a0f77 100644 --- a/backend/app/schemas/hero_banner.py +++ b/backend/app/schemas/hero_banner.py @@ -36,9 +36,11 @@ class HeroBannerBase(BaseModel): title_ko: Optional[str] = None title_en: Optional[str] = None title_mn: Optional[str] = None + title_ru: Optional[str] = None # 러시아어 subtitle_ko: Optional[str] = None subtitle_en: Optional[str] = None subtitle_mn: Optional[str] = None + subtitle_ru: Optional[str] = None # 러시아어 image_url: str link_url: Optional[str] = None car_id: Optional[int] = None @@ -54,9 +56,11 @@ class HeroBannerUpdate(BaseModel): title_ko: Optional[str] = None title_en: Optional[str] = None title_mn: Optional[str] = None + title_ru: Optional[str] = None # 러시아어 subtitle_ko: Optional[str] = None subtitle_en: Optional[str] = None subtitle_mn: Optional[str] = None + subtitle_ru: Optional[str] = None # 러시아어 image_url: Optional[str] = None link_url: Optional[str] = None car_id: Optional[int] = None diff --git a/backend/app/schemas/vehicle_request.py b/backend/app/schemas/vehicle_request.py index c413f30..c7a5400 100644 --- a/backend/app/schemas/vehicle_request.py +++ b/backend/app/schemas/vehicle_request.py @@ -23,6 +23,8 @@ class VehicleRequestCreate(BaseModel): class VehicleRequestResponse(BaseModel): id: int user_id: int + user_email: Optional[str] = None # 관리자용 + user_name: Optional[str] = None # 관리자용 maker_code: Optional[str] maker_name: Optional[str] model_code: Optional[str] diff --git a/frontend/src/app/admin/cars/page.tsx b/frontend/src/app/admin/cars/page.tsx index ed63b78..c4d5bdf 100644 --- a/frontend/src/app/admin/cars/page.tsx +++ b/frontend/src/app/admin/cars/page.tsx @@ -1,8 +1,9 @@ 'use client'; import { useState, useEffect } from 'react'; +import { useSearchParams, useRouter } from 'next/navigation'; import Image from 'next/image'; -import api, { heroBannersApi, carmodooApi } from '@/lib/api'; +import api, { heroBannersApi, carmodooApi, vehicleRequestsApi } from '@/lib/api'; interface CarmodooMaker { code: string; @@ -193,6 +194,16 @@ export default function CarsAdminPage() { }>; } | null>(null); + // Vehicle Request mode (when coming from Vehicle Requests page) + const searchParams = useSearchParams(); + const router = useRouter(); + const requestId = searchParams.get('requestId'); + const [addingToRequest, setAddingToRequest] = useState(false); + const [requestAddResult, setRequestAddResult] = useState<{ + added: number; + errors: number; + } | null>(null); + // Dealer description editing state const [showDescEditModal, setShowDescEditModal] = useState(false); const [editingCar, setEditingCar] = useState(null); @@ -249,6 +260,33 @@ export default function CarsAdminPage() { } }, [filters.model_code]); + // Pre-fill filters from URL params (when coming from Vehicle Requests page) + useEffect(() => { + if (requestId) { + const makerCode = searchParams.get('maker_code') || ''; + const modelCode = searchParams.get('model_code') || ''; + const grade = searchParams.get('grade') || ''; + const yearMin = searchParams.get('year_min') || ''; + const yearMax = searchParams.get('year_max') || ''; + const mileageMax = searchParams.get('mileage_max') || ''; + const fuel = searchParams.get('fuel') || ''; + + setFilters(prev => ({ + ...prev, + maker_code: makerCode, + model_code: modelCode, + grade: grade, + year_min: yearMin, + year_max: yearMax, + mileage_max: mileageMax, + fuel: fuel, + })); + + // Switch to Carmodoo tab + setActiveTab('carmodoo'); + } + }, [requestId]); + const loadLocalCars = async (page = 1) => { setLocalLoading(true); try { @@ -714,6 +752,76 @@ export default function CarsAdminPage() { } }; + // 차량 추천 목록에 추가 함수 (Vehicle Request용) + const handleAddToRequest = async () => { + if (!requestId) return; + if (selectedCars.size === 0) { + alert('Please select at least one car to add to the request.'); + return; + } + + if (!confirm(`${selectedCars.size}개의 차량을 추천 목록에 추가하시겠습니까?`)) { + return; + } + + setAddingToRequest(true); + setRequestAddResult(null); + + try { + const selectedCarsList = cars.filter(car => selectedCars.has(car.id)); + let addedCount = 0; + let errorCount = 0; + + for (const car of selectedCarsList) { + try { + // Add vehicle to request + await vehicleRequestsApi.adminAddVehicle(parseInt(requestId), { + request_id: parseInt(requestId), + car_data: { + id: car.id, + car_name: car.car_name, + maker_name: car.maker_name, + model_name: car.model_name, + year: car.year, + mileage: car.mileage, + final_price: car.price, + fuel: car.fuel, + transmission: car.transmission, + color: car.color, + main_image: car.main_image, + }, + is_approved: true, + }); + addedCount++; + } catch (err) { + console.error(`Failed to add car ${car.id}:`, err); + errorCount++; + } + } + + setRequestAddResult({ + added: addedCount, + errors: errorCount, + }); + + setSelectedCars(new Set()); + + if (errorCount > 0) { + alert(`${addedCount}개의 차량이 추천 목록에 추가되었습니다.\n(실패: ${errorCount}개)`); + } else { + alert(`${addedCount}개의 차량이 추천 목록에 추가되었습니다.`); + } + + // Redirect back to vehicle requests page + router.push('/admin/vehicle-requests'); + } catch (err) { + console.error('Add to request failed:', err); + alert('추천 목록 추가에 실패했습니다.'); + } finally { + setAddingToRequest(false); + } + }; + const handleDeleteCar = async (carId: number) => { if (!confirm('Are you sure you want to delete this car?')) { return; @@ -858,6 +966,29 @@ export default function CarsAdminPage() {

Cars Management

+ {/* Request Mode Banner */} + {requestId && ( +
+
+
+ + + +
+
+

Adding vehicles to Request #{requestId}

+

Search and select vehicles, then click "Add to Request" button.

+
+
+ +
+ )} + {/* Tabs */}
+ {requestId && ( + + )}
diff --git a/frontend/src/app/admin/vehicle-requests/page.tsx b/frontend/src/app/admin/vehicle-requests/page.tsx index 403311e..cc6b7fb 100644 --- a/frontend/src/app/admin/vehicle-requests/page.tsx +++ b/frontend/src/app/admin/vehicle-requests/page.tsx @@ -1,24 +1,16 @@ 'use client'; import { useState, useEffect } from 'react'; -import { vehicleRequestsApi, carmodooApi, VehicleRequest, VehicleRequestWithVehicles, CarmodooSearchResult } from '@/lib/api'; - -const ITEMS_PER_PAGE = 12; +import Link from 'next/link'; +import { vehicleRequestsApi, VehicleRequest, VehicleRequestWithVehicles } from '@/lib/api'; export default function AdminVehicleRequestsPage() { const [requests, setRequests] = useState([]); const [selectedRequest, setSelectedRequest] = useState(null); const [isLoading, setIsLoading] = useState(true); const [statusFilter, setStatusFilter] = useState(''); - const [showAddModal, setShowAddModal] = useState(false); - const [searchResults, setSearchResults] = useState([]); - const [isSearching, setIsSearching] = useState(false); const [deletingVehicleId, setDeletingVehicleId] = useState(null); - // Pagination state - const [currentPage, setCurrentPage] = useState(1); - const [totalResults, setTotalResults] = useState(0); - // Load requests useEffect(() => { loadRequests(); @@ -46,65 +38,21 @@ export default function AdminVehicleRequestsPage() { } }; - // Search for vehicles based on request criteria - const searchVehicles = async (page: number = 1) => { - if (!selectedRequest) return; + // Build Cars page URL with search criteria from request + const buildCarsPageUrl = () => { + if (!selectedRequest) return '/admin/cars'; - try { - setIsSearching(true); - const params: any = { - page: page, - page_size: 50, // Fetch more results - }; + const params = new URLSearchParams(); + params.set('requestId', selectedRequest.request.id.toString()); + if (selectedRequest.request.maker_code) params.set('maker_code', selectedRequest.request.maker_code); + if (selectedRequest.request.model_code) params.set('model_code', selectedRequest.request.model_code); + if (selectedRequest.request.grade_code) params.set('grade', selectedRequest.request.grade_code); + if (selectedRequest.request.year_from) params.set('year_min', selectedRequest.request.year_from.toString()); + if (selectedRequest.request.year_to) params.set('year_max', selectedRequest.request.year_to.toString()); + if (selectedRequest.request.mileage_max) params.set('mileage_max', selectedRequest.request.mileage_max.toString()); + if (selectedRequest.request.fuel) params.set('fuel', selectedRequest.request.fuel); - if (selectedRequest.request.maker_code) params.maker_code = selectedRequest.request.maker_code; - if (selectedRequest.request.model_code) params.model_code = selectedRequest.request.model_code; - if (selectedRequest.request.grade_code) params.grade = selectedRequest.request.grade_code; - if (selectedRequest.request.year_from) params.year_min = selectedRequest.request.year_from; - if (selectedRequest.request.year_to) params.year_max = selectedRequest.request.year_to; - if (selectedRequest.request.mileage_min) params.mileage_min = selectedRequest.request.mileage_min; - if (selectedRequest.request.mileage_max) params.mileage_max = selectedRequest.request.mileage_max; - if (selectedRequest.request.fuel) params.fuel = selectedRequest.request.fuel; - - const result = await carmodooApi.requestSearch(params); - setSearchResults(result.cars); - setTotalResults(result.cars.length); - setCurrentPage(page); - } catch (error) { - console.error('Failed to search vehicles:', error); - } finally { - setIsSearching(false); - } - }; - - // Get paginated results - const getPaginatedResults = () => { - const startIndex = (currentPage - 1) * ITEMS_PER_PAGE; - const endIndex = startIndex + ITEMS_PER_PAGE; - return searchResults.slice(startIndex, endIndex); - }; - - const totalPages = Math.ceil(searchResults.length / ITEMS_PER_PAGE); - - // Add vehicle to request - const addVehicleToRequest = async (car: CarmodooSearchResult) => { - if (!selectedRequest) return; - - try { - await vehicleRequestsApi.adminAddVehicle(selectedRequest.request.id, { - request_id: selectedRequest.request.id, - car_data: car, - is_approved: true, // Auto-approve when adding - }); - - // Reload request detail - await loadRequestDetail(selectedRequest.request.id); - - // Remove from search results - setSearchResults(prev => prev.filter(c => c.id !== car.id)); - } catch (error) { - console.error('Failed to add vehicle:', error); - } + return `/admin/cars?${params.toString()}`; }; // Delete vehicle from request @@ -212,26 +160,52 @@ export default function AdminVehicleRequestsPage() { >
+
+ + #{request.id} + + {getStatusBadge(request.status)} +

- {request.maker_name} - {request.model_name} + {request.maker_name || '-'} {request.model_name || ''} + {request.grade_name && / {request.grade_name}}

-

- User ID: {request.user_id} -

- {getStatusBadge(request.status)}
-
- {request.year_from && request.year_to && ( - Year: {request.year_from}-{request.year_to} - )} - {request.mileage_max && ( - Max Mileage: {Math.round(request.mileage_max / 10000)}만km - )} +
+
+ Year: + + {request.year_from || '-'} ~ {request.year_to || '-'} + +
+
+ Fuel: + {request.fuel || '-'} +
+
+ Mileage: + + {request.mileage_max ? `~${Math.round(request.mileage_max / 10000)}만km` : '-'} + +
+
+ CC: + + {request.displacement_min || request.displacement_max + ? `${request.displacement_min || '-'} ~ ${request.displacement_max || '-'}` + : '-'} + +
+
+
+ + {request.user_email || request.user_name || `User #${request.user_id}`} + + + {formatDate(request.created_at)} +
-

- {formatDate(request.created_at)} -

))} @@ -315,16 +289,13 @@ export default function AdminVehicleRequestsPage() {

Recommended Vehicles ({selectedRequest.approved_vehicles.length})

- + 🚗 + Search & Add from Carmodoo + {selectedRequest.approved_vehicles.length === 0 ? ( @@ -376,150 +347,6 @@ export default function AdminVehicleRequestsPage() { )} - - {/* Add Vehicle Modal */} - {showAddModal && selectedRequest && ( -
-
-
-

Search & Add Vehicles

- -
- -
-
-
-

- Searching for: {selectedRequest.request.maker_name} {selectedRequest.request.model_name} -

- {searchResults.length > 0 && ( -

- Found {searchResults.length} vehicles (showing {Math.min((currentPage - 1) * ITEMS_PER_PAGE + 1, searchResults.length)}-{Math.min(currentPage * ITEMS_PER_PAGE, searchResults.length)}) -

- )} -
- -
-
- -
- {isSearching ? ( -
-
-

Searching from Carmodoo...

-
- ) : searchResults.length === 0 ? ( -
No vehicles found
- ) : ( -
- {getPaginatedResults().map((car) => ( -
- {car.main_image && ( - - )} -
-

{car.car_name}

-
-

{car.year}년 | {car.mileage?.toLocaleString()}km

-

{car.fuel} | {car.transmission}

- {car.color &&

Color: {car.color}

} -
-

- {car.final_price?.toLocaleString()}만원 -

- -
-
- ))} -
- )} -
- - {/* Pagination */} - {searchResults.length > ITEMS_PER_PAGE && ( -
- - - - {/* Page numbers */} - {Array.from({ length: totalPages }, (_, i) => i + 1) - .filter(page => { - // Show first, last, current, and pages near current - return page === 1 || page === totalPages || Math.abs(page - currentPage) <= 2; - }) - .map((page, index, array) => ( - - {index > 0 && array[index - 1] !== page - 1 && ( - ... - )} - - - ))} - - - -
- )} -
-
- )} ); } diff --git a/frontend/src/app/cars/[id]/page.tsx b/frontend/src/app/cars/[id]/page.tsx index 13a3a4f..13debf6 100644 --- a/frontend/src/app/cars/[id]/page.tsx +++ b/frontend/src/app/cars/[id]/page.tsx @@ -9,14 +9,15 @@ import { useTranslation } from '@/lib/i18n'; import { useTranslate } from '@/lib/useTranslate'; // 이미지 URL 변환 (로컬 경로는 백엔드 URL 추가) +const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8000'; + const getImageUrl = (url: string | undefined): string => { if (!url) return ''; if (url.startsWith('http://') || url.startsWith('https://')) { return url; } // 로컬 경로인 경우 백엔드 URL 추가 - const backendPort = process.env.NEXT_PUBLIC_API_URL?.includes('8001') ? 8001 : 8000; - return `http://localhost:${backendPort}${url}`; + return `${API_BASE_URL}${url}`; }; export default function CarDetailPage() { diff --git a/frontend/src/app/page.tsx b/frontend/src/app/page.tsx index 5abe815..58fd78e 100644 --- a/frontend/src/app/page.tsx +++ b/frontend/src/app/page.tsx @@ -13,22 +13,21 @@ export default function Home() { const [bannerSettings, setBannerSettings] = useState(); useEffect(() => { + const loadBanners = async () => { + try { + const [bannersData, settingsData] = await Promise.all([ + heroBannersApi.getList(language), + heroBannersApi.getSettings(), + ]); + setBanners(bannersData); + setBannerSettings(settingsData); + } catch (error) { + console.error('Failed to load banners:', error); + // 에러 시 샘플 배너 사용 (FilmStripSlider 내부에서 처리) + } + }; loadBanners(); - }, []); - - const loadBanners = async () => { - try { - const [bannersData, settingsData] = await Promise.all([ - heroBannersApi.getList(language), - heroBannersApi.getSettings(), - ]); - setBanners(bannersData); - setBannerSettings(settingsData); - } catch (error) { - console.error('Failed to load banners:', error); - // 에러 시 샘플 배너 사용 (FilmStripSlider 내부에서 처리) - } - }; + }, [language]); return (
diff --git a/frontend/src/components/FilmStripSlider.tsx b/frontend/src/components/FilmStripSlider.tsx index e28af9e..8d9fa86 100644 --- a/frontend/src/components/FilmStripSlider.tsx +++ b/frontend/src/components/FilmStripSlider.tsx @@ -338,15 +338,23 @@ const getImageUrl = (url: string): string => { // Helper to get localized title/subtitle based on language function getLocalizedText(banner: HeroBanner, field: 'title' | 'subtitle', language: Language): string { - // Public API returns localized single field (title, subtitle) - const directField = banner[field as keyof HeroBanner] as string | undefined; - if (directField) { - return directField; - } - // Admin API returns multi-language fields (title_ko, title_en, etc.) + // 1. 먼저 선택된 언어의 필드 확인 (title_mn, title_en, etc.) const langKey = `${field}_${language}` as keyof HeroBanner; + const langValue = banner[langKey] as string | undefined; + if (langValue) { + return langValue; + } + + // 2. 영어 폴백 const enKey = `${field}_en` as keyof HeroBanner; - return (banner[langKey] as string) || (banner[enKey] as string) || ''; + const enValue = banner[enKey] as string | undefined; + if (enValue) { + return enValue; + } + + // 3. 마지막으로 직접 필드 (API가 단일 필드로 반환하는 경우) + const directField = banner[field as keyof HeroBanner] as string | undefined; + return directField || ''; } function BannerCard({ banner, width, height }: BannerCardProps) { diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 1e60123..e38dd97 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -544,6 +544,8 @@ export const settingsApi = { export interface VehicleRequest { id: number; user_id: number; + user_email?: string; // 관리자용 + user_name?: string; // 관리자용 maker_code?: string; maker_name?: string; model_code?: string; diff --git a/frontend/src/lib/i18n.ts b/frontend/src/lib/i18n.ts index 672c9d3..263d552 100644 --- a/frontend/src/lib/i18n.ts +++ b/frontend/src/lib/i18n.ts @@ -2994,6 +2994,12 @@ const CAR_TRANSLATIONS: Record> = { '듀얼': { ko: '듀얼', en: 'Dual', mn: 'Dual', ru: 'Дуал' }, '싱글': { ko: '싱글', en: 'Single', mn: 'Single', ru: 'Сингл' }, '트윈': { ko: '트윈', en: 'Twin', mn: 'Twin', ru: 'Твин' }, + // Fuel Types (추가분) + '경유': { ko: '경유', en: 'Diesel', mn: 'Дизель', ru: 'Дизель' }, + '휘발유': { ko: '휘발유', en: 'Gasoline', mn: 'Бензин', ru: 'Бензин' }, + // Transmission Types (추가분) + '오토': { ko: '오토', en: 'Automatic', mn: 'Автомат', ru: 'Автомат' }, + '수동': { ko: '수동', en: 'Manual', mn: 'Механик', ru: 'Механика' }, }; // Sorted keys for translation (longest first to avoid partial matches) diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index 5dbf41f..f2306be 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -134,10 +134,12 @@ export interface HeroBanner { title_ko?: string; title_en?: string; title_mn?: string; + title_ru?: string; // 러시아어 // 다국어 서브타이틀 (Admin API 응답) subtitle_ko?: string; subtitle_en?: string; subtitle_mn?: string; + subtitle_ru?: string; // 러시아어 // 로컬라이즈된 필드 (Public API 응답) title?: string; subtitle?: string;