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:
AutonetSellCar Deploy
2025-12-30 13:24:39 +09:00
commit 1f0dcb1ddb
224 changed files with 55119 additions and 0 deletions

1609
frontend/src/lib/api.ts Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,114 @@
/**
* Exchange Rate Store
* 한국수출입은행 API에서 가져온 환율 정보를 저장하고 관리
*/
import { create } from 'zustand';
import { exchangeRateApi, ExchangeRateSimple } from './api';
// 기본 환율 (API 실패 시 사용) - 2024년 12월 기준
const DEFAULT_RATES: ExchangeRateSimple = {
USD: { rate: 1483, symbol: '$', name: '미국 달러' },
MNT: { rate: 0.43, symbol: '₮', name: '몽골 투그릭' },
RUB: { rate: 14.5, symbol: '₽', name: '러시아 루블' },
CNY: { rate: 203, symbol: '¥', name: '중국 위안' },
EUR: { rate: 1750, symbol: '€', name: '유로' },
JPY: { rate: 9.5, symbol: '¥', name: '일본 엔' },
};
interface ExchangeRateState {
rates: ExchangeRateSimple;
isLoading: boolean;
lastUpdated: Date | null;
error: string | null;
fetchRates: () => Promise<void>;
getKrwToUsd: () => number; // KRW를 USD로 변환하는 비율 (1 KRW = X USD)
getUsdToMnt: () => number; // USD를 MNT로 변환하는 비율 (1 USD = X MNT)
getUsdToRub: () => number; // USD를 RUB로 변환하는 비율 (1 USD = X RUB)
convertKrwTo: (krwAmount: number, currency: string) => number;
}
export const useExchangeRateStore = create<ExchangeRateState>((set, get) => ({
rates: DEFAULT_RATES,
isLoading: false,
lastUpdated: null,
error: null,
fetchRates: async () => {
// 이미 로딩 중이면 스킵
if (get().isLoading) return;
// 30분 이내에 업데이트했으면 스킵
const lastUpdated = get().lastUpdated;
if (lastUpdated && Date.now() - lastUpdated.getTime() < 30 * 60 * 1000) {
return;
}
set({ isLoading: true, error: null });
try {
const rates = await exchangeRateApi.getSimpleRates();
set({
rates: { ...DEFAULT_RATES, ...rates },
isLoading: false,
lastUpdated: new Date(),
});
} catch (error) {
console.error('Failed to fetch exchange rates:', error);
set({
isLoading: false,
error: 'Failed to fetch exchange rates',
});
// 실패해도 기본값 사용
}
},
getKrwToUsd: () => {
const { rates } = get();
const usdRate = rates.USD?.rate || DEFAULT_RATES.USD.rate;
return 1 / usdRate; // 1 KRW = 1/1450 USD
},
getUsdToMnt: () => {
const { rates } = get();
const usdRate = rates.USD?.rate || DEFAULT_RATES.USD.rate;
const mntRate = rates.MNT?.rate || DEFAULT_RATES.MNT.rate;
// MNT rate is already in KRW per MNT
// So 1 USD = (USD_KRW / MNT_KRW) MNT
return usdRate / mntRate;
},
getUsdToRub: () => {
const { rates } = get();
const usdRate = rates.USD?.rate || DEFAULT_RATES.USD.rate;
const rubRate = rates.RUB?.rate || DEFAULT_RATES.RUB.rate;
return usdRate / rubRate;
},
convertKrwTo: (krwAmount: number, currency: string) => {
const { rates } = get();
const rate = rates[currency]?.rate || DEFAULT_RATES[currency]?.rate;
if (!rate) return 0;
return krwAmount / rate;
},
}));
// 환율 초기화 함수 (앱 시작 시 호출)
export async function initExchangeRates() {
const store = useExchangeRateStore.getState();
await store.fetchRates();
}
// 환율 변환 헬퍼 함수들
export function formatWithExchangeRate(
krwAmount: number,
currency: 'USD' | 'MNT' | 'RUB' | 'CNY' | 'EUR'
): string {
const store = useExchangeRateStore.getState();
const converted = store.convertKrwTo(krwAmount, currency);
const symbol = store.rates[currency]?.symbol || DEFAULT_RATES[currency]?.symbol || '';
return `${symbol}${converted.toLocaleString('en-US', {
minimumFractionDigits: 0,
maximumFractionDigits: currency === 'USD' ? 0 : 0,
})}`;
}

