This commit is contained in:
oliver
2026-04-26 10:32:18 -03:00
parent 3afcf77a16
commit afb5a2ac20
19 changed files with 6061 additions and 4413 deletions
-50
View File
@@ -1,50 +0,0 @@
# NGO Website — ODOO4projects
**Project:** NGO marketing homepage and blog
**URL:** [ngo.odoo4projects.com](https://ngo.odoo4projects.com)
**Stack:** Static HTML · Tailwind CSS · Vanilla JS
---
## BLOG POSTS
Blog posts live in `blog.json` — do not create separate backup files.
To add a blog post customize this command
jq '.arrays[0] = [$NEW_JSON] + .arrays[0]' blog.json > tmp.json && mv tmp.json blog.json
## Repository Rules
- Do not touch this readme
- Keep the seperation of Site and the blogposts in blog.json
Example of JSON structure for blog.json
{
"area": "Transparency",
"title": "Every Peso on the Page: Ana's Ledger Story",
"teaser": "<p>Ana replaced five notebooks with a four-stop impact ledger and now answers donor questions in seconds.</p>",
"content": "<h3>Every peso needs a map</h3>\n<p>Ana used to juggle five notebooks and still felt blind when a donor asked where their coin landed.</p>\n<h3>Pressure points</h3>\n<p without late nights.</p>\n",
"date": "2026-03-22"
},
---
## Page Structure
| # | Block | Purpose |
|---|-------|---------|
| 1 | **Hero** | Headline, subheadline, free-trial CTA, key stats |
| 2 | **Hot NGO Topics** | Three focused topic cards (transparency, communications, fundraising) |
| 3 | **Blog** | Latest articles loaded dynamically from `blog.json` |
| 4 | **Sign Up — Free Trial** | Email signup form, 4-week free trial, no credit card |
| 5 | **Have a Meeting** | 30-minute consultation booking via Google Calendar |
---
## Languages
| Code | Language |
|------|----------|
| `en` | English (default) |
| `es` | Español |
| `de` | Deutsch |
All UI strings are managed via the inline `translations` object in `index.html`.
-1
View File
File diff suppressed because one or more lines are too long
-16
View File
@@ -1,16 +0,0 @@
[
{
"area": "Automatisierung",
"date": "2026-03-25",
"title": "NGO-Verwaltung um 2040 % reduzieren: 7 Workflows, die du diesen Monat automatisieren kannst",
"teaser": "<p>Maria verbrachte ihre Abende mit Papierkram statt mit den Familien, denen sie helfen wollte. Mit Odoo und n8n reduzierte ihr Team den Verwaltungsaufwand in unter vier Wochen um ein Drittel.</p>",
"content": "<h3>Die Admin-Falle, die jede NGO kennt</h3><p>Verstreute Spenderdaten, wiederholte Kopier-Einfüge-Schritte und langsame Bestätigungen untergraben das Vertrauen und kosten wertvolle Energie. Ein ERP bündelt Kontakte, Spenden, Rechnungen und Projektaufgaben an einem zentralen Ort — niemand muss dieselben Daten mehr in fünf verschiedene Tools eintippen.</p><h3>Odoo als Kern, n8n als Verbindung</h3><p>Nutze Odoo für Kontakte, Spenden, Projekte und Dokumente. Erstelle dann n8n-Workflows, um automatische Spendenquittungen zu versenden, Kontaktdatensätze bei Formulareinreichungen zu aktualisieren, Folgeaufgaben zu terminieren und Zusammenfassungsberichte per Slack oder E-Mail zu verteilen.</p><h3>7 Workflows für diesen Monat</h3><ul><li>Sofortige Dankesnachricht an Spender bei jeder Zuwendung</li><li>Fördermittel-Compliance-Erinnerungen im 30-Tage-Rhythmus</li><li>Onboarding-Checkliste für Freiwillige automatisch bei der Anmeldung</li><li>Monatlicher Impact-Bericht automatisch erstellt und versendet</li><li>Dubletten-Erkennung bei jedem Datenimport</li><li>Rechnungsgenehmigung nach Projekt und Budgetposition geroutet</li><li>Ausgabenerfassung aus dem Feld mit Foto-Belegerfassung</li></ul><h3>Was du erwarten kannst</h3><p>Typische NGO-Implementierungen zeigen innerhalb weniger Wochen 2040 % weniger Verwaltungsstunden. Marias Team hörte mit den nächtlichen Admin-Sprints auf und begann jeden Morgen mit einem einzigen Dashboard — mehr Zeit für Lehrercoaching, Gemeindebesuche und die Planung wirkungsvoller Programme.</p>"
},
{
"area": "Spenderkommunikation",
"date": "2026-03-18",
"title": "Wie Maya das Spender-Chaos in klare Verbindungen verwandelte",
"teaser": "<p>Maya verwandelte verstreute Spendernachrichten in einen reibungslosen, vertrauenswürdigen Ablauf, der die Unterstützung wachsen lässt und das gesamte Team auf Kurs hält.</p>",
"content": "<h3>Von verstreuten E-Mails zur einheitlichen Ansicht</h3><p>Maya jonglierte früher mit Tabellen, E-Mail-Threads und Haftnotizen, um Spenderinteraktionen zu verfolgen. Nachrichten gingen verloren, Dankesworte kamen verspätet an und das Vertrauen bröckelte. Sie wusste, dass sich etwas ändern musste.</p><h3>Den Spender-Journey strukturieren</h3><p>Sie kartierte jeden Kontaktpunkt: Erstkontakt, Spende, Nachfassaktionen, Erneuerungen und Sonderkampagnen. Jede Interaktion erhielt eine Karte in Odoo mit Spendername, Datum, bevorzugtem Kanal und Teamnotizen — für alle auf einen Blick sichtbar.</p><h3>Automatisierte Berührungspunkte</h3><p>n8n übernimmt die Routine: Spender werden innerhalb von Minuten nach einer Zuwendung gedankt, Zusagen werden bestätigt und das Team wird vor fälligen Erneuerungen benachrichtigt. Nichts fällt durchs Raster, und der persönliche Ton bleibt erhalten — dank Vorlagen, die auf jedes Spendersegment zugeschnitten sind.</p><h3>Transparenz auf allen Ebenen</h3><p>Spender können Updates zu den Projekten sehen, die sie unterstützt haben. Status-Labels in Kampagnen zeigen zugewiesene Mittel, Impact-Geschichten und bevorstehende Veranstaltungen. Mitarbeiter hinterlassen für Kollegen sichtbare Notizen — doppelte Kontaktaufnahmen und unangenehme Überschneidungen gehören der Vergangenheit an.</p><h3>Visuelle Echtzeit-Dashboards</h3><p>Ein Live-Dashboard zeigt Spenderaktivitäten, bevorstehende Erneuerungen und die Kampagnengesundheit auf einen Blick. Farbcodes setzen Prioritäten: Grün für bestätigte Gaben, Gelb für ausstehende Zusagen, Rot für dringende Nachfassaktionen, die menschliche Aufmerksamkeit erfordern.</p><h3>Kommunikationsrhythmus</h3><p>Wöchentliche Spender-Check-ins halten das Team auf Kurs. Monatliche Newsletter heben Erfolge, Spender-Impact und kommende Möglichkeiten hervor. Sonderkampagnen lösen personalisierte Micro-E-Mails aus, die auf Spenderlevel und Verlauf jeder Person abgestimmt sind.</p><ul><li>Montag: neue Spender und ausstehende Nachfassaktionen prüfen.</li><li>Mittwoch: Kampagnenfortschritt bestätigen und Spendernotizen aktualisieren.</li><li>Freitag: Dankesmails und Impact-Updates versenden.</li></ul><h3>Kennzahlen &amp; Wirkung</h3><p>Die Spenderbindung verbesserte sich um 25 %. Reaktionszeiten sanken von Tagen auf Stunden. Mitarbeiter berichten von weniger Stress, weil Kommunikation berechenbar, sichtbar und nicht mehr vom Gedächtnis einer einzelnen Person abhängig ist.</p><h3>Dein Rollout-Plan</h3><ul><li>Alle Spenderdaten in Odoo-Karten importieren und Verantwortliche zuweisen.</li><li>n8n-Alerts für Gaben, Nachfassaktionen und bevorstehende Erneuerungen einrichten.</li><li>Dashboards mit aktiven Spendern, Zusagen und Kampagnenstatus erstellen.</li><li>Dankesmails und vierteljährliche Impact-Updates automatisieren.</li></ul><p>Vier Schritte ersetzen wochenlange verstreute E-Mails und verpasste Möglichkeiten — und jeder Spender beginnt sich wirklich wahrgenommen zu fühlen.</p>"
}
]
-16
View File
@@ -1,16 +0,0 @@
[
{
"area": "Automatización",
"date": "2026-03-25",
"title": "Reduce el trabajo administrativo de tu ONG un 2040 %: 7 flujos que puedes automatizar este mes",
"teaser": "<p>María pasaba sus noches sepultada en papeleo en lugar de ayudar a las familias que había venido a servir. Con Odoo y n8n, su equipo redujo el tiempo administrativo en un tercio en menos de cuatro semanas.</p>",
"content": "<h3>La trampa administrativa que toda ONG conoce</h3><p>Datos dispersos de donantes, pasos repetitivos de copiar y pegar y acuses de recibo lentos erosionan la confianza y agotan la energía del equipo. Un ERP centraliza contactos, donaciones, facturas y tareas de proyecto en un único lugar, de modo que nadie tenga que reintroducir los mismos datos en cinco herramientas distintas.</p><h3>Odoo como núcleo, n8n como conector</h3><p>Usa Odoo para Contactos, Donaciones, Proyectos y Documentos. Después crea flujos en n8n para enviar recibos automáticos a donantes, actualizar registros cuando se envían formularios, programar tareas de seguimiento y distribuir informes de resumen por Slack o correo electrónico.</p><h3>7 flujos para automatizar este mes</h3><ul><li>Correo de agradecimiento instantáneo al donante en cada aportación</li><li>Recordatorios de cumplimiento de subvenciones cada 30 días</li><li>Lista de incorporación de voluntarios activada al registrarse</li><li>Informe de impacto mensual generado y enviado automáticamente</li><li>Detección de contactos duplicados en cada importación</li><li>Enrutamiento de aprobaciones de facturas por proyecto y línea presupuestaria</li><li>Envío de gastos desde campo con captura de recibo fotográfico</li></ul><h3>Resultados esperados</h3><p>Las implementaciones habituales en ONG muestran un 2040 % menos de horas administrativas en pocas semanas. El equipo de María dejó de hacer maratones nocturnos de administración y empezó cada mañana con un solo panel de control, liberando tiempo para capacitar a docentes, visitar comunidades y planificar programas de impacto.</p>"
},
{
"area": "Comunicación con Donantes",
"date": "2026-03-18",
"title": "Cómo Maya convirtió el caos de donantes en conexiones claras",
"teaser": "<p>Maya transformó los mensajes dispersos de donantes en un flujo fluido y confiable que hace crecer el apoyo y mantiene a todo el equipo alineado.</p>",
"content": "<h3>De correos dispersos a una visión única</h3><p>Maya solía manejar hojas de cálculo, hilos de correo y notas adhesivas para rastrear las interacciones con donantes. Se perdían mensajes, los agradecimientos llegaban tarde y la confianza comenzaba a tambalearse. Sabía que algo tenía que cambiar.</p><h3>Organizando el recorrido del donante</h3><p>Mapeó cada punto de contacto: contacto inicial, donación, seguimientos, renovaciones y campañas especiales. Cada interacción tuvo su tarjeta en Odoo con el nombre del donante, la fecha, el canal preferido y las notas del equipo, visible para todos de un vistazo.</p><h3>Puntos de contacto automatizados</h3><p>n8n automatiza la rutina: agradece a los donantes a los pocos minutos de recibir una aportación, confirma los compromisos y avisa al equipo antes de que venzan las renovaciones. Nadie se queda sin respuesta y el tono personal se mantiene intacto gracias a plantillas diseñadas para cada segmento de donantes.</p><h3>Transparencia en todos los niveles</h3><p>Los donantes pueden ver actualizaciones de los proyectos que apoyaron. Las etiquetas de estado en las campañas muestran los fondos asignados, historias de impacto y próximos eventos. El personal deja notas visibles para sus colegas, eliminando contactos duplicados y superposiciones incómodas.</p><h3>Paneles visuales en tiempo real</h3><p>Un panel en vivo muestra la actividad de donantes, las próximas renovaciones y la salud de la campaña de un solo vistazo. Los códigos de color marcan la prioridad: verde para donaciones confirmadas, amarillo para compromisos pendientes, rojo para seguimientos urgentes que requieren atención humana.</p><h3>Ritmo de comunicación</h3><p>Las revisiones semanales de donantes mantienen al equipo alineado. Los boletines mensuales destacan logros, impacto de los donantes y próximas oportunidades. Las campañas especiales activan microemails personalizados según el nivel de donación y el historial de cada persona.</p><ul><li>Lunes: revisar nuevos donantes y seguimientos pendientes.</li><li>Miércoles: confirmar el avance de las campañas y actualizar las notas de donantes.</li><li>Viernes: enviar correos de agradecimiento y actualizaciones de impacto.</li></ul><h3>Métricas e impacto</h3><p>La retención de donantes mejoró un 25 %. Los tiempos de respuesta bajaron de días a horas. El personal reporta menos estrés porque la comunicación es predecible, visible y ya no depende de la memoria de una sola persona.</p><h3>Tu plan de implementación</h3><ul><li>Importar todos los registros de donantes a Odoo y asignar responsables.</li><li>Configurar alertas en n8n para donaciones, seguimientos y renovaciones.</li><li>Crear paneles con donantes activos, compromisos y estado de campaña.</li><li>Automatizar correos de agradecimiento y actualizaciones de impacto trimestrales.</li></ul><p>Cuatro pasos reemplazan semanas de correos dispersos y oportunidades perdidas — y cada donante empieza a sentirse genuinamente valorado.</p>"
}
]
-23
View File
File diff suppressed because one or more lines are too long
-685
View File
@@ -1,685 +0,0 @@
<!doctype html>
<html>
<head>
<meta charset="UTF-8" />
<title>Dashboard</title>
<style>
body {
margin: 0;
width: 1920px;
height: 1200px;
font-family: Arial, sans-serif;
background: #2f3e4e;
color: #e6edf3;
overflow: hidden;
}
.dashboard {
width: 100%;
height: 100%;
padding: 20px;
box-sizing: border-box;
display: grid;
grid-template-columns: 2fr 1fr 1fr;
grid-template-rows: 1fr 1fr;
gap: 20px;
}
.box {
background: #3b4c5e;
border-radius: 14px;
padding: 18px;
box-sizing: border-box;
box-shadow: 0 6px 14px rgba(0, 0, 0, 0.25);
position: relative;
overflow: hidden;
}
.title {
font-size: 20px;
font-weight: bold;
margin-bottom: 12px;
opacity: 0.9;
}
.status-light {
position: absolute;
top: 18px;
right: 18px;
width: 14px;
height: 14px;
border-radius: 50%;
flex-shrink: 0;
}
.green {
background: #3ad29f;
}
.yellow {
background: #f5c542;
}
.red {
background: #e05d5d;
}
.green-text {
color: #3ad29f;
}
.yellow-text {
color: #f5c542;
}
.red-text {
color: #e05d5d;
}
/* SERVER GRID */
.servers {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 12px;
}
.server {
background: #44586d;
border-radius: 10px;
padding: 12px;
position: relative;
}
.server-name {
font-size: 15px;
margin-bottom: 8px;
opacity: 0.85;
font-weight: bold;
padding-right: 22px;
}
.server .status-light {
top: 12px;
right: 12px;
}
.metrics {
font-size: 12px;
line-height: 1.7;
opacity: 0.85;
}
.metric-row {
display: flex;
justify-content: space-between;
align-items: center;
}
/* MEETINGS */
.meetings-scroll {
overflow-y: auto;
max-height: calc(100% - 45px);
}
.meeting {
background: #44586d;
border-radius: 10px;
padding: 10px 12px;
margin-bottom: 8px;
border: 2px solid transparent;
position: relative;
}
.meeting.soon {
border: 2px solid #e05d5d;
box-shadow: 0 0 8px rgba(224, 93, 93, 0.4);
}
.meeting-header {
display: flex;
justify-content: space-between;
align-items: center;
}
.meeting-name {
font-size: 13px;
font-weight: bold;
opacity: 0.95;
flex: 1;
}
.meeting-badges {
display: flex;
gap: 4px;
margin-left: 6px;
}
.badge {
font-size: 9px;
padding: 2px 5px;
border-radius: 4px;
font-weight: bold;
white-space: nowrap;
}
.badge-odoo {
background: #714b67;
color: #e6edf3;
}
.badge-brandize {
background: #1a6fa0;
color: #e6edf3;
}
.time {
font-size: 11px;
opacity: 0.65;
margin-top: 3px;
}
/* TODOS */
.todos-scroll {
overflow-y: auto;
max-height: calc(100% - 45px);
}
.todo {
background: #44586d;
border-radius: 10px;
padding: 10px 12px;
margin-bottom: 8px;
font-size: 13px;
opacity: 0.9;
display: flex;
align-items: center;
gap: 8px;
}
.todo.done-todo {
opacity: 0.5;
text-decoration: line-through;
}
.todo-check {
width: 14px;
height: 14px;
border-radius: 3px;
border: 2px solid #8aa0b5;
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
}
.todo-check.checked {
background: #3ad29f;
border-color: #3ad29f;
}
/* LATEST SIGNUPS */
.signups-scroll {
overflow-y: auto;
max-height: calc(100% - 45px);
}
.signup-item {
background: #44586d;
border-radius: 10px;
padding: 10px 12px;
margin-bottom: 8px;
font-size: 12px;
}
.signup-email {
font-size: 13px;
font-weight: bold;
margin-bottom: 4px;
opacity: 0.95;
}
.signup-meta {
display: flex;
gap: 8px;
opacity: 0.7;
flex-wrap: wrap;
}
.signup-tag {
background: #2f3e4e;
border-radius: 4px;
padding: 1px 6px;
font-size: 10px;
color: #3ad29f;
}
.signup-server {
font-size: 11px;
opacity: 0.7;
}
/* CURRENT TESTS */
.test-box {
background: #44586d;
border-radius: 10px;
padding: 12px;
font-size: 12px;
line-height: 1.8;
}
.test-row {
display: flex;
justify-content: space-between;
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
padding: 3px 0;
}
.test-label {
opacity: 0.6;
font-size: 11px;
}
.test-value {
font-size: 11px;
text-align: right;
max-width: 65%;
word-break: break-all;
}
.status-badge {
display: inline-block;
padding: 1px 7px;
border-radius: 5px;
font-size: 10px;
font-weight: bold;
background: #3ad29f33;
color: #3ad29f;
}
.status-badge.dev {
background: #f5c54233;
color: #f5c542;
}
/* LOADING / ERROR */
.loading {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
font-size: 18px;
opacity: 0.5;
letter-spacing: 1px;
}
.error-msg {
color: #e05d5d;
font-size: 13px;
opacity: 0.85;
padding: 12px;
}
</style>
</head>
<body>
<div class="dashboard" id="dashboard">
<div class="loading" style="grid-column: 1/-1; grid-row: 1/-1">
Loading dashboard…
</div>
</div>
<script>
const WEBHOOK_URL =
"https://002-001-5dd6e535-4d1c-46bc-9bd9-42ad4bc5f082.odoo4projects.com/webhook/52781c84-9f12-4a61-96b2-9e1e3933148d";
const REFRESH_INTERVAL_MS = 60 * 1000; // refresh every 60 seconds
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function mbToGb(mb) {
return (mb / 1024).toFixed(1);
}
function pct(free, total) {
return total > 0 ? (free / total) * 100 : 0;
}
function metricColor(freePct) {
if (freePct < 10) return "red";
if (freePct < 20) return "yellow";
return "green";
}
function worstColor(colors) {
if (colors.includes("red")) return "red";
if (colors.includes("yellow")) return "yellow";
return "green";
}
// Parse the wall-clock date/time directly from an ISO-8601 string that
// already carries an offset (e.g. "2026-03-31T10:00:00-03:00").
// We never let the browser re-interpret it into its own timezone.
function parseISOParts(dateTimeStr) {
// Matches: YYYY-MM-DDTHH:MM:SS(±HH:MM or Z)
const m = dateTimeStr.match(
/^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})(?::\d{2})?/,
);
if (!m) return null;
return {
year: parseInt(m[1], 10),
month: parseInt(m[2], 10),
day: parseInt(m[3], 10),
hour: parseInt(m[4], 10),
minute: parseInt(m[5], 10),
};
}
function formatLocalTime(dateTimeStr) {
const p = parseISOParts(dateTimeStr);
if (!p) return "";
const h = String(p.hour).padStart(2, "0");
const m = String(p.minute).padStart(2, "0");
return `${h}:${m}`;
}
function formatLocalDate(dateTimeStr) {
const p = parseISOParts(dateTimeStr);
if (!p) return "";
// Build a UTC date just for the locale label so day/month names are correct
const d = new Date(Date.UTC(p.year, p.month - 1, p.day));
return d.toLocaleDateString([], {
weekday: "short",
month: "short",
day: "numeric",
timeZone: "UTC",
});
}
function isToday(dateTimeStr) {
const now = new Date();
const p = parseISOParts(dateTimeStr);
if (!p) return false;
return (
p.year === now.getFullYear() &&
p.month === now.getMonth() + 1 &&
p.day === now.getDate()
);
}
function isSoonOrOngoing(startStr, endStr) {
const now = new Date();
const start = new Date(startStr);
const end = new Date(endStr);
const diffMin = (start - now) / 60000;
if (now >= start && now <= end) return true;
if (diffMin >= 0 && diffMin <= 30) return true;
return false;
}
// ---------------------------------------------------------------------------
// Server cards
// ---------------------------------------------------------------------------
function buildServerCard(s) {
const ramPct = pct(s.free_ram, s.available_ram);
const hddPct = pct(s.free_hdd, s.available_hdd);
const ramColor = metricColor(ramPct);
const hddColor = metricColor(hddPct);
const lightColor = worstColor([ramColor, hddColor]);
const displayNames = {
saopaulo: "São Paulo",
manchester: "Manchester",
mumbai: "Mumbai",
sydney: "Sydney",
meppel: "Meppel",
boston: "Boston",
};
const displayName =
displayNames[s.Server] ||
s.Server.charAt(0).toUpperCase() + s.Server.slice(1);
return {
lightColor,
html: `
<div class="server">
<div class="server-name">${displayName}</div>
<div class="status-light ${lightColor}"></div>
<div class="metrics">
<div class="metric-row">
<span>RAM:</span>
<span class="${ramColor}-text">${ramPct.toFixed(0)}% free (${mbToGb(s.free_ram)} GB)</span>
</div>
<div class="metric-row">
<span>HDD:</span>
<span class="${hddColor}-text">${hddPct.toFixed(0)}% free (${mbToGb(s.free_hdd)} GB)</span>
</div>
</div>
</div>
`,
};
}
// ---------------------------------------------------------------------------
// Meetings
// ---------------------------------------------------------------------------
function parseMeetings(calEvents, calName) {
const seen = new Set();
const result = [];
for (const e of calEvents) {
const ev = e.json || e;
if (seen.has(ev.id)) continue;
seen.add(ev.id);
result.push({ ...ev, _calName: calName });
}
return result;
}
function buildMeetingCard(m) {
const startStr =
(m.start && (m.start.dateTime || m.start.date)) || "";
const endStr = (m.end && (m.end.dateTime || m.end.date)) || "";
const soon =
startStr && endStr
? isSoonOrOngoing(startStr, endStr)
: false;
const today = startStr ? isToday(startStr) : false;
const badges = (m._cals || [])
.map((c) => {
if (c === "ODOO4projects")
return `<span class="badge badge-odoo">ODOO4</span>`;
if (c === "Brandize")
return `<span class="badge badge-brandize">Brandize</span>`;
return "";
})
.join("");
const dateLabel =
today || !startStr
? ""
: `<span style="font-size:10px;opacity:0.55;margin-right:4px;">${formatLocalDate(startStr)}</span>`;
const timeLabel =
startStr && endStr
? `${formatLocalTime(startStr)} ${formatLocalTime(endStr)}`
: "";
return `
<div class="meeting${soon ? " soon" : ""}">
<div class="meeting-header">
<div class="meeting-name">${m.summary || "Meeting"}</div>
<div class="meeting-badges">${badges}</div>
</div>
<div class="time">${dateLabel}${timeLabel}</div>
</div>
`;
}
// ---------------------------------------------------------------------------
// Todos
// ---------------------------------------------------------------------------
function buildTodoItem(t) {
const done = !!t.done;
const checkInner = done
? `<svg width="10" height="10" viewBox="0 0 10 10"><polyline points="1.5,5 4,7.5 8.5,2.5" stroke="#fff" stroke-width="1.8" fill="none" stroke-linecap="round"/></svg>`
: "";
return `
<div class="todo${done ? " done-todo" : ""}">
<div class="todo-check${done ? " checked" : ""}">${checkInner}</div>
<span>${t.name || ""}</span>
${t.Minutes != null ? `<span style="margin-left:auto;opacity:0.55;font-size:12px;flex-shrink:0;">${t.Minutes}</span>` : ""}
</div>
`;
}
// ---------------------------------------------------------------------------
// Latest Signup item
// ---------------------------------------------------------------------------
function buildSignupItem(ct) {
if (!ct) return "";
const tagBadges = (ct.tags || "")
.split(",")
.map((t) => t.trim())
.filter(Boolean)
.map((t) => `<span class="signup-tag">${t}</span>`)
.join("");
return `
<div class="signup-item">
<div class="signup-email">${ct.email || ""}</div>
<div class="signup-meta">
<span class="signup-server">&#128421; ${ct.server || ""}</span>
${tagBadges}
<span style="opacity:0.5;font-size:10px;">${ct.status || ""}</span>
</div>
<div style="margin-top:4px;opacity:0.55;font-size:10px;">
${ct.Domains ? ct.Domains : ""}${ct.expires ? " &middot; expires " + ct.expires : ""}
</div>
</div>
`;
}
function buildSignupItems(raw) {
if (!raw) return { html: "", count: 0 };
const items = Array.isArray(raw) ? raw : [raw];
const html = items.map(buildSignupItem).join("");
return { html, count: items.length };
}
// ---------------------------------------------------------------------------
// Render
// ---------------------------------------------------------------------------
function render(data) {
const serverStatusRaw = data["Server Status"] || [];
const calOdoo = data["Calender ODOO4projects"] || [];
const calBrandize = data["Calender Brandize"] || [];
const todos = data["Todos"] || [];
const currentTestsRaw = data["Current Tests"] || null;
// Deduplicate servers by Server key
const serverMap = {};
for (const s of serverStatusRaw) {
if (!serverMap[s.Server]) serverMap[s.Server] = s;
}
const servers = Object.values(serverMap);
// Server cards
const serverCards = servers.map(buildServerCard);
const overallColor = worstColor(
serverCards.map((c) => c.lightColor),
);
const serverCardsHtml = serverCards.map((c) => c.html).join("");
// Meetings merge & deduplicate across both calendars
const odooMeetings = parseMeetings(calOdoo, "ODOO4projects");
const brandizeMeetings = parseMeetings(calBrandize, "Brandize");
const allMeetings = [...odooMeetings, ...brandizeMeetings];
const meetingById = {};
for (const m of allMeetings) {
if (!meetingById[m.id]) {
meetingById[m.id] = { ...m, _cals: [m._calName] };
} else if (!meetingById[m.id]._cals.includes(m._calName)) {
meetingById[m.id]._cals.push(m._calName);
}
}
const dedupedMeetings = Object.values(meetingById).sort(
(a, b) => {
const aStart =
(a.start && (a.start.dateTime || a.start.date)) ||
"";
const bStart =
(b.start && (b.start.dateTime || b.start.date)) ||
"";
return new Date(aStart) - new Date(bStart);
},
);
const meetingsHtml = dedupedMeetings
.map(buildMeetingCard)
.join("");
const todosHtml = todos.map(buildTodoItem).join("");
const totalMinutes = todos.reduce(
(sum, t) => sum + (t.Minutes || 0),
0,
);
const totalHours = (totalMinutes / 60)
.toFixed(1)
.replace(/\.0$/, "");
const signups = buildSignupItems(currentTestsRaw);
document.getElementById("dashboard").innerHTML = `
<!-- SERVER STATUS -->
<div class="box" style="grid-column:1; grid-row:1/3;">
<div class="title">Server Status</div>
<div class="status-light ${overallColor}"></div>
<div class="servers">
${serverCardsHtml}
</div>
</div>
<!-- TODAY'S MEETINGS -->
<div class="box" style="grid-column:2; grid-row:1;">
<div class="title">Meetings</div>
<div class="meetings-scroll">
${meetingsHtml || '<div style="opacity:0.4;font-size:13px;">No meetings</div>'}
</div>
</div>
<!-- LATEST SIGNUPS -->
<div class="box" style="grid-column:3; grid-row:1/3;">
<div class="title">Latest Signups <span style="font-size:13px;font-weight:normal;opacity:0.6;background:#2f3e4e;border-radius:10px;padding:1px 9px;margin-left:6px;">${signups.count}</span></div>
<div class="signups-scroll">
${signups.html || '<div style="opacity:0.4;font-size:13px;">No signups</div>'}
</div>
</div>
<!-- TODOS -->
<div class="box" style="grid-column:2; grid-row:2;">
<div class="title" style="display:flex;align-items:center;">Today's Todos<span style="margin-left:auto;font-size:13px;font-weight:normal;opacity:0.6;background:#2f3e4e;border-radius:10px;padding:1px 9px;">${totalHours}h</span></div>
<div class="todos-scroll">
${todosHtml || '<div style="opacity:0.4;font-size:13px;">No todos</div>'}
</div>
</div>
`;
}
// ---------------------------------------------------------------------------
// Fetch & refresh loop
// ---------------------------------------------------------------------------
async function loadData() {
try {
const res = await fetch(WEBHOOK_URL);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data = await res.json();
render(data);
} catch (err) {
document.getElementById("dashboard").innerHTML = `
<div class="error-msg" style="grid-column:1/-1;grid-row:1/-1;padding:40px;">
&#9888; Failed to load dashboard data: ${err.message}<br>
<span style="font-size:11px;opacity:0.6;">Retrying in ${REFRESH_INTERVAL_MS / 1000}s…</span>
</div>
`;
}
}
loadData();
setInterval(loadData, REFRESH_INTERVAL_MS);
</script>
</body>
</html>
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

