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:
@@ -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; }
|
||||
|
||||
Reference in New Issue
Block a user