#!/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 = { 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}`)