import { useState, useEffect, type FormEvent } from 'react'; import WatchlistItemCard, { type WatchlistItem } from './WatchlistItemCard'; import AdvancedSizesSection from './AdvancedSizesSection'; // ── Types ──────────────────────────────────────────────────────────────── interface UserProfile { id: string; email: string; first_name: string | null; last_name: string | null; shoe_size: string | null; gender_preference: string; alert_threshold_pct: number; alert_frequency: string; alert_day_of_week: string; notify_back_in_stock: boolean; notify_weekly_digest: boolean; notify_new_releases: boolean; paused_until: string | null; budget_max: number | null; email_verified: boolean; } // ── LoginForm ──────────────────────────────────────────────────────────── function LoginForm() { const [email, setEmail] = useState(''); const [loading, setLoading] = useState(false); const [message, setMessage] = useState(''); const handleSubmit = async (e: FormEvent) => { e.preventDefault(); setLoading(true); setMessage(''); try { const res = await fetch('/api/auth/register', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ email }), }); const data = await res.json(); if (data.ok) { setMessage('Check je email voor de bevestigingslink!'); } else if (data.error?.includes('al geregistreerd')) { const loginRes = await fetch('/api/auth/login', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ email }), }); const loginData = await loginRes.json(); setMessage(loginData.message || 'Check je email voor de inloglink!'); } else { setMessage(data.error || 'Er ging iets mis'); } } catch { setMessage('Er ging iets mis'); } setLoading(false); }; return (

Mijn Profiel

Log in om je voorkeuren, watchlist en e-mailinstellingen te beheren.

{/* Google login */} Inloggen met Google
of via e-mail
setEmail(e.target.value)} placeholder="je@email.nl" required className="mb-3 w-full rounded-lg border border-border/30 bg-bg-elevated px-4 py-3 text-text placeholder-text-muted focus:border-accent/50 focus:outline-none" /> {message && (

{message}

)}

