fix: banner translations and deployment improvements

- Add translateCarName import from i18n.ts for proper multilingual support
- Change default API language from 'ko' to 'en' for hero banners
- Add checkbox column for Local Cars banner registration
- Update Dockerfile with Playwright dependencies
- Add PostgreSQL migration script
- Add banner translation fix script

🤖 Generated with [Claude Code](https://claude.ai/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
AutonetSellCar Deploy
2025-12-31 10:41:42 +09:00
parent 898ab3a0eb
commit e661d91c72
10 changed files with 1145 additions and 490 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -2,16 +2,37 @@ FROM python:3.11-slim
WORKDIR /app
# Install system dependencies
# Install system dependencies for PostgreSQL and Playwright
RUN apt-get update && apt-get install -y \
gcc \
libpq-dev \
# Playwright dependencies
libnss3 \
libnspr4 \
libatk1.0-0 \
libatk-bridge2.0-0 \
libcups2 \
libdrm2 \
libdbus-1-3 \
libxkbcommon0 \
libatspi2.0-0 \
libxcomposite1 \
libxdamage1 \
libxfixes3 \
libxrandr2 \
libgbm1 \
libasound2 \
libpango-1.0-0 \
libcairo2 \
&& rm -rf /var/lib/apt/lists/*
# Install Python dependencies
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
# Install Playwright browsers (Chromium only for smaller image)
RUN playwright install chromium
# Copy application code
COPY . .

View File

@@ -40,7 +40,7 @@ def get_localized_field(obj, field: str, lang: str) -> Optional[str]:
@router.get("/", response_model=List[HeroBannerLocalizedResponse])
def get_hero_banners(
lang: str = Query("ko", regex="^(ko|en|mn|ru)$"),
lang: str = Query("en", regex="^(ko|en|mn|ru)$"),
db: Session = Depends(get_db)
):
"""활성 히어로 배너 목록 조회 (Public)"""

View File

@@ -64,7 +64,10 @@ class Settings(BaseSettings):
base_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
db_path = os.path.join(base_dir, "autonet.db")
return f"sqlite:///{db_path}"
return f"postgresql://{self.DB_USER}:{self.DB_PASSWORD}@{self.DB_HOST}:{self.DB_PORT}/{self.DB_NAME}"
# URL-encode password for special characters like @ # etc
from urllib.parse import quote_plus
encoded_password = quote_plus(self.DB_PASSWORD)
return f"postgresql://{self.DB_USER}:{encoded_password}@{self.DB_HOST}:{self.DB_PORT}/{self.DB_NAME}"
@property
def REDIS_URL(self) -> str:

View File

@@ -1,6 +1,8 @@
from fastapi import FastAPI
from fastapi import FastAPI, Request
from fastapi.middleware.cors import CORSMiddleware
from fastapi.staticfiles import StaticFiles
from starlette.middleware.base import BaseHTTPMiddleware
from starlette.responses import RedirectResponse
from contextlib import asynccontextmanager
import os
import asyncio
@@ -15,6 +17,42 @@ from datetime import datetime, timedelta
app_settings = get_settings()
class TrailingSlashMiddleware(BaseHTTPMiddleware):
"""
Middleware to normalize trailing slashes on API paths.
Uses redirect to strip trailing slashes from non-root routes.
Routes defined with "/" (like /hero-banners/) keep trailing slash.
Routes defined without "/" (like /cars) get redirected.
"""
# Routes that are defined WITH trailing slash (router.get("/"))
TRAILING_SLASH_ROUTES = {
"/api/hero-banners/",
"/api/settings/",
"/api/notifications/",
"/api/vehicle-requests/",
}
async def dispatch(self, request: Request, call_next):
path = request.url.path
# Skip if it's a known trailing-slash route
if path in self.TRAILING_SLASH_ROUTES:
return await call_next(request)
# Redirect trailing slash from other /api/* paths
if path.startswith("/api/") and path.endswith("/") and len(path) > 5:
new_path = path.rstrip("/")
if request.url.query:
new_url = f"{new_path}?{request.url.query}"
else:
new_url = new_path
return RedirectResponse(url=new_url, status_code=307)
return await call_next(request)
# Create tables
Base.metadata.create_all(bind=engine)
@@ -118,6 +156,9 @@ app = FastAPI(
lifespan=lifespan
)
# Trailing slash middleware (must be added before CORS)
app.add_middleware(TrailingSlashMiddleware)
# CORS - credentials=True requires explicit origins (not "*")
app.add_middleware(
CORSMiddleware,
@@ -126,6 +167,8 @@ app.add_middleware(
"http://127.0.0.1:3000",
"http://localhost:8000",
"http://192.168.0.202:3000", # Local network
"https://autonetsellcar.com", # Production
"http://autonetsellcar.com", # Production (HTTP redirect)
],
allow_credentials=True,
allow_methods=["*"],

View File

@@ -0,0 +1,124 @@
#!/usr/bin/env python3
"""
Fix hero banner translations by updating title_en, title_mn, title_ru from title_ko
"""
import sqlite3
import os
# Translation dictionary (subset of key terms)
TRANSLATIONS = {
# Makers
'현대': {'en': 'Hyundai', 'mn': 'Хёндай', 'ru': 'Хёндай'},
'기아': {'en': 'Kia', 'mn': 'Киа', 'ru': 'Киа'},
'제네시스': {'en': 'Genesis', 'mn': 'Женезис', 'ru': 'Дженезис'},
'쉐보레': {'en': 'Chevrolet', 'mn': 'Шевроле', 'ru': 'Шевроле'},
'KG모빌리티': {'en': 'KG Mobility', 'mn': 'КЖ Мобилити', 'ru': 'КГ Мобилити'},
'쌍용': {'en': 'SsangYong', 'mn': 'СсангЁнг', 'ru': 'СсангЙонг'},
# Models
'모하비': {'en': 'Mohave', 'mn': 'Мохаве', 'ru': 'Мохаве'},
'더 마스터': {'en': 'The Master', 'mn': 'Мастер', 'ru': 'Мастер'},
'신형': {'en': 'New', 'mn': 'Шинэ', 'ru': 'Новый'},
'더 뉴': {'en': 'The New', 'mn': 'Шинэ', 'ru': 'Новый'},
'그랜드스타렉스': {'en': 'Grand Starex', 'mn': 'Гранд Старекс', 'ru': 'Гранд Старекс'},
'스타렉스': {'en': 'Starex', 'mn': 'Старекс', 'ru': 'Старекс'},
'싼타페': {'en': 'Santa Fe', 'mn': 'Санта Фе', 'ru': 'Санта Фе'},
'스팅어': {'en': 'Stinger', 'mn': 'Стингер', 'ru': 'Стингер'},
'마이스터': {'en': 'Meister', 'mn': 'Мейстер', 'ru': 'Мейстер'},
'쏘렌토': {'en': 'Sorento', 'mn': 'Соренто', 'ru': 'Соренто'},
'스포티지': {'en': 'Sportage', 'mn': 'Спортаж', 'ru': 'Спортаж'},
'카니발': {'en': 'Carnival', 'mn': 'Карнивал', 'ru': 'Карнивал'},
'셀토스': {'en': 'Seltos', 'mn': 'Селтос', 'ru': 'Селтос'},
'투싼': {'en': 'Tucson', 'mn': 'Туксон', 'ru': 'Туксон'},
'팰리세이드': {'en': 'Palisade', 'mn': 'Палисейд', 'ru': 'Палисейд'},
'아반떼': {'en': 'Avante', 'mn': 'Аванте', 'ru': 'Аванте'},
'쏘나타': {'en': 'Sonata', 'mn': 'Соната', 'ru': 'Соната'},
'그랜저': {'en': 'Grandeur', 'mn': 'Грандёр', 'ru': 'Грандёр'},
'코나': {'en': 'Kona', 'mn': 'Кона', 'ru': 'Кона'},
'K5': {'en': 'K5', 'mn': 'K5', 'ru': 'K5'},
'K3': {'en': 'K3', 'mn': 'K3', 'ru': 'K3'},
'K7': {'en': 'K7', 'mn': 'K7', 'ru': 'K7'},
'K8': {'en': 'K8', 'mn': 'K8', 'ru': 'K8'},
'K9': {'en': 'K9', 'mn': 'K9', 'ru': 'K9'},
'GV70': {'en': 'GV70', 'mn': 'GV70', 'ru': 'GV70'},
'GV80': {'en': 'GV80', 'mn': 'GV80', 'ru': 'GV80'},
'G70': {'en': 'G70', 'mn': 'G70', 'ru': 'G70'},
'G80': {'en': 'G80', 'mn': 'G80', 'ru': 'G80'},
'G90': {'en': 'G90', 'mn': 'G90', 'ru': 'G90'},
}
# Sort keys by length (longest first) to avoid partial matches
SORTED_KEYS = sorted(TRANSLATIONS.keys(), key=len, reverse=True)
def translate(text: str, lang: str) -> str:
"""Translate Korean text to target language"""
if not text:
return text
result = text
for key in SORTED_KEYS:
if key in result:
translation = TRANSLATIONS[key].get(lang, key)
result = result.replace(key, translation)
return result
def main():
# Find database file
db_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), "autonet.db")
if not os.path.exists(db_path):
print(f"Database not found at: {db_path}")
return
print(f"Using database: {db_path}")
conn = sqlite3.connect(db_path)
cursor = conn.cursor()
# Get all banners
cursor.execute("SELECT id, title_ko, title_en, title_mn, title_ru FROM hero_banners")
banners = cursor.fetchall()
print(f"\nFound {len(banners)} banners to update:\n")
for banner in banners:
banner_id, title_ko, title_en, title_mn, title_ru = banner
if not title_ko:
print(f"Banner {banner_id}: No Korean title, skipping")
continue
new_title_en = translate(title_ko, 'en')
new_title_mn = translate(title_ko, 'mn')
new_title_ru = translate(title_ko, 'ru')
print(f"Banner {banner_id}:")
print(f" KO: {title_ko}")
print(f" EN: {title_en} -> {new_title_en}")
print(f" MN: {title_mn} -> {new_title_mn}")
print(f" RU: {title_ru} -> {new_title_ru}")
print()
# Update the banner
cursor.execute("""
UPDATE hero_banners
SET title_en = ?, title_mn = ?, title_ru = ?
WHERE id = ?
""", (new_title_en, new_title_mn, new_title_ru, banner_id))
conn.commit()
print(f"Updated {len(banners)} banners successfully!")
# Verify
print("\n--- Verification ---")
cursor.execute("SELECT id, title_ko, title_en, title_mn FROM hero_banners")
for row in cursor.fetchall():
print(f"ID {row[0]}: {row[1]} -> EN: {row[2]}, MN: {row[3]}")
conn.close()
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,297 @@
"""
SQLite to PostgreSQL Migration Script
Handles boolean conversion and foreign key constraints
"""
import os
import sys
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import sqlite3
import psycopg2
from urllib.parse import quote_plus
SQLITE_PATH = os.path.join(os.path.dirname(__file__), "autonet.db")
PG_CONFIG = {
"host": "192.168.0.201",
"port": 5432,
"database": "autonet",
"user": "admin",
"password": "roskfl@1122"
}
# Tables in dependency order (parents first)
TABLES_ORDER = [
# Base tables (no FK dependencies)
"car_makers",
"car_models",
"translations",
"system_settings",
"cc_packages",
"exchange_rates",
"exchange_rate_history",
"hero_banner_settings",
# Users before user-dependent tables
"users",
# Car related
"cars",
"car_images",
"car_options",
"car_performance_checks",
"car_specifications",
"car_views",
"performance_check_views",
# Cache
"car_cache",
"car_detail_cache",
"cache_request_queue",
# Hero banners
"hero_banners",
# User activities
"charge_history",
"inquiries",
"inquiry_messages",
"vehicle_requests",
"request_vehicles",
"purchased_vehicles",
"dealer_applications",
"dealer_info",
"vehicle_shares",
"share_rewards",
"withdrawal_requests",
"referral_rewards",
"notifications",
"push_subscriptions",
"user_notification_preferences",
"verification_codes",
"visitor_logs",
"visitor_daily_stats",
"visitor_sessions",
]
def create_tables():
"""Create tables in PostgreSQL"""
print("\n[Step 1] Creating tables in PostgreSQL...")
os.environ["USE_SQLITE"] = "False"
os.environ["DB_HOST"] = PG_CONFIG["host"]
os.environ["DB_PORT"] = str(PG_CONFIG["port"])
os.environ["DB_NAME"] = PG_CONFIG["database"]
os.environ["DB_USER"] = PG_CONFIG["user"]
os.environ["DB_PASSWORD"] = PG_CONFIG["password"]
from sqlalchemy import create_engine
from app.database import Base
from app.models import (
CarMaker, CarModel, Car, CarImage, CarOption,
User, CarView, PerformanceCheckView, ChargeHistory, VerificationCode,
Inquiry, InquiryMessage, HeroBanner, HeroBannerSettings,
Translation, CarCache, CarDetailCache, CacheRequestQueue,
SystemSettings, VehicleRequest, RequestVehicle, PurchasedVehicle,
DealerApplication, DealerInfo, VehicleShare, ShareReward,
WithdrawalRequest, ReferralReward, Notification,
PushSubscription, UserNotificationPreference,
CarPerformanceCheck, CarSpecification,
ExchangeRate, ExchangeRateHistory, CCPackage,
VisitorLog, VisitorDailyStats, VisitorSession,
)
encoded_pw = quote_plus(PG_CONFIG['password'])
pg_url = f"postgresql://{PG_CONFIG['user']}:{encoded_pw}@{PG_CONFIG['host']}:{PG_CONFIG['port']}/{PG_CONFIG['database']}"
engine = create_engine(pg_url, echo=False)
Base.metadata.create_all(bind=engine)
print(" Tables created successfully!")
def get_boolean_columns(pg_cursor, table_name):
"""Get list of boolean columns for a table"""
pg_cursor.execute("""
SELECT column_name FROM information_schema.columns
WHERE table_schema = 'public' AND table_name = %s AND data_type = 'boolean'
""", (table_name,))
return [row[0] for row in pg_cursor.fetchall()]
def convert_row(row, columns, bool_cols):
"""Convert SQLite row values for PostgreSQL (handle booleans)"""
result = []
for i, val in enumerate(row):
col_name = columns[i]
if col_name in bool_cols and val is not None:
# Convert 0/1 to False/True
result.append(bool(val))
else:
result.append(val)
return tuple(result)
def migrate_table(sqlite_conn, pg_conn, table_name):
"""Migrate a single table"""
sqlite_cursor = sqlite_conn.cursor()
pg_cursor = pg_conn.cursor()
# Get SQLite columns
sqlite_cursor.execute(f"PRAGMA table_info({table_name})")
sqlite_cols = [col[1] for col in sqlite_cursor.fetchall()]
if not sqlite_cols:
return 0
# Get data
sqlite_cursor.execute(f"SELECT * FROM {table_name}")
rows = sqlite_cursor.fetchall()
if not rows:
print(f" {table_name}: 0 rows (empty)")
return 0
# Check PostgreSQL table exists
pg_cursor.execute("""
SELECT EXISTS (SELECT FROM information_schema.tables
WHERE table_schema = 'public' AND table_name = %s)
""", (table_name,))
if not pg_cursor.fetchone()[0]:
print(f" {table_name}: skipped (not in PostgreSQL)")
return 0
# Get PostgreSQL columns
pg_cursor.execute("""
SELECT column_name FROM information_schema.columns
WHERE table_schema = 'public' AND table_name = %s
ORDER BY ordinal_position
""", (table_name,))
pg_cols = [row[0] for row in pg_cursor.fetchall()]
# Find common columns
common_cols = [c for c in sqlite_cols if c in pg_cols]
if not common_cols:
print(f" {table_name}: skipped (no matching columns)")
return 0
col_indices = [sqlite_cols.index(c) for c in common_cols]
# Get boolean columns
bool_cols = set(get_boolean_columns(pg_cursor, table_name))
# Prepare query
cols_str = ", ".join(common_cols)
placeholders = ", ".join(["%s"] * len(common_cols))
insert_sql = f"INSERT INTO {table_name} ({cols_str}) VALUES ({placeholders})"
try:
# Truncate with CASCADE to handle FK
pg_cursor.execute(f"TRUNCATE TABLE {table_name} CASCADE")
success = 0
for row in rows:
try:
# Extract and convert row
filtered = tuple(row[i] for i in col_indices)
converted = convert_row(filtered, common_cols, bool_cols)
pg_cursor.execute(insert_sql, converted)
success += 1
except Exception as e:
pg_conn.rollback()
# Truncate again after rollback
pg_cursor.execute(f"TRUNCATE TABLE {table_name} CASCADE")
print(f" {table_name}: error - {str(e)[:80]}")
return 0
pg_conn.commit()
print(f" {table_name}: {success}/{len(rows)} rows migrated")
return success
except Exception as e:
pg_conn.rollback()
print(f" {table_name}: error - {str(e)[:80]}")
return 0
def reset_sequences(pg_conn):
"""Reset sequences to max(id) + 1"""
print("\n[Step 3] Resetting sequences...")
pg_cursor = pg_conn.cursor()
# Get all tables with id column
pg_cursor.execute("""
SELECT table_name FROM information_schema.columns
WHERE table_schema = 'public' AND column_name = 'id'
""")
tables = [row[0] for row in pg_cursor.fetchall()]
for table in tables:
try:
# Check if sequence exists
seq_name = f"{table}_id_seq"
pg_cursor.execute("""
SELECT EXISTS (SELECT FROM pg_sequences WHERE schemaname = 'public' AND sequencename = %s)
""", (seq_name,))
if pg_cursor.fetchone()[0]:
pg_cursor.execute(f"""
SELECT setval('{seq_name}', COALESCE((SELECT MAX(id) FROM {table}), 0) + 1, false)
""")
except:
pass
pg_conn.commit()
print(" Sequences reset completed")
def main():
print("=" * 60)
print("SQLite to PostgreSQL Migration")
print("=" * 60)
# Step 1: Create tables
try:
create_tables()
except Exception as e:
print(f" Failed: {e}")
sys.exit(1)
# Step 2: Connect and migrate
print("\n[Step 2] Migrating data...")
sqlite_conn = sqlite3.connect(SQLITE_PATH)
encoded_pw = quote_plus(PG_CONFIG['password'])
pg_conn = psycopg2.connect(
host=PG_CONFIG['host'],
port=PG_CONFIG['port'],
database=PG_CONFIG['database'],
user=PG_CONFIG['user'],
password=PG_CONFIG['password']
)
# Disable FK checks during migration
pg_cursor = pg_conn.cursor()
pg_cursor.execute("SET session_replication_role = 'replica';")
pg_conn.commit()
# Get SQLite tables
sqlite_cursor = sqlite_conn.cursor()
sqlite_cursor.execute("SELECT name FROM sqlite_master WHERE type='table' AND name != 'sqlite_sequence'")
all_tables = set(t[0] for t in sqlite_cursor.fetchall())
# Migrate in order
total = 0
migrated_tables = set()
for table in TABLES_ORDER:
if table in all_tables:
total += migrate_table(sqlite_conn, pg_conn, table)
migrated_tables.add(table)
# Migrate remaining tables
remaining = all_tables - migrated_tables
for table in remaining:
total += migrate_table(sqlite_conn, pg_conn, table)
# Re-enable FK checks
pg_cursor.execute("SET session_replication_role = 'origin';")
pg_conn.commit()
# Step 3: Reset sequences
reset_sequences(pg_conn)
sqlite_conn.close()
pg_conn.close()
print("\n" + "=" * 60)
print(f"Migration completed! Total rows: {total}")
print("=" * 60)
if __name__ == "__main__":
main()

View File

@@ -1,7 +1,7 @@
fastapi
uvicorn[standard]
sqlalchemy
# psycopg2-binary # Uncomment for PostgreSQL production
psycopg2-binary # PostgreSQL production
redis
python-dotenv
pydantic
@@ -15,6 +15,8 @@ lxml
alembic
email-validator
playwright # PDF capture for performance check reports
img2pdf # Convert screenshots to PDF
pillow # Image processing for PDF generation
apscheduler # Scheduled tasks (exchange rate updates)
stripe # Payment processing
user-agents # Visitor tracking

View File

@@ -4,6 +4,7 @@ import { useState, useEffect } from 'react';
import { useSearchParams, useRouter } from 'next/navigation';
import Image from 'next/image';
import api, { heroBannersApi, carmodooApi, vehicleRequestsApi } from '@/lib/api';
import { translateCarName } from '@/lib/i18n';
interface CarmodooMaker {
code: string;
@@ -130,6 +131,8 @@ export default function CarsAdminPage() {
const [selectedCar, setSelectedCar] = useState<LocalCar | null>(null);
const [showDetailModal, setShowDetailModal] = useState(false);
const [currentImageIndex, setCurrentImageIndex] = useState(0);
const [selectedLocalCars, setSelectedLocalCars] = useState<Set<number>>(new Set());
const [registeringLocalBanner, setRegisteringLocalBanner] = useState(false);
// All Cars (public view) state
const [allCars, setAllCars] = useState<LocalCar[]>([]);
@@ -549,30 +552,6 @@ export default function CarsAdminPage() {
}
};
// 차량명 번역 함수
const translateCarName = (koreanName: string | undefined): string => {
if (!koreanName) return '-';
const translations: Record<string, string> = {
'현대': 'Hyundai', '제네시스': 'Genesis', '기아': 'Kia',
'쉐보레(대우)': 'Chevrolet', '쉐보레': 'Chevrolet',
'르노(삼성)': 'Renault', 'KG모빌리티(쌍용)': 'KG Mobility',
'닛산': 'Nissan', '렉서스': 'Lexus', '토요타': 'Toyota', '혼다': 'Honda',
'쏘렌토': 'Sorento', '스포티지': 'Sportage', '셀토스': 'Seltos',
'카니발': 'Carnival', '모닝': 'Morning', '레이': 'Ray',
'아반떼': 'Avante', '쏘나타': 'Sonata', '그랜저': 'Grandeur',
'투싼': 'Tucson', '싼타페': 'Santa Fe', '팰리세이드': 'Palisade',
'코나': 'Kona', '스타리아': 'Staria', '캐스퍼': 'Casper',
};
let result = koreanName;
const sortedKeys = Object.keys(translations).sort((a, b) => b.length - a.length);
for (const korean of sortedKeys) {
result = result.replace(new RegExp(korean, 'g'), translations[korean]);
}
return result;
};
// 딜러 설명 미리보기 및 편집 함수
const handleEditDealerDescription = async (car: CarmodooCarItem) => {
setEditingCar(car);
@@ -700,11 +679,13 @@ export default function CarsAdminPage() {
const bannerData = {
title_ko: car.car_name || '',
title_en: translateCarName(car.car_name),
title_mn: translateCarName(car.car_name),
title_en: translateCarName(car.car_name, 'en'),
title_mn: translateCarName(car.car_name, 'mn'),
title_ru: translateCarName(car.car_name, 'ru'),
subtitle_ko: `${car.year}년식 | ${car.mileage?.toLocaleString()}km`,
subtitle_en: `${car.year} | ${car.mileage?.toLocaleString()}km`,
subtitle_mn: `${car.year} | ${car.mileage?.toLocaleString()}km`,
subtitle_ru: `${car.year} | ${car.mileage?.toLocaleString()}km`,
image_url: localImageUrl,
link_url: `/cars/${carId}`,
is_active: true,
@@ -752,6 +733,80 @@ export default function CarsAdminPage() {
}
};
// Local Cars에서 배너 등록하는 함수
const handleRegisterLocalCarAsBanner = async () => {
if (selectedLocalCars.size === 0) {
alert('Please select at least one car to register as banner.');
return;
}
if (!confirm(`${selectedLocalCars.size}개의 차량을 Hero Banner로 등록하시겠습니까?`)) {
return;
}
setRegisteringLocalBanner(true);
try {
const selectedCarsList = localCars.filter(car => selectedLocalCars.has(car.id));
const existingBanners = await heroBannersApi.adminGetList();
let orderStart = existingBanners.length;
let successCount = 0;
for (const car of selectedCarsList) {
const localImageUrl = `/uploads/cars/${car.id}/image_0.jpg`;
const bannerData = {
title_ko: car.car_name || '',
title_en: translateCarName(car.car_name || '', 'en'),
title_mn: translateCarName(car.car_name || '', 'mn'),
title_ru: translateCarName(car.car_name || '', 'ru'),
subtitle_ko: `${car.year || ''}년식 | ${car.mileage ? formatMileage(car.mileage) : ''}`,
subtitle_en: `${car.year || ''} | ${car.mileage ? formatMileage(car.mileage) : ''}`,
subtitle_mn: `${car.year || ''} | ${car.mileage ? formatMileage(car.mileage) : ''}`,
subtitle_ru: `${car.year || ''} | ${car.mileage ? formatMileage(car.mileage) : ''}`,
image_url: localImageUrl,
link_url: `/cars/${car.id}`,
display_order: orderStart++,
is_active: true,
car_id: car.id,
};
await heroBannersApi.adminCreate(bannerData);
successCount++;
}
alert(`${successCount}개의 차량이 Hero Banner로 등록되었습니다.`);
setSelectedLocalCars(new Set());
} catch (err) {
console.error('Local banner registration failed:', err);
alert('배너 등록에 실패했습니다.');
} finally {
setRegisteringLocalBanner(false);
}
};
// Local car selection toggle
const handleLocalCarSelect = (carId: number, e: React.MouseEvent) => {
e.stopPropagation();
setSelectedLocalCars(prev => {
const newSet = new Set(prev);
if (newSet.has(carId)) {
newSet.delete(carId);
} else {
newSet.add(carId);
}
return newSet;
});
};
// Select all local cars
const handleSelectAllLocalCars = () => {
if (selectedLocalCars.size === localCars.length) {
setSelectedLocalCars(new Set());
} else {
setSelectedLocalCars(new Set(localCars.map(car => car.id)));
}
};
// 차량 추천 목록에 추가 함수 (Vehicle Request용)
const handleAddToRequest = async () => {
if (!requestId) return;
@@ -1029,17 +1084,45 @@ export default function CarsAdminPage() {
<div className="flex justify-between items-center mb-4">
<h2 className="text-lg font-semibold text-gray-800">
Imported Cars ({localTotal} total)
{selectedLocalCars.size > 0 && (
<span className="ml-2 text-sm font-normal text-purple-600">
({selectedLocalCars.size} selected)
</span>
)}
</h2>
<button
onClick={() => loadLocalCars(localPage)}
disabled={localLoading}
className="text-primary-600 hover:text-primary-700 flex items-center gap-1"
>
<svg className={`w-4 h-4 ${localLoading ? 'animate-spin' : ''}`} 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>
Refresh
</button>
<div className="flex items-center gap-3">
{selectedLocalCars.size > 0 && (
<button
onClick={handleRegisterLocalCarAsBanner}
disabled={registeringLocalBanner}
className="bg-purple-600 text-white px-4 py-2 rounded-lg hover:bg-purple-700 transition-colors disabled:opacity-50 flex items-center gap-2"
>
{registeringLocalBanner ? (
<>
<div className="w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin"></div>
<span>Registering...</span>
</>
) : (
<>
<svg className="w-5 h-5" 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>Register as Banner</span>
</>
)}
</button>
)}
<button
onClick={() => loadLocalCars(localPage)}
disabled={localLoading}
className="text-primary-600 hover:text-primary-700 flex items-center gap-1"
>
<svg className={`w-4 h-4 ${localLoading ? 'animate-spin' : ''}`} 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>
Refresh
</button>
</div>
</div>
{localLoading ? (
@@ -1066,6 +1149,14 @@ export default function CarsAdminPage() {
<table className="w-full">
<thead>
<tr className="border-b border-gray-200">
<th className="py-3 px-2 text-center text-sm font-medium text-gray-600">
<input
type="checkbox"
checked={selectedLocalCars.size === localCars.length && localCars.length > 0}
onChange={handleSelectAllLocalCars}
className="w-4 h-4 text-purple-600 rounded border-gray-300 focus:ring-purple-500"
/>
</th>
<th className="py-3 px-2 text-center text-sm font-medium text-gray-600">Display</th>
<th className="py-3 px-4 text-left text-sm font-medium text-gray-600">Image</th>
<th className="py-3 px-4 text-left text-sm font-medium text-gray-600">Car Name</th>
@@ -1088,6 +1179,24 @@ export default function CarsAdminPage() {
className={`border-b border-gray-100 hover:bg-gray-50 cursor-pointer ${!car.is_displayed ? 'opacity-60' : ''}`}
onClick={() => handleCarClick(car)}
>
<td className="py-3 px-2 text-center" onClick={(e) => e.stopPropagation()}>
<input
type="checkbox"
checked={selectedLocalCars.has(car.id)}
onChange={() => {
setSelectedLocalCars(prev => {
const newSet = new Set(prev);
if (newSet.has(car.id)) {
newSet.delete(car.id);
} else {
newSet.add(car.id);
}
return newSet;
});
}}
className="w-4 h-4 text-purple-600 rounded border-gray-300 focus:ring-purple-500"
/>
</td>
<td className="py-3 px-2 text-center">
<button
onClick={(e) => handleToggleDisplay(car, e)}
@@ -2276,7 +2385,7 @@ export default function CarsAdminPage() {
<textarea
value={editCommentData.ko}
onChange={(e) => setEditCommentData({ ...editCommentData, ko: e.target.value })}
className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm resize-none"
className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm resize-y min-h-[80px]"
rows={3}
/>
</div>
@@ -2285,7 +2394,7 @@ export default function CarsAdminPage() {
<textarea
value={editCommentData.en}
onChange={(e) => setEditCommentData({ ...editCommentData, en: e.target.value })}
className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm resize-none"
className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm resize-y min-h-[80px]"
rows={3}
/>
</div>
@@ -2294,7 +2403,7 @@ export default function CarsAdminPage() {
<textarea
value={editCommentData.mn}
onChange={(e) => setEditCommentData({ ...editCommentData, mn: e.target.value })}
className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm resize-none"
className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm resize-y min-h-[80px]"
rows={3}
/>
</div>
@@ -2303,7 +2412,7 @@ export default function CarsAdminPage() {
<textarea
value={editCommentData.ru}
onChange={(e) => setEditCommentData({ ...editCommentData, ru: e.target.value })}
className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm resize-none"
className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm resize-y min-h-[80px]"
rows={3}
/>
</div>

View File

@@ -133,7 +133,7 @@ export const inquiriesApi = {
// Hero Banners API
export const heroBannersApi = {
// Public APIs
getList: async (lang: string = 'ko'): Promise<HeroBanner[]> => {
getList: async (lang: string = 'en'): Promise<HeroBanner[]> => {
const { data } = await api.get('/hero-banners/', { params: { lang } });
return data;
},