diff --git a/cloudflare-buildEmailTemplate-replacement.js b/cloudflare-buildEmailTemplate-replacement.js index c1ae30f..9a42110 100644 --- a/cloudflare-buildEmailTemplate-replacement.js +++ b/cloudflare-buildEmailTemplate-replacement.js @@ -45,12 +45,15 @@ function buildEmailTemplate(bodyText, data, isDE) { }; const body = formatText(bodyText); - const reportUrl = "https://plan-b.now/#results-section"; - const analysisUrl = "https://plan-b.now/#narrative-section"; - const recommendationsUrl = "https://plan-b.now/#recs-anchor"; - const budgetUrl = "https://plan-b.now/#panel-budget"; - const timelineUrl = "https://plan-b.now/#panel-timeline"; - const proteinUrl = "https://plan-b.now/#protein-offer-section"; + 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`; return ` diff --git a/cloudflare-d1-report-restore-worker-patch.js b/cloudflare-d1-report-restore-worker-patch.js new file mode 100644 index 0000000..714e462 --- /dev/null +++ b/cloudflare-d1-report-restore-worker-patch.js @@ -0,0 +1,137 @@ +/* +Paste these changes into the deployed Cloudflare Worker. + +Goal: +- Store a private report_token against each D1 customers row. +- Return { report_id, report_token } from /submit. +- Add GET /report?id=&token= to restore the report from D1. +- Pass report_id/report_token into buildEmailTemplate so email links can use: + https://plan-b.now/?r=&t=#panel-budget +*/ + +// 1) In fetch(request, env, ctx), create url before the method guard and add GET /report. +// Replace the beginning of fetch with this shape: +async function fetch(request, env, ctx) { + 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" + }; + if (request.method === "OPTIONS") return new Response(null, { status: 204, headers: CORS }); + + const JSON_H = { ...CORS, "Content-Type": "application/json" }; + const url = new URL(request.url); + + if (request.method === "GET" && url.pathname === "/report") { + return handleReport(request, env, JSON_H); + } + + 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 new Response(JSON.stringify({ ok: false, error: "Invalid JSON" }), { status: 400, headers: JSON_H }); + } + + if (url.pathname === "/narrative") return handleNarrative(data, env, JSON_H); + return handleSubmit(data, env, ctx, JSON_H); +} + +// 2) Add these helpers anywhere near the other helper functions. +function generateReportToken() { + const bytes = new Uint8Array(24); + crypto.getRandomValues(bytes); + return Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join(""); +} + +async function ensureReportTokenColumn(env) { + if (!env.DB) return; + try { + await env.DB.exec("ALTER TABLE customers ADD COLUMN report_token TEXT"); + } catch (err) { + if (!String(err && err.message || err).includes("duplicate column")) { + console.error("D1 report_token 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 handleReport(request, env, headers) { + if (!env.DB) { + return new Response(JSON.stringify({ ok: false, error: "Database not configured" }), { status: 503, headers }); + } + + 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 new Response(JSON.stringify({ ok: false, error: "Invalid report link" }), { status: 400, headers }); + } + + await initDB(env); + await ensureReportTokenColumn(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, language_used, protein_preference, protein_security + FROM customers + WHERE id = ? AND report_token = ? + LIMIT 1 + `).bind(id, token).first(); + + if (!report) { + return new Response(JSON.stringify({ ok: false, error: "Report not found" }), { status: 404, headers }); + } + + return new Response(JSON.stringify({ ok: true, report }), { headers }); +} + +// 3) In initDB(env), after the CREATE TABLE exec finishes, call: +// await ensureReportTokenColumn(env); + +// 4) In storeCustomer(env, data), after the INSERT .run(), update the new token column. +// Replace the return block after stmt.bind(...).run() with: +async function storeCustomerReturnBlockExample(env, data, result) { + const rowId = result.meta && result.meta.last_row_id || null; + if (rowId && data.report_token) { + await env.DB.prepare("UPDATE customers SET report_token = ? WHERE id = ?").bind(data.report_token, rowId).run(); + } + return rowId; +} + +// 5) In handleSubmit(data, env, ctx, headers), generate a token before storing/sending: +// const reportToken = generateReportToken(); +// data.report_token = reportToken; +// +// Then store the customer synchronously before building the email: +// await initDB(env); +// await ensureReportTokenColumn(env); +// const reportId = await storeCustomer(env, data); +// data.report_id = reportId; +// data.report_token = reportToken; +// +// Remove or adjust the old ctx.waitUntil DB store block so it does not insert a duplicate row. +// +// Finally, return the report link fields: +// return new Response(JSON.stringify({ +// ok: true, +// report_id: reportId, +// report_token: reportToken +// }), { headers }); + +// 6) In buildEmailTemplate(bodyText, data, isDE), use the updated +// cloudflare-buildEmailTemplate-replacement.js file. It already creates durable links +// when data.report_id and data.report_token are present. diff --git a/src/App.vue b/src/App.vue index 01912a7..87b52d9 100644 --- a/src/App.vue +++ b/src/App.vue @@ -884,6 +884,7 @@ const T = { } const WORKER_URL = import.meta.env.VITE_WORKER_URL || 'https://planb-email.janwellmann.workers.dev/submit' +const WORKER_BASE_URL = WORKER_URL.replace(/\/submit\/?$/, '') let currentLang = 'en' function setLang(lang) { @@ -1125,6 +1126,72 @@ function loadState() { } catch { return null } } +function splitStoredList(value) { + if (Array.isArray(value)) return value.filter(Boolean) + return String(value || '').split(',').map(item => item.trim()).filter(Boolean) +} + +function answersFromReportRecord(record) { + return { + location: record.location || '', + household: record.household_size || '', + water: record.water_access || '', + food: record.food_reserves || '', + medical: record.medical_needs || '', + sanitation: record.sanitation || '', + budget: parseInt(record.budget_eur) || 1500, + scenarios: splitStoredList(record.scenarios || record.priorities), + protein_pref: splitStoredList(record.protein_preference), + protein_access: record.protein_security || '', + protein: record.protein_access || '', + _first_name: record.first_name || '', + _last_name: record.last_name || '', + _email: record.email || '', + _city: record.city || '', + _country: record.country || '', + _phone: record.phone || '', + } +} + +function saveRestoredReport(record) { + answers = answersFromReportRecord(record) + currentQ = QUESTIONS.length - 1 + currentScenario = 1 + currentLang = record.preferred_language || record.language_used || currentLang + riskScore = parseInt(record.risk_score) || 0 + riskLevelStr = record.risk_level || '' + try { + const payload = JSON.stringify({ + stage: 'results', + currentQ, + answers, + currentScenario, + currentLang, + ts: Date.now(), + }) + localStorage.setItem(STATE_KEY, payload) + sessionStorage.setItem(STATE_KEY, payload) + } catch {} +} + +async function restoreReportFromUrl() { + const params = new URLSearchParams(window.location.search) + const id = params.get('r') + const token = params.get('t') + if (!id || !token) return false + + const res = await fetch(`${WORKER_BASE_URL}/report?id=${encodeURIComponent(id)}&token=${encodeURIComponent(token)}`, { + method: 'GET', + headers: { 'Accept': 'application/json' }, + mode: 'cors', + }) + if (!res.ok) throw new Error('Report restore failed') + const data = await res.json() + if (!data.ok || !data.report) throw new Error('Report restore missing report') + saveRestoredReport(data.report) + return true +} + // ══════════════════════════════════════ // QUIZ FLOW // ══════════════════════════════════════ @@ -1666,6 +1733,14 @@ function submitCapture(e) { _subject: (T[currentLang].brand || 'Plan-B') + ' — ' + lvl + ' — ' + firstName + ' ' + lastName + ' — ' + city + ', ' + country } + answers._first_name = firstName + answers._last_name = lastName + answers._email = email + answers._city = city + answers._country = country + answers._phone = phone + saveState() + const showSuccess = () => { const form = document.getElementById('capture-form') const sub = document.querySelector('#capture-modal .capture-sub') @@ -1694,7 +1769,15 @@ function submitCapture(e) { body: JSON.stringify(payload) }), workerTimeout - ]).catch(() => {}) + ]) + .then(res => res && res.ok ? res.json() : null) + .then(data => { + if (!data || !data.report_id || !data.report_token) return + answers._report_id = String(data.report_id) + answers._report_token = String(data.report_token) + saveState() + }) + .catch(() => {}) setTimeout(showSuccess, 1500) } @@ -2263,7 +2346,7 @@ function initPaintPicker() { // ══════════════════════════════════════ // MOUNT // ══════════════════════════════════════ -onMounted(() => { +onMounted(async () => { // Expose functions referenced by inline onclick / onsubmit / onchange handlers window.startQuiz = startQuiz window.restartQuiz = restartQuiz @@ -2296,6 +2379,13 @@ onMounted(() => { // / renderResults pick up the right strings. If no saved language, sniff // navigator.language so German speakers land on the German copy without // having to click the toggle. + let restoredFromRemote = false + try { + restoredFromRemote = await restoreReportFromUrl() + } catch (err) { + console.warn('Could not restore report from D1', err) + } + const saved = loadState() const detectLang = () => { const nav = (navigator.language || navigator.userLanguage || 'en').toLowerCase() @@ -2305,10 +2395,10 @@ onMounted(() => { setLang(lang) updateRegionIndicator() - // Intro overlay — only on the very first home-page visit in this tab. - // If we're resuming a saved quiz/results stage, or the intro has already - // played in this session, jump straight to the hero with no animation. - const resumingMidFlow = !!(saved && saved.stage && saved.stage !== 'home') + // Intro overlay — only on the first visit. If we're resuming a saved + // quiz/results stage, or the intro has already been seen/skipped, jump + // straight to the hero with no animation. + const resumingMidFlow = restoredFromRemote || !!(saved && saved.stage && saved.stage !== 'home') maybePlayIntro({ skip: resumingMidFlow }) if (saved && saved.stage && saved.stage !== 'home') {