Initial commit: AutonetSellCar platform with deployment system
- Frontend: Next.js 14 with TypeScript - Backend: FastAPI with SQLAlchemy - Agent: Carmodoo sync agent - Deployment: Docker Compose based staging/production setup - Scripts: Automated deployment with rollback support 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
396
frontend/src/app/admin/dealer-translations/page.tsx
Normal file
396
frontend/src/app/admin/dealer-translations/page.tsx
Normal file
@@ -0,0 +1,396 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { carmodooApi } from '@/lib/api';
|
||||
|
||||
interface CarTranslation {
|
||||
id: number;
|
||||
car_name: string;
|
||||
dealer_description: string;
|
||||
has_en: boolean;
|
||||
has_mn: boolean;
|
||||
has_ru: boolean;
|
||||
}
|
||||
|
||||
interface CarTranslationDetail {
|
||||
car_id: number;
|
||||
car_name: string;
|
||||
dealer_description: string | null;
|
||||
translations: {
|
||||
en: string | null;
|
||||
mn: string | null;
|
||||
ru: string | null;
|
||||
};
|
||||
has_translations: boolean;
|
||||
papago_configured: boolean;
|
||||
}
|
||||
|
||||
export default function DealerTranslationsPage() {
|
||||
const [untranslatedCars, setUntranslatedCars] = useState<CarTranslation[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [selectedCar, setSelectedCar] = useState<CarTranslationDetail | null>(null);
|
||||
const [editMode, setEditMode] = useState(false);
|
||||
const [editData, setEditData] = useState({
|
||||
dealer_description: '', // 한국어 원문
|
||||
dealer_description_en: '',
|
||||
dealer_description_mn: '',
|
||||
dealer_description_ru: '',
|
||||
});
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [regenerating, setRegenerating] = useState(false);
|
||||
const [batchTranslating, setBatchTranslating] = useState(false);
|
||||
const [batchResult, setBatchResult] = useState<{
|
||||
total: number;
|
||||
success: number;
|
||||
failed: number;
|
||||
} | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
loadUntranslatedCars();
|
||||
}, []);
|
||||
|
||||
const loadUntranslatedCars = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const data = await carmodooApi.getUntranslatedCars(50);
|
||||
setUntranslatedCars(data.cars);
|
||||
} catch (err) {
|
||||
console.error('Failed to load untranslated cars:', err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const loadCarTranslations = async (carId: number) => {
|
||||
try {
|
||||
const data = await carmodooApi.getCarTranslations(carId);
|
||||
setSelectedCar(data);
|
||||
setEditData({
|
||||
dealer_description: data.dealer_description || '',
|
||||
dealer_description_en: data.translations.en || '',
|
||||
dealer_description_mn: data.translations.mn || '',
|
||||
dealer_description_ru: data.translations.ru || '',
|
||||
});
|
||||
setEditMode(false);
|
||||
} catch (err) {
|
||||
console.error('Failed to load car translations:', err);
|
||||
alert('Failed to load translations');
|
||||
}
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!selectedCar) return;
|
||||
setSaving(true);
|
||||
try {
|
||||
await carmodooApi.updateCarTranslations(selectedCar.car_id, editData);
|
||||
await loadCarTranslations(selectedCar.car_id);
|
||||
await loadUntranslatedCars();
|
||||
setEditMode(false);
|
||||
alert('Translations saved successfully');
|
||||
} catch (err) {
|
||||
console.error('Failed to save translations:', err);
|
||||
alert('Failed to save translations');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRegenerate = async () => {
|
||||
if (!selectedCar) return;
|
||||
if (!confirm('Regenerate translations using Papago API? This will overwrite existing translations.')) return;
|
||||
|
||||
setRegenerating(true);
|
||||
try {
|
||||
const result = await carmodooApi.regenerateTranslations(selectedCar.car_id);
|
||||
setSelectedCar({
|
||||
...selectedCar,
|
||||
translations: result.translations,
|
||||
has_translations: true,
|
||||
});
|
||||
setEditData({
|
||||
dealer_description: selectedCar.dealer_description || '',
|
||||
dealer_description_en: result.translations.en || '',
|
||||
dealer_description_mn: result.translations.mn || '',
|
||||
dealer_description_ru: result.translations.ru || '',
|
||||
});
|
||||
await loadUntranslatedCars();
|
||||
alert('Translations regenerated successfully');
|
||||
} catch (err: any) {
|
||||
console.error('Failed to regenerate translations:', err);
|
||||
alert(err.response?.data?.detail || 'Failed to regenerate translations');
|
||||
} finally {
|
||||
setRegenerating(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleBatchTranslate = async () => {
|
||||
if (!confirm('Translate all pending cars? This may take a while.')) return;
|
||||
|
||||
setBatchTranslating(true);
|
||||
setBatchResult(null);
|
||||
try {
|
||||
const result = await carmodooApi.translateAllPending();
|
||||
setBatchResult({
|
||||
total: result.total,
|
||||
success: result.success,
|
||||
failed: result.failed,
|
||||
});
|
||||
await loadUntranslatedCars();
|
||||
} catch (err: any) {
|
||||
console.error('Failed to batch translate:', err);
|
||||
alert(err.response?.data?.detail || 'Failed to batch translate');
|
||||
} finally {
|
||||
setBatchTranslating(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex justify-between items-center mb-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-800">Dealer Description Translations</h1>
|
||||
<p className="text-sm text-gray-500 mt-1">
|
||||
Manage translations for dealer descriptions before displaying to users
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleBatchTranslate}
|
||||
disabled={batchTranslating || untranslatedCars.length === 0}
|
||||
className="bg-purple-600 text-white px-4 py-2 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-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>
|
||||
Translate All Pending
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Batch Result */}
|
||||
{batchResult && (
|
||||
<div className="bg-green-50 border border-green-200 rounded-lg p-4 mb-6">
|
||||
<h3 className="font-medium text-green-800 mb-2">Batch Translation Complete</h3>
|
||||
<div className="flex gap-6">
|
||||
<div>
|
||||
<span className="text-gray-600">Total:</span> <span className="font-bold">{batchResult.total}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-gray-600">Success:</span> <span className="font-bold text-green-600">{batchResult.success}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-gray-600">Failed:</span> <span className="font-bold text-red-600">{batchResult.failed}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{/* Untranslated Cars List */}
|
||||
<div className="bg-white rounded-xl shadow-sm">
|
||||
<div className="p-4 border-b border-gray-200">
|
||||
<h2 className="font-semibold text-gray-800">
|
||||
Cars Without Translations ({untranslatedCars.length})
|
||||
</h2>
|
||||
</div>
|
||||
<div className="max-h-[600px] overflow-y-auto">
|
||||
{loading ? (
|
||||
<div className="flex justify-center py-8">
|
||||
<div className="w-8 h-8 border-4 border-primary-600 border-t-transparent rounded-full animate-spin"></div>
|
||||
</div>
|
||||
) : untranslatedCars.length === 0 ? (
|
||||
<div className="py-8 text-center text-gray-500">
|
||||
All cars have translations
|
||||
</div>
|
||||
) : (
|
||||
<div className="divide-y divide-gray-100">
|
||||
{untranslatedCars.map((car) => (
|
||||
<button
|
||||
key={car.id}
|
||||
onClick={() => loadCarTranslations(car.id)}
|
||||
className={`w-full p-4 text-left hover:bg-gray-50 transition ${
|
||||
selectedCar?.car_id === car.id ? 'bg-blue-50' : ''
|
||||
}`}
|
||||
>
|
||||
<div className="font-medium text-gray-800 truncate">{car.car_name}</div>
|
||||
<div className="text-sm text-gray-500 truncate mt-1">{car.dealer_description}</div>
|
||||
<div className="flex gap-2 mt-2">
|
||||
<span className={`px-2 py-0.5 rounded text-xs ${car.has_en ? 'bg-green-100 text-green-700' : 'bg-gray-100 text-gray-500'}`}>
|
||||
EN {car.has_en ? '✓' : '✗'}
|
||||
</span>
|
||||
<span className={`px-2 py-0.5 rounded text-xs ${car.has_mn ? 'bg-green-100 text-green-700' : 'bg-gray-100 text-gray-500'}`}>
|
||||
MN {car.has_mn ? '✓' : '✗'}
|
||||
</span>
|
||||
<span className={`px-2 py-0.5 rounded text-xs ${car.has_ru ? 'bg-green-100 text-green-700' : 'bg-gray-100 text-gray-500'}`}>
|
||||
RU {car.has_ru ? '✓' : '✗'}
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Translation Editor */}
|
||||
<div className="bg-white rounded-xl shadow-sm">
|
||||
<div className="p-4 border-b border-gray-200 flex justify-between items-center">
|
||||
<h2 className="font-semibold text-gray-800">Translation Editor</h2>
|
||||
{selectedCar && (
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={handleRegenerate}
|
||||
disabled={regenerating || !selectedCar.dealer_description}
|
||||
className="px-3 py-1.5 bg-purple-100 text-purple-700 rounded hover:bg-purple-200 disabled:opacity-50 text-sm flex items-center gap-1"
|
||||
>
|
||||
{regenerating ? (
|
||||
<div className="w-4 h-4 border-2 border-purple-600 border-t-transparent rounded-full animate-spin"></div>
|
||||
) : (
|
||||
<svg className="w-4 h-4" 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>
|
||||
)}
|
||||
Regenerate
|
||||
</button>
|
||||
{!editMode ? (
|
||||
<button
|
||||
onClick={() => setEditMode(true)}
|
||||
className="px-3 py-1.5 bg-blue-100 text-blue-700 rounded hover:bg-blue-200 text-sm"
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
) : (
|
||||
<div className="flex gap-1">
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={saving}
|
||||
className="px-3 py-1.5 bg-green-600 text-white rounded hover:bg-green-700 disabled:opacity-50 text-sm"
|
||||
>
|
||||
{saving ? 'Saving...' : 'Save'}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
setEditMode(false);
|
||||
if (selectedCar) {
|
||||
setEditData({
|
||||
dealer_description: selectedCar.dealer_description || '',
|
||||
dealer_description_en: selectedCar.translations.en || '',
|
||||
dealer_description_mn: selectedCar.translations.mn || '',
|
||||
dealer_description_ru: selectedCar.translations.ru || '',
|
||||
});
|
||||
}
|
||||
}}
|
||||
className="px-3 py-1.5 bg-gray-100 text-gray-700 rounded hover:bg-gray-200 text-sm"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{selectedCar ? (
|
||||
<div className="p-4 space-y-4">
|
||||
<div className="text-sm text-gray-500 mb-2">
|
||||
Car: <span className="font-medium text-gray-800">{selectedCar.car_name}</span>
|
||||
{!selectedCar.papago_configured && (
|
||||
<span className="ml-2 px-2 py-0.5 bg-yellow-100 text-yellow-700 rounded text-xs">
|
||||
Papago API not configured
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Korean Original */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Korean Original (한국어 원문)
|
||||
</label>
|
||||
{editMode ? (
|
||||
<textarea
|
||||
value={editData.dealer_description}
|
||||
onChange={(e) => setEditData({ ...editData, dealer_description: e.target.value })}
|
||||
className="w-full border border-gray-300 rounded-lg p-3 text-sm min-h-[100px]"
|
||||
placeholder="Enter Korean description..."
|
||||
/>
|
||||
) : (
|
||||
<div className="bg-gray-50 rounded-lg p-3 text-sm text-gray-700 whitespace-pre-wrap max-h-40 overflow-y-auto">
|
||||
{selectedCar.dealer_description || <span className="text-gray-400 italic">No description</span>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* English */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
English Translation
|
||||
</label>
|
||||
{editMode ? (
|
||||
<textarea
|
||||
value={editData.dealer_description_en}
|
||||
onChange={(e) => setEditData({ ...editData, dealer_description_en: e.target.value })}
|
||||
className="w-full border border-gray-300 rounded-lg p-3 text-sm min-h-[100px]"
|
||||
placeholder="Enter English translation..."
|
||||
/>
|
||||
) : (
|
||||
<div className="bg-blue-50 rounded-lg p-3 text-sm text-gray-700 whitespace-pre-wrap min-h-[60px]">
|
||||
{selectedCar.translations.en || <span className="text-gray-400 italic">Not translated</span>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Mongolian */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Mongolian Translation (Монгол)
|
||||
</label>
|
||||
{editMode ? (
|
||||
<textarea
|
||||
value={editData.dealer_description_mn}
|
||||
onChange={(e) => setEditData({ ...editData, dealer_description_mn: e.target.value })}
|
||||
className="w-full border border-gray-300 rounded-lg p-3 text-sm min-h-[100px]"
|
||||
placeholder="Enter Mongolian translation..."
|
||||
/>
|
||||
) : (
|
||||
<div className="bg-green-50 rounded-lg p-3 text-sm text-gray-700 whitespace-pre-wrap min-h-[60px]">
|
||||
{selectedCar.translations.mn || <span className="text-gray-400 italic">Not translated (Using English)</span>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Russian */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Russian Translation (Русский)
|
||||
</label>
|
||||
{editMode ? (
|
||||
<textarea
|
||||
value={editData.dealer_description_ru}
|
||||
onChange={(e) => setEditData({ ...editData, dealer_description_ru: e.target.value })}
|
||||
className="w-full border border-gray-300 rounded-lg p-3 text-sm min-h-[100px]"
|
||||
placeholder="Enter Russian translation..."
|
||||
/>
|
||||
) : (
|
||||
<div className="bg-red-50 rounded-lg p-3 text-sm text-gray-700 whitespace-pre-wrap min-h-[60px]">
|
||||
{selectedCar.translations.ru || <span className="text-gray-400 italic">Not translated</span>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="p-8 text-center text-gray-500">
|
||||
Select a car from the list to view and edit translations
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user