feat(editorial): curated scientific studies with real PMID citations

Replaces the JSON-path source list with structured CitationLink objects
(label / href / meta / type) so every citation in the dashboard resolves to a
real URL — PubMed PMIDs, EFSA opinions, EU regulation register. Adds a
hand-curated scientificStudyDraft per pilot SKU with study framing first and
EFSA verbatim wording quoted separately, matching the compliance pattern
Wild Nutrition, Ritual, Heights, and Nothing Fishy use.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Omair Saleh
2026-06-02 19:22:40 +08:00
parent 056c47581f
commit e3fd4dc806
9 changed files with 784 additions and 230 deletions
+226 -9
View File
@@ -110,16 +110,52 @@
</div>
</header>
<div class="section-body sci-body">
<p class="sci-prose">{{ pilot.scientificStudy.body_copy }}</p>
<!-- Study framing: actual journal-cited prose, not vague "research suggests" -->
<p
v-for="(para, i) in sciBodyParagraphs"
:key="`sci-p-${i}`"
class="sci-prose"
>{{ para }}</p>
<!-- Authorised health claim — verbatim, set apart visually so it
reads as a regulator quote, not marketing copy. -->
<aside v-if="pilot.scientificStudy.efsaClaim?.verbatim" class="efsa-quote">
<span class="efsa-eyebrow">Authorised health claim</span>
<blockquote>"{{ pilot.scientificStudy.efsaClaim.verbatim }}"</blockquote>
<p v-if="pilot.scientificStudy.efsaClaim.context" class="efsa-context">
{{ pilot.scientificStudy.efsaClaim.context }}
</p>
</aside>
<!-- Stat tiles — each linked to its real source URL -->
<div class="stat-strip">
<div v-for="s in pilot.scientificStudy.stats?.slice(0, 3)" :key="s.label" class="stat">
<component
v-for="s in pilot.scientificStudy.stats?.slice(0, 3)"
:key="s.label"
:is="s.source && /^https?:/.test(s.source) ? 'a' : 'div'"
:href="s.source && /^https?:/.test(s.source) ? s.source : null"
:target="s.source && /^https?:/.test(s.source) ? '_blank' : null"
rel="noopener"
class="stat"
:class="{ 'stat-clickable': s.source && /^https?:/.test(s.source) }"
>
<div class="stat-num">{{ s.value }}</div>
<div class="stat-label">{{ s.label }}</div>
</div>
<span v-if="s.source && /^https?:/.test(s.source)" class="stat-cite">view source ↗</span>
</component>
</div>
<!-- References — every citation a real URL, clickable. No file paths. -->
<div v-if="(pilot.scientificStudy.sourceLinks || []).length" class="references">
<span class="references-label">References</span>
<ul class="references-list">
<li v-for="(c, i) in pilot.scientificStudy.sourceLinks" :key="`sci-ref-${i}`">
<a v-if="c.href" :href="c.href" target="_blank" rel="noopener">{{ c.label }}</a>
<span v-else>{{ c.label }}</span>
<span v-if="c.meta" class="ref-meta">{{ c.meta }}</span>
</li>
</ul>
</div>
<a class="study-link" :href="pilot.scientificStudy.link_url" target="_blank" rel="noopener">
↗ {{ pilot.scientificStudy.link_label }}
</a>
</div>
</section>
@@ -260,9 +296,14 @@
<button class="cite-close" @click="citeOpen = false">close ×</button>
</header>
<ul class="cite-list">
<li v-for="(c, i) in citeSources" :key="i">
<li v-for="(c, i) in citeSources" :key="i" class="cite-row">
<span class="cite-num">{{ String(i + 1).padStart(2, '0') }}</span>
<span class="cite-mono">{{ c }}</span>
<div class="cite-body">
<a v-if="c.href" class="cite-link" :href="c.href" target="_blank" rel="noopener">{{ c.label }}</a>
<span v-else class="cite-text">{{ c.label }}</span>
<span v-if="c.meta" class="cite-meta">{{ c.meta }}</span>
<span v-if="c.type" class="cite-tag" :data-type="c.type">{{ c.type }}</span>
</div>
</li>
</ul>
</div>
@@ -301,6 +342,20 @@ const citeKey = ref(null)
const activeSku = computed(() => route.params.sku || pilots.value[0]?.sku || 'JV-D1000')
const activeSkinLabel = computed(() => SKINS.find(s => s.key === activeSkin.value)?.label || '')
// Split the curated Scientific Studies body into paragraphs on blank lines.
// The generator emits study framing → EFSA verbatim quote → context, separated
// by '\n\n'. We render each as a distinct <p> so the quote sets visually apart.
const sciBodyParagraphs = computed(() => {
const raw = (pilot.value?.scientificStudy?.body_copy || '').trim()
if (!raw) return []
// Strip the verbatim authorised-claim line — it gets its own block.
return raw
.split(/\n\s*\n/)
.map(p => p.trim())
.filter(p => p && !p.startsWith('Authorised health claim:'))
.filter(p => !p.startsWith('Authorised European Union nutrient claim'))
})
const truthBullets = computed(() => {
if (!truth.value) return []
const out = []
@@ -346,7 +401,28 @@ function catLabel(c) {
}
// Citation drawer
const citeSources = computed(() => (pilot.value && citeKey.value) ? (pilot.value[citeKey.value]?.sources || []) : [])
// Prefer the structured sourceLinks array (objects with label/href/meta/type)
// over the legacy sources string array. Show only readable citations, never
// raw file paths.
const citeSources = computed(() => {
if (!pilot.value || !citeKey.value) return []
const section = pilot.value[citeKey.value] || {}
const structured = Array.isArray(section.sourceLinks) ? section.sourceLinks : null
if (structured && structured.length) return structured
// Fallback: convert legacy string sources into citation-link shape.
return (section.sources || [])
.filter(s => typeof s === 'string')
.filter(s => !s.startsWith('data/') && !s.startsWith('(no ')) // hide internal paths
.map(s => {
// Heuristic: pull out a URL if present in the string.
const urlMatch = s.match(/https?:\/\/\S+/)
if (urlMatch) {
const label = s.replace(urlMatch[0], '').replace(/[—\-:\s]+$/, '').trim() || urlMatch[0]
return { type: 'other', label, href: urlMatch[0] }
}
return { type: 'other', label: s }
})
})
const citeTitle = computed(() => {
if (!citeKey.value) return ''
const map = { scientificStudy: 'Scientific Studies', timelineBlock: 'Results Timeline', comparisonTable: 'Comparison Table', faqBlock: 'FAQs' }
@@ -766,6 +842,102 @@ watch(() => route.params.sku, async (sku) => {
text-underline-offset: 4px;
}
/* EFSA verbatim quote — set apart visually so it reads as a regulator quote,
not marketing copy. Cream block, rule on the left, italic Fraunces. */
.efsa-quote {
margin: var(--s-6) 0;
padding: var(--s-5) var(--s-6);
background: var(--bg-cream-alt);
border-left: 2px solid var(--accent-sage);
max-width: var(--col-mid);
}
.efsa-eyebrow {
display: block;
font-family: var(--font-sans);
font-size: 10px;
letter-spacing: 0.12em;
text-transform: uppercase;
color: var(--accent-sage);
margin-bottom: var(--s-2);
font-weight: 600;
}
.efsa-quote blockquote {
font-family: var(--font-display);
font-style: italic;
font-variation-settings: 'opsz' 36, 'WONK' 1, 'SOFT' 30;
font-size: var(--t-xl);
line-height: 1.3;
color: var(--ink);
letter-spacing: -0.012em;
}
.efsa-context {
margin-top: var(--s-3);
font-size: var(--t-xs);
line-height: 1.55;
color: var(--ink-muted);
}
/* Clickable stat tile — when the source is a real URL, the whole tile links */
.stat-clickable {
text-decoration: none;
color: inherit;
position: relative;
transition: opacity var(--dur) var(--ease);
}
.stat-clickable:hover { opacity: 0.7; }
.stat-cite {
display: block;
margin-top: var(--s-2);
font-family: var(--font-sans);
font-size: 10px;
letter-spacing: 0.05em;
color: var(--accent-sage);
text-transform: uppercase;
}
/* References footer — real citations as clickable links, not file paths */
.references {
margin-top: var(--s-7);
padding-top: var(--s-5);
border-top: 1px solid var(--rule);
max-width: var(--col-mid);
}
.references-label {
font-family: var(--font-sans);
font-size: 10px;
letter-spacing: 0.12em;
text-transform: uppercase;
color: var(--ink-muted);
font-weight: 600;
}
.references-list {
list-style: none;
margin-top: var(--s-3);
display: flex;
flex-direction: column;
gap: var(--s-3);
}
.references-list li {
display: flex;
flex-direction: column;
gap: 2px;
font-size: var(--t-sm);
line-height: 1.45;
}
.references-list a {
color: var(--ink);
text-decoration: underline;
text-decoration-color: var(--accent-sage);
text-underline-offset: 3px;
}
.references-list a:hover { color: var(--accent-sage); }
.ref-meta {
font-family: var(--font-mono);
font-size: 11px;
color: var(--ink-muted);
letter-spacing: -0.01em;
}
/* Timeline */
.timeline-grid {
display: grid;
@@ -1002,6 +1174,51 @@ watch(() => route.params.sku, async (sku) => {
color: var(--ink-on-dark);
word-break: break-all;
}
.cite-row {
display: grid !important;
grid-template-columns: 36px 1fr !important;
}
.cite-body { display: flex; flex-direction: column; gap: 4px; }
.cite-link {
color: var(--ink-on-dark);
text-decoration: underline;
text-decoration-color: var(--accent-sage);
text-underline-offset: 3px;
font-family: var(--font-display);
font-size: var(--t-base);
font-variation-settings: 'opsz' 24, 'SOFT' 20;
letter-spacing: -0.005em;
}
.cite-link:hover { color: var(--accent-sage); }
.cite-text {
font-family: var(--font-display);
font-size: var(--t-base);
font-variation-settings: 'opsz' 24;
color: var(--ink-on-dark);
}
.cite-meta {
font-family: var(--font-mono);
font-size: 11px;
color: rgba(244, 239, 230, 0.55);
letter-spacing: -0.01em;
}
.cite-tag {
display: inline-block;
font-family: var(--font-sans);
font-size: 9px;
letter-spacing: 0.12em;
text-transform: uppercase;
font-weight: 600;
padding: 2px 6px;
border-radius: 2px;
margin-top: 4px;
align-self: flex-start;
background: rgba(244, 239, 230, 0.08);
color: rgba(244, 239, 230, 0.75);
}
.cite-tag[data-type="pubmed"] { background: rgba(92, 107, 74, 0.25); color: #B8C2B0; }
.cite-tag[data-type="efsa"],
.cite-tag[data-type="regulation"] { background: rgba(184, 115, 44, 0.18); color: #D6A47A; }
.slide-up-enter-active, .slide-up-leave-active { transition: transform var(--dur) var(--ease-out), opacity var(--dur) var(--ease); }
.slide-up-enter-from, .slide-up-leave-to { transform: translateY(100%); opacity: 0; }