136 lines
5.3 KiB
TypeScript
136 lines
5.3 KiB
TypeScript
import { join } from 'path'
|
|
|
|
const PORT = 3456
|
|
const PIPELINE_ROOT = process.env.PIPELINE_ROOT ||
|
|
join(import.meta.dir, '..', 'data', 'pipeline')
|
|
const INTEL_ROOT = join(import.meta.dir, '..', 'data', 'intelligence')
|
|
const DIST_ROOT = join(import.meta.dir, '..', 'dist')
|
|
const IS_PROD = process.env.NODE_ENV === 'production'
|
|
|
|
const cors = {
|
|
'Access-Control-Allow-Origin': '*',
|
|
'Access-Control-Allow-Methods': 'GET, OPTIONS',
|
|
'Access-Control-Allow-Headers': 'Content-Type',
|
|
'Content-Type': 'application/json'
|
|
}
|
|
|
|
async function readJson(path: string) {
|
|
const f = Bun.file(path)
|
|
if (!(await f.exists())) return null
|
|
return f.json()
|
|
}
|
|
|
|
async function getProducts() {
|
|
const catalog = await readJson(join(PIPELINE_ROOT, 'catalog.json')) as any[]
|
|
const images = await readJson(join(PIPELINE_ROOT, 'product-images.json')) as Record<string, any>
|
|
|
|
return catalog.map((p: any) => {
|
|
const img = images?.[p.sku] || {}
|
|
const intelPath = join(INTEL_ROOT, p.sku, 'review-aspects.json')
|
|
return {
|
|
sku: p.sku,
|
|
name: img.name || p.sku,
|
|
totalReviews: p.total_reviews || 0,
|
|
variants: p.variants || [],
|
|
imageUrl: img.image_url || null,
|
|
productUrl: img.url ? `https://www.justvitamins.co.uk${img.url}` : null,
|
|
hasIntelligence: Bun.file(intelPath).exists()
|
|
}
|
|
}).sort((a: any, b: any) => b.totalReviews - a.totalReviews)
|
|
}
|
|
|
|
async function serveStatic(pathname: string): Promise<Response | null> {
|
|
if (!IS_PROD) return null
|
|
// Strip query string, decode
|
|
const clean = decodeURIComponent(pathname.split('?')[0])
|
|
const filePath = join(DIST_ROOT, clean === '/' ? 'index.html' : clean)
|
|
const f = Bun.file(filePath)
|
|
if (await f.exists()) return new Response(f)
|
|
// SPA fallback — serve index.html for all non-file routes
|
|
const index = Bun.file(join(DIST_ROOT, 'index.html'))
|
|
if (await index.exists()) return new Response(index, { headers: { 'Content-Type': 'text/html' } })
|
|
return null
|
|
}
|
|
|
|
// In-memory cache for scraped image URLs per SKU
|
|
const imageCache: Record<string, string[]> = {}
|
|
|
|
async function scrapeProductImages(sku: string): Promise<string[]> {
|
|
if (imageCache[sku]) return imageCache[sku]
|
|
const images = await readJson(join(PIPELINE_ROOT, 'product-images.json')) as Record<string, any>
|
|
const productPath = images?.[sku]?.url
|
|
if (!productPath) return []
|
|
try {
|
|
const res = await fetch(`https://www.justvitamins.co.uk${productPath}`, {
|
|
headers: { 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36' }
|
|
})
|
|
const html = await res.text()
|
|
// Extract filenames like {uuid}-{n}.jpg from anywhere in the page
|
|
const matches = [...html.matchAll(/([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}-\d+)\.jpg/g)]
|
|
const filenames = [...new Set(matches.map(m => m[1]))]
|
|
const urls = filenames.map(f => `https://images.justvitamins.co.uk/product-images/Normal/${f}.jpg`)
|
|
imageCache[sku] = urls
|
|
return urls
|
|
} catch { return [] }
|
|
}
|
|
|
|
const server = Bun.serve({
|
|
port: PORT,
|
|
async fetch(req) {
|
|
const url = new URL(req.url)
|
|
const path = url.pathname
|
|
|
|
if (req.method === 'OPTIONS') return new Response(null, { headers: cors })
|
|
|
|
// API routes
|
|
if (path.startsWith('/api/')) {
|
|
// GET /api/products
|
|
if (path === '/api/products') {
|
|
const products = await getProducts()
|
|
const resolved = await Promise.all(
|
|
products.map(async (p: any) => ({ ...p, hasIntelligence: await p.hasIntelligence }))
|
|
)
|
|
return new Response(JSON.stringify(resolved), { headers: cors })
|
|
}
|
|
|
|
// GET /api/product-images/:sku
|
|
const imgMatch = path.match(/^\/api\/product-images\/([^/]+)$/)
|
|
if (imgMatch) {
|
|
const urls = await scrapeProductImages(imgMatch[1])
|
|
return new Response(JSON.stringify({ urls }), { headers: cors })
|
|
}
|
|
|
|
// GET /api/intelligence/:sku/:view
|
|
const intelMatch = path.match(/^\/api\/intelligence\/([^/]+)\/([^/]+)$/)
|
|
if (intelMatch) {
|
|
const [, sku, view] = intelMatch
|
|
if (view === 'all') {
|
|
const views = ['review-aspects', 'conversion-driver', 'strengths-weaknesses', 'conversion-blockers', 'improvements', 'image-audit', 'photo-brief']
|
|
const result: Record<string, any> = {}
|
|
for (const v of views) {
|
|
const data = await readJson(join(INTEL_ROOT, sku, `${v}.json`))
|
|
if (data) result[v.replace(/-([a-z])/g, (_: string, c: string) => c.toUpperCase())] = data
|
|
}
|
|
return Object.keys(result).length
|
|
? new Response(JSON.stringify(result), { headers: cors })
|
|
: new Response(JSON.stringify({ error: 'No intelligence data' }), { status: 404, headers: cors })
|
|
}
|
|
const data = await readJson(join(INTEL_ROOT, sku, `${view}.json`))
|
|
return data
|
|
? new Response(JSON.stringify(data), { headers: cors })
|
|
: new Response(JSON.stringify({ error: 'Not found' }), { status: 404, headers: cors })
|
|
}
|
|
|
|
return new Response(JSON.stringify({ error: 'Not found' }), { status: 404, headers: cors })
|
|
}
|
|
|
|
// Static file serving in production
|
|
const static_ = await serveStatic(path)
|
|
if (static_) return static_
|
|
|
|
return new Response('Not found', { status: 404 })
|
|
}
|
|
})
|
|
|
|
console.log(`JV Dashboard running on http://localhost:${PORT} (${IS_PROD ? 'production' : 'dev'})`)
|