An error occurred while processing the template.
The following has evaluated to null or missing:
==> product.productId  [in template "34352066712900#33336#null" at line 10, column 22]

----
Tip: It's the step after the last dot that caused this error, not those before it.
----
Tip: If the failing expression is known to legally refer to something that's sometimes null or missing, either specify a default value like myOptionalVar!myDefault, or use <#if myOptionalVar??>when-present<#else>when-missing</#if>. (These only cover the last step of the expression; to cover the whole expression, use parenthesis: (myOptionalVar.foo)!myDefault, (myOptionalVar.foo)??
----

----
FTL stack trace ("~" means nesting-related):
	- Failed at: #assign productId = product.productId  [in template "34352066712900#33336#null" at line 10, column 1]
----
1<#-- Variables --> 
2<#assign isDebug = false> 
3<#assign channelResponse = restClient.get("/headless-commerce-delivery-catalog/v1.0/channels?filter=name eq 'Aenor Tienda'")> 
4<#assign channel = channelResponse.items[0]> 
5<#assign channelId = channel.id> 
6<#assign product = getProduct(channelId, CPDefinition_cProductId.getData()) /> 
7 
8<#-- Product data --> 
9<#assign displayDateProduct = CPDefinition_displayDate.getData() /> 
10<#assign productId = product.productId /> 
11<#assign cpDefinitionId = product.id /> 
12<#assign productERC = product.externalReferenceCode /> 
13 
14<#assign categoriesProduct = getProductCategories(channelId, productId) /> 
15<#assign hasProductCategoriaTipoEntidadLibro = isVocabularyNameIntoCategories(categoriesProduct, 'entity type', 'libro') /> 
16<#assign hasProductCategoriaTipoEntidadNorma = isVocabularyNameIntoCategories(categoriesProduct, 'entity type', 'norma') /> 
17<#assign hasProductCategoriaTipoEntidadColeccionTematica = isVocabularyNameIntoCategories(categoriesProduct, 'entity type', 'coleccion tematica') /> 
18 
19 
20<#-- Functions --> 
21<#function getProductCategories channelId productId> 
22    <#return restClient.get("/headless-commerce-delivery-catalog/v1.0/channels/${channelId}/products/${productId}/categories?sort=vocabulary").items> 
23</#function> 
24 
25 
26<#function isVocabularyNameIntoCategories categories vocabulary name> 
27    <#assign found = false /> 
28 
29    <#if categories?has_content && vocabulary?has_content && name?has_content> 
30 
31        <#assign vocabNorm = normalize(vocabulary) /> 
32        <#assign nameNorm  = normalize(name) /> 
33 
34        <#list categories as category> 
35            <#if !found> 
36                <#assign catVocabNorm = normalize(category.vocabulary) /> 
37                <#assign catNameNorm  = normalize(category.name) /> 
38 
39                <#if catVocabNorm == vocabNorm && catNameNorm == nameNorm> 
40                    <#assign found = true /> 
41                </#if> 
42            </#if> 
43        </#list> 
44 
45    </#if> 
46 
47    <#return found> 
48</#function> 
49 
50 
51<#function getProduct channelId productId> 
52    <#return restClient.get("/headless-commerce-delivery-catalog/v1.0/channels/${channelId}/products/${productId}")> 
53</#function> 
54 
55 
56<#function normalize text onlyAccents = false> 
57    <#-- proteger null --> 
58    <#if !text?has_content> 
59        <#return ""> 
60    </#if> 
61 
62    <#assign t = text /> 
63 
64    <#-- quitar acentos --> 
65    <#assign t = t 
66        ?replace("á","a")?replace("é","e")?replace("í","i") 
67        ?replace("ó","o")?replace("ú","u")?replace("ü","u") 
68        ?replace("ñ","n") 
69        ?replace("Á","A")?replace("É","E")?replace("Í","I") 
70        ?replace("Ó","O")?replace("Ú","U")?replace("Ü","U") 
71        ?replace("Ñ","N") 
72    /> 
73 
74    <#-- si NO es solo acentos, normalización completa --> 
75    <#if !onlyAccents> 
76        <#assign t = t?lower_case /> 
77        <#assign t = t?trim /> 
78        <#assign t = t?replace("\\s+", " ", "r") /> 
79    </#if> 
80 
81    <#return t> 
82</#function> 
83 
84 
85 
86 
87<#-- Div with data --> 
88<div id="ecom-scripts"> 
89    <div class="data-product-wrapper" 
90        data-product-id="${productId}" 
91        data-product-erc="${productERC}" 
92        data-product-cpdefinition-id="${cpDefinitionId}" 
93        data-channel-id="${channelId}" 
94        data-product-islibro="${hasProductCategoriaTipoEntidadLibro?c}" 
95        data-product-isnorma="${hasProductCategoriaTipoEntidadNorma?c}" 
96        data-product-iscolecciontematica="${hasProductCategoriaTipoEntidadColeccionTematica?c}" 
97    /> 
98</div> 
99 
100 
101<#-- Script JS --> 
102<script id="ecom-generic-scripts"> 
103 
104    window.ecomGenericScripts = window.ecomGenericScripts || (function () { 
105 
106        /* ===================================== 
107           PROPIEDADES PRIVADAS 
108        ===================================== */ 
109        const privateProps = window.ecomGenericScripts?.privateProps || {}; 
110 
111        /* ===================================== 
112           PROPIEDADES PÚBLICAS 
113        ===================================== */ 
114 
115        const publicProps = { 
116 
117            isDebug: ${isDebug?c} 
118 
119        }; 
120 
121        /* ===================================== 
122           FUNCIONES PÚBLICAS 
123        ===================================== */ 
124 
125        //En functions declaramos la functions publicas 
126        const functions = {}; 
127 
128        functions.init = async function () { 
129            await _DOMContentLoaded(); 
130        }; 
131 
132        /* ===================================== 
133           FUNCIONES PRIVADAS 
134        ===================================== */ 
135 
136        const _loadInit = async function () { 
137            if (publicProps.isDebug) console.log("DOMContentLoaded ecom Generic scripts"); 
138        }; 
139 
140        const _parsePriceText = function (text) { 
141            if (!text) return 0; 
142            const cleaned = String(text) 
143                .replace(/[^\d,.-]/g, "") 
144                .replace(/\./g, "") 
145                .replace(",", "."); 
146            const n = Number(cleaned); 
147            return Number.isFinite(n) ? n : 0; 
148        }; 
149 
150        const _getDetailContext = function (button) { 
151            const wrapper = document.querySelector("#ecom-scripts .data-product-wrapper"); 
152            const purchaseBox = button?.closest(".purchase-box") || document; 
153            const langSelect = purchaseBox.querySelector(".select-language"); 
154            const formatSelect = purchaseBox.querySelector(".select-format"); 
155            const qtyInput = purchaseBox.querySelector(".qty input[type='number']"); 
156            const priceNode = purchaseBox.querySelector(".price-header .price"); 
157            const titleNode = document.querySelector(".ecom-libro .title, .ecom-norma .title, .ecom-coleccion_tematica .title, .title-book"); 
158 
159            return { 
160                wrapper, 
161                purchaseBox, 
162                langSelect, 
163                formatSelect, 
164                qtyInput, 
165                priceNode, 
166                titleNode 
167            }; 
168        }; 
169 
170        const _getDetailData = async function (productERC, productId, isLibro, isNorma, isColeccionTematica) { 
171            if (!window.ecomGlobalScripts?.functions) return null; 
172            try { 
173                if (isLibro) { 
174                    if (productERC) return await window.ecomGlobalScripts.functions.getProductBooksByERC(productERC); 
175                    if (productId) return await window.ecomGlobalScripts.functions.getProductBooks(productId); 
176
177                if (isColeccionTematica) { 
178                    if (productERC) return await window.ecomGlobalScripts.functions.getProductThematicCollectionsByERC(productERC); 
179                    if (productId) return await window.ecomGlobalScripts.functions.getProductThematicCollections(productId); 
180
181                if (isNorma) { 
182                    if (productERC) return await window.ecomGlobalScripts.functions.getProductNormasDetailsByERC(productERC); 
183                    if (productId) return await window.ecomGlobalScripts.functions.getProductNormasDetails(productId); 
184
185            } catch (e) {} 
186            return null; 
187        }; 
188 
189        const _syncDetailBuyButtonState = function (purchaseBox) { 
190            if (!purchaseBox) return; 
191 
192            const btn = purchaseBox.querySelector(".buy-btn"); 
193            if (!btn) return; 
194 
195            const langSelect = purchaseBox.querySelector(".select-language"); 
196            const formatSelect = purchaseBox.querySelector(".select-format"); 
197            const hasSelectors = !!(langSelect || formatSelect); 
198            const hasSelection = !!(langSelect?.value && formatSelect?.value); 
199 
200            if (!hasSelectors) { 
201                if (!btn.disabled) btn.disabled = true; 
202                if (!btn.classList.contains("disabled")) btn.classList.add("disabled"); 
203                return; 
204
205 
206            if (btn.disabled === hasSelection) { 
207                btn.disabled = !hasSelection; 
208
209            btn.classList.toggle("disabled", !hasSelection); 
210        }; 
211 
212        const _setupQtyPriceMultiplier = function (purchaseBox) { 
213            if (!purchaseBox || purchaseBox.dataset.aeQtyMultBound === "true") return; 
214            purchaseBox.dataset.aeQtyMultBound = "true"; 
215 
216            const priceNode = purchaseBox.querySelector(".price-header .price"); 
217            const qtyInput = purchaseBox.querySelector(".qty input[type='number']"); 
218            if (!priceNode || !qtyInput) return; 
219 
220            const formatter = window.ecomGlobalScripts?.properties?.language?.formatterPrice; 
221 
222            const splitPriceAndCurrency = function (text) { 
223                const m = String(text || "").match(/^(.*?)(\s*[^\d.,\s]+)\s*$/); 
224                if (m) return [m[1].trim(), m[2].trim()]; 
225                return [String(text || "").trim(), ""]; 
226            }; 
227 
228            const formatPrice = function (value, currency) { 
229                const numText = formatter 
230                    ? formatter.format(value) 
231                    : value.toLocaleString("es-ES", { minimumFractionDigits: 2, maximumFractionDigits: 2 }); 
232                return currency ? numText + " " + currency : numText; 
233            }; 
234 
235            const captureUnit = function () { 
236                const parts = splitPriceAndCurrency(priceNode.textContent); 
237                const parsed = _parsePriceText(parts[0]); 
238                if (parsed > 0) { 
239                    purchaseBox.dataset.aeUnitPrice = String(parsed); 
240                    purchaseBox.dataset.aeUnitCurrency = parts[1]; 
241
242            }; 
243 
244            let observer; 
245            const startObserve = function () { 
246                observer.observe(priceNode, { childList: true, characterData: true, subtree: true }); 
247            }; 
248 
249            const reapply = function () { 
250                const unit = Number(purchaseBox.dataset.aeUnitPrice) || 0; 
251                if (!unit) return; 
252                const qty = Math.max(1, parseInt(qtyInput.value, 10) || 1); 
253                const total = unit * qty; 
254                const currency = purchaseBox.dataset.aeUnitCurrency || ""; 
255                if (observer) observer.disconnect(); 
256                priceNode.textContent = formatPrice(total, currency); 
257                if (observer) startObserve(); 
258            }; 
259 
260            // Cuando _renderFragmentPrice (en ECOM-Global_functions/index.js) reescribe 
261            // .price con el precio unitario tras cambiar idioma/formato, recapturamos 
262            // el unitario y reaplicamos la cantidad actual. 
263            observer = new MutationObserver(function () { 
264                captureUnit(); 
265                reapply(); 
266            }); 
267            startObserve(); 
268 
269            qtyInput.addEventListener("input", reapply); 
270            qtyInput.addEventListener("change", reapply); 
271 
272            // Captura inicial por si el precio ya estaba renderizado antes de montar el observer. 
273            captureUnit(); 
274            reapply(); 
275        }; 
276 
277        const _bindDetailSelectorState = function (purchaseBox) { 
278            if (!purchaseBox) return; 
279 
280            const sync = function () { 
281                _syncDetailBuyButtonState(purchaseBox); 
282            }; 
283 
284            if (purchaseBox.dataset.aeSelectorsBound === "true") { 
285                sync(); 
286                return; 
287
288 
289            purchaseBox.dataset.aeSelectorsBound = "true"; 
290 
291            purchaseBox.addEventListener("change", function (event) { 
292                if (!event.target?.matches(".select-language, .select-format")) return; 
293                sync(); 
294            }); 
295 
296            const selectorsRoot = purchaseBox.querySelector(".selector-language_format") || purchaseBox; 
297            const observer = new MutationObserver(function () { 
298                sync(); 
299            }); 
300 
301            observer.observe(selectorsRoot, { 
302                childList: true, 
303                subtree: true 
304            }); 
305 
306            sync(); 
307        }; 
308 
309        const _attachDetailAddToCart = async function () { 
310            const buttons = document.querySelectorAll(".purchase-box .buy-btn"); 
311            if (!buttons.length) return; 
312 
313            buttons.forEach(btn => { 
314                _bindDetailSelectorState(btn.closest(".purchase-box")); 
315                _setupQtyPriceMultiplier(btn.closest(".purchase-box")); 
316 
317                if (btn.dataset.aeBound === "true") return; 
318                btn.dataset.aeBound = "true"; 
319 
320                btn.addEventListener("click", (e) => { 
321                    e.preventDefault(); 
322 
323                    const run = async () => { 
324                        const ctx = _getDetailContext(btn); 
325                        const wrapper = ctx.wrapper; 
326                        if (!wrapper) { 
327                            window.dispatchEvent(new CustomEvent("cart:error", { detail: { message: "Error al añadir el producto a la cesta" } })); 
328                            return; 
329
330 
331                        const productId = Number(wrapper.dataset.productId) || null; 
332                        const productERC = wrapper.dataset.productErc || ""; 
333                        const isLibro = wrapper.dataset.productIslibro === "true"; 
334                        const isNorma = wrapper.dataset.productIsnorma === "true"; 
335                        const isColeccionTematica = wrapper.dataset.productIscolecciontematica === "true"; 
336 
337                        const codIdioma = (ctx.langSelect?.value || "").toString(); 
338                        const codFormato = (ctx.formatSelect?.value || "").toString(); 
339 
340                        if ((ctx.langSelect || ctx.formatSelect) && (!codIdioma || !codFormato)) { 
341                            window.dispatchEvent(new CustomEvent("cart:error", { detail: { message: "Selecciona idioma y formato" } })); 
342                            return; 
343
344 
345                        const entryType = isLibro ? "Libro" : isColeccionTematica ? "Colección Temática" : "Norma"; 
346                        const code = productERC || (ctx.titleNode?.textContent || "").trim(); 
347                        const qty = Math.max(1, parseInt(ctx.qtyInput?.value, 10) || 1); 
348                        const name = (ctx.titleNode?.textContent || "").trim(); 
349 
350                        let priceToSend = 0; 
351                        const data = await _getDetailData(productERC, productId, isLibro, isNorma, isColeccionTematica); 
352                        if (Array.isArray(data) && data.length && codIdioma && codFormato) { 
353                            const entry = data.find(item => { 
354                                const langKey = item.codLanguage ?? item.language; 
355                                const fmtKey = item.codFormat ?? item.format; 
356                                return String(langKey) === codIdioma && String(fmtKey) === codFormato; 
357                            }); 
358                            if (entry) { 
359                                const basePrice = Number(entry.price) || 0; 
360                                const discount = Number(entry.webDiscount) || 0; 
361                                priceToSend = discount > 0 ? Math.floor((basePrice - (basePrice * discount / 100)) * 100) / 100 : basePrice; 
362
363
364                        if (!priceToSend && ctx.priceNode) { 
365                            priceToSend = _parsePriceText(ctx.priceNode.textContent); 
366
367 
368                        await window.ecomGlobalScripts.functions.addToCart({ 
369                            entryType: entryType, 
370                            code: code, 
371                            codIdioma: codIdioma, 
372                            codFormato: codFormato, 
373                            price: priceToSend, 
374                            name: name, 
375                            amount: qty 
376                        }); 
377                    }; 
378 
379                    const helper = window.ecomGlobalScripts?.functions?.withButtonSpinner; 
380                    if (typeof helper === "function") { 
381                        return helper(btn, run, { spinnerClass: "spinner-border spinner-border-sm text-light", mode: "replace" }); 
382
383                    return run(); 
384                }); 
385            }); 
386        }; 
387 
388        const _DOMContentLoaded = async function() { 
389            //Event DOMContentLoaded 
390            if (publicProps.isDebug) console.log("DOMContentLoaded ecom Generic scripts"); 
391 
392            if (publicProps.isDebug) console.log("DOMContentLoaded ecom Generic scripts - _loadInit execute"); 
393            await _loadInit(); 
394 
395            await _attachDetailAddToCart(); 
396 
397        }; 
398 
399        // ---- Execute Listener DOMContentLoaded ---- 
400        document.addEventListener("DOMContentLoaded", async function() { 
401            await _DOMContentLoaded(); 
402        }); 
403 
404        /* ===================================== 
405           API PÚBLICA 
406        ===================================== */ 
407        return { 
408            properties: publicProps, // properties public 
409            functions: functions     // functions public 
410        }; 
411 
412    })(); 
413 
414</script> 

UNE-EN ISO 12999-1:2021