+114
View File
@@ -0,0 +1,114 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg fill="#000000" width="800px" height="800px" viewBox="0 0 64 64" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;">
<g transform="matrix(1,0,0,1,-1152,-256)">
<rect id="Icons" x="0" y="0" width="1280" height="800" style="fill:none;"/>
<g id="Icons1" serif:id="Icons">
<g id="Strike">
</g>
<g id="H1">
</g>
<g id="H2">
</g>
<g id="H3">
</g>
<g id="list-ul">
</g>
<g id="hamburger-1">
</g>
<g id="hamburger-2">
</g>
<g id="list-ol">
</g>
<g id="list-task">
</g>
<g id="trash">
</g>
<g id="vertical-menu">
</g>
<g id="horizontal-menu">
</g>
<g id="sidebar-2">
</g>
<g id="Pen">
</g>
<g id="Pen1" serif:id="Pen">
</g>
<g id="clock">
</g>
<g id="external-link">
</g>
<g id="hr">
</g>
<g id="info">
</g>
<g id="warning">
</g>
<g id="plus-circle">
</g>
<g id="minus-circle">
</g>
<g id="vue">
</g>
<g id="cog">
</g>
<g id="logo">
</g>
<g id="radio-check">
</g>
<g id="eye-slash">
</g>
<g id="eye">
</g>
<g id="toggle-off">
</g>
<g id="shredder">
</g>
<g id="spinner--loading--dots-" serif:id="spinner [loading, dots]">
</g>
<g id="react">
</g>
<g id="check-selected">
</g>
<g id="turn-off">
</g>
<g id="code-block">
</g>
<g id="user">
</g>
<g id="coffee-bean">
</g>
<g transform="matrix(0.638317,0.368532,-0.368532,0.638317,785.021,-208.975)">
<g id="coffee-beans">
<g id="coffee-bean1" serif:id="coffee-bean">
</g>
</g>
</g>
<g id="coffee-bean-filled" transform="matrix(0.866025,0.5,-0.5,0.866025,717.879,-387.292)">
<g transform="matrix(1,0,0,1,0,-0.699553)">
<path d="M737.673,328.231C738.494,328.056 739.334,328.427 739.757,329.152C739.955,329.463 740.106,329.722 740.106,329.722C740.106,329.722 745.206,338.581 739.429,352.782C737.079,358.559 736.492,366.083 738.435,371.679C738.697,372.426 738.482,373.258 737.89,373.784C737.298,374.31 736.447,374.426 735.735,374.077C730.192,371.375 722.028,365.058 722.021,352C722.015,340.226 728.812,330.279 737.673,328.231Z"/>
</g>
<g transform="matrix(-1,0,0,-1,1483.03,703.293)">
<path d="M737.609,328.246C738.465,328.06 739.344,328.446 739.785,329.203C739.97,329.49 740.106,329.722 740.106,329.722C740.106,329.722 745.206,338.581 739.429,352.782C737.1,358.507 736.503,365.948 738.383,371.527C738.646,372.304 738.415,373.164 737.796,373.703C737.177,374.243 736.294,374.356 735.56,373.989C730.02,371.241 722.028,364.92 722.021,352C722.016,340.255 728.779,330.328 737.609,328.246Z"/>
</g>
</g>
<g transform="matrix(0.638317,0.368532,-0.368532,0.638317,913.062,-208.975)">
<g id="coffee-beans-filled">
<g id="coffee-bean2" serif:id="coffee-bean">
</g>
</g>
</g>
<g id="clipboard">
</g>
<g transform="matrix(1,0,0,1,128.011,1.35415)">
<g id="clipboard-paste">
</g>
</g>
<g id="clipboard-copy">
</g>
<g id="Layer1">
</g>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 4.5 KiB

