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>