Via e-mail ontvang je een magic link — geen wachtwoord nodig.

); } // ── Tabs ────────────────────────────────────────────────────────────────── const TABS = [ { id: 'profiel', label: 'Profiel', icon: 'M15.75 6a3.75 3.75 0 11-7.5 0 3.75 3.75 0 017.5 0zM4.501 20.118a7.5 7.5 0 0114.998 0A17.933 17.933 0 0112 21.75c-2.676 0-5.216-.584-7.499-1.632z' }, { id: 'watchlist', label: 'Watchlist', icon: 'M21 8.25c0-2.485-2.099-4.5-4.688-4.5-1.935 0-3.597 1.126-4.312 2.733-.715-1.607-2.377-2.733-4.313-2.733C5.1 3.75 3 5.765 3 8.25c0 7.22 9 12 9 12s9-4.78 9-12z' }, { id: 'email', label: 'E-mail', icon: 'M21.75 6.75v10.5a2.25 2.25 0 01-2.25 2.25h-15a2.25 2.25 0 01-2.25-2.25V6.75m19.5 0A2.25 2.25 0 0019.5 4.5h-15a2.25 2.25 0 00-2.25 2.25m19.5 0v.243a2.25 2.25 0 01-1.07 1.916l-7.5 4.615a2.25 2.25 0 01-2.36 0L3.32 8.91a2.25 2.25 0 01-1.07-1.916V6.75' }, ] as const; type TabId = typeof TABS[number]['id']; const SIZES = ['35', '36', '36.5', '37', '37.5', '38', '38.5', '39', '39.5', '40', '40.5', '41', '41.5', '42', '42.5', '43', '43.5', '44', '44.5', '45', '45.5', '46', '46.5', '47', '47.5', '48', '49', '50']; const WEEKDAYS = [ { value: 'monday', short: 'Ma' }, { value: 'tuesday', short: 'Di' }, { value: 'wednesday', short: 'Wo' }, { value: 'thursday', short: 'Do' }, { value: 'friday', short: 'Vr' }, { value: 'saturday', short: 'Za' }, { value: 'sunday', short: 'Zo' }, ]; // ── Toast helper ───────────────────────────────────────────────────────── function showToast(msg: string, type: 'success' | 'info' | 'error' = 'success') { if (typeof window !== 'undefined' && (window as any).__showToast) { (window as any).__showToast(msg, type); } } // ── Main Component ─────────────────────────────────────────────────────── export default function ProfielApp() { const [user, setUser] = useState(null); const [loading, setLoading] = useState(true); const [tab, setTab] = useState('profiel'); // Profile state const [profile, setProfile] = useState(null); const [saving, setSaving] = useState(false); // Watchlist state const [watchlist, setWatchlist] = useState([]); useEffect(() => { fetch('/api/auth/session') .then(r => r.json()) .then(data => { setUser(data.user); if (data.user) { loadAllData(); } setLoading(false); }) .catch(() => setLoading(false)); }, []); async function loadAllData() { const [profileRes, watchlistRes] = await Promise.all([ fetch('/api/user/profile', { credentials: 'include' }).then(r => r.json()).catch(() => null), fetch('/api/watchlist', { credentials: 'include' }).then(r => r.json()).catch(() => ({ items: [] })), ]); if (profileRes && !profileRes.error) setProfile(profileRes); setWatchlist(watchlistRes.items || []); } if (loading) return
Laden...
; if (!user) return ; // ── Tab Navigation ─────────────────────────────────────────────────── return (
{/* Tab bar */}
{TABS.map(t => ( ))}
{/* Tab content */} {tab === 'profiel' && } {tab === 'watchlist' && ( )} {tab === 'email' && }
); } // ── Tab 1: Profiel ─────────────────────────────────────────────────────── function ProfileTab({ profile, setProfile, saving, setSaving }: { profile: UserProfile | null; setProfile: (p: UserProfile) => void; saving: boolean; setSaving: (s: boolean) => void; }) { const [firstName, setFirstName] = useState(profile?.first_name || ''); const [lastName, setLastName] = useState(profile?.last_name || ''); const [shoeSize, setShoeSize] = useState(profile?.shoe_size || ''); const [gender, setGender] = useState(profile?.gender_preference || 'all'); const [budgetMax, setBudgetMax] = useState(profile?.budget_max?.toString() || ''); // Sync state when profile loads asynchronously useEffect(() => { if (profile) { setFirstName(profile.first_name || ''); setLastName(profile.last_name || ''); setShoeSize(profile.shoe_size || ''); setGender(profile.gender_preference || 'all'); setBudgetMax(profile.budget_max?.toString() || ''); } }, [profile]); const handleSave = async () => { setSaving(true); const body: Record = { first_name: firstName || null, last_name: lastName || null, shoe_size: shoeSize || null, gender_preference: gender, budget_max: budgetMax ? parseInt(budgetMax) : null, }; const res = await fetch('/api/user/profile', { method: 'PATCH', credentials: 'include', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body), }); if (res.ok) { setProfile({ ...profile!, ...body } as UserProfile); showToast('Profiel opgeslagen', 'success'); } else { showToast('Opslaan mislukt', 'error'); } setSaving(false); }; const needsCompletion = !firstName || !shoeSize; return (
{needsCompletion && (

Stel je profiel in

Voeg je naam en maat toe voor gepersonaliseerde alerts.

)}

Persoonlijke gegevens

{/* First name */}
setFirstName(e.target.value)} placeholder="Je voornaam (optioneel)" className="w-full rounded-lg border border-border/30 bg-bg-elevated px-3 py-2.5 text-text placeholder-text-muted focus:border-accent/50 focus:outline-none" />
{/* Last name */}
setLastName(e.target.value)} placeholder="Je achternaam (optioneel)" className="w-full rounded-lg border border-border/30 bg-bg-elevated px-3 py-2.5 text-text placeholder-text-muted focus:border-accent/50 focus:outline-none" />
{/* Email (readonly) */}
{/* Shoe size */}
{/* Gender */}
{[ { value: 'all', label: 'Alles' }, { value: 'heren', label: 'Heren' }, { value: 'dames', label: 'Dames' }, ].map(opt => ( ))}
{/* Budget max */}
setBudgetMax(e.target.value)} placeholder="Geen limiet" min="0" step="10" className="w-full rounded-lg border border-border/30 bg-bg-elevated pl-7 pr-3 py-2.5 text-text placeholder-text-muted focus:border-accent/50 focus:outline-none" />

Ontvang alleen alerts voor sneakers onder dit bedrag

