From 9f62eebed6421f36452390756c574425d2af8344 Mon Sep 17 00:00:00 2001 From: Kroonk Date: Fri, 22 May 2026 18:04:28 +0200 Subject: [PATCH] Feat: Complete smart contact matching, CRM features integration, and premium glassmorphism UI --- public/app.js | 173 +++++++++++++++++++++++++++-- public/index.html | 274 +++++++++++++++++++++++++++++++--------------- public/styles.css | 243 +++++++++++++++++++++++++++++++++++++++- server.js | 216 +++++++++++++++++++++++++++++++++--- 4 files changed, 791 insertions(+), 115 deletions(-) diff --git a/public/app.js b/public/app.js index f43cef5..d4ebfd6 100644 --- a/public/app.js +++ b/public/app.js @@ -4,6 +4,7 @@ const state = { contacts: [], recognition: null, listening: false, + selectedContactId: null, }; const elements = { @@ -29,8 +30,33 @@ const elements = { saveButton: document.querySelector("#saveButton"), discardButton: document.querySelector("#discardButton"), messageLog: document.querySelector("#messageLog"), + + // Tab Elements + tabButtons: document.querySelectorAll(".tab-btn"), + tabViews: document.querySelectorAll(".tab-view"), + tabBtnContactInfo: document.querySelector("#tabBtnContactInfo"), + + // Contact Info Elements + contactInfoPlaceholder: document.querySelector("#contact-info-placeholder"), + contactInfoContent: document.querySelector("#contact-info-content"), + contactDetailsAvatar: document.querySelector("#contactDetailsAvatar"), + contactDetailsName: document.querySelector("#contactDetailsName"), + contactDetailsDesc: document.querySelector("#contactDetailsDesc"), + contactDetailsEmail: document.querySelector("#contactDetailsEmail"), + contactDetailsPhone: document.querySelector("#contactDetailsPhone"), + contactDetailsAddress: document.querySelector("#contactDetailsAddress"), + + // Create Contact Elements + createContactForm: document.querySelector("#createContactForm"), + newContactFirstName: document.querySelector("#newContactFirstName"), + newContactLastName: document.querySelector("#newContactLastName"), + newContactEmail: document.querySelector("#newContactEmail"), + newContactPhone: document.querySelector("#newContactPhone"), + newContactAddress: document.querySelector("#newContactAddress"), + newContactDesc: document.querySelector("#newContactDesc"), }; +// Mode Buttons document.querySelectorAll("[data-mode]").forEach((button) => { button.addEventListener("click", () => { state.mode = button.dataset.mode; @@ -38,6 +64,20 @@ document.querySelectorAll("[data-mode]").forEach((button) => { }); }); +// Tab Switch Listeners +elements.tabButtons.forEach((btn) => { + btn.addEventListener("click", () => { + const tabName = btn.dataset.tab; + elements.tabButtons.forEach((b) => b.classList.toggle("is-active", b === btn)); + elements.tabViews.forEach((v) => v.classList.toggle("is-active", v.id === `view-${tabName}`)); + + if (tabName === "contact-info") { + loadSelectedContactDetails(); + } + }); +}); + +// Dictate Control Listeners elements.analyzeButton.addEventListener("click", analyzeInput); elements.clearButton.addEventListener("click", () => { elements.inputText.value = ""; @@ -53,12 +93,64 @@ elements.saveButton.addEventListener("click", saveEntry); elements.contactSearch.addEventListener("input", debounce(loadContacts, 240)); elements.voiceButton.addEventListener("click", toggleSpeech); +// Select Contact Listener +elements.contactSelect.addEventListener("change", () => { + const val = elements.contactSelect.value; + state.selectedContactId = val ? Number(val) : null; + if (state.selectedContactId) { + loadSelectedContactDetails(); + } else { + hideContactDetails(); + } +}); + +// Create Contact Form Listener +elements.createContactForm.addEventListener("submit", async (e) => { + e.preventDefault(); + + const contactData = { + first_name: elements.newContactFirstName.value.trim(), + last_name: elements.newContactLastName.value.trim(), + email: elements.newContactEmail.value.trim(), + phone: elements.newContactPhone.value.trim(), + address: elements.newContactAddress.value.trim(), + description: elements.newContactDesc.value.trim(), + }; + + try { + log("Erstelle neuen Kontakt in Monica..."); + const res = await api("/api/contacts/create", { + method: "POST", + body: JSON.stringify(contactData), + }); + + log(`Kontakt ${res.data.name} wurde erfolgreich erstellt!`); + elements.createContactForm.reset(); + + // Reload contacts and auto-select new one + await loadContacts(); + elements.contactSelect.value = res.data.id; + state.selectedContactId = res.data.id; + elements.contactSearch.value = res.data.name; + + // Switch to dictate and load details + elements.tabButtons[0].click(); + loadSelectedContactDetails(); + } catch (error) { + log(`Kontakt konnte nicht erstellt werden: ${error.message}`, "error"); + } +}); + await boot(); async function boot() { await checkHealth(); await loadContacts(); setupSpeechRecognition(); + + if (window.lucide) { + window.lucide.createIcons(); + } } async function checkHealth() { @@ -91,14 +183,57 @@ function renderContacts(selectedName = "") { const empty = new Option("Kein Kontakt ausgewählt", ""); elements.contactSelect.append(empty); + let selectedId = ""; for (const contact of state.contacts) { const option = new Option(contact.name, contact.id); option.dataset.name = contact.name; + if (selectedName && contact.name.toLowerCase().includes(selectedName.toLowerCase())) { option.selected = true; + selectedId = contact.id; } elements.contactSelect.append(option); } + + if (selectedId) { + state.selectedContactId = Number(selectedId); + } +} + +async function loadSelectedContactDetails() { + if (!state.selectedContactId) { + hideContactDetails(); + return; + } + + try { + const details = await api(`/api/contacts/${state.selectedContactId}`); + + elements.contactDetailsName.textContent = details.name; + elements.contactDetailsDesc.textContent = details.description || "Keine Beschreibung"; + elements.contactDetailsEmail.textContent = details.email || "-"; + elements.contactDetailsPhone.textContent = details.phone || "-"; + elements.contactDetailsAddress.textContent = details.address || "-"; + + const avatarLetter = details.first_name ? details.first_name.slice(0, 1).toUpperCase() : "?"; + elements.contactDetailsAvatar.textContent = avatarLetter; + + elements.contactInfoPlaceholder.hidden = true; + elements.contactInfoContent.removeAttribute("hidden"); + elements.contactInfoContent.style.display = "block"; + + if (window.lucide) { + window.lucide.createIcons(); + } + } catch (error) { + log(`Kontaktdetails konnten nicht geladen werden: ${error.message}`, "error"); + } +} + +function hideContactDetails() { + elements.contactInfoPlaceholder.hidden = false; + elements.contactInfoContent.setAttribute("hidden", "true"); + elements.contactInfoContent.style.display = "none"; } async function analyzeInput() { @@ -112,10 +247,14 @@ async function analyzeInput() { try { const analysis = await api("/api/analyze", { method: "POST", - body: JSON.stringify({ text, mode: state.mode }), + body: JSON.stringify({ + text, + mode: state.mode, + contacts: state.contacts + }), }); state.analysis = analysis; - renderAnalysis(analysis); + await renderAnalysis(analysis); log("Ich habe daraus einen Vorschlag gemacht. Bitte kurz prüfen."); } catch (error) { log(error.message, "error"); @@ -124,7 +263,7 @@ async function analyzeInput() { } } -function renderAnalysis(analysis) { +async function renderAnalysis(analysis) { elements.review.hidden = false; elements.typeSelect.value = analysis.type; elements.summaryInput.value = analysis.summary; @@ -135,10 +274,26 @@ function renderAnalysis(analysis) { elements.journalPost.value = analysis.journal?.post || ""; elements.reminders.innerHTML = ""; - const inferredContact = analysis.contact?.name || ""; - if (inferredContact) { - elements.contactSearch.value = inferredContact; - loadContacts().then(() => renderContacts(inferredContact)); + const inferredContact = analysis.contact; + if (inferredContact && inferredContact.id) { + let match = state.contacts.find(c => c.id === inferredContact.id); + if (!match) { + state.contacts.push({ + id: inferredContact.id, + name: inferredContact.name, + description: inferredContact.description || "" + }); + renderContacts(); + } + + elements.contactSearch.value = ""; + elements.contactSelect.value = inferredContact.id; + state.selectedContactId = inferredContact.id; + await loadSelectedContactDetails(); + } else { + elements.contactSelect.value = ""; + state.selectedContactId = null; + hideContactDetails(); } for (const reminder of analysis.reminders || []) { @@ -246,7 +401,7 @@ function setupSpeechRecognition() { recognition.addEventListener("end", () => { state.listening = false; elements.voiceButton.classList.remove("listening"); - elements.voiceButton.textContent = "Aufnehmen"; + elements.voiceButton.innerHTML = ' Aufnehmen'; }); recognition.addEventListener("error", (event) => { @@ -266,7 +421,7 @@ function toggleSpeech() { state.listening = true; elements.voiceButton.classList.add("listening"); - elements.voiceButton.textContent = "Stoppen"; + elements.voiceButton.innerHTML = ' Stoppen'; state.recognition.start(); } diff --git a/public/index.html b/public/index.html index 7fd4f39..9463d26 100644 --- a/public/index.html +++ b/public/index.html @@ -7,6 +7,12 @@ + + + + + + @@ -32,102 +38,190 @@ + +
-
-
-
- - - -
-
- -
- -
- - - -
- - -
-
- - + +
+ + + +
+ + +
+
+ + + + + +
+
+
+ +

Wähle einen Kontakt im Diktieren-Tab aus, um Details anzuzeigen.

+
+ +
+
+ + +
+
+

Hinzufügen

+

Neuen Kontakt erstellen

+

Lege eine neue Person direkt in deinem CRM an.

+ +
+
+ +
- - - -
- -
-
-

Erinnerungen

- -
-
-
- -
- - -
- +
+ + +
+ + +
+ + +
+ + +
@@ -145,6 +239,8 @@ + + diff --git a/public/styles.css b/public/styles.css index 86290a4..bb9de03 100644 --- a/public/styles.css +++ b/public/styles.css @@ -28,7 +28,7 @@ --down: #fb7185; --cyan: #67e8f9; --radius: 8px; - font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + font-family: 'Inter', ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; } html { @@ -406,3 +406,244 @@ input::placeholder { flex: 1; } } + +/* Typography: Outfit for headers */ +h1, h2, h3, .brand h1, .section-kicker, .eyebrow, .tab-btn { + font-family: 'Outfit', 'Inter', sans-serif; + letter-spacing: -0.02em; +} + +/* Tabs & Navigation */ +.tab-nav { + display: flex; + gap: 8px; + margin-top: 8px; + margin-bottom: 24px; + padding: 6px; + background: rgba(17, 22, 36, 0.65); + border: 1px solid var(--border); + border-radius: var(--radius); + backdrop-filter: blur(12px); + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.15); +} + +.tab-btn { + flex: 1; + display: inline-flex; + align-items: center; + justify-content: center; + gap: 8px; + min-height: 44px; + padding: 0 16px; + color: var(--muted); + background: transparent; + border: 1px solid transparent; + font-size: 0.95rem; + font-weight: 600; + transition: all 0.25s cubic-bezier(0.4, 0, 0.2, 1); +} + +.tab-btn i, .tab-btn svg { + width: 16px; + height: 16px; + stroke-width: 2.2px; +} + +.tab-btn:hover { + color: var(--text); + background: rgba(255, 255, 255, 0.04); +} + +.tab-btn.is-active { + color: #062027; + background: linear-gradient(135deg, #67e8f9 0%, #c084fc 100%); + font-weight: 700; + box-shadow: 0 8px 24px rgba(103, 232, 249, 0.22); + border-color: transparent; +} + +/* Tab Views Show/Hide */ +.tab-view { + display: none !important; +} + +.tab-view.is-active { + display: block !important; + animation: fadeIn 0.4s ease; +} + +@keyframes fadeIn { + from { + opacity: 0; + transform: translateY(6px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +/* Contact Details View Styles */ +.contact-details-view { + background: rgba(17, 22, 36, 0.74); + border: 1px solid var(--border); + border-radius: var(--radius); + backdrop-filter: blur(14px); + padding: 24px; + box-shadow: 0 20px 50px rgba(0, 0, 0, 0.3); +} + +.info-placeholder { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 48px 24px; + color: var(--dim); + text-align: center; +} + +.placeholder-icon { + width: 48px; + height: 48px; + margin-bottom: 16px; + color: var(--dim); + opacity: 0.6; +} + +.contact-header { + display: flex; + align-items: center; + gap: 20px; + margin-bottom: 28px; + padding-bottom: 20px; + border-bottom: 1px solid var(--border); +} + +.contact-avatar { + display: flex; + align-items: center; + justify-content: center; + width: 68px; + height: 68px; + border-radius: 50%; + background: linear-gradient(135deg, #38bdf8 0%, #c084fc 100%); + color: #070914; + font-family: 'Outfit', sans-serif; + font-size: 1.8rem; + font-weight: 800; + box-shadow: 0 8px 24px rgba(192, 132, 252, 0.25); + border: 2px solid rgba(255, 255, 255, 0.15); +} + +.contact-header h2 { + font-size: 1.6rem; + font-weight: 700; + color: #fff; + line-height: 1.2; +} + +.contact-header .eyebrow { + margin-top: 4px; + font-size: 0.75rem; + color: var(--cyan); +} + +.contact-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); + gap: 16px; +} + +.info-item { + display: flex; + flex-direction: column; + gap: 8px; + padding: 16px; + background: rgba(7, 9, 20, 0.38); + border: 1px solid var(--border); + border-radius: var(--radius); + transition: border-color 0.2s ease; +} + +.info-item:hover { + border-color: var(--border-strong); +} + +.info-label { + display: inline-flex; + align-items: center; + gap: 6px; + font-size: 0.75rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.08em; + color: var(--muted); +} + +.info-label i, .info-label svg { + width: 14px; + height: 14px; + color: var(--cyan); +} + +.info-value { + font-size: 1.05rem; + font-weight: 500; + color: var(--text); + overflow-wrap: break-word; +} + +/* New Contact Form Styles */ +.new-contact-form { + margin-top: 0; +} + +.new-contact-form form { + display: flex; + flex-direction: column; + gap: 18px; + margin-top: 24px; +} + +.new-contact-form label { + font-weight: 600; + color: var(--text); +} + +.new-contact-form label input, +.new-contact-form label textarea { + margin-top: 6px; +} + +/* Voice Microphone Pulsing Recording Animation */ +.mic-pulse-circle { + display: inline-block; + width: 10px; + height: 10px; + border-radius: 50%; + background: var(--muted); + margin-right: 8px; + transition: all 0.3s ease; +} + +.mic-pulse-circle.recording { + background: var(--down); + box-shadow: 0 0 0 0 rgba(251, 113, 133, 0.7); + animation: mic-pulse 1.5s infinite ease-in-out; +} + +@keyframes mic-pulse { + 0% { + transform: scale(0.9); + box-shadow: 0 0 0 0 rgba(251, 113, 133, 0.8); + } + 70% { + transform: scale(1.1); + box-shadow: 0 0 0 10px rgba(251, 113, 133, 0); + } + 100% { + transform: scale(0.9); + box-shadow: 0 0 0 0 rgba(251, 113, 133, 0); + } +} diff --git a/server.js b/server.js index 76e4c8b..b244328 100644 --- a/server.js +++ b/server.js @@ -37,7 +37,8 @@ app.get("/api/contacts", async (req, res) => { } try { - const suffix = query ? `?query=${encodeURIComponent(query)}` : ""; + const limit = 150; + const suffix = query ? `?query=${encodeURIComponent(query)}&limit=${limit}` : `?limit=${limit}`; const payload = await monicaFetch(`/api/contacts${suffix}`); const contacts = Array.isArray(payload.data) ? payload.data : []; res.json({ @@ -53,16 +54,160 @@ app.get("/api/contacts", async (req, res) => { } }); +app.get("/api/contacts/:id", async (req, res) => { + const id = Number(req.params.id); + if (!monicaApiToken) { + res.json(demoContactDetails(id)); + return; + } + + try { + const contactPayload = await monicaFetch(`/api/contacts/${id}`); + const contact = contactPayload.data || {}; + + let emails = []; + let phones = []; + try { + const fieldsPayload = await monicaFetch(`/api/contacts/${id}/contactfields`); + const fields = Array.isArray(fieldsPayload.data) ? fieldsPayload.data : []; + emails = fields.filter(f => f.contact_field_type?.name?.toLowerCase().includes("email") || f.contact_field_type_id === 1).map(f => f.data); + phones = fields.filter(f => f.contact_field_type?.name?.toLowerCase().includes("phone") || f.contact_field_type_id === 2 || f.contact_field_type_id === 3 || f.contact_field_type?.name?.toLowerCase().includes("telefon") || f.contact_field_type?.name?.toLowerCase().includes("mobil")).map(f => f.data); + } catch (e) { + console.log("Could not load contact fields:", e.message); + } + + let addressStr = ""; + try { + const addressPayload = await monicaFetch(`/api/contacts/${id}/addresses`); + const addresses = Array.isArray(addressPayload.data) ? addressPayload.data : []; + if (addresses.length > 0) { + const addr = addresses[0]; + addressStr = [addr.street, addr.postal_code, addr.city].filter(Boolean).join(", "); + } + } catch (e) { + console.log("Could not load addresses:", e.message); + } + + res.json({ + id: contact.id, + first_name: contact.first_name || "", + last_name: contact.last_name || "", + name: contact.complete_name || [contact.first_name, contact.last_name].filter(Boolean).join(" "), + description: contact.description || "", + birthdate: contact.birthdate || "", + email: emails[0] || "", + phone: phones[0] || "", + address: addressStr || "", + }); + } catch (error) { + res.status(502).json({ error: "Monica contact details could not be loaded.", detail: error.message }); + } +}); + +app.post("/api/contacts/create", async (req, res) => { + const { first_name, last_name, description, email, phone, address } = req.body || {}; + if (!first_name) { + res.status(400).json({ error: "First name is required." }); + return; + } + + if (!monicaApiToken) { + const newId = Math.floor(Math.random() * 1000) + 10; + const newContact = { + id: newId, + first_name, + last_name, + name: [first_name, last_name].filter(Boolean).join(" "), + description: description || "Neu angelegter Demo-Kontakt", + email: email || "", + phone: phone || "", + address: address || "", + }; + res.json({ ok: true, mode: "demo", data: newContact }); + return; + } + + try { + const contactPayload = await monicaFetch("/api/contacts", { + method: "POST", + body: JSON.stringify({ + first_name, + last_name: last_name || "", + description: description || "", + }), + }); + const contact = contactPayload.data || {}; + const contactId = contact.id; + + if (contactId && email) { + try { + await monicaFetch("/api/contactfields", { + method: "POST", + body: JSON.stringify({ + contact_id: contactId, + contact_field_type_id: 1, // Email + data: email, + }), + }); + } catch (e) { + console.error("Could not save email:", e.message); + } + } + + if (contactId && phone) { + try { + await monicaFetch("/api/contactfields", { + method: "POST", + body: JSON.stringify({ + contact_id: contactId, + contact_field_type_id: 3, // Cell phone + data: phone, + }), + }); + } catch (e) { + console.error("Could not save phone:", e.message); + } + } + + if (contactId && address) { + try { + await monicaFetch("/api/addresses", { + method: "POST", + body: JSON.stringify({ + contact_id: contactId, + name: "Hauptadresse", + street: address, + }), + }); + } catch (e) { + console.error("Could not save address:", e.message); + } + } + + res.json({ + ok: true, + mode: "monica", + data: { + id: contactId, + name: contact.complete_name || [first_name, last_name].filter(Boolean).join(" "), + }, + }); + } catch (error) { + res.status(502).json({ error: "Contact could not be created in Monica.", detail: error.message }); + } +}); + app.post("/api/analyze", async (req, res) => { const text = String(req.body.text || "").trim(); const mode = String(req.body.mode || "auto"); + const contactsList = req.body.contacts || []; if (!text) { res.status(400).json({ error: "Text is required." }); return; } - res.json(analyzeText(text, mode)); + res.json(analyzeText(text, mode, contactsList)); }); app.post("/api/save", async (req, res) => { @@ -152,9 +297,9 @@ async function monicaWrite(type, apiPath, body) { } } -function analyzeText(text, mode = "auto") { +function analyzeText(text, mode = "auto", contactsList = []) { const normalized = text.replace(/\s+/g, " ").trim(); - const contacts = mode === "journal" ? [] : inferContacts(normalized); + const contacts = mode === "journal" ? [] : inferContacts(normalized, contactsList); const reminder = inferReminder(normalized); const type = mode === "auto" ? inferType(normalized, contacts, reminder) : mode; const summary = summarize(normalized); @@ -194,20 +339,50 @@ function inferType(text, contacts, reminder) { return "journal"; } -function inferContacts(text) { +function inferContacts(text, contactsList = []) { const candidates = []; - const patterns = [ - /(?:mit|bei|von|über|wegen)\s+([A-ZÄÖÜ][a-zäöüß]+(?:\s+[A-ZÄÖÜ][a-zäöüß]+)?)/g, - /([A-ZÄÖÜ][a-zäöüß]+(?:\s+[A-ZÄÖÜ][a-zäöüß]+)?)\s+(?:getroffen|gesprochen|telefoniert|gesehen)/g, - /(?:treffe|spreche|telefoniere|schreibe)\s+(?:mit\s+)?([A-ZÄÖÜ][a-zäöüß]+(?:\s+[A-ZÄÖÜ][a-zäöüß]+)?)/g, - /@([A-Za-zÄÖÜäöüß]+(?:\s+[A-Za-zÄÖÜäöüß]+)?)/g, - ]; - for (const pattern of patterns) { - for (const match of text.matchAll(pattern)) { - const name = cleanupName(match[1]); - if (name && !candidates.some((item) => item.name.toLowerCase() === name.toLowerCase())) { - candidates.push({ name }); + // 1. Scan for echten Kontakten (from DB/Monica) + if (Array.isArray(contactsList) && contactsList.length > 0) { + for (const contact of contactsList) { + if (!contact.name) continue; + const nameParts = contact.name.trim().split(/\s+/); + const firstName = nameParts[0]; + const fullName = contact.name.trim(); + + const escFullName = fullName.replace(/[-\/\\^$*+?.()|[\]{}]/g, '\\$&'); + const escFirstName = firstName.replace(/[-\/\\^$*+?.()|[\]{}]/g, '\\$&'); + + const regexFull = new RegExp(`\\b${escFullName}\\b`, 'i'); + const regexFirst = new RegExp(`\\b${escFirstName}\\b`, 'i'); + + if (regexFull.test(text) || (firstName.length > 2 && regexFirst.test(text))) { + if (!candidates.some((item) => item.id === contact.id)) { + candidates.push({ + id: contact.id, + name: contact.name, + description: contact.description || "" + }); + } + } + } + } + + // 2. Fallback / Ergänzung durch klassische Regexes (wenn keine echten Kontakte gematcht wurden) + if (candidates.length === 0) { + const patterns = [ + /(?:mit|bei|von|über|wegen)\s+([A-ZÄÖÜ][a-zäöüß]+(?:\s+[A-ZÄÖÜ][a-zäöüß]+)?)/g, + /([A-ZÄÖÜ][a-zäöüß]+(?:\s+[A-ZÄÖÜ][a-zäöüß]+)?)\s+(?:getroffen|gesprochen|telefoniert|gesehen)/g, + /(?:treffe|spreche|telefoniere|schreibe)\s+(?:mit\s+)?([A-ZÄÖÜ][a-zäöüß]+(?:\s+[A-ZÄÖÜ][a-zäöüß]+)?)/g, + /@([A-Za-zÄÖÜäöüß]+(?:\s+[A-Za-zÄÖÜäöüß]+)?)/g, + ]; + + for (const pattern of patterns) { + for (const match of text.matchAll(pattern)) { + const name = cleanupName(match[1]); + if (name && !candidates.some((item) => item.name.toLowerCase() === name.toLowerCase())) { + candidates.push({ name }); + } } } } @@ -215,6 +390,15 @@ function inferContacts(text) { return candidates.slice(0, 3); } +function demoContactDetails(id) { + const details = { + 1: { id: 1, first_name: "Aaron", last_name: "Lingel", name: "Aaron Lingel", description: "Bester Kumpel auf dem Campus", email: "aaron.lingel@example.com", phone: "+49 176 898989", address: "Fischbrötchenallee 42, 22767 Hamburg" }, + 2: { id: 2, first_name: "Tom", last_name: "", name: "Tom", description: "Vibecoding-Kollege", email: "tom@example.com", phone: "+49 152 111222", address: "Mischweg 7, 20357 Hamburg" }, + 3: { id: 3, first_name: "MrDiderot", last_name: "", name: "MrDiderot", description: "Admin und CRM Host", email: "mrdiderot@mischlabs.de", phone: "+49 170 333444", address: "NAS-Zentrale 1, 20095 Hamburg" }, + }; + return details[id] || { id, first_name: "Unbekannt", last_name: "", name: "Unbekannter Kontakt", description: "", email: "", phone: "", address: "" }; +} + function cleanupName(name) { const cleaned = name .replace(/\b(heute|morgen|gestern|freitag|samstag|sonntag|montag|dienstag|mittwoch|donnerstag)\b/gi, "")