+224
View File
@@ -0,0 +1,224 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg
fill="#000000"
width="800px"
height="800px"
viewBox="0 0 64 64"
version="1.1"
xml:space="preserve"
style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;"
id="svg6"
sodipodi:docname="bean_dark_Roast.svg"
inkscape:version="1.4.3 (0d15f75042, 2025-12-25)"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns:serif="http://www.serif.com/"><defs
id="defs6" /><sodipodi:namedview
id="namedview6"
pagecolor="#ffffff"
bordercolor="#000000"
borderopacity="0.25"
inkscape:showpageshadow="2"
inkscape:pageopacity="0.0"
inkscape:pagecheckerboard="0"
inkscape:deskcolor="#d1d1d1"
inkscape:zoom="1.075"
inkscape:cx="400"
inkscape:cy="400"
inkscape:window-width="1920"
inkscape:window-height="1129"
inkscape:window-x="0"
inkscape:window-y="0"
inkscape:window-maximized="1"
inkscape:current-layer="svg6" />
<g
transform="matrix(1,0,0,1,-1152,-256)"
id="g6">
<rect
id="Icons"
x="0"
y="0"
width="1280"
height="800"
style="fill:none;" />
<g
id="Icons1"
serif:id="Icons">
<g
id="Strike">
</g>
<g
id="H1">
</g>
<g
id="H2">
</g>
<g
id="H3">
</g>
<g
id="list-ul">
</g>
<g
id="hamburger-1">
</g>
<g
id="hamburger-2">
</g>
<g
id="list-ol">
</g>
<g
id="list-task">
</g>
<g
id="trash">
</g>
<g
id="vertical-menu">
</g>
<g
id="horizontal-menu">
</g>
<g
id="sidebar-2">
</g>
<g
id="Pen">
</g>
<g
id="Pen1"
serif:id="Pen">
</g>
<g
id="clock">
</g>
<g
id="external-link">
</g>
<g
id="hr">
</g>
<g
id="info">
</g>
<g
id="warning">
</g>
<g
id="plus-circle">
</g>
<g
id="minus-circle">
</g>
<g
id="vue">
</g>
<g
id="cog">
</g>
<g
id="logo">
</g>
<g
id="radio-check">
</g>
<g
id="eye-slash">
</g>
<g
id="eye">
</g>
<g
id="toggle-off">
</g>
<g
id="shredder">
</g>
<g
id="spinner--loading--dots-"
serif:id="spinner [loading, dots]">
</g>
<g
id="react">
</g>
<g
id="check-selected">
</g>
<g
id="turn-off">
</g>
<g
id="code-block">
</g>
<g
id="user">
</g>
<g
id="coffee-bean">
</g>
<g
transform="matrix(0.638317,0.368532,-0.368532,0.638317,785.021,-208.975)"
id="g1">
<g
id="coffee-beans">
<g
id="coffee-bean1"
serif:id="coffee-bean">
</g>
</g>
</g>
<g
id="coffee-bean-filled"
transform="matrix(0.866025,0.5,-0.5,0.866025,717.879,-387.292)">
<g
transform="matrix(1,0,0,1,0,-0.699553)"
id="g2">
<path
d="M737.673,328.231C738.494,328.056 739.334,328.427 739.757,329.152C739.955,329.463 740.106,329.722 740.106,329.722C740.106,329.722 745.206,338.581 739.429,352.782C737.079,358.559 736.492,366.083 738.435,371.679C738.697,372.426 738.482,373.258 737.89,373.784C737.298,374.31 736.447,374.426 735.735,374.077C730.192,371.375 722.028,365.058 722.021,352C722.015,340.226 728.812,330.279 737.673,328.231Z"
id="path1"
style="fill:#3e2723;fill-opacity:1" />
</g>
<g
transform="matrix(-1,0,0,-1,1483.03,703.293)"
id="g3">
<path
d="M737.609,328.246C738.465,328.06 739.344,328.446 739.785,329.203C739.97,329.49 740.106,329.722 740.106,329.722C740.106,329.722 745.206,338.581 739.429,352.782C737.1,358.507 736.503,365.948 738.383,371.527C738.646,372.304 738.415,373.164 737.796,373.703C737.177,374.243 736.294,374.356 735.56,373.989C730.02,371.241 722.028,364.92 722.021,352C722.016,340.255 728.779,330.328 737.609,328.246Z"
id="path2"
style="stroke:#3e2723;stroke-opacity:1;fill:#3e2723;fill-opacity:1" />
</g>
</g>
<g
transform="matrix(0.638317,0.368532,-0.368532,0.638317,913.062,-208.975)"
id="g4">
<g
id="coffee-beans-filled">
<g
id="coffee-bean2"
serif:id="coffee-bean">
</g>
</g>
</g>
<g
id="clipboard">
</g>
<g
transform="matrix(1,0,0,1,128.011,1.35415)"
id="g5">
<g
id="clipboard-paste">
</g>
</g>
<g
id="clipboard-copy">
</g>
<g
id="Layer1">
</g>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 5.4 KiB

