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:
1609
frontend/src/lib/api.ts
Normal file
1609
frontend/src/lib/api.ts
Normal file
File diff suppressed because it is too large
Load Diff
114
frontend/src/lib/exchangeRateStore.ts
Normal file
114
frontend/src/lib/exchangeRateStore.ts
Normal 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
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
30
frontend/src/lib/store.ts
Normal 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 });
|
||||
},
|
||||
}));
|
||||
134
frontend/src/lib/useTranslate.ts
Normal file
134
frontend/src/lib/useTranslate.ts
Normal 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];
|
||||
});
|
||||
}
|
||||
91
frontend/src/lib/useVisitorTracking.ts
Normal file
91
frontend/src/lib/useVisitorTracking.ts
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user