3021
frontend/src/lib/i18n.ts Normal file

File diff suppressed because it is too large Load Diff

30
frontend/src/lib/store.ts Normal file
View File

@@ -0,0 +1,30 @@
import { create } from 'zustand';
import { User } from '@/types';
interface AuthState {
user: User | null;
token: string | null;
isLoading: boolean;
setUser: (user: User | null) => void;
setToken: (token: string | null) => void;
logout: () => void;
}
export const useAuthStore = create<AuthState>((set) => ({
user: null,
token: typeof window !== 'undefined' ? localStorage.getItem('token') : null,
isLoading: true,
setUser: (user) => set({ user, isLoading: false }),
setToken: (token) => {
if (token) {
localStorage.setItem('token', token);
} else {
localStorage.removeItem('token');
}
set({ token });
},
logout: () => {
localStorage.removeItem('token');
set({ user: null, token: null });
},
}));

View File

@@ -0,0 +1,134 @@
import { useState, useEffect, useCallback } from 'react';
import { translationsApi } from './api';
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
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]);
// Translate car object fields
const translateCar = useCallback((car: {
car_name?: string;
fuel?: string;
transmission?: string;
color?: string;
maker?: { name: string };
model?: { name: string };
}) => {
return {
car_name: translate(car.car_name),
fuel: translate(car.fuel),
transmission: translate(car.transmission),
color: translate(car.color),
maker_name: translate(car.maker?.name),
model_name: translate(car.model?.name),
};
}, [translate]);
// Preload translations for a list of cars
const preloadCarTranslations = useCallback(async (cars: Array<{
car_name?: string;
fuel?: string;
transmission?: string;
color?: string;
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]);
return {
translate,
translateCar,
loadTranslations,
preloadCarTranslations,
loading,
};
}
// Clear translation cache (useful when translations are updated)
export function clearTranslationCache() {
Object.keys(translationCache).forEach(key => {
delete translationCache[key];
});
}

View File

@@ -0,0 +1,91 @@
'use client';
import { useEffect, useRef } from 'react';
import { usePathname } from 'next/navigation';
const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8000';
// Generate a simple session ID
const generateSessionId = (): string => {
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {
const r = (Math.random() * 16) | 0;
const v = c === 'x' ? r : (r & 0x3) | 0x8;
return v.toString(16);
});
};
// Get or create session ID
const getSessionId = (): string => {
if (typeof window === 'undefined') return '';
let sessionId = sessionStorage.getItem('visitor_session_id');
if (!sessionId) {
sessionId = generateSessionId();
sessionStorage.setItem('visitor_session_id', sessionId);
}
return sessionId;
};
// Extract UTM parameters from URL
const getUtmParams = () => {
if (typeof window === 'undefined') return {};
const params = new URLSearchParams(window.location.search);
return {
utm_source: params.get('utm_source') || undefined,
utm_medium: params.get('utm_medium') || undefined,
utm_campaign: params.get('utm_campaign') || undefined,
};
};
export function useVisitorTracking() {
const pathname = usePathname();
const lastPath = useRef<string>('');
useEffect(() => {
// Avoid duplicate tracking on same path
if (pathname === lastPath.current) return;
lastPath.current = pathname;
// Skip admin pages from tracking
if (pathname.startsWith('/admin')) return;
const trackVisit = async () => {
try {
const token = localStorage.getItem('token');
const headers: Record<string, string> = {
'Content-Type': 'application/json',
};
if (token) {
headers['Authorization'] = `Bearer ${token}`;
}
const utmParams = getUtmParams();
await fetch(`${API_URL}/api/visitor/log`, {
method: 'POST',
headers,
body: JSON.stringify({
page_path: pathname,
page_title: document.title,
referrer: document.referrer || null,
session_id: getSessionId(),
...utmParams,
}),
});
} catch (error) {
// Silent fail - don't disrupt user experience
console.debug('Visitor tracking failed:', error);
}
};
// Small delay to ensure page title is set
setTimeout(trackVisit, 100);
}, [pathname]);
}
// Component wrapper for use in layout
export function VisitorTracker() {
useVisitorTracking();
return null;
}