+224
View File
@@ -0,0 +1,224 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg
fill="#000000"
width="800px"
height="800px"
viewBox="0 0 64 64"
version="1.1"
xml:space="preserve"
style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;"
id="svg6"
sodipodi:docname="bean_light_brown.svg"
inkscape:version="1.4.3 (0d15f75042, 2025-12-25)"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns:serif="http://www.serif.com/"><defs
id="defs6" /><sodipodi:namedview
id="namedview6"
pagecolor="#ffffff"
bordercolor="#000000"
borderopacity="0.25"
inkscape:showpageshadow="2"
inkscape:pageopacity="0.0"
inkscape:pagecheckerboard="0"
inkscape:deskcolor="#d1d1d1"
inkscape:zoom="1.075"
inkscape:cx="400"
inkscape:cy="400"
inkscape:window-width="1920"
inkscape:window-height="1129"
inkscape:window-x="0"
inkscape:window-y="0"
inkscape:window-maximized="1"
inkscape:current-layer="svg6" />
<g
transform="matrix(1,0,0,1,-1152,-256)"
id="g6">
<rect
id="Icons"
x="0"
y="0"
width="1280"
height="800"
style="fill:none;" />
<g
id="Icons1"
serif:id="Icons">
<g
id="Strike">
</g>
<g
id="H1">
</g>
<g
id="H2">
</g>
<g
id="H3">
</g>
<g
id="list-ul">
</g>
<g
id="hamburger-1">
</g>
<g
id="hamburger-2">
</g>
<g
id="list-ol">
</g>
<g
id="list-task">
</g>
<g
id="trash">
</g>
<g
id="vertical-menu">
</g>
<g
id="horizontal-menu">
</g>
<g
id="sidebar-2">
</g>
<g
id="Pen">
</g>
<g
id="Pen1"
serif:id="Pen">
</g>
<g
id="clock">
</g>
<g
id="external-link">
</g>
<g
id="hr">
</g>
<g
id="info">
</g>
<g
id="warning">
</g>
<g
id="plus-circle">
</g>
<g
id="minus-circle">
</g>
<g
id="vue">
</g>
<g
id="cog">
</g>
<g
id="logo">
</g>
<g
id="radio-check">
</g>
<g
id="eye-slash">
</g>
<g
id="eye">
</g>
<g
id="toggle-off">
</g>
<g
id="shredder">
</g>
<g
id="spinner--loading--dots-"
serif:id="spinner [loading, dots]">
</g>
<g
id="react">
</g>
<g
id="check-selected">
</g>
<g
id="turn-off">
</g>
<g
id="code-block">
</g>
<g
id="user">
</g>
<g
id="coffee-bean">
</g>
<g
transform="matrix(0.638317,0.368532,-0.368532,0.638317,785.021,-208.975)"
id="g1">
<g
id="coffee-beans">
<g
id="coffee-bean1"
serif:id="coffee-bean">
</g>
</g>
</g>
<g
id="coffee-bean-filled"
transform="matrix(0.866025,0.5,-0.5,0.866025,717.879,-387.292)">
<g
transform="matrix(1,0,0,1,0,-0.699553)"
id="g2">
<path
d="M737.673,328.231C738.494,328.056 739.334,328.427 739.757,329.152C739.955,329.463 740.106,329.722 740.106,329.722C740.106,329.722 745.206,338.581 739.429,352.782C737.079,358.559 736.492,366.083 738.435,371.679C738.697,372.426 738.482,373.258 737.89,373.784C737.298,374.31 736.447,374.426 735.735,374.077C730.192,371.375 722.028,365.058 722.021,352C722.015,340.226 728.812,330.279 737.673,328.231Z"
id="path1"
style="fill:#5d4037;fill-opacity:1" />
</g>
<g
transform="matrix(-1,0,0,-1,1483.03,703.293)"
id="g3">
<path
d="M737.609,328.246C738.465,328.06 739.344,328.446 739.785,329.203C739.97,329.49 740.106,329.722 740.106,329.722C740.106,329.722 745.206,338.581 739.429,352.782C737.1,358.507 736.503,365.948 738.383,371.527C738.646,372.304 738.415,373.164 737.796,373.703C737.177,374.243 736.294,374.356 735.56,373.989C730.02,371.241 722.028,364.92 722.021,352C722.016,340.255 728.779,330.328 737.609,328.246Z"
id="path2"
style="stroke:#5d4037;stroke-opacity:1;fill:#5d4037;fill-opacity:1" />
</g>
</g>
<g
transform="matrix(0.638317,0.368532,-0.368532,0.638317,913.062,-208.975)"
id="g4">
<g
id="coffee-beans-filled">
<g
id="coffee-bean2"
serif:id="coffee-bean">
</g>
</g>
</g>
<g
id="clipboard">
</g>
<g
transform="matrix(1,0,0,1,128.011,1.35415)"
id="g5">
<g
id="clipboard-paste">
</g>
</g>
<g
id="clipboard-copy">
</g>
<g
id="Layer1">
</g>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 5.4 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 115 KiB

