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:
AutonetSellCar Deploy
2025-12-30 13:24:39 +09:00
commit 1f0dcb1ddb
224 changed files with 55119 additions and 0 deletions

View File

@@ -0,0 +1,270 @@
'use client';
import { useState, useEffect } from 'react';
import { useRouter } from 'next/navigation';
import { useAuthStore } from '@/lib/store';
import { useTranslation } from '@/lib/i18n';
import { pushApi, NotificationPreferences } from '@/lib/api';
// Helper to convert base64 URL to Uint8Array
function urlBase64ToUint8Array(base64String: string) {
const padding = '='.repeat((4 - base64String.length % 4) % 4);
const base64 = (base64String + padding)
.replace(/-/g, '+')
.replace(/_/g, '/');
const rawData = window.atob(base64);
const outputArray = new Uint8Array(rawData.length);
for (let i = 0; i < rawData.length; ++i) {
outputArray[i] = rawData.charCodeAt(i);
}
return outputArray;
}
export default function NotificationSettingsPage() {
const { user } = useAuthStore();
const { language } = useTranslation();
const router = useRouter();
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [pushSupported, setPushSupported] = useState(false);
const [pushPermission, setPushPermission] = useState<NotificationPermission>('default');
const [isSubscribed, setIsSubscribed] = useState(false);
const [preferences, setPreferences] = useState<NotificationPreferences | null>(null);
useEffect(() => {
if (!user) {
router.push('/login');
return;
}
checkPushSupport();
loadPreferences();
}, [user, router]);
const checkPushSupport = async () => {
if ('serviceWorker' in navigator && 'PushManager' in window) {
setPushSupported(true);
setPushPermission(Notification.permission);
// Check if already subscribed
const registration = await navigator.serviceWorker.ready;
const subscription = await registration.pushManager.getSubscription();
setIsSubscribed(!!subscription);
}
};
const loadPreferences = async () => {
try {
const prefs = await pushApi.getPreferences();
setPreferences(prefs);
} catch (error) {
console.error('Failed to load preferences:', error);
} finally {
setLoading(false);
}
};
const requestPermission = async () => {
const permission = await Notification.requestPermission();
setPushPermission(permission);
return permission;
};
const subscribeToPush = async () => {
try {
// Request permission if needed
if (pushPermission !== 'granted') {
const permission = await requestPermission();
if (permission !== 'granted') {
alert(language === 'ko' ? '알림 권한이 필요합니다.' : 'Notification permission is required.');
return;
}
}
// Register service worker
const registration = await navigator.serviceWorker.register('/sw.js');
await navigator.serviceWorker.ready;
// Get VAPID public key
const { public_key } = await pushApi.getVapidKey();
// Subscribe to push
const subscription = await registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: urlBase64ToUint8Array(public_key)
});
// Send subscription to server
const json = subscription.toJSON();
await pushApi.subscribe({
endpoint: json.endpoint!,
p256dh_key: json.keys!.p256dh,
auth_key: json.keys!.auth,
device_info: navigator.userAgent.slice(0, 100)
});
setIsSubscribed(true);
alert(language === 'ko' ? '푸시 알림이 활성화되었습니다.' : 'Push notifications enabled.');
} catch (error) {
console.error('Failed to subscribe:', error);
alert(language === 'ko' ? '푸시 알림 등록에 실패했습니다.' : 'Failed to enable push notifications.');
}
};
const unsubscribeFromPush = async () => {
try {
const registration = await navigator.serviceWorker.ready;
const subscription = await registration.pushManager.getSubscription();
if (subscription) {
await subscription.unsubscribe();
await pushApi.unsubscribe(subscription.endpoint);
}
setIsSubscribed(false);
alert(language === 'ko' ? '푸시 알림이 비활성화되었습니다.' : 'Push notifications disabled.');
} catch (error) {
console.error('Failed to unsubscribe:', error);
}
};
const updatePreference = async (key: keyof NotificationPreferences, value: boolean) => {
if (!preferences) return;
setSaving(true);
try {
await pushApi.updatePreferences({ [key]: value });
setPreferences({ ...preferences, [key]: value });
} catch (error) {
console.error('Failed to update preference:', error);
} finally {
setSaving(false);
}
};
if (!user) return null;
if (loading) {
return (
<div className="min-h-screen bg-gray-50 flex items-center justify-center">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-primary-500"></div>
</div>
);
}
const notificationTypes = [
{ key: 'vehicle_recommended', label: language === 'ko' ? '차량 추천' : 'Vehicle Recommendations', desc: language === 'ko' ? '추천 차량이 등록되었을 때' : 'When recommended vehicles are added' },
{ key: 'shipping_update', label: language === 'ko' ? '배송 업데이트' : 'Shipping Updates', desc: language === 'ko' ? '배송 상태가 변경되었을 때' : 'When shipping status changes' },
{ key: 'payment_confirmed', label: language === 'ko' ? '결제 확인' : 'Payment Confirmed', desc: language === 'ko' ? '결제가 확인되었을 때' : 'When payment is confirmed' },
{ key: 'withdrawal_processed', label: language === 'ko' ? '출금 처리' : 'Withdrawal Processed', desc: language === 'ko' ? '출금이 처리되었을 때' : 'When withdrawal is processed' },
{ key: 'dealer_status', label: language === 'ko' ? '딜러 상태' : 'Dealer Status', desc: language === 'ko' ? '딜러 신청 결과' : 'Dealer application result' },
{ key: 'share_purchased', label: language === 'ko' ? '공유 구매' : 'Share Purchased', desc: language === 'ko' ? '공유한 차량이 구매되었을 때' : 'When shared vehicle is purchased' },
{ key: 'referral_reward', label: language === 'ko' ? '추천 보상' : 'Referral Reward', desc: language === 'ko' ? '추천 보상을 받았을 때' : 'When you receive referral reward' },
{ key: 'inquiry_reply', label: language === 'ko' ? '문의 답변' : 'Inquiry Reply', desc: language === 'ko' ? '문의에 답변이 달렸을 때' : 'When your inquiry is replied' },
{ key: 'system_announcements', label: language === 'ko' ? '시스템 공지' : 'System Announcements', desc: language === 'ko' ? '중요 공지사항' : 'Important announcements' },
] as const;
return (
<div className="min-h-screen bg-gray-50 py-8">
<div className="max-w-2xl mx-auto px-4">
<h1 className="text-2xl font-bold text-gray-800 mb-6">
{language === 'ko' ? '알림 설정' : 'Notification Settings'}
</h1>
{/* Push Notification Toggle */}
<div className="bg-white rounded-xl shadow-sm p-6 mb-6">
<h2 className="text-lg font-semibold mb-4">
{language === 'ko' ? '푸시 알림' : 'Push Notifications'}
</h2>
{!pushSupported ? (
<div className="text-gray-500">
{language === 'ko' ? '이 브라우저는 푸시 알림을 지원하지 않습니다.' : 'This browser does not support push notifications.'}
</div>
) : (
<div className="space-y-4">
<div className="flex items-center justify-between">
<div>
<p className="font-medium">
{language === 'ko' ? '브라우저 푸시 알림' : 'Browser Push Notifications'}
</p>
<p className="text-sm text-gray-500">
{pushPermission === 'granted'
? (language === 'ko' ? '알림 권한 허용됨' : 'Permission granted')
: pushPermission === 'denied'
? (language === 'ko' ? '알림 권한 거부됨 (브라우저 설정에서 변경 필요)' : 'Permission denied (change in browser settings)')
: (language === 'ko' ? '알림 권한 필요' : 'Permission required')}
</p>
</div>
{pushPermission !== 'denied' && (
<button
onClick={isSubscribed ? unsubscribeFromPush : subscribeToPush}
className={`px-4 py-2 rounded-lg font-medium transition ${
isSubscribed
? 'bg-red-100 text-red-600 hover:bg-red-200'
: 'bg-primary-500 text-white hover:bg-primary-600'
}`}
>
{isSubscribed
? (language === 'ko' ? '비활성화' : 'Disable')
: (language === 'ko' ? '활성화' : 'Enable')}
</button>
)}
</div>
{isSubscribed && (
<div className="text-sm text-green-600 flex items-center gap-2">
<span></span>
<span>{language === 'ko' ? '푸시 알림이 활성화되어 있습니다.' : 'Push notifications are enabled.'}</span>
</div>
)}
</div>
)}
</div>
{/* Notification Type Preferences */}
{preferences && (
<div className="bg-white rounded-xl shadow-sm p-6">
<h2 className="text-lg font-semibold mb-4">
{language === 'ko' ? '알림 유형' : 'Notification Types'}
</h2>
<p className="text-sm text-gray-500 mb-4">
{language === 'ko' ? '받고 싶은 알림 유형을 선택하세요.' : 'Choose which notifications you want to receive.'}
</p>
<div className="space-y-4">
{notificationTypes.map(({ key, label, desc }) => (
<div key={key} className="flex items-center justify-between py-3 border-b last:border-b-0">
<div>
<p className="font-medium">{label}</p>
<p className="text-sm text-gray-500">{desc}</p>
</div>
<label className="relative inline-flex items-center cursor-pointer">
<input
type="checkbox"
checked={preferences[key]}
onChange={(e) => updatePreference(key, e.target.checked)}
disabled={saving}
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 peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[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-500"></div>
</label>
</div>
))}
</div>
</div>
)}
{/* Back button */}
<div className="mt-6">
<button
onClick={() => router.back()}
className="text-gray-600 hover:text-gray-800"
>
{language === 'ko' ? '뒤로' : 'Back'}
</button>
</div>
</div>
</div>
);
}