json-object-editor
Version:
JOE the Json Object Editor | Platform Edition
563 lines (504 loc) • 24.4 kB
JavaScript
module.exports = function(data, options) {
const protocol = data.protocol || data.ITEM;
if (!protocol) {
return '<div style="padding: 20px; color: #dc3545;">Error: No protocol data available</div>';
}
// Helper functions
function getRef(refId) {
if (!refId) return null;
try {
return $J.get(refId);
} catch(e) {
console.error('Error getting reference:', refId, e);
return null;
}
}
function extractText(rendering) {
if (!rendering) return '';
if (typeof rendering === 'string') {
return rendering.replace(/<[^>]*>/g, '').trim();
}
return String(rendering);
}
function preserveHtml(html) {
if (!html) return '';
if (typeof html === 'string') {
return html.trim();
}
return String(html);
}
function escapeHtml(text) {
if (!text) return '';
return String(text)
.replace(/&/g, '&' + 'amp;')
.replace(/</g, '&' + 'lt;')
.replace(/>/g, '&' + 'gt;')
.replace(/"/g, '&' + 'quot;')
.replace(/'/g, '&' + '#039;');
}
function formatDate(dateString) {
if (!dateString) return '';
try {
const date = new Date(dateString);
return date.toLocaleDateString('en-US', {
year: 'numeric',
month: 'long',
day: 'numeric'
});
} catch(e) {
return '';
}
}
function splitList(text) {
if (!text) return [];
return text.split(/\n|•|·|-/).map(s => s.trim()).filter(Boolean);
}
// Get client (handle both array and string formats)
let clientId = protocol.client;
if (Array.isArray(clientId)) {
clientId = clientId.length > 0 ? clientId[0] : null;
}
const client = clientId ? getRef(clientId) : null;
// Build report data
const reportData = {
protocol_name: protocol.name || '',
protocol_headline: protocol.info || '',
name: client ? (client.name || '') : '',
email: client ? (client.email || '') : '',
phone_number: client ? (client.phone || '') : '',
generated_on: formatDate(protocol.joeUpdated || protocol.created),
protocol_conditions: [],
main_concerns: [],
top_outcomes: [],
client_symptoms: '',
urine_ph: protocol.urine_analysis_ph ? String(protocol.urine_analysis_ph) : '',
urine_status: protocol.urine_status || '',
blood_pressure_analysis_headline: protocol.blood_pressure_analysis_headline || '',
urine_analysis_headline: protocol.urine_analysis_headline || '',
condition_summaries: []
};
// Client data
if (client) {
if (client.main_concerns) {
reportData.main_concerns = splitList(extractText(client.main_concerns));
}
if (client.top_outcomes) {
reportData.top_outcomes = splitList(extractText(client.top_outcomes));
}
if (client.client_symptoms) {
reportData.client_symptoms = extractText(client.client_symptoms);
}
}
// Protocol conditions
if (protocol.conditions && Array.isArray(protocol.conditions)) {
reportData.protocol_conditions = protocol.conditions.map(function(condId) {
const cond = getRef(condId);
return cond ? cond.name : null;
}).filter(Boolean);
}
// Build phases
const phases = [];
for (let i = 1; i <= 5; i++) {
const phaseHeadline = protocol[`phase_${i}_headline`] || '';
const phaseDesc = protocol[`phase_${i}_description`] ? preserveHtml(protocol[`phase_${i}_description`]) : '';
const phaseRecIds = protocol[`phase_${i}_recommendations`] || [];
const phaseOverrides = protocol[`phase_${i}_overrides`] || [];
// Handle both array and single value formats
const recIds = Array.isArray(phaseRecIds) ? phaseRecIds : (phaseRecIds ? [phaseRecIds] : []);
// Build override lookup map - match rec _id on recToOverride
const overrideMap = {};
if (Array.isArray(phaseOverrides)) {
phaseOverrides.forEach(function(override) {
const recIdField = `phase_${i}_recToOverride`;
const dosageField = `phase_${i}_dosage`;
const instructionsField = `phase_${i}_instructions`;
const recId = override[recIdField];
if (recId) {
overrideMap[recId] = {
dosage: override[dosageField] || '',
instructions: override[instructionsField] || ''
};
}
});
}
const recommendations = recIds.map(function(recId) {
if (!recId || typeof recId !== 'string') return null;
try {
const rec = getRef(recId);
if (!rec || !rec.name) return null;
const override = overrideMap[recId] || {};
return {
_id: recId,
name: rec.name || '',
short_description: rec.info || '',
long_description: extractText(rec.description) || '',
dosage: override.dosage || rec.standard_dosage || '',
overrideInstructions: override.instructions || '',
domain: rec.recommendation_domain || 'product'
};
} catch(e) {
console.error('Error processing recommendation:', recId, e);
return null;
}
}).filter(Boolean);
// Always add phase if it has headline or description, even without recommendations
if (phaseHeadline || phaseDesc) {
phases.push({
headline: phaseHeadline,
description: phaseDesc,
recommendations: recommendations
});
}
}
// Collect all unique recommendations from all phases
const allRecommendationsMap = new Map();
phases.forEach(function(phase) {
phase.recommendations.forEach(function(rec) {
if (rec._id && !allRecommendationsMap.has(rec._id)) {
allRecommendationsMap.set(rec._id, rec);
}
});
});
// Group recommendations by domain
const allRecommendations = Array.from(allRecommendationsMap.values());
const recommendationsByDomain = {
product: [],
dietary: [],
activity: []
};
allRecommendations.forEach(function(rec) {
const domain = rec.domain || 'product';
if (recommendationsByDomain[domain]) {
recommendationsByDomain[domain].push(rec);
} else {
recommendationsByDomain.product.push(rec); // Fallback
}
});
// Condition summaries
if (protocol.conditions && Array.isArray(protocol.conditions)) {
reportData.condition_summaries = protocol.conditions.map(function(condId) {
const cond = getRef(condId);
if (!cond) return null;
return {
name: cond.name || '',
description: extractText(cond.long_description || cond.description || '')
};
}).filter(Boolean);
}
// HTML generation helpers
function renderPill(label, value) {
if (!value) return '';
return `<span class="pill"><small>${escapeHtml(label)}</small> ${escapeHtml(value)}</span>`;
}
function renderPhaseRecommendationLink(rec) {
const recId = rec._id || rec.name.toLowerCase().replace(/\s+/g, '-');
const dosageHtml = rec.dosage ? `<div class="dose">${escapeHtml(rec.dosage)}</div>` : '';
const instructionsHtml = rec.overrideInstructions ? `<div style="font-size: 11px; color: #666; margin-top: 6px; font-style: italic; padding-left: 4px;">${escapeHtml(rec.overrideInstructions)}</div>` : '';
return `
<div style="margin-bottom: 8px;">
<div style="display:flex; justify-content:space-between; gap:10px; align-items:flex-start;">
<div style="flex:1;">
<a href="#rec-${recId}" class="rec-link">${escapeHtml(rec.name)}</a>
${instructionsHtml}
</div>
${dosageHtml}
</div>
</div>
`;
}
function renderFullRecommendation(rec) {
const recId = rec._id || rec.name.toLowerCase().replace(/\s+/g, '-');
return `
<div class="rec" id="rec-${recId}">
<div class="rec-top">
<div>
<div class="rec-name">${escapeHtml(rec.name)}</div>
${rec.short_description ? `<div class="muted" style="margin-top:6px">
${escapeHtml(rec.short_description)}
<button class="toggle" type="button" data-toggle="more" aria-expanded="false">+</button>
</div>` : ''}
</div>
${rec.dosage ? `<div class="dose">${escapeHtml(rec.dosage)}</div>` : ''}
</div>
<div class="more">
${rec.long_description ? `<div class="muted">${escapeHtml(rec.long_description)}</div>` : ''}
</div>
</div>
`;
}
function renderPhase(phase, i) {
if (!phase.headline && !phase.description && phase.recommendations.length === 0) {
return '';
}
var pmap = ["Phase 1: Stabilization",
"Phase 2: Foundations",
"Phase 3: Repair",
"Phase 4: Optimization",
"Phase 5: Integration & Maintenance"];
const phaseInstructions = protocol[`phase_${i + 1}_instructions`] || '';
const instructionsHtml = phaseInstructions ? preserveHtml(phaseInstructions) : '';
return `
<div class="phase">
<div class="head">
<div class="phase-label">${pmap[i]}
${phase.headline ? `<div><small><i>${escapeHtml(phase.headline)}</i></small></div>` : ''}
</div>
${phase.description ? `<div class="muted">${phase.description}</div>` : ''}
</div>
${phase.recommendations.length > 0 || instructionsHtml ? `
<div class="recs" style="display:grid; grid-template-columns: 1fr 1fr; gap: 16px; padding:12px;">
<div>
<h4 style="margin:0 0 8px; font-size:13px; color:var(--plum);">Recommendations</h4>
<div style="display:flex; flex-direction:column; gap:6px;">
${phase.recommendations.map(rec => renderPhaseRecommendationLink(rec)).join('')}
</div>
</div>
${instructionsHtml ? `
<div>
<h4 style="margin:0 0 8px; font-size:13px; color:var(--plum);">Instructions</h4>
<div class="muted">${instructionsHtml}</div>
</div>
` : '<div></div>'}
</div>
` : ''}
</div>
`;
}
// Generate HTML
const html = `
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Harmonious Wellness — Protocol Report</title>
<style>
:root{
--plum:#3a1430;
--gold:#c6a44d;
--olive:#6c7b3b;
--ink:#0f172a;
--muted:#475569;
--bg:#0b0810;
--paper:#ffffff;
--soft:#f8fafc;
--line:#e5e7eb;
--max: 980px;
--radius: 16px;
--font: ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial;
}
html,body{height:100%;}
body{margin:0; font-family:var(--font); background:var(--bg); color:var(--ink);}
.topbar{position:sticky; top:0; z-index:10; background:rgba(11,8,16,.92); border-bottom:1px solid rgba(255,255,255,.10);}
.topbar .inner{max-width:var(--max); margin:0 auto; padding:10px 14px; display:flex; align-items:center; justify-content:space-between; gap:10px; flex-wrap:wrap;}
.brand{display:flex; align-items:center; gap:10px; color:#f1f5f9;}
.mark{width:28px; height:28px; border-radius:10px; background:linear-gradient(135deg,var(--olive),var(--gold)); box-shadow:0 0 0 3px rgba(198,164,77,.15);}
.brand b{letter-spacing:.2px;}
.actions{display:flex; gap:8px; flex-wrap:wrap;}
button{border:1px solid rgba(255,255,255,.18); background:rgba(255,255,255,.06); color:#e2e8f0; padding:8px 10px; border-radius:999px; font-weight:700; cursor:pointer; transition:opacity 0.2s;}
button:hover{opacity:0.8;}
button.primary{border-color:rgba(198,164,77,.55); background:rgba(198,164,77,.18); color:#fff7e6;}
.wrap{max-width:var(--max); margin:18px auto 52px; padding:0 14px;}
.paper{background:var(--paper); border-radius:22px; overflow:hidden; border:1px solid rgba(255,255,255,.06);}
.hero{padding:18px; border-bottom:1px solid var(--line); background:linear-gradient(180deg,#fff,#fbfdff);}
.hero h1{margin:0; font-size:22px; line-height:1.2;}
.hero .sub{margin-top:6px; color:var(--muted); font-weight:700;}
.meta{display:flex; gap:10px; flex-wrap:wrap; margin-top:12px;}
.pill{display:inline-flex; gap:8px; align-items:center; padding:6px 10px; border:1px solid var(--line); background:var(--soft); border-radius:999px; font-weight:800; font-size:13px;}
.pill small{color:var(--muted); font-weight:800;}
.section{padding:16px 18px; border-bottom:1px solid var(--line);}
.section:last-child{border-bottom:none;}
.section h2{margin:0 0 10px; font-size:16px;}
.grid{display:grid; gap:10px;}
.two{grid-template-columns: repeat(2, minmax(0,1fr));}
@media (max-width:780px){.two{grid-template-columns:1fr;}}
.card{border:1px solid var(--line); border-radius:var(--radius); padding:12px; background:#fff;}
.card h3{margin:0 0 8px; font-size:13px; color:var(--plum);}
.muted{color:var(--muted); line-height:1.55;}
ul{margin:0; padding-left:18px; color:var(--muted); font-weight:650;}
li{margin:6px 0;}
.phase-label{font-weight:bold;}
.phase{border:1px solid var(--line); border-radius:18px; overflow:hidden; margin-bottom:12px;}
.phase .head{padding:12px; background:linear-gradient(180deg,#fff,#f9fbff); border-bottom:1px solid var(--line);}
.phase .head b{color:var(--plum);}
.phase .head p{margin:6px 0 0;}
.phase .recs{padding:12px; display:grid; gap:10px;}
.rec{border:1px solid var(--line); border-radius:14px; padding:10px;}
.rec-top{display:flex; justify-content:space-between; gap:10px; align-items:flex-start;}
.rec-name{font-weight:900;}
.rec-link{color:var(--plum); text-decoration:none; font-weight:600; padding:4px 0; display:block; transition:opacity 0.2s;}
.rec-link:hover{text-decoration:underline; opacity:0.8;}
.dose{font-size:12px; font-weight:900; color:var(--plum); background:rgba(58,20,48,.06); border:1px solid rgba(58,20,48,.12); padding:6px 10px; border-radius:999px; white-space:nowrap;}
.more{display:none; margin-top:8px; padding-top:8px; border-top:1px dashed rgba(15,23,42,.18);}
.toggle{margin-left:8px; border:1px solid rgba(15,23,42,.18); background:#fff; color:var(--ink); padding:3px 8px; border-radius:999px; font-weight:900; font-size:12px; cursor:pointer; transition:background 0.2s;}
.toggle:hover{background:#f0f0f0;}
body.pdf-mode .toggle{display:none;}
body.pdf-mode .more{display:block;}
@media print{
body{background:#fff;}
.topbar{display:none;}
.wrap{max-width:100%; margin:0; padding:0;}
.paper{border-radius:0;}
.more{display:block ;}
.toggle{display:none ;}
.phase{break-inside:avoid; page-break-inside:avoid;}
}
</style>
</head>
<body>
<div class="topbar">
<div class="inner">
<div class="brand">
<div class="mark" aria-hidden="true"></div>
<div>
<b>Harmonious Wellness</b>
<div style="font-size:12px; color:#cbd5e1; font-weight:700;">Protocol Report</div>
</div>
</div>
<div class="actions">
<button id="pdfBtn" aria-pressed="false">📄 PDF Mode</button>
<button class="primary" onclick="window.print()">🖨️ Print / Save</button>
</div>
</div>
</div>
<main class="wrap">
<article class="paper" id="report">
<section class="hero">
<h1>${escapeHtml(reportData.protocol_name)}</h1>
${reportData.protocol_headline ? `<div class="sub">${escapeHtml(reportData.protocol_headline)}</div>` : ''}
<div class="meta">
${reportData.name ? renderPill('Client', reportData.name) : ''}
${reportData.generated_on ? renderPill('Generated', reportData.generated_on) : ''}
${reportData.email ? renderPill('Email', reportData.email) : ''}
${reportData.phone_number ? renderPill('Phone', reportData.phone_number) : ''}
</div>
</section>
<section class="section">
<h2>Intentions and Goals</h2>
<div class="grid two">
<div class="card">
<h3>Conditions of focus</h3>
<div class="muted">${reportData.protocol_conditions.length > 0 ? escapeHtml(reportData.protocol_conditions.join(', ')) : 'None specified'}</div>
</div>
<div class="card">
<h3>Main Glands, Organs, or Systems Involved</h3>
<div class="muted">${reportData.client_symptoms || 'Not specified'}</div>
</div>
</div>
<div class="grid two" style="margin-top:10px">
<div class="card">
<h3>Top Client Concerns</h3>
${reportData.main_concerns.length > 0 ? `<ul>${reportData.main_concerns.map(c => `<li>${escapeHtml(c)}</li>`).join('')}</ul>` : '<div class="muted">Not specified</div>'}
</div>
<div class="card">
<h3>Other Key Notes from Consult</h3>
${reportData.top_outcomes.length > 0 ? `<ul>${reportData.top_outcomes.map(o => `<li>${escapeHtml(o)}</li>`).join('')}</ul>` : '<div class="muted">Not specified</div>'}
</div>
</div>
<div class="grid two" style="margin-top:10px">
<div class="card">
<h3>Kidney Filtration</h3>
<div class="muted">Urine pH: ${reportData.urine_ph || 'N/A'}</div>
<div class="muted">Status: ${reportData.urine_status || 'N/A'}</div>
</div>
<div class="card">
<h3>Analysis</h3>
${reportData.blood_pressure_analysis_headline ? `<div class="muted">${escapeHtml(reportData.blood_pressure_analysis_headline)}</div>` : ''}
${reportData.urine_analysis_headline ? `<div class="muted" style="margin-top:8px">${escapeHtml(reportData.urine_analysis_headline)}</div>` : ''}
</div>
</div>
</section>
<section class="section">
<h2>Protocol Overview</h2>
<div class="card">
<h2>${protocol.info}</h2>
${protocol.description}
</div>
</section>
${phases.length > 0 ? `
<section class="section">
<h2>Protocol Phases & Recommendations</h2>
${phases.map((phase, i) => renderPhase(phase, i)).join('')}
</section>
` : ''}
${allRecommendations.length > 0 ? `
<section class="section" id="recommendations">
<h2>Recommendations</h2>
${Object.keys(recommendationsByDomain).map(function(domain) {
const recs = recommendationsByDomain[domain];
if (recs.length === 0) return '';
const domainLabel = domain.charAt(0).toUpperCase() + domain.slice(1);
return `
<div style="margin-bottom:20px">
<h3 style="margin:0 0 12px; font-size:14px; color:var(--plum); text-transform:capitalize;">${domainLabel}</h3>
<div style="display:grid; gap:10px;">
${recs.map(renderFullRecommendation).join('')}
</div>
</div>
`;
}).filter(Boolean).join('')}
</section>
` : ''}
<section class="section">
<h2>Education & Resources</h2>
${reportData.condition_summaries.length > 0 ? `
<div class="card">
<h3>Key Conditions We're Supporting</h3>
${reportData.condition_summaries.map(c => `
<div style="margin-top:10px">
<div style="font-weight:900; color:var(--plum)">${escapeHtml(c.name)}</div>
${c.description ? `<div class="muted">${escapeHtml(c.description)}</div>` : ''}
</div>
`).join('')}
</div>
` : ''}
<div class="card" style="margin-top:10px">
<h3>Products Referenced in Protocol</h3>
<div class="muted">List and links for products referenced in this protocol will be emailed separately.</div>
</div>
</section>
<section class="section">
<h2>Closing Note</h2>
<div class="muted">
${reportData.name ? escapeHtml(reportData.name.split(' ')[0]) : 'Client'}, This is a ton of information and oftentimes it takes some digestion and processing. If you would like to book a follow up appointment or just hop on the phone to discuss any of this please let me know. I am here to support you! I also make tailor made tinctures and teas so feel free to let me know if that is of interest. Deep blessings on the next step in your health journey! I am available by email or text if you have questions along the way. Please never hesitate to reach out! I would love to at least connect back in 3 months to hear how things are going and recommend adjustments/modifications as needed. You can book your follow-up appointment online here. Select either a Short Follow up or Long Follow up. There is a small fee for these follow ups. However, accessibility is a value of mine and I will never turn anyone away for lack of funds. Please let me know and I will share my sliding scale range. Thank you ${reportData.name ? escapeHtml(reportData.name) : 'you'} for trusting and allowing me to support you. The work works. Keep the faith. Live inspired. Blessings on your wellness journey and enhanced vitality! ~ Kelly Shay
</div>
</section>
</article>
</main>
<script>
(function() {
// PDF Mode toggle
const pdfBtn = document.getElementById('pdfBtn');
if (pdfBtn) {
pdfBtn.addEventListener('click', function() {
const enabled = !document.body.classList.contains('pdf-mode');
document.body.classList.toggle('pdf-mode', enabled);
pdfBtn.setAttribute('aria-pressed', String(enabled));
});
}
// Expand/collapse recommendations (only in recommendations section)
document.addEventListener('click', function(e) {
const toggle = e.target.closest('[data-toggle="more"]');
if (!toggle || document.body.classList.contains('pdf-mode')) return;
const rec = toggle.closest('.rec');
if (!rec) return;
// Only expand if in recommendations section
if (!rec.closest('#recommendations')) return;
const more = rec.querySelector('.more');
if (!more) return;
const expanded = toggle.getAttribute('aria-expanded') === 'true';
toggle.setAttribute('aria-expanded', String(!expanded));
toggle.textContent = expanded ? '+' : '−';
more.style.display = expanded ? 'none' : 'block';
});
})();
</script>
</body>
</html>
`;
// Use renderHTMLFramework if available, otherwise return raw HTML
if (options && options.renderHTMLFramework) {
return options.renderHTMLFramework(data.REPORT, protocol, html);
}
return html;
};