+116
View File
@@ -0,0 +1,116 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
width="124.8711mm"
height="31.440542mm"
viewBox="0 0 124.8711 31.440542"
version="1.1"
id="svg1"
inkscape:version="1.4.3 (0d15f75042, 2025-12-25)"
sodipodi:docname="logo.svg"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg">
<sodipodi:namedview
id="namedview1"
pagecolor="#ffffff"
bordercolor="#000000"
borderopacity="0.25"
inkscape:showpageshadow="2"
inkscape:pageopacity="0.0"
inkscape:pagecheckerboard="0"
inkscape:deskcolor="#d1d1d1"
inkscape:document-units="mm"
inkscape:zoom="0.70021044"
inkscape:cx="329.90082"
inkscape:cy="345.61039"
inkscape:window-width="1920"
inkscape:window-height="1046"
inkscape:window-x="0"
inkscape:window-y="0"
inkscape:window-maximized="1"
inkscape:current-layer="layer1" />
<defs
id="defs1" />
<g
inkscape:label="Ebene 1"
inkscape:groupmode="layer"
id="layer1"
transform="translate(-17.727083,-56.885417)">
<g
id="g5"
transform="matrix(0.26458333,0,0,0.26458333,-67.310852,25.675979)">
<text
id="text5"
xml:space="preserve"
transform="matrix(1.3333333,0,0,1.3333333,316.00667,186.29426)"><tspan
id="tspan5"
style="font-variant:normal;font-weight:700;font-size:65.991px;font-family:'Noto Sans';writing-mode:lr-tb;fill:#3e2723;fill-opacity:1;fill-rule:nonzero;stroke:#3e2723;stroke-width:2.19965;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:10;stroke-dasharray:none;stroke-opacity:1"
x="0"
y="0">my</tspan></text>
<text
id="text6"
xml:space="preserve"
transform="matrix(1.3333333,0,0,1.3333333,452.48533,186.29426)"><tspan
id="tspan6"
style="font-variant:normal;font-weight:700;font-size:65.991px;font-family:'Noto Sans';writing-mode:lr-tb;fill:#e65100;fill-opacity:1;fill-rule:nonzero;stroke:#e65100;stroke-width:2.19965;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:10;stroke-dasharray:none;stroke-opacity:1"
x="0"
y="0">-</tspan></text>
<text
id="text7"
xml:space="preserve"
transform="matrix(1.3333333,0,0,1.3333333,480.79333,186.29426)"><tspan
id="tspan7"
style="font-variant:normal;font-weight:700;font-size:65.991px;font-family:'Noto Sans';writing-mode:lr-tb;fill:#3e2723;fill-opacity:1;fill-rule:nonzero;stroke:#3e2723;stroke-width:2.19965;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:10;stroke-dasharray:none;stroke-opacity:1"
x="0"
y="0">biz</tspan></text>
<text
id="text8"
xml:space="preserve"
transform="matrix(1.3333333,0,0,1.3333333,606.236,186.29426)"><tspan
id="tspan8"
style="font-variant:normal;font-weight:700;font-size:65.991px;font-family:'Noto Sans';writing-mode:lr-tb;fill:#e65100;fill-opacity:1;fill-rule:nonzero;stroke:#e65100;stroke-width:2.19965;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:10;stroke-dasharray:none;stroke-opacity:1"
x="0"
y="0">.app</tspan></text>
</g>
<g
id="g8"
transform="matrix(0.26458333,0,0,0.26458333,-67.310852,25.675979)">
<path
id="path8"
d="M 249.449,279.212 H 391.181"
style="fill:none;stroke:#e65100;stroke-width:1;stroke-linecap:butt;stroke-linejoin:round;stroke-miterlimit:10;stroke-dasharray:none;stroke-opacity:1"
transform="matrix(1.3333333,0,0,-1.3333333,0,595.27559)" />
</g>
<g
id="g9"
transform="matrix(0.26458333,0,0,0.26458333,-67.310852,25.675979)">
<path
id="path9"
d="M 442.205,279.212 H 583.937"
style="fill:none;stroke:#e65100;stroke-width:1;stroke-linecap:butt;stroke-linejoin:round;stroke-miterlimit:10;stroke-dasharray:none;stroke-opacity:1"
transform="matrix(1.3333333,0,0,-1.3333333,0,595.27559)" />
</g>
<g
id="g10"
transform="matrix(0.26458333,0,0,0.26458333,-67.310852,25.675979)">
<path
id="path10"
d="m 420.661,289.332 c 0.369,-0.114 0.596,-0.454 0.596,-0.822 0.028,-0.171 0.028,-0.284 0.028,-0.284 0,0 -0.028,-4.535 -5.386,-8.702 -2.182,-1.701 -4.053,-4.451 -4.564,-7.03 -0.056,-0.34 -0.311,-0.624 -0.652,-0.681 -0.368,-0.085 -0.708,0.057 -0.907,0.369 -1.53,2.268 -3.259,6.491 -0.368,11.48 2.608,4.536 7.427,6.832 11.253,5.67 z"
style="fill:#3e2723;fill-opacity:1;fill-rule:evenodd;stroke:none"
transform="matrix(1.3333333,0,0,-1.3333333,0,595.27559)" />
<path
id="path11"
d="m 413.121,269.376 c -0.368,0.141 -0.623,0.482 -0.623,0.85 0,0.17 0,0.284 0,0.284 0,0 0,4.535 5.357,8.702 2.154,1.672 4.054,4.394 4.564,6.945 0.057,0.368 0.34,0.623 0.709,0.708 0.34,0.057 0.708,-0.085 0.907,-0.396 1.53,-2.268 3.174,-6.463 0.34,-11.424 -2.608,-4.507 -7.399,-6.803 -11.254,-5.669 z"
style="fill:#3e2723;fill-opacity:1;fill-rule:evenodd;stroke:none"
transform="matrix(1.3333333,0,0,-1.3333333,0,595.27559)" />
<path
id="path12"
d="m 413.121,269.376 c -0.368,0.141 -0.623,0.482 -0.623,0.85 0,0.17 0,0.284 0,0.284 0,0 0,4.535 5.357,8.702 2.154,1.672 4.054,4.394 4.564,6.945 0.057,0.368 0.34,0.623 0.709,0.708 0.34,0.057 0.708,-0.085 0.907,-0.396 1.53,-2.268 3.174,-6.463 0.34,-11.424 -2.608,-4.507 -7.399,-6.803 -11.254,-5.669 z"
style="fill:none;stroke:#3e2723;stroke-width:0.4428;stroke-linecap:butt;stroke-linejoin:round;stroke-miterlimit:10;stroke-dasharray:none;stroke-opacity:1"
transform="matrix(1.3333333,0,0,-1.3333333,0,595.27559)" />
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 5.8 KiB