{/* Logout button */}
); } // ── Taste Profile Section ──────────────────────────────────────────────── function TasteProfileSection() { const [profile, setProfile] = useState<{ preferred_brands: { slug: string; name: string; score: number }[]; preferred_price_range: { min: number | null; max: number | null; avg: number | null }; } | null>(null); useEffect(() => { fetch('/api/user/taste-profile', { credentials: 'include' }) .then(r => r.json()) .then(data => { if (!data.error) setProfile(data); }) .catch(() => {}); }, []); if (!profile || profile.preferred_brands.length === 0) return null; const maxScore = Math.max(...profile.preferred_brands.map(b => b.score)); return (

Jouw smaakprofiel

Gebaseerd op je watchlist en gevolgde merken

{/* Brand preferences */}
{profile.preferred_brands.slice(0, 5).map(brand => (
{brand.name}
))}
{/* Price range */} {profile.preferred_price_range.avg && (

Gem. prijs

€{profile.preferred_price_range.avg}

{profile.preferred_price_range.min && (

Bereik

€{profile.preferred_price_range.min} – €{profile.preferred_price_range.max}

)}
)}
); } // ── Tab 2: Watchlist ───────────────────────────────────────────────────── type SortOption = 'newest' | 'price_low' | 'price_high' | 'discount' | 'price_drop'; function WatchlistTab({ items, setItems }: { items: WatchlistItem[]; setItems: (items: WatchlistItem[]) => void; }) { const [sortBy, setSortBy] = useState('newest'); const [filterBrand, setFilterBrand] = useState('all'); const [filterDealsOnly, setFilterDealsOnly] = useState(false); const removeItem = async (productId: number) => { await fetch('/api/watchlist', { method: 'DELETE', credentials: 'include', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ product_id: productId }), }); setItems(items.filter(i => i.product_id !== productId)); showToast('Verwijderd van watchlist', 'success'); }; const updateTargetPrice = async (productId: number, price: number | null) => { await fetch('/api/watchlist', { method: 'PUT', credentials: 'include', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ product_id: productId, target_price: price }), }); setItems(items.map(i => i.product_id === productId ? { ...i, target_price: price } : i)); showToast('Doelprijs bijgewerkt', 'success'); }; const toggleNotification = async (productId: number, field: 'notify_price_drop' | 'notify_back_in_stock' | 'notify_push_price_drop' | 'notify_push_back_in_stock', value: boolean) => { await fetch('/api/watchlist', { method: 'PUT', credentials: 'include', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ product_id: productId, [field]: value }), }); setItems(items.map(i => i.product_id === productId ? { ...i, [field]: value } : i)); showToast(value ? 'Melding ingeschakeld' : 'Melding uitgeschakeld', 'success'); }; const updateSizeOverride = async (productId: number, sizeOverride: string | null) => { await fetch('/api/watchlist', { method: 'PUT', credentials: 'include', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ product_id: productId, size_override: sizeOverride }), }); setItems(items.map(i => i.product_id === productId ? { ...i, size_override: sizeOverride } : i)); showToast('Maat bijgewerkt', 'success'); }; // Derive unique brands const brands = [...new Set(items.map(i => i.brand))].sort(); // Filter let filtered = items; if (filterBrand !== 'all') { filtered = filtered.filter(i => i.brand === filterBrand); } if (filterDealsOnly) { filtered = filtered.filter(i => { const lp = i.lowest_price ? parseFloat(String(i.lowest_price)) : null; const tp = i.target_price ? parseFloat(String(i.target_price)) : null; return lp && tp && lp <= tp; }); } // Sort const sorted = [...filtered].sort((a, b) => { const aPrice = a.lowest_price ? parseFloat(String(a.lowest_price)) : Infinity; const bPrice = b.lowest_price ? parseFloat(String(b.lowest_price)) : Infinity; const aOrig = a.original_price ? parseFloat(String(a.original_price)) : aPrice; const bOrig = b.original_price ? parseFloat(String(b.original_price)) : bPrice; const aDiscount = aOrig > 0 ? (aOrig - aPrice) / aOrig : 0; const bDiscount = bOrig > 0 ? (bOrig - bPrice) / bOrig : 0; switch (sortBy) { case 'price_low': return aPrice - bPrice; case 'price_high': return bPrice - aPrice; case 'discount': return bDiscount - aDiscount; case 'price_drop': { const aDelta = (a.price_7d_ago && a.lowest_price) ? parseFloat(String(a.lowest_price)) - parseFloat(String(a.price_7d_ago)) : 0; const bDelta = (b.price_7d_ago && b.lowest_price) ? parseFloat(String(b.lowest_price)) - parseFloat(String(b.price_7d_ago)) : 0; return aDelta - bDelta; } default: return 0; } }); if (items.length === 0) { return (

