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>
124 lines
4.7 KiB
TypeScript
124 lines
4.7 KiB
TypeScript
#!/usr/bin/env bun
|
|
import { existsSync, readFileSync, writeFileSync } from 'fs'
|
|
import { join } from 'path'
|
|
|
|
const root = process.cwd()
|
|
const contentRoot = join(root, 'data', 'content')
|
|
const intelRoot = join(root, 'data', 'intelligence')
|
|
|
|
type Review = { comment?: string; stars?: number }
|
|
|
|
type CollectionConfig = {
|
|
collection: string
|
|
name: string
|
|
skus: string[]
|
|
keywords: string[]
|
|
buttonText: string
|
|
buttonLink: string
|
|
backgroundColour: string
|
|
}
|
|
|
|
const configs: CollectionConfig[] = [
|
|
{
|
|
collection: 'vitamin-d',
|
|
name: 'Vitamin D',
|
|
skus: ['JV-D4000', 'JV-D1000'],
|
|
keywords: ['vitamin d', 'sun', 'winter', 'blood', 'deficiency', 'doctor', 'gp', 'recommend'],
|
|
buttonText: 'Shop Vitamin D',
|
|
buttonLink: '/collections/vitamin-d',
|
|
backgroundColour: '#F5E8C8'
|
|
},
|
|
{
|
|
collection: 'joint-care',
|
|
name: 'Joint Care',
|
|
skus: ['JV-TURMERIC500'],
|
|
keywords: ['joint', 'arthritis', 'pain', 'mobility', 'knee', 'rheumatoid', 'turmeric'],
|
|
buttonText: 'Shop Joint Care',
|
|
buttonLink: '/collections/joint-care',
|
|
backgroundColour: '#E8D9B7'
|
|
}
|
|
]
|
|
|
|
function readJson(path: string, fallback: any = null) {
|
|
if (!existsSync(path)) return fallback
|
|
return JSON.parse(readFileSync(path, 'utf8').replace(/^\uFEFF/, ''))
|
|
}
|
|
function writeJson(path: string, data: any) {
|
|
writeFileSync(path, JSON.stringify(data, null, 2) + '\n', 'utf8')
|
|
}
|
|
function slug(value: string) {
|
|
return String(value || '').toLowerCase().replace(/&/g, 'and').replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '')
|
|
}
|
|
function cleanQuote(value: string) {
|
|
return String(value || '').replace(/\s+/g, ' ').trim()
|
|
}
|
|
function scoreReview(review: Review, cfg: CollectionConfig) {
|
|
const comment = cleanQuote(review.comment || '')
|
|
if (!comment || comment.length < 70 || comment.length > 260) return -999
|
|
const lower = comment.toLowerCase()
|
|
const keywordScore = cfg.keywords.reduce((score, keyword) => score + (lower.includes(keyword) ? 8 : 0), 0)
|
|
const starScore = Number(review.stars || 0) * 5
|
|
const usefulLength = Math.min(comment.length, 180) / 10
|
|
const vaguePenalty = /great product|good product|excellent product/i.test(comment) && comment.length < 90 ? 20 : 0
|
|
return keywordScore + starScore + usefulLength - vaguePenalty
|
|
}
|
|
function bestReview(cfg: CollectionConfig) {
|
|
const candidates = []
|
|
for (const sku of cfg.skus) {
|
|
const reviews: Review[] = readJson(join(intelRoot, sku, '_raw_reviews.json'), [])
|
|
for (let index = 0; index < reviews.length; index++) {
|
|
const review = reviews[index]
|
|
const score = scoreReview(review, cfg)
|
|
if (score > -999) candidates.push({ sku, index, score, review })
|
|
}
|
|
}
|
|
return candidates.sort((a, b) => b.score - a.score)[0] || null
|
|
}
|
|
|
|
const metaPath = join(contentRoot, 'metaobjects.json')
|
|
const collectionsPath = join(contentRoot, 'smart-collections.json')
|
|
const meta = readJson(metaPath, { definitions: {} })
|
|
const collections = readJson(collectionsPath, { collections: [] })
|
|
meta.definitions = meta.definitions || {}
|
|
meta.definitions.promo_card = Array.isArray(meta.definitions.promo_card) ? meta.definitions.promo_card : []
|
|
|
|
const created = []
|
|
for (const cfg of configs) {
|
|
const selected = bestReview(cfg)
|
|
if (!selected) {
|
|
created.push({ collection: cfg.collection, status: 'no_suitable_review_found' })
|
|
continue
|
|
}
|
|
const handle = `${slug(cfg.collection)}-feefo-review`
|
|
const title = `"${cleanQuote(selected.review.comment || '')}"`
|
|
const existingIndex = meta.definitions.promo_card.findIndex((row: any) => row.handle === handle)
|
|
const row = {
|
|
handle,
|
|
name: `${cfg.name} Feefo review`,
|
|
title,
|
|
subtitle: 'Verified Feefo reviewer',
|
|
button_text: cfg.buttonText,
|
|
button_link: cfg.buttonLink,
|
|
background_colour: cfg.backgroundColour,
|
|
background_image: '',
|
|
status: 'needs_review',
|
|
source: `${selected.sku}/_raw_reviews.json index ${selected.index}, ${selected.review.stars || ''} stars`
|
|
}
|
|
if (existingIndex >= 0) meta.definitions.promo_card[existingIndex] = { ...meta.definitions.promo_card[existingIndex], ...row }
|
|
else meta.definitions.promo_card.push(row)
|
|
|
|
const collection = (collections.collections || []).find((item: any) => item.handle === cfg.collection)
|
|
if (collection && !collection.promoCard) {
|
|
collection.promoCard = `promo_card.${handle}`
|
|
collection.status = 'needs_review'
|
|
}
|
|
created.push({ collection: cfg.collection, handle, sku: selected.sku, reviewIndex: selected.index, score: selected.score })
|
|
}
|
|
|
|
meta.generatedAt = new Date().toISOString()
|
|
collections.generatedAt = new Date().toISOString()
|
|
writeJson(metaPath, meta)
|
|
writeJson(collectionsPath, collections)
|
|
console.log(`Promo card candidates ready: ${created.filter(row => row.handle).length}`)
|
|
for (const row of created) console.log(JSON.stringify(row))
|