+3671 -2500
View File
File diff suppressed because it is too large Load Diff
-1025
View File
File diff suppressed because it is too large Load Diff
-22
View File
@@ -1,22 +0,0 @@
{
"name": "ngo",
"version": "1.0.0",
"description": "",
"main": "index.js",
"scripts": {
"build:css": "tailwindcss -i src/input.css -o assets/site.css --minify",
"watch:css": "tailwindcss -i src/input.css -o assets/site.css --watch",
"test": "echo \"Error: no test specified\" && exit 1"
},
"repository": {
"type": "git",
"url": "git@git.odoo4projects.com:Oliver/NGO.git"
},
"keywords": [],
"author": "",
"license": "ISC",
"type": "commonjs",
"devDependencies": {
"tailwindcss": "^3.4.17"
}
}
+82
View File
@@ -0,0 +1,82 @@
[
{
"headline": "How a regional NGO cut grant reporting time by 70%",
"vertical": "NGO",
"date": "2025-06-12",
"shortText": "Donor reconciliation and project cost allocation moved into one workflow, returning 15 hours a week to mission work instead of admin.",
"image": "https://images.unsplash.com/photo-1559027615-cd4628902d4a?w=900&q=80&auto=format&fit=crop",
"link": "https://ngo.my-biz.app"
},
{
"headline": "Why investors love clean Odoo data rooms",
"vertical": "Startups",
"date": "2025-05-28",
"shortText": "Founders shared how structured finance and CRM data reduced scramble during due diligence.",
"image": "https://images.unsplash.com/photo-1522202176988-66273c2fd55f?w=900&q=80&auto=format&fit=crop",
"link": "https://startups.my-biz.app"
},
{
"headline": "Real-time BoM costing saved a factory $40k",
"vertical": "Manufacturing",
"date": "2025-04-21",
"shortText": "Live material cost tracking helped the team reprice before margin loss became irreversible.",
"image": "https://images.unsplash.com/photo-1565793298595-6a879b1d9492?w=900&q=80&auto=format&fit=crop",
"link": "https://manufacturing.my-biz.app"
},
{
"headline": "Food cost under control before it hit the P&L",
"vertical": "Restaurants",
"date": "2025-03-14",
"shortText": "Linking POS data to supplier invoices exposed a 12% creep in week one instead of month three.",
"image": "https://images.unsplash.com/photo-1414235077428-338989a2e8c0?w=900&q=80&auto=format&fit=crop",
"link": "https://restaurants.my-biz.app"
},
{
"headline": "Impact dashboards boards helped trustees decide faster",
"vertical": "NGO",
"date": "2025-02-10",
"shortText": "Program leads and finance teams aligned on one dashboard instead of emailing static reports back and forth.",
"image": "https://images.unsplash.com/photo-1519389950473-47ba0277781c?w=900&q=80&auto=format&fit=crop",
"link": "https://ngo.my-biz.app"
},
{
"headline": "Startup founders replaced five admin tools with one stack",
"vertical": "Startups",
"date": "2025-01-24",
"shortText": "Sales, invoicing, and project workflows moved into one system and reduced duplicate entry across the team.",
"image": "https://images.unsplash.com/photo-1552664730-d307ca884978?w=900&q=80&auto=format&fit=crop",
"link": "https://startups.my-biz.app"
},
{
"headline": "Maintenance planning cut line stoppages in week three",
"vertical": "Manufacturing",
"date": "2024-12-16",
"shortText": "Production teams connected maintenance schedules with inventory and planning data before failures became emergencies.",
"image": "https://images.unsplash.com/photo-1581092580497-e0d23cbdf1dc?w=900&q=80&auto=format&fit=crop",
"link": "https://manufacturing.my-biz.app"
},
{
"headline": "Restaurant groups finally saw payroll and stock in one view",
"vertical": "Restaurants",
"date": "2024-11-30",
"shortText": "Managers connected staffing, purchasing, and daily sales to spot margin leaks earlier each week.",
"image": "https://images.unsplash.com/photo-1552566626-52f8b828add9?w=900&q=80&auto=format&fit=crop",
"link": "https://restaurants.my-biz.app"
},
{
"headline": "Automation teams improved agent accuracy with structured records",
"vertical": "Agentic Businesses",
"date": "2024-10-18",
"shortText": "AI workflows performed more reliably once customer, ops, and billing data shared the same foundation.",
"image": "https://images.unsplash.com/photo-1516321318423-f06f85e504b3?w=900&q=80&auto=format&fit=crop",
"link": "https://agentic.my-biz.app"
},
{
"headline": "Cross-border teams chose regional servers without changing workflows",
"vertical": "Infrastructure",
"date": "2024-09-06",
"shortText": "Distributed companies matched server location to their needs while keeping finance, CRM, and operations fully connected.",
"image": "https://images.unsplash.com/photo-1460925895917-afdab827c52f?w=900&q=80&auto=format&fit=crop",
"link": "#pricing"
}
]
-47
View File
@@ -1,47 +0,0 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
body {
@apply font-brand;
}
#lang-selector {
color: #ffffff;
}
#lang-selector:focus,
#lang-selector:focus-visible {
color: #201824;
background-color: #ffffff;
}
/* ── Blog post modal animations ───────────────────────── */
@keyframes modal-slide-in {
from {
opacity: 0;
transform: scale(0.95) translateY(20px);
}
to {
opacity: 1;
transform: scale(1) translateY(0);
}
}
@keyframes modal-fade-in {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
.modal-animate-box {
animation: modal-slide-in 0.3s cubic-bezier(0.16, 1, 0.3, 1) both;
}
.modal-animate-backdrop {
animation: modal-fade-in 0.2s ease both;
}
+1630
View File
File diff suppressed because it is too large Load Diff
-28
View File
@@ -1,28 +0,0 @@
/** @type {import('tailwindcss').Config} */
module.exports = {
content: ["./index.html"],
theme: {
extend: {
colors: {
"brand-ink": "#201824",
"brand-primary": "#603F57",
"brand-accent": "#F762B4",
"brand-sunrise": "#F8B84A",
"brand-emerald": "#39B982",
"brand-snow": "#F5F2F7",
"brand-smoke": "#E5DCE8",
},
fontFamily: {
brand: ["system-ui", "-apple-system", "Segoe UI", "sans-serif"],
},
boxShadow: {
brand: "0 20px 60px rgba(32, 24, 36, 0.15)",
},
backgroundImage: {
"hero-gradient":
"radial-gradient(circle at top left, rgba(247,98,180,0.35), rgba(32,24,36,0.95))",
},
},
},
plugins: [],
};