feat: temp range display, verification badges, competitor prices, tag tooltips
- Temperature Range: COM→'0-70°C (COM)', IND→'-40-85°C (IND)'
- GET /api/transceivers/🆔 returns competitor_prices[] from price_observations
- Detail view: verification summary bar (★ 100% VERIFIED / partial)
- Detail view: Current Prices section with vendor, price, verified badge, date, link
- Detail view: tag tooltips on vendor/category/market_status chips
- List view: new Verified column with 100% stamp or price check
- Optical Budget: TX Power Min/Max labels clarified
This commit is contained in:
parent
cd48eee316
commit
3811b3b953
@ -31,7 +31,7 @@ transceiverRouter.get("/", async (req: Request, res: Response) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// GET /api/transceivers/:id — Get single transceiver
|
// GET /api/transceivers/:id — Get single transceiver with latest prices per vendor
|
||||||
transceiverRouter.get("/:id", async (req: Request, res: Response) => {
|
transceiverRouter.get("/:id", async (req: Request, res: Response) => {
|
||||||
try {
|
try {
|
||||||
const transceiver = await getTransceiverById(String(req.params.id));
|
const transceiver = await getTransceiverById(String(req.params.id));
|
||||||
@ -39,7 +39,36 @@ transceiverRouter.get("/:id", async (req: Request, res: Response) => {
|
|||||||
res.status(404).json({ success: false, error: "Transceiver not found" });
|
res.status(404).json({ success: false, error: "Transceiver not found" });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
res.json({ success: true, data: transceiver });
|
|
||||||
|
// Latest price per source vendor — last 30 days only
|
||||||
|
const pricesResult = await pool.query(
|
||||||
|
`SELECT DISTINCT ON (po.source_vendor_id)
|
||||||
|
po.price, po.currency, po.url, po.time, po.stock_level, po.is_verified,
|
||||||
|
v.name AS vendor_name, v.type AS vendor_type, v.website AS vendor_website
|
||||||
|
FROM price_observations po
|
||||||
|
JOIN vendors v ON po.source_vendor_id = v.id
|
||||||
|
WHERE po.transceiver_id = $1
|
||||||
|
AND po.time > NOW() - INTERVAL '30 days'
|
||||||
|
ORDER BY po.source_vendor_id, po.time DESC`,
|
||||||
|
[transceiver.id]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Flag: price is only "verified" if also observed within 7 days
|
||||||
|
const sevenDaysAgo = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000);
|
||||||
|
const prices = pricesResult.rows.map((row) => ({
|
||||||
|
vendor_name: row.vendor_name,
|
||||||
|
vendor_type: row.vendor_type,
|
||||||
|
vendor_website: row.vendor_website,
|
||||||
|
price: parseFloat(row.price),
|
||||||
|
currency: row.currency,
|
||||||
|
url: row.url,
|
||||||
|
stock_level: row.stock_level,
|
||||||
|
observed_at: row.time,
|
||||||
|
// Verified = scraped from real URL + observed within 7 days
|
||||||
|
is_verified: row.url && new Date(row.time) > sevenDaysAgo,
|
||||||
|
}));
|
||||||
|
|
||||||
|
res.json({ success: true, data: { ...transceiver, competitor_prices: prices } });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Get transceiver error:", err);
|
console.error("Get transceiver error:", err);
|
||||||
res.status(500).json({ success: false, error: "Internal server error" });
|
res.status(500).json({ success: false, error: "Internal server error" });
|
||||||
|
|||||||
@ -823,7 +823,7 @@
|
|||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="table-wrap">
|
<div class="table-wrap">
|
||||||
<table>
|
<table>
|
||||||
<thead><tr><th style="width:30px"></th><th>Name<span class="sort-arrow"></span></th><th>Vendor<span class="sort-arrow"></span></th><th>Form Factor<span class="sort-arrow"></span></th><th>Speed<span class="sort-arrow"></span></th><th>Reach<span class="sort-arrow"></span></th><th>Price<span class="sort-arrow"></span></th><th>Tier<span class="sort-arrow"></span></th><th>Avail.<span class="sort-arrow"></span></th><th>Category<span class="sort-arrow"></span></th></tr></thead>
|
<thead><tr><th style="width:30px"></th><th>Name<span class="sort-arrow"></span></th><th>Vendor<span class="sort-arrow"></span></th><th>Form Factor<span class="sort-arrow"></span></th><th>Speed<span class="sort-arrow"></span></th><th>Reach<span class="sort-arrow"></span></th><th>Price<span class="sort-arrow"></span></th><th>Tier<span class="sort-arrow"></span></th><th>Avail.<span class="sort-arrow"></span></th><th>Category<span class="sort-arrow"></span></th><th>Verified</th></tr></thead>
|
||||||
<tbody id="tx-table"></tbody>
|
<tbody id="tx-table"></tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
@ -959,6 +959,20 @@ function buildDOM(parent, html) {
|
|||||||
parent.appendChild(t.content.cloneNode(true));
|
parent.appendChild(t.content.cloneNode(true));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Temperature range code → human-readable display
|
||||||
|
function tempRangeDisplay(code) {
|
||||||
|
if (!code) return null;
|
||||||
|
var map = { 'COM': '0 – 70 °C (COM)', 'IND': '-40 – 85 °C (IND)', 'EXT': '-40 – 70 °C (EXT)' };
|
||||||
|
return map[code] || code;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Format observed date as "DD.MM.YYYY"
|
||||||
|
function fmtDate(iso) {
|
||||||
|
if (!iso) return '';
|
||||||
|
var d = new Date(iso);
|
||||||
|
return d.getDate().toString().padStart(2,'0') + '.' + (d.getMonth()+1).toString().padStart(2,'0') + '.' + d.getFullYear();
|
||||||
|
}
|
||||||
|
|
||||||
// Build a human-readable descriptive product name from available fields
|
// Build a human-readable descriptive product name from available fields
|
||||||
function txDescName(t) {
|
function txDescName(t) {
|
||||||
// Use description field if populated and meaningful (not just the SKU)
|
// Use description field if populated and meaningful (not just the SKU)
|
||||||
@ -1779,6 +1793,10 @@ function searchTransceivers() {
|
|||||||
+ '<td>' + (t.price_tier ? '<span class="b ' + (t.price_tier === 'Premium' ? 'b-purple' : t.price_tier === 'Budget' ? 'b-green' : 'b-neutral') + '">' + esc(t.price_tier) + '</span>' : '—') + '</td>'
|
+ '<td>' + (t.price_tier ? '<span class="b ' + (t.price_tier === 'Premium' ? 'b-purple' : t.price_tier === 'Budget' ? 'b-green' : 'b-neutral') + '">' + esc(t.price_tier) + '</span>' : '—') + '</td>'
|
||||||
+ '<td>' + (t.market_status ? '<span class="b b-green">' + esc(t.market_status) + '</span>' : '—') + '</td>'
|
+ '<td>' + (t.market_status ? '<span class="b b-green">' + esc(t.market_status) + '</span>' : '—') + '</td>'
|
||||||
+ '<td>' + (t.category ? '<span class="b b-neutral">' + esc(t.category) + '</span>' : '') + '</td>'
|
+ '<td>' + (t.category ? '<span class="b b-neutral">' + esc(t.category) + '</span>' : '') + '</td>'
|
||||||
|
+ '<td>' + (t.fully_verified
|
||||||
|
? '<span style="background:linear-gradient(135deg,#1b4332,#2d6a4f);color:#fff;font-size:0.62rem;font-weight:700;padding:2px 6px;border-radius:4px;white-space:nowrap">★ 100%</span>'
|
||||||
|
: t.price_verified ? '<span style="color:#2d6a4f;font-size:0.68rem;font-weight:600">✓ Price</span>' : '')
|
||||||
|
+ '</td>'
|
||||||
+ '</tr>';
|
+ '</tr>';
|
||||||
}).join(''));
|
}).join(''));
|
||||||
|
|
||||||
@ -1818,9 +1836,15 @@ async function openTxDetail(id) {
|
|||||||
// Title + Vendor badge
|
// Title + Vendor badge
|
||||||
h += '<div class="panel-title">' + esc(t.standard_name || t.slug) + '</div>';
|
h += '<div class="panel-title">' + esc(t.standard_name || t.slug) + '</div>';
|
||||||
h += '<div class="panel-sub">';
|
h += '<div class="panel-sub">';
|
||||||
if (t.vendor_name) h += '<span class="b b-blue">' + esc(t.vendor_name) + '</span> ';
|
if (t.vendor_name) h += '<span class="b b-blue" title="Hersteller / Marke dieses Produkts">' + esc(t.vendor_name) + '</span> ';
|
||||||
if (t.category) h += '<span class="b b-neutral">' + esc(t.category) + '</span> ';
|
if (t.category) h += '<span class="b b-neutral" title="Einsatzbereich: ' + esc(t.category) + '">' + esc(t.category) + '</span> ';
|
||||||
if (t.market_status) h += '<span class="b ' + (t.market_status === 'Mainstream' ? 'b-green' : t.market_status === 'Emerging' ? 'b-yellow' : 'b-neutral') + '">' + esc(t.market_status) + '</span>';
|
if (t.market_status) {
|
||||||
|
var msTooltip = t.market_status === 'Emerging' ? 'Hype Cycle: Technologie gewinnt erste Akzeptanz – wachsendes Ökosystem, noch Premium-Preise'
|
||||||
|
: t.market_status === 'Mainstream' ? 'Hype Cycle: Technologie ist etabliert – breite Verfügbarkeit, kompetitive Preise'
|
||||||
|
: t.market_status === 'Legacy' ? 'Hype Cycle: Technologie veraltet – wird durch neuere Generationen abgelöst'
|
||||||
|
: 'Marktphase: ' + t.market_status;
|
||||||
|
h += '<span class="b ' + (t.market_status === 'Mainstream' ? 'b-green' : t.market_status === 'Emerging' ? 'b-yellow' : 'b-neutral') + '" title="' + msTooltip + '">' + esc(t.market_status) + '</span>';
|
||||||
|
}
|
||||||
h += '</div>';
|
h += '</div>';
|
||||||
if (t.description) h += '<div style="font-size:0.8rem;color:var(--text-dim);margin:0.5rem 0">' + esc(t.description) + '</div>';
|
if (t.description) h += '<div style="font-size:0.8rem;color:var(--text-dim);margin:0.5rem 0">' + esc(t.description) + '</div>';
|
||||||
|
|
||||||
@ -1832,6 +1856,24 @@ async function openTxDetail(id) {
|
|||||||
h += '<div class="panel-stat"><div class="panel-stat-label">Fiber</div><div class="panel-stat-val" style="font-size:1rem">' + esc(t.fiber_type || '—') + '</div></div>';
|
h += '<div class="panel-stat"><div class="panel-stat-label">Fiber</div><div class="panel-stat-val" style="font-size:1rem">' + esc(t.fiber_type || '—') + '</div></div>';
|
||||||
h += '</div>';
|
h += '</div>';
|
||||||
|
|
||||||
|
// Verification summary bar
|
||||||
|
var verItems = [];
|
||||||
|
if (t.price_verified) verItems.push('<span style="color:#2d6a4f;font-size:0.75rem">✓ Price</span>');
|
||||||
|
if (t.image_verified) verItems.push('<span style="color:#2d6a4f;font-size:0.75rem">✓ Image</span>');
|
||||||
|
if (t.details_verified) verItems.push('<span style="color:#2d6a4f;font-size:0.75rem">✓ Details</span>');
|
||||||
|
if (t.fully_verified) {
|
||||||
|
h += '<div style="display:flex;align-items:center;gap:0.6rem;flex-wrap:wrap;margin:0.8rem 0;padding:0.5rem 0.75rem;background:linear-gradient(135deg,#1b4332,#2d6a4f);border-radius:8px">'
|
||||||
|
+ '<span style="color:#fff;font-size:0.8rem;font-weight:700;letter-spacing:0.03em">★ 100% VERIFIED</span>'
|
||||||
|
+ '<span style="color:rgba(255,255,255,0.6);font-size:0.7rem">–</span>'
|
||||||
|
+ verItems.join('<span style="color:rgba(255,255,255,0.4);font-size:0.7rem">·</span>')
|
||||||
|
+ (t.fully_verified_at ? '<span style="color:rgba(255,255,255,0.5);font-size:0.68rem;margin-left:auto">seit ' + fmtDate(t.fully_verified_at) + '</span>' : '')
|
||||||
|
+ '</div>';
|
||||||
|
} else if (verItems.length > 0) {
|
||||||
|
h += '<div style="display:flex;align-items:center;gap:0.5rem;flex-wrap:wrap;margin:0.6rem 0;padding:0.4rem 0.6rem;background:rgba(45,106,79,0.08);border:1px solid rgba(45,106,79,0.2);border-radius:6px">'
|
||||||
|
+ verItems.join('<span style="color:#aaa;font-size:0.7rem">·</span>')
|
||||||
|
+ '</div>';
|
||||||
|
}
|
||||||
|
|
||||||
// Helper: render a spec section as a clean table (like Flexoptix spec tables)
|
// Helper: render a spec section as a clean table (like Flexoptix spec tables)
|
||||||
function renderSpecTable(title, rows) {
|
function renderSpecTable(title, rows) {
|
||||||
var visible = rows.filter(function(r) { return r[1] != null && r[1] !== '' && r[1] !== false; });
|
var visible = rows.filter(function(r) { return r[1] != null && r[1] !== '' && r[1] !== false; });
|
||||||
@ -1856,7 +1898,7 @@ async function openTxDetail(id) {
|
|||||||
['Tunable', t.tunable ? 'Yes' : null],
|
['Tunable', t.tunable ? 'Yes' : null],
|
||||||
['ITU Grid', t.itu_grid],
|
['ITU Grid', t.itu_grid],
|
||||||
['Coherent', t.coherent ? 'Yes' : null],
|
['Coherent', t.coherent ? 'Yes' : null],
|
||||||
['Temperature Range', t.temp_range],
|
['Temperature Range', tempRangeDisplay(t.temp_range)],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// SPECIFICATION — Performance
|
// SPECIFICATION — Performance
|
||||||
@ -1875,9 +1917,9 @@ async function openTxDetail(id) {
|
|||||||
// SPECIFICATION — Optical Budget
|
// SPECIFICATION — Optical Budget
|
||||||
h += renderSpecTable('Optical Budget', [
|
h += renderSpecTable('Optical Budget', [
|
||||||
['Power Budget', t.optical_budget_db ? t.optical_budget_db + ' dB' : null],
|
['Power Budget', t.optical_budget_db ? t.optical_budget_db + ' dB' : null],
|
||||||
['Tx Power Min', t.tx_power_min_dbm ? t.tx_power_min_dbm + ' dBm' : null],
|
['TX Power (Min)', t.tx_power_min_dbm != null ? t.tx_power_min_dbm + ' dBm' : null],
|
||||||
['Tx Power Max', t.tx_power_max_dbm ? t.tx_power_max_dbm + ' dBm' : null],
|
['TX Power (Max)', t.tx_power_max_dbm != null ? t.tx_power_max_dbm + ' dBm' : null],
|
||||||
['Rx Sensitivity', t.rx_sensitivity_dbm ? t.rx_sensitivity_dbm + ' dBm' : null],
|
['RX Sensitivity', t.rx_sensitivity_dbm != null ? t.rx_sensitivity_dbm + ' dBm' : null],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// SPECIFICATION — Breakout
|
// SPECIFICATION — Breakout
|
||||||
@ -1899,12 +1941,30 @@ async function openTxDetail(id) {
|
|||||||
['Year Mainstream', t.year_mainstream],
|
['Year Mainstream', t.year_mainstream],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// SPECIFICATION — Pricing
|
// SPECIFICATION — Pricing (verified prices from DB)
|
||||||
h += renderSpecTable('Pricing', [
|
var prices = t.competitor_prices || [];
|
||||||
['MSRP', t.msrp_usd ? '$' + parseFloat(t.msrp_usd).toLocaleString() : null],
|
if (prices.length > 0) {
|
||||||
['Street Price', t.street_price_usd ? '$' + parseFloat(t.street_price_usd).toLocaleString() : null],
|
h += '<div class="panel-section">Current Prices</div>';
|
||||||
['Price Tier', t.price_tier],
|
h += '<div class="spec-table">';
|
||||||
]);
|
prices.forEach(function(p) {
|
||||||
|
var verBadge = p.is_verified
|
||||||
|
? '<span style="color:#2d6a4f;font-size:0.7rem;font-weight:600;margin-left:0.4rem">✓ Verified</span>'
|
||||||
|
: '<span style="color:#888;font-size:0.7rem;margin-left:0.4rem">unverified</span>';
|
||||||
|
var priceStr = p.currency + ' ' + parseFloat(p.price).toLocaleString('de-DE', {minimumFractionDigits:2,maximumFractionDigits:2});
|
||||||
|
var dateStr = '<span style="color:#aaa;font-size:0.68rem;margin-left:0.4rem">' + fmtDate(p.observed_at) + '</span>';
|
||||||
|
var urlLink = p.url ? ' <a href="' + esc(p.url) + '" target="_blank" rel="noopener" style="color:var(--accent);font-size:0.7rem;text-decoration:none;margin-left:0.3rem">↗</a>' : '';
|
||||||
|
h += '<div class="spec-row"><span class="spec-label">' + esc(p.vendor_name) + '</span>'
|
||||||
|
+ '<span class="spec-val">' + priceStr + verBadge + dateStr + urlLink + '</span></div>';
|
||||||
|
});
|
||||||
|
h += '</div>';
|
||||||
|
} else {
|
||||||
|
// Fallback: show MSRP/street from transceivers table if no price_observations
|
||||||
|
h += renderSpecTable('Pricing', [
|
||||||
|
['MSRP', t.msrp_usd ? '$' + parseFloat(t.msrp_usd).toLocaleString() : null],
|
||||||
|
['Street Price', t.street_price_usd ? '$' + parseFloat(t.street_price_usd).toLocaleString() : null],
|
||||||
|
['Price Tier', t.price_tier],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
// Notes (scraped extra specs)
|
// Notes (scraped extra specs)
|
||||||
if (t.notes) {
|
if (t.notes) {
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user