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:
AutonetSellCar Deploy
2025-12-31 18:10:27 +09:00
parent be7f12bbf3
commit 2a8e32f427
8 changed files with 230 additions and 73 deletions

View File

@@ -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>

View File

@@ -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="&#127760;"
/>
<BreakdownCard
title="By Country"
data={overview?.country_breakdown || {}}
icon="&#127757;"
nameMap={countryNames}
/>
</div>
{/* Tables */}