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:
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user