feat: Add admin settings for dealer comment & domestic cost, enhance visitor country stats
- Add show_dealer_comment toggle to admin settings - Add domestic_export_customs_krw setting for cost page - Cost page now uses dynamic settings instead of hardcoded values - Enhance Visitor Stats with dedicated Country Stats card with flags - Fix hero_banners API route ordering (422 error fix) - Fix banner toggle logic to check HeroBanner table instead of car.is_banner - Add country flag emojis for 23+ countries 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -14,6 +14,8 @@ interface SystemSettings {
|
||||
cache_ttl_hours: number;
|
||||
container_logistics_usd: number;
|
||||
shoring_cost_usd: number;
|
||||
domestic_export_customs_krw: number;
|
||||
show_dealer_comment: boolean;
|
||||
referral_reward_enabled: boolean;
|
||||
referral_reward_percent: number;
|
||||
referral_reward_type: string;
|
||||
@@ -57,6 +59,8 @@ export default function SettingsPage() {
|
||||
cache_ttl_hours: 2,
|
||||
container_logistics_usd: 3600,
|
||||
shoring_cost_usd: 300,
|
||||
domestic_export_customs_krw: 1150000,
|
||||
show_dealer_comment: true,
|
||||
referral_reward_enabled: true,
|
||||
referral_reward_percent: 10.0,
|
||||
referral_reward_type: 'one_time',
|
||||
@@ -84,6 +88,8 @@ export default function SettingsPage() {
|
||||
cache_ttl_hours: data.cache_ttl_hours,
|
||||
container_logistics_usd: data.container_logistics_usd || 3600,
|
||||
shoring_cost_usd: data.shoring_cost_usd || 300,
|
||||
domestic_export_customs_krw: data.domestic_export_customs_krw || 1150000,
|
||||
show_dealer_comment: data.show_dealer_comment ?? true,
|
||||
referral_reward_enabled: data.referral_reward_enabled ?? true,
|
||||
referral_reward_percent: data.referral_reward_percent ?? 10.0,
|
||||
referral_reward_type: data.referral_reward_type || 'one_time',
|
||||
@@ -234,6 +240,31 @@ export default function SettingsPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Display Settings */}
|
||||
<div className="bg-white rounded-xl shadow-sm p-6">
|
||||
<h2 className="text-lg font-semibold text-gray-800 mb-4 flex items-center gap-2">
|
||||
<span>Display Settings</span>
|
||||
</h2>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<label className="relative inline-flex items-center cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={formData.show_dealer_comment}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, show_dealer_comment: e.target.checked }))}
|
||||
className="sr-only peer"
|
||||
/>
|
||||
<div className="w-11 h-6 bg-gray-200 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-primary-300 rounded-full peer peer-checked:after:translate-x-full rtl:peer-checked:after:-translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:start-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-primary-600"></div>
|
||||
</label>
|
||||
<div>
|
||||
<span className="text-sm font-medium text-gray-700">Show Dealer Comment</span>
|
||||
<p className="text-sm text-gray-500">차량 상세 페이지에서 딜러 코멘트 표시 여부</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Margin Settings */}
|
||||
<div className="bg-white rounded-xl shadow-sm p-6">
|
||||
<h2 className="text-lg font-semibold text-gray-800 mb-4 flex items-center gap-2">
|
||||
@@ -388,6 +419,22 @@ export default function SettingsPage() {
|
||||
/>
|
||||
<p className="mt-1 text-sm text-gray-500">쇼링비 - 컨테이너 고정 비용 (기본값: $300)</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Korean Domestic + Export Customs (KRW)
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
max="10000000"
|
||||
step="10000"
|
||||
value={formData.domestic_export_customs_krw}
|
||||
onChange={(e) => handleChange('domestic_export_customs_krw', parseInt(e.target.value) || 1150000)}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
|
||||
/>
|
||||
<p className="mt-1 text-sm text-gray-500">국내비용 + 수출통관비용 (기본값: ₩1,150,000)</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 p-4 bg-blue-50 rounded-lg">
|
||||
@@ -396,6 +443,7 @@ export default function SettingsPage() {
|
||||
<p>Total Container Cost: ${formData.container_logistics_usd + formData.shoring_cost_usd}</p>
|
||||
<p>Small Car (5.5/10): ${((formData.container_logistics_usd + formData.shoring_cost_usd) * 0.275).toFixed(0)} per car</p>
|
||||
<p>Compact Car (4.5/10): ${((formData.container_logistics_usd + formData.shoring_cost_usd) * 0.225).toFixed(0)} per car</p>
|
||||
<p className="mt-2 pt-2 border-t border-blue-200">Korean Domestic + Export Customs: ₩{formData.domestic_export_customs_krw.toLocaleString()}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -3,22 +3,39 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { visitorApi, VisitorStatsOverview, TopPage, TopReferrer, ChartData, RealtimeStats } from '@/lib/api';
|
||||
|
||||
// Country code to name mapping
|
||||
const countryNames: Record<string, string> = {
|
||||
MN: 'Mongolia',
|
||||
RU: 'Russia',
|
||||
KR: 'Korea',
|
||||
US: 'United States',
|
||||
CN: 'China',
|
||||
JP: 'Japan',
|
||||
DE: 'Germany',
|
||||
FR: 'France',
|
||||
GB: 'United Kingdom',
|
||||
AU: 'Australia',
|
||||
unknown: 'Unknown',
|
||||
LO: 'Local',
|
||||
// Country code to name and flag mapping
|
||||
const countryInfo: Record<string, { name: string; flag: string }> = {
|
||||
MN: { name: 'Mongolia', flag: '🇲🇳' },
|
||||
RU: { name: 'Russia', flag: '🇷🇺' },
|
||||
KR: { name: 'Korea', flag: '🇰🇷' },
|
||||
US: { name: 'United States', flag: '🇺🇸' },
|
||||
CN: { name: 'China', flag: '🇨🇳' },
|
||||
JP: { name: 'Japan', flag: '🇯🇵' },
|
||||
DE: { name: 'Germany', flag: '🇩🇪' },
|
||||
FR: { name: 'France', flag: '🇫🇷' },
|
||||
GB: { name: 'United Kingdom', flag: '🇬🇧' },
|
||||
AU: { name: 'Australia', flag: '🇦🇺' },
|
||||
CA: { name: 'Canada', flag: '🇨🇦' },
|
||||
IN: { name: 'India', flag: '🇮🇳' },
|
||||
SG: { name: 'Singapore', flag: '🇸🇬' },
|
||||
HK: { name: 'Hong Kong', flag: '🇭🇰' },
|
||||
TW: { name: 'Taiwan', flag: '🇹🇼' },
|
||||
VN: { name: 'Vietnam', flag: '🇻🇳' },
|
||||
TH: { name: 'Thailand', flag: '🇹🇭' },
|
||||
MY: { name: 'Malaysia', flag: '🇲🇾' },
|
||||
ID: { name: 'Indonesia', flag: '🇮🇩' },
|
||||
PH: { name: 'Philippines', flag: '🇵🇭' },
|
||||
KZ: { name: 'Kazakhstan', flag: '🇰🇿' },
|
||||
UZ: { name: 'Uzbekistan', flag: '🇺🇿' },
|
||||
UA: { name: 'Ukraine', flag: '🇺🇦' },
|
||||
unknown: { name: 'Unknown', flag: '🌐' },
|
||||
LO: { name: 'Local', flag: '🏠' },
|
||||
};
|
||||
|
||||
const countryNames: Record<string, string> = Object.fromEntries(
|
||||
Object.entries(countryInfo).map(([code, info]) => [code, info.name])
|
||||
);
|
||||
|
||||
// Simple bar chart component
|
||||
const SimpleBarChart = ({ data, color = 'bg-primary-500', height = 120 }: {
|
||||
data: ChartData | null;
|
||||
@@ -104,6 +121,61 @@ const BreakdownCard = ({ title, data, icon, nameMap }: {
|
||||
);
|
||||
};
|
||||
|
||||
// Country Stats Card with flags
|
||||
const CountryStatsCard = ({ data }: { data: Record<string, number> }) => {
|
||||
const total = Object.values(data).reduce((a, b) => a + b, 0);
|
||||
const sortedEntries = Object.entries(data).sort((a, b) => b[1] - a[1]).slice(0, 10);
|
||||
|
||||
if (total === 0) {
|
||||
return (
|
||||
<div className="bg-white rounded-xl shadow-sm p-6">
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<span className="text-2xl">🌍</span>
|
||||
<h3 className="text-lg font-semibold text-gray-800">Visitors by Country</h3>
|
||||
</div>
|
||||
<div className="text-gray-400 text-center py-8">No country data available</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-xl shadow-sm p-6">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-2xl">🌍</span>
|
||||
<h3 className="text-lg font-semibold text-gray-800">Visitors by Country</h3>
|
||||
</div>
|
||||
<span className="text-sm text-gray-500">Total: {total.toLocaleString()}</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{sortedEntries.map(([code, value], index) => {
|
||||
const info = countryInfo[code] || { name: code, flag: '🌐' };
|
||||
const percentage = ((value / total) * 100).toFixed(1);
|
||||
|
||||
return (
|
||||
<div key={code} className="flex items-center gap-3 p-3 bg-gray-50 rounded-lg hover:bg-gray-100 transition-colors">
|
||||
<span className="text-2xl">{info.flag}</span>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="font-medium text-gray-800 truncate">{info.name}</span>
|
||||
<span className="text-sm font-semibold text-primary-600">{value.toLocaleString()}</span>
|
||||
</div>
|
||||
<div className="w-full bg-gray-200 rounded-full h-2 mt-1">
|
||||
<div
|
||||
className="h-2 rounded-full bg-gradient-to-r from-primary-400 to-primary-600"
|
||||
style={{ width: `${(value / sortedEntries[0][1]) * 100}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-xs text-gray-500">{percentage}% of total</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default function VisitorStatsPage() {
|
||||
const [overview, setOverview] = useState<VisitorStatsOverview | null>(null);
|
||||
const [visitsChart, setVisitsChart] = useState<ChartData | null>(null);
|
||||
@@ -315,8 +387,11 @@ export default function VisitorStatsPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Breakdowns */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
{/* Country Stats - Full Width */}
|
||||
<CountryStatsCard data={overview?.country_breakdown || {}} />
|
||||
|
||||
{/* Device & Browser Breakdowns */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<BreakdownCard
|
||||
title="By Device"
|
||||
data={overview?.device_breakdown || {}}
|
||||
@@ -327,12 +402,6 @@ export default function VisitorStatsPage() {
|
||||
data={overview?.browser_breakdown || {}}
|
||||
icon="🌐"
|
||||
/>
|
||||
<BreakdownCard
|
||||
title="By Country"
|
||||
data={overview?.country_breakdown || {}}
|
||||
icon="🌍"
|
||||
nameMap={countryNames}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Tables */}
|
||||
|
||||
@@ -51,10 +51,26 @@ export default function CarDetailPage() {
|
||||
// Collapsible sections
|
||||
const [showDealerComment, setShowDealerComment] = useState(false);
|
||||
|
||||
// System settings
|
||||
const [showDealerCommentSetting, setShowDealerCommentSetting] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
if (params.id) {
|
||||
loadCar(Number(params.id));
|
||||
}
|
||||
// Fetch system settings for dealer comment visibility
|
||||
const fetchSettings = async () => {
|
||||
try {
|
||||
const response = await fetch(`${API_BASE_URL}/api/settings/`);
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
setShowDealerCommentSetting(data.show_dealer_comment ?? true);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch settings:', error);
|
||||
}
|
||||
};
|
||||
fetchSettings();
|
||||
}, [params.id]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -458,7 +474,7 @@ export default function CarDetailPage() {
|
||||
)}
|
||||
|
||||
{/* Dealer's Comment - Left Column (Collapsible) - Requires 0.1 CC */}
|
||||
{hasPerformanceCheckAccess && car.dealer_description && (
|
||||
{showDealerCommentSetting && hasPerformanceCheckAccess && car.dealer_description && (
|
||||
<div className="mt-4 bg-amber-50 border border-amber-200 rounded-lg overflow-hidden">
|
||||
<button
|
||||
onClick={() => setShowDealerComment(!showDealerComment)}
|
||||
@@ -921,7 +937,7 @@ export default function CarDetailPage() {
|
||||
)}
|
||||
|
||||
{/* Unlock section for banner cars without performance check access */}
|
||||
{!performanceCheck?.found && isBannerCar && !hasPerformanceCheckAccess && car.dealer_description && (
|
||||
{showDealerCommentSetting && !performanceCheck?.found && isBannerCar && !hasPerformanceCheckAccess && car.dealer_description && (
|
||||
<div className="bg-white rounded-lg shadow p-6 mb-6">
|
||||
<h2 className="text-lg font-semibold mb-4 flex items-center">
|
||||
<svg className="w-5 h-5 mr-2 text-blue-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
|
||||
@@ -9,7 +9,6 @@ import SidebarLayout from '@/components/SidebarLayout';
|
||||
import { useAuthStore } from '@/lib/store';
|
||||
|
||||
// Cost constants
|
||||
const DOMESTIC_COST_KRW = 1150000; // ₩1,150,000
|
||||
const KOREAN_FEE_PERCENT = 5; // 5% of vehicle price
|
||||
const MONGOLIAN_FEE_PERCENT = 5; // 5% of vehicle price
|
||||
const CUSTOMS_FEE_USD = 200; // $200
|
||||
@@ -35,6 +34,7 @@ interface ContainerSlot {
|
||||
interface Settings {
|
||||
container_logistics_usd: number;
|
||||
shoring_cost_usd: number;
|
||||
domestic_export_customs_krw: number;
|
||||
}
|
||||
|
||||
export default function CostPage() {
|
||||
@@ -91,6 +91,7 @@ export default function CostPage() {
|
||||
const [settings, setSettings] = useState<Settings>({
|
||||
container_logistics_usd: 3600,
|
||||
shoring_cost_usd: 300,
|
||||
domestic_export_customs_krw: 1150000,
|
||||
});
|
||||
|
||||
// Calculator state
|
||||
@@ -122,6 +123,7 @@ export default function CostPage() {
|
||||
setSettings({
|
||||
container_logistics_usd: data.container_logistics_usd || 3600,
|
||||
shoring_cost_usd: data.shoring_cost_usd || 300,
|
||||
domestic_export_customs_krw: data.domestic_export_customs_krw || 1150000,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -215,7 +217,7 @@ export default function CostPage() {
|
||||
const usdToKrw = useExchangeRateStore.getState().rates.USD?.rate || 1483;
|
||||
|
||||
// Korean domestic cost
|
||||
const domesticCost = DOMESTIC_COST_KRW;
|
||||
const domesticCost = settings.domestic_export_customs_krw;
|
||||
|
||||
// Korean fee (5% of vehicle price)
|
||||
const koreanFee = priceKrw * (KOREAN_FEE_PERCENT / 100);
|
||||
@@ -346,7 +348,7 @@ export default function CostPage() {
|
||||
</div>
|
||||
<div className="flex justify-between items-center py-2 border-b">
|
||||
<span className="text-gray-600">{t.koreanDomesticCost}</span>
|
||||
<span className="font-medium text-primary-600">{formatLocalCurrency(DOMESTIC_COST_KRW)}</span>
|
||||
<span className="font-medium text-primary-600">{formatLocalCurrency(settings.domestic_export_customs_krw)}</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center py-2 border-b">
|
||||
<span className="text-gray-600">{t.koreanMargin}</span>
|
||||
|
||||
Reference in New Issue
Block a user