Compare commits
10 Commits
46973c8508
...
72eb8144e0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
72eb8144e0 | ||
|
|
b17840ef75 | ||
|
|
234f91a14a | ||
|
|
f01abfa15e | ||
|
|
958ea252bb | ||
|
|
a8aced66a8 | ||
|
|
b5d4b8b5bd | ||
|
|
a5a87e78e8 | ||
|
|
e274bc763d | ||
|
|
3f27297c4a |
@@ -516,9 +516,23 @@ class CarmodooClient:
|
||||
except:
|
||||
pass
|
||||
|
||||
# 이미지 URL 생성
|
||||
car_no_padded = car_no.zfill(9)
|
||||
main_image = f"{CARMODOO_BASE_URL}/data/__carPhoto/{car_no_padded[:3]}/{car_no_padded[3:6]}/{car_no_padded[6:]}/cmcar_0.jpg"
|
||||
# 이미지 URL: HTML img 태그에서 실제 URL 추출 (타임스탬프 패턴 대응)
|
||||
main_image = ""
|
||||
img_elems = row.xpath('.//img[contains(@src, "__carPhoto")]/@src')
|
||||
if img_elems:
|
||||
img_src = img_elems[0]
|
||||
# __THUM 접미사 제거하여 원본 이미지 URL 생성
|
||||
img_src = img_src.replace('__THUM', '')
|
||||
if img_src.startswith('/'):
|
||||
main_image = f"{CARMODOO_BASE_URL}{img_src}"
|
||||
elif img_src.startswith('http'):
|
||||
main_image = img_src
|
||||
else:
|
||||
main_image = f"{CARMODOO_BASE_URL}/{img_src}"
|
||||
if not main_image:
|
||||
# 폴백: 기존 패턴
|
||||
car_no_padded = car_no.zfill(9)
|
||||
main_image = f"{CARMODOO_BASE_URL}/data/__carPhoto/{car_no_padded[:3]}/{car_no_padded[3:6]}/{car_no_padded[6:]}/cmcar_0.jpg"
|
||||
|
||||
# 성능점검번호 및 key 추출 시도
|
||||
check_num = ""
|
||||
@@ -758,6 +772,64 @@ class CarmodooClient:
|
||||
|
||||
return result
|
||||
|
||||
async def get_car_image_urls(self, car_key: str) -> List[str]:
|
||||
"""AutoDB AJAX API를 통해 차량 이미지 URL 목록을 가져옴
|
||||
|
||||
dealerCarView.html이 사용하는 /common/ajax/AutoDB.html?mode=view&key={car_key}
|
||||
AJAX 엔드포인트를 호출하여 XML 응답의 strPhotos 필드에서 이미지 URL을 추출.
|
||||
|
||||
Args:
|
||||
car_key: 암호화된 차량 키
|
||||
|
||||
Returns:
|
||||
이미지 URL 리스트 (최대 20개)
|
||||
"""
|
||||
if not car_key:
|
||||
return []
|
||||
|
||||
if not self.is_logged_in:
|
||||
await self.login()
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=30.0, cookies=self.cookies) as client:
|
||||
url = f"{CARMODOO_BASE_URL}/common/ajax/AutoDB.html"
|
||||
params = {"mode": "view", "key": car_key}
|
||||
|
||||
response = await client.get(url, params=params, headers=self.headers)
|
||||
|
||||
if response.status_code == 200:
|
||||
try:
|
||||
xml_text = response.content.decode('euc-kr')
|
||||
except:
|
||||
xml_text = response.content.decode('utf-8', errors='replace')
|
||||
|
||||
# strPhotos 추출 (XML 태그, CDATA 포함 가능)
|
||||
photos_match = re.search(r'<strPhotos>(.*?)</strPhotos>', xml_text, re.DOTALL)
|
||||
if photos_match:
|
||||
photos_str = photos_match.group(1).strip()
|
||||
# CDATA 태그 제거
|
||||
photos_str = re.sub(r'<!\[CDATA\[', '', photos_str)
|
||||
photos_str = re.sub(r'\]\]>', '', photos_str)
|
||||
photos_str = photos_str.strip()
|
||||
if photos_str:
|
||||
urls = [u.strip() for u in photos_str.split("##") if u.strip()]
|
||||
# 상대 경로를 절대 경로로 변환
|
||||
full_urls = []
|
||||
for u in urls:
|
||||
if u.startswith('http'):
|
||||
full_urls.append(u)
|
||||
elif u.startswith('/'):
|
||||
full_urls.append(f"{CARMODOO_BASE_URL}{u}")
|
||||
else:
|
||||
full_urls.append(f"{CARMODOO_BASE_URL}/{u}")
|
||||
print(f"[Image Fallback] Found {len(full_urls)} image URLs from AutoDB for key={car_key[:20]}...")
|
||||
return full_urls
|
||||
|
||||
except Exception as e:
|
||||
print(f"Get car image URLs error: {e}")
|
||||
|
||||
return []
|
||||
|
||||
async def get_performance_check(self, car_no: str, car_key: str = "", check_num: str = "") -> dict:
|
||||
"""성능점검표 가져오기 - ck.carmodoo.com에서 조회
|
||||
|
||||
@@ -1520,6 +1592,25 @@ async def import_cars_from_carmodoo(
|
||||
if i > 0:
|
||||
break
|
||||
|
||||
# 폴백: __carPhoto에서 이미지를 못 가져온 경우 AutoDB AJAX로 실제 URL 조회
|
||||
if len(downloaded_images) == 0 and car_data.car_key:
|
||||
print(f"[Image Fallback] No images from __carPhoto for car {car.id} (car_no={car_data.car_no}), trying AutoDB...")
|
||||
fallback_urls = await carmodoo_client.get_car_image_urls(car_data.car_key)
|
||||
for i, img_url in enumerate(fallback_urls):
|
||||
local_filename = f"image_{i}.jpg"
|
||||
local_path = f"{image_base_dir}/{local_filename}"
|
||||
if await download_image(img_url, local_path):
|
||||
car_image = CarImage(
|
||||
car_id=car.id,
|
||||
url=f"/uploads/cars/{car.id}/{local_filename}",
|
||||
local_path=local_path,
|
||||
is_main=(i == 0),
|
||||
sort_order=i
|
||||
)
|
||||
db.add(car_image)
|
||||
downloaded_images.append(local_filename)
|
||||
print(f"[Image Fallback] Downloaded {len(downloaded_images)} images via AutoDB for car {car.id}")
|
||||
|
||||
# 성능점검표 가져오기 (check_num이 있으면 직접 사용)
|
||||
perf_check_result = await carmodoo_client.get_performance_check(
|
||||
car_data.car_no,
|
||||
|
||||
@@ -64,6 +64,8 @@ def get_cars(
|
||||
price_max: Optional[int] = None,
|
||||
mileage_max: Optional[int] = None,
|
||||
fuel: Optional[str] = None,
|
||||
color: Optional[str] = None,
|
||||
search: Optional[str] = None,
|
||||
status: Optional[str] = None,
|
||||
is_displayed: Optional[bool] = None,
|
||||
admin: bool = Query(False, description="Admin mode - show all cars"),
|
||||
@@ -85,6 +87,14 @@ def get_cars(
|
||||
if is_displayed is not None and admin:
|
||||
base_query = base_query.filter(Car.is_displayed == is_displayed)
|
||||
|
||||
# 통합 검색 (차량번호, 차량명)
|
||||
if search:
|
||||
search_term = f"%{search}%"
|
||||
base_query = base_query.filter(
|
||||
(Car.car_number.ilike(search_term)) |
|
||||
(Car.car_name.ilike(search_term))
|
||||
)
|
||||
|
||||
if maker_id:
|
||||
base_query = base_query.filter(Car.maker_id == maker_id)
|
||||
if model_id:
|
||||
@@ -101,6 +111,8 @@ def get_cars(
|
||||
base_query = base_query.filter(Car.mileage <= mileage_max)
|
||||
if fuel:
|
||||
base_query = base_query.filter(Car.fuel == fuel)
|
||||
if color:
|
||||
base_query = base_query.filter(Car.color.ilike(f"%{color}%"))
|
||||
|
||||
total = base_query.count()
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -9,7 +9,7 @@ import asyncio
|
||||
from apscheduler.schedulers.asyncio import AsyncIOScheduler
|
||||
from apscheduler.triggers.cron import CronTrigger
|
||||
from .database import engine, Base, SessionLocal
|
||||
from .api import cars, auth, inquiries, hero_banners, carmodoo, translations, cc, settings, vehicle_requests, dealer, vehicle_share, withdrawal, referral, notification, dashboard, push, exchange_rate, verification, visitor, sns_share, bulletin, reviews
|
||||
from .api import cars, auth, inquiries, hero_banners, carmodoo, cc, settings, vehicle_requests, dealer, vehicle_share, withdrawal, referral, notification, dashboard, push, exchange_rate, verification, visitor, sns_share, bulletin, reviews
|
||||
from .config import get_settings
|
||||
from .services.exchange_rate_service import update_exchange_rates
|
||||
from .services.visitor_service import aggregate_daily_stats, cleanup_old_visitor_logs
|
||||
@@ -223,7 +223,6 @@ app.include_router(auth.router, prefix="/api")
|
||||
app.include_router(inquiries.router, prefix="/api")
|
||||
app.include_router(hero_banners.router, prefix="/api")
|
||||
app.include_router(carmodoo.router, prefix="/api")
|
||||
app.include_router(translations.router, prefix="/api")
|
||||
app.include_router(cc.router, prefix="/api")
|
||||
app.include_router(settings.router, prefix="/api")
|
||||
app.include_router(vehicle_requests.router, prefix="/api")
|
||||
|
||||
@@ -2,7 +2,6 @@ from .car import CarMaker, CarModel, Car, CarImage, CarOption
|
||||
from .user import User, CarView, PerformanceCheckView, ChargeHistory, VerificationCode
|
||||
from .inquiry import Inquiry, InquiryMessage, InquiryStatus, InquiryCategory
|
||||
from .hero_banner import HeroBanner, HeroBannerSettings
|
||||
from .translation import Translation
|
||||
from .cache import CarCache, CarDetailCache, CacheRequestQueue
|
||||
from .settings import SystemSettings
|
||||
from .vehicle_request import VehicleRequest, RequestVehicle, PurchasedVehicle
|
||||
@@ -40,7 +39,6 @@ __all__ = [
|
||||
"InquiryCategory",
|
||||
"HeroBanner",
|
||||
"HeroBannerSettings",
|
||||
"Translation",
|
||||
"CarCache",
|
||||
"CarDetailCache",
|
||||
"CacheRequestQueue",
|
||||
|
||||
@@ -1,28 +0,0 @@
|
||||
from sqlalchemy import Column, Integer, String, DateTime, Index
|
||||
from sqlalchemy.sql import func
|
||||
from ..database import Base
|
||||
|
||||
|
||||
class Translation(Base):
|
||||
"""Translation dictionary for car-related terms"""
|
||||
__tablename__ = "translations"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
|
||||
# Source text (Korean)
|
||||
source_text = Column(String(500), nullable=False, index=True)
|
||||
|
||||
# Category: maker, model, fuel, transmission, color, car_name, etc.
|
||||
category = Column(String(50), nullable=False, index=True)
|
||||
|
||||
# Translations
|
||||
text_en = Column(String(500)) # English
|
||||
text_mn = Column(String(500)) # Mongolian
|
||||
text_ru = Column(String(500)) # Russian
|
||||
|
||||
created_at = Column(DateTime, default=func.now())
|
||||
updated_at = Column(DateTime, default=func.now(), onupdate=func.now())
|
||||
|
||||
__table_args__ = (
|
||||
Index('ix_translations_source_category', 'source_text', 'category', unique=True),
|
||||
)
|
||||
@@ -16,10 +16,6 @@ from .hero_banner import (
|
||||
HeroBannerListResponse, HeroBannerLocalizedResponse,
|
||||
HeroBannerSettingsUpdate, HeroBannerSettingsResponse,
|
||||
)
|
||||
from .translation import (
|
||||
TranslationCreate, TranslationUpdate, TranslationResponse,
|
||||
TranslationListResponse, TranslationBulkRequest, TranslationBulkResponse,
|
||||
)
|
||||
from .vehicle_request import (
|
||||
VehicleRequestCreate, VehicleRequestResponse,
|
||||
RequestVehicleCreate, RequestVehicleResponse, RequestVehicleApprove,
|
||||
@@ -65,8 +61,6 @@ __all__ = [
|
||||
"HeroBannerCreate", "HeroBannerUpdate", "HeroBannerResponse",
|
||||
"HeroBannerListResponse", "HeroBannerLocalizedResponse",
|
||||
"HeroBannerSettingsUpdate", "HeroBannerSettingsResponse",
|
||||
"TranslationCreate", "TranslationUpdate", "TranslationResponse",
|
||||
"TranslationListResponse", "TranslationBulkRequest", "TranslationBulkResponse",
|
||||
"VehicleRequestCreate", "VehicleRequestResponse",
|
||||
"RequestVehicleCreate", "RequestVehicleResponse", "RequestVehicleApprove",
|
||||
"PurchasedVehicleCreate", "PurchasedVehicleResponse", "PurchasedVehicleUpdateStatus",
|
||||
|
||||
@@ -1,52 +0,0 @@
|
||||
from pydantic import BaseModel
|
||||
from typing import Optional, List
|
||||
from datetime import datetime
|
||||
|
||||
|
||||
class TranslationCreate(BaseModel):
|
||||
source_text: str
|
||||
category: str
|
||||
text_en: Optional[str] = None
|
||||
text_mn: Optional[str] = None
|
||||
text_ru: Optional[str] = None
|
||||
|
||||
|
||||
class TranslationUpdate(BaseModel):
|
||||
source_text: Optional[str] = None
|
||||
category: Optional[str] = None
|
||||
text_en: Optional[str] = None
|
||||
text_mn: Optional[str] = None
|
||||
text_ru: Optional[str] = None
|
||||
|
||||
|
||||
class TranslationResponse(BaseModel):
|
||||
id: int
|
||||
source_text: str
|
||||
category: str
|
||||
text_en: Optional[str] = None
|
||||
text_mn: Optional[str] = None
|
||||
text_ru: Optional[str] = None
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class TranslationListResponse(BaseModel):
|
||||
total: int
|
||||
page: int
|
||||
page_size: int
|
||||
translations: List[TranslationResponse]
|
||||
|
||||
|
||||
class TranslationBulkRequest(BaseModel):
|
||||
"""Bulk translation lookup request"""
|
||||
texts: List[str]
|
||||
category: Optional[str] = None
|
||||
lang: str = "en"
|
||||
|
||||
|
||||
class TranslationBulkResponse(BaseModel):
|
||||
"""Returns a dictionary mapping source text to translated text"""
|
||||
translations: dict # {source_text: translated_text}
|
||||
226
frontend/package-lock.json
generated
226
frontend/package-lock.json
generated
@@ -14,6 +14,7 @@
|
||||
"@types/leaflet": "^1.9.21",
|
||||
"axios": "^1.6.5",
|
||||
"framer-motion": "^12.23.25",
|
||||
"jspdf": "^4.2.1",
|
||||
"leaflet": "^1.9.4",
|
||||
"next": "14.1.0",
|
||||
"react": "^18.2.0",
|
||||
@@ -45,6 +46,15 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/runtime": {
|
||||
"version": "7.29.2",
|
||||
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.2.tgz",
|
||||
"integrity": "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@heroicons/react": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@heroicons/react/-/react-2.2.0.tgz",
|
||||
@@ -350,6 +360,12 @@
|
||||
"undici-types": "~6.21.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/pako": {
|
||||
"version": "2.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@types/pako/-/pako-2.0.4.tgz",
|
||||
"integrity": "sha512-VWDCbrLeVXJM9fihYodcLiIv0ku+AlOa/TQ1SvYOaBuyrSKgEcro95LJyIsJ4vSo6BXIxOKxiJAat04CmST9Fw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/prop-types": {
|
||||
"version": "15.7.15",
|
||||
"resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz",
|
||||
@@ -357,6 +373,13 @@
|
||||
"devOptional": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/raf": {
|
||||
"version": "3.4.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/raf/-/raf-3.4.3.tgz",
|
||||
"integrity": "sha512-c4YAvMedbPZ5tEyxzQdMoOhhJ4RD3rngZIdwC2/qDN3d7JpEhB6fiBRKVY1lg5B7Wk+uPBjn5f39j1/2MY1oOw==",
|
||||
"license": "MIT",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/@types/react": {
|
||||
"version": "18.3.27",
|
||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.27.tgz",
|
||||
@@ -379,6 +402,13 @@
|
||||
"@types/react": "^18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/trusted-types": {
|
||||
"version": "2.0.7",
|
||||
"resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
|
||||
"integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==",
|
||||
"license": "MIT",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/any-promise": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz",
|
||||
@@ -462,6 +492,16 @@
|
||||
"proxy-from-env": "^1.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/base64-arraybuffer": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/base64-arraybuffer/-/base64-arraybuffer-1.0.2.tgz",
|
||||
"integrity": "sha512-I3yl4r9QB5ZRY3XuJVEPfc2XhZO6YweFPI+UovAzn+8/hb3oJ6lnysaFcjVpkCPfVWFUDvoZ8kmVDP7WyRtYtQ==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"engines": {
|
||||
"node": ">= 0.6.0"
|
||||
}
|
||||
},
|
||||
"node_modules/baseline-browser-mapping": {
|
||||
"version": "2.9.4",
|
||||
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.4.tgz",
|
||||
@@ -587,6 +627,26 @@
|
||||
],
|
||||
"license": "CC-BY-4.0"
|
||||
},
|
||||
"node_modules/canvg": {
|
||||
"version": "3.0.11",
|
||||
"resolved": "https://registry.npmjs.org/canvg/-/canvg-3.0.11.tgz",
|
||||
"integrity": "sha512-5ON+q7jCTgMp9cjpu4Jo6XbvfYwSB2Ow3kzHKfIyJfaCAOHLbdKPQqGKgfED/R5B+3TFFfe8pegYA+b423SRyA==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.12.5",
|
||||
"@types/raf": "^3.4.0",
|
||||
"core-js": "^3.8.3",
|
||||
"raf": "^3.4.1",
|
||||
"regenerator-runtime": "^0.13.7",
|
||||
"rgbcolor": "^1.0.1",
|
||||
"stackblur-canvas": "^2.0.0",
|
||||
"svg-pathdata": "^6.0.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/chokidar": {
|
||||
"version": "3.6.0",
|
||||
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz",
|
||||
@@ -653,6 +713,28 @@
|
||||
"node": ">= 6"
|
||||
}
|
||||
},
|
||||
"node_modules/core-js": {
|
||||
"version": "3.49.0",
|
||||
"resolved": "https://registry.npmjs.org/core-js/-/core-js-3.49.0.tgz",
|
||||
"integrity": "sha512-es1U2+YTtzpwkxVLwAFdSpaIMyQaq0PBgm3YD1W3Qpsn1NAmO3KSgZfu+oGSWVu6NvLHoHCV/aYcsE5wiB7ALg==",
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/core-js"
|
||||
}
|
||||
},
|
||||
"node_modules/css-line-break": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/css-line-break/-/css-line-break-2.1.0.tgz",
|
||||
"integrity": "sha512-FHcKFCZcAha3LwfVBhCQbW2nCNbkZXn7KVUJcsT5/P8YmfsVja0FMPJr0B903j/E69HUphKiV9iQArX8SDYA4w==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"utrie": "^1.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/cssesc": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz",
|
||||
@@ -696,6 +778,16 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/dompurify": {
|
||||
"version": "3.3.3",
|
||||
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.3.3.tgz",
|
||||
"integrity": "sha512-Oj6pzI2+RqBfFG+qOaOLbFXLQ90ARpcGG6UePL82bJLtdsa6CYJD7nmiU8MW9nQNOtCHV3lZ/Bzq1X0QYbBZCA==",
|
||||
"license": "(MPL-2.0 OR Apache-2.0)",
|
||||
"optional": true,
|
||||
"optionalDependencies": {
|
||||
"@types/trusted-types": "^2.0.7"
|
||||
}
|
||||
},
|
||||
"node_modules/dunder-proto": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
|
||||
@@ -802,6 +894,17 @@
|
||||
"node": ">= 6"
|
||||
}
|
||||
},
|
||||
"node_modules/fast-png": {
|
||||
"version": "6.4.0",
|
||||
"resolved": "https://registry.npmjs.org/fast-png/-/fast-png-6.4.0.tgz",
|
||||
"integrity": "sha512-kAqZq1TlgBjZcLr5mcN6NP5Rv4V2f22z00c3g8vRrwkcqjerx7BEhPbOnWCPqaHUl2XWQBJQvOT/FQhdMT7X/Q==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/pako": "^2.0.3",
|
||||
"iobuffer": "^5.3.2",
|
||||
"pako": "^2.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/fastq": {
|
||||
"version": "1.19.1",
|
||||
"resolved": "https://registry.npmjs.org/fastq/-/fastq-1.19.1.tgz",
|
||||
@@ -812,6 +915,12 @@
|
||||
"reusify": "^1.0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/fflate": {
|
||||
"version": "0.8.2",
|
||||
"resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.2.tgz",
|
||||
"integrity": "sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/fill-range": {
|
||||
"version": "7.1.1",
|
||||
"resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
|
||||
@@ -1033,6 +1142,26 @@
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/html2canvas": {
|
||||
"version": "1.4.1",
|
||||
"resolved": "https://registry.npmjs.org/html2canvas/-/html2canvas-1.4.1.tgz",
|
||||
"integrity": "sha512-fPU6BHNpsyIhr8yyMpTLLxAbkaK8ArIBcmZIRiBLiDhjeqvXolaEmDGmELFuX9I4xDcaKKcJl+TKZLqruBbmWA==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"css-line-break": "^2.1.0",
|
||||
"text-segmentation": "^1.0.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/iobuffer": {
|
||||
"version": "5.4.0",
|
||||
"resolved": "https://registry.npmjs.org/iobuffer/-/iobuffer-5.4.0.tgz",
|
||||
"integrity": "sha512-DRebOWuqDvxunfkNJAlc3IzWIPD5xVxwUNbHr7xKB8E6aLJxIPfNX3CoMJghcFjpv6RWQsrcJbghtEwSPoJqMA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/is-binary-path": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz",
|
||||
@@ -1112,6 +1241,23 @@
|
||||
"integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/jspdf": {
|
||||
"version": "4.2.1",
|
||||
"resolved": "https://registry.npmjs.org/jspdf/-/jspdf-4.2.1.tgz",
|
||||
"integrity": "sha512-YyAXyvnmjTbR4bHQRLzex3CuINCDlQnBqoSYyjJwTP2x9jDLuKDzy7aKUl0hgx3uhcl7xzg32agn5vlie6HIlQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.28.6",
|
||||
"fast-png": "^6.2.0",
|
||||
"fflate": "^0.8.1"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"canvg": "^3.0.11",
|
||||
"core-js": "^3.6.0",
|
||||
"dompurify": "^3.3.1",
|
||||
"html2canvas": "^1.0.0-rc.5"
|
||||
}
|
||||
},
|
||||
"node_modules/leaflet": {
|
||||
"version": "1.9.4",
|
||||
"resolved": "https://registry.npmjs.org/leaflet/-/leaflet-1.9.4.tgz",
|
||||
@@ -1370,6 +1516,12 @@
|
||||
"node": ">= 6"
|
||||
}
|
||||
},
|
||||
"node_modules/pako": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/pako/-/pako-2.1.0.tgz",
|
||||
"integrity": "sha512-w+eufiZ1WuJYgPXbV/PO3NCMEc3xqylkKHzp8bxp1uW4qaSNQUkwmLLEc3kKsfz8lpV1F8Ht3U1Cm+9Srog2ug==",
|
||||
"license": "(MIT AND Zlib)"
|
||||
},
|
||||
"node_modules/path-parse": {
|
||||
"version": "1.0.7",
|
||||
"resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz",
|
||||
@@ -1377,6 +1529,13 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/performance-now": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/performance-now/-/performance-now-2.1.0.tgz",
|
||||
"integrity": "sha512-7EAHlyLHI56VEIdK57uwHdHKIaAGbnXPiw0yWbarQZOKaKpvUIgW0jWRVLiatnM+XXlSwsanIBH/hzGMJulMow==",
|
||||
"license": "MIT",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/picocolors": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
||||
@@ -1618,6 +1777,16 @@
|
||||
],
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/raf": {
|
||||
"version": "3.4.1",
|
||||
"resolved": "https://registry.npmjs.org/raf/-/raf-3.4.1.tgz",
|
||||
"integrity": "sha512-Sq4CW4QhwOHE8ucn6J34MqtZCeWFP2aQSmrlroYgqAV1PjStIhJXxYuTgUIfkEk7zTLjmIjLmU5q+fbD1NnOJA==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"performance-now": "^2.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react": {
|
||||
"version": "18.3.1",
|
||||
"resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
|
||||
@@ -1704,6 +1873,13 @@
|
||||
"node": ">=8.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/regenerator-runtime": {
|
||||
"version": "0.13.11",
|
||||
"resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.11.tgz",
|
||||
"integrity": "sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==",
|
||||
"license": "MIT",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/resolve": {
|
||||
"version": "1.22.11",
|
||||
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz",
|
||||
@@ -1736,6 +1912,16 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/rgbcolor": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/rgbcolor/-/rgbcolor-1.0.1.tgz",
|
||||
"integrity": "sha512-9aZLIrhRaD97sgVhtJOW6ckOEh6/GnvQtdVNfdZ6s67+3/XwLS9lBcQYzEEhYVeUowN7pRzMLsyGhK2i/xvWbw==",
|
||||
"license": "MIT OR SEE LICENSE IN FEEL-FREE.md",
|
||||
"optional": true,
|
||||
"engines": {
|
||||
"node": ">= 0.8.15"
|
||||
}
|
||||
},
|
||||
"node_modules/run-parallel": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz",
|
||||
@@ -1778,6 +1964,16 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/stackblur-canvas": {
|
||||
"version": "2.7.0",
|
||||
"resolved": "https://registry.npmjs.org/stackblur-canvas/-/stackblur-canvas-2.7.0.tgz",
|
||||
"integrity": "sha512-yf7OENo23AGJhBriGx0QivY5JP6Y1HbrrDI6WLt6C5auYZXlQrheoY8hD4ibekFKz1HOfE48Ww8kMWMnJD/zcQ==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"engines": {
|
||||
"node": ">=0.1.14"
|
||||
}
|
||||
},
|
||||
"node_modules/streamsearch": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz",
|
||||
@@ -1845,6 +2041,16 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/svg-pathdata": {
|
||||
"version": "6.0.3",
|
||||
"resolved": "https://registry.npmjs.org/svg-pathdata/-/svg-pathdata-6.0.3.tgz",
|
||||
"integrity": "sha512-qsjeeq5YjBZ5eMdFuUa4ZosMLxgr5RZ+F+Y1OrDhuOCEInRMA3x74XdBtggJcj9kOeInz0WE+LgCPDkZFlBYJw==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"engines": {
|
||||
"node": ">=12.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/tailwindcss": {
|
||||
"version": "3.4.18",
|
||||
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.18.tgz",
|
||||
@@ -1883,6 +2089,16 @@
|
||||
"node": ">=14.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/text-segmentation": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/text-segmentation/-/text-segmentation-1.0.3.tgz",
|
||||
"integrity": "sha512-iOiPUo/BGnZ6+54OsWxZidGCsdU8YbE4PSpdPinp7DeMtUJNJBoJ/ouUSTJjHkh1KntHaltHl/gDs2FC4i5+Nw==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"utrie": "^1.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/thenify": {
|
||||
"version": "3.3.1",
|
||||
"resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz",
|
||||
@@ -2049,6 +2265,16 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/utrie": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/utrie/-/utrie-1.0.2.tgz",
|
||||
"integrity": "sha512-1MLa5ouZiOmQzUbjbu9VmjLzn1QLXBhwpUa7kdLUQK+KQ5KA9I1vk5U4YHe/X2Ch7PYnJfWuWT+VbuxbGwljhw==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"base64-arraybuffer": "^1.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/zustand": {
|
||||
"version": "4.5.7",
|
||||
"resolved": "https://registry.npmjs.org/zustand/-/zustand-4.5.7.tgz",
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
"@types/leaflet": "^1.9.21",
|
||||
"axios": "^1.6.5",
|
||||
"framer-motion": "^12.23.25",
|
||||
"jspdf": "^4.2.1",
|
||||
"leaflet": "^1.9.4",
|
||||
"next": "14.1.0",
|
||||
"react": "^18.2.0",
|
||||
|
||||
@@ -4,8 +4,9 @@ import { useState, useEffect, useCallback } from 'react';
|
||||
import { useSearchParams, useRouter } from 'next/navigation';
|
||||
import Image from 'next/image';
|
||||
import { Reorder, useDragControls } from 'framer-motion';
|
||||
import api, { heroBannersApi, carmodooApi, vehicleRequestsApi, carsApi } from '@/lib/api';
|
||||
import api, { heroBannersApi, carmodooApi, vehicleRequestsApi, carsApi, ccApi } from '@/lib/api';
|
||||
import { translateCarName } from '@/lib/i18n';
|
||||
import { jsPDF } from 'jspdf';
|
||||
|
||||
interface CarmodooMaker {
|
||||
code: string;
|
||||
@@ -152,6 +153,12 @@ export default function CarsAdminPage() {
|
||||
const [hasBannerChanges, setHasBannerChanges] = useState(false); // 변경사항 있는지
|
||||
const [updatingBanners, setUpdatingBanners] = useState(false); // 업데이트 중
|
||||
|
||||
// Local cars filter state
|
||||
const [localFilterSearch, setLocalFilterSearch] = useState('');
|
||||
const [localFilterColor, setLocalFilterColor] = useState('');
|
||||
const [localFilterYearMin, setLocalFilterYearMin] = useState('');
|
||||
const [localFilterYearMax, setLocalFilterYearMax] = useState('');
|
||||
|
||||
// All Cars (public view) state
|
||||
const [allCars, setAllCars] = useState<LocalCar[]>([]);
|
||||
const [allCarsLoading, setAllCarsLoading] = useState(false);
|
||||
@@ -463,10 +470,19 @@ export default function CarsAdminPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const loadLocalCars = async (page = 1, preserveBannerState = false) => {
|
||||
const loadLocalCars = async (page = 1, preserveBannerState = false, overrideFilters?: { search?: string; color?: string; yearMin?: string; yearMax?: string }) => {
|
||||
setLocalLoading(true);
|
||||
try {
|
||||
const { data } = await api.get('/cars', { params: { page, page_size: 100, admin: true } });
|
||||
const filterParams: any = { page, page_size: 100, admin: true };
|
||||
const s = overrideFilters?.search ?? localFilterSearch;
|
||||
const c = overrideFilters?.color ?? localFilterColor;
|
||||
const yMin = overrideFilters?.yearMin ?? localFilterYearMin;
|
||||
const yMax = overrideFilters?.yearMax ?? localFilterYearMax;
|
||||
if (s) filterParams.search = s;
|
||||
if (c) filterParams.color = c;
|
||||
if (yMin) filterParams.year_min = parseInt(yMin);
|
||||
if (yMax) filterParams.year_max = parseInt(yMax);
|
||||
const { data } = await api.get('/cars', { params: filterParams });
|
||||
const cars: LocalCar[] = data.cars || [];
|
||||
|
||||
// 배너 목록도 함께 로드 (순서 정보 포함) - 실패해도 차량 목록은 표시
|
||||
@@ -690,6 +706,89 @@ export default function CarsAdminPage() {
|
||||
});
|
||||
};
|
||||
|
||||
const [quickImporting, setQuickImporting] = useState<string | null>(null);
|
||||
|
||||
const handleQuickImport = async (car: CarmodooCarItem) => {
|
||||
setQuickImporting(car.id);
|
||||
try {
|
||||
const carToImport = {
|
||||
car_no: car.id,
|
||||
car_name: car.car_name || '',
|
||||
maker_name: car.maker_name,
|
||||
model_name: car.model_name,
|
||||
year: car.year,
|
||||
mileage: car.mileage,
|
||||
price: car.price,
|
||||
fuel: car.fuel,
|
||||
transmission: car.transmission,
|
||||
color: car.color,
|
||||
displacement: car.displacement,
|
||||
main_image: car.main_image,
|
||||
check_num: car.check_num,
|
||||
car_key: car.car_key,
|
||||
dealer_description: editedDescriptions[car.id],
|
||||
};
|
||||
|
||||
const { data } = await api.post('/carmodoo/import', { cars: [carToImport] });
|
||||
|
||||
if (data.summary.imported_count > 0 && data.imported?.[0]?.car_id) {
|
||||
const importedCarId = data.imported[0].car_id;
|
||||
// Fetch the imported car and show detail modal
|
||||
const carResponse = await api.get(`/cars/${importedCarId}?admin=true`);
|
||||
const localCar: LocalCar = carResponse.data;
|
||||
setSelectedCar(localCar);
|
||||
setCurrentImageIndex(0);
|
||||
setShowDetailModal(true);
|
||||
// Load dealer comments
|
||||
setDealerComment(null);
|
||||
setLoadingComment(true);
|
||||
try {
|
||||
const commentData = await carmodooApi.getCarTranslations(importedCarId);
|
||||
setDealerComment({
|
||||
ko: commentData.dealer_description,
|
||||
en: commentData.translations.en,
|
||||
mn: commentData.translations.mn,
|
||||
ru: commentData.translations.ru,
|
||||
});
|
||||
} catch { setDealerComment(null); }
|
||||
finally { setLoadingComment(false); }
|
||||
// Refresh local cars list
|
||||
loadLocalCars();
|
||||
} else if (data.summary.skipped_count > 0) {
|
||||
// Already imported - find and show existing car
|
||||
const skipped = data.skipped?.[0];
|
||||
if (skipped?.car_id) {
|
||||
const carResponse = await api.get(`/cars/${skipped.car_id}?admin=true`);
|
||||
const localCar: LocalCar = carResponse.data;
|
||||
setSelectedCar(localCar);
|
||||
setCurrentImageIndex(0);
|
||||
setShowDetailModal(true);
|
||||
setDealerComment(null);
|
||||
setLoadingComment(true);
|
||||
try {
|
||||
const commentData = await carmodooApi.getCarTranslations(skipped.car_id);
|
||||
setDealerComment({
|
||||
ko: commentData.dealer_description,
|
||||
en: commentData.translations.en,
|
||||
mn: commentData.translations.mn,
|
||||
ru: commentData.translations.ru,
|
||||
});
|
||||
} catch { setDealerComment(null); }
|
||||
finally { setLoadingComment(false); }
|
||||
} else {
|
||||
alert(`Already imported: ${skipped?.reason || 'duplicate'}`);
|
||||
}
|
||||
} else {
|
||||
alert('Import failed. Check car details.');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Quick import failed:', err);
|
||||
alert('Import failed. Please try again.');
|
||||
} finally {
|
||||
setQuickImporting(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSelectAll = () => {
|
||||
if (selectedCars.size === cars.length) {
|
||||
setSelectedCars(new Set());
|
||||
@@ -1258,6 +1357,105 @@ export default function CarsAdminPage() {
|
||||
setEditingComment(false);
|
||||
};
|
||||
|
||||
const [pdfDownloading, setPdfDownloading] = useState(false);
|
||||
|
||||
const handleDownloadImagesPdf = async () => {
|
||||
if (!selectedCar?.images || selectedCar.images.length === 0) return;
|
||||
|
||||
setPdfDownloading(true);
|
||||
try {
|
||||
const pdf = new jsPDF({ orientation: 'landscape', unit: 'mm', format: 'a4' });
|
||||
const pageWidth = pdf.internal.pageSize.getWidth();
|
||||
const pageHeight = pdf.internal.pageSize.getHeight();
|
||||
|
||||
const images = selectedCar.images;
|
||||
for (let i = 0; i < images.length; i++) {
|
||||
if (i > 0) pdf.addPage();
|
||||
|
||||
const imgUrl = getImageUrl(images[i].url);
|
||||
|
||||
try {
|
||||
const response = await fetch(imgUrl);
|
||||
const blob = await response.blob();
|
||||
const dataUrl = await new Promise<string>((resolve) => {
|
||||
const reader = new FileReader();
|
||||
reader.onloadend = () => resolve(reader.result as string);
|
||||
reader.readAsDataURL(blob);
|
||||
});
|
||||
|
||||
const img = new window.Image();
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
img.onload = () => resolve();
|
||||
img.onerror = () => reject(new Error(`Failed to load image ${i + 1}`));
|
||||
img.src = dataUrl;
|
||||
});
|
||||
|
||||
const imgRatio = img.width / img.height;
|
||||
const pageRatio = pageWidth / pageHeight;
|
||||
|
||||
let drawWidth: number, drawHeight: number, x: number, y: number;
|
||||
if (imgRatio > pageRatio) {
|
||||
drawWidth = pageWidth;
|
||||
drawHeight = pageWidth / imgRatio;
|
||||
x = 0;
|
||||
y = (pageHeight - drawHeight) / 2;
|
||||
} else {
|
||||
drawHeight = pageHeight;
|
||||
drawWidth = pageHeight * imgRatio;
|
||||
x = (pageWidth - drawWidth) / 2;
|
||||
y = 0;
|
||||
}
|
||||
|
||||
pdf.addImage(dataUrl, 'JPEG', x, y, drawWidth, drawHeight);
|
||||
} catch (imgError) {
|
||||
console.error(`Failed to load image ${i + 1}:`, imgError);
|
||||
pdf.setFontSize(14);
|
||||
pdf.text(`Image ${i + 1} - Failed to load`, pageWidth / 2, pageHeight / 2, { align: 'center' });
|
||||
}
|
||||
}
|
||||
|
||||
const carName = selectedCar.car_name.replace(/[^a-zA-Z0-9가-힣\s]/g, '').trim();
|
||||
const carNumber = selectedCar.car_number?.replace(/\s/g, '') || 'unknown';
|
||||
pdf.save(`${carName}_${carNumber}.pdf`);
|
||||
} catch (error) {
|
||||
console.error('PDF generation failed:', error);
|
||||
alert('PDF generation failed. Please try again.');
|
||||
} finally {
|
||||
setPdfDownloading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const [perfPdfDownloading, setPerfPdfDownloading] = useState(false);
|
||||
|
||||
const handleDownloadPerformanceCheckPdf = async () => {
|
||||
if (!selectedCar) return;
|
||||
|
||||
setPerfPdfDownloading(true);
|
||||
try {
|
||||
const blob = await ccApi.downloadPerformanceCheckPdf(selectedCar.id);
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
const carName = selectedCar.car_name.replace(/[^a-zA-Z0-9가-힣\s]/g, '').trim();
|
||||
const carNumber = selectedCar.car_number?.replace(/\s/g, '') || 'unknown';
|
||||
a.download = `${carName}_${carNumber}_성능점검표.pdf`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
window.URL.revokeObjectURL(url);
|
||||
} catch (error: any) {
|
||||
console.error('Performance check PDF download failed:', error);
|
||||
const status = error.response?.status;
|
||||
if (status === 404) {
|
||||
alert('Performance check PDF not available for this car.');
|
||||
} else {
|
||||
alert('Failed to download performance check PDF.');
|
||||
}
|
||||
} finally {
|
||||
setPerfPdfDownloading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleEditComment = () => {
|
||||
if (dealerComment) {
|
||||
setEditCommentData({
|
||||
@@ -1462,6 +1660,74 @@ export default function CarsAdminPage() {
|
||||
{/* Local Cars Tab */}
|
||||
{activeTab === 'local' && (
|
||||
<div className="bg-white rounded-xl shadow-sm p-6">
|
||||
{/* Filter Bar */}
|
||||
<div className="bg-gray-50 rounded-lg p-4 mb-4">
|
||||
<form onSubmit={(e) => { e.preventDefault(); setLocalPage(1); loadLocalCars(1); }} className="flex flex-wrap gap-3 items-end">
|
||||
<div className="flex-1 min-w-[200px]">
|
||||
<label className="block text-xs font-medium text-gray-500 mb-1">Car Name / Plate Number</label>
|
||||
<input
|
||||
type="text"
|
||||
value={localFilterSearch}
|
||||
onChange={(e) => setLocalFilterSearch(e.target.value)}
|
||||
placeholder="NX, 286소9799..."
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-primary-500 focus:border-transparent"
|
||||
/>
|
||||
</div>
|
||||
<div className="w-32">
|
||||
<label className="block text-xs font-medium text-gray-500 mb-1">Color</label>
|
||||
<input
|
||||
type="text"
|
||||
value={localFilterColor}
|
||||
onChange={(e) => setLocalFilterColor(e.target.value)}
|
||||
placeholder="검정, 흰색..."
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-primary-500 focus:border-transparent"
|
||||
/>
|
||||
</div>
|
||||
<div className="w-24">
|
||||
<label className="block text-xs font-medium text-gray-500 mb-1">Year From</label>
|
||||
<input
|
||||
type="number"
|
||||
value={localFilterYearMin}
|
||||
onChange={(e) => setLocalFilterYearMin(e.target.value)}
|
||||
placeholder="2020"
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-primary-500 focus:border-transparent"
|
||||
/>
|
||||
</div>
|
||||
<div className="w-24">
|
||||
<label className="block text-xs font-medium text-gray-500 mb-1">Year To</label>
|
||||
<input
|
||||
type="number"
|
||||
value={localFilterYearMax}
|
||||
onChange={(e) => setLocalFilterYearMax(e.target.value)}
|
||||
placeholder="2025"
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-primary-500 focus:border-transparent"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
className="px-4 py-2 bg-primary-600 text-white rounded-lg hover:bg-primary-700 text-sm font-medium"
|
||||
>
|
||||
Search
|
||||
</button>
|
||||
{(localFilterSearch || localFilterColor || localFilterYearMin || localFilterYearMax) && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setLocalFilterSearch('');
|
||||
setLocalFilterColor('');
|
||||
setLocalFilterYearMin('');
|
||||
setLocalFilterYearMax('');
|
||||
setLocalPage(1);
|
||||
loadLocalCars(1, false, { search: '', color: '', yearMin: '', yearMax: '' });
|
||||
}}
|
||||
className="px-4 py-2 border border-gray-300 text-gray-600 rounded-lg hover:bg-gray-100 text-sm"
|
||||
>
|
||||
Clear
|
||||
</button>
|
||||
)}
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<h2 className="text-lg font-semibold text-gray-800">
|
||||
Imported Cars ({localTotal} total)
|
||||
@@ -2525,8 +2791,8 @@ export default function CarsAdminPage() {
|
||||
key={car.id}
|
||||
className={`border-b border-gray-100 hover:bg-gray-50 cursor-pointer ${
|
||||
selectedCars.has(car.id) ? 'bg-primary-50' : ''
|
||||
}`}
|
||||
onClick={() => handleSelectCar(car.id)}
|
||||
} ${quickImporting === car.id ? 'opacity-50' : ''}`}
|
||||
onClick={() => handleQuickImport(car)}
|
||||
>
|
||||
<td className="py-3 px-4">
|
||||
<input
|
||||
@@ -2667,16 +2933,58 @@ export default function CarsAdminPage() {
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-white rounded-2xl max-w-4xl w-full max-h-[90vh] overflow-y-auto">
|
||||
{/* Modal Header */}
|
||||
<div className="sticky top-0 bg-white border-b border-gray-200 px-6 py-4 flex justify-between items-center">
|
||||
<div className="sticky top-0 bg-white border-b border-gray-200 px-6 py-4 flex justify-between items-center z-10">
|
||||
<h2 className="text-xl font-bold text-gray-800">{selectedCar.car_name}</h2>
|
||||
<button
|
||||
onClick={closeDetailModal}
|
||||
className="text-gray-500 hover:text-gray-700"
|
||||
>
|
||||
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
<div className="flex items-center gap-2">
|
||||
{selectedCar.images && selectedCar.images.length > 0 && (
|
||||
<button
|
||||
onClick={handleDownloadImagesPdf}
|
||||
disabled={pdfDownloading}
|
||||
className="flex items-center gap-1.5 px-3 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 disabled:opacity-50 transition font-medium text-sm"
|
||||
>
|
||||
{pdfDownloading ? (
|
||||
<>
|
||||
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white"></div>
|
||||
<span>Generating...</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />
|
||||
</svg>
|
||||
<span>Images PDF ({selectedCar.images.length})</span>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={handleDownloadPerformanceCheckPdf}
|
||||
disabled={perfPdfDownloading}
|
||||
className="flex items-center gap-1.5 px-3 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50 transition font-medium text-sm"
|
||||
>
|
||||
{perfPdfDownloading ? (
|
||||
<>
|
||||
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white"></div>
|
||||
<span>Downloading...</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
||||
</svg>
|
||||
<span>Performance Check</span>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
onClick={closeDetailModal}
|
||||
className="text-gray-500 hover:text-gray-700"
|
||||
>
|
||||
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Modal Content */}
|
||||
|
||||
@@ -11,14 +11,13 @@ const menuItems = [
|
||||
{ href: '/admin/cars', label: 'Cars', icon: '🚗' },
|
||||
{ href: '/admin/vehicle-requests', label: 'Vehicle Requests', icon: '📋' },
|
||||
{ href: '/admin/purchased', label: 'Purchased Vehicles', icon: '📦' },
|
||||
{ href: '/admin/dealers', label: 'Dealers', icon: '🤝' },
|
||||
{ href: '/admin/payments', label: 'Payments', icon: '💳' },
|
||||
{ href: '/admin/withdrawals', label: 'Withdrawals', icon: '💸' },
|
||||
{ href: '/admin/sns-shares', label: 'SNS Shares', icon: '📢' },
|
||||
{ href: '/admin/notifications', label: 'Notifications', icon: '🔔' },
|
||||
{ href: '/admin/translations', label: 'Translations', icon: '🌐' },
|
||||
{ href: '/admin/dealer-translations', label: 'Dealer Descriptions', icon: '📝' },
|
||||
{ href: '/admin/users', label: 'Users', icon: '👥' },
|
||||
{ href: '/admin/dealers', label: 'Dealers', icon: '🤝' },
|
||||
{ href: '/admin/dealer-translations', label: 'Dealer Descriptions', icon: '📝' },
|
||||
{ href: '/admin/inquiries', label: 'Inquiries', icon: '💬' },
|
||||
{ href: '/admin/board', label: 'Board', icon: '📌' },
|
||||
{ href: '/admin/reviews', label: 'Reviews', icon: '⭐' },
|
||||
|
||||
@@ -1,765 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { translationsApi, Translation, TranslationListResponse } from '@/lib/api';
|
||||
|
||||
const CATEGORY_LABELS: Record<string, string> = {
|
||||
maker: 'Maker (제조사)',
|
||||
model: 'Model (모델)',
|
||||
fuel: 'Fuel (연료)',
|
||||
transmission: 'Transmission (변속기)',
|
||||
color: 'Color (색상)',
|
||||
car_name: 'Car Name (차량명)',
|
||||
general: 'General (일반)',
|
||||
};
|
||||
|
||||
interface TranslationStats {
|
||||
total_entries: number;
|
||||
by_category: Record<string, number>;
|
||||
translation_coverage: {
|
||||
english: { translated: number; total: number; percentage: number };
|
||||
mongolian: { translated: number; total: number; percentage: number };
|
||||
russian: { translated: number; total: number; percentage: number };
|
||||
};
|
||||
}
|
||||
|
||||
export default function TranslationsPage() {
|
||||
const [translations, setTranslations] = useState<Translation[]>([]);
|
||||
const [categories, setCategories] = useState<string[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [page, setPage] = useState(1);
|
||||
const [selectedCategory, setSelectedCategory] = useState<string>('');
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [editingId, setEditingId] = useState<number | null>(null);
|
||||
const [editData, setEditData] = useState<Partial<Translation>>({});
|
||||
const [showAddModal, setShowAddModal] = useState(false);
|
||||
const [showBatchModal, setShowBatchModal] = useState(false);
|
||||
const [translatingId, setTranslatingId] = useState<number | null>(null);
|
||||
const [batchTranslating, setBatchTranslating] = useState(false);
|
||||
const [stats, setStats] = useState<TranslationStats | null>(null);
|
||||
const [batchOptions, setBatchOptions] = useState({
|
||||
category: '',
|
||||
overwriteExisting: false,
|
||||
targetLangs: ['en', 'mn', 'ru'] as string[],
|
||||
});
|
||||
const [batchResult, setBatchResult] = useState<{
|
||||
total_processed: number;
|
||||
successful: number;
|
||||
failed: number;
|
||||
} | null>(null);
|
||||
const [newTranslation, setNewTranslation] = useState({
|
||||
source_text: '',
|
||||
category: 'general',
|
||||
text_en: '',
|
||||
text_mn: '',
|
||||
text_ru: '',
|
||||
});
|
||||
|
||||
const pageSize = 20;
|
||||
|
||||
useEffect(() => {
|
||||
loadCategories();
|
||||
loadStats();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
loadTranslations();
|
||||
}, [page, selectedCategory, searchTerm]);
|
||||
|
||||
const loadCategories = async () => {
|
||||
try {
|
||||
const data = await translationsApi.getCategories();
|
||||
setCategories(data);
|
||||
} catch (err) {
|
||||
console.error('Failed to load categories:', err);
|
||||
}
|
||||
};
|
||||
|
||||
const loadStats = async () => {
|
||||
try {
|
||||
const data = await translationsApi.getStats();
|
||||
setStats(data);
|
||||
} catch (err) {
|
||||
console.error('Failed to load stats:', err);
|
||||
}
|
||||
};
|
||||
|
||||
const loadTranslations = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const data = await translationsApi.getList({
|
||||
page,
|
||||
page_size: pageSize,
|
||||
category: selectedCategory || undefined,
|
||||
search: searchTerm || undefined,
|
||||
});
|
||||
setTranslations(data.translations);
|
||||
setTotal(data.total);
|
||||
} catch (err) {
|
||||
console.error('Failed to load translations:', err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAutoExtract = async () => {
|
||||
try {
|
||||
const result = await translationsApi.autoExtract();
|
||||
alert(result.message);
|
||||
loadTranslations();
|
||||
loadStats();
|
||||
} catch (err) {
|
||||
console.error('Failed to auto-extract:', err);
|
||||
alert('Failed to auto-extract translations');
|
||||
}
|
||||
};
|
||||
|
||||
const handleSeedAllDefaults = async () => {
|
||||
try {
|
||||
const result = await translationsApi.seedAllDefaults();
|
||||
alert(`${result.message}\n\nCategories: ${result.categories.join(', ')}`);
|
||||
loadTranslations();
|
||||
loadStats();
|
||||
} catch (err) {
|
||||
console.error('Failed to seed defaults:', err);
|
||||
alert('Failed to seed default translations');
|
||||
}
|
||||
};
|
||||
|
||||
const handleAutoTranslate = async (translation: Translation) => {
|
||||
setTranslatingId(translation.id);
|
||||
try {
|
||||
const result = await translationsApi.autoTranslate(translation.id);
|
||||
alert(`Auto-translated: ${result.message}`);
|
||||
loadTranslations();
|
||||
loadStats();
|
||||
} catch (err: any) {
|
||||
console.error('Failed to auto-translate:', err);
|
||||
alert(err.response?.data?.detail || 'Failed to auto-translate');
|
||||
} finally {
|
||||
setTranslatingId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleBatchTranslate = async () => {
|
||||
setBatchTranslating(true);
|
||||
setBatchResult(null);
|
||||
try {
|
||||
const result = await translationsApi.autoTranslateBatch(
|
||||
batchOptions.targetLangs,
|
||||
batchOptions.category || undefined,
|
||||
batchOptions.overwriteExisting
|
||||
);
|
||||
setBatchResult({
|
||||
total_processed: result.total_processed,
|
||||
successful: result.successful,
|
||||
failed: result.failed,
|
||||
});
|
||||
loadTranslations();
|
||||
loadStats();
|
||||
} catch (err: any) {
|
||||
console.error('Failed to batch translate:', err);
|
||||
alert(err.response?.data?.detail || 'Failed to batch translate');
|
||||
} finally {
|
||||
setBatchTranslating(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleEdit = (translation: Translation) => {
|
||||
setEditingId(translation.id);
|
||||
setEditData({
|
||||
text_en: translation.text_en || '',
|
||||
text_mn: translation.text_mn || '',
|
||||
text_ru: translation.text_ru || '',
|
||||
});
|
||||
};
|
||||
|
||||
const handleSave = async (id: number) => {
|
||||
try {
|
||||
await translationsApi.update(id, editData);
|
||||
setEditingId(null);
|
||||
loadTranslations();
|
||||
loadStats();
|
||||
} catch (err) {
|
||||
console.error('Failed to save:', err);
|
||||
alert('Failed to save translation');
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (id: number) => {
|
||||
if (!confirm('Delete this translation?')) return;
|
||||
try {
|
||||
await translationsApi.delete(id);
|
||||
loadTranslations();
|
||||
loadStats();
|
||||
} catch (err) {
|
||||
console.error('Failed to delete:', err);
|
||||
alert('Failed to delete translation');
|
||||
}
|
||||
};
|
||||
|
||||
const handleAdd = async () => {
|
||||
if (!newTranslation.source_text.trim()) {
|
||||
alert('Source text is required');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await translationsApi.create(newTranslation);
|
||||
setShowAddModal(false);
|
||||
setNewTranslation({
|
||||
source_text: '',
|
||||
category: 'general',
|
||||
text_en: '',
|
||||
text_mn: '',
|
||||
text_ru: '',
|
||||
});
|
||||
loadTranslations();
|
||||
loadStats();
|
||||
} catch (err: any) {
|
||||
console.error('Failed to add:', err);
|
||||
alert(err.response?.data?.detail || 'Failed to add translation');
|
||||
}
|
||||
};
|
||||
|
||||
const totalPages = Math.ceil(total / pageSize);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex justify-between items-center mb-6">
|
||||
<h1 className="text-2xl font-bold text-gray-800">Translations Management</h1>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={handleSeedAllDefaults}
|
||||
className="bg-indigo-600 text-white px-4 py-2 rounded-lg hover:bg-indigo-700 flex items-center gap-2"
|
||||
title="Load all predefined translations (makers, models, colors, fuels, etc.)"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-8l-4-4m0 0L8 8m4-4v12" />
|
||||
</svg>
|
||||
Seed Defaults
|
||||
</button>
|
||||
<button
|
||||
onClick={handleAutoExtract}
|
||||
className="bg-blue-600 text-white px-4 py-2 rounded-lg hover:bg-blue-700 flex items-center gap-2"
|
||||
title="Extract terms from cars in database"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
|
||||
</svg>
|
||||
Auto Extract
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setShowBatchModal(true)}
|
||||
className="bg-purple-600 text-white px-4 py-2 rounded-lg hover:bg-purple-700 flex items-center gap-2"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 5h12M9 3v2m1.048 9.5A18.022 18.022 0 016.412 9m6.088 9h7M11 21l5-10 5 10M12.751 5C11.783 10.77 8.07 15.61 3 18.129" />
|
||||
</svg>
|
||||
Batch Translate
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setShowAddModal(true)}
|
||||
className="bg-green-600 text-white px-4 py-2 rounded-lg hover:bg-green-700 flex items-center gap-2"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
|
||||
</svg>
|
||||
Add New
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Translation Statistics */}
|
||||
{stats && (
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 mb-6">
|
||||
<div className="bg-white rounded-xl shadow-sm p-4">
|
||||
<div className="text-sm text-gray-500">Total Entries</div>
|
||||
<div className="text-2xl font-bold text-gray-800">{stats.total_entries}</div>
|
||||
</div>
|
||||
<div className="bg-white rounded-xl shadow-sm p-4">
|
||||
<div className="text-sm text-gray-500">English Coverage</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="text-2xl font-bold text-blue-600">{stats.translation_coverage.english.percentage.toFixed(1)}%</div>
|
||||
<div className="text-xs text-gray-400">({stats.translation_coverage.english.translated}/{stats.translation_coverage.english.total})</div>
|
||||
</div>
|
||||
<div className="w-full bg-gray-200 rounded-full h-2 mt-2">
|
||||
<div className="bg-blue-600 h-2 rounded-full" style={{ width: `${stats.translation_coverage.english.percentage}%` }}></div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-white rounded-xl shadow-sm p-4">
|
||||
<div className="text-sm text-gray-500">Mongolian Coverage</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="text-2xl font-bold text-green-600">{stats.translation_coverage.mongolian.percentage.toFixed(1)}%</div>
|
||||
<div className="text-xs text-gray-400">({stats.translation_coverage.mongolian.translated}/{stats.translation_coverage.mongolian.total})</div>
|
||||
</div>
|
||||
<div className="w-full bg-gray-200 rounded-full h-2 mt-2">
|
||||
<div className="bg-green-600 h-2 rounded-full" style={{ width: `${stats.translation_coverage.mongolian.percentage}%` }}></div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-white rounded-xl shadow-sm p-4">
|
||||
<div className="text-sm text-gray-500">Russian Coverage</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="text-2xl font-bold text-red-600">{stats.translation_coverage.russian.percentage.toFixed(1)}%</div>
|
||||
<div className="text-xs text-gray-400">({stats.translation_coverage.russian.translated}/{stats.translation_coverage.russian.total})</div>
|
||||
</div>
|
||||
<div className="w-full bg-gray-200 rounded-full h-2 mt-2">
|
||||
<div className="bg-red-600 h-2 rounded-full" style={{ width: `${stats.translation_coverage.russian.percentage}%` }}></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Category Stats */}
|
||||
{stats && Object.keys(stats.by_category).length > 0 && (
|
||||
<div className="bg-white rounded-xl shadow-sm p-4 mb-6">
|
||||
<h3 className="text-sm font-medium text-gray-700 mb-3">Entries by Category</h3>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{Object.entries(stats.by_category).map(([cat, count]) => (
|
||||
<span key={cat} className="px-3 py-1 bg-gray-100 text-gray-700 rounded-full text-sm">
|
||||
{CATEGORY_LABELS[cat] || cat}: {count}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Filters */}
|
||||
<div className="bg-white rounded-xl shadow-sm p-4 mb-6">
|
||||
<div className="flex flex-wrap gap-4">
|
||||
<div className="flex-1 min-w-[200px]">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Search</label>
|
||||
<input
|
||||
type="text"
|
||||
value={searchTerm}
|
||||
onChange={(e) => {
|
||||
setSearchTerm(e.target.value);
|
||||
setPage(1);
|
||||
}}
|
||||
placeholder="Search translations..."
|
||||
className="w-full border border-gray-300 rounded-lg px-3 py-2"
|
||||
/>
|
||||
</div>
|
||||
<div className="w-48">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Category</label>
|
||||
<select
|
||||
value={selectedCategory}
|
||||
onChange={(e) => {
|
||||
setSelectedCategory(e.target.value);
|
||||
setPage(1);
|
||||
}}
|
||||
className="w-full border border-gray-300 rounded-lg px-3 py-2"
|
||||
>
|
||||
<option value="">All Categories</option>
|
||||
{categories.map((cat) => (
|
||||
<option key={cat} value={cat}>
|
||||
{CATEGORY_LABELS[cat] || cat}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Table */}
|
||||
<div className="bg-white rounded-xl shadow-sm overflow-hidden">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className="bg-gray-50 border-b border-gray-200">
|
||||
<th className="py-3 px-4 text-left text-sm font-medium text-gray-600">Category</th>
|
||||
<th className="py-3 px-4 text-left text-sm font-medium text-gray-600">Korean (Source)</th>
|
||||
<th className="py-3 px-4 text-left text-sm font-medium text-gray-600">English</th>
|
||||
<th className="py-3 px-4 text-left text-sm font-medium text-gray-600">Mongolian</th>
|
||||
<th className="py-3 px-4 text-left text-sm font-medium text-gray-600">Russian</th>
|
||||
<th className="py-3 px-4 text-left text-sm font-medium text-gray-600 w-40">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{loading ? (
|
||||
<tr>
|
||||
<td colSpan={6} className="py-8 text-center">
|
||||
<div className="flex justify-center">
|
||||
<div className="w-8 h-8 border-4 border-primary-600 border-t-transparent rounded-full animate-spin"></div>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
) : translations.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={6} className="py-8 text-center text-gray-500">
|
||||
No translations found
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
translations.map((trans) => (
|
||||
<tr key={trans.id} className="border-b border-gray-100 hover:bg-gray-50">
|
||||
<td className="py-3 px-4">
|
||||
<span className="px-2 py-1 bg-blue-100 text-blue-700 rounded text-xs">
|
||||
{CATEGORY_LABELS[trans.category] || trans.category}
|
||||
</span>
|
||||
</td>
|
||||
<td className="py-3 px-4 font-medium text-gray-800">{trans.source_text}</td>
|
||||
<td className="py-3 px-4">
|
||||
{editingId === trans.id ? (
|
||||
<input
|
||||
type="text"
|
||||
value={editData.text_en || ''}
|
||||
onChange={(e) => setEditData({ ...editData, text_en: e.target.value })}
|
||||
className="w-full border border-gray-300 rounded px-2 py-1 text-sm"
|
||||
/>
|
||||
) : (
|
||||
<span className={trans.text_en ? 'text-gray-700' : 'text-gray-400 italic'}>
|
||||
{trans.text_en || 'Not set'}
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="py-3 px-4">
|
||||
{editingId === trans.id ? (
|
||||
<input
|
||||
type="text"
|
||||
value={editData.text_mn || ''}
|
||||
onChange={(e) => setEditData({ ...editData, text_mn: e.target.value })}
|
||||
className="w-full border border-gray-300 rounded px-2 py-1 text-sm"
|
||||
/>
|
||||
) : (
|
||||
<span className={trans.text_mn ? 'text-gray-700' : 'text-gray-400 italic'}>
|
||||
{trans.text_mn || 'Not set'}
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="py-3 px-4">
|
||||
{editingId === trans.id ? (
|
||||
<input
|
||||
type="text"
|
||||
value={editData.text_ru || ''}
|
||||
onChange={(e) => setEditData({ ...editData, text_ru: e.target.value })}
|
||||
className="w-full border border-gray-300 rounded px-2 py-1 text-sm"
|
||||
/>
|
||||
) : (
|
||||
<span className={trans.text_ru ? 'text-gray-700' : 'text-gray-400 italic'}>
|
||||
{trans.text_ru || 'Not set'}
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="py-3 px-4">
|
||||
{editingId === trans.id ? (
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => handleSave(trans.id)}
|
||||
className="text-green-600 hover:text-green-700"
|
||||
title="Save"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setEditingId(null)}
|
||||
className="text-gray-600 hover:text-gray-700"
|
||||
title="Cancel"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => handleAutoTranslate(trans)}
|
||||
disabled={translatingId === trans.id}
|
||||
className={`text-purple-600 hover:text-purple-700 ${translatingId === trans.id ? 'opacity-50' : ''}`}
|
||||
title="Auto Translate"
|
||||
>
|
||||
{translatingId === trans.id ? (
|
||||
<div className="w-5 h-5 border-2 border-purple-600 border-t-transparent rounded-full animate-spin"></div>
|
||||
) : (
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 5h12M9 3v2m1.048 9.5A18.022 18.022 0 016.412 9m6.088 9h7M11 21l5-10 5 10M12.751 5C11.783 10.77 8.07 15.61 3 18.129" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleEdit(trans)}
|
||||
className="text-blue-600 hover:text-blue-700"
|
||||
title="Edit"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDelete(trans.id)}
|
||||
className="text-red-600 hover:text-red-700"
|
||||
title="Delete"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Pagination */}
|
||||
{totalPages > 1 && (
|
||||
<div className="flex justify-center items-center gap-2 py-4 border-t border-gray-200">
|
||||
<button
|
||||
onClick={() => setPage((p) => Math.max(1, p - 1))}
|
||||
disabled={page === 1}
|
||||
className="px-4 py-2 border border-gray-300 rounded-lg hover:bg-gray-50 disabled:opacity-50"
|
||||
>
|
||||
Prev
|
||||
</button>
|
||||
<span className="px-4 text-gray-600">
|
||||
Page {page} of {totalPages} ({total} total)
|
||||
</span>
|
||||
<button
|
||||
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
|
||||
disabled={page === totalPages}
|
||||
className="px-4 py-2 border border-gray-300 rounded-lg hover:bg-gray-50 disabled:opacity-50"
|
||||
>
|
||||
Next
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Add Modal */}
|
||||
{showAddModal && (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-white rounded-2xl max-w-lg w-full p-6">
|
||||
<h2 className="text-xl font-bold text-gray-800 mb-4">Add Translation</h2>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Korean (Source Text) *
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={newTranslation.source_text}
|
||||
onChange={(e) => setNewTranslation({ ...newTranslation, source_text: e.target.value })}
|
||||
className="w-full border border-gray-300 rounded-lg px-3 py-2"
|
||||
placeholder="Enter Korean text"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Category</label>
|
||||
<select
|
||||
value={newTranslation.category}
|
||||
onChange={(e) => setNewTranslation({ ...newTranslation, category: e.target.value })}
|
||||
className="w-full border border-gray-300 rounded-lg px-3 py-2"
|
||||
>
|
||||
{categories.map((cat) => (
|
||||
<option key={cat} value={cat}>
|
||||
{CATEGORY_LABELS[cat] || cat}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">English</label>
|
||||
<input
|
||||
type="text"
|
||||
value={newTranslation.text_en}
|
||||
onChange={(e) => setNewTranslation({ ...newTranslation, text_en: e.target.value })}
|
||||
className="w-full border border-gray-300 rounded-lg px-3 py-2"
|
||||
placeholder="English translation"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Mongolian</label>
|
||||
<input
|
||||
type="text"
|
||||
value={newTranslation.text_mn}
|
||||
onChange={(e) => setNewTranslation({ ...newTranslation, text_mn: e.target.value })}
|
||||
className="w-full border border-gray-300 rounded-lg px-3 py-2"
|
||||
placeholder="Mongolian translation"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Russian</label>
|
||||
<input
|
||||
type="text"
|
||||
value={newTranslation.text_ru}
|
||||
onChange={(e) => setNewTranslation({ ...newTranslation, text_ru: e.target.value })}
|
||||
className="w-full border border-gray-300 rounded-lg px-3 py-2"
|
||||
placeholder="Russian translation"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-3 mt-6">
|
||||
<button
|
||||
onClick={() => setShowAddModal(false)}
|
||||
className="px-4 py-2 border border-gray-300 rounded-lg hover:bg-gray-50"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={handleAdd}
|
||||
className="px-4 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700"
|
||||
>
|
||||
Add
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Batch Translate Modal */}
|
||||
{showBatchModal && (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-white rounded-2xl max-w-lg w-full p-6">
|
||||
<h2 className="text-xl font-bold text-gray-800 mb-4">Batch Auto-Translate</h2>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Category (Optional)</label>
|
||||
<select
|
||||
value={batchOptions.category}
|
||||
onChange={(e) => setBatchOptions({ ...batchOptions, category: e.target.value })}
|
||||
className="w-full border border-gray-300 rounded-lg px-3 py-2"
|
||||
>
|
||||
<option value="">All Categories</option>
|
||||
{categories.map((cat) => (
|
||||
<option key={cat} value={cat}>
|
||||
{CATEGORY_LABELS[cat] || cat}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<p className="text-xs text-gray-500 mt-1">Leave empty to translate all categories</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">Target Languages</label>
|
||||
<div className="flex gap-4">
|
||||
<label className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={batchOptions.targetLangs.includes('en')}
|
||||
onChange={(e) => {
|
||||
if (e.target.checked) {
|
||||
setBatchOptions({ ...batchOptions, targetLangs: [...batchOptions.targetLangs, 'en'] });
|
||||
} else {
|
||||
setBatchOptions({ ...batchOptions, targetLangs: batchOptions.targetLangs.filter(l => l !== 'en') });
|
||||
}
|
||||
}}
|
||||
className="rounded"
|
||||
/>
|
||||
<span>English</span>
|
||||
</label>
|
||||
<label className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={batchOptions.targetLangs.includes('mn')}
|
||||
onChange={(e) => {
|
||||
if (e.target.checked) {
|
||||
setBatchOptions({ ...batchOptions, targetLangs: [...batchOptions.targetLangs, 'mn'] });
|
||||
} else {
|
||||
setBatchOptions({ ...batchOptions, targetLangs: batchOptions.targetLangs.filter(l => l !== 'mn') });
|
||||
}
|
||||
}}
|
||||
className="rounded"
|
||||
/>
|
||||
<span>Mongolian</span>
|
||||
</label>
|
||||
<label className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={batchOptions.targetLangs.includes('ru')}
|
||||
onChange={(e) => {
|
||||
if (e.target.checked) {
|
||||
setBatchOptions({ ...batchOptions, targetLangs: [...batchOptions.targetLangs, 'ru'] });
|
||||
} else {
|
||||
setBatchOptions({ ...batchOptions, targetLangs: batchOptions.targetLangs.filter(l => l !== 'ru') });
|
||||
}
|
||||
}}
|
||||
className="rounded"
|
||||
/>
|
||||
<span>Russian</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={batchOptions.overwriteExisting}
|
||||
onChange={(e) => setBatchOptions({ ...batchOptions, overwriteExisting: e.target.checked })}
|
||||
className="rounded"
|
||||
/>
|
||||
<span className="text-sm text-gray-700">Overwrite existing translations</span>
|
||||
</label>
|
||||
<p className="text-xs text-gray-500 mt-1">If unchecked, only empty translations will be filled</p>
|
||||
</div>
|
||||
|
||||
{batchResult && (
|
||||
<div className="bg-gray-50 rounded-lg p-4">
|
||||
<h3 className="font-medium text-gray-700 mb-2">Translation Results</h3>
|
||||
<div className="grid grid-cols-3 gap-4 text-center">
|
||||
<div>
|
||||
<div className="text-2xl font-bold text-gray-800">{batchResult.total_processed}</div>
|
||||
<div className="text-xs text-gray-500">Processed</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-2xl font-bold text-green-600">{batchResult.successful}</div>
|
||||
<div className="text-xs text-gray-500">Successful</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-2xl font-bold text-red-600">{batchResult.failed}</div>
|
||||
<div className="text-xs text-gray-500">Failed</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-3 mt-6">
|
||||
<button
|
||||
onClick={() => {
|
||||
setShowBatchModal(false);
|
||||
setBatchResult(null);
|
||||
}}
|
||||
className="px-4 py-2 border border-gray-300 rounded-lg hover:bg-gray-50"
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
<button
|
||||
onClick={handleBatchTranslate}
|
||||
disabled={batchTranslating || batchOptions.targetLangs.length === 0}
|
||||
className="px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 disabled:opacity-50 flex items-center gap-2"
|
||||
>
|
||||
{batchTranslating ? (
|
||||
<>
|
||||
<div className="w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin"></div>
|
||||
Translating...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 5h12M9 3v2m1.048 9.5A18.022 18.022 0 016.412 9m6.088 9h7M11 21l5-10 5 10M12.751 5C11.783 10.77 8.07 15.61 3 18.129" />
|
||||
</svg>
|
||||
Start Batch Translate
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -27,7 +27,7 @@ interface DealerApplication {
|
||||
|
||||
export default function DealerApplyPage() {
|
||||
const { t, language } = useTranslation();
|
||||
const { user, token } = useAuthStore();
|
||||
const { user, token, isLoading } = useAuthStore();
|
||||
const router = useRouter();
|
||||
|
||||
const [loading, setLoading] = useState(false);
|
||||
@@ -35,6 +35,11 @@ export default function DealerApplyPage() {
|
||||
const [existingApplication, setExistingApplication] = useState<DealerApplication | null>(null);
|
||||
const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null);
|
||||
|
||||
const [step, setStep] = useState<'agreement' | 'form'>('agreement');
|
||||
const [privacyAgreed, setPrivacyAgreed] = useState(false);
|
||||
const [obligationAgreed, setObligationAgreed] = useState(false);
|
||||
const [agreeError, setAgreeError] = useState(false);
|
||||
|
||||
const [formData, setFormData] = useState({
|
||||
business_name: '',
|
||||
business_number: '',
|
||||
@@ -48,6 +53,8 @@ export default function DealerApplyPage() {
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (isLoading) return;
|
||||
|
||||
if (!user) {
|
||||
router.push('/login');
|
||||
return;
|
||||
@@ -60,7 +67,7 @@ export default function DealerApplyPage() {
|
||||
|
||||
// Check for existing application
|
||||
checkExistingApplication();
|
||||
}, [user, router]);
|
||||
}, [user, router, isLoading]);
|
||||
|
||||
const checkExistingApplication = async () => {
|
||||
if (!token) return;
|
||||
@@ -116,7 +123,7 @@ export default function DealerApplyPage() {
|
||||
}
|
||||
};
|
||||
|
||||
if (!user || checkingApplication) {
|
||||
if (isLoading || !user || checkingApplication) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 flex items-center justify-center">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-primary-600"></div>
|
||||
@@ -182,6 +189,138 @@ export default function DealerApplyPage() {
|
||||
);
|
||||
}
|
||||
|
||||
const handleProceedToForm = () => {
|
||||
if (!privacyAgreed || !obligationAgreed) {
|
||||
setAgreeError(true);
|
||||
return;
|
||||
}
|
||||
setAgreeError(false);
|
||||
setStep('form');
|
||||
};
|
||||
|
||||
const handleAgreeAll = (checked: boolean) => {
|
||||
setPrivacyAgreed(checked);
|
||||
setObligationAgreed(checked);
|
||||
if (checked) setAgreeError(false);
|
||||
};
|
||||
|
||||
// Step 1: Agreement
|
||||
if (step === 'agreement') {
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 py-12">
|
||||
<div className="container mx-auto px-4">
|
||||
<div className="max-w-2xl mx-auto">
|
||||
{/* Header */}
|
||||
<div className="text-center mb-8">
|
||||
<h1 className="text-3xl font-bold text-gray-800 mb-2">{t.dealerAgreementTitle}</h1>
|
||||
<p className="text-gray-600">{t.dealerApplicationSubtitle}</p>
|
||||
</div>
|
||||
|
||||
{/* Benefits Card */}
|
||||
<div className="bg-gradient-to-r from-primary-600 to-primary-700 text-white rounded-xl p-6 mb-8">
|
||||
<h2 className="text-xl font-semibold mb-4">
|
||||
{language === 'ko' ? '딜러 혜택' : 'Dealer Benefits'}
|
||||
</h2>
|
||||
<ul className="space-y-2">
|
||||
<li className="flex items-center gap-2">
|
||||
<span>💰</span>
|
||||
<span>{language === 'ko' ? '차량 판매 시 수수료 50% 수익' : '50% commission on vehicle sales'}</span>
|
||||
</li>
|
||||
<li className="flex items-center gap-2">
|
||||
<span>🎫</span>
|
||||
<span>{language === 'ko' ? '공식 딜러증 발급' : 'Official dealer card issued'}</span>
|
||||
</li>
|
||||
<li className="flex items-center gap-2">
|
||||
<span>📈</span>
|
||||
<span>{language === 'ko' ? '수익 관리 대시보드' : 'Earnings management dashboard'}</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{/* Privacy Agreement */}
|
||||
<div className="bg-white rounded-xl shadow-lg p-6 mb-4">
|
||||
<div className="flex items-start gap-3 mb-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="privacy-agree"
|
||||
checked={privacyAgreed}
|
||||
onChange={(e) => {
|
||||
setPrivacyAgreed(e.target.checked);
|
||||
if (e.target.checked) setAgreeError(false);
|
||||
}}
|
||||
className="mt-1 w-5 h-5 text-primary-600 rounded border-gray-300 focus:ring-primary-500 cursor-pointer"
|
||||
/>
|
||||
<label htmlFor="privacy-agree" className="font-semibold text-gray-800 cursor-pointer">
|
||||
{t.dealerPrivacyAgreement}
|
||||
</label>
|
||||
</div>
|
||||
<div className="bg-gray-50 border border-gray-200 rounded-lg p-4 max-h-48 overflow-y-auto">
|
||||
<p className="text-sm text-gray-700 whitespace-pre-line leading-relaxed">
|
||||
{t.dealerPrivacyContent}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Obligation Agreement */}
|
||||
<div className="bg-white rounded-xl shadow-lg p-6 mb-4">
|
||||
<div className="flex items-start gap-3 mb-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="obligation-agree"
|
||||
checked={obligationAgreed}
|
||||
onChange={(e) => {
|
||||
setObligationAgreed(e.target.checked);
|
||||
if (e.target.checked) setAgreeError(false);
|
||||
}}
|
||||
className="mt-1 w-5 h-5 text-primary-600 rounded border-gray-300 focus:ring-primary-500 cursor-pointer"
|
||||
/>
|
||||
<label htmlFor="obligation-agree" className="font-semibold text-gray-800 cursor-pointer">
|
||||
{t.dealerObligationAgreement}
|
||||
</label>
|
||||
</div>
|
||||
<div className="bg-gray-50 border border-gray-200 rounded-lg p-4 max-h-48 overflow-y-auto">
|
||||
<p className="text-sm text-gray-700 whitespace-pre-line leading-relaxed">
|
||||
{t.dealerObligationContent}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Agree All + Proceed */}
|
||||
<div className="bg-white rounded-xl shadow-lg p-6">
|
||||
<label className="flex items-center gap-3 cursor-pointer mb-4">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={privacyAgreed && obligationAgreed}
|
||||
onChange={(e) => handleAgreeAll(e.target.checked)}
|
||||
className="w-5 h-5 text-primary-600 rounded border-gray-300 focus:ring-primary-500"
|
||||
/>
|
||||
<span className="font-semibold text-gray-800">{t.dealerAgreeAll}</span>
|
||||
</label>
|
||||
|
||||
{agreeError && (
|
||||
<div className="mb-4 p-3 bg-red-50 border border-red-200 rounded-lg">
|
||||
<p className="text-sm text-red-600">{t.dealerMustAgree}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={handleProceedToForm}
|
||||
className={`w-full px-4 py-4 rounded-lg font-semibold transition ${
|
||||
privacyAgreed && obligationAgreed
|
||||
? 'bg-primary-600 text-white hover:bg-primary-700'
|
||||
: 'bg-gray-300 text-gray-500 cursor-not-allowed'
|
||||
}`}
|
||||
>
|
||||
{t.dealerProceedToApply}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Step 2: Application Form
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 py-12">
|
||||
<div className="container mx-auto px-4">
|
||||
@@ -192,25 +331,17 @@ export default function DealerApplyPage() {
|
||||
<p className="text-gray-600">{t.dealerApplicationSubtitle}</p>
|
||||
</div>
|
||||
|
||||
{/* Benefits Card */}
|
||||
<div className="bg-gradient-to-r from-primary-600 to-primary-700 text-white rounded-xl p-6 mb-8">
|
||||
<h2 className="text-xl font-semibold mb-4">
|
||||
{language === 'ko' ? '딜러 혜택' : 'Dealer Benefits'}
|
||||
</h2>
|
||||
<ul className="space-y-2">
|
||||
<li className="flex items-center gap-2">
|
||||
<span>💰</span>
|
||||
<span>{language === 'ko' ? '차량 판매 시 수수료 50% 수익' : '50% commission on vehicle sales'}</span>
|
||||
</li>
|
||||
<li className="flex items-center gap-2">
|
||||
<span>🎫</span>
|
||||
<span>{language === 'ko' ? '공식 딜러증 발급' : 'Official dealer card issued'}</span>
|
||||
</li>
|
||||
<li className="flex items-center gap-2">
|
||||
<span>📈</span>
|
||||
<span>{language === 'ko' ? '수익 관리 대시보드' : 'Earnings management dashboard'}</span>
|
||||
</li>
|
||||
</ul>
|
||||
{/* Step indicator */}
|
||||
<div className="flex items-center justify-center gap-3 mb-8">
|
||||
<div className="flex items-center gap-2 text-green-600">
|
||||
<div className="w-8 h-8 rounded-full bg-green-600 text-white flex items-center justify-center text-sm font-bold">✓</div>
|
||||
<span className="text-sm font-medium">{t.dealerAgreementTitle}</span>
|
||||
</div>
|
||||
<div className="w-8 h-px bg-gray-300"></div>
|
||||
<div className="flex items-center gap-2 text-primary-600">
|
||||
<div className="w-8 h-8 rounded-full bg-primary-600 text-white flex items-center justify-center text-sm font-bold">2</div>
|
||||
<span className="text-sm font-medium">{t.dealerApplication}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Message */}
|
||||
|
||||
@@ -249,6 +249,30 @@ export default function ProfilePage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Dealer Program Section */}
|
||||
<div className="bg-white rounded-xl shadow-lg p-6 mb-6">
|
||||
<h2 className="text-xl font-semibold text-gray-800 mb-4">{t.dealerSectionTitle}</h2>
|
||||
<p className="text-gray-600 text-sm mb-4">{t.dealerSectionDesc}</p>
|
||||
|
||||
{user.is_dealer ? (
|
||||
<a
|
||||
href="/dealer/my-card"
|
||||
className="w-full flex items-center justify-center gap-2 px-4 py-3 bg-gradient-to-r from-amber-500 to-amber-600 text-white rounded-lg hover:from-amber-600 hover:to-amber-700 transition font-semibold"
|
||||
>
|
||||
<span>🎫</span>
|
||||
<span>{t.dealerViewCard}</span>
|
||||
</a>
|
||||
) : (
|
||||
<a
|
||||
href="/dealer/apply"
|
||||
className="w-full flex items-center justify-center gap-2 px-4 py-3 bg-primary-600 text-white rounded-lg hover:bg-primary-700 transition font-semibold"
|
||||
>
|
||||
<span>🤝</span>
|
||||
<span>{t.dealerApplyButton}</span>
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Referral Code Card */}
|
||||
<div className="bg-white rounded-xl shadow-lg p-6">
|
||||
<h2 className="text-xl font-semibold text-gray-800 mb-4">{t.myReferralCode}</h2>
|
||||
|
||||
@@ -235,146 +235,6 @@ export const heroBannersApi = {
|
||||
},
|
||||
};
|
||||
|
||||
// Translations API
|
||||
export interface Translation {
|
||||
id: number;
|
||||
source_text: string;
|
||||
category: string;
|
||||
text_en?: string;
|
||||
text_mn?: string;
|
||||
text_ru?: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface TranslationListResponse {
|
||||
total: number;
|
||||
page: number;
|
||||
page_size: number;
|
||||
translations: Translation[];
|
||||
}
|
||||
|
||||
export const translationsApi = {
|
||||
getCategories: async (): Promise<string[]> => {
|
||||
const { data } = await api.get('/translations/categories');
|
||||
return data;
|
||||
},
|
||||
|
||||
getList: async (params: {
|
||||
page?: number;
|
||||
page_size?: number;
|
||||
category?: string;
|
||||
search?: string;
|
||||
}): Promise<TranslationListResponse> => {
|
||||
const { data } = await api.get('/translations', { params });
|
||||
return data;
|
||||
},
|
||||
|
||||
getById: async (id: number): Promise<Translation> => {
|
||||
const { data } = await api.get(`/translations/${id}`);
|
||||
return data;
|
||||
},
|
||||
|
||||
create: async (translationData: {
|
||||
source_text: string;
|
||||
category: string;
|
||||
text_en?: string;
|
||||
text_mn?: string;
|
||||
text_ru?: string;
|
||||
}): Promise<Translation> => {
|
||||
const { data } = await api.post('/translations', translationData);
|
||||
return data;
|
||||
},
|
||||
|
||||
update: async (id: number, translationData: {
|
||||
source_text?: string;
|
||||
category?: string;
|
||||
text_en?: string;
|
||||
text_mn?: string;
|
||||
text_ru?: string;
|
||||
}): Promise<Translation> => {
|
||||
const { data } = await api.put(`/translations/${id}`, translationData);
|
||||
return data;
|
||||
},
|
||||
|
||||
delete: async (id: number): Promise<void> => {
|
||||
await api.delete(`/translations/${id}`);
|
||||
},
|
||||
|
||||
autoExtract: async (): Promise<{ message: string }> => {
|
||||
const { data } = await api.post('/translations/auto-extract');
|
||||
return data;
|
||||
},
|
||||
|
||||
seedAllDefaults: async (): Promise<{ message: string; added: number; skipped: number; categories: string[] }> => {
|
||||
const { data } = await api.post('/translations/seed-all-defaults');
|
||||
return data;
|
||||
},
|
||||
|
||||
bulkLookup: async (texts: string[], lang: string, category?: string): Promise<{ translations: Record<string, string> }> => {
|
||||
const { data } = await api.post('/translations/bulk-lookup', {
|
||||
texts,
|
||||
lang,
|
||||
category,
|
||||
});
|
||||
return data;
|
||||
},
|
||||
|
||||
// Auto-translation endpoints
|
||||
autoTranslate: async (translationId: number, targetLangs?: string[]): Promise<{
|
||||
id: number;
|
||||
source_text: string;
|
||||
translations: Record<string, string>;
|
||||
message: string;
|
||||
}> => {
|
||||
const { data } = await api.post(`/translations/auto-translate/${translationId}`, {
|
||||
target_langs: targetLangs || ['en', 'mn', 'ru']
|
||||
});
|
||||
return data;
|
||||
},
|
||||
|
||||
autoTranslateBatch: async (targetLangs?: string[], category?: string, overwriteExisting?: boolean): Promise<{
|
||||
total_processed: number;
|
||||
successful: number;
|
||||
failed: number;
|
||||
results: Array<{ id: number; source_text: string; success: boolean; error?: string }>;
|
||||
}> => {
|
||||
const { data } = await api.post('/translations/auto-translate-batch', {
|
||||
target_langs: targetLangs || ['en', 'mn', 'ru'],
|
||||
category,
|
||||
overwrite_existing: overwriteExisting || false
|
||||
});
|
||||
return data;
|
||||
},
|
||||
|
||||
translateOnDemand: async (text: string, sourceLang: string, targetLang: string): Promise<{
|
||||
source_text: string;
|
||||
translated_text: string;
|
||||
source_lang: string;
|
||||
target_lang: string;
|
||||
}> => {
|
||||
const { data } = await api.post('/translations/translate-on-demand', {
|
||||
text,
|
||||
source_lang: sourceLang,
|
||||
target_lang: targetLang
|
||||
});
|
||||
return data;
|
||||
},
|
||||
|
||||
getStats: async (): Promise<{
|
||||
total_entries: number;
|
||||
by_category: Record<string, number>;
|
||||
translation_coverage: {
|
||||
english: { translated: number; total: number; percentage: number };
|
||||
mongolian: { translated: number; total: number; percentage: number };
|
||||
russian: { translated: number; total: number; percentage: number };
|
||||
};
|
||||
}> => {
|
||||
const { data } = await api.get('/translations/stats');
|
||||
return data;
|
||||
},
|
||||
};
|
||||
|
||||
// Carmodoo API
|
||||
export interface CarmodooMaker {
|
||||
code: string;
|
||||
|
||||
@@ -381,6 +381,18 @@ export interface Translations {
|
||||
pendingApplicationMessage: string;
|
||||
rejectedApplicationMessage: string;
|
||||
rejectReason: string;
|
||||
dealerSectionTitle: string;
|
||||
dealerSectionDesc: string;
|
||||
dealerApplyButton: string;
|
||||
dealerViewCard: string;
|
||||
dealerAgreementTitle: string;
|
||||
dealerPrivacyAgreement: string;
|
||||
dealerPrivacyContent: string;
|
||||
dealerObligationAgreement: string;
|
||||
dealerObligationContent: string;
|
||||
dealerAgreeAll: string;
|
||||
dealerMustAgree: string;
|
||||
dealerProceedToApply: string;
|
||||
|
||||
// Vehicle Sharing
|
||||
shareVehicle: string;
|
||||
@@ -860,6 +872,18 @@ const translations: Record<Language, Translations> = {
|
||||
pendingApplicationMessage: 'Your application is being reviewed. We will notify you once approved.',
|
||||
rejectedApplicationMessage: 'Your application was rejected.',
|
||||
rejectReason: 'Rejection Reason',
|
||||
dealerSectionTitle: 'Dealer Program',
|
||||
dealerSectionDesc: 'Become a dealer and earn commissions by recommending vehicles to friends.',
|
||||
dealerApplyButton: 'Apply to Become a Dealer',
|
||||
dealerViewCard: 'View My Dealer Card',
|
||||
dealerAgreementTitle: 'Dealer Agreement',
|
||||
dealerPrivacyAgreement: 'Consent to Personal Information Collection and Use',
|
||||
dealerPrivacyContent: `By applying for the Dealer Program, you agree to the collection and use of the following personal information:\n\n1. Information Collected: Full name, phone number, ID/passport number, bank account details, business registration number\n2. Purpose of Collection: Dealer identity verification, commission payment processing, tax reporting\n3. Retention Period: Retained for the duration of the dealer agreement and 5 years after termination (as required by law)\n4. Right to Refuse: You may refuse to provide personal information; however, this will prevent dealer registration.\n\nYour personal information will be handled in accordance with applicable privacy laws and will not be shared with third parties without your consent, except as required by law.`,
|
||||
dealerObligationAgreement: 'Dealer Obligations Agreement',
|
||||
dealerObligationContent: `By becoming a dealer, you agree to the following obligations:\n\n1. Accurate Information: You must provide truthful and accurate vehicle information to customers.\n2. Fair Pricing: You must not add excessive markups or engage in deceptive pricing practices.\n3. Customer Service: You are responsible for responding to customer inquiries promptly and professionally.\n4. Compliance: You must comply with all applicable local laws and regulations regarding vehicle sales.\n5. Platform Rules: You must follow AutonetSellCar platform policies, including commission structures and dispute resolution procedures.\n6. Prohibited Activities: Misrepresentation of vehicle condition, unauthorized use of platform branding, and soliciting customers outside the platform are strictly prohibited.\n7. Termination: Violation of these obligations may result in immediate termination of your dealer status without prior notice.\n\nAutonetSellCar reserves the right to modify these obligations with reasonable notice.`,
|
||||
dealerAgreeAll: 'I agree to all terms above',
|
||||
dealerMustAgree: 'You must agree to all terms before proceeding.',
|
||||
dealerProceedToApply: 'Proceed to Application',
|
||||
|
||||
// Vehicle Sharing
|
||||
shareVehicle: 'Share Vehicle',
|
||||
@@ -1337,6 +1361,18 @@ const translations: Record<Language, Translations> = {
|
||||
pendingApplicationMessage: 'Таны хүсэлт хянагдаж байна. Зөвшөөрөгдсөний дараа мэдэгдэх болно.',
|
||||
rejectedApplicationMessage: 'Таны хүсэлт татгалзагдлаа.',
|
||||
rejectReason: 'Татгалзсан шалтгаан',
|
||||
dealerSectionTitle: 'Дилерийн хөтөлбөр',
|
||||
dealerSectionDesc: 'Дилер болж, найзууддаа машин санал болгож комисс аваарай.',
|
||||
dealerApplyButton: 'Дилер болох хүсэлт гаргах',
|
||||
dealerViewCard: 'Миний дилерийн карт харах',
|
||||
dealerAgreementTitle: 'Дилерийн гэрээ',
|
||||
dealerPrivacyAgreement: 'Хувийн мэдээлэл цуглуулах, ашиглахыг зөвшөөрөх',
|
||||
dealerPrivacyContent: `Дилерийн хөтөлбөрт бүртгүүлснээр та дараах хувийн мэдээлэл цуглуулах, ашиглахыг зөвшөөрч байна:\n\n1. Цуглуулах мэдээлэл: Бүтэн нэр, утасны дугаар, иргэний үнэмлэх/паспортын дугаар, банкны дансны мэдээлэл, бизнесийн бүртгэлийн дугаар\n2. Цуглуулах зорилго: Дилерийн таньж мэдэх, комисс төлбөр хийх, татварын тайлан\n3. Хадгалах хугацаа: Дилерийн гэрээний хугацаанд болон дуусгавар болсноос хойш 5 жил (хуулийн шаардлагаар)\n4. Татгалзах эрх: Та хувийн мэдээлэл өгөхөөс татгалзаж болно, гэхдээ энэ нь дилерийн бүртгэлийг боломжгүй болгоно.`,
|
||||
dealerObligationAgreement: 'Дилерийн үүргийн гэрээ',
|
||||
dealerObligationContent: `Дилер болсноор та дараах үүргүүдийг хүлээн зөвшөөрч байна:\n\n1. Үнэн зөв мэдээлэл: Та худалдан авагчдад машины талаар үнэн зөв мэдээлэл өгөх ёстой.\n2. Шударга үнэ: Хэт өндөр нэмэлт үнэ тогтоох, хууран мэхлэх үнийн бодлого явуулахыг хориглоно.\n3. Хэрэглэгчийн үйлчилгээ: Та хэрэглэгчийн асуултад шуурхай, мэргэжлийн түвшинд хариулах үүрэгтэй.\n4. Хууль дүрэм: Машин худалдаатай холбоотой бүх холбогдох хууль тогтоомжийг дагаж мөрдөх ёстой.\n5. Платформын дүрэм: AutonetSellCar платформын бодлогыг дагах ёстой.\n6. Хориотой үйлдэл: Машины байдлыг буруу мэдээлэх, платформын бренд зөвшөөрөлгүй ашиглах зэрэг нь хатуу хориотой.\n7. Цуцлах: Эдгээр үүргийг зөрчсөн тохиолдолд дилерийн статусыг нэн даруй цуцалж болно.`,
|
||||
dealerAgreeAll: 'Дээрх бүх нөхцлийг зөвшөөрч байна',
|
||||
dealerMustAgree: 'Үргэлжлүүлэхийн өмнө бүх нөхцлийг зөвшөөрөх шаардлагатай.',
|
||||
dealerProceedToApply: 'Хүсэлт гаргах руу үргэлжлүүлэх',
|
||||
|
||||
// Vehicle Sharing
|
||||
shareVehicle: 'Машин хуваалцах',
|
||||
@@ -1814,6 +1850,18 @@ const translations: Record<Language, Translations> = {
|
||||
pendingApplicationMessage: 'Ваша заявка рассматривается. Мы уведомим вас после одобрения.',
|
||||
rejectedApplicationMessage: 'Ваша заявка была отклонена.',
|
||||
rejectReason: 'Причина отклонения',
|
||||
dealerSectionTitle: 'Дилерская программа',
|
||||
dealerSectionDesc: 'Станьте дилером и зарабатывайте комиссионные, рекомендуя автомобили друзьям.',
|
||||
dealerApplyButton: 'Подать заявку на дилера',
|
||||
dealerViewCard: 'Просмотр карты дилера',
|
||||
dealerAgreementTitle: 'Соглашение дилера',
|
||||
dealerPrivacyAgreement: 'Согласие на сбор и использование персональных данных',
|
||||
dealerPrivacyContent: `Подавая заявку на участие в дилерской программе, вы соглашаетесь на сбор и использование следующих персональных данных:\n\n1. Собираемая информация: ФИО, номер телефона, номер паспорта/удостоверения, банковские реквизиты, регистрационный номер бизнеса\n2. Цель сбора: Верификация дилера, обработка комиссионных выплат, налоговая отчётность\n3. Срок хранения: На протяжении действия дилерского соглашения и 5 лет после его прекращения (по требованию закона)\n4. Право на отказ: Вы можете отказаться от предоставления персональных данных, однако это сделает регистрацию дилера невозможной.`,
|
||||
dealerObligationAgreement: 'Соглашение об обязанностях дилера',
|
||||
dealerObligationContent: `Становясь дилером, вы соглашаетесь с следующими обязанностями:\n\n1. Достоверная информация: Вы обязаны предоставлять клиентам правдивую и точную информацию об автомобилях.\n2. Справедливое ценообразование: Запрещается устанавливать чрезмерные наценки или вводить в заблуждение относительно цен.\n3. Обслуживание клиентов: Вы обязаны оперативно и профессионально отвечать на запросы клиентов.\n4. Соблюдение законов: Вы обязаны соблюдать все применимые законы и правила, касающиеся продажи автомобилей.\n5. Правила платформы: Вы обязаны следовать политике платформы AutonetSellCar.\n6. Запрещённые действия: Искажение состояния автомобиля, несанкционированное использование бренда платформы строго запрещены.\n7. Прекращение: Нарушение данных обязанностей может привести к немедленному прекращению статуса дилера.`,
|
||||
dealerAgreeAll: 'Я согласен со всеми вышеуказанными условиями',
|
||||
dealerMustAgree: 'Для продолжения необходимо согласиться со всеми условиями.',
|
||||
dealerProceedToApply: 'Перейти к заявке',
|
||||
|
||||
// Vehicle Sharing
|
||||
shareVehicle: 'Поделиться авто',
|
||||
@@ -2291,6 +2339,18 @@ const translations: Record<Language, Translations> = {
|
||||
pendingApplicationMessage: '신청서가 검토 중입니다. 승인되면 알려드리겠습니다.',
|
||||
rejectedApplicationMessage: '신청이 거부되었습니다.',
|
||||
rejectReason: '거부 사유',
|
||||
dealerSectionTitle: '딜러 프로그램',
|
||||
dealerSectionDesc: '딜러가 되어 친구에게 차량을 추천하고 수수료를 받으세요.',
|
||||
dealerApplyButton: '딜러 신청하기',
|
||||
dealerViewCard: '내 딜러증 보기',
|
||||
dealerAgreementTitle: '딜러 약관 동의',
|
||||
dealerPrivacyAgreement: '개인정보 수집 및 이용 동의',
|
||||
dealerPrivacyContent: `딜러 프로그램 신청 시 아래의 개인정보 수집 및 이용에 동의하게 됩니다.\n\n1. 수집 항목: 성명, 전화번호, 주민등록번호(또는 여권번호), 은행 계좌 정보, 사업자등록번호\n2. 수집 목적: 딜러 본인 확인, 수수료 지급 처리, 세금 신고\n3. 보유 기간: 딜러 계약 기간 및 종료 후 5년간 보유 (관계 법령에 따름)\n4. 거부 권리: 개인정보 제공을 거부할 수 있으나, 이 경우 딜러 등록이 불가합니다.\n\n귀하의 개인정보는 관련 법률에 따라 처리되며, 법령에 의한 경우를 제외하고 본인의 동의 없이 제3자에게 제공되지 않습니다.`,
|
||||
dealerObligationAgreement: '딜러 의무 수행 동의',
|
||||
dealerObligationContent: `딜러가 됨으로써 아래의 의무에 동의하게 됩니다.\n\n1. 정확한 정보 제공: 고객에게 차량에 대한 정확하고 진실된 정보를 제공해야 합니다.\n2. 공정한 가격: 과도한 추가 금액을 부과하거나 기만적인 가격 정책을 사용할 수 없습니다.\n3. 고객 응대: 고객 문의에 신속하고 전문적으로 응답할 의무가 있습니다.\n4. 법규 준수: 차량 판매와 관련된 모든 관련 법률 및 규정을 준수해야 합니다.\n5. 플랫폼 규칙: AutonetSellCar 플랫폼 정책(수수료 구조, 분쟁 해결 절차 등)을 따라야 합니다.\n6. 금지 행위: 차량 상태 허위 표시, 플랫폼 브랜드 무단 사용, 플랫폼 외부에서의 고객 유인 행위는 엄격히 금지됩니다.\n7. 자격 박탈: 위 의무를 위반할 경우 사전 통보 없이 딜러 자격이 즉시 박탈될 수 있습니다.\n\nAutonetSellCar는 합리적인 사전 고지 후 본 의무 사항을 변경할 권리를 보유합니다.`,
|
||||
dealerAgreeAll: '위의 모든 약관에 동의합니다',
|
||||
dealerMustAgree: '계속하려면 모든 약관에 동의해야 합니다.',
|
||||
dealerProceedToApply: '신청서 작성으로 이동',
|
||||
|
||||
// Vehicle Sharing
|
||||
shareVehicle: '차량 공유',
|
||||
|
||||
@@ -1,74 +1,16 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { translationsApi } from './api';
|
||||
import { useCallback } from 'react';
|
||||
import { useLanguageStore, translateCarName, Language } from './i18n';
|
||||
|
||||
// Cache for translations to avoid repeated API calls
|
||||
const translationCache: Record<string, Record<string, string>> = {};
|
||||
|
||||
export function useTranslate() {
|
||||
const { language } = useLanguageStore();
|
||||
const [translations, setTranslations] = useState<Record<string, string>>({});
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
// Get cache key for current language
|
||||
const cacheKey = `trans_${language}`;
|
||||
|
||||
// Load translations from cache on mount
|
||||
useEffect(() => {
|
||||
if (translationCache[cacheKey]) {
|
||||
setTranslations(translationCache[cacheKey]);
|
||||
}
|
||||
}, [cacheKey]);
|
||||
|
||||
// Translate a single text
|
||||
// Translate a single text using static dictionary
|
||||
const translate = useCallback((text: string | undefined | null): string => {
|
||||
if (!text) return '';
|
||||
if (language === 'ko') return text; // Korean is source, no translation needed
|
||||
|
||||
// Try static translations FIRST (for fuel, transmission, car names, etc.)
|
||||
const staticTranslation = translateCarName(text, language as Language);
|
||||
if (staticTranslation !== text) {
|
||||
return staticTranslation;
|
||||
}
|
||||
|
||||
// Then check API cache for other translations
|
||||
const cached = translationCache[cacheKey]?.[text];
|
||||
if (cached) return cached;
|
||||
|
||||
return text; // Fallback to original if no translation found
|
||||
}, [language, cacheKey]);
|
||||
|
||||
// Bulk load translations for multiple texts
|
||||
const loadTranslations = useCallback(async (texts: string[], category?: string) => {
|
||||
if (language === 'ko') return; // No need to translate Korean
|
||||
|
||||
// Filter out already cached texts
|
||||
const uncachedTexts = texts.filter(
|
||||
t => t && !translationCache[cacheKey]?.[t]
|
||||
);
|
||||
|
||||
if (uncachedTexts.length === 0) return;
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
// Map language code to API expected format
|
||||
const langCode = language === 'mn' ? 'mn' : language === 'ru' ? 'ru' : 'en';
|
||||
|
||||
const result = await translationsApi.bulkLookup(uncachedTexts, langCode, category);
|
||||
|
||||
// Update cache
|
||||
if (!translationCache[cacheKey]) {
|
||||
translationCache[cacheKey] = {};
|
||||
}
|
||||
|
||||
Object.assign(translationCache[cacheKey], result.translations);
|
||||
setTranslations({ ...translationCache[cacheKey] });
|
||||
} catch (err) {
|
||||
console.error('Failed to load translations:', err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [language, cacheKey]);
|
||||
return translateCarName(text, language as Language);
|
||||
}, [language]);
|
||||
|
||||
// Translate car object fields
|
||||
const translateCar = useCallback((car: {
|
||||
@@ -89,8 +31,8 @@ export function useTranslate() {
|
||||
};
|
||||
}, [translate]);
|
||||
|
||||
// Preload translations for a list of cars
|
||||
const preloadCarTranslations = useCallback(async (cars: Array<{
|
||||
// Kept for API compatibility - static translations are synchronous, so this is a no-op
|
||||
const preloadCarTranslations = useCallback(async (_cars: Array<{
|
||||
car_name?: string;
|
||||
fuel?: string;
|
||||
transmission?: string;
|
||||
@@ -98,37 +40,13 @@ export function useTranslate() {
|
||||
maker?: { name: string };
|
||||
model?: { name: string };
|
||||
}>) => {
|
||||
const textsToTranslate: string[] = [];
|
||||
|
||||
cars.forEach(car => {
|
||||
if (car.car_name) textsToTranslate.push(car.car_name);
|
||||
if (car.fuel) textsToTranslate.push(car.fuel);
|
||||
if (car.transmission) textsToTranslate.push(car.transmission);
|
||||
if (car.color) textsToTranslate.push(car.color);
|
||||
if (car.maker?.name) textsToTranslate.push(car.maker.name);
|
||||
if (car.model?.name) textsToTranslate.push(car.model.name);
|
||||
});
|
||||
|
||||
// Remove duplicates
|
||||
const uniqueTexts = Array.from(new Set(textsToTranslate));
|
||||
|
||||
if (uniqueTexts.length > 0) {
|
||||
await loadTranslations(uniqueTexts);
|
||||
}
|
||||
}, [loadTranslations]);
|
||||
// No-op: static dictionary translations are synchronous
|
||||
}, []);
|
||||
|
||||
return {
|
||||
translate,
|
||||
translateCar,
|
||||
loadTranslations,
|
||||
preloadCarTranslations,
|
||||
loading,
|
||||
loading: false,
|
||||
};
|
||||
}
|
||||
|
||||
// Clear translation cache (useful when translations are updated)
|
||||
export function clearTranslationCache() {
|
||||
Object.keys(translationCache).forEach(key => {
|
||||
delete translationCache[key];
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user