Je watchlist is nog leeg

Ontdek sneakers
); } return (
{/* Sort & Filter controls */}

{filtered.length} sneaker{filtered.length !== 1 ? 's' : ''}

{brands.length > 1 && ( )}
{sorted.length === 0 ? (

Geen sneakers gevonden met deze filters

) : ( sorted.map(item => ( {}} /> )) )}
); } // ── Tab 3: Email Preferences ───────────────────────────────────────────── function EmailTab({ profile, setProfile }: { profile: UserProfile | null; setProfile: (p: UserProfile) => void; }) { const [threshold, setThreshold] = useState(profile?.alert_threshold_pct || 15); const [frequency, setFrequency] = useState(profile?.alert_frequency || 'instant'); const [alertDay, setAlertDay] = useState(profile?.alert_day_of_week || 'monday'); const [notifyBIS, setNotifyBIS] = useState(profile?.notify_back_in_stock ?? true); const [notifyDigest, setNotifyDigest] = useState(profile?.notify_weekly_digest ?? true); const [notifyReleases, setNotifyReleases] = useState(profile?.notify_new_releases ?? false); const [saving, setSaving] = useState(false); const [pausing, setPausing] = useState(false); const [pushEnabled, setPushEnabled] = useState(false); const [pushSubscribing, setPushSubscribing] = useState(false); const pausedUntil = profile?.paused_until ? new Date(profile.paused_until) : null; const isPaused = pausedUntil && pausedUntil > new Date(); // Check push notification status on mount useEffect(() => { setPushEnabled(localStorage.getItem('sp_push_subscribed') === '1'); }, []); const enablePush = async () => { if (!('PushManager' in window) || !('serviceWorker' in navigator)) { showToast('Je browser ondersteunt geen push notificaties.', 'error'); return; } setPushSubscribing(true); try { const reg = await navigator.serviceWorker.ready; const vapidKey = (import.meta as any).env?.PUBLIC_VAPID_KEY; const urlBase64ToUint8Array = (base64String: string) => { const padding = '='.repeat((4 - (base64String.length % 4)) % 4); const base64 = (base64String + padding).replace(/-/g, '+').replace(/_/g, '/'); const rawData = atob(base64); const outputArray = new Uint8Array(rawData.length); for (let i = 0; i < rawData.length; ++i) outputArray[i] = rawData.charCodeAt(i); return outputArray; }; const sub = await reg.pushManager.subscribe({ userVisibleOnly: true, applicationServerKey: urlBase64ToUint8Array(vapidKey), }); await fetch('/api/push/subscribe', { method: 'POST', headers: { 'Content-Type': 'application/json' }, credentials: 'include', body: JSON.stringify(sub.toJSON()), }); localStorage.setItem('sp_push_subscribed', '1'); localStorage.removeItem('sp_push_dismissed'); setPushEnabled(true); showToast('Push notificaties ingeschakeld!', 'success'); } catch (err: any) { showToast('Push notificaties konden niet worden ingeschakeld.', 'error'); } setPushSubscribing(false); }; const disablePush = async () => { setPushSubscribing(true); try { await fetch('/api/push/unsubscribe', { method: 'POST', headers: { 'Content-Type': 'application/json' }, credentials: 'include', }); localStorage.removeItem('sp_push_subscribed'); setPushEnabled(false); showToast('Push notificaties uitgeschakeld', 'success'); } catch (err) { showToast('Uitschakelen mislukt', 'error'); } setPushSubscribing(false); }; const savePreferences = async () => { setSaving(true); const body = { alert_threshold_pct: threshold, alert_frequency: frequency, alert_day_of_week: alertDay, notify_back_in_stock: notifyBIS, notify_weekly_digest: notifyDigest, notify_new_releases: notifyReleases, }; const res = await fetch('/api/user/profile', { method: 'PATCH', credentials: 'include', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body), }); if (res.ok) { setProfile({ ...profile!, ...body }); showToast('E-mailvoorkeuren opgeslagen', 'success'); } else { showToast('Opslaan mislukt', 'error'); } setSaving(false); }; const pauseEmails = async (days: number) => { setPausing(true); const res = await fetch('/api/user/pause', { method: 'POST', credentials: 'include', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ days }), }); if (res.ok) { const data = await res.json(); setProfile({ ...profile!, paused_until: data.paused_until }); showToast(`E-mails gepauzeerd voor ${days} dagen`, 'success'); } setPausing(false); }; const resumeEmails = async () => { setPausing(true); const res = await fetch('/api/user/pause', { method: 'DELETE', credentials: 'include' }); if (res.ok) { setProfile({ ...profile!, paused_until: null }); showToast('E-mails hervat', 'success'); } setPausing(false); }; const thresholdLabels: Record = { 5: 'Elke daling', 10: 'Kleine korting', 15: 'Goede deals', 20: 'Goede deals', 25: 'Flinke korting', 30: 'Top deals', 35: 'Top deals', 40: 'Mega sale', 45: 'Mega sale', 50: 'Mega sale', }; const frequencyOptions = [ { value: 'instant', label: 'Direct', desc: 'Zodra prijs daalt', icon: 'M3.75 13.5l10.5-11.25L12 10.5h8.25L9.75 21.75 12 13.5H3.75z' }, { value: 'morning', label: 'Ochtend', desc: 'Dagelijks 08:00', icon: 'M12 3v2.25m6.364.386l-1.591 1.591M21 12h-2.25m-.386 6.364l-1.591-1.591M12 18.75V21m-4.773-4.227l-1.591 1.591M5.25 12H3m4.227-4.773L5.636 5.636M15.75 12a3.75 3.75 0 11-7.5 0 3.75 3.75 0 017.5 0z' }, { value: 'evening', label: 'Avond', desc: 'Dagelijks 19:00', icon: 'M21.752 15.002A9.718 9.718 0 0118 15.75c-5.385 0-9.75-4.365-9.75-9.75 0-1.33.266-2.597.748-3.752A9.753 9.753 0 003 11.25C3 16.635 7.365 21 12.75 21a9.753 9.753 0 009.002-5.998z' }, { value: 'weekly', label: 'Wekelijks', desc: 'Maandag ochtend', icon: 'M6.75 3v2.25M17.25 3v2.25M3 18.75V7.5a2.25 2.25 0 012.25-2.25h13.5A2.25 2.25 0 0121 7.5v11.25m-18 0A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75m-18 0v-7.5A2.25 2.25 0 015.25 9h13.5A2.25 2.25 0 0121 11.25v7.5' }, ]; return (
{/* Pause banner */} {isPaused && (

E-mails gepauzeerd

Tot {pausedUntil!.toLocaleDateString('nl-NL', { day: 'numeric', month: 'long', year: 'numeric' })} . Watchlist-alerts komen nog steeds door.

)} {/* Alert threshold */}

