diff --git a/packages/api/src/routes/transceivers.ts b/packages/api/src/routes/transceivers.ts
index 1a5526e..e17d2a7 100644
--- a/packages/api/src/routes/transceivers.ts
+++ b/packages/api/src/routes/transceivers.ts
@@ -77,7 +77,10 @@ transceiverRouter.get("/:id", async (req: Request, res: Response) => {
`SELECT DISTINCT ON (po.source_vendor_id)
po.price, po.currency, po.url, po.time,
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
JOIN transceivers t2 ON (
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
comparable_part: row.part_number || row.standard_name,
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];
diff --git a/packages/dashboard/index.html b/packages/dashboard/index.html
index dff5140..bf0133e 100644
--- a/packages/dashboard/index.html
+++ b/packages/dashboard/index.html
@@ -3499,12 +3499,91 @@ async function openTxDetail(id) {
directPrices.forEach(function(p) { h += renderPriceRow(p); });
- if (comparPrices.length > 0) {
- h += '
Vergleichbare Produkte anderer Hersteller (gleiche Spezifikation)
';
- comparPrices.forEach(function(p) { h += renderPriceRow(p); });
- }
-
h += '';
+
+ // Comparable products → Side-by-Side spec comparison cards
+ if (comparPrices.length > 0) {
+ h += 'Vergleichbare Wettbewerber-Produkte
';
+ h += 'Gleiche Spezifikationsklasse — andere Part Number
';
+ 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 = ''
+ + '−' + pct + '% günstiger';
+ } else if (diff < 0) {
+ savBadge = ''
+ + '+' + pct + '% teurer';
+ }
+ }
+
+ // 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 '| ' + label + ' | '
+ + '' + esc(myVal || '—') + ' | '
+ + '' + esc(compVal || '—') + ' |
';
+ }
+
+ 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 += '';
+
+ // Header: vendor + part + price + savings badge
+ h += '
';
+ h += '
';
+ h += '' + esc(p.vendor_name) + '';
+ h += '' + esc(p.comparable_part || '—') + '';
+ h += '
';
+ h += '
';
+ var priceDisplayEur = compEur ? ('EUR\u00a0' + compEur.toLocaleString('de-DE',{minimumFractionDigits:2,maximumFractionDigits:2})) : '';
+ h += '
' + priceDisplayEur + '';
+ if (p.url) h += '
↗';
+ h += '
';
+ h += '
';
+
+ // Savings badge row
+ if (savBadge) {
+ h += '
';
+ h += savBadge;
+ h += 'vs. Flexoptix Listenpreis';
+ h += '
';
+ }
+
+ // Spec comparison table: Flexoptix (links) vs. Wettbewerber (rechts)
+ h += '
';
+ h += '
';
+ h += '';
+ h += ' | ';
+ h += 'Flexoptix | ';
+ h += '' + esc(p.vendor_name) + ' | ';
+ h += '
';
+ 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 += '
';
+ h += '
🕐 Stand: ' + fmtDate(p.observed_at) + (p.is_verified ? ' · ✓ Verified' : '') + '
';
+ h += '
';
+ h += '
'; // card end
+ });
+ }
}
// No competitor prices → show "Kein Markt" info block with last scan date
if (!cVer && t.last_competitor_scan) {