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:
270
frontend/src/app/settings/notifications/page.tsx
Normal file
270
frontend/src/app/settings/notifications/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user