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:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user