Alertgevoeligheid

Vanaf welk kortingspercentage wil je een melding?

setThreshold(parseInt(e.target.value))} className="w-full accent-accent" />
Elke daling Goede deals Top deals Mega sale

{threshold}% — {thresholdLabels[threshold] || 'Goede deals'}

{/* Frequency */}

Timing

Wanneer wil je meldingen ontvangen?

{frequencyOptions.map(opt => ( ))}
{/* Weekly day picker */} {frequency === 'weekly' && (

Dag van de week

Op welke dag wil je de wekelijkse digest ontvangen?

{WEEKDAYS.map(day => ( ))}
)} {/* Notification types */}

E-mail meldingen

{/* Push notifications */} {('PushManager' in window) && (

Push notificaties

Ontvang directe browsernotificaties voor prijsdalingen op je watchlist items.

{pushEnabled ? '✓ Push notificaties actief' : 'Push notificaties'}

{pushEnabled ? 'Je ontvangt direct een melding bij prijsdalingen' : 'Inschakelen voor directe browsernotificaties'}

)} {/* Save button */} {/* Smart pause */} {!isPaused && (

Even pauze?

Pauzeer marketing e-mails tijdelijk. Watchlist-alerts blijven gewoon werken.

{[7, 14, 30].map(days => ( ))}
)}
); } // ── Toggle Row Component ───────────────────────────────────────────────── function ToggleRow({ label, desc, checked, onChange }: { label: string; desc: string; checked: boolean; onChange: (val: boolean) => void; }) { return ( ); }