642 lines
28 KiB
JavaScript
642 lines
28 KiB
JavaScript
const CORS = {
|
|
"Access-Control-Allow-Origin": "*",
|
|
"Access-Control-Allow-Methods": "POST, GET, OPTIONS",
|
|
"Access-Control-Allow-Headers": "Content-Type, Accept, Origin",
|
|
"Access-Control-Max-Age": "86400"
|
|
};
|
|
|
|
const JSON_HEADERS = { ...CORS, "Content-Type": "application/json" };
|
|
|
|
export default {
|
|
async queue(batch, env) {
|
|
for (const message of batch.messages) {
|
|
try {
|
|
await processAndSendEmail(message.body, env);
|
|
message.ack();
|
|
} catch (err) {
|
|
console.error("Queue processing error:", err);
|
|
message.retry();
|
|
}
|
|
}
|
|
},
|
|
|
|
async fetch(request, env, ctx) {
|
|
if (request.method === "OPTIONS") return new Response(null, { status: 204, headers: CORS });
|
|
|
|
const url = new URL(request.url);
|
|
|
|
if (request.method === "GET" && url.pathname === "/report") {
|
|
return handleReport(request, env);
|
|
}
|
|
|
|
if (request.method !== "POST") {
|
|
return new Response("Method not allowed", { status: 405, headers: CORS });
|
|
}
|
|
|
|
let data;
|
|
try {
|
|
data = await request.json();
|
|
data._ip = request.headers.get("CF-Connecting-IP") || "unknown";
|
|
} catch {
|
|
return json({ ok: false, error: "Invalid JSON" }, 400);
|
|
}
|
|
|
|
if (url.pathname === "/narrative") return handleNarrative(data, env);
|
|
return handleSubmit(data, env, ctx);
|
|
}
|
|
};
|
|
|
|
async function initDB(env) {
|
|
if (!env.DB) return;
|
|
await env.DB.exec(`
|
|
CREATE TABLE IF NOT EXISTS customers (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
submitted_at TEXT NOT NULL,
|
|
first_name TEXT,
|
|
last_name TEXT,
|
|
email TEXT,
|
|
city TEXT,
|
|
country TEXT,
|
|
phone TEXT,
|
|
preferred_language TEXT,
|
|
newsletter TEXT DEFAULT 'no',
|
|
risk_level TEXT,
|
|
risk_score INTEGER,
|
|
location TEXT,
|
|
household_size TEXT,
|
|
water_access TEXT,
|
|
food_reserves TEXT,
|
|
medical_needs TEXT,
|
|
sanitation TEXT,
|
|
budget_eur INTEGER,
|
|
scenarios TEXT,
|
|
priorities TEXT,
|
|
protein_access TEXT,
|
|
protein_detail TEXT,
|
|
protein_preference TEXT,
|
|
protein_security TEXT,
|
|
report_token TEXT,
|
|
language_used TEXT,
|
|
source TEXT DEFAULT 'plan-b.now'
|
|
);
|
|
CREATE INDEX IF NOT EXISTS idx_email ON customers(email);
|
|
CREATE INDEX IF NOT EXISTS idx_country ON customers(country);
|
|
CREATE INDEX IF NOT EXISTS idx_risk_level ON customers(risk_level);
|
|
CREATE INDEX IF NOT EXISTS idx_scenarios ON customers(scenarios);
|
|
CREATE INDEX IF NOT EXISTS idx_submitted ON customers(submitted_at);
|
|
CREATE INDEX IF NOT EXISTS idx_report_token ON customers(report_token);
|
|
`);
|
|
await ensureD1Columns(env);
|
|
}
|
|
|
|
async function ensureD1Columns(env) {
|
|
if (!env.DB) return;
|
|
const migrations = [
|
|
["report_token", "ALTER TABLE customers ADD COLUMN report_token TEXT"],
|
|
["protein_preference", "ALTER TABLE customers ADD COLUMN protein_preference TEXT"],
|
|
["protein_security", "ALTER TABLE customers ADD COLUMN protein_security TEXT"]
|
|
];
|
|
for (const [name, sql] of migrations) {
|
|
try {
|
|
await env.DB.exec(sql);
|
|
} catch (err) {
|
|
if (!String(err && err.message || err).includes("duplicate column")) {
|
|
console.error(`D1 ${name} migration error:`, err);
|
|
}
|
|
}
|
|
}
|
|
try {
|
|
await env.DB.exec("CREATE INDEX IF NOT EXISTS idx_report_token ON customers(report_token)");
|
|
} catch (err) {
|
|
console.error("D1 report_token index error:", err);
|
|
}
|
|
}
|
|
|
|
async function storeCustomer(env, data) {
|
|
if (!env.DB) return null;
|
|
try {
|
|
await initDB(env);
|
|
const stmt = env.DB.prepare(`
|
|
INSERT INTO customers (
|
|
submitted_at, first_name, last_name, email, city, country, phone,
|
|
preferred_language, newsletter, risk_level, risk_score,
|
|
location, household_size, water_access, food_reserves, medical_needs,
|
|
sanitation, budget_eur, scenarios, priorities, protein_access,
|
|
protein_detail, protein_preference, protein_security, report_token,
|
|
language_used
|
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
`);
|
|
const result = await stmt.bind(
|
|
data.submitted_at || new Date().toISOString(),
|
|
data.first_name || "",
|
|
data.last_name || "",
|
|
data.email || "",
|
|
data.city || "",
|
|
data.country || "",
|
|
data.phone || "",
|
|
data.preferred_language || data.language_used || "en",
|
|
data.newsletter || "no",
|
|
data.risk_level || "",
|
|
parseInt(data.risk_score) || 0,
|
|
data.location || "",
|
|
data.household_size || "",
|
|
data.water_access || "",
|
|
data.food_reserves || "",
|
|
data.medical_needs || "",
|
|
data.sanitation || "",
|
|
parseInt(data.budget_eur) || 0,
|
|
data.scenarios || "",
|
|
data.priorities || "",
|
|
data.protein_access || "",
|
|
data.protein_detail || "",
|
|
data.protein_preference || "",
|
|
data.protein_security || "",
|
|
data.report_token || "",
|
|
data.language_used || "en"
|
|
).run();
|
|
return result.meta && result.meta.last_row_id || null;
|
|
} catch (err) {
|
|
console.error("D1 insert error:", err);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
async function handleReport(request, env) {
|
|
if (!env.DB) return json({ ok: false, error: "Database not configured" }, 503);
|
|
|
|
const url = new URL(request.url);
|
|
const id = parseInt(url.searchParams.get("id") || "0", 10);
|
|
const token = String(url.searchParams.get("token") || "").trim();
|
|
if (!id || !token || token.length < 32) return json({ ok: false, error: "Invalid report link" }, 400);
|
|
|
|
await initDB(env);
|
|
const report = await env.DB.prepare(`
|
|
SELECT
|
|
id, submitted_at, first_name, last_name, email, city, country, phone,
|
|
preferred_language, newsletter, risk_level, risk_score,
|
|
location, household_size, water_access, food_reserves, medical_needs,
|
|
sanitation, budget_eur, scenarios, priorities, protein_access,
|
|
protein_detail, protein_preference, protein_security, language_used
|
|
FROM customers
|
|
WHERE id = ? AND report_token = ?
|
|
LIMIT 1
|
|
`).bind(id, token).first();
|
|
|
|
if (!report) return json({ ok: false, error: "Report not found" }, 404);
|
|
return json({ ok: true, report });
|
|
}
|
|
|
|
async function handleNarrative(data, env) {
|
|
const isDE = isGerman(data);
|
|
let narrative = "";
|
|
try {
|
|
const res = await fetch("https://api.anthropic.com/v1/messages", {
|
|
method: "POST",
|
|
headers: {
|
|
"Content-Type": "application/json",
|
|
"x-api-key": env.ANTHROPIC_API_KEY,
|
|
"anthropic-version": "2023-06-01"
|
|
},
|
|
body: JSON.stringify({
|
|
model: "claude-sonnet-4-20250514",
|
|
max_tokens: 1800,
|
|
system: isDE
|
|
? "Du bist ein erfahrener Krisenvorsorge-Berater. Schreibe auf Deutsch. Warm, präzise, handlungsorientiert. Kein HTML. **Fett** für Abschnittsüberschriften, • für Aufzählungen."
|
|
: "You are an experienced crisis preparedness advisor. English. Warm, precise, action-oriented. No HTML. **Bold** for headings, • for bullets.",
|
|
messages: [{ role: "user", content: buildNarrativePrompt(data, isDE) }]
|
|
})
|
|
});
|
|
if (res.ok) {
|
|
const d = await res.json();
|
|
narrative = d.content && d.content[0] && d.content[0].text || "";
|
|
}
|
|
} catch {}
|
|
if (!narrative || narrative.length < 100) narrative = getFallbackNarrative(data, isDE);
|
|
return json({ ok: true, narrative });
|
|
}
|
|
|
|
async function handleSubmit(data, env, ctx) {
|
|
try {
|
|
const ip = data._ip || "unknown";
|
|
const emailKey = String(data.email || "unknown").trim().toLowerCase();
|
|
const skipRateLimit = ip === "109.146.77.109" || emailKey === "test-x5guc3r39@srv1.mail-tester.com";
|
|
if (!skipRateLimit && ip !== "unknown" && env.RATE_LIMIT) {
|
|
const key = `submit:${ip}:${emailKey}`;
|
|
const hits = parseInt(await env.RATE_LIMIT.get(key) || "0", 10);
|
|
if (hits >= 20) return json({ ok: false, error: "Too many submissions. Please try again later." }, 429);
|
|
await env.RATE_LIMIT.put(key, String(hits + 1), { expirationTtl: 3600 });
|
|
}
|
|
} catch (err) {
|
|
console.error("Rate limit check error:", err);
|
|
}
|
|
|
|
const reportToken = generateReportToken();
|
|
data.report_token = reportToken;
|
|
const reportId = await storeCustomer(env, data);
|
|
data.report_id = reportId;
|
|
data.report_token = reportToken;
|
|
|
|
const emailAddr = data.email;
|
|
if (emailAddr && emailAddr.includes("@")) {
|
|
if (env.EMAIL_QUEUE) {
|
|
ctx.waitUntil(env.EMAIL_QUEUE.send(data));
|
|
} else {
|
|
ctx.waitUntil(processAndSendEmail(data, env));
|
|
}
|
|
}
|
|
|
|
return json({ ok: true, report_id: reportId, report_token: reportToken });
|
|
}
|
|
|
|
async function processAndSendEmail(data, env) {
|
|
const emailAddr = data.email;
|
|
if (!emailAddr || !emailAddr.includes("@")) return;
|
|
|
|
const isDE = isGerman(data);
|
|
let emailBody = "";
|
|
try {
|
|
const res = await fetch("https://api.anthropic.com/v1/messages", {
|
|
method: "POST",
|
|
headers: {
|
|
"Content-Type": "application/json",
|
|
"x-api-key": env.ANTHROPIC_API_KEY,
|
|
"anthropic-version": "2023-06-01"
|
|
},
|
|
body: JSON.stringify({
|
|
model: "claude-sonnet-4-20250514",
|
|
max_tokens: 1600,
|
|
system: isDE
|
|
? "Krisenvorsorge-Berater. Persönliche E-Mails auf Deutsch. Kein HTML. • für Listen. **Fett** für Überschriften. Warm und direkt."
|
|
: "Crisis preparedness advisor. Personal emails in English. No HTML. • for lists. **Bold** for headings. Warm and direct.",
|
|
messages: [{ role: "user", content: buildEmailPrompt(data, isDE) }]
|
|
})
|
|
});
|
|
if (res.ok) {
|
|
const d = await res.json();
|
|
emailBody = d.content && d.content[0] && d.content[0].text || "";
|
|
}
|
|
} catch {}
|
|
|
|
if (!emailBody || emailBody.length < 100) emailBody = getFallbackEmailText(data, isDE);
|
|
|
|
const firstName = data.first_name || "";
|
|
const subject = isDE
|
|
? `${firstName ? firstName + ", dein Notfallplan ist bereit" : "Dein Notfallplan ist bereit"}`
|
|
: `${firstName ? firstName + ", your preparedness plan is ready" : "Your preparedness plan is ready"}`;
|
|
const emailHtml = buildEmailTemplate(emailBody, data, isDE);
|
|
const fromAddresses = ["Plan B Now <hello@plan-b.now>", "Plan B Now <onboarding@resend.dev>"];
|
|
|
|
for (const fromAddr of fromAddresses) {
|
|
try {
|
|
const resendRes = await fetch("https://api.resend.com/emails", {
|
|
method: "POST",
|
|
headers: {
|
|
"Authorization": `Bearer ${env.RESEND_API_KEY}`,
|
|
"Content-Type": "application/json"
|
|
},
|
|
body: JSON.stringify({
|
|
from: fromAddr,
|
|
to: [emailAddr],
|
|
reply_to: "hello@plan-b.now",
|
|
subject,
|
|
html: emailHtml,
|
|
headers: {
|
|
"List-Unsubscribe": "<mailto:hello@plan-b.now?subject=unsubscribe>",
|
|
"List-Unsubscribe-Post": "List-Unsubscribe=One-Click",
|
|
"X-Entity-Ref-ID": `${emailAddr}-${Date.now()}`
|
|
}
|
|
})
|
|
});
|
|
const resendBody = await resendRes.json().catch(() => ({}));
|
|
console.log(`Resend [${fromAddr}] status:`, resendRes.status, JSON.stringify(resendBody).substring(0, 200));
|
|
if (resendRes.ok) return;
|
|
console.error(`Resend failed [${fromAddr}]:`, resendBody.message || resendBody.name);
|
|
} catch (err) {
|
|
console.error(`Resend exception [${fromAddr}]:`, err.message);
|
|
}
|
|
}
|
|
}
|
|
|
|
function generateReportToken() {
|
|
const bytes = new Uint8Array(24);
|
|
crypto.getRandomValues(bytes);
|
|
return Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join("");
|
|
}
|
|
|
|
function isGerman(data) {
|
|
return (data.preferred_language || data.language_used || "en") === "de";
|
|
}
|
|
|
|
function buildNarrativePrompt(data, isDE) {
|
|
const name = data.first_name || (isDE ? "du" : "you");
|
|
const city = data.city || (isDE ? "deiner Stadt" : "your city");
|
|
const country = data.country || "";
|
|
const risk = getRiskLabel(data.risk_level, isDE);
|
|
const budget = data.budget_eur ? `EUR ${data.budget_eur}` : "";
|
|
const household = householdLabel(data.household_size, isDE);
|
|
const scenarios = (data.scenarios || "").split(",").map((s) => s.trim()).filter(Boolean);
|
|
const protein = data.protein_access || data.protein_detail || "";
|
|
const scenarioLabels = getScenarioLabels(isDE);
|
|
const selectedScenarios = scenarios.map((s) => scenarioLabels[s] || s).join(", ") || (isDE ? "allgemeine Vorsorge" : "general preparedness");
|
|
|
|
if (isDE) return `Erstelle eine personalisierte Krisenvorsorge-Analyse für ${name} aus ${city}, ${country}.
|
|
|
|
PROFIL: Risiko ${risk} | ${household} | Budget ${budget}
|
|
GEWÄHLTE SZENARIEN: ${selectedScenarios}
|
|
BEDENKEN: ${data.priorities || "allgemein"} | Protein: ${protein || "keine"}
|
|
LÜCKEN: Wasser (${getStatusLabel(data.water_access, true)}) | Lebensmittel (${getStatusLabel(data.food_reserves, true)}) | Sanitär (${getStatusLabel(data.sanitation, true)})
|
|
|
|
Schreibe eine vollständige, persönliche Analyse mit **Fettschrift**-Überschriften und • Listen. Warm, sachlich, nicht alarmistisch.`;
|
|
|
|
return `Create a personalised crisis preparedness analysis for ${name} from ${city}, ${country}.
|
|
|
|
PROFILE: Risk ${risk} | ${household} | Budget ${budget}
|
|
SELECTED SCENARIOS: ${selectedScenarios}
|
|
CONCERNS: ${data.priorities || "general"} | Protein: ${protein || "none"}
|
|
GAPS: Water (${getStatusLabel(data.water_access, false)}) | Food (${getStatusLabel(data.food_reserves, false)}) | Sanitation (${getStatusLabel(data.sanitation, false)})
|
|
|
|
Write a complete personalised analysis with **Bold** headings and • bullets. Warm, factual, not alarmist.`;
|
|
}
|
|
|
|
function buildEmailPrompt(data, isDE) {
|
|
const fullName = [data.first_name, data.last_name].filter(Boolean).join(" ") || (isDE ? "dort" : "there");
|
|
const city = data.city || "";
|
|
const country = data.country || "";
|
|
const risk = getRiskLabel(data.risk_level, isDE);
|
|
const budget = data.budget_eur ? `EUR ${data.budget_eur}` : "";
|
|
const household = householdLabel(data.household_size, isDE);
|
|
const protein = data.protein_access || data.protein_detail || "";
|
|
const scenarios = (data.scenarios || "").split(",").map((s) => s.trim()).filter(Boolean);
|
|
const scenarioLabels = getScenarioLabels(isDE);
|
|
const selectedScenarios = scenarios.map((s) => scenarioLabels[s] || s).join(", ") || (isDE ? "allgemeine Vorsorge" : "general preparedness");
|
|
const base = "https://plan-b.now/go?p=";
|
|
const links = {
|
|
water: base + "awg",
|
|
food: base + "rice",
|
|
sprouts: base + "sprouts",
|
|
solar: base + "ecoflow",
|
|
protein: base + "protein"
|
|
};
|
|
|
|
if (isDE) return `Schreibe eine vollständige, persönliche Willkommens-E-Mail an ${fullName} aus ${city}, ${country}.
|
|
|
|
PROFIL: ${risk} | ${household} | Budget ${budget}
|
|
GEWÄHLTE SZENARIEN: ${selectedScenarios}
|
|
Protein: ${protein || "keine"}
|
|
|
|
Struktur:
|
|
1. Liebe/Lieber ${fullName}, herzliche Begrüßung und Dank fürs Assessment.
|
|
2. Kurze Erklärung des Risikos ${risk} für ${city}.
|
|
3. Die 3 dringendsten Maßnahmen:
|
|
• Wasserversorgung: ${links.water}
|
|
• Lebensmittelvorrat: ${links.food}
|
|
• Keimsprossen-Set: ${links.sprouts}
|
|
• Solar-Energie: ${links.solar}
|
|
${data.protein_security === "uncertain" ? "• Proteinquelle: " + links.protein : ""}
|
|
4. Kurzer Abschnitt zu den gewählten Szenarien.
|
|
5. Ermutigung + Hinweis auf wöchentliche Updates.
|
|
|
|
Ton: Wie ein erfahrener Freund. KEIN HTML. • für Listen.`;
|
|
|
|
return `Write a complete personal welcome email to ${fullName} from ${city}, ${country}.
|
|
|
|
PROFILE: ${risk} | ${household} | Budget ${budget}
|
|
SELECTED SCENARIOS: ${selectedScenarios}
|
|
Protein: ${protein || "none"}
|
|
|
|
Structure:
|
|
1. Dear ${fullName}, warm welcome and thanks for the assessment.
|
|
2. Brief explanation of risk ${risk} for ${city}.
|
|
3. The 3 most urgent actions:
|
|
• Water security: ${links.water}
|
|
• Food reserves: ${links.food}
|
|
• Sprouting kit: ${links.sprouts}
|
|
• Solar power: ${links.solar}
|
|
${data.protein_security === "uncertain" ? "• Protein source: " + links.protein : ""}
|
|
4. Brief section on selected scenarios.
|
|
5. Encouraging close + weekly updates.
|
|
|
|
Tone: Like advice from an experienced friend. NO HTML. • for lists.`;
|
|
}
|
|
|
|
function buildEmailTemplate(bodyText, data, isDE) {
|
|
const esc = (v) => String(v ?? "")
|
|
.replace(/&/g, "&")
|
|
.replace(/</g, "<")
|
|
.replace(/>/g, ">")
|
|
.replace(/"/g, """)
|
|
.replace(/'/g, "'");
|
|
|
|
const fullName = [data.first_name, data.last_name].filter(Boolean).join(" ") || "";
|
|
const greeting = fullName ? isDE ? `Liebe/Lieber ${esc(fullName)}` : `Hello ${esc(fullName)}` : isDE ? "Hallo" : "Hello";
|
|
const risk = data.risk_level || "ASSESSED";
|
|
const scenarios = (data.scenarios || "").split(",").map((s) => s.trim()).filter(Boolean);
|
|
const scenarioLabels = getScenarioLabels(isDE);
|
|
const scenarioBadges = scenarios.map((s) =>
|
|
`<span style="display:inline-block;background:#F2F2F0;border:1px solid #E4E4E0;padding:5px 9px;font-size:11px;line-height:1.3;color:#5A5A54;margin:3px 4px 3px 0">${esc(scenarioLabels[s] || s)}</span>`
|
|
).join("");
|
|
|
|
const formatText = (text) => {
|
|
return esc(text).split(/\n\n+/).filter((p) => p.trim()).map((p) => {
|
|
if (p.startsWith("* ") || p.startsWith("- ") || p.startsWith("•") || p.includes("\n•")) {
|
|
const items = p.split("\n").filter((l) => l.trim()).map((l) => {
|
|
const clean = l.trim().replace(/^(\*|-|•)\s*/, "");
|
|
const linked = clean.replace(/(https?:\/\/[^\s<]+)/g, `<a href="$1" style="color:#4A8A68;font-weight:700;text-decoration:underline">${isDE ? "Ansehen" : "View"}</a>`);
|
|
const bolded = linked.replace(/\*\*(.+?)\*\*/g, '<strong style="color:#1A1A18">$1</strong>');
|
|
return `<li style="margin:0 0 10px;line-height:1.7">${bolded}</li>`;
|
|
}).join("");
|
|
return `<ul style="margin:12px 0 18px;padding-left:20px;color:#5A5A54">${items}</ul>`;
|
|
}
|
|
const linked = p.replace(/(https?:\/\/[^\s<]+)/g, `<a href="$1" style="color:#4A8A68;font-weight:700;text-decoration:underline">${isDE ? "Ansehen" : "View"}</a>`);
|
|
const bolded = linked.replace(/\*\*(.+?)\*\*/g, '<strong style="color:#1A1A18">$1</strong>');
|
|
return `<p style="margin:0 0 17px;line-height:1.72">${bolded}</p>`;
|
|
}).join("");
|
|
};
|
|
|
|
const restoreQuery = data.report_id && data.report_token
|
|
? `?r=${encodeURIComponent(data.report_id)}&t=${encodeURIComponent(data.report_token)}`
|
|
: "";
|
|
const reportUrl = `https://plan-b.now/${restoreQuery}#results-section`;
|
|
const analysisUrl = `https://plan-b.now/${restoreQuery}#narrative-section`;
|
|
const recommendationsUrl = `https://plan-b.now/${restoreQuery}#recs-anchor`;
|
|
const budgetUrl = `https://plan-b.now/${restoreQuery}#panel-budget`;
|
|
const timelineUrl = `https://plan-b.now/${restoreQuery}#panel-timeline`;
|
|
const proteinUrl = `https://plan-b.now/${restoreQuery}#protein-offer-section`;
|
|
const body = formatText(bodyText);
|
|
|
|
return `<!doctype html>
|
|
<html lang="${isDE ? "de" : "en"}">
|
|
<head>
|
|
<meta charset="UTF-8"/>
|
|
<meta name="viewport" content="width=device-width,initial-scale=1"/>
|
|
<title>${isDE ? "Dein Plan-B Notfallplan" : "Your Plan-B Preparedness Plan"}</title>
|
|
</head>
|
|
<body style="margin:0;padding:0;background:#FAFAFA;font-family:Arial,Helvetica,sans-serif;color:#5A5A54;">
|
|
<div style="max-width:680px;margin:0 auto;padding:30px 12px;">
|
|
<div style="background:#FAFAFA;border:1px solid #E4E4E0;overflow:hidden;">
|
|
<div style="padding:34px 32px 28px;border-bottom:1px solid #E4E4E0;">
|
|
<div style="font-family:Georgia,'Times New Roman',serif;font-size:42px;line-height:1;letter-spacing:.04em;color:#1A1A18;">Plan<span style="color:#5A9A78">-B</span></div>
|
|
<div style="margin-top:22px;font-size:11px;line-height:1.4;letter-spacing:3px;text-transform:uppercase;color:#5A9A78;font-weight:700;">${isDE ? "Persoenlicher Vorsorgeplan" : "Personal Preparedness Plan"}</div>
|
|
<h1 style="margin:14px 0 0;font-size:28px;line-height:1.14;letter-spacing:2px;text-transform:uppercase;color:#1A1A18;font-weight:800;">${esc(getRiskLabel(risk, isDE))} ${isDE ? "Bericht" : "Readiness Report"}</h1>
|
|
</div>
|
|
<div style="padding:30px 32px;">
|
|
<p style="margin:0 0 22px;font-size:16px;line-height:1.7;color:#3A3A34;">${greeting},</p>
|
|
<div style="background:#E8F2EC;border-left:4px solid #5A9A78;padding:18px 18px 18px 20px;margin-bottom:18px;">
|
|
<div style="font-size:10px;line-height:1.4;letter-spacing:2px;text-transform:uppercase;color:#8A8A84;font-weight:700;">${isDE ? "Risikoprofil" : "Risk Profile"}</div>
|
|
<div style="margin-top:7px;font-size:20px;line-height:1.2;color:#1A1A18;font-weight:800;">${esc(getRiskLabel(risk, isDE))}</div>
|
|
${scenarioBadges ? `<div style="margin-top:12px">${scenarioBadges}</div>` : ""}
|
|
</div>
|
|
<div style="font-size:15px;line-height:1.72;color:#5A5A54;">${body}</div>
|
|
<div style="margin-top:32px;">
|
|
<a href="${reportUrl}" style="display:inline-block;background:#1A1A18;color:#FAFAFA;text-decoration:none;padding:16px 24px;border-radius:0;font-size:12px;font-weight:700;letter-spacing:2.4px;text-transform:uppercase">${isDE ? "Meinen Bericht oeffnen" : "Open My Report"}</a>
|
|
</div>
|
|
</div>
|
|
<div style="margin:0 32px 28px;background:#FAFAFA;border:1px solid #E4E4E0;padding:18px;">
|
|
<div style="font-size:10px;color:#8A8A84;letter-spacing:2px;text-transform:uppercase;margin-bottom:12px;font-weight:700;">${isDE ? "Direkt zu deinem Bericht" : "Jump back to your report"}</div>
|
|
<table style="width:100%;border-collapse:collapse;">
|
|
<tr>
|
|
<td style="width:50%;padding:0 6px 8px 0;"><a href="${analysisUrl}" style="display:block;background:#F2F2F0;border:1px solid #E4E4E0;color:#1A1A18;text-decoration:none;padding:13px 14px;font-size:12px;font-weight:700;letter-spacing:1.2px;text-transform:uppercase;">${isDE ? "Analyse" : "Analysis"}</a></td>
|
|
<td style="width:50%;padding:0 0 8px 6px;"><a href="${recommendationsUrl}" style="display:block;background:#F2F2F0;border:1px solid #E4E4E0;color:#1A1A18;text-decoration:none;padding:13px 14px;font-size:12px;font-weight:700;letter-spacing:1.2px;text-transform:uppercase;">${isDE ? "Empfehlungen" : "Recommendations"}</a></td>
|
|
</tr>
|
|
<tr>
|
|
<td style="width:50%;padding:0 6px 0 0;"><a href="${budgetUrl}" style="display:block;background:#F2F2F0;border:1px solid #E4E4E0;color:#1A1A18;text-decoration:none;padding:13px 14px;font-size:12px;font-weight:700;letter-spacing:1.2px;text-transform:uppercase;">${isDE ? "Budget" : "Budget"}</a></td>
|
|
<td style="width:50%;padding:0 0 0 6px;"><a href="${timelineUrl}" style="display:block;background:#F2F2F0;border:1px solid #E4E4E0;color:#1A1A18;text-decoration:none;padding:13px 14px;font-size:12px;font-weight:700;letter-spacing:1.2px;text-transform:uppercase;">${isDE ? "Zeitplan" : "Timeline"}</a></td>
|
|
</tr>
|
|
</table>
|
|
${data.protein_security === "uncertain" ? `<div style="margin-top:10px;"><a href="${proteinUrl}" style="display:block;background:#E8F2EC;border:1px solid #C7DDCE;color:#1A1A18;text-decoration:none;padding:13px 14px;font-size:12px;font-weight:700;letter-spacing:1.2px;text-transform:uppercase;">${isDE ? "Proteinquelle sichern" : "Secure protein source"}</a></div>` : ""}
|
|
</div>
|
|
<div style="margin:0 32px 28px;background:#F2F2F0;border:1px solid #E4E4E0;padding:18px;">
|
|
<div style="font-size:10px;color:#8A8A84;letter-spacing:2px;text-transform:uppercase;margin-bottom:10px;font-weight:700;">${isDE ? "Dein Profil" : "Your Profile"}</div>
|
|
<table style="width:100%;border-collapse:collapse;font-size:13px;line-height:1.5">
|
|
${erow(isDE ? "Name" : "Name", esc(fullName))}
|
|
${erow(isDE ? "Wohnort" : "Location", esc([data.city, data.country].filter(Boolean).join(", ")))}
|
|
${erow(isDE ? "Haushalt" : "Household", esc(householdLabel(data.household_size, isDE)))}
|
|
${erow(isDE ? "Budget" : "Budget", data.budget_eur ? `EUR ${esc(data.budget_eur)}` : "")}
|
|
${erow(isDE ? "Szenarien" : "Scenarios", esc(scenarios.map((s) => scenarioLabels[s] || s).join(", ")))}
|
|
</table>
|
|
</div>
|
|
<div style="padding:20px 32px 26px;background:#F2F2F0;border-top:1px solid #E4E4E0;">
|
|
<p style="margin:0;color:#8A8A84;font-size:12px;line-height:1.7;">${isDE ? 'Du erhaeltst diese E-Mail, weil du das Plan-B Assessment abgeschlossen hast.' : 'You are receiving this because you completed the Plan-B assessment.'} <a href="mailto:hello@plan-b.now?subject=unsubscribe" style="color:#4A8A68;">${isDE ? "Abmelden" : "Unsubscribe"}</a>. ${isDE ? "Deine Daten werden nie verkauft." : "Your data is never sold."}</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</body>
|
|
</html>`;
|
|
}
|
|
|
|
function erow(label, value) {
|
|
if (!value || value === "—" || !String(value).trim()) return "";
|
|
return `<tr><td style="padding:5px 0;color:#8A8A84;width:38%;vertical-align:top">${label}</td><td style="padding:5px 0;color:#1A1A18;font-weight:600">${value}</td></tr>`;
|
|
}
|
|
|
|
function getFallbackNarrative(data, isDE) {
|
|
if (isDE) return `**DEINE AKTUELLE LAGE**
|
|
|
|
Dein Assessment zeigt, wo die wichtigsten Lücken sind. Du hast den ersten entscheidenden Schritt gemacht.
|
|
|
|
**NÄCHSTE SCHRITTE**
|
|
|
|
• Wasser zuerst sichern
|
|
• Lebensmittelvorrat auf zwei Wochen bringen
|
|
• Energie, Medizin und Hygiene als zweite Redundanz planen`;
|
|
|
|
return `**YOUR CURRENT SITUATION**
|
|
|
|
Your assessment shows where the key gaps are. You have taken the first decisive step.
|
|
|
|
**NEXT STEPS**
|
|
|
|
• Secure water first
|
|
• Build food reserves toward two weeks
|
|
• Add redundancy for energy, medical needs, and sanitation`;
|
|
}
|
|
|
|
function getFallbackEmailText(data, isDE) {
|
|
const fullName = [data.first_name, data.last_name].filter(Boolean).join(" ") || "";
|
|
const risk = getRiskLabel(data.risk_level, isDE);
|
|
if (isDE) return `Liebe/Lieber ${fullName || "dort"},
|
|
|
|
Danke für das Plan-B Assessment. Risikoniveau: ${risk}.
|
|
|
|
• Wasser zuerst sichern
|
|
• Lebensmittelvorrat aufbauen
|
|
• Energie und Hygiene redundant planen
|
|
|
|
Wöchentliche Updates folgen.
|
|
|
|
Plan-B Team`;
|
|
|
|
return `Dear ${fullName || "there"},
|
|
|
|
Thank you for the Plan-B assessment. Risk level: ${risk}.
|
|
|
|
• Secure water first
|
|
• Build food reserves
|
|
• Add redundancy for energy and hygiene
|
|
|
|
Weekly updates to follow.
|
|
|
|
Plan-B Team`;
|
|
}
|
|
|
|
function getScenarioLabels(isDE) {
|
|
return {
|
|
total_collapse: isDE ? "Totaler Kollaps" : "Total Collapse",
|
|
partial_collapse: isDE ? "Partieller Kollaps" : "Partial Collapse",
|
|
hyperinflation: isDE ? "Hyperinflation" : "Hyperinflation",
|
|
supply_shock: isDE ? "Versorgungsengpass" : "Supply Side Shock",
|
|
food_crisis: isDE ? "Lebensmittelkrise" : "Food Crisis",
|
|
bank_crisis: isDE ? "Banken- / Finanzkrise" : "Bank / Financial Crisis",
|
|
power_outage: isDE ? "Längerer Stromausfall" : "Extended Power Outage",
|
|
water_failure: isDE ? "Wasserversorgungsausfall" : "Water Supply Failure",
|
|
war: isDE ? "Krieg / Bewaffneter Konflikt" : "War / Armed Conflict"
|
|
};
|
|
}
|
|
|
|
function getRiskLabel(level, isDE) {
|
|
const map = {
|
|
CRITICAL: { en: "Critical", de: "Kritisch" },
|
|
"HIGH RISK": { en: "High Risk", de: "Hohes Risiko" },
|
|
HIGH: { en: "High Risk", de: "Hohes Risiko" },
|
|
MODERATE: { en: "Moderate", de: "Mäßig" },
|
|
PREPARED: { en: "Prepared", de: "Vorbereitet" }
|
|
};
|
|
const entry = map[level] || { en: level || "Assessed", de: level || "Bewertet" };
|
|
return isDE ? entry.de : entry.en;
|
|
}
|
|
|
|
function householdLabel(size, isDE) {
|
|
const map = {
|
|
"1": { en: "1 person", de: "1 Person" },
|
|
"2": { en: "2 people", de: "2 Personen" },
|
|
"4": { en: "3-4 people", de: "3-4 Personen" },
|
|
"6": { en: "5-6 people", de: "5-6 Personen" },
|
|
"8": { en: "7+ people", de: "7+ Personen" }
|
|
};
|
|
const entry = map[size] || { en: size || "—", de: size || "—" };
|
|
return isDE ? entry.de : entry.en;
|
|
}
|
|
|
|
function getStatusLabel(value, isDE) {
|
|
const map = {
|
|
none: { en: "None", de: "Keine" },
|
|
minimal: { en: "Minimal", de: "Minimal" },
|
|
filter: { en: "Filter + storage", de: "Filter + Vorrat" },
|
|
solid: { en: "Solid 1mo+", de: "Gut 1Mo+" },
|
|
week: { en: "~1 week", de: "~1 Woche" },
|
|
month: { en: "1-3 months", de: "1-3 Monate" },
|
|
mild: { en: "Mild", de: "Gering" },
|
|
moderate: { en: "Moderate", de: "Mäßig" },
|
|
high: { en: "High-critical", de: "Hoch-kritisch" },
|
|
basic: { en: "Basic only", de: "Nur Grundbedarf" },
|
|
bucket: { en: "Bucket system", de: "Eimer-System" },
|
|
full: { en: "Full kit", de: "Vollständig" }
|
|
};
|
|
const entry = map[value] || { en: value || "—", de: value || "—" };
|
|
return isDE ? entry.de : entry.en;
|
|
}
|
|
|
|
function json(body, status = 200) {
|
|
return new Response(JSON.stringify(body), { status, headers: JSON_HEADERS });
|
|
}
|