feat: side-by-side competitor comparison + fix 1.6T speed_gbps
- Fix OSFP-DR8-1.6T-FL and OSFP-2FR4-1.6T-FL: speed_gbps was 200, now 1600 → FS.com 1.6T products now correctly match as comparables for Flexoptix O.1316T.C.05.M - API: extend comparable price query to return comp_form_factor, comp_speed_gbps, comp_reach_meters, comp_reach_label, comp_fiber_type, comp_wavelengths - Dashboard: replace plain comparable price row with side-by-side spec comparison card showing Flexoptix vs. competitor: Form Factor, Speed, Reach, Fiber, Wavelengths with color coding (green=match, orange=mismatch) and savings badge (−45% günstiger)
This commit is contained in:
parent
62d97a783c
commit
446ac667b0
@ -77,7 +77,10 @@ transceiverRouter.get("/:id", async (req: Request, res: Response) => {
|
|||||||
`SELECT DISTINCT ON (po.source_vendor_id)
|
`SELECT DISTINCT ON (po.source_vendor_id)
|
||||||
po.price, po.currency, po.url, po.time,
|
po.price, po.currency, po.url, po.time,
|
||||||
sv.name AS vendor_name, sv.type AS vendor_type,
|
sv.name AS vendor_name, sv.type AS vendor_type,
|
||||||
t2.part_number, t2.standard_name, t2.id AS comparable_id
|
t2.part_number, t2.standard_name, t2.id AS comparable_id,
|
||||||
|
t2.form_factor AS comp_form_factor, t2.speed_gbps AS comp_speed_gbps,
|
||||||
|
t2.reach_meters AS comp_reach_meters, t2.reach_label AS comp_reach_label,
|
||||||
|
t2.fiber_type AS comp_fiber_type, t2.wavelengths AS comp_wavelengths
|
||||||
FROM transceivers t1
|
FROM transceivers t1
|
||||||
JOIN transceivers t2 ON (
|
JOIN transceivers t2 ON (
|
||||||
t2.form_factor = t1.form_factor
|
t2.form_factor = t1.form_factor
|
||||||
@ -117,6 +120,13 @@ transceiverRouter.get("/:id", async (req: Request, res: Response) => {
|
|||||||
is_same_product: false, // different SKU, same spec class
|
is_same_product: false, // different SKU, same spec class
|
||||||
comparable_part: row.part_number || row.standard_name,
|
comparable_part: row.part_number || row.standard_name,
|
||||||
comparable_id: row.comparable_id,
|
comparable_id: row.comparable_id,
|
||||||
|
// Spec details for side-by-side comparison in dashboard
|
||||||
|
comp_form_factor: row.comp_form_factor,
|
||||||
|
comp_speed_gbps: row.comp_speed_gbps ? parseFloat(row.comp_speed_gbps) : null,
|
||||||
|
comp_reach_meters: row.comp_reach_meters,
|
||||||
|
comp_reach_label: row.comp_reach_label,
|
||||||
|
comp_fiber_type: row.comp_fiber_type,
|
||||||
|
comp_wavelengths: row.comp_wavelengths,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const allPrices = [...prices, ...comparablePrices];
|
const allPrices = [...prices, ...comparablePrices];
|
||||||
|
|||||||
@ -3499,12 +3499,91 @@ async function openTxDetail(id) {
|
|||||||
|
|
||||||
directPrices.forEach(function(p) { h += renderPriceRow(p); });
|
directPrices.forEach(function(p) { h += renderPriceRow(p); });
|
||||||
|
|
||||||
if (comparPrices.length > 0) {
|
|
||||||
h += '<div style="font-size:0.7rem;color:#888;margin:0.5rem 0 0.25rem;padding-top:0.4rem;border-top:1px solid var(--border)">Vergleichbare Produkte anderer Hersteller (gleiche Spezifikation)</div>';
|
|
||||||
comparPrices.forEach(function(p) { h += renderPriceRow(p); });
|
|
||||||
}
|
|
||||||
|
|
||||||
h += '</div>';
|
h += '</div>';
|
||||||
|
|
||||||
|
// Comparable products → Side-by-Side spec comparison cards
|
||||||
|
if (comparPrices.length > 0) {
|
||||||
|
h += '<div class="panel-section" style="margin-top:0.8rem">Vergleichbare Wettbewerber-Produkte</div>';
|
||||||
|
h += '<div style="font-size:0.72rem;color:#888;margin-bottom:0.5rem">Gleiche Spezifikationsklasse — andere Part Number</div>';
|
||||||
|
comparPrices.forEach(function(p) {
|
||||||
|
// Calculate price delta (EUR-normalized)
|
||||||
|
var myEur = null;
|
||||||
|
var refPrice = directPrices.length > 0 ? directPrices[0] : null;
|
||||||
|
if (refPrice) {
|
||||||
|
var ra = parseFloat(refPrice.price), rc = (refPrice.currency||'USD').toUpperCase();
|
||||||
|
myEur = rc === 'EUR' ? ra : rc === 'USD' ? ra * 0.92 : ra;
|
||||||
|
}
|
||||||
|
var compEur = null;
|
||||||
|
var ca = parseFloat(p.price), cc = (p.currency||'USD').toUpperCase();
|
||||||
|
compEur = cc === 'EUR' ? ca : cc === 'USD' ? ca * 0.92 : ca;
|
||||||
|
|
||||||
|
var savBadge = '';
|
||||||
|
if (myEur && compEur && myEur > 0 && compEur > 0) {
|
||||||
|
var diff = myEur - compEur;
|
||||||
|
var pct = Math.round(Math.abs(diff) / myEur * 100);
|
||||||
|
if (diff > 0) {
|
||||||
|
savBadge = '<span style="background:rgba(22,163,74,0.15);color:#16a34a;font-size:0.72rem;font-weight:700;padding:2px 7px;border-radius:4px;border:1px solid rgba(22,163,74,0.35)">'
|
||||||
|
+ '−' + pct + '% günstiger</span>';
|
||||||
|
} else if (diff < 0) {
|
||||||
|
savBadge = '<span style="background:rgba(220,38,38,0.1);color:#dc2626;font-size:0.72rem;font-weight:700;padding:2px 7px;border-radius:4px;border:1px solid rgba(220,38,38,0.25)">'
|
||||||
|
+ '+' + pct + '% teurer</span>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Spec comparison helper — highlight match/mismatch
|
||||||
|
function specRow(label, myVal, compVal) {
|
||||||
|
var match = myVal && compVal && String(myVal).toLowerCase() === String(compVal).toLowerCase();
|
||||||
|
var compColor = !myVal || !compVal ? '#aaa' : match ? '#4ade80' : '#fb923c';
|
||||||
|
return '<tr><td style="color:#888;font-size:0.7rem;padding:2px 6px 2px 0;white-space:nowrap">' + label + '</td>'
|
||||||
|
+ '<td style="color:#ccc;font-size:0.7rem;padding:2px 8px 2px 0">' + esc(myVal || '—') + '</td>'
|
||||||
|
+ '<td style="color:' + compColor + ';font-size:0.7rem;padding:2px 0">' + esc(compVal || '—') + '</td></tr>';
|
||||||
|
}
|
||||||
|
|
||||||
|
var mySpeed = t.speed_gbps >= 1000 ? (t.speed_gbps / 1000).toFixed(1).replace('.0','') + 'T' : t.speed_gbps + 'G';
|
||||||
|
var compSpeed = p.comp_speed_gbps ? (p.comp_speed_gbps >= 1000 ? (p.comp_speed_gbps/1000).toFixed(1).replace('.0','')+'T' : p.comp_speed_gbps+'G') : null;
|
||||||
|
|
||||||
|
h += '<div style="border:1px solid var(--border);border-radius:8px;margin-bottom:0.6rem;overflow:hidden">';
|
||||||
|
|
||||||
|
// Header: vendor + part + price + savings badge
|
||||||
|
h += '<div style="display:flex;align-items:center;justify-content:space-between;padding:0.55rem 0.75rem;background:rgba(255,255,255,0.03);border-bottom:1px solid var(--border)">';
|
||||||
|
h += '<div>';
|
||||||
|
h += '<span style="font-size:0.78rem;font-weight:700;color:var(--accent)">' + esc(p.vendor_name) + '</span>';
|
||||||
|
h += '<span style="font-size:0.7rem;color:#888;margin-left:0.5rem">' + esc(p.comparable_part || '—') + '</span>';
|
||||||
|
h += '</div>';
|
||||||
|
h += '<div style="display:flex;align-items:center;gap:0.4rem">';
|
||||||
|
var priceDisplayEur = compEur ? ('EUR\u00a0' + compEur.toLocaleString('de-DE',{minimumFractionDigits:2,maximumFractionDigits:2})) : '';
|
||||||
|
h += '<span style="font-size:0.82rem;font-weight:700;color:var(--text)">' + priceDisplayEur + '</span>';
|
||||||
|
if (p.url) h += '<a href="' + esc(p.url) + '" target="_blank" rel="noopener" style="color:var(--accent);font-size:0.7rem;text-decoration:none">↗</a>';
|
||||||
|
h += '</div>';
|
||||||
|
h += '</div>';
|
||||||
|
|
||||||
|
// Savings badge row
|
||||||
|
if (savBadge) {
|
||||||
|
h += '<div style="padding:0.3rem 0.75rem;background:rgba(255,255,255,0.02);border-bottom:1px solid var(--border);display:flex;align-items:center;gap:0.5rem">';
|
||||||
|
h += savBadge;
|
||||||
|
h += '<span style="font-size:0.68rem;color:#666">vs. Flexoptix Listenpreis</span>';
|
||||||
|
h += '</div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Spec comparison table: Flexoptix (links) vs. Wettbewerber (rechts)
|
||||||
|
h += '<div style="padding:0.5rem 0.75rem">';
|
||||||
|
h += '<table style="width:100%;border-collapse:collapse">';
|
||||||
|
h += '<thead><tr>';
|
||||||
|
h += '<th style="font-size:0.67rem;color:#555;text-align:left;padding-bottom:4px;padding-right:8px"></th>';
|
||||||
|
h += '<th style="font-size:0.67rem;color:#888;text-align:left;padding-bottom:4px;padding-right:8px">Flexoptix</th>';
|
||||||
|
h += '<th style="font-size:0.67rem;color:#888;text-align:left;padding-bottom:4px">' + esc(p.vendor_name) + '</th>';
|
||||||
|
h += '</tr></thead><tbody>';
|
||||||
|
h += specRow('Form Factor', t.form_factor, p.comp_form_factor);
|
||||||
|
h += specRow('Speed', mySpeed, compSpeed);
|
||||||
|
h += specRow('Reach', t.reach_label || (t.reach_meters ? t.reach_meters + 'm' : null), p.comp_reach_label || (p.comp_reach_meters ? p.comp_reach_meters + 'm' : null));
|
||||||
|
h += specRow('Fiber', t.fiber_type, p.comp_fiber_type);
|
||||||
|
if (t.wavelengths || p.comp_wavelengths) h += specRow('Wavelengths', t.wavelengths, p.comp_wavelengths);
|
||||||
|
h += '</tbody></table>';
|
||||||
|
h += '<div style="font-size:0.65rem;color:#555;margin-top:0.35rem">🕐 Stand: ' + fmtDate(p.observed_at) + (p.is_verified ? ' · <span style="color:#2d6a4f">✓ Verified</span>' : '') + '</div>';
|
||||||
|
h += '</div>';
|
||||||
|
h += '</div>'; // card end
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
// No competitor prices → show "Kein Markt" info block with last scan date
|
// No competitor prices → show "Kein Markt" info block with last scan date
|
||||||
if (!cVer && t.last_competitor_scan) {
|
if (!cVer && t.last_competitor_scan) {
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user