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 */}
|
||||
|
||||
Reference in New Issue
Block a user