Compare commits

...

10 Commits

Author SHA1 Message Date
AutonetSellCar Deploy
72eb8144e0 feat: Quick import on Carmodoo search result click
Click a car in Carmodoo search results to instantly import it and
open the detail modal with images. If already imported, opens the
existing car's detail modal instead.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 15:17:45 +09:00
AutonetSellCar Deploy
b17840ef75 feat: Add search/filter bar to admin cars page
- Backend: Add search (car name/plate number), color, year filters to GET /api/cars
- Frontend: Add filter bar with car name/plate, color, year range inputs
- Clear button resets all filters

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 14:54:01 +09:00
AutonetSellCar Deploy
234f91a14a fix: Parse actual image URLs from search HTML for thumbnails
Extract main_image from HTML img tags instead of constructing from
car_no pattern. Handles timestamp-based filenames (e.g., 1767925381_0.jpg)
that differ from the default cmcar_0.jpg pattern.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 12:53:37 +09:00
AutonetSellCar Deploy
f01abfa15e feat: Add image download fallback via AutoDB AJAX API
When __carPhoto URL pattern returns 404 (e.g., timestamp-based filenames
like 1767161441_0.jpg instead of cmcar_0.jpg), fall back to fetching
actual image URLs from /common/ajax/AutoDB.html?mode=view&key={car_key}
XML response (strPhotos field).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 12:48:13 +09:00
AutonetSellCar Deploy
958ea252bb feat: Improve PDF downloads in admin car detail modal
- Rename image PDF to {carName}_{carNumber}.pdf
- Add performance check PDF download button (blue, next to image PDF)
- Performance check PDF named {carName}_{carNumber}_성능점검표.pdf

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 18:00:06 +09:00
AutonetSellCar Deploy
a8aced66a8 feat: Add PDF download button for car images in admin car detail modal
- Download all car images as a single PDF (A4 landscape, one image per page)
- Button shows image count and displays in modal header for easy access
- Uses jsPDF library for client-side PDF generation

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 17:41:00 +09:00
AutonetSellCar Deploy
b5d4b8b5bd fix: Reorder admin sidebar menu - move Dealers under Users
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 17:16:19 +09:00
AutonetSellCar Deploy
a5a87e78e8 fix: Prevent redirect to login on dealer apply page during auth loading
Wait for isLoading to complete before checking user state to avoid
premature redirect when navigating from profile page.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 16:44:08 +09:00
AutonetSellCar Deploy
e274bc763d feat: Add dealer apply link to profile and agreement step to dealer application
- Add dealer program section to profile page with apply/view card button
- Add 2-step dealer application: privacy consent + obligations agreement before form
- Add all translations (en/mn/ru/ko) for new dealer agreement UI

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 16:24:35 +09:00
AutonetSellCar Deploy
3f27297c4a refactor: Remove unused DB translation system
Static dictionary (i18n.ts CAR_TRANSLATIONS) already covers all terms.
DB translations table had only 179 entries used as fallback and was
never actually reached. Simplifies useTranslate hook to static-only.
DB table preserved for safety.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-18 23:24:38 +09:00
18 changed files with 904 additions and 2181 deletions

View File

@@ -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,

View File

@@ -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

View File

@@ -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")

View File

@@ -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",

View File

@@ -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),
)

View File

@@ -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",

View File

@@ -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}

View File

@@ -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",

View File

@@ -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",

View File

@@ -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 */}

View File

@@ -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: '⭐' },

View File

@@ -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>
);
}

View File

@@ -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 */}

View File

@@ -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>

View File

@@ -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;

View File

@@ -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: '차량 공유',

View File

@@ -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];
});
}