Branded Products
Loading branded products...
// Format pages - dynamic routing
app.get('/formats/:format', async (req, res) => {
const formatSlug = req.params.format;
// Find format configuration
const formatConfig = FORMATS.find(f => f.slug === formatSlug);
if (!formatConfig) {
return res.status(404).render('404', { requestedFormat: formatSlug });
}
// Determine rendering mode from query param
// Formats with no SSR equivalent default to frontend mode
const frontendOnlyFormats = ['sponsored-products-offers'];
const defaultMode = frontendOnlyFormats.includes(formatConfig.slug) ? 'frontend' : 'backend';
const mode = ['backend', 'frontend'].includes(req.query.mode)
? req.query.mode
: defaultMode;
// Extract debug parameters from URL query string
const debugParams = {
adbeta: req.query.adbeta || null,
test_area: req.query.test_area || null,
test_site: req.query.test_site || null,
test_kwrd: req.query.test_kwrd || null,
test_tid: req.query.test_tid || null,
};
// Incoming headers for debug tracking
const incomingHeaders = req.headers;
if (Object.values(debugParams).some(Boolean)) {
console.log('[Server] Debug params:', debugParams);
}
if (incomingHeaders['x-adp-event-track-id']) {
console.log('[Server] Debug tracking ID:', incomingHeaders['x-adp-event-track-id']);
}
let adData = null;
let adData2 = null; // Second slot for sponsored-single-tile
let bidderDebugUrl = null;
// Only fetch bid server-side for backend mode
if (mode === 'backend') {
try {
// ═══════════════════════════════════════════════════════════════
// SPONSORED SINGLE TILE: Multi-slot request (2 independent tiles)
// Mirrors frontend: one bid request with 2 imp objects
// ═══════════════════════════════════════════════════════════════
if (formatSlug === 'sponsored-single-tile') {
const { slots: slotResults, bidderDebugUrl: debugUrl } = await bidder.fetchMultiSlotBid({
site: 'demo_page',
slots: [
{ id: 'sponsored-tile', tagId: formatConfig.tagId, containerId: formatConfig.containerId },
{ id: 'sponsored-tile-2', tagId: 'product-tile2', containerId: 'sponsored-product-container-2' }
],
networkId: formatConfig.networkId,
keyvalues: {
manufacturer_id: '12345678',
npa: 0
},
debugParams,
incomingHeaders
});
bidderDebugUrl = debugUrl;
// Extract per-slot adData
const slot1 = slotResults['sponsored-tile'];
const slot2 = slotResults['sponsored-tile-2'];
adData = slot1?.hasAd ? slot1.adData : null;
adData2 = slot2?.hasAd ? slot2.adData : null;
// Process shop data for each slot independently
[adData, adData2].forEach(data => {
if (!data) return;
if (typeof data.redirectToOffers === 'undefined') {
data.redirectToOffers = true; // default matches frontend
}
data.shopDataMap = {};
if (data.redirectToOffers === false && data.offers && data.offers.length > 0) {
console.log('[Server] FLOW: Sponsored Product (shop data needed)');
const productId = data.offers[0].product_id;
if (productId) {
const shopProducts = productService.getProductsByIds([productId]);
if (shopProducts.length > 0) {
const p = shopProducts[0];
data.shopDataMap[productId] = {
url: p.url, name: p.name, image: p.image, price: p.price
};
}
}
} else {
console.log('[Server] FLOW: Sponsored Offer (ad server data only)');
}
});
}
// ═══════════════════════════════════════════════════════════════
// SPONSORED PRODUCT SLIDER: Multi-slot request (2 slider slots)
// Uses product-tile-slider tagId for horizontal carousel layout
// ═══════════════════════════════════════════════════════════════
else if (formatSlug === 'sponsored-product-slider') {
const { slots: slotResults, bidderDebugUrl: debugUrl } = await bidder.fetchMultiSlotBid({
site: 'demo_page',
slots: [
{ id: 'slider-slot-1', tagId: 'product-tile-slider', containerId: 'slider-sponsored-1' },
{ id: 'slider-slot-2', tagId: 'product-tile-slider2', containerId: 'slider-sponsored-2' }
],
networkId: formatConfig.networkId,
keyvalues: {
main_category_id: '101',
npa: 0
},
debugParams,
incomingHeaders
});
bidderDebugUrl = debugUrl;
const slot1 = slotResults['slider-slot-1'];
const slot2 = slotResults['slider-slot-2'];
adData = slot1?.hasAd ? slot1.adData : null;
adData2 = slot2?.hasAd ? slot2.adData : null;
// Enrich shop data for each slot (same logic as sponsored-single-tile)
[adData, adData2].forEach(data => {
if (!data) return;
if (typeof data.redirectToOffers === 'undefined') {
data.redirectToOffers = true; // default matches frontend
}
data.shopDataMap = {};
if (data.redirectToOffers === false && data.offers && data.offers.length > 0) {
console.log('[Server] FLOW: Sponsored Product (shop data needed)');
const productId = data.offers[0].product_id;
if (productId) {
const shopProducts = productService.getProductsByIds([productId]);
if (shopProducts.length > 0) {
const p = shopProducts[0];
data.shopDataMap[productId] = {
url: p.url, name: p.name, image: p.image, price: p.price
};
}
}
} else {
console.log('[Server] FLOW: Sponsored Offer (ad server data only)');
}
});
}
// ═══════════════════════════════════════════════════════════════
// ALL OTHER FORMATS: Single-slot request
// ═══════════════════════════════════════════════════════════════
else {
const { result: bidResult, bidderDebugUrl: debugUrl } = await bidder.fetchBid({
site: 'demo_page',
tagId: formatConfig.tagId,
containerId: formatConfig.containerId,
networkId: formatConfig.networkId,
keyvalues: {
manufacturer_id: '12345678',
npa: 0
},
debugParams,
incomingHeaders
});
bidderDebugUrl = debugUrl;
adData = bidResult?.hasAd ? bidResult.adData : null;
// For branded-products: look up product details using IDs from ad server
if (formatSlug === 'branded-products' && adData) {
const originalOffers = adData.offers || [];
const productIds = originalOffers.map(o => o.product_id).filter(Boolean);
console.log('[Server] Looking up product IDs from ad server:', productIds.join(', '));
const shopProducts = productService.getProductsByIds(productIds);
adData.shopDataMap = {};
if (shopProducts.length > 0) {
shopProducts.forEach(p => {
adData.shopDataMap[p.productId] = { url: p.url, name: p.name, image: p.image, price: p.price };
});
console.log('[Server] Shop data loaded for', shopProducts.length, 'products');
} else {
console.log('[Server] Product IDs not in shop catalog, shopDataMap empty');
}
}
}
} catch (error) {
console.error(`[Server] Error fetching bid for ${formatSlug}:`, error.message);
adData = null;
adData2 = null;
}
}
console.log(`[Server] Rendering ${formatSlug} in ${mode.toUpperCase()} mode`);
res.render(`formats/${formatSlug}`, {
adData,
adData2,
mode,
format: formatConfig,
formats: FORMATS,
sourceCode: SOURCE_CODE_BY_FORMAT[formatSlug] || SOURCE_CODE_BY_FORMAT['brand-store'],
bidderDebugUrl: bidderDebugUrl || null
});
});
/**
* CSR Bidder Client
* ═══════════════════════════════════════════════════════════════════════════════
* Fetches ads from Ring DAS CSR bidder for server-side rendering (SSR).
*
* SUPPORTED FORMATS:
* ─────────────────────────────────────────────────────────────────────────────
* 1. Brand Store (brand-store)
* - Ad server provides: click tracking URL
* - Shop provides: ALL product data (logo, price, URL)
*
* 2. Branded Products (branded-products)
* - Ad server provides: bannerImage (logo) + offers[].product_id
* - Shop provides: product details fetched using product_id
*
* ═══════════════════════════════════════════════════════════════════════════════
*
* Usage:
* const { BidderClient } = require('./lib/bidder-client');
* const bidder = new BidderClient({ networkId: '1746213' });
* const result = await bidder.fetchBid({
* site: 'demo_page',
* tagId: 'branded-products',
* containerId: 'ad-container'
* });
*/
// ═══════════════════════════════════════════════════════════════════════════════
// HELPER FUNCTIONS
// ═══════════════════════════════════════════════════════════════════════════════
/**
* Returns hardcoded shop display data for Brand Store format.
* In real implementation, shop would provide this data.
*/
function getBrandStoreShopData() {
return {
shopName: 'AD Partner Shop',
price: '€899.00',
label: 'Ad'
};
}
/**
* Generates unique request ID for bid requests
*/
function generateRequestId() {
return Math.random().toString(16).substring(2, 15) +
Math.random().toString(16).substring(2, 15);
}
// ═══════════════════════════════════════════════════════════════════════════════
// BIDDER CLIENT CLASS
// ═══════════════════════════════════════════════════════════════════════════════
class BidderClient {
/**
* Create a new BidderClient instance
* @param {Object} options
* @param {string} [options.networkId='1746213'] - Default network ID
*/
constructor(options = {}) {
this.networkId = options.networkId || '1746213';
this.baseUrl = `https://csr.onet.pl/${this.networkId}/bid`;
}
// ─────────────────────────────────────────────────────────────────────────
// REQUEST BUILDING
// ─────────────────────────────────────────────────────────────────────────
/**
* Build OpenRTB-compatible bid request body
*
* @param {Object} params
* @param {string} params.site - Site identifier (e.g., 'demo_page')
* @param {string} params.tagId - Slot tag ID ('brand-store' or 'branded-products')
* @param {string} params.networkId - Network ID for this request
* @param {Object} [params.keyvalues={}] - Additional targeting key-values
* @param {Object} [params.debugParams={}] - Debug overrides (adbeta, test_area, test_site, test_kwrd, test_tid)
* @returns {Object} OpenRTB bid request body
*/
buildRequestBody({ site, tagId, networkId, keyvalues = {}, debugParams = {} }) {
const effectiveSiteId = debugParams.test_site || site;
const effectiveNetworkId = debugParams.test_tid || networkId;
const siteExt = {};
if (debugParams.test_area) siteExt.area = debugParams.test_area;
if (debugParams.test_kwrd) siteExt.kwrd = debugParams.test_kwrd;
return {
id: generateRequestId(),
imp: [{
id: 'imp-1',
tagid: tagId,
secure: 1,
native: { request: "{}" },
ext: {
offers_limit: 10,
no_redirect: true // adclick URLs are pixel-only (no redirect); shop handles redirect via href
}
}],
site: {
id: effectiveSiteId,
...(Object.keys(siteExt).length > 0 && { ext: siteExt })
},
user: {
ext: {
npa: false // false = consent given, true = no consent
}
},
ext: {
network: effectiveNetworkId,
keyvalues: {
kvIV: generateRequestId(),
kvIP: generateRequestId(),
...keyvalues
},
is_non_prebid_request: true,
...(debugParams.adbeta && { adbeta: debugParams.adbeta })
},
at: 1,
tmax: 1000,
regs: {
gdpr: 1,
gpp: '',
ext: {
dsa: 1 // Request DSA transparency info
}
}
};
}
// ─────────────────────────────────────────────────────────────────────────
// RESPONSE PARSING
// ─────────────────────────────────────────────────────────────────────────
/**
* Parse a single bid object and extract ad data.
* Core parsing logic shared by parseResponse() and fetchMultiSlotBid().
*
* @param {Object} bid - Single bid object from seatbid[].bid[]
* @param {string} containerId - Container element ID for viewability
* @returns {Object|null} { hasAd, adData } or null on error
*/
parseSingleBid(bid, containerId) {
try {
const adm = JSON.parse(bid.adm);
// DSA (Digital Services Act) transparency info
const dsaData = bid?.ext?.dsa || null;
const dsaInfo = dsaData ? {
advertiser: dsaData.behalf || null,
payer: dsaData.paid || null,
adrender: dsaData.adrender || 0
} : null;
// Click tracking URL (for Brand Store)
const metaAdclick = adm?.meta?.adclick;
const fieldsClick = adm?.fields?.click;
const clickUrl = metaAdclick
? metaAdclick + encodeURIComponent(fieldsClick || '')
: (fieldsClick || null);
// Branded Products data from ad server
const bannerImage = adm?.fields?.bannerImage || null;
const urlRedirect = adm?.fields?.urlRedirect || null;
const offers = adm?.fields?.feed?.offers || [];
// Extract redirectToOffers flag (determines tile link behavior)
let redirectToOffers = undefined;
if (typeof adm?.fields?.redirectToOffers !== 'undefined') {
redirectToOffers = adm.fields.redirectToOffers;
} else if (typeof adm?.fields?.feed?.redirectToOffers !== 'undefined') {
redirectToOffers = adm.fields.feed.redirectToOffers;
}
const impressionUrl = bid?.ext?.ems_link;
return {
hasAd: true,
adData: {
containerId,
adm: bid.adm,
bannerImage,
urlRedirect,
offers,
redirectToOffers,
dsaInfo,
clickUrl,
metaAdclick,
impressionUrl,
adId: adm?.meta?.adid,
templateCode: adm?.tplCode,
...getBrandStoreShopData()
}
};
} catch (error) {
console.error('[BidderClient] Error parsing bid:', error.message);
return null;
}
}
/**
* Parse bid response (single-slot) and extract ad data.
* Delegates to parseSingleBid for the first bid found.
*
* @param {Object} response - Raw bid response from ad server
* @param {string} containerId - Container element ID for viewability
* @returns {Object|null} Parsed ad data or null if no bid
*/
parseResponse(response, containerId) {
const bid = response?.seatbid?.[0]?.bid?.[0];
if (!bid) {
console.log('[BidderClient] No bid in response');
return null;
}
return this.parseSingleBid(bid, containerId);
}
// ─────────────────────────────────────────────────────────────────────────
// MAIN FETCH METHOD
// ─────────────────────────────────────────────────────────────────────────
/**
* Fetch bid from Ring DAS CSR bidder (single slot)
*
* @param {Object} params
* @param {string} params.site - Site identifier (e.g., 'demo_page')
* @param {string} params.tagId - Slot tag ID ('brand-store' or 'branded-products')
* @param {string} params.containerId - Container element ID for viewability
* @param {string} [params.networkId] - Network ID (overrides default)
* @param {Object} [params.keyvalues={}] - Additional targeting parameters
* @param {Object} [params.debugParams={}] - Debug overrides (adbeta, test_area, test_site, test_kwrd, test_tid)
* @param {Object} [params.incomingHeaders={}] - Incoming request headers (for forwarding x-adp-event-track-id)
* @returns {Promise<{result: Object|null, bidderDebugUrl: string}>}
*/
async fetchBid({ site, tagId, containerId, networkId, keyvalues = {}, debugParams = {}, incomingHeaders = {} }) {
const effectiveNetworkId = debugParams.test_tid || networkId || this.networkId;
const bidUrl = `https://csr.onet.pl/${effectiveNetworkId}/bid`;
const requestBody = this.buildRequestBody({
site,
tagId,
networkId: effectiveNetworkId,
keyvalues,
debugParams
});
console.log('[BidderClient] ─────────────────────────────────────────');
console.log('[BidderClient] Fetching bid from:', bidUrl);
console.log('[BidderClient] Tag ID:', tagId);
console.log('[BidderClient] Request:', JSON.stringify(requestBody, null, 2));
// Build full debug URL with encoded request body
const encodedData = encodeURIComponent(JSON.stringify(requestBody));
const bidderDebugUrl = `${bidUrl}?data=${encodedData}`;
// Forward x-adp-event-track-id header if present
const bidderHeaders = {};
const trackId = incomingHeaders['x-adp-event-track-id'];
if (trackId) bidderHeaders['X-ADP-EVENT-TRACK-ID'] = trackId;
try {
const response = await fetch(bidderDebugUrl, { headers: bidderHeaders });
console.log('[BidderClient] Response status:', response.status);
// 204 = no-bid (valid response, no ad available)
if (response.status === 204) {
console.log('[BidderClient] ✗ No bid (204)');
console.log('[BidderClient] ─────────────────────────────────────────');
return { result: null, bidderDebugUrl };
}
if (!response.ok) {
console.error('[BidderClient] HTTP error:', response.status, response.statusText);
return { result: null, bidderDebugUrl };
}
const data = await response.json();
console.log('[BidderClient] Response received');
const result = this.parseResponse(data, containerId);
if (result) {
console.log('[BidderClient] ✓ Ad found');
console.log('[BidderClient] - Banner image:', result.adData.bannerImage ? 'Yes' : 'No');
console.log('[BidderClient] - URL redirect:', result.adData.urlRedirect ? 'Yes' : 'No');
console.log('[BidderClient] - Products:', result.adData.offers?.length || 0);
console.log('[BidderClient] - DSA info:', result.adData.dsaInfo ? 'Yes' : 'No');
} else {
console.log('[BidderClient] ✗ No ad available');
}
console.log('[BidderClient] ─────────────────────────────────────────');
return { result, bidderDebugUrl };
} catch (error) {
console.error('[BidderClient] Fetch error:', error.message);
return { result: null, bidderDebugUrl };
}
}
// ─────────────────────────────────────────────────────────────────────────
// MULTI-SLOT FETCH (multiple imp objects in a single request)
// ─────────────────────────────────────────────────────────────────────────
/**
* Fetch multiple ad slots in a single bid request.
* Mirrors frontend behavior where the SDK sends one request with multiple imp objects.
*
* @param {Object} params
* @param {string} params.site - Site identifier
* @param {Array<{id: string, tagId: string, containerId: string}>} params.slots - Slot definitions
* @param {string} [params.networkId] - Network ID (overrides default)
* @param {Object} [params.keyvalues={}] - Additional targeting parameters
* @param {Object} [params.debugParams={}] - Debug overrides (adbeta, test_area, test_site, test_kwrd, test_tid)
* @param {Object} [params.incomingHeaders={}] - Incoming request headers (for forwarding x-adp-event-track-id)
* @returns {Promise<{slots: Object, bidderDebugUrl: string}>}
*/
async fetchMultiSlotBid({ site, slots, networkId, keyvalues = {}, debugParams = {}, incomingHeaders = {} }) {
const effectiveNetworkId = debugParams.test_tid || networkId || this.networkId;
const bidUrl = `https://csr.onet.pl/${effectiveNetworkId}/bid`;
const effectiveSiteId = debugParams.test_site || site;
const siteExt = {};
if (debugParams.test_area) siteExt.area = debugParams.test_area;
if (debugParams.test_kwrd) siteExt.kwrd = debugParams.test_kwrd;
const requestBody = {
id: generateRequestId(),
imp: slots.map((slot, index) => ({
id: slot.id,
tagid: slot.tagId,
secure: 1,
native: { request: "{}" },
ext: {
pos: String(index + 1),
swidth: 220,
no_redirect: true // adclick URLs are pixel-only (no redirect); shop handles redirect via href
}
})),
site: {
id: effectiveSiteId,
...(Object.keys(siteExt).length > 0 && { ext: siteExt })
},
user: { ext: { npa: false } },
ext: {
network: effectiveNetworkId,
keyvalues: {
kvIV: generateRequestId(),
kvIP: generateRequestId(),
...keyvalues
},
is_non_prebid_request: true,
...(debugParams.adbeta && { adbeta: debugParams.adbeta })
},
at: 1,
tmax: 1000,
regs: {
gdpr: 1,
gpp: '',
ext: { dsa: 1 }
}
};
console.log('[BidderClient] ─────────────────────────────────────────');
console.log('[BidderClient] Multi-slot bid request:', slots.length, 'slots');
console.log('[BidderClient] Fetching from:', bidUrl);
console.log('[BidderClient] Slots:', slots.map(s => s.id).join(', '));
// Build full debug URL with encoded request body
const encodedData = encodeURIComponent(JSON.stringify(requestBody));
const bidderDebugUrl = `${bidUrl}?data=${encodedData}`;
// Forward x-adp-event-track-id header if present
const bidderHeaders = {};
const trackId = incomingHeaders['x-adp-event-track-id'];
if (trackId) bidderHeaders['X-ADP-EVENT-TRACK-ID'] = trackId;
try {
const response = await fetch(bidderDebugUrl, { headers: bidderHeaders });
console.log('[BidderClient] Response status:', response.status);
// 204 = no-bid (valid response, no ad available)
if (response.status === 204) {
console.log('[BidderClient] ✗ No bid (204)');
console.log('[BidderClient] ─────────────────────────────────────────');
return { slots: {}, bidderDebugUrl };
}
if (!response.ok) {
console.error('[BidderClient] HTTP error:', response.status, response.statusText);
return { slots: {}, bidderDebugUrl };
}
const data = await response.json();
// Collect all bids by impid
const bidsById = {};
for (const seatbid of (data.seatbid || [])) {
for (const bid of (seatbid.bid || [])) {
bidsById[bid.impid] = bid;
}
}
const results = {};
for (const slot of slots) {
const bid = bidsById[slot.id];
if (bid) {
const parsed = this.parseSingleBid(bid, slot.containerId);
if (parsed) {
results[slot.id] = parsed;
console.log('[BidderClient] Slot', slot.id, '→ ad found, offers:', parsed.adData.offers?.length || 0);
}
} else {
console.log('[BidderClient] Slot', slot.id, '→ no bid');
}
}
console.log('[BidderClient] ─────────────────────────────────────────');
return { slots: results, bidderDebugUrl };
} catch (error) {
console.error('[BidderClient] Multi-slot fetch error:', error.message);
return { slots: {}, bidderDebugUrl: bidUrl };
}
}
}
module.exports = {
BidderClient
};
<%
/**
* Branded Products - Backend (SSR) Partial
*
* DATA FLOW (Hybrid Format):
* ─────────────────────────────────────────────────────────────────────────────
* 1. Ad Server provides: bannerImage (brand logo) + offers[].product_id
* 2. Shop fetches: product details (image, price, name, URL) for each product_id
* 3. Shop renders: combined data (logo from ad server + product details from shop)
*
* For this demo, the server.js fetches from bidder and passes adData here.
* In real implementation, shop would fetch product details from its database.
* ─────────────────────────────────────────────────────────────────────────────
*
* Expected adData structure:
* {
* // FROM AD SERVER (never mutated):
* bannerImage: string, // Brand logo URL
* urlRedirect: string|null, // Brand logo click destination URL (optional)
* metaAdclick: string, // Base URL for click tracking
* offers: Array<{ // Original offers from ad server
* product_id,
* offer_url, // Click tracking parameter (append to metaAdclick)
* offer_image, offer_name, offer_price // Fallback display data
* }>,
*
* // FROM SHOP (separate from offers, keyed by product_id):
* shopDataMap: {
* [productId]: {
* url: string, // Shop's product URL (destination)
* name: string, // Product name from shop catalog
* image: string, // Product image from shop catalog
* price: number // Product price from shop catalog
* }
* },
*
* // TRACKING:
* dsaInfo: { advertiser, payer }, // DSA transparency info
* adm: string, // adm for viewability tracking
* containerId: string
* }
*
* NOTE: Number of products is configurable by shop (not limited to 5).
*/
// Configuration - shop decides how many products to display
const MAX_PRODUCTS = 5;
/**
* Format price in EUR currency
*/
function formatPrice(price) {
return new Intl.NumberFormat('de-DE', {
style: 'currency',
currency: 'EUR'
}).format(price);
}
/**
* Build display-ready product list by merging shop data with ad server offers.
* Ad server offers are never mutated — shop data comes from a separate shopDataMap.
*
* @param {Object} shopDataMap - Shop product data keyed by product_id: { url, name, image, price }
* @param {Array} offers - Original offers from ad server (never mutated)
* @returns {Array} Products ready for display with tracking URLs
*/
function getProductsForDisplay(shopDataMap, offers) {
return (offers || []).map(offer => {
var shop = shopDataMap[offer.product_id] || {};
return {
productId: offer.product_id || offer.offer_id,
image: shop.image || offer.offer_image,
name: shop.name || offer.offer_name,
price: shop.price || offer.offer_price,
url: shop.url || offer.offer_url,
// Per-offer click tracking URL provided directly by the ad server
trackingUrl: offer.adclick || ''
};
});
}
// Get products for display (shop data from shopDataMap, offers from ad server)
const products = adData ? getProductsForDisplay(adData.shopDataMap || {}, adData.offers) : [];
%>
<% if (adData) { %>
<div class="branded-products-container" id="<%= adData.containerId || 'branded-products-container' %>">
<!-- ═══════════════════════════════════════════════════════════════════
HEADER: Sponsored badge + DSA info (from AD SERVER)
═══════════════════════════════════════════════════════════════════ -->
<div class="sponsored-header">
<span class="sponsored-badge">Sponsored</span>
<% if (adData.dsaInfo) { %>
<div class="dsa-info-wrapper">
<span class="dsa-info-icon" title="Ad transparency info">i</span>
<div class="dsa-info-tooltip">
<div class="dsa-info-row">
<span class="dsa-info-label">Advertiser:</span>
<span class="dsa-info-value"><%= adData.dsaInfo.advertiser || 'N/A' %></span>
</div>
<div class="dsa-info-row">
<span class="dsa-info-label">Paid by:</span>
<span class="dsa-info-value"><%= adData.dsaInfo.payer || 'N/A' %></span>
</div>
</div>
</div>
<% } %>
</div>
<!-- ═══════════════════════════════════════════════════════════════════
BRAND SECTION: Logo from AD SERVER
═══════════════════════════════════════════════════════════════════ -->
<div class="brand-section">
<% if (adData.bannerImage) { %>
<% if (adData.urlRedirect) { %>
<!-- Brand logo with click-through URL from ad server -->
<a href="<%= adData.urlRedirect %>" target="_blank" rel="noopener nofollow sponsored">
<img class="brand-logo" src="<%= adData.bannerImage %>" alt="Brand Logo">
</a>
<% } else { %>
<!-- Brand logo URL provided by ad server -->
<img class="brand-logo" src="<%= adData.bannerImage %>" alt="Brand Logo">
<% } %>
<% } else { %>
<div class="brand-logo-placeholder">Brand Logo</div>
<% } %>
</div>
<!-- ═══════════════════════════════════════════════════════════════════
PRODUCTS SECTION: Details from SHOP (fetched using product_id)
═══════════════════════════════════════════════════════════════════ -->
<div class="products-section">
<% if (products.length > 0) { %>
<%
// Shop decides how many products to display
const displayProducts = products.slice(0, MAX_PRODUCTS);
%>
<% displayProducts.forEach(function(product) { %>
<!--
Product data comes from SHOP's database
(looked up using product_id from ad server)
Click tracking URL (trackingUrl) comes from AD SERVER
-->
<a href="<%= product.url %>" class="product-tile"
target="_blank" rel="noopener nofollow sponsored"
<% if (product.trackingUrl) { %>onclick="new Image().src='<%= product.trackingUrl %>';"<% } %>>
<img class="product-image"
src="<%= product.image %>"
alt="<%= product.name %>">
<div class="product-name"><%= product.name %></div>
<div class="product-price"><%= formatPrice(product.price) %></div>
</a>
<% }); %>
<% } else { %>
<!-- Placeholder when no products available -->
<% for (let i = 0; i < 3; i++) { %>
<div class="product-tile">
<div class="product-image-placeholder">Product <%= i + 1 %></div>
<div class="product-name">Sample Product Name</div>
<div class="product-price">€99.00</div>
</div>
<% } %>
<% } %>
</div>
</div>
<!-- ═══════════════════════════════════════════════════════════════════════
TRACKING: Register bid response for viewability measurement
═══════════════════════════════════════════════════════════════════════ -->
<script>
dlApi.cmd.push(function() {
dlApi.registerBidResponse(<%- JSON.stringify(adData.adm) %>, '<%= adData.containerId || 'branded-products-container' %>');
});
</script>
<% } else { %>
<!-- No ad data available -->
<div class="no-ad-state">
No ad data available (Backend mode)
</div>
<% } %>
<!-- Branded Products - Frontend Mode (CSR) -->
<!--
DATA FLOW (Hybrid Format):
1. Ad Server provides: bannerImage (brand logo) + offers[] with product_id and tracking URLs
2. Shop fetches: product details (image, price, name, URL) from its catalog using product_ids
3. Shop renders: combined data (logo from ad server + product details from shop + tracking URLs)
If a product_id is not found in the shop's catalog, the offer data from the ad server is used directly.
-->
<div id="branded-products-container" class="branded-products-container">
<div class="loading-state">
<div class="spinner"></div>
<span>Loading branded products...</span>
</div>
</div>
<script>
(function() {
'use strict';
// =========================================================================
// CONFIGURATION
// =========================================================================
const CONFIG = {
containerId: 'branded-products-container',
maxProducts: 5, // Shop decides how many products to display
tplCode: '1746213/Sponsored-Product-Plus',
// Mock product service endpoint (simulates shop's product catalog)
productApiUrl: '/api/products'
};
// =========================================================================
// HELPER FUNCTIONS
// =========================================================================
/**
* Escape HTML special characters to prevent XSS
*/
function escapeHtml(text) {
if (!text) return '';
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
/**
* Format price in EUR currency
*/
function formatPrice(price) {
return new Intl.NumberFormat('de-DE', {
style: 'currency',
currency: 'EUR'
}).format(price);
}
/**
* Build DSA (Digital Services Act) transparency info HTML
*/
function buildDsaHtml(dsa) {
if (!dsa) return '';
return `
<div class="dsa-info-wrapper">
<span class="dsa-info-icon" title="Ad transparency info">i</span>
<div class="dsa-info-tooltip">
<div class="dsa-info-row">
<span class="dsa-info-label">Advertiser:</span>
<span class="dsa-info-value">${escapeHtml(dsa.behalf) || 'N/A'}</span>
</div>
<div class="dsa-info-row">
<span class="dsa-info-label">Paid by:</span>
<span class="dsa-info-value">${escapeHtml(dsa.paid) || 'N/A'}</span>
</div>
</div>
</div>
`;
}
// =========================================================================
// SHOP'S PRODUCT DATA FETCHING (MOCK SERVICE)
// =========================================================================
/**
* Fetch products from shop's product catalog (mock service in this demo)
*
* @param {Array<string>} productIds - Product IDs to fetch
* @returns {Promise<Array>} - Product details from shop catalog
*/
async function fetchProductsFromMockService(productIds) {
console.log('[MockService] ─────────────────────────────────────────');
console.log('[MockService] Fetching products from mock service...');
console.log('[MockService] Requested IDs:', productIds.join(', '));
try {
const ids = productIds.slice(0, CONFIG.maxProducts).join(',');
const url = `${CONFIG.productApiUrl}?ids=${ids}`;
console.log('[MockService] Request URL:', url);
const response = await fetch(url);
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
const data = await response.json();
console.log('[MockService] Response source:', data.source);
console.log('[MockService] Products received:', data.count);
data.products.forEach((p, i) => {
console.log(`[MockService] ${i + 1}. ${p.name} - ${p.formattedPrice}`);
});
console.log('[MockService] ─────────────────────────────────────────');
return data.products;
} catch (error) {
console.error('[MockService] Error:', error.message);
console.log('[MockService] ─────────────────────────────────────────');
return [];
}
}
// =========================================================================
// RENDERING
// =========================================================================
/**
* Build HTML for a single product tile
* @param {Object} product - Product with shop data + trackingUrl from ad server
*/
function buildProductTileHtml(product) {
// trackingUrl from ad server for click tracking
const trackingOnclick = product.trackingUrl
? `onclick="new Image().src='${product.trackingUrl}';"`
: '';
return `
<a href="${escapeHtml(product.url)}" class="product-tile"
target="_blank" rel="noopener nofollow sponsored"
${trackingOnclick}>
<img class="product-image" src="${escapeHtml(product.image)}"
alt="${escapeHtml(product.name)}">
<div class="product-name">${escapeHtml(product.name)}</div>
<div class="product-price">${formatPrice(product.price)}</div>
</a>
`;
}
/**
* Build placeholder HTML when no products available
*/
function buildPlaceholderHtml(count) {
let html = '';
for (let i = 0; i < count; i++) {
html += `
<div class="product-tile">
<div class="product-image-placeholder">Product ${i + 1}</div>
<div class="product-name">Sample Product Name</div>
<div class="product-price">€99.00</div>
</div>
`;
}
return html;
}
/**
* Render the complete Branded Products banner
*
* @param {string} bannerImage - Brand logo URL (from AD SERVER)
* @param {Array} products - Product details (from SHOP)
* @param {Object} dsaInfo - DSA transparency info (from AD SERVER)
* @param {string} urlRedirect - Logo click destination URL (from AD SERVER, optional)
*/
function renderBrandedProducts(bannerImage, products, dsaInfo, urlRedirect) {
const container = document.getElementById(CONFIG.containerId);
if (!container) {
console.error('[Render] Container not found');
return;
}
// Build brand section (data from AD SERVER)
const brandHtml = bannerImage
? (urlRedirect
? `<a href="${escapeHtml(urlRedirect)}" target="_blank" rel="noopener nofollow sponsored">
<img class="brand-logo" src="${escapeHtml(bannerImage)}" alt="Brand Logo">
</a>`
: `<img class="brand-logo" src="${escapeHtml(bannerImage)}" alt="Brand Logo">`)
: '<div class="brand-logo-placeholder">Brand Logo</div>';
// Build products section (data from SHOP)
const displayProducts = products.slice(0, CONFIG.maxProducts);
const productsHtml = displayProducts.length > 0
? displayProducts.map(buildProductTileHtml).join('')
: buildPlaceholderHtml(3);
// Render complete banner
container.innerHTML = `
<div class="sponsored-header">
<span class="sponsored-badge">Sponsored</span>
${buildDsaHtml(dsaInfo)}
</div>
<div class="brand-section">
<!-- Brand logo from AD SERVER -->
${brandHtml}
</div>
<div class="products-section">
<!-- Product details from SHOP -->
${productsHtml}
</div>
`;
console.log('[Render] Banner rendered with', displayProducts.length, 'products');
}
/**
* Show empty state when no ad is available
*/
function renderEmptyState(message) {
const container = document.getElementById(CONFIG.containerId);
if (container) {
container.innerHTML = `<div class="no-ad-state">${escapeHtml(message)}</div>`;
}
}
// =========================================================================
// MAIN: SDK INTEGRATION
// =========================================================================
dlApi.cmd.push(function(dlApi) {
// Step 2: Fetch ad using fetchNativeAd
dlApi.fetchNativeAd({
slot: 'display', // fixed value for Branded Products
div: CONFIG.containerId, // container ID for viewability tracking
opts: {
offers_limit: CONFIG.maxProducts // maximum number of products returned from ad server
},
tplCode: CONFIG.tplCode, // fixed value for Branded Products
asyncRender: true // manually count impression with ad.render()
}).then(async function(ad) {
if (!ad) {
console.log('[Ad Server] No ad available');
renderEmptyState('No branded products available');
return;
}
console.log('[Ad Server] Response received:', ad);
// ─────────────────────────────────────────────────────────────
// STEP A: Extract data from AD SERVER
// ─────────────────────────────────────────────────────────────
const bannerImage = ad.fields?.bannerImage || ''; // Brand logo
const urlRedirect = ad.fields?.urlRedirect || ''; // Logo click URL (optional)
const offers = ad.fields?.feed?.offers || []; // Product offers with tracking URLs
const dsaInfo = ad.dsa;
console.log('[Ad Server] Brand logo:', bannerImage ? 'Yes' : 'No');
console.log('[Ad Server] Offers received:', offers.length);
offers.forEach((o, i) => {
console.log(`[Ad Server] ${i + 1}. product_id: ${o.product_id}, offer_url: ${o.offer_url ? 'Yes' : 'No'}`);
});
// ─────────────────────────────────────────────────────────────
// STEP B: Fetch product details from SHOP using ad server IDs
// ─────────────────────────────────────────────────────────────
const productIds = offers.map(o => o.product_id).filter(Boolean);
console.log('[Shop] Looking up product IDs from ad server:', productIds.join(', '));
let products = await fetchProductsFromMockService(productIds);
// Fallback: if shop catalog doesn't have these IDs, use offer data directly
if (products.length === 0 && offers.length > 0) {
console.log('[Shop] Products not found in catalog, using ad server offer data as fallback');
products = offers.map(o => ({
productId: o.product_id,
name: o.offer_name,
price: o.offer_price,
image: o.offer_image,
url: o.offer_url
}));
}
// ─────────────────────────────────────────────────────────────
// STEP C: Combine shop data with ad server tracking URLs
// ─────────────────────────────────────────────────────────────
// href = shop URL (destination), onclick = per-offer adclick URL from ad server
const productsWithTracking = products.map((product, index) => ({
...product,
trackingUrl: offers[index]?.adclick || ''
}));
console.log('[Data Merge] Products with tracking URLs:');
productsWithTracking.forEach((p, i) => {
console.log(`[Data Merge] ${i + 1}. ${p.name} - tracking: ${p.trackingUrl ? 'Yes' : 'No'}`);
});
// ─────────────────────────────────────────────────────────────
// STEP D: Render combined data
// ─────────────────────────────────────────────────────────────
renderBrandedProducts(bannerImage, productsWithTracking, dsaInfo, urlRedirect);
// ─────────────────────────────────────────────────────────────
// STEP E: Count impression
// ─────────────────────────────────────────────────────────────
ad.render();
console.log('[Ad Server] Impression counted');
}).catch(function(err) {
console.error('[Ad Server] Ad could not be loaded:', err);
renderEmptyState('Error loading branded products');
});
// Step 3: Trigger ad request
dlApi.fetch();
});
})();
</script>