Files
justvitamin/scripts/seed-promo-cards.ts
T
Omair Saleh 056c47581f feat: editorial review dashboard + elite-grade pilot batch (5 SKUs)
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>
2026-06-02 18:50:09 +08:00

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))