From 2a8e32f42711c9a8f861b762c44c888c2c0cd36a Mon Sep 17 00:00:00 2001 From: AutonetSellCar Deploy Date: Wed, 31 Dec 2025 18:10:27 +0900 Subject: [PATCH] feat: Add admin settings for dealer comment & domestic cost, enhance visitor country stats MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- CLAUDE.md | 5 + backend/app/api/hero_banners.py | 93 +++++++------- backend/app/models/settings.py | 6 + backend/app/schemas/settings.py | 10 ++ frontend/src/app/admin/settings/page.tsx | 48 ++++++++ frontend/src/app/admin/visitor-stats/page.tsx | 113 ++++++++++++++---- frontend/src/app/cars/[id]/page.tsx | 20 +++- frontend/src/app/cost/page.tsx | 8 +- 8 files changed, 230 insertions(+), 73 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index acd18b8..d7d33f5 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -660,6 +660,11 @@ Import ์‹œ ์ž๋™ ๋ฒˆ์—ญ (Azure API) | ๋‚ ์งœ | ๋ณ€๊ฒฝ ๋‚ด์šฉ | |------|----------| +| 2024-12-31 | **Admin Settings ๊ธฐ๋Šฅ ์ถ”๊ฐ€**: Show Dealer Comment ํ† ๊ธ€, Korean Domestic + Export Customs ๊ธˆ์•ก ์„ค์ • | +| 2024-12-31 | Cost ํŽ˜์ด์ง€์—์„œ ๊ตญ๋‚ด๋น„์šฉ+์ˆ˜์ถœํ†ต๊ด€๋น„์šฉ ๋™์  ์ ์šฉ (settings API ์—ฐ๋™) | +| 2024-12-31 | **Visitor Stats ๊ตญ๊ฐ€๋ณ„ ํ†ต๊ณ„ ๊ฐ•ํ™”**: ๊ตญ๊ธฐ ์ด๋ชจ์ง€ ์ถ”๊ฐ€, ์ „์šฉ Country Stats ์นด๋“œ ์ถ”๊ฐ€ | +| 2024-12-31 | Hero Banners API ๋ผ์šฐํŠธ ์ˆœ์„œ ์ˆ˜์ • (422 ์—๋Ÿฌ ํ•ด๊ฒฐ) | +| 2024-12-31 | Banner Toggle ๋กœ์ง ์ˆ˜์ • (HeroBanner ํ…Œ์ด๋ธ” ๊ธฐ์ค€์œผ๋กœ ๋ณ€๊ฒฝ) | | 2024-12-27 | **๋”œ๋Ÿฌ ์„ค๋ช… ๋ฒˆ์—ญ ์‹œ์Šคํ…œ ์ถ”๊ฐ€**: Azure Translator API ์—ฐ๋™, ํ•œ๊ตญ์–ดโ†’์˜์–ด/๋ชฝ๊ณจ์–ด/๋Ÿฌ์‹œ์•„์–ด ์ง์ ‘ ๋ฒˆ์—ญ | | 2024-12-27 | ๊ด€๋ฆฌ์ž ๋ฒˆ์—ญ ๊ด€๋ฆฌ ํŽ˜์ด์ง€ ์ถ”๊ฐ€ (`/admin/dealer-translations`) | | 2024-12-27 | DB ์Šคํ‚ค๋งˆ ํ™•์žฅ: `dealer_description_en/mn/ru` ์ปฌ๋Ÿผ ์ถ”๊ฐ€ | diff --git a/backend/app/api/hero_banners.py b/backend/app/api/hero_banners.py index a28fc52..4dbf8c7 100644 --- a/backend/app/api/hero_banners.py +++ b/backend/app/api/hero_banners.py @@ -143,6 +143,46 @@ def get_banner_cars( } +@router.put("/admin/reorder") +def reorder_banners( + request: BannerReorderRequest, + db: Session = Depends(get_db), + current_user: User = Depends(get_admin_user) +): + """๋ฐฐ๋„ˆ ์ˆœ์„œ ์žฌ์ •๋ ฌ (Admin) + + car_ids: ๋ฐฐ๋„ˆ ์ฐจ๋Ÿ‰ ID ๋ชฉ๋ก (์›ํ•˜๋Š” ์ˆœ์„œ๋Œ€๋กœ) + """ + for order, car_id in enumerate(request.car_ids): + banner = db.query(HeroBanner).filter(HeroBanner.car_id == car_id).first() + if banner: + banner.display_order = order + + db.commit() + return {"message": "Banner order updated", "count": len(request.car_ids)} + + +@router.put("/admin/settings", response_model=HeroBannerSettingsResponse) +def update_banner_settings( + settings_data: HeroBannerSettingsUpdate, + db: Session = Depends(get_db), + current_user: User = Depends(get_admin_user) +): + """๋ฐฐ๋„ˆ ์Šฌ๋ผ์ด๋” ์„ค์ • ์ˆ˜์ • (Admin)""" + settings_obj = db.query(HeroBannerSettings).first() + if not settings_obj: + settings_obj = HeroBannerSettings() + db.add(settings_obj) + + update_data = settings_data.model_dump(exclude_unset=True) + for field, value in update_data.items(): + setattr(settings_obj, field, value) + + db.commit() + db.refresh(settings_obj) + return settings_obj + + @router.get("/admin/{banner_id}", response_model=HeroBannerResponse) def admin_get_banner( banner_id: int, @@ -218,27 +258,6 @@ def delete_banner( return {"message": "Banner deleted successfully"} -@router.put("/admin/settings", response_model=HeroBannerSettingsResponse) -def update_banner_settings( - settings_data: HeroBannerSettingsUpdate, - db: Session = Depends(get_db), - current_user: User = Depends(get_admin_user) -): - """๋ฐฐ๋„ˆ ์Šฌ๋ผ์ด๋” ์„ค์ • ์ˆ˜์ • (Admin)""" - settings_obj = db.query(HeroBannerSettings).first() - if not settings_obj: - settings_obj = HeroBannerSettings() - db.add(settings_obj) - - update_data = settings_data.model_dump(exclude_unset=True) - for field, value in update_data.items(): - setattr(settings_obj, field, value) - - db.commit() - db.refresh(settings_obj) - return settings_obj - - # ==================== Image Upload ==================== @router.post("/admin/upload-image") @@ -297,18 +316,19 @@ def toggle_banner( ): """์ฐจ๋Ÿ‰์˜ ๋ฐฐ๋„ˆ ์ƒํƒœ ํ† ๊ธ€ (Admin) - - is_banner=False โ†’ True: HeroBanner ์ƒ์„ฑ - - is_banner=True โ†’ False: HeroBanner ์‚ญ์ œ + - HeroBanner ์กด์žฌ โ†’ ์‚ญ์ œ + - HeroBanner ์—†์Œ โ†’ ์ƒ์„ฑ """ car = db.query(Car).filter(Car.id == car_id).first() if not car: raise HTTPException(status_code=404, detail="Car not found") - if car.is_banner: + # HeroBanner ํ…Œ์ด๋ธ”์„ ๊ธฐ์ค€์œผ๋กœ ํŒ๋‹จ (car.is_banner ํ•„๋“œ ๋Œ€์‹ ) + existing_banner = db.query(HeroBanner).filter(HeroBanner.car_id == car_id).first() + + if existing_banner: # ๋ฐฐ๋„ˆ์—์„œ ์ œ๊ฑฐ - banner = db.query(HeroBanner).filter(HeroBanner.car_id == car_id).first() - if banner: - db.delete(banner) + db.delete(existing_banner) car.is_banner = False db.commit() return {"car_id": car_id, "is_banner": False, "message": "Removed from banner"} @@ -341,22 +361,3 @@ def toggle_banner( db.commit() db.refresh(banner) return {"car_id": car_id, "is_banner": True, "banner_id": banner.id, "message": "Added to banner"} - - -@router.put("/admin/reorder") -def reorder_banners( - request: BannerReorderRequest, - db: Session = Depends(get_db), - current_user: User = Depends(get_admin_user) -): - """๋ฐฐ๋„ˆ ์ˆœ์„œ ์žฌ์ •๋ ฌ (Admin) - - car_ids: ๋ฐฐ๋„ˆ ์ฐจ๋Ÿ‰ ID ๋ชฉ๋ก (์›ํ•˜๋Š” ์ˆœ์„œ๋Œ€๋กœ) - """ - for order, car_id in enumerate(request.car_ids): - banner = db.query(HeroBanner).filter(HeroBanner.car_id == car_id).first() - if banner: - banner.display_order = order - - db.commit() - return {"message": "Banner order updated", "count": len(request.car_ids)} diff --git a/backend/app/models/settings.py b/backend/app/models/settings.py index f39681d..4d6186a 100644 --- a/backend/app/models/settings.py +++ b/backend/app/models/settings.py @@ -29,6 +29,12 @@ class SystemSettings(Base): container_logistics_usd = Column(Integer, default=3600) # ์ปจํ…Œ์ด๋„ˆ ๋ฌผ๋ฅ˜๋น„ $3,600 shoring_cost_usd = Column(Integer, default=300) # ์‡ผ๋ง๋น„ (์ปจํ…Œ์ด๋„ˆ ๊ณ ์ •) $300 + # ๊ตญ๋‚ด๋น„์šฉ + ์ˆ˜์ถœํ†ต๊ด€๋น„์šฉ (KRW) + domestic_export_customs_krw = Column(Integer, default=1150000) # โ‚ฉ1,150,000 + + # ๋”œ๋Ÿฌ ์ฝ”๋ฉ˜ํŠธ ํ‘œ์‹œ ์„ค์ • + show_dealer_comment = Column(Boolean, default=True) # ๋”œ๋Ÿฌ ์ฝ”๋ฉ˜ํŠธ ํ‘œ์‹œ ์—ฌ๋ถ€ + # ๋ ˆํผ๋Ÿด ๋ณด์ƒ ์„ค์ • referral_reward_enabled = Column(Boolean, default=True) # ๋ ˆํผ๋Ÿด ๋ณด์ƒ ํ™œ์„ฑํ™” referral_reward_percent = Column(Float, default=10.0) # ๋ณด์ƒ ๋น„์œจ (๊ธฐ๋ณธ 10%) diff --git a/backend/app/schemas/settings.py b/backend/app/schemas/settings.py index d08a75b..93d3267 100644 --- a/backend/app/schemas/settings.py +++ b/backend/app/schemas/settings.py @@ -15,6 +15,11 @@ class SystemSettingsUpdate(BaseModel): cache_ttl_hours: Optional[int] = None container_logistics_usd: Optional[int] = None shoring_cost_usd: Optional[int] = None + domestic_export_customs_krw: Optional[int] = None + show_dealer_comment: Optional[bool] = None + referral_reward_enabled: Optional[bool] = None + referral_reward_percent: Optional[float] = None + referral_reward_type: Optional[str] = None class SystemSettingsResponse(BaseModel): @@ -30,6 +35,11 @@ class SystemSettingsResponse(BaseModel): cache_ttl_hours: int container_logistics_usd: int shoring_cost_usd: int + domestic_export_customs_krw: int = 1150000 + show_dealer_comment: bool = True + referral_reward_enabled: bool = True + referral_reward_percent: float = 10.0 + referral_reward_type: str = "one_time" created_at: Optional[datetime] = None updated_at: Optional[datetime] = None diff --git a/frontend/src/app/admin/settings/page.tsx b/frontend/src/app/admin/settings/page.tsx index a7cde09..98085ed 100644 --- a/frontend/src/app/admin/settings/page.tsx +++ b/frontend/src/app/admin/settings/page.tsx @@ -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() { + {/* Display Settings */} +
+

+ Display Settings +

+ +
+
+ +
+ Show Dealer Comment +

์ฐจ๋Ÿ‰ ์ƒ์„ธ ํŽ˜์ด์ง€์—์„œ ๋”œ๋Ÿฌ ์ฝ”๋ฉ˜ํŠธ ํ‘œ์‹œ ์—ฌ๋ถ€

+
+
+
+
+ {/* Margin Settings */}

@@ -388,6 +419,22 @@ export default function SettingsPage() { />

์‡ผ๋ง๋น„ - ์ปจํ…Œ์ด๋„ˆ ๊ณ ์ • ๋น„์šฉ (๊ธฐ๋ณธ๊ฐ’: $300)

+ +
+ + 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" + /> +

๊ตญ๋‚ด๋น„์šฉ + ์ˆ˜์ถœํ†ต๊ด€๋น„์šฉ (๊ธฐ๋ณธ๊ฐ’: โ‚ฉ1,150,000)

+
@@ -396,6 +443,7 @@ export default function SettingsPage() {

Total Container Cost: ${formData.container_logistics_usd + formData.shoring_cost_usd}

Small Car (5.5/10): ${((formData.container_logistics_usd + formData.shoring_cost_usd) * 0.275).toFixed(0)} per car

Compact Car (4.5/10): ${((formData.container_logistics_usd + formData.shoring_cost_usd) * 0.225).toFixed(0)} per car

+

Korean Domestic + Export Customs: โ‚ฉ{formData.domestic_export_customs_krw.toLocaleString()}

diff --git a/frontend/src/app/admin/visitor-stats/page.tsx b/frontend/src/app/admin/visitor-stats/page.tsx index 4c8f420..83b083b 100644 --- a/frontend/src/app/admin/visitor-stats/page.tsx +++ b/frontend/src/app/admin/visitor-stats/page.tsx @@ -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 = { - 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 = { + 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 = 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 }) => { + 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 ( +
+
+ ๐ŸŒ +

Visitors by Country

+
+
No country data available
+
+ ); + } + + return ( +
+
+
+ ๐ŸŒ +

Visitors by Country

+
+ Total: {total.toLocaleString()} +
+
+ {sortedEntries.map(([code, value], index) => { + const info = countryInfo[code] || { name: code, flag: '๐ŸŒ' }; + const percentage = ((value / total) * 100).toFixed(1); + + return ( +
+ {info.flag} +
+
+ {info.name} + {value.toLocaleString()} +
+
+
+
+ {percentage}% of total +
+
+ ); + })} +
+
+ ); +}; + export default function VisitorStatsPage() { const [overview, setOverview] = useState(null); const [visitsChart, setVisitsChart] = useState(null); @@ -315,8 +387,11 @@ export default function VisitorStatsPage() {
- {/* Breakdowns */} -
+ {/* Country Stats - Full Width */} + + + {/* Device & Browser Breakdowns */} +
-
{/* Tables */} diff --git a/frontend/src/app/cars/[id]/page.tsx b/frontend/src/app/cars/[id]/page.tsx index 13debf6..1b67734 100644 --- a/frontend/src/app/cars/[id]/page.tsx +++ b/frontend/src/app/cars/[id]/page.tsx @@ -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 && (