056c47581f
Ships the second dashboard surface — a Pattern Library + Preview Theatre — that presents the 4-section PDP pilot batch back to Umar, compliance, and the board in an editorial format. Adds the full data layer that drives it: 5 source-backed per-SKU drafts at QA 100/100, 15 competitor PDP semantic extracts, PubMed evidence packs, EFSA claims library extension, JV brand voice guide, hand-curated product FAQs, and the Matrixify-ready CSV exports for Lewis. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
163 lines
11 KiB
TypeScript
163 lines
11 KiB
TypeScript
#!/usr/bin/env bun
|
|
import { existsSync, readdirSync } from 'fs'
|
|
import { join } from 'path'
|
|
import { INTEL_ROOT, ROOT, intelPath, now, readJson, slug, writeJson } from './lib/intel-utils'
|
|
|
|
const PRODUCT_FIELDS = [
|
|
'filterDietaryTag', 'filterHealthGoals', 'pdpKeyIngredients', 'pdpBenefits', 'pdpClinicallyShownTo',
|
|
'breadcrumbPrimaryCollection', 'filterStrength', 'pdpWhoItsFor', 'pdpProductBenefits', 'pdpKeyFeatures',
|
|
'pdpResults', 'pdpResultsMobile', 'pdpHowWeCompare', 'faqHeadingOne', 'faqHeadingTwo', 'faqHeadingThree',
|
|
'faqAnswerOne', 'faqAnswerTwo', 'faqAnswerThree'
|
|
]
|
|
const CONTENT_PRODUCTS_ROOT = join(ROOT, 'data', 'content', 'products')
|
|
const WORKFLOW_ROOT = join(ROOT, 'data', 'content', 'workflow')
|
|
const force = process.argv.includes('--force')
|
|
|
|
function imageRecord(images: any, sku: string) {
|
|
return images[sku] || images[sku.replace(/^JV-/, 'JV-').replace(/\d+$/, '')] || {}
|
|
}
|
|
function handleFrom(product: any, img: any) {
|
|
const fromUrl = String(img.url || '').split('/').filter(Boolean).pop() || ''
|
|
return slug(fromUrl || img.name || product.name || product.sku)
|
|
}
|
|
function hasIntel(sku: string) { return existsSync(intelPath(sku, 'review-aspects.json')) }
|
|
function readIntel(sku: string, file: string, fallback: any = {}) { return readJson(intelPath(sku, file), fallback) }
|
|
function topStrengths(sku: string) {
|
|
const sw = readIntel(sku, 'strengths-weaknesses.json', {})
|
|
return (sw.items || []).filter((i: any) => /strength/i.test(i.classification || '')).slice(0, 4)
|
|
}
|
|
function productMetafields(product: any, sku: string, intelligent: boolean) {
|
|
if (!intelligent) {
|
|
return {
|
|
filterDietaryTag: [], filterHealthGoals: [], pdpKeyIngredients: '', pdpBenefits: '', pdpClinicallyShownTo: '',
|
|
breadcrumbPrimaryCollection: product.category ? slug(product.category) : '', filterStrength: '', pdpWhoItsFor: '',
|
|
pdpProductBenefits: [], pdpKeyFeatures: '', pdpResults: '', pdpResultsMobile: '', pdpHowWeCompare: '',
|
|
faqHeadingOne: '', faqHeadingTwo: '', faqHeadingThree: '', faqAnswerOne: '', faqAnswerTwo: '', faqAnswerThree: ''
|
|
}
|
|
}
|
|
const aspects = readIntel(sku, 'review-aspects.json', {})
|
|
const blockers = readIntel(sku, 'conversion-blockers.json', {})
|
|
const improvements = readIntel(sku, 'improvements.json', {})
|
|
const strengths = topStrengths(sku)
|
|
const isD = sku.includes('D') && !sku.includes('TURMERIC')
|
|
const isTurmeric = sku.includes('TURMERIC')
|
|
const ingredient = isTurmeric ? 'turmeric-root-extract' : isD ? 'vitamin-d3' : ''
|
|
const topAspects = (aspects.aspects || []).slice(0, 4).map((a: any) => a.aspect)
|
|
const topImprovements = (improvements.improvements || []).slice(0, 3).map((i: any) => i.improvement)
|
|
return {
|
|
filterDietaryTag: isTurmeric ? ['dietary_tag.vegan', 'dietary_tag.vegetarian'] : ['dietary_tag.vegetarian'],
|
|
filterHealthGoals: isTurmeric ? ['health_goals.joint-mobility'] : isD ? ['health_goals.bone-immune-support'] : [],
|
|
pdpKeyIngredients: ingredient ? `key_ingredients.${ingredient}` : '',
|
|
pdpBenefits: strengths[0] ? `benefits.${slug(strengths[0].insight)}` : '',
|
|
pdpClinicallyShownTo: '',
|
|
breadcrumbPrimaryCollection: isTurmeric ? 'joint-care' : isD ? 'vitamin-d' : (product.category ? slug(product.category) : ''),
|
|
filterStrength: sku === 'JV-D4000' ? 'Extra Strength' : sku === 'JV-D1000' ? 'Daily Strength' : isTurmeric ? 'High Strength' : '',
|
|
pdpWhoItsFor: isTurmeric ? 'People looking for a simple daily turmeric supplement to support an active joint-care routine.' : isD ? 'People who want a simple daily Vitamin D supplement, especially during darker months or low-sun routines.' : '',
|
|
pdpProductBenefits: strengths.map((i: any) => i.insight).slice(0, 4),
|
|
pdpKeyFeatures: [...topAspects, ...topImprovements].filter(Boolean).slice(0, 5).join('\n'),
|
|
pdpResults: '', pdpResultsMobile: '', pdpHowWeCompare: '',
|
|
faqHeadingOne: 'How do I take it?',
|
|
faqHeadingTwo: 'Who is it best for?',
|
|
faqHeadingThree: 'What should we improve on the PDP?',
|
|
faqAnswerOne: 'Follow the directions on the pack. Keep the answer reviewed against the final Shopify PDP and label text before import.',
|
|
faqAnswerTwo: isTurmeric ? 'Customers using turmeric as part of a daily joint and mobility routine.' : isD ? 'Customers who want a simple Vitamin D supplement for everyday wellbeing support.' : '',
|
|
faqAnswerThree: blockers.blockers?.[0]?.fix || ''
|
|
}
|
|
}
|
|
function approvalFor(intelligent: boolean) {
|
|
return intelligent
|
|
? { content: 'draft', compliance: 'blocked', images: 'needed', export: 'not_ready' }
|
|
: { content: 'needs_intelligence', compliance: 'blocked', images: 'needed', export: 'not_ready' }
|
|
}
|
|
function sourceTrail(sku: string, intelligent: boolean) {
|
|
return intelligent ? {
|
|
reviewAspects: `${sku}/review-aspects.json`, strengthsWeaknesses: `${sku}/strengths-weaknesses.json`,
|
|
conversionBlockers: `${sku}/conversion-blockers.json`, improvements: `${sku}/improvements.json`, photoBrief: `${sku}/photo-brief.json`
|
|
} : { catalog: 'data/pipeline/catalog.json', productImages: 'data/pipeline/product-images.json', intelligence: 'pending' }
|
|
}
|
|
function fieldStatus(field: string, product: any, intelligent: boolean) {
|
|
const value = product.metafields?.[field]
|
|
if (!intelligent) return 'pending_intelligence'
|
|
if (field === 'pdpClinicallyShownTo') return 'blocked_compliance'
|
|
if (['pdpResults', 'pdpResultsMobile', 'pdpHowWeCompare'].includes(field)) return value ? 'needs_review' : 'blocked_asset'
|
|
return value && (!Array.isArray(value) || value.length) ? 'draft' : 'missing'
|
|
}
|
|
function fieldCitations(sku: string, field: string, intelligent: boolean) {
|
|
if (!intelligent) return [{ source: 'catalog placeholder', path: 'data/pipeline/catalog.json', note: 'Content is not generated until review/intelligence inputs exist.' }]
|
|
const map: Record<string, string[]> = {
|
|
filterDietaryTag: ['catalog taxonomy seed'], filterHealthGoals: ['review-aspects.json', 'strengths-weaknesses.json'],
|
|
pdpKeyIngredients: ['existing PDP ingredient panel seed'], pdpBenefits: ['strengths-weaknesses.json'], pdpClinicallyShownTo: ['claims-evidence.json'],
|
|
breadcrumbPrimaryCollection: ['catalog collection mapping seed'], filterStrength: ['catalog strength seed'], pdpWhoItsFor: ['audience-profile.json', 'review-aspects.json'],
|
|
pdpProductBenefits: ['strengths-weaknesses.json', 'review-aspects.json'], pdpKeyFeatures: ['review-aspects.json', 'improvements.json'],
|
|
pdpResults: ['photo-brief.json', 'Shopify Files asset manifest'], pdpResultsMobile: ['photo-brief.json', 'Shopify Files asset manifest'],
|
|
pdpHowWeCompare: ['competitor-comparison.json', 'Shopify Files asset manifest'], faqHeadingOne: ['content template seed'], faqHeadingTwo: ['audience-profile.json'],
|
|
faqHeadingThree: ['conversion-blockers.json'], faqAnswerOne: ['content template seed'], faqAnswerTwo: ['audience-profile.json'], faqAnswerThree: ['conversion-blockers.json', 'photo-brief.json']
|
|
}
|
|
return (map[field] || []).map(source => ({ source, path: source.endsWith('.json') ? `${sku}/${source}` : source, note: field === 'pdpClinicallyShownTo' ? 'Compliance-gated. Do not approve without source-backed clinical evidence.' : 'Seed/source used for draft content.' }))
|
|
}
|
|
function workflowFor(product: any, existing: any, intelligent: boolean) {
|
|
const fields = Object.fromEntries(PRODUCT_FIELDS.map(field => [field, {
|
|
label: field.replace(/([A-Z])/g, ' $1').replace(/^./, c => c.toUpperCase()),
|
|
status: fieldStatus(field, product, intelligent),
|
|
citations: fieldCitations(product.sku, field, intelligent),
|
|
lastReviewedAt: existing?.fields?.[field]?.lastReviewedAt || null,
|
|
reviewer: existing?.fields?.[field]?.reviewer || null,
|
|
decision: existing?.fields?.[field]?.decision || null
|
|
}]))
|
|
return {
|
|
sku: product.sku,
|
|
generatedAt: existing?.generatedAt || now(),
|
|
updatedAt: now(),
|
|
stage: intelligent ? (existing?.stage || 'content_draft') : 'pending_intelligence',
|
|
owner: 'JV content workspace',
|
|
approval: product.approval,
|
|
fields,
|
|
events: existing?.events?.length ? existing.events : [{ at: now(), type: intelligent ? 'seeded' : 'catalog_placeholder_seeded', actor: 'system', summary: intelligent ? 'Workflow status seeded from current product content and intelligence files.' : 'Catalogue placeholder seeded; content generation waits for intelligence inputs.' }]
|
|
}
|
|
}
|
|
|
|
const catalog = readJson(join(ROOT, 'data', 'pipeline', 'catalog.json'), [])
|
|
const images = readJson(join(ROOT, 'data', 'pipeline', 'product-images.json'), {})
|
|
let created = 0, updated = 0, skipped = 0
|
|
for (const item of catalog) {
|
|
const sku = item.sku
|
|
const productFile = join(CONTENT_PRODUCTS_ROOT, `${sku}.json`)
|
|
const intelligent = hasIntel(sku)
|
|
const existing = readJson(productFile, null)
|
|
if (existing && !force) { skipped++; continue }
|
|
const img = imageRecord(images, sku)
|
|
const product = existing && force ? existing : {
|
|
sku,
|
|
handle: handleFrom(item, img),
|
|
name: img.name || item.name || sku,
|
|
status: intelligent ? 'draft' : 'pending_intelligence',
|
|
approval: approvalFor(intelligent),
|
|
metafields: productMetafields(item, sku, intelligent),
|
|
variants: (item.variants || [sku]).map((variant: string, idx: number) => ({ sku: variant, pdpTag: '', pdpColour: idx === 0 ? '#1f4f8f' : '#f59e0b', pdpPricePer: '', pdpStrapline: '' })),
|
|
sources: sourceTrail(sku, intelligent),
|
|
notes: intelligent ? ['Seeded from existing Feefo intelligence only.', 'Clinical claims and image file references remain blocked until sourced/produced.'] : ['Catalogue placeholder only. Intelligence/content generation has not run for this SKU.']
|
|
}
|
|
writeJson(productFile, product)
|
|
const workflowFile = join(WORKFLOW_ROOT, `${sku}.json`)
|
|
const existingWorkflow = readJson(workflowFile, null)
|
|
writeJson(workflowFile, workflowFor(product, existingWorkflow, intelligent))
|
|
existing ? updated++ : created++
|
|
}
|
|
// Always refresh workflow for existing content records and index, even when product files skipped.
|
|
const files = readdirSync(CONTENT_PRODUCTS_ROOT).filter(f => f.endsWith('.json'))
|
|
const index = { generatedAt: now(), products: [] as any[] }
|
|
for (const file of files) {
|
|
const product = readJson(join(CONTENT_PRODUCTS_ROOT, file), null)
|
|
if (!product) continue
|
|
const intelligent = hasIntel(product.sku)
|
|
const workflowFile = join(WORKFLOW_ROOT, `${product.sku}.json`)
|
|
const workflow = workflowFor(product, readJson(workflowFile, null), intelligent)
|
|
writeJson(workflowFile, workflow)
|
|
index.products.push({ sku: product.sku, stage: workflow.stage, approval: workflow.approval, blockedFields: Object.values(workflow.fields).filter((f: any) => String(f.status).startsWith('blocked')).length })
|
|
}
|
|
index.products.sort((a, b) => a.sku.localeCompare(b.sku))
|
|
writeJson(join(WORKFLOW_ROOT, 'index.json'), index)
|
|
console.log(`Catalog products: ${catalog.length}`)
|
|
console.log(`Created: ${created}; updated: ${updated}; skipped existing: ${skipped}`)
|
|
console.log(`Workflow indexed: ${index.products.length}`)
|