Initiële commit: VibeFinance 3-container opzet

Basislijn van de bestaande applicatie (Vite/React frontend, Express
backend, PostgreSQL) zoals die in de index stond, plus dev-, release-
en documentatiescripts.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jordy Hoogeveen
2026-09-04 09:19:14 +02:00
co-authored by Claude Opus 5
commit e45d5b73fb
54 changed files with 11434 additions and 0 deletions
@@ -0,0 +1,377 @@
import { useState, useEffect, useRef } from "react";
import { RED, GREEN } from "../constants/index.js";
const TEAL = "linear-gradient(135deg, #7c3aed, #a855f7)";
const TEAL_COLOR = "#a855f7";
const TEAL_BG = "rgba(124,58,237,0.15)";
const TEAL_BORDER = "rgba(124,58,237,0.35)";
import { useApp } from "../context/AppContext.jsx";
const getToken = () => localStorage.getItem("vf_token");
const authHdrs = () => ({ "Content-Type": "application/json", Authorization: `Bearer ${getToken()}` });
async function apiFetch(path, options = {}) {
const res = await fetch(`/api${path}`, { ...options, headers: { ...authHdrs(), ...(options.headers || {}) } });
if (!res.ok) {
const data = await res.json().catch(() => ({}));
throw new Error(data.error || `HTTP ${res.status}`);
}
return res.json();
}
function fmtDatum(iso) {
if (!iso) return "—";
const d = new Date(iso);
return `${d.getDate()}-${d.getMonth() + 1}-${d.getFullYear()}`;
}
function RolSelect({ value, onChange, opties, T, darkMode }) {
const [open, setOpen] = useState(false);
const ref = useRef(null);
useEffect(() => {
const handler = (e) => { if (ref.current && !ref.current.contains(e.target)) setOpen(false); };
document.addEventListener("mousedown", handler);
return () => document.removeEventListener("mousedown", handler);
}, []);
return (
<div ref={ref} style={{ position: "relative", width: "100%" }}>
<button type="button" onClick={() => setOpen((o) => !o)} style={{
width: "100%", background: T.inputBg, border: `1px solid ${T.inputBorder}`,
borderRadius: 8, color: T.text, padding: "8px 10px", fontSize: 13,
cursor: "pointer", display: "flex", justifyContent: "space-between", alignItems: "center",
boxSizing: "border-box",
}}>
<span>{value}</span>
<span style={{ fontSize: 10, color: T.muted }}>▼</span>
</button>
{open && (
<div style={{
position: "absolute", top: "calc(100% + 4px)", left: 0, right: 0, zIndex: 999,
background: T.card, border: `1px solid ${T.inputBorder}`,
borderRadius: 8, overflow: "hidden",
boxShadow: darkMode ? "0 8px 24px rgba(0,0,0,0.5)" : "0 8px 24px rgba(0,0,0,0.15)",
}}>
{opties.map((o) => (
<div key={o} onClick={() => { onChange(o); setOpen(false); }} style={{
padding: "9px 12px", fontSize: 13, cursor: "pointer",
color: o === value ? TEAL_COLOR : T.text,
background: o === value ? TEAL_BG : "transparent",
}}
onMouseEnter={(e) => e.currentTarget.style.background = "rgba(14,221,212,0.10)"}
onMouseLeave={(e) => e.currentTarget.style.background = o === value ? TEAL_BG : "transparent"}
>
{o}
</div>
))}
</div>
)}
</div>
);
}
export default function GebruikersBeheer({ onClose }) {
const { currentUser, darkMode, T } = useApp();
const [users, setUsers] = useState([]);
const [loadErr, setLoadErr] = useState("");
const [showAdd, setShowAdd] = useState(false);
const [editUser, setEditUser] = useState(null); // user object dat bewerkt wordt
const [nieuwNaam, setNieuwNaam] = useState("");
const [nieuwEmail, setNieuwEmail] = useState("");
const [nieuwWw, setNieuwWw] = useState("");
const [nieuwRol, setNieuwRol] = useState("Viewer");
const [editNaam, setEditNaam] = useState("");
const [editEmail, setEditEmail] = useState("");
const [editWw, setEditWw] = useState("");
const [err, setErr] = useState("");
const [saving, setSaving] = useState(false);
const rollen = ["Admin", "Bewerker", "Viewer"];
useEffect(() => {
apiFetch("/users")
.then(setUsers)
.catch((e) => setLoadErr(e.message));
}, []);
const is = {
background: T.inputBg, border: `1px solid ${T.inputBorder}`,
borderRadius: 8, color: T.text, padding: "8px 10px",
fontSize: 13, outline: "none", boxSizing: "border-box", width: "100%",
};
const addUser = async () => {
if (!nieuwNaam.trim() || !nieuwEmail.trim() || !nieuwWw.trim()) {
setErr("Naam, e-mail en wachtwoord zijn verplicht.");
return;
}
setSaving(true);
try {
const u = await apiFetch("/users", {
method: "POST",
body: JSON.stringify({ naam: nieuwNaam, email: nieuwEmail, wachtwoord: nieuwWw, rol: nieuwRol }),
});
setUsers((prev) => [...prev, u]);
setNieuwNaam(""); setNieuwEmail(""); setNieuwWw(""); setErr("");
setShowAdd(false);
} catch (e) {
setErr(e.message);
} finally {
setSaving(false);
}
};
const openEdit = (u) => {
setEditUser(u);
setEditNaam(u.naam);
setEditEmail(u.email);
setEditWw("");
setErr("");
};
const saveEdit = async () => {
if (!editNaam.trim() || !editEmail.trim()) { setErr("Naam en e-mail zijn verplicht."); return; }
setSaving(true);
try {
const patch = { naam: editNaam.trim(), email: editEmail.trim() };
if (editWw) patch.wachtwoord = editWw;
const updated = await apiFetch(`/users/${editUser.id}`, {
method: "PATCH",
body: JSON.stringify(patch),
});
setUsers((prev) => prev.map((x) => x.id === editUser.id ? { ...x, ...updated } : x));
setEditUser(null); setErr("");
} catch (e) { setErr(e.message); }
finally { setSaving(false); }
};
const toggleActief = async (u) => {
try {
const updated = await apiFetch(`/users/${u.id}`, {
method: "PATCH",
body: JSON.stringify({ actief: !u.actief }),
});
setUsers((prev) => prev.map((x) => x.id === u.id ? { ...x, ...updated } : x));
} catch (e) { setErr(e.message); }
};
const removeUser = async (id) => {
try {
await apiFetch(`/users/${id}`, { method: "DELETE" });
setUsers((prev) => prev.filter((u) => u.id !== id));
} catch (e) { setErr(e.message); }
};
const th = {
padding: "8px 12px", fontSize: 11, color: T.muted, fontWeight: 600,
textAlign: "left", borderBottom: `1px solid ${T.border}`,
background: T.card, textTransform: "uppercase", letterSpacing: "0.05em",
};
const td = { padding: "10px 12px", fontSize: 13, color: T.text, borderBottom: `1px solid ${T.border}` };
return (
<div
onClick={onClose}
style={{
position: "fixed", inset: 0, zIndex: 400,
background: "rgba(0,0,0,0.55)", backdropFilter: "blur(4px)",
display: "flex", alignItems: "center", justifyContent: "center",
}}
>
<div
onClick={(e) => e.stopPropagation()}
style={{
background: T.card, border: `1px solid ${T.border}`,
borderRadius: 16, width: "100%", maxWidth: 700,
maxHeight: "90vh", display: "flex", flexDirection: "column",
boxShadow: "0 16px 48px rgba(0,0,0,0.4)",
overflow: "hidden",
}}
>
{/* Header */}
<div style={{
display: "flex", alignItems: "center", justifyContent: "space-between",
padding: "18px 22px", borderBottom: `1px solid ${T.border}`, flexShrink: 0,
}}>
<div>
<span style={{ fontWeight: 700, fontSize: 15, color: T.text }}>Gebruikersbeheer</span>
<span style={{ marginLeft: 10, fontSize: 11, fontWeight: 600, color: TEAL_COLOR,
background: TEAL_BG, borderRadius: 99, padding: "2px 8px", border: `1px solid ${TEAL_BORDER}`,
}}>
{users.length} gebruiker{users.length !== 1 ? "s" : ""}
</span>
</div>
<button onClick={onClose} style={{
background: "transparent", border: "none", cursor: "pointer",
color: T.muted, fontSize: 20, lineHeight: 1, padding: 4,
}}>×</button>
</div>
<div style={{ padding: "16px 22px", overflowY: "auto", flex: 1 }}>
{loadErr && (
<div style={{ color: RED, fontSize: 13, marginBottom: 12 }}>{loadErr}</div>
)}
{/* Tabel */}
<div style={{ overflowX: "auto", marginBottom: 16 }}>
<table style={{ width: "100%", borderCollapse: "collapse" }}>
<thead>
<tr>
<th style={th}>Naam</th>
<th style={th}>E-mail</th>
<th style={th}>Aangemaakt</th>
<th style={th}>Admin</th>
<th style={th}></th>
</tr>
</thead>
<tbody>
{users.map((u) => {
const initials = u.naam.split(" ").map((w) => w[0]).join("").toUpperCase().slice(0, 2);
const isSelf = u.id === currentUser?.id || u.email === currentUser?.email;
return (
<tr key={u.id} style={{ opacity: u.actief ? 1 : 0.5 }}>
<td style={td}>
<div style={{ display: "flex", alignItems: "center", gap: 10 }}>
<div style={{
width: 32, height: 32, borderRadius: "50%",
background: TEAL_BG, display: "flex",
alignItems: "center", justifyContent: "center",
fontSize: 12, fontWeight: 700, color: TEAL_COLOR, flexShrink: 0,
}}>
{initials}
</div>
<span style={{ fontWeight: 600 }}>{u.naam}</span>
{isSelf && (
<span style={{
fontSize: 10, fontWeight: 700, color: TEAL_COLOR,
background: TEAL_BG, borderRadius: 99,
padding: "1px 7px", border: `1px solid ${TEAL_BORDER}`,
}}>jij</span>
)}
</div>
</td>
<td style={{ ...td, color: T.muted }}>{u.email}</td>
<td style={{ ...td, color: T.muted }}>{fmtDatum(u.aangemaakt)}</td>
<td style={td}>
{u.rol === "Admin"
? <span style={{ fontSize: 18 }}>⭐</span>
: <span style={{ fontSize: 13, color: T.muted }}>—</span>
}
</td>
<td style={{ ...td, whiteSpace: "nowrap" }}>
<div style={{ display: "flex", gap: 6, alignItems: "center" }}>
<button onClick={() => openEdit(u)} style={{
padding: "3px 8px", background: TEAL_BG,
border: `1px solid ${TEAL_BORDER}`, borderRadius: 6,
color: TEAL_COLOR, cursor: "pointer", fontSize: 12,
}}>✏️</button>
{!isSelf && (
<>
<button onClick={() => toggleActief(u)} style={{
padding: "3px 8px", borderRadius: 6, cursor: "pointer",
fontSize: 11, fontWeight: 700,
background: u.actief ? "rgba(16,185,129,0.12)" : "rgba(239,68,68,0.1)",
border: `1px solid ${u.actief ? "rgba(16,185,129,0.4)" : "rgba(239,68,68,0.3)"}`,
color: u.actief ? GREEN : RED,
}}>
{u.actief ? "Actief" : "Inactief"}
</button>
<button onClick={() => removeUser(u.id)} style={{
padding: "3px 8px", background: "rgba(239,68,68,0.1)",
border: "1px solid rgba(239,68,68,0.25)", borderRadius: 6,
color: RED, cursor: "pointer", fontSize: 13,
}}>×</button>
</>
)}
</div>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
{err && (
<div style={{ color: RED, fontSize: 12, marginBottom: 12, padding: "6px 10px", background: "rgba(239,68,68,0.1)", borderRadius: 8, border: "1px solid rgba(239,68,68,0.25)" }}>
{err}
</div>
)}
{/* Bewerken modal */}
{editUser && (
<div style={{
background: darkMode ? "rgba(124,58,237,0.06)" : "#f5f0ff",
border: `1px solid ${TEAL_BORDER}`, borderRadius: 10, padding: "16px", marginBottom: 16,
}}>
<div style={{ fontSize: 11, color: TEAL_COLOR, fontWeight: 700, textTransform: "uppercase", letterSpacing: "0.05em", marginBottom: 12 }}>
{editUser.naam} bewerken
</div>
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 10, marginBottom: 10 }}>
<input placeholder="Naam" value={editNaam} onChange={(e) => setEditNaam(e.target.value)} style={is} />
<input placeholder="E-mail" value={editEmail} onChange={(e) => setEditEmail(e.target.value)} style={is} />
<input placeholder="Nieuw wachtwoord (optioneel)" type="password" value={editWw} onChange={(e) => setEditWw(e.target.value)} style={{ ...is, gridColumn: "1 / -1" }} />
</div>
<div style={{ display: "flex", gap: 8 }}>
<button onClick={() => { setEditUser(null); setErr(""); }} style={{
flex: 1, padding: "8px", background: "transparent",
border: `1px solid ${T.border}`, borderRadius: 8,
color: T.muted, cursor: "pointer", fontSize: 13, fontWeight: 600,
}}>Annuleren</button>
<button onClick={saveEdit} disabled={saving} style={{
flex: 2, padding: "8px",
background: TEAL,
border: "none", borderRadius: 8, color: "#fff",
fontWeight: 700, fontSize: 13, cursor: saving ? "not-allowed" : "pointer",
}}>{saving ? "Opslaan..." : "Opslaan"}</button>
</div>
</div>
)}
{/* Nieuwe gebruiker */}
{showAdd ? (
<div style={{
background: darkMode ? "rgba(124,58,237,0.06)" : "#f5f0ff",
border: `1px solid ${T.border}`, borderRadius: 10, padding: "16px",
}}>
<div style={{ fontSize: 11, color: T.muted, fontWeight: 700, textTransform: "uppercase", letterSpacing: "0.05em", marginBottom: 12 }}>
Nieuwe gebruiker
</div>
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 10, marginBottom: 10 }}>
<input placeholder="Naam" value={nieuwNaam} onChange={(e) => setNieuwNaam(e.target.value)} style={is} />
<input placeholder="E-mail" value={nieuwEmail} onChange={(e) => setNieuwEmail(e.target.value)} style={is} />
<input placeholder="Wachtwoord" type="password" value={nieuwWw} onChange={(e) => setNieuwWw(e.target.value)} style={is} />
<RolSelect value={nieuwRol} onChange={setNieuwRol} opties={rollen} T={T} darkMode={darkMode} />
</div>
<div style={{ display: "flex", gap: 8 }}>
<button onClick={() => { setShowAdd(false); setErr(""); }} style={{
flex: 1, padding: "8px", background: "transparent",
border: `1px solid ${T.border}`, borderRadius: 8,
color: T.muted, cursor: "pointer", fontSize: 13, fontWeight: 600,
}}>Annuleren</button>
<button onClick={addUser} disabled={saving} style={{
flex: 2, padding: "8px",
background: TEAL,
border: "none", borderRadius: 8, color: "#fff",
fontWeight: 700, fontSize: 13, cursor: saving ? "not-allowed" : "pointer",
}}>
{saving ? "Opslaan..." : "Toevoegen"}
</button>
</div>
</div>
) : (
<button onClick={() => setShowAdd(true)} style={{
padding: "8px 18px",
background: TEAL,
border: "none", borderRadius: 8, color: "#fff",
fontWeight: 700, fontSize: 13, cursor: "pointer",
}}>+ Nieuwe gebruiker</button>
)}
</div>
</div>
</div>
);
}
@@ -0,0 +1,112 @@
import { useApp } from "../context/AppContext.jsx";
const TEAL = "linear-gradient(135deg, #7c3aed, #a855f7)";
const BACKUP_ICON = (
<svg width="22" height="22" viewBox="0 0 24 24" fill="none">
<path d="M12 15V5M8 11l4-4 4 4" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"/>
<path d="M4 17v2a1 1 0 001 1h14a1 1 0 001-1v-2" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round"/>
</svg>
);
const RESTORE_ICON = (
<svg width="22" height="22" viewBox="0 0 24 24" fill="none">
<path d="M4 8a8 8 0 1 1 0 8" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round"/>
<polyline points="4,4 4,8 8,8" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"/>
</svg>
);
function ActionRow({ icon, title, description, onClick }) {
return (
<div
onClick={onClick}
style={{
display: "flex", alignItems: "center", gap: 14,
padding: "12px 0", cursor: "pointer",
}}
>
<div style={{ color: "#94a3b8", flexShrink: 0 }}>{icon}</div>
<div>
<div style={{ fontWeight: 600, fontSize: 14, color: "inherit", marginBottom: 2 }}>{title}</div>
<div style={{ fontSize: 12, color: "#64748b" }}>{description}</div>
</div>
</div>
);
}
export default function InstellingenModal({ onClose }) {
const { T, darkMode, setDarkMode, doBackup, doRestore } = useApp();
return (
<div
onClick={onClose}
style={{
position: "fixed", inset: 0, zIndex: 400,
background: "rgba(0,0,0,0.55)", backdropFilter: "blur(4px)",
display: "flex", alignItems: "center", justifyContent: "center",
}}
>
<div
onClick={(e) => e.stopPropagation()}
style={{
background: T.card, border: `1px solid ${T.border}`,
borderRadius: 16, padding: "22px 22px 8px",
width: 360, boxShadow: "0 16px 48px rgba(0,0,0,0.4)",
color: T.text,
}}
>
{/* Header */}
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", marginBottom: 20 }}>
<span style={{ fontWeight: 700, fontSize: 16 }}>Instellingen</span>
<button onClick={onClose} style={{
background: "transparent", border: "none", cursor: "pointer",
color: T.muted, fontSize: 20, lineHeight: 1, padding: "0 4px",
}}>×</button>
</div>
{/* Dark mode rij */}
<div style={{
display: "flex", alignItems: "center", justifyContent: "space-between",
padding: "4px 0 16px",
}}>
<span style={{ fontWeight: 600, fontSize: 14 }}>Dark mode</span>
<div
onClick={() => setDarkMode((d) => !d)}
style={{
width: 44, height: 24, borderRadius: 12, cursor: "pointer",
background: darkMode ? TEAL : "#334155",
position: "relative", transition: "background 0.2s", flexShrink: 0,
}}
>
<div style={{
position: "absolute", top: 3, borderRadius: "50%",
width: 18, height: 18, background: "#fff",
left: darkMode ? 23 : 3,
transition: "left 0.2s",
boxShadow: "0 1px 4px rgba(0,0,0,0.3)",
}} />
</div>
</div>
<div style={{ height: 1, background: T.border, margin: "0 -22px" }} />
{/* Acties */}
<div style={{ padding: "4px 0 10px" }}>
<ActionRow
icon={BACKUP_ICON}
title="Backup maken"
description="Download een volledige backup als ZIP"
onClick={() => { doBackup(); onClose(); }}
/>
<div style={{ height: 1, background: T.border }} />
<ActionRow
icon={RESTORE_ICON}
title="Backup herstellen"
description="Herstel data vanuit een backup-bestand"
onClick={() => { doRestore(); onClose(); }}
/>
</div>
</div>
</div>
);
}
+220
View File
@@ -0,0 +1,220 @@
/**
* Merkeigen achtergrond voor de loginpagina.
*
* Vertelt in één beeld waar de app over gaat: het eigen vermogen (paars/groen)
* stijgt, de schuld (oranje) daalt en de twee lijnen kruisen elkaar. Daarnaast
* de categorie-donut, maandstaafjes en zwevende munten — allemaal in de
* huisstijl-paars. Volledig SVG, dus scherp op elk formaat en zonder asset.
*/
// Eigen Vermogen: stijgend. Schuld: dalend. Ze kruisen rond x = 320.
const EV_LINE = "M 30 612 C 110 596, 150 560, 215 528 S 300 470, 340 430 S 430 336, 500 286 S 560 250, 600 232";
const EV_AREA = `${EV_LINE} L 600 760 L 30 760 Z`;
const DEBT_LINE = "M 30 286 C 110 306, 150 340, 215 372 S 300 430, 340 466 S 430 542, 500 578 S 560 602, 600 616";
const EV_DOTS = [[215, 528], [340, 430], [500, 286]];
const BARS = [26, 38, 34, 52, 47, 68, 62, 84, 96];
export default function LoginBackground() {
return (
<svg
viewBox="0 0 600 800"
preserveAspectRatio="xMidYMid slice"
aria-hidden="true"
style={{ position: "absolute", inset: 0, width: "100%", height: "100%", display: "block" }}
>
<defs>
{/* Basis: diep paars met licht verloop naar de rechterbovenhoek */}
<linearGradient id="vfbg-base" x1="0" y1="1" x2="1" y2="0">
<stop offset="0%" stopColor="#12061f" />
<stop offset="45%" stopColor="#2a0f4d" />
<stop offset="100%" stopColor="#4c1d95" />
</linearGradient>
{/* Zachte lichtbollen */}
<radialGradient id="vfbg-orb-purple">
<stop offset="0%" stopColor="#a855f7" stopOpacity="0.55" />
<stop offset="100%" stopColor="#a855f7" stopOpacity="0" />
</radialGradient>
<radialGradient id="vfbg-orb-violet">
<stop offset="0%" stopColor="#7c3aed" stopOpacity="0.5" />
<stop offset="100%" stopColor="#7c3aed" stopOpacity="0" />
</radialGradient>
<radialGradient id="vfbg-orb-teal">
<stop offset="0%" stopColor="#10b981" stopOpacity="0.28" />
<stop offset="100%" stopColor="#10b981" stopOpacity="0" />
</radialGradient>
{/* Vlak onder de EV-lijn */}
<linearGradient id="vfbg-ev-fill" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor="#c084fc" stopOpacity="0.34" />
<stop offset="55%" stopColor="#8b5cf6" stopOpacity="0.12" />
<stop offset="100%" stopColor="#7c3aed" stopOpacity="0" />
</linearGradient>
{/* Lijnkleuren */}
<linearGradient id="vfbg-ev-stroke" x1="0" y1="1" x2="1" y2="0">
<stop offset="0%" stopColor="#7c3aed" />
<stop offset="55%" stopColor="#c084fc" />
<stop offset="100%" stopColor="#34d399" />
</linearGradient>
<linearGradient id="vfbg-debt-stroke" x1="0" y1="0" x2="1" y2="1">
<stop offset="0%" stopColor="#fb923c" stopOpacity="0.85" />
<stop offset="100%" stopColor="#f97316" stopOpacity="0.25" />
</linearGradient>
<linearGradient id="vfbg-bar" x1="0" y1="1" x2="0" y2="0">
<stop offset="0%" stopColor="#7c3aed" stopOpacity="0.15" />
<stop offset="100%" stopColor="#c4b5fd" stopOpacity="0.42" />
</linearGradient>
{/* Ruit-raster */}
<pattern id="vfbg-grid" width="48" height="48" patternUnits="userSpaceOnUse">
<path d="M 48 0 L 0 0 0 48" fill="none" stroke="#ffffff" strokeOpacity="0.055" strokeWidth="1" />
</pattern>
{/* Vignet zodat kaartjes en tekst leesbaar blijven */}
<radialGradient id="vfbg-vignette" cx="0.5" cy="0.42" r="0.78">
<stop offset="55%" stopColor="#09090f" stopOpacity="0" />
<stop offset="100%" stopColor="#09090f" stopOpacity="0.62" />
</radialGradient>
<linearGradient id="vfbg-floor" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor="#09090f" stopOpacity="0" />
<stop offset="100%" stopColor="#09090f" stopOpacity="0.7" />
</linearGradient>
<filter id="vfbg-glow" x="-30%" y="-30%" width="160%" height="160%">
<feGaussianBlur stdDeviation="7" result="b" />
<feMerge>
<feMergeNode in="b" />
<feMergeNode in="SourceGraphic" />
</feMerge>
</filter>
<filter id="vfbg-soft" x="-50%" y="-50%" width="200%" height="200%">
<feGaussianBlur stdDeviation="18" />
</filter>
</defs>
<style>{`
@keyframes vfbgDrift {
0%,100% { transform: translate(0,0) scale(1); }
50% { transform: translate(26px,-34px) scale(1.1); }
}
@keyframes vfbgDriftAlt {
0%,100% { transform: translate(0,0) scale(1.05); }
50% { transform: translate(-30px,26px) scale(0.94); }
}
@keyframes vfbgFloat {
0%,100% { transform: translateY(0) rotate(0deg); }
50% { transform: translateY(-16px) rotate(6deg); }
}
@keyframes vfbgDraw { to { stroke-dashoffset: 0; } }
@keyframes vfbgSpin { to { transform: rotate(360deg); } }
@keyframes vfbgPulse {
0%,100% { opacity: 0.35; transform: scale(1); }
50% { opacity: 0.9; transform: scale(1.5); }
}
.vfbg-orb-a { animation: vfbgDrift 26s ease-in-out infinite; }
.vfbg-orb-b { animation: vfbgDriftAlt 32s ease-in-out infinite; }
.vfbg-orb-c { animation: vfbgDrift 38s ease-in-out infinite reverse; }
.vfbg-coin { animation: vfbgFloat 11s ease-in-out infinite; transform-origin: center; }
.vfbg-coin-2 { animation-duration: 14s; animation-delay: -3s; }
.vfbg-coin-3 { animation-duration: 9s; animation-delay: -6s; }
.vfbg-line { stroke-dasharray: 1400; stroke-dashoffset: 1400; animation: vfbgDraw 2.4s ease-out 0.15s forwards; }
.vfbg-line-slow { animation-duration: 2.9s; animation-delay: 0.35s; }
.vfbg-ring { animation: vfbgSpin 90s linear infinite; transform-origin: 452px 168px; }
.vfbg-dot { transform-box: fill-box; transform-origin: center; animation: vfbgPulse 4.5s ease-in-out infinite; }
@media (prefers-reduced-motion: reduce) {
.vfbg-orb-a, .vfbg-orb-b, .vfbg-orb-c,
.vfbg-coin, .vfbg-ring, .vfbg-dot { animation: none; }
.vfbg-line { stroke-dashoffset: 0; animation: none; }
}
`}</style>
{/* ── Basis + raster ─────────────────────────────────────────────────── */}
<rect width="600" height="800" fill="url(#vfbg-base)" />
<rect width="600" height="800" fill="url(#vfbg-grid)" />
{/* ── Lichtbollen ────────────────────────────────────────────────────── */}
<g style={{ mixBlendMode: "screen" }}>
<circle className="vfbg-orb-a" cx="470" cy="150" r="210" fill="url(#vfbg-orb-purple)" />
<circle className="vfbg-orb-b" cx="110" cy="560" r="240" fill="url(#vfbg-orb-violet)" />
<circle className="vfbg-orb-c" cx="540" cy="640" r="180" fill="url(#vfbg-orb-teal)" />
</g>
{/* ── Categorie-donut (Aandelen / Crypto / Commodities / Vastgoed) ───── */}
<g opacity="0.5">
<circle cx="452" cy="168" r="86" fill="none" stroke="#ffffff" strokeOpacity="0.07" strokeWidth="14" />
<g className="vfbg-ring" fill="none" strokeWidth="14" strokeLinecap="round">
<circle cx="452" cy="168" r="86" stroke="#3b82f6" strokeOpacity="0.75" strokeDasharray="150 390" />
<circle cx="452" cy="168" r="86" stroke="#8b5cf6" strokeOpacity="0.8" strokeDasharray="110 430" strokeDashoffset="-158" />
<circle cx="452" cy="168" r="86" stroke="#f59e0b" strokeOpacity="0.6" strokeDasharray="66 474" strokeDashoffset="-276" />
<circle cx="452" cy="168" r="86" stroke="#10b981" strokeOpacity="0.65" strokeDasharray="94 446" strokeDashoffset="-350" />
</g>
</g>
{/* ── Maandstaafjes ──────────────────────────────────────────────────── */}
<g opacity="0.55">
{BARS.map((h, i) => (
<rect
key={i}
x={38 + i * 62} y={700 - h} width="30" height={h}
rx="7" fill="url(#vfbg-bar)"
/>
))}
<line x1="30" y1="701" x2="570" y2="701" stroke="#ffffff" strokeOpacity="0.12" strokeWidth="1.5" />
</g>
{/* ── Schuldlijn: dalend ─────────────────────────────────────────────── */}
<path
className="vfbg-line vfbg-line-slow" d={DEBT_LINE}
fill="none" stroke="url(#vfbg-debt-stroke)" strokeWidth="3"
strokeLinecap="round" strokeDasharray="10 9" opacity="0.75"
/>
{/* ── Eigen vermogen: stijgend, met vlak eronder ─────────────────────── */}
<path d={EV_AREA} fill="url(#vfbg-ev-fill)" />
<path
className="vfbg-line" d={EV_LINE}
fill="none" stroke="url(#vfbg-ev-stroke)" strokeWidth="4.5"
strokeLinecap="round" strokeLinejoin="round" filter="url(#vfbg-glow)"
/>
{EV_DOTS.map(([cx, cy], i) => (
<g key={cx}>
<circle className="vfbg-dot" cx={cx} cy={cy} r="9" fill="#c4b5fd" opacity="0.4"
style={{ animationDelay: `${i * -1.4}s` }} />
<circle cx={cx} cy={cy} r="4.5" fill="#f5f3ff" />
</g>
))}
{/* Doellijn: The Road naar € 500.000 */}
<g opacity="0.5">
<line x1="30" y1="232" x2="600" y2="232" stroke="#34d399" strokeOpacity="0.45"
strokeWidth="1.5" strokeDasharray="6 8" />
<text x="34" y="222" fill="#6ee7b7" fillOpacity="0.75" fontSize="13" fontWeight="600"
fontFamily="'Poppins', system-ui, sans-serif" letterSpacing="0.08em">DOEL € 500.000</text>
</g>
{/* ── Zwevende munten ───────────────────────────────────────────────── */}
<g fontFamily="'Poppins', system-ui, sans-serif" fontWeight="700" textAnchor="middle">
<g className="vfbg-coin" opacity="0.6">
<circle cx="120" cy="190" r="26" fill="#a855f7" fillOpacity="0.16" stroke="#c4b5fd" strokeOpacity="0.4" strokeWidth="1.5" />
<text x="120" y="199" fill="#e9d5ff" fillOpacity="0.85" fontSize="22">€</text>
</g>
<g className="vfbg-coin vfbg-coin-2" opacity="0.45">
<circle cx="252" cy="112" r="18" fill="#10b981" fillOpacity="0.14" stroke="#6ee7b7" strokeOpacity="0.35" strokeWidth="1.4" />
<text x="252" y="119" fill="#a7f3d0" fillOpacity="0.8" fontSize="16">€</text>
</g>
<g className="vfbg-coin vfbg-coin-3" opacity="0.4">
<circle cx="536" cy="392" r="21" fill="#7c3aed" fillOpacity="0.18" stroke="#c4b5fd" strokeOpacity="0.32" strokeWidth="1.4" />
<text x="536" y="400" fill="#ddd6fe" fillOpacity="0.8" fontSize="18">€</text>
</g>
</g>
{/* ── Diepte + leesbaarheid ─────────────────────────────────────────── */}
<ellipse cx="300" cy="770" rx="300" ry="90" fill="#7c3aed" fillOpacity="0.2" filter="url(#vfbg-soft)" />
<rect width="600" height="800" fill="url(#vfbg-vignette)" />
<rect y="480" width="600" height="320" fill="url(#vfbg-floor)" />
</svg>
);
}
+378
View File
@@ -0,0 +1,378 @@
import { useState } from "react";
import { RED } from "../constants/index.js";
const MailIcon = () => (
<svg width="15" height="15" viewBox="0 0 16 16" fill="none">
<rect x="1" y="3" width="14" height="10" rx="2" stroke="currentColor" strokeWidth="1.5"/>
<polyline points="1,5 8,9.5 15,5" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/>
</svg>
);
const LockIcon = () => (
<svg width="15" height="15" viewBox="0 0 16 16" fill="none">
<rect x="3" y="7" width="10" height="8" rx="2" stroke="currentColor" strokeWidth="1.5"/>
<path d="M5 7V5a3 3 0 016 0v2" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round"/>
</svg>
);
const PersonIcon = () => (
<svg width="15" height="15" viewBox="0 0 16 16" fill="none">
<circle cx="8" cy="5" r="3" stroke="currentColor" strokeWidth="1.5"/>
<path d="M2 14c0-3 2.5-5 6-5s6 2 6 5" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round"/>
</svg>
);
function EyeBtn({ show, onToggle }) {
return (
<button onClick={onToggle} type="button" style={{
background: "none", border: "none", cursor: "pointer",
color: "rgba(255,255,255,0.35)", padding: 0, lineHeight: 1, display: "flex", flexShrink: 0,
}}>
{show
? <svg width="16" height="16" viewBox="0 0 16 16" fill="none"><path d="M1 8s2.5-5 7-5 7 5 7 5-2.5 5-7 5-7-5-7-5z" stroke="currentColor" strokeWidth="1.4"/><circle cx="8" cy="8" r="2" stroke="currentColor" strokeWidth="1.4"/><line x1="2" y1="2" x2="14" y2="14" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round"/></svg>
: <svg width="16" height="16" viewBox="0 0 16 16" fill="none"><path d="M1 8s2.5-5 7-5 7 5 7 5-2.5 5-7 5-7-5-7-5z" stroke="currentColor" strokeWidth="1.4"/><circle cx="8" cy="8" r="2" stroke="currentColor" strokeWidth="1.4"/></svg>
}
</button>
);
}
function FloatingInput({ label, type = "text", value, onChange, onKeyDown, icon, rightElement }) {
const [focused, setFocused] = useState(false);
return (
<div style={{ position: "relative", marginTop: 10 }}>
<div style={{
position: "relative",
border: `1px solid ${focused ? "rgba(124,58,237,0.7)" : "rgba(255,255,255,0.1)"}`,
borderRadius: 10, background: "rgba(255,255,255,0.03)",
transition: "border-color 0.15s",
}}>
<label style={{
position: "absolute", top: -8, left: 12,
fontSize: 10, fontWeight: 700,
color: focused ? "#a855f7" : "rgba(255,255,255,0.35)",
background: "#1a1b22", padding: "0 5px",
letterSpacing: "0.06em", textTransform: "uppercase",
pointerEvents: "none", transition: "color 0.15s",
}}>{label}</label>
<div style={{ display: "flex", alignItems: "center", padding: "13px 14px", gap: 10 }}>
{icon && (
<span style={{ color: focused ? "rgba(168,85,247,0.7)" : "rgba(255,255,255,0.25)", flexShrink: 0, display: "flex", transition: "color 0.15s" }}>
{icon}
</span>
)}
<input
type={type} value={value} onChange={onChange} onKeyDown={onKeyDown}
onFocus={() => setFocused(true)} onBlur={() => setFocused(false)}
style={{
flex: 1, background: "transparent", border: "none", outline: "none",
color: "#f1f5f9", fontSize: 14,
fontFamily: "'Poppins', system-ui, sans-serif",
}}
/>
{rightElement}
</div>
</div>
</div>
);
}
function MiniBarChart() {
const bars = [38, 52, 45, 68, 74, 61, 89];
const months = ["nov", "dec", "jan", "feb", "mrt", "apr", "mei"];
return (
<div style={{ display: "flex", alignItems: "flex-end", gap: 6, height: 48, marginTop: 4 }}>
{bars.map((h, i) => (
<div key={i} style={{ flex: 1, display: "flex", flexDirection: "column", alignItems: "center", gap: 4 }}>
<div style={{
width: "100%", borderRadius: 4,
height: `${h}%`,
background: i === bars.length - 1
? "linear-gradient(180deg, #a855f7, #7c3aed)"
: "rgba(255,255,255,0.12)",
}} />
<span style={{ fontSize: 8, color: "rgba(255,255,255,0.35)", whiteSpace: "nowrap" }}>{months[i]}</span>
</div>
))}
</div>
);
}
export default function LoginPage({ onLogin }) {
const [mode, setMode] = useState("login");
const [naam, setNaam] = useState("");
const [email, setEmail] = useState("");
const [wachtwoord, setWachtwoord] = useState("");
const [herhaalWw, setHerhaalWw] = useState("");
const [showPass, setShowPass] = useState(false);
const [showPass2, setShowPass2] = useState(false);
const [err, setErr] = useState("");
const [loading, setLoading] = useState(false);
const switchMode = (m) => { setMode(m); setNaam(""); setEmail(""); setWachtwoord(""); setHerhaalWw(""); setErr(""); };
const handleSubmit = async () => {
setErr("");
if (mode === "register") {
if (!naam.trim()) { setErr("Vul je naam in."); return; }
if (wachtwoord.length < 8) { setErr("Wachtwoord moet minimaal 8 tekens zijn."); return; }
if (!/[A-Z]/.test(wachtwoord)) { setErr("Wachtwoord moet minimaal één hoofdletter bevatten."); return; }
if (!/[0-9]/.test(wachtwoord)) { setErr("Wachtwoord moet minimaal één cijfer bevatten."); return; }
if (wachtwoord !== herhaalWw) { setErr("Wachtwoorden komen niet overeen."); return; }
}
if (!email || !wachtwoord) { setErr("Vul e-mail en wachtwoord in."); return; }
setLoading(true);
try {
const url = mode === "login" ? "/api/auth/login" : "/api/auth/register";
const body = mode === "login" ? { email, wachtwoord } : { naam, email, wachtwoord };
const res = await fetch(url, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) });
const data = await res.json();
if (!res.ok) { setErr(data.error || "Er is een fout opgetreden."); return; }
localStorage.setItem("vf_token", data.token);
onLogin(data.user);
} catch {
setErr("Kan geen verbinding maken met de server.");
} finally {
setLoading(false);
}
};
const onEnter = (e) => e.key === "Enter" && handleSubmit();
return (
<div style={{
minHeight: "100vh",
background: "#09090f",
display: "flex", alignItems: "center", justifyContent: "center",
fontFamily: "'Poppins', system-ui, sans-serif",
color: "#f1f5f9",
padding: "40px 24px", boxSizing: "border-box",
}}>
{/* Floating card — larger */}
<div style={{
display: "flex", width: "100%", maxWidth: 1080,
minHeight: 620,
borderRadius: 24, overflow: "hidden",
boxShadow: "0 40px 100px rgba(0,0,0,0.75), 0 0 0 1px rgba(255,255,255,0.06)",
}}>
{/* LEFT: Form panel */}
<div style={{
width: 460, flexShrink: 0,
background: "#1a1b22",
padding: "52px 48px", boxSizing: "border-box",
display: "flex", flexDirection: "column",
}}>
{/* Logo */}
<div style={{ display: "flex", alignItems: "center", gap: 10, marginBottom: 40 }}>
<div style={{
width: 36, height: 36, flexShrink: 0, borderRadius: 10,
background: "rgba(124,58,237,0.15)", border: "1px solid rgba(124,58,237,0.3)",
display: "flex", alignItems: "center", justifyContent: "center",
}}>
<svg width="20" height="20" viewBox="0 0 20 20" fill="none">
<polyline points="2,15 6,9 10,12 14,5 18,8" stroke="#a855f7" strokeWidth="2.2" strokeLinejoin="round" strokeLinecap="round"/>
<line x1="2" y1="17" x2="18" y2="17" stroke="rgba(124,58,237,0.35)" strokeWidth="1.4"/>
</svg>
</div>
<span style={{ fontSize: 19, fontWeight: 700, color: "#fff", letterSpacing: "-0.3px" }}>VibeFinance</span>
</div>
{/* Heading */}
<div style={{ marginBottom: 32 }}>
<h1 style={{ fontSize: 26, fontWeight: 800, margin: "0 0 10px", color: "#fff", letterSpacing: "-0.4px" }}>
Welkom terug!
</h1>
<p style={{ fontSize: 14, color: "rgba(255,255,255,0.38)", margin: 0, lineHeight: 1.6 }}>
{mode === "login"
? "Log in om je budgetoverzicht te bekijken."
: "Maak een account aan om je vermogen bij te houden."}
</p>
</div>
{/* Tab switcher */}
<div style={{
display: "flex", background: "rgba(255,255,255,0.05)",
borderRadius: 11, padding: 4, marginBottom: 32,
border: "1px solid rgba(255,255,255,0.07)",
}}>
{[["login", "Inloggen"], ["register", "Registreren"]].map(([m, lbl]) => (
<button key={m} onClick={() => switchMode(m)} style={{
flex: 1, padding: "10px 0", borderRadius: 8,
border: "none", cursor: "pointer",
fontFamily: "'Poppins', system-ui, sans-serif",
fontWeight: 600, fontSize: 13,
transition: "all 0.15s",
background: mode === m ? "linear-gradient(135deg, #7c3aed, #a855f7)" : "transparent",
color: mode === m ? "#fff" : "rgba(255,255,255,0.35)",
boxShadow: mode === m ? "0 4px 14px rgba(124,58,237,0.4)" : "none",
}}>{lbl}</button>
))}
</div>
{/* Form fields */}
<div style={{ display: "flex", flexDirection: "column", gap: 2 }}>
{mode === "register" && (
<FloatingInput
label="Naam" value={naam} icon={<PersonIcon />}
onChange={(e) => { setNaam(e.target.value); setErr(""); }}
onKeyDown={onEnter}
/>
)}
<FloatingInput
label="E-mailadres" type="email" value={email} icon={<MailIcon />}
onChange={(e) => { setEmail(e.target.value); setErr(""); }}
onKeyDown={onEnter}
/>
<FloatingInput
label="Wachtwoord" type={showPass ? "text" : "password"} value={wachtwoord} icon={<LockIcon />}
onChange={(e) => { setWachtwoord(e.target.value); setErr(""); }}
onKeyDown={onEnter}
rightElement={<EyeBtn show={showPass} onToggle={() => setShowPass(s => !s)} />}
/>
{mode === "register" && (
<>
<FloatingInput
label="Herhaal wachtwoord" type={showPass2 ? "text" : "password"} value={herhaalWw} icon={<LockIcon />}
onChange={(e) => { setHerhaalWw(e.target.value); setErr(""); }}
onKeyDown={onEnter}
rightElement={<EyeBtn show={showPass2} onToggle={() => setShowPass2(s => !s)} />}
/>
<div style={{ fontSize: 11, color: "rgba(255,255,255,0.25)", marginTop: 6, paddingLeft: 2 }}>
Min. 8 tekens · één hoofdletter · één cijfer
</div>
</>
)}
</div>
{err && (
<div style={{
background: "rgba(239,68,68,0.08)", border: "1px solid rgba(239,68,68,0.22)",
borderRadius: 8, padding: "9px 12px", marginTop: 16, fontSize: 12, color: RED,
}}>{err}</div>
)}
{/* Submit */}
<button onClick={handleSubmit} disabled={loading} style={{
width: "100%", padding: "14px",
background: loading ? "rgba(124,58,237,0.3)" : "linear-gradient(135deg, #7c3aed, #a855f7)",
border: "none", borderRadius: 11, color: "#fff",
fontFamily: "'Poppins', system-ui, sans-serif",
fontWeight: 700, fontSize: 15, cursor: loading ? "not-allowed" : "pointer",
boxShadow: loading ? "none" : "0 6px 24px rgba(124,58,237,0.45)",
marginTop: 28, opacity: loading ? 0.7 : 1, transition: "opacity 0.15s",
}}>
{loading
? (mode === "login" ? "Inloggen..." : "Account aanmaken...")
: (mode === "login" ? "Inloggen" : "Account aanmaken")}
</button>
</div>
{/* RIGHT: Purple mockup panel */}
<div style={{
flex: 1,
background: "linear-gradient(145deg, #3b0764 0%, #5b21b6 40%, #7c3aed 80%, #9333ea 100%)",
position: "relative", overflow: "hidden",
display: "flex", flexDirection: "column",
alignItems: "center", justifyContent: "center",
padding: "48px 40px",
}}>
{/* Decorative circles */}
<div style={{ position: "absolute", width: 320, height: 320, borderRadius: "50%", background: "rgba(168,85,247,0.2)", top: -100, right: -80, pointerEvents: "none" }} />
<div style={{ position: "absolute", width: 220, height: 220, borderRadius: "50%", background: "rgba(168,85,247,0.15)", bottom: -40, left: -60, pointerEvents: "none" }} />
<div style={{ position: "absolute", width: 120, height: 120, borderRadius: "50%", background: "rgba(168,85,247,0.12)", top: "42%", left: "8%", pointerEvents: "none" }} />
<div style={{ position: "absolute", width: 60, height: 60, borderRadius: "50%", background: "rgba(255,255,255,0.06)", top: "18%", right: "22%", pointerEvents: "none" }} />
{/* Top badge */}
<div style={{
background: "rgba(255,255,255,0.13)", backdropFilter: "blur(8px)",
borderRadius: 99, padding: "7px 16px", fontSize: 12, fontWeight: 600,
color: "rgba(255,255,255,0.92)", marginBottom: 32,
border: "1px solid rgba(255,255,255,0.18)",
display: "flex", alignItems: "center", gap: 7,
position: "relative", zIndex: 2,
}}>
<span style={{ color: "#fbbf24", fontSize: 14 }}>✦</span>
Jouw vermogen in één oogopslag
</div>
{/* Main glassmorphism card */}
<div style={{
background: "rgba(255,255,255,0.11)", backdropFilter: "blur(16px)",
border: "1px solid rgba(255,255,255,0.18)",
borderRadius: 18, padding: "22px 24px",
width: "100%", maxWidth: 320,
boxShadow: "0 12px 40px rgba(0,0,0,0.3)",
position: "relative", zIndex: 2, marginBottom: 16,
}}>
{/* Card header */}
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: 18 }}>
<div>
<div style={{ fontSize: 13, fontWeight: 700, color: "#fff" }}>Vermogensoverzicht</div>
<div style={{ fontSize: 10, color: "rgba(255,255,255,0.5)", marginTop: 2 }}>The Road naar € 500.000</div>
</div>
<div style={{ fontSize: 10, color: "rgba(255,255,255,0.5)", background: "rgba(255,255,255,0.1)", borderRadius: 6, padding: "3px 8px" }}>Mei 2026</div>
</div>
{/* KPI rows */}
{[
{ label: "Eigen Vermogen", val: "€ 445.500", color: "#10b981", icon: "↑" },
{ label: "Schuld", val: "€ 54.500", color: "#f97316", icon: "↓" },
{ label: "Winst", val: "€ 142.500", color: "#a855f7", icon: "↑" },
].map(({ label, val, color, icon }) => (
<div key={label} style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: 12 }}>
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
<div style={{ width: 26, height: 26, borderRadius: 7, background: `${color}22`, display: "flex", alignItems: "center", justifyContent: "center", fontSize: 11, color, fontWeight: 700 }}>{icon}</div>
<span style={{ fontSize: 12, color: "rgba(255,255,255,0.7)" }}>{label}</span>
</div>
<span style={{ fontSize: 13, fontWeight: 700, color: "#fff" }}>{val}</span>
</div>
))}
{/* Progress toward goal */}
<div style={{ marginTop: 16, padding: "12px 14px", background: "rgba(0,0,0,0.2)", borderRadius: 10 }}>
<div style={{ display: "flex", justifyContent: "space-between", marginBottom: 8 }}>
<span style={{ fontSize: 11, color: "rgba(255,255,255,0.6)" }}>Voortgang naar doel</span>
<span style={{ fontSize: 11, fontWeight: 700, color: "#fff" }}>89%</span>
</div>
<div style={{ height: 6, background: "rgba(255,255,255,0.12)", borderRadius: 99, overflow: "hidden" }}>
<div style={{ width: "89%", height: "100%", background: "linear-gradient(90deg, #a855f7, #c084fc)", borderRadius: 99 }} />
</div>
<div style={{ display: "flex", justifyContent: "space-between", marginTop: 6 }}>
<span style={{ fontSize: 9, color: "rgba(255,255,255,0.35)" }}>€ 0</span>
<span style={{ fontSize: 9, color: "rgba(255,255,255,0.35)" }}>€ 500.000</span>
</div>
</div>
{/* Mini bar chart */}
<div style={{ marginTop: 14 }}>
<div style={{ fontSize: 10, color: "rgba(255,255,255,0.45)", marginBottom: 6 }}>Groei per maand</div>
<MiniBarChart />
</div>
</div>
{/* Floating badges */}
<div style={{ display: "flex", gap: 12, position: "relative", zIndex: 2 }}>
<div style={{
background: "rgba(255,255,255,0.95)", borderRadius: 14, padding: "12px 16px",
boxShadow: "0 8px 24px rgba(0,0,0,0.25)",
}}>
<div style={{ fontSize: 10, color: "#64748b", fontWeight: 600, marginBottom: 3 }}>ROI</div>
<div style={{ fontSize: 18, fontWeight: 800, color: "#10b981" }}>+47,03%</div>
</div>
<div style={{
background: "rgba(255,255,255,0.95)", borderRadius: 14, padding: "12px 16px",
boxShadow: "0 8px 24px rgba(0,0,0,0.25)",
}}>
<div style={{ fontSize: 10, color: "#64748b", fontWeight: 600, marginBottom: 3 }}>Doel</div>
<div style={{ fontSize: 18, fontWeight: 800, color: "#7c3aed" }}>€ 500.000</div>
</div>
</div>
{/* Bottom tagline */}
<div style={{ position: "absolute", bottom: 24, left: 40, right: 40, fontSize: 12, color: "rgba(255,255,255,0.4)", lineHeight: 1.6, textAlign: "center" }}>
Beheer je vermogen, volg je groei en bereik je financiële doelen — allemaal op één plek.
</div>
</div>
</div>
</div>
);
}
+286
View File
@@ -0,0 +1,286 @@
import { useState, useEffect } from "react";
import { TABS } from "../constants/index.js";
import { version, updateCheckUrl } from "../../package.json";
import { useApp } from "../context/AppContext.jsx";
const TAB_ICONS = [
<svg width="16" height="16" viewBox="0 0 16 16" fill="none">
<rect x="1" y="1" width="6" height="6" rx="1.5" stroke="currentColor" strokeWidth="1.5"/>
<rect x="9" y="1" width="6" height="6" rx="1.5" stroke="currentColor" strokeWidth="1.5"/>
<rect x="1" y="9" width="6" height="6" rx="1.5" stroke="currentColor" strokeWidth="1.5"/>
<rect x="9" y="9" width="6" height="6" rx="1.5" stroke="currentColor" strokeWidth="1.5"/>
</svg>,
<svg width="16" height="16" viewBox="0 0 16 16" fill="none">
<polyline points="1,12 5,7 8,10 11,4 15,6" stroke="currentColor" strokeWidth="1.5" strokeLinejoin="round" strokeLinecap="round"/>
<polyline points="11,4 15,4 15,8" stroke="currentColor" strokeWidth="1.5" strokeLinejoin="round" strokeLinecap="round"/>
</svg>,
<svg width="16" height="16" viewBox="0 0 16 16" fill="none">
<rect x="1" y="3" width="14" height="10" rx="2" stroke="currentColor" strokeWidth="1.5"/>
<line x1="1" y1="6.5" x2="15" y2="6.5" stroke="currentColor" strokeWidth="1.5"/>
<line x1="3" y1="10" x2="6" y2="10" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round"/>
</svg>,
<svg width="16" height="16" viewBox="0 0 16 16" fill="none">
<rect x="1.5" y="8" width="3" height="6" rx="1" fill="currentColor"/>
<rect x="6.5" y="5" width="3" height="9" rx="1" fill="currentColor"/>
<rect x="11.5" y="2" width="3" height="12" rx="1" fill="currentColor"/>
</svg>,
];
const GEBRUIKERS_ICON = (
<svg width="16" height="16" viewBox="0 0 16 16" fill="none">
<circle cx="6" cy="5" r="2.5" stroke="currentColor" strokeWidth="1.5"/>
<path d="M1 13c0-2.5 2-4 5-4s5 1.5 5 4" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round"/>
<circle cx="12.5" cy="5" r="2" stroke="currentColor" strokeWidth="1.4"/>
<path d="M14.5 13c0-1.5-.8-2.7-2-3.3" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round"/>
</svg>
);
const INSTELLINGEN_ICON = (
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
<path fillRule="evenodd" clipRule="evenodd" d="M6.5 1a.75.75 0 0 0-.743.648l-.16 1.112a5.07 5.07 0 0 0-.88.51l-1.054-.424a.75.75 0 0 0-.9.31L1.263 4.95a.75.75 0 0 0 .165.96l.888.717a5.14 5.14 0 0 0 0 1.018l-.888.717a.75.75 0 0 0-.165.96l1.5 2.598a.75.75 0 0 0 .9.31l1.053-.424c.276.19.572.36.881.51l.16 1.112A.75.75 0 0 0 6.5 14h3a.75.75 0 0 0 .743-.648l.16-1.112c.309-.15.605-.32.88-.51l1.054.424a.75.75 0 0 0 .9-.31l1.5-2.598a.75.75 0 0 0-.165-.96l-.888-.717a5.14 5.14 0 0 0 0-1.018l.888-.717a.75.75 0 0 0 .165-.96l-1.5-2.598a.75.75 0 0 0-.9-.31l-1.053.424a5.07 5.07 0 0 0-.881-.51L9.743 1.648A.75.75 0 0 0 9.5 1h-3Zm1.5 4.5a2.5 2.5 0 1 0 0 5 2.5 2.5 0 0 0 0-5Z"/>
</svg>
);
const LOGOUT_ICON = (
<svg width="16" height="16" viewBox="0 0 16 16" fill="none">
<path d="M6 2H3a1 1 0 00-1 1v10a1 1 0 001 1h3" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round"/>
<polyline points="10,5 13,8 10,11" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/>
<line x1="13" y1="8" x2="6" y2="8" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round"/>
</svg>
);
const INFO_ICON = (
<svg width="16" height="16" viewBox="0 0 16 16" fill="none">
<circle cx="8" cy="8" r="6.5" stroke="currentColor" strokeWidth="1.5"/>
<line x1="8" y1="7" x2="8" y2="11.5" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round"/>
<circle cx="8" cy="4.5" r="0.8" fill="currentColor"/>
</svg>
);
const MOON_ICON = (
<svg width="16" height="16" viewBox="0 0 16 16" fill="none">
<path d="M13.5 9.5A6 6 0 016.5 2.5a6 6 0 100 11 6 6 0 007-4z" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/>
</svg>
);
function initials(naam = "") {
const parts = naam.trim().split(/\s+/);
if (parts.length >= 2) return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase();
return naam.slice(0, 2).toUpperCase();
}
function SectionLabel({ children }) {
return (
<div style={{
fontSize: 11, fontWeight: 700, letterSpacing: "0.12em",
textTransform: "uppercase", color: "#3d4966",
padding: "0 12px", marginBottom: 2, marginTop: 20,
}}>
{children}
</div>
);
}
const ACTIVE_BG = "#7c3aed";
const ACTIVE_SHADOW = "0 4px 14px rgba(124,58,237,0.4)";
function NavItem({ active, onClick, icon, label, T, danger }) {
const [hovered, setHovered] = useState(false);
const isLit = active || hovered;
return (
<div
onClick={onClick}
onMouseEnter={() => setHovered(true)}
onMouseLeave={() => setHovered(false)}
style={{
display: "flex", alignItems: "center", gap: 10,
padding: "11px 12px", borderRadius: 9, cursor: "pointer", marginBottom: 4,
background: active
? ACTIVE_BG
: hovered ? "rgba(139,92,246,0.08)" : "transparent",
color: active ? "#fff" : hovered ? T.text : (danger ? "#ef4444" : T.muted),
fontWeight: active ? 600 : 400,
fontSize: 15,
boxShadow: active ? ACTIVE_SHADOW : "none",
transition: "all 0.15s",
}}
>
<span style={{ flexShrink: 0, display: "flex", color: "inherit", opacity: active ? 1 : (isLit ? 0.85 : 0.55) }}>{icon}</span>
<span style={{ flex: 1 }}>{label}</span>
</div>
);
}
function OverPopup({ onClose, nieuweVersie, T }) {
return (
<>
{/* Overlay */}
<div onClick={onClose} style={{
position: "fixed", inset: 0, zIndex: 490,
background: "rgba(0,0,0,0.55)", backdropFilter: "blur(4px)",
display: "flex", alignItems: "center", justifyContent: "center",
}}>
{/* Modal */}
<div onClick={(e) => e.stopPropagation()} style={{
background: T.card,
border: `1px solid ${T.border}`,
borderRadius: 16, padding: "28px 28px 24px", width: 300,
boxShadow: "0 24px 60px rgba(0,0,0,0.5)",
position: "relative",
}}>
{/* Sluitknop */}
<button onClick={onClose} style={{
position: "absolute", top: 14, right: 14,
background: "transparent", border: "none", cursor: "pointer",
color: T.muted, fontSize: 20, lineHeight: 1, padding: 4,
}}>×</button>
{/* Logo + naam */}
<div style={{ display: "flex", alignItems: "center", gap: 12, marginBottom: 20 }}>
<div style={{
width: 44, height: 44, borderRadius: 12, flexShrink: 0,
background: "rgba(124,58,237,0.15)", border: "1px solid rgba(124,58,237,0.3)",
display: "flex", alignItems: "center", justifyContent: "center",
}}>
<svg width="22" height="22" viewBox="0 0 20 20" fill="none">
<polyline points="2,15 6,9 10,12 14,5 18,8" stroke="#a855f7" strokeWidth="2.2" strokeLinejoin="round" strokeLinecap="round"/>
<line x1="2" y1="17" x2="18" y2="17" stroke="rgba(168,85,247,0.35)" strokeWidth="1.4"/>
</svg>
</div>
<div>
<div style={{ fontSize: 15, fontWeight: 700, color: T.text }}>VibeFinance</div>
</div>
</div>
<div style={{ height: 1, background: T.border, marginBottom: 16 }} />
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: 10 }}>
<span style={{ fontSize: 13, color: T.muted }}>Versie</span>
<span style={{ fontSize: 12, fontWeight: 700, color: "#a855f7", background: "rgba(124,58,237,0.15)", borderRadius: 6, padding: "3px 10px" }}>
v{version}
</span>
</div>
{nieuweVersie && (
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: 10 }}>
<span style={{ fontSize: 13, color: T.muted }}>Nieuw beschikbaar</span>
<span style={{ fontSize: 12, fontWeight: 700, color: "#f97316", background: "rgba(249,115,22,0.12)", borderRadius: 6, padding: "3px 10px" }}>
v{nieuweVersie}
</span>
</div>
)}
<div style={{ height: 1, background: T.border, margin: "12px 0" }} />
<div style={{ fontSize: 12, color: T.muted, textAlign: "center" }}>© {new Date().getFullYear()} VibeFinance</div>
</div>
</div>
</>
);
}
function DarkModeToggle({ darkMode, setDarkMode, T }) {
return (
<div
onClick={() => setDarkMode(!darkMode)}
style={{
display: "flex", alignItems: "center", gap: 10,
padding: "11px 12px", borderRadius: 9, cursor: "pointer", marginBottom: 4,
color: T.muted, fontSize: 13.5, fontWeight: 500,
transition: "background 0.12s",
}}
onMouseEnter={e => e.currentTarget.style.background = "rgba(255,255,255,0.04)"}
onMouseLeave={e => e.currentTarget.style.background = "transparent"}
>
<span style={{ flexShrink: 0, display: "flex", opacity: 0.6 }}>{MOON_ICON}</span>
<span style={{ flex: 1 }}>Dark Mode</span>
{/* Toggle pill */}
<div style={{
width: 36, height: 20, borderRadius: 99, flexShrink: 0,
background: darkMode ? "#7c3aed" : "rgba(255,255,255,0.15)",
position: "relative", transition: "background 0.2s",
}}>
<div style={{
position: "absolute", top: 3, left: darkMode ? 19 : 3,
width: 14, height: 14, borderRadius: "50%",
background: "#fff", transition: "left 0.2s",
boxShadow: "0 1px 4px rgba(0,0,0,0.3)",
}} />
</div>
</div>
);
}
export default function NavBar() {
const { tab, setTab, setShowUsers, setShowInstellingen, T, logout, darkMode, setDarkMode, currentUser, avatar } = useApp();
const [nieuweVersie, setNieuweVersie] = useState(null);
const [overOpen, setOverOpen] = useState(false);
useEffect(() => {
if (!updateCheckUrl) return;
fetch(updateCheckUrl)
.then(r => r.json())
.then(data => {
const latest = data?.tag_name?.replace(/^v/, "");
if (latest && latest !== version) setNieuweVersie(latest);
})
.catch(() => {});
}, []);
return (
<div style={{
width: 260, minWidth: 260, flexShrink: 0,
height: "100vh", position: "sticky", top: 0,
background: T.sidebar ?? T.card,
borderRight: `1px solid ${T.border}`,
display: "flex", flexDirection: "column",
zIndex: 100,
}}>
{/* Logo */}
<div style={{ padding: "22px 16px 28px", display: "flex", alignItems: "center", gap: 10 }}>
<div style={{
width: 32, height: 32, flexShrink: 0, borderRadius: 9,
background: "rgba(124,58,237,0.15)",
border: "1px solid rgba(124,58,237,0.3)",
display: "flex", alignItems: "center", justifyContent: "center",
}}>
<svg width="18" height="18" viewBox="0 0 20 20" fill="none">
<polyline points="2,15 6,9 10,12 14,5 18,8"
stroke="#a855f7" strokeWidth="2.2"
strokeLinejoin="round" strokeLinecap="round"/>
<line x1="2" y1="17" x2="18" y2="17"
stroke="rgba(168,85,247,0.35)" strokeWidth="1.4"/>
</svg>
</div>
<span style={{ fontSize: 17, fontWeight: 700, color: T.text, letterSpacing: "-0.3px" }}>VibeFinance</span>
</div>
{/* Nav */}
<nav style={{ flex: 1, padding: "0 8px", overflowY: "auto" }}>
{TABS.map((t, i) => (
<NavItem key={t} active={tab === i} onClick={() => setTab(i)} icon={TAB_ICONS[i]} label={t} T={T} />
))}
<NavItem onClick={() => setShowUsers(true)} icon={GEBRUIKERS_ICON} label="Gebruikers" T={T} />
<NavItem onClick={() => setShowInstellingen(true)} icon={INSTELLINGEN_ICON} label="Instellingen" T={T} />
<div style={{ position: "relative" }}>
<NavItem onClick={() => setOverOpen(o => !o)} icon={INFO_ICON} label="Over VibeFinance" active={false} T={T} />
{overOpen && <OverPopup onClose={() => setOverOpen(false)} nieuweVersie={nieuweVersie} T={T} />}
{nieuweVersie && !overOpen && (
<div style={{ position: "absolute", top: 10, right: 26, width: 6, height: 6, borderRadius: "50%", background: "#f97316", pointerEvents: "none" }} />
)}
</div>
</nav>
{/* Uitloggen */}
<div style={{ padding: "8px 8px 12px", borderTop: `1px solid ${T.border}` }}>
<NavItem onClick={logout} icon={LOGOUT_ICON} label="Log Out" T={T} />
</div>
</div>
);
}
+265
View File
@@ -0,0 +1,265 @@
import { useState } from "react";
import { useApp } from "../context/AppContext.jsx";
import { RED } from "../constants/index.js";
const TEAL = "linear-gradient(135deg, #7c3aed, #a855f7)";
const TEAL_SHADOW = "0 4px 14px rgba(124,58,237,0.4)";
function initials(naam = "") {
const parts = naam.trim().split(/\s+/);
if (parts.length >= 2) return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase();
return naam.slice(0, 2).toUpperCase();
}
const LOCK_ICON = (
<svg width="16" height="16" viewBox="0 0 16 16" fill="none">
<rect x="3" y="7" width="10" height="8" rx="2" stroke="currentColor" strokeWidth="1.5"/>
<path d="M5 7V5a3 3 0 016 0v2" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round"/>
</svg>
);
const LOGOUT_ICON = (
<svg width="16" height="16" viewBox="0 0 16 16" fill="none">
<path d="M6 2H3a1 1 0 00-1 1v10a1 1 0 001 1h3" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round"/>
<polyline points="10,5 13,8 10,11" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/>
<line x1="13" y1="8" x2="6" y2="8" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round"/>
</svg>
);
const CHEVRON = (
<svg width="14" height="14" viewBox="0 0 14 14" fill="none">
<polyline points="5,3 9,7 5,11" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/>
</svg>
);
const BACK_ICON = (
<svg width="16" height="16" viewBox="0 0 16 16" fill="none">
<polyline points="10,3 6,8 10,13" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/>
</svg>
);
function InputField({ label, type = "text", value, onChange, T }) {
return (
<div>
<label style={{ fontSize: 11, color: T.muted, fontWeight: 600, display: "block", marginBottom: 5, textTransform: "uppercase", letterSpacing: "0.06em" }}>{label}</label>
<input type={type} value={value} onChange={onChange} style={{
width: "100%", padding: "10px 12px", borderRadius: 10, fontSize: 13,
background: T.bg, border: `1px solid ${T.border}`, color: T.text, outline: "none",
}} />
</div>
);
}
export default function ProfielPopup({ onClose }) {
const { currentUser, updateProfile, changePassword, logout, T, avatar, setAvatar } = useApp();
const [view, setView] = useState("main");
const [naam, setNaam] = useState(currentUser?.naam || "");
const [email, setEmail] = useState(currentUser?.email || "");
const [profielErr, setProfielErr] = useState("");
const [profielOk, setProfielOk] = useState(false);
const [profielBusy, setProfielBusy] = useState(false);
const [huidig, setHuidig] = useState("");
const [nieuw, setNieuw] = useState("");
const [bevestig, setBevestig] = useState("");
const [pwErr, setPwErr] = useState("");
const [pwOk, setPwOk] = useState(false);
const [pwBusy, setPwBusy] = useState(false);
const saveProfiel = async () => {
setProfielErr(""); setProfielOk(false);
if (!naam.trim() || !email.trim()) { setProfielErr("Naam en e-mail zijn verplicht."); return; }
setProfielBusy(true);
try { await updateProfile(naam.trim(), email.trim()); setProfielOk(true); }
catch (err) { setProfielErr(err.message || "Opslaan mislukt."); }
finally { setProfielBusy(false); }
};
const saveWachtwoord = async () => {
setPwErr(""); setPwOk(false);
if (!huidig || !nieuw || !bevestig) { setPwErr("Vul alle velden in."); return; }
if (nieuw !== bevestig) { setPwErr("Nieuwe wachtwoorden komen niet overeen."); return; }
if (nieuw.length < 8) { setPwErr("Minimaal 8 tekens."); return; }
setPwBusy(true);
try { await changePassword(huidig, nieuw); setPwOk(true); setHuidig(""); setNieuw(""); setBevestig(""); }
catch (err) { setPwErr(err.message || "Wijzigen mislukt."); }
finally { setPwBusy(false); }
};
const popupStyle = {
position: "fixed", top: 64, right: 16, zIndex: 199,
background: T.card, border: `1px solid ${T.border}`,
borderRadius: 16, width: 340,
boxShadow: "0 16px 48px rgba(0,0,0,0.45)",
overflow: "hidden",
};
const headerStyle = {
display: "flex", alignItems: "center", justifyContent: "center",
padding: "16px 16px 14px", position: "relative",
borderBottom: `1px solid ${T.border}`,
};
const closeBtn = (
<button onClick={onClose} style={{
position: "absolute", right: 14, top: "50%", transform: "translateY(-50%)",
background: "transparent", border: "none", cursor: "pointer",
color: T.muted, fontSize: 20, lineHeight: 1, padding: 4,
}}>×</button>
);
const backBtn = (label) => (
<button onClick={() => setView("main")} style={{
position: "absolute", left: 12, top: "50%", transform: "translateY(-50%)",
background: "transparent", border: "none", cursor: "pointer",
color: T.muted, display: "flex", alignItems: "center", padding: 4,
}}>{BACK_ICON}</button>
);
const actionRow = (icon, label, onClick, color) => (
<div onClick={onClick} style={{
display: "flex", alignItems: "center", gap: 12,
padding: "14px 18px", cursor: "pointer",
color: color || T.text,
}}>
<span style={{ color: color || T.muted, display: "flex" }}>{icon}</span>
<span style={{ flex: 1, fontWeight: 600, fontSize: 14 }}>{label}</span>
{!color && <span style={{ color: T.muted, display: "flex" }}>{CHEVRON}</span>}
</div>
);
return (
<>
<div onClick={onClose} style={{ position: "fixed", inset: 0, zIndex: 198 }} />
<div style={popupStyle}>
{/* ── Hoofdscherm ── */}
{view === "main" && (
<>
<div style={headerStyle}>
<span style={{ fontWeight: 700, fontSize: 15, color: T.text }}>Mijn account</span>
{closeBtn}
</div>
{/* Avatar + naam + email */}
<div style={{ display: "flex", flexDirection: "column", alignItems: "center", padding: "24px 18px 20px", gap: 6 }}>
<div style={{
width: 72, height: 72, borderRadius: "50%", overflow: "hidden",
background: avatar ? "transparent" : TEAL,
display: "flex", alignItems: "center", justifyContent: "center",
fontSize: 24, fontWeight: 800, color: "#fff",
boxShadow: "0 0 20px rgba(124,58,237,0.4)", marginBottom: 4,
}}>
{avatar
? <img src={avatar} alt="avatar" style={{ width: "100%", height: "100%", objectFit: "cover" }} />
: initials(currentUser?.naam)
}
</div>
<div style={{ fontWeight: 700, fontSize: 16, color: T.text }}>{currentUser?.naam}</div>
<div style={{ fontSize: 13, color: T.muted }}>{currentUser?.email}</div>
<button onClick={() => setView("profiel")} style={{
marginTop: 8, padding: "9px 24px",
background: TEAL,
border: "none", borderRadius: 10, color: "#fff",
fontWeight: 700, fontSize: 13, cursor: "pointer",
boxShadow: TEAL_SHADOW,
}}>Profiel bewerken</button>
</div>
<div style={{ height: 1, background: T.border }} />
{actionRow(LOCK_ICON, "Wachtwoord wijzigen", () => setView("wachtwoord"))}
</>
)}
{/* ── Profiel bewerken ── */}
{view === "profiel" && (
<>
<div style={headerStyle}>
{backBtn()}
<span style={{ fontWeight: 700, fontSize: 15, color: T.text }}>Profiel bewerken</span>
{closeBtn}
</div>
<div style={{ padding: "20px 18px", display: "flex", flexDirection: "column", gap: 14 }}>
{/* Avatar — klikbaar om foto te uploaden */}
<div style={{ display: "flex", justifyContent: "center", marginBottom: 4 }}>
<label style={{ cursor: "pointer", position: "relative", display: "block" }} title="Klik om foto te uploaden">
<div style={{
width: 64, height: 64, borderRadius: "50%", overflow: "hidden",
background: avatar ? "transparent" : TEAL,
display: "flex", alignItems: "center", justifyContent: "center",
fontSize: 20, fontWeight: 800, color: "#fff",
}}>
{avatar
? <img src={avatar} alt="avatar" style={{ width: "100%", height: "100%", objectFit: "cover" }} />
: initials(currentUser?.naam)
}
</div>
<div style={{
position: "absolute", inset: 0, borderRadius: "50%",
background: "rgba(0,0,0,0.35)", display: "flex", alignItems: "center", justifyContent: "center",
opacity: 0, transition: "opacity 0.15s",
}} className="avatar-overlay">
<svg width="20" height="20" viewBox="0 0 20 20" fill="none">
<path d="M13 3H7L5 6H2a1 1 0 00-1 1v9a1 1 0 001 1h16a1 1 0 001-1V7a1 1 0 00-1-1h-3L13 3z" stroke="#fff" strokeWidth="1.5" strokeLinejoin="round"/>
<circle cx="10" cy="11" r="2.5" stroke="#fff" strokeWidth="1.5"/>
</svg>
</div>
<input type="file" accept="image/*" style={{ display: "none" }} onChange={(e) => {
const file = e.target.files?.[0]; if (!file) return;
const reader = new FileReader();
reader.onload = (ev) => setAvatar(ev.target.result);
reader.readAsDataURL(file);
}} />
</label>
</div>
<style>{`.avatar-overlay { opacity: 0 !important; } label:hover .avatar-overlay { opacity: 1 !important; }`}</style>
{avatar && (
<button onClick={() => setAvatar(null)} style={{
alignSelf: "center", padding: "4px 12px", background: "transparent",
border: `1px solid ${T.border}`, borderRadius: 8,
color: T.muted, fontSize: 12, fontWeight: 600, cursor: "pointer",
}}>Foto verwijderen</button>
)}
<InputField label="Naam" value={naam} onChange={(e) => { setNaam(e.target.value); setProfielOk(false); }} T={T} />
<InputField label="E-mail" type="email" value={email} onChange={(e) => { setEmail(e.target.value); setProfielOk(false); }} T={T} />
{profielErr && <div style={{ fontSize: 12, color: RED }}>{profielErr}</div>}
{profielOk && <div style={{ fontSize: 12, color: "#10b981" }}>Profiel opgeslagen.</div>}
<button onClick={saveProfiel} disabled={profielBusy} style={{
padding: "11px", background: TEAL,
border: "none", borderRadius: 10, color: "#fff", fontWeight: 700,
fontSize: 13, cursor: profielBusy ? "default" : "pointer", opacity: profielBusy ? 0.7 : 1,
}}>{profielBusy ? "Opslaan…" : "Opslaan"}</button>
</div>
</>
)}
{/* ── Wachtwoord wijzigen ── */}
{view === "wachtwoord" && (
<>
<div style={headerStyle}>
{backBtn()}
<span style={{ fontWeight: 700, fontSize: 15, color: T.text }}>Wachtwoord wijzigen</span>
{closeBtn}
</div>
<div style={{ padding: "20px 18px", display: "flex", flexDirection: "column", gap: 14 }}>
<InputField label="Huidig wachtwoord" type="password" value={huidig} onChange={(e) => { setHuidig(e.target.value); setPwOk(false); }} T={T} />
<InputField label="Nieuw wachtwoord" type="password" value={nieuw} onChange={(e) => { setNieuw(e.target.value); setPwOk(false); }} T={T} />
<InputField label="Bevestig nieuw" type="password" value={bevestig} onChange={(e) => { setBevestig(e.target.value); setPwOk(false); }} T={T} />
{pwErr && <div style={{ fontSize: 12, color: RED }}>{pwErr}</div>}
{pwOk && <div style={{ fontSize: 12, color: "#10b981" }}>Wachtwoord gewijzigd.</div>}
<button onClick={saveWachtwoord} disabled={pwBusy} style={{
padding: "11px", background: TEAL,
border: "none", borderRadius: 10, color: "#fff", fontWeight: 700,
fontSize: 13, cursor: pwBusy ? "default" : "pointer", opacity: pwBusy ? 0.7 : 1,
}}>{pwBusy ? "Opslaan…" : "Opslaan"}</button>
</div>
</>
)}
</div>
</>
);
}
+498
View File
@@ -0,0 +1,498 @@
import { useRef, useState, useCallback, useContext, createContext } from "react";
import { PURPLE, PURPLE_LIGHT, EMOJI_OPTIONS } from "../../constants/index.js";
const DragContext = createContext(null);
// ── VCard ─────────────────────────────────────────────────────────────────────
export function VCard({ children, style = {} }) {
return (
<div style={{
borderRadius: 12, padding: "16px 18px",
boxShadow: "none",
...style,
}}>
{children}
</div>
);
}
// ── VBadge ────────────────────────────────────────────────────────────────────
export function VBadge({ label, color = PURPLE }) {
return (
<span style={{
background: `${color}22`, color,
border: `1px solid ${color}44`,
borderRadius: 99, padding: "2px 10px",
fontSize: 11, fontWeight: 700,
}}>
{label}
</span>
);
}
// ── TabBtn ────────────────────────────────────────────────────────────────────
export function TabBtn({ active, onClick, children, darkMode = true }) {
return (
<button
onClick={onClick}
style={{
padding: "8px 16px", border: "none", borderRadius: 8, cursor: "pointer",
fontSize: 13, fontWeight: active ? 700 : 500,
background: active ? `${PURPLE}22` : "transparent",
color: active ? PURPLE_LIGHT : (darkMode ? "#94a3b8" : "#64748b"),
borderBottom: active ? `2px solid ${PURPLE_LIGHT}` : "2px solid transparent",
transition: "all 0.15s",
}}
>
{children}
</button>
);
}
// ── VProgressBar ──────────────────────────────────────────────────────────────
export function VProgressBar({ value, max, color = PURPLE }) {
const p = Math.min((value / (max || 1)) * 100, 100);
return (
<div style={{ height: 8, background: "rgba(255,255,255,0.07)", borderRadius: 99, overflow: "hidden" }}>
<div style={{
height: "100%", width: `${p}%`, background: color,
borderRadius: 99, transition: "width 0.5s ease",
}} />
</div>
);
}
// ── DonutChart ────────────────────────────────────────────────────────────────
export function DonutChart({ segments, total, size = 120, label, centerVal }) {
const strokeW = size * 0.155;
const r = (size - strokeW) / 2 - 1;
const cx = size / 2, cy = size / 2;
const circ = 2 * Math.PI * r;
const active = segments.filter((s) => s.val > 0);
const GAP = active.length > 1 ? circ * 0.022 : 0;
let off = 0;
const slices = active.map((s) => {
const raw = (s.val / total) * circ;
const dash = Math.max(raw - GAP, 2);
const sl = { ...s, dash, offset: off };
off += raw;
return sl;
});
const glowId = `dgl${size}`;
return (
<svg width={size} height={size} viewBox={`0 0 ${size} ${size}`} style={{ overflow: "visible" }}>
<defs>
<filter id={glowId} x="-40%" y="-40%" width="180%" height="180%">
<feGaussianBlur stdDeviation={size * 0.022} />
</filter>
</defs>
{/* Dark inner fill */}
<circle cx={cx} cy={cy} r={r - strokeW / 2 + 1} fill="rgba(0,0,0,0.55)" />
{/* Track */}
<circle cx={cx} cy={cy} r={r} fill="none"
stroke="rgba(255,255,255,0.06)" strokeWidth={strokeW} />
{/* Glow pass */}
{slices.map((s, i) => (
<circle key={`g${i}`} cx={cx} cy={cy} r={r} fill="none"
stroke={s.color} strokeWidth={strokeW * 1.5}
strokeDasharray={`${s.dash} ${circ - s.dash}`}
strokeDashoffset={-s.offset + circ / 4}
strokeLinecap="round"
opacity="0.18"
filter={`url(#${glowId})`}
/>
))}
{/* Crisp segments */}
{slices.map((s, i) => (
<circle key={`c${i}`} cx={cx} cy={cy} r={r} fill="none"
stroke={s.color} strokeWidth={strokeW}
strokeDasharray={`${s.dash} ${circ - s.dash}`}
strokeDashoffset={-s.offset + circ / 4}
strokeLinecap="round"
/>
))}
{/* Center label */}
{label && (
<text x={cx} y={cy - size * 0.1} fill="rgba(255,255,255,0.4)"
fontSize={size * 0.09} textAnchor="middle" dominantBaseline="middle"
fontFamily="inherit">
{label}
</text>
)}
{/* Center value */}
{centerVal && (
<text x={cx} y={cy + size * 0.09} fill="#fff"
fontSize={size * 0.155} fontWeight="800" textAnchor="middle" dominantBaseline="middle"
fontFamily="inherit">
{centerVal}
</text>
)}
</svg>
);
}
// ── SvgLineChart ──────────────────────────────────────────────────────────────
export function SvgLineChart({ pts, lines, T, H = 180 }) {
if (pts.length < 2) {
return (
<div style={{ color: T.muted, fontSize: 12, textAlign: "center", padding: "24px 0" }}>
Minimaal 2 datapunten nodig
</div>
);
}
const W = 580, pl = 64, pr = 14, pt2 = 16, pb = 30;
const iW = W - pl - pr, iH = H - pt2 - pb;
const allVals = lines.flatMap((l) => pts.map((p) => p[l.key]));
const mn = Math.min(0, ...allVals);
const mx = Math.max(...allVals) * 1.08 || 1;
const rng = mx - mn || 1;
const xp = (i) => pl + (i / (pts.length - 1)) * iW;
const yp = (v) => pt2 + (1 - (v - mn) / rng) * iH;
const gc = T.bg === "#0f1117" ? "rgba(255,255,255,0.05)" : "rgba(0,0,0,0.05)";
return (
<div style={{ overflowX: "auto" }}>
<svg viewBox={`0 0 ${W} ${H}`}
style={{ width: "100%", minWidth: 340, height: "auto", display: "block" }}>
<defs>
{lines.map((l) => (
<linearGradient key={l.key} id={`g_${l.key}`} x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor={l.color} stopOpacity="0.25" />
<stop offset="100%" stopColor={l.color} stopOpacity="0.02" />
</linearGradient>
))}
</defs>
{[0, 0.25, 0.5, 0.75, 1].map((f) => {
const gy = pt2 + (1 - f) * iH;
const v = mn + f * rng;
return (
<g key={f}>
<line x1={pl} y1={gy} x2={W - pr} y2={gy} stroke={gc} strokeWidth="1" />
<text x={pl - 4} y={gy + 4} fill={T.muted} fontSize="8" textAnchor="end">
{new Intl.NumberFormat("nl-NL", { style: "currency", currency: "EUR", maximumFractionDigits: 0 }).format(v)}
</text>
</g>
);
})}
{lines.map((l) => {
const xs = pts.map((_, i) => xp(i));
const ys = pts.map((p) => yp(p[l.key]));
const ls = xs.map((x, i) => `${x},${ys[i]}`).join(" ");
const ar = `${xs[0]},${pt2 + iH} ${ls} ${xs[xs.length - 1]},${pt2 + iH}`;
return (
<g key={l.key}>
<polygon points={ar} fill={`url(#g_${l.key})`} />
<polyline points={ls} fill="none" stroke={l.color} strokeWidth="2.5"
strokeLinejoin="round" strokeLinecap="round" />
{xs.map((x, i) => (
<circle key={i} cx={x} cy={ys[i]} r="4"
fill={l.color} stroke={T.card} strokeWidth="2" />
))}
</g>
);
})}
{pts.map((p, i) => {
const [y, m, d] = (p.datum || "").split("-");
const label = d ? `${d}-${m}-${y}` : p.datum;
return (
<text key={i} x={xp(i)} y={H - 8} fill={T.muted} fontSize="8" textAnchor="middle">
{label}
</text>
);
})}
</svg>
<div style={{ display: "flex", gap: 20, justifyContent: "center", marginTop: 10 }}>
{lines.map((l) => (
<div key={l.key} style={{ display: "flex", alignItems: "center", gap: 6 }}>
<div style={{ width: 18, height: 3, background: l.color, borderRadius: 2 }} />
<div style={{ width: 8, height: 8, borderRadius: "50%", background: l.color }} />
<span style={{ fontSize: 12, color: T.muted }}>{l.label}</span>
</div>
))}
</div>
</div>
);
}
// ── EthIcon ───────────────────────────────────────────────────────────────────
export function EthIcon({ size = 18, color = "#3b82f6" }) {
const w = size * 0.6, h = size;
return (
<svg width={w} height={h} viewBox="0 0 60 100" xmlns="http://www.w3.org/2000/svg"
style={{ display: "inline-block", verticalAlign: "middle" }}>
<polygon points="30,2 58,50 30,38" fill={color} opacity="1" />
<polygon points="2,50 30,2 30,38" fill={color} opacity="0.6" />
<polygon points="30,62 58,50 30,38" fill={color} opacity="0.6" />
<polygon points="2,50 30,38 30,62" fill={color} opacity="0.3" />
<polygon points="30,98 58,50 30,62" fill={color} opacity="0.6" />
<polygon points="2,50 30,62 30,98" fill={color} opacity="1" />
</svg>
);
}
// ── BtcIcon ───────────────────────────────────────────────────────────────────
export function BtcIcon({ size = 18, color = "#f59e0b" }) {
return (
<svg width={size} height={size} viewBox="0 0 64 64" xmlns="http://www.w3.org/2000/svg"
style={{ display: "inline-block", verticalAlign: "middle" }}>
<circle cx="32" cy="32" r="30" fill={color} />
<text x="32" y="44" textAnchor="middle" fontSize="36" fontWeight="bold"
fontFamily="Arial, sans-serif" fill="#fff">₿</text>
</svg>
);
}
// ── GoldIcon ──────────────────────────────────────────────────────────────────
export function GoldIcon({ size = 24 }) {
return (
<svg width={size} height={size * 0.72} viewBox="0 0 100 72" xmlns="http://www.w3.org/2000/svg"
style={{ display: "inline-block", verticalAlign: "middle" }}>
{/* Bovenkant */}
<polygon points="18,4 82,4 100,20 0,20" fill="#fcd34d" />
{/* Voorkant */}
<rect x="0" y="20" width="100" height="40" fill="#f59e0b" />
{/* Rechterkant */}
<polygon points="100,20 100,60 82,72 82,36" fill="#d97706" />
{/* Linkeronderkant */}
<polygon points="0,60 0,20 18,4 18,44" fill="#fbbf24" />
{/* Onderkant schaduw */}
<polygon points="0,60 82,60 100,72 18,72" fill="#b45309" />
{/* Streep highlight bovenkant */}
<polygon points="28,8 72,8 88,18 12,18" fill="#fde68a" opacity="0.5" />
{/* GOLD tekst */}
<text x="50" y="46" textAnchor="middle" fontSize="18" fontWeight="800"
fontFamily="Arial, sans-serif" fill="#92400e" letterSpacing="2">GOLD</text>
</svg>
);
}
// ── Modal primitieven ─────────────────────────────────────────────────────────
export function Overlay({ children, onClose, zIndex = 400 }) {
return (
<div
onClick={(e) => { if (e.target === e.currentTarget) onClose(); }}
style={{
position: "fixed", inset: 0,
background: "rgba(0,0,0,0.75)",
zIndex,
}}
>
{children}
</div>
);
}
export function ModalBox({ children, maxWidth = 480 }) {
const ref = useRef(null);
const [pos, setPos] = useState(null);
const dragState = useRef(null);
const startDrag = useCallback((e) => {
if (e.button !== 0) return;
e.preventDefault();
const rect = ref.current.getBoundingClientRect();
dragState.current = {
startX: e.clientX,
startY: e.clientY,
startLeft: rect.left,
startTop: rect.top,
};
const onMove = (e) => {
if (!dragState.current) return;
setPos({
x: dragState.current.startLeft + (e.clientX - dragState.current.startX),
y: dragState.current.startTop + (e.clientY - dragState.current.startY),
});
};
const onUp = () => {
dragState.current = null;
window.removeEventListener("mousemove", onMove);
window.removeEventListener("mouseup", onUp);
};
window.addEventListener("mousemove", onMove);
window.addEventListener("mouseup", onUp);
}, []);
const posStyle = pos
? { left: pos.x, top: pos.y, transform: "none" }
: { left: "50%", top: "50%", transform: "translate(-50%, -50%)" };
return (
<DragContext.Provider value={startDrag}>
<div ref={ref} style={{
position: "fixed",
...posStyle,
background: "#1a1d27",
border: "1px solid rgba(255,255,255,0.07)",
borderRadius: 18, width: "100%", maxWidth,
boxShadow: "0 24px 64px rgba(0,0,0,0.5)",
overflow: "hidden",
}}>
{children}
</div>
</DragContext.Provider>
);
}
export function ModalHeader({ icon, iconBg, title, subtitle, subtitleColor, onClose, headerBg, T }) {
const startDrag = useContext(DragContext);
return (
<div
onMouseDown={startDrag}
style={{
display: "flex", alignItems: "center",
justifyContent: "space-between",
padding: "18px 22px",
borderBottom: `1px solid ${T.border}`,
background: headerBg,
cursor: "grab",
userSelect: "none",
}}>
<div style={{ display: "flex", alignItems: "center", gap: 10 }}>
<div style={{
width: 32, height: 32, borderRadius: 8,
background: `${iconBg}33`,
display: "flex", alignItems: "center",
justifyContent: "center", fontSize: 16,
}}>
{icon}
</div>
<div>
<div style={{ fontWeight: 700, fontSize: 15, color: T.text }}>{title}</div>
<div style={{ fontSize: 11, color: subtitleColor, fontWeight: 600 }}>{subtitle}</div>
</div>
</div>
<button onClick={onClose} onMouseDown={(e) => e.stopPropagation()} style={{
background: "transparent", border: "none",
color: T.muted, cursor: "pointer",
fontSize: 22, lineHeight: 1, padding: 4,
}}>×</button>
</div>
);
}
export function ModalFooter({ children, T }) {
return (
<div style={{
display: "flex", gap: 10,
padding: "14px 22px",
borderTop: `1px solid ${T.border}`,
}}>
{children}
</div>
);
}
// ── AppIcon — rendert emoji-string of speciale SVG-iconen ────────────────────
export function AppIcon({ value, fallback = "🏷", size = 20 }) {
if (value === "__BTC__") return <BtcIcon size={size} color="#f59e0b" />;
if (value === "__ETH__") return <EthIcon size={size} color="#627eea" />;
if (value === "__GOLD__") return <GoldIcon size={size * 1.4} />;
if (value) return <span style={{ fontSize: size, lineHeight: 1 }}>{value}</span>;
return <span style={{ fontSize: size, lineHeight: 1 }}>{fallback}</span>;
}
// ── EmojiPicker ───────────────────────────────────────────────────────────────
const SPECIAL_ICONS = [
{ key: "__BTC__", node: <BtcIcon size={24} color="#f59e0b" /> },
{ key: "__ETH__", node: <EthIcon size={18} color="#627eea" /> },
{ key: "__GOLD__", node: <GoldIcon size={28} /> },
];
export function EmojiPicker({ selected, T, accentColor, onSelect, onClear }) {
const ac = accentColor || "#f59e0b";
const btnStyle = (active) => ({
width: 34, height: 34, borderRadius: 8,
border: `2px solid ${active ? ac : T.border}`,
background: active ? `${ac}22` : "transparent",
cursor: "pointer", fontSize: 18,
display: "flex", alignItems: "center", justifyContent: "center",
});
return (
<div style={{
background: T.inputBg, border: `1px solid ${T.border}`,
borderRadius: 12, padding: 10, marginTop: 8,
}}>
<div style={{ display: "flex", flexWrap: "wrap", gap: 6, maxHeight: 200, overflowY: "auto" }}>
{selected && (
<button onClick={onClear} style={btnStyle(false)}>
<span style={{ fontSize: 13, color: T.muted }}>✕</span>
</button>
)}
{SPECIAL_ICONS.map(({ key, node }) => (
<button key={key} onClick={() => onSelect(key)} style={btnStyle(selected === key)}>
{node}
</button>
))}
{EMOJI_OPTIONS.map((e) => (
<button key={e} onClick={() => onSelect(e)} style={btnStyle(selected === e)}>
{e}
</button>
))}
</div>
</div>
);
}
// ── ConfirmDeleteDialog ───────────────────────────────────────────────────────
export function ConfirmDeleteDialog({ naam, T, onCancel, onConfirm }) {
const RED = "#ef4444";
return (
<div style={{
background: "rgba(239,68,68,0.08)",
border: "1px solid rgba(239,68,68,0.25)",
borderRadius: 10, padding: "12px 14px",
}}>
<div style={{ fontSize: 13, color: RED, fontWeight: 600, marginBottom: 10 }}>
Weet je zeker dat je <strong>{naam}</strong> wilt verwijderen?
</div>
<div style={{ display: "flex", gap: 8 }}>
<button onClick={onCancel} style={{
flex: 1, padding: "8px",
background: "transparent",
border: `1px solid ${T.border}`,
borderRadius: 8, color: T.muted,
cursor: "pointer", fontWeight: 600, fontSize: 13,
}}>Annuleren</button>
<button onClick={onConfirm} style={{
flex: 1, padding: "8px",
background: "rgba(239,68,68,0.15)",
border: "1px solid rgba(239,68,68,0.4)",
borderRadius: 8, color: RED,
cursor: "pointer", fontWeight: 700, fontSize: 13,
}}>Ja, verwijderen</button>
</div>
</div>
);
}
// ── EmptyState ────────────────────────────────────────────────────────────────
export function EmptyState({ icon, text, hint, onClick, locked, T }) {
return (
<div
onClick={!locked ? onClick : undefined}
style={{
textAlign: "center", padding: "36px 0",
color: T.muted,
cursor: locked ? "default" : "pointer",
border: `2px dashed ${T.border}`,
borderRadius: 12,
}}
>
<div style={{ fontSize: 32, marginBottom: 10 }}>{icon}</div>
<div style={{ fontSize: 13, marginBottom: 4 }}>{text}</div>
{!locked && hint && (
<div style={{ fontSize: 12, color: PURPLE_LIGHT }}>{hint}</div>
)}
</div>
);
}