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

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;
},