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>
242 lines
11 KiB
TypeScript
242 lines
11 KiB
TypeScript
import { mkdir, readFile, readdir, writeFile } from 'node:fs/promises'
|
|
import { join } from 'node:path'
|
|
|
|
const root = process.cwd()
|
|
const contentRoot = join(root, 'data', 'content')
|
|
const outRoot = join(root, 'content_population_exports')
|
|
const assetValidationPath = join(outRoot, 'asset_manifest_validation.json')
|
|
const clinicalBlankDecisionPath = join(contentRoot, 'compliance', 'clinical-blank-decisions.json')
|
|
|
|
const productColumns = [
|
|
'Handle',
|
|
'Metafield: filter.dietary_tag [list.metaobject_reference]',
|
|
'Metafield: filter.health_goals [list.metaobject_reference]',
|
|
'Metafield: pdp.key_ingredients [metaobject_reference]',
|
|
'Metafield: pdp.benefits [metaobject_reference]',
|
|
'Metafield: pdp.clinically_shown_to [metaobject_reference]',
|
|
'Metafield: breadcrumb.primary_collection [collection_reference]',
|
|
'Metafield: filter.strength [single_line_text_field]',
|
|
'Metafield: pdp.who_its_for [single_line_text_field]',
|
|
'Metafield: pdp.product_benefits [list.single_line_text_field]',
|
|
'Metafield: pdp.key_features [multi_line_text_field]',
|
|
'Metafield: pdp.results [file_reference]',
|
|
'Metafield: pdp.results_mobile [file_reference]',
|
|
'Metafield: pdp.how_we_compare [file_reference]',
|
|
'Metafield: faq.heading_one [single_line_text_field]',
|
|
'Metafield: faq.heading_two [single_line_text_field]',
|
|
'Metafield: faq.heading_three [single_line_text_field]',
|
|
'Metafield: faq.answer_one [multi_line_text_field]',
|
|
'Metafield: faq.answer_two [multi_line_text_field]',
|
|
'Metafield: faq.answer_three [multi_line_text_field]',
|
|
'Variant SKU',
|
|
'Variant Metafield: pdp.tag [single_line_text_field]',
|
|
'Variant Metafield: pdp.colour [color]',
|
|
'Variant Metafield: pdp.price_per [single_line_text_field]',
|
|
'Variant Metafield: pdp.strapline [single_line_text_field]'
|
|
]
|
|
|
|
const metaColumns = ['Handle', 'Display Name', 'Status', 'Definition: Handle', 'Definition: Name', 'Field', 'Value']
|
|
const metaDefinitionNames = {
|
|
dietary_tag: 'Dietary Tag',
|
|
health_goals: 'Health Goals',
|
|
key_ingredients: 'Key Ingredients',
|
|
benefits: 'Full Description - Benefits',
|
|
clinically_shown_to: 'Clinically Shown To...',
|
|
promo_card: 'Promo Card (Collections)'
|
|
}
|
|
|
|
function csvEscape(value) {
|
|
if (Array.isArray(value)) value = value.join(', ')
|
|
if (value === undefined || value === null) value = ''
|
|
value = String(value)
|
|
return /[",\n\r]/.test(value) ? `"${value.replace(/"/g, '""')}"` : value
|
|
}
|
|
function toCsv(rows) {
|
|
return rows.map(row => row.map(csvEscape).join(',')).join('\n') + '\n'
|
|
}
|
|
async function readJson(path) { return JSON.parse(await readFile(path, 'utf8')) }
|
|
async function readJsonFallback(path, fallback) {
|
|
try { return await readJson(path) } catch (_) { return fallback }
|
|
}
|
|
function asList(value) {
|
|
if (Array.isArray(value)) return value.map(v => String(v).trim()).filter(Boolean)
|
|
if (value === undefined || value === null || value === '') return []
|
|
if (typeof value === 'string') return value.split(',').map(v => v.trim()).filter(Boolean)
|
|
return [String(value)]
|
|
}
|
|
function countBy(rows, key) {
|
|
return rows.reduce((acc, row) => {
|
|
const value = row[key] || 'unknown'
|
|
acc[value] = (acc[value] || 0) + 1
|
|
return acc
|
|
}, {})
|
|
}
|
|
function uniqueSkus(rows) { return [...new Set(rows.map(row => row.sku).filter(Boolean))].sort() }
|
|
function isApprovedBlankClinicalDecision(decision) {
|
|
return decision?.decision === 'approved_blank' &&
|
|
Boolean(decision.approvedBy) &&
|
|
Boolean(decision.approvedAt) &&
|
|
Boolean(decision.rationale) &&
|
|
decision.constraintsAcknowledged === true
|
|
}
|
|
|
|
await mkdir(outRoot, { recursive: true })
|
|
const generatedAt = new Date().toISOString()
|
|
const assetValidation = await readJsonFallback(assetValidationPath, null)
|
|
const clinicalBlankDecisions = await readJsonFallback(clinicalBlankDecisionPath, { decisions: [] })
|
|
const clinicalBlankDecisionBySku = new Map((clinicalBlankDecisions.decisions || []).map(row => [row.sku, row]))
|
|
const productFiles = (await readdir(join(contentRoot, 'products'))).filter(f => f.endsWith('.json'))
|
|
const products = []
|
|
for (const file of productFiles) products.push(await readJson(join(contentRoot, 'products', file)))
|
|
products.sort((a, b) => a.sku.localeCompare(b.sku))
|
|
|
|
const productRows = [productColumns]
|
|
const blockers = []
|
|
for (const product of products) {
|
|
const m = product.metafields || {}
|
|
if (!m.pdpClinicallyShownTo && !isApprovedBlankClinicalDecision(clinicalBlankDecisionBySku.get(product.sku))) {
|
|
blockers.push({ sku: product.sku, type: 'compliance', blocker: 'Missing pdp.clinically_shown_to or approved blank compliance decision' })
|
|
}
|
|
for (const field of ['pdpResults', 'pdpResultsMobile', 'pdpHowWeCompare']) {
|
|
if (!m[field]) blockers.push({ sku: product.sku, type: 'asset', blocker: `Missing ${field}` })
|
|
}
|
|
if (!product.approval || product.approval.content !== 'approved') {
|
|
blockers.push({ sku: product.sku, type: 'approval', blocker: 'Content not approved' })
|
|
}
|
|
;(product.variants || [{ sku: product.sku }]).forEach((variant, index) => {
|
|
const first = index === 0
|
|
productRows.push([
|
|
product.handle,
|
|
first ? asList(m.filterDietaryTag).join(', ') : '',
|
|
first ? asList(m.filterHealthGoals).join(', ') : '',
|
|
first ? m.pdpKeyIngredients : '',
|
|
first ? m.pdpBenefits : '',
|
|
first ? m.pdpClinicallyShownTo : '',
|
|
first ? m.breadcrumbPrimaryCollection : '',
|
|
first ? m.filterStrength : '',
|
|
first ? m.pdpWhoItsFor : '',
|
|
first ? JSON.stringify(asList(m.pdpProductBenefits)) : '',
|
|
first ? m.pdpKeyFeatures : '',
|
|
first ? m.pdpResults : '',
|
|
first ? m.pdpResultsMobile : '',
|
|
first ? m.pdpHowWeCompare : '',
|
|
first ? m.faqHeadingOne : '',
|
|
first ? m.faqHeadingTwo : '',
|
|
first ? m.faqHeadingThree : '',
|
|
first ? m.faqAnswerOne : '',
|
|
first ? m.faqAnswerTwo : '',
|
|
first ? m.faqAnswerThree : '',
|
|
variant.sku,
|
|
variant.pdpTag,
|
|
variant.pdpColour,
|
|
variant.pdpPricePer,
|
|
variant.pdpStrapline
|
|
])
|
|
})
|
|
}
|
|
if (assetValidation?.missingReferences?.length) {
|
|
for (const item of assetValidation.missingReferences) {
|
|
blockers.push({ sku: item.Owner || item.owner || '', type: 'asset_reference', blocker: `${item.Field || item.field} filename missing` })
|
|
}
|
|
}
|
|
if (assetValidation?.required?.length) {
|
|
for (const item of assetValidation.required.filter((row) => (row.Exists || row.exists) === 'no' || row.exists === false)) {
|
|
blockers.push({ sku: item.Owner || item.owner || '', type: 'asset_upload', blocker: `${item.Field || item.field} file not found/uploaded: ${item.Filename || item.filename}` })
|
|
}
|
|
}
|
|
await writeFile(join(outRoot, 'products_content_matrixify.csv'), toCsv(productRows), 'utf8')
|
|
|
|
const meta = await readJson(join(contentRoot, 'metaobjects.json'))
|
|
const metaRows = [metaColumns]
|
|
const metaobjectCounts = {}
|
|
for (const [definition, rows] of Object.entries(meta.definitions || {})) {
|
|
metaobjectCounts[definition] = rows.length
|
|
for (const row of rows) {
|
|
const handle = row.handle || row.name_internal || row.name
|
|
const displayName = row.name || row.heading || row.name_internal || handle
|
|
const status = ['active', 'approved', 'ready'].includes(String(row.status || '').toLowerCase()) ? 'Active' : 'Draft'
|
|
const definitionName = metaDefinitionNames[definition] || definition
|
|
for (const [field, value] of Object.entries(row)) {
|
|
if (field === 'handle' || field === 'status' || field === 'source') continue
|
|
metaRows.push([handle, displayName, status, definition, definitionName, field, value])
|
|
}
|
|
}
|
|
}
|
|
await writeFile(join(outRoot, 'metaobjects_matrixify.csv'), toCsv(metaRows), 'utf8')
|
|
|
|
const collections = await readJson(join(contentRoot, 'smart-collections.json'))
|
|
const collectionRows = [['Handle', 'Metafield: custom.promo_card [metaobject_reference]'], ...(collections.collections || []).map(c => [c.handle, c.promoCard])]
|
|
await writeFile(join(outRoot, 'smart_collections_matrixify.csv'), toCsv(collectionRows), 'utf8')
|
|
await writeFile(join(outRoot, 'export_blockers.csv'), toCsv([['SKU', 'Type', 'Blocker'], ...blockers.map(b => [b.sku, b.type, b.blocker])]), 'utf8')
|
|
|
|
const blockersByType = countBy(blockers, 'type')
|
|
const blockedSkus = uniqueSkus(blockers)
|
|
const manifest = {
|
|
generatedAt,
|
|
readyForLewis: blockers.length === 0,
|
|
source: 'jv-dashboard/data/content',
|
|
outputs: {
|
|
products: 'products_content_matrixify.csv',
|
|
metaobjects: 'metaobjects_matrixify.csv',
|
|
smartCollections: 'smart_collections_matrixify.csv',
|
|
blockers: 'export_blockers.csv',
|
|
handoffManifest: 'handoff_manifest.json',
|
|
handoffSummary: 'handoff_summary.md'
|
|
},
|
|
counts: {
|
|
productRecords: products.length,
|
|
variantRows: productRows.length - 1,
|
|
metaobjectRows: metaRows.length - 1,
|
|
smartCollectionRows: collectionRows.length - 1,
|
|
blockers: blockers.length,
|
|
blockedSkus: blockedSkus.length
|
|
},
|
|
blockerSummary: blockersByType,
|
|
clinicalBlankDecisions: {
|
|
decisionFile: 'data/content/compliance/clinical-blank-decisions.json',
|
|
validBlankApprovals: [...clinicalBlankDecisionBySku.values()].filter(isApprovedBlankClinicalDecision).length,
|
|
totalDecisionRows: clinicalBlankDecisionBySku.size
|
|
},
|
|
metaobjectCounts,
|
|
blockedSkus,
|
|
nextActions: [
|
|
blockersByType.compliance ? 'Add source-backed clinical claims and map pdp.clinically_shown_to metaobjects, or record approved_blank compliance decisions for SKUs that should intentionally omit the module.' : null,
|
|
blockersByType.asset ? 'Assign/produce PDP result, mobile result, and comparison filenames, then upload to Shopify Files before import.' : null,
|
|
blockersByType.approval ? 'Complete dashboard human review and mark content approved before handoff.' : null,
|
|
'Run migration folder Matrixify emitters and staging import spot-checks with Lewis/Euan.'
|
|
].filter(Boolean)
|
|
}
|
|
await writeFile(join(outRoot, 'handoff_manifest.json'), JSON.stringify(manifest, null, 2) + '\n', 'utf8')
|
|
|
|
const summary = [
|
|
'# JV Shopify Matrixify handoff summary',
|
|
'',
|
|
`Generated: ${generatedAt}`,
|
|
'',
|
|
`Ready for Lewis/Euan: **${manifest.readyForLewis ? 'YES' : 'NO'}**`,
|
|
'',
|
|
'## Output files',
|
|
...Object.values(manifest.outputs).map(file => `- ${file}`),
|
|
'',
|
|
'## Counts',
|
|
`- Product records: ${manifest.counts.productRecords}`,
|
|
`- Product/variant CSV rows: ${manifest.counts.variantRows}`,
|
|
`- Metaobject rows: ${manifest.counts.metaobjectRows}`,
|
|
`- Smart collection rows: ${manifest.counts.smartCollectionRows}`,
|
|
`- Blockers: ${manifest.counts.blockers}`,
|
|
`- Blocked SKUs: ${manifest.counts.blockedSkus}`,
|
|
'',
|
|
'## Blockers by type',
|
|
...Object.entries(blockersByType).map(([type, count]) => `- ${type}: ${count}`),
|
|
...(Object.keys(blockersByType).length ? [] : ['- None']),
|
|
'',
|
|
'## Next actions',
|
|
...manifest.nextActions.map(action => `- ${action}`),
|
|
''
|
|
].join('\n')
|
|
await writeFile(join(outRoot, 'handoff_summary.md'), summary, 'utf8')
|
|
|
|
console.log(`Wrote ${outRoot}`)
|
|
console.log(`${products.length} product records, ${blockers.length} blockers`)
|
|
console.log(`Handoff ready: ${manifest.readyForLewis ? 'yes' : 'no'}`)
|