/o//commerce-media/accounts/-1/images/18578837?download=true
The following has evaluated to null or missing: ==> filteredProductImages[0] [in template "3192443#3192485#null" at line 27, column 31] ---- Tip: It's the final [] step 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: ${filteredProductImages[0].title?html} [in template "3192443#3192485#null" at line 27, column 29] ----
1<#assign
2 channel = restClient.get("/headless-commerce-delivery-catalog/v1.0/channels?accountId=-1&filter=siteGroupId eq '${themeDisplay.getScopeGroupId()}'")
3
4 productImagesResponse = restClient.get(
5 "/headless-commerce-delivery-catalog/v1.0/channels/" + channel.items[0].id +
6 "/products/" + CPDefinition_cProductId.getData() + "/images?accountId=-1"
7 )
8
9 allProductImages = productImagesResponse.items![]
10 filteredProductImages = []
11>
12
13<#list allProductImages as image>
14 <#if image.galleryEnabled?? && image.galleryEnabled>
15 <#assign filteredProductImages += [image] />
16 </#if>
17</#list>
18
19<#assign totalCount = filteredProductImages?size >
20
21<div class = "carousel-container">
22 <div class = "main-image-wrapper">
23 <button class = "nav-button prev" aria-label = "Previous Image">
24 <span class = "lexicon-icon-overwide"> <@clay["icon"] symbol = "angle-left" /></span>
25 </button>
26
27 <img alt = "${filteredProductImages[0].title?html}" id = "main-image" src = "${(filteredProductImages[0].src?replace("https://", "http://"))}" />
28
29 <button class="nav-button next" aria-label="Next Image">
30 <span class="lexicon-icon-overwide"> <@clay["icon"] symbol="angle-right" /></span>
31 </button>
32 </div>
33
34 <div class="thumbnails-wrapper">
35 <div class="thumbnails align-items-center"></div>
36
37 <#assign count = (totalCount?default(0)?number) />
38
39 <#if count gt 5>
40 <button class="view-full-gallery">
41 <span class="title">
42 ${languageUtil.get(locale, "full-gallery", "Full Gallery")}
43 </span>
44 <span class="subtitle">
45 ${count} ${languageUtil.get(locale, "photos", "Photos")}
46 </span>
47 </button>
48 </#if>
49 </div>
50 </div>
51
52<template id="modal-gallery">
53 <div class="modal-gallery-content">
54 <button class="modal-prev" data-role="modal-prev">
55 <@clay["icon"] symbol="angle-left" />
56 </button>
57
58 <img class="modal-image" data-role="modal-image" />
59
60 <button class="modal-next" data-role="modal-next">
61 <@clay["icon"] symbol="angle-right" />
62 </button>
63 </div>
64</template>
65
66<script ${nonceAttribute}>
67(function () {
68 let currentIndex = 0;
69 let images = [];
70
71 const carouselNextBtn = document.querySelector('.nav-button.next');
72 const carouselPrevBtn = document.querySelector('.nav-button.prev');
73 const carouselMainImage = document.getElementById('main-image');
74 const thumbnailsContainer = document.querySelector('.thumbnails');
75 const viewFullGalleryBtn = document.querySelector('.view-full-gallery');
76
77 function loadImages() {
78 images = [
79 <#list filteredProductImages as image>
80 {
81 src: "${(image.src?replace('https://', 'http://'))?js_string}",
82 alt: "${image.title?html?js_string}"
83 }<#if image_has_next>,</#if>
84 </#list>
85 ]
86 }
87
88 function renderThumbnails() {
89 const maxVisible = 5;
90 let start = currentIndex - 2;
91
92 if (start < 0) start = 0;
93 if (start > images.length - maxVisible) start = Math.max(images.length - maxVisible, 0);
94
95 const end = Math.min(images.length, start + maxVisible);
96
97 thumbnailsContainer.innerHTML = '';
98
99 for (let i = start; i < end; i++) {
100 const img = document.createElement('img');
101 img.className = 'thumbnail' + (i === currentIndex ? ' selected' : '');
102 img.src = images[i].src;
103 img.alt = images[i].alt;
104 img.dataset.index = i;
105 img.addEventListener('click', () => updateMainImage(i));
106 thumbnailsContainer.appendChild(img);
107 }
108 }
109
110 function updateMainImage(index) {
111 currentIndex = index;
112 carouselMainImage.src = images[index].src;
113 carouselMainImage.alt = images[index].alt;
114
115 carouselPrevBtn.disabled = index === 0;
116 carouselNextBtn.disabled = index === images.length - 1;
117
118 renderThumbnails();
119 }
120
121 function setupNavigationButtons() {
122 carouselPrevBtn.addEventListener('click', () => {
123 if (currentIndex > 0) updateMainImage(currentIndex - 1);
124 });
125
126 carouselNextBtn.addEventListener('click', () => {
127 if (currentIndex < images.length - 1) updateMainImage(currentIndex + 1);
128 });
129 }
130
131 function setupModalTriggers() {
132 carouselMainImage.addEventListener('click', () => openModalGallery(currentIndex));
133 if (viewFullGalleryBtn) {
134 viewFullGalleryBtn.addEventListener('click', () => openModalGallery(currentIndex));
135 }
136 }
137
138 function openModalGallery(startIndex) {
139 let current = startIndex;
140
141 const template = document.getElementById('modal-gallery');
142 const clone = template.content.cloneNode(true);
143 const container = document.createElement('div');
144 container.appendChild(clone);
145
146 Liferay.Util.openModal({
147 bodyHTML: container.innerHTML,
148 center: true,
149 headerHTML: '<h2 class="modal-gallery-header" id="modal-header-title"><@clay["icon"] symbol="picture"/> ${languageUtil.get(locale, "Image")} <span id="modal-index-display"></span></h2>',
150 size: "full-screen",
151 onOpen: () => {
152 const modalContainer = document.querySelector('.modal-content');
153 if (modalContainer) {
154 modalContainer.classList.add('custom-gallery-modal');
155 }
156
157 const modalImage = document.querySelector('[data-role="modal-image"]');
158 const modalNext = document.querySelector('[data-role="modal-next"]');
159 const modalPrev = document.querySelector('[data-role="modal-prev"]');
160 const indexDisplay = document.getElementById('modal-index-display');
161
162 function updateModalImage(index) {
163 const img = images[index];
164 modalImage.src = img.src;
165 modalImage.alt = img.alt;
166
167 modalNext.disabled = index === images.length - 1;
168 modalPrev.disabled = index === 0;
169
170 if (indexDisplay) {
171 indexDisplay.textContent = (index + 1) + ' ${languageUtil.get(locale, "of")} ' + images.length;
172 }
173 }
174
175 modalNext.addEventListener('click', () => {
176 if (current < images.length - 1) {
177 current++;
178 updateModalImage(current);
179 }
180 });
181
182 modalPrev.addEventListener('click', () => {
183 if (current > 0) {
184 current--;
185 updateModalImage(current);
186 }
187 });
188
189 updateModalImage(current);
190 }
191 });
192 }
193
194 function main() {
195 loadImages();
196 setupNavigationButtons();
197 setupModalTriggers();
198 updateMainImage(0);
199 }
200
201 main();
202})();
203</script>
204
205<style ${nonceAttribute}>
206.carousel-container img {
207 cursor: pointer;
208 object-fit: contain;
209}
210
211.custom-gallery-modal button:disabled {
212 cursor: default;
213 opacity: 0.4;
214 pointer-events: none;
215}
216
217.custom-gallery-modal {
218 background-color: #282934 !important;
219 border-bottom: none;
220 color: white !important;
221}
222
223.custom-gallery-modal .liferay-modal-body {
224 align-items: center;
225 display: flex;
226 justify-content: center;
227 position: relative;
228}
229
230.custom-gallery-modal .close {
231 color: white !important;
232 margin-right: 16px !important;
233}
234
235.lexicon-icon-overwide .lexicon-icon {
236 height: 2em;
237 margin: 0px !important;
238}
239
240.custom-gallery-modal{
241 height: 80% !important;
242}
243
244.main-image-wrapper {
245 align-items: center;
246 display:flex;
247 justify-content: center;
248 position: relative;
249 width: 902px;
250 height: 454px;
251}
252
253.main-image-wrapper img {
254 border-radius: 8px;
255 max-height: 100%;
256}
257
258.main-image-wrapper:hover .nav-button {
259 opacity: 1;
260 pointer-events: auto;
261}
262
263.main-image-wrapper:hover .nav-button:disabled{
264 cursor: default;
265 opacity: 0.4;
266}
267
268.modal-image {
269 aspect-ratio: 16/9;
270 object-fit: contain;;
271 border-radius: 8px;
272 max-width: 100vh !important;
273}
274
275.modal-gallery-header .lexicon-icon {
276 fill: #FFC124 !important;
277 margin-right: 8px !important;
278 width: 16px !important;
279}
280
281.modal-gallery-header {
282
283 padding: 16px !important;
284}
285
286.modal-prev,
287.modal-next {
288 align-items: center;
289 background: rgba(105, 102, 102, 0.4) !important;
290 border-radius: 50%;
291 border: none;
292 display: flex;
293 font-size: 1.6rem;
294 justify-content: center;
295 padding: 14px !important;
296 position: absolute;
297 top: 45%;
298}
299
300.modal-prev .lexicon-icon,
301.modal-next .lexicon-icon {
302 margin-top: 0px !important;
303}
304
305.modal-next {
306 right: 24px;
307}
308
309.modal-prev {
310 left: 24px;
311}
312
313.nav-button {
314 background: rgba(0,0,0,0.4);
315 border-radius: 50%;
316 border: none;
317 color: white;
318 cursor: pointer;
319 font-size: 1rem;
320 opacity: 0;
321 padding: 0 8px;
322 position: absolute;
323 top: 50%;
324 transform: translateY(-50%);
325 transition: opacity 0.3s ease;
326 user-select: none;
327}
328
329.nav-button.prev {
330 left: 10px;
331}
332
333.nav-button.next {
334 right: 10px;
335}
336
337.thumbnail {
338 border-radius: 12px;
339 border: 2px solid transparent;
340 cursor: pointer;
341 height: 86px;
342 object-fit: cover;
343 opacity: 0.6;
344 transition:
345 border-color 250ms ease-out,
346 opacity 250ms ease-out;
347 width: 142px;
348}
349
350.thumbnail.selected,
351.thumbnail:hover {
352 border-color: #8FB5FF;
353 opacity: 1;
354}
355
356.thumbnail.selected{
357 height: 102px;
358}
359
360.thumbnails {
361 display: flex;
362 gap: 8px;
363 overflow-x: auto;
364}
365
366.thumbnails-wrapper {
367 align-items: center;
368 display: flex;
369 justify-content: flex-start;
370 margin-top: 24px;
371 max-height: 86px;
372 max-width: 902px;
373}
374
375.view-full-gallery {
376 background-color: white;
377 border-radius: 12px;
378 border: 1px solid #E2E2E4;
379 color: #2563eb;
380 cursor: pointer;
381 display: flex;
382 flex-direction: column;
383 height: 86px;
384 justify-content: center;
385 margin-left: 8px;
386 min-width: 152px;
387 transition: background-color 0.3s ease,
388 box-shadow 0.3s ease,
389 border-color 250ms ease-out;
390}
391
392.view-full-gallery .subtitle {
393 color: #6b7280;
394 font-size: 12px;
395 font-weight: 400;
396 line-height: 1;
397}
398
399.view-full-gallery .title {
400 font-size: 16px;
401 font-weight: 600;
402 line-height: 1;
403 margin-bottom: 4px;
404}
405
406.view-full-gallery:hover {
407 background-color: #f3f4f6;
408 box-shadow: 0 2px 4px rgb(0 0 0 / 0.1);
409 border: 2px solid rgb(143, 181, 255);
410}
411</style>
LAM is a component that lets you define your configuration and 'tech' content in a DSL and store this properly alongside the code for your project. Just deploy the scripts as a module and the component will make sure the portal is updated with the current version of the DSL scripts. It is quite similar to existing tooling in the SQL world, like Liquibase or Flyway.
In real life, projects that implement a Liferay portal will often have to deal not only with a few particular portlets, or hooks, but deliver the whole package of a working, interacting site. So this also includes configuring roles, user groups, custom fields, page sets, structures, templates etc. There are some tools/techniques available in Liferay but they do not cover the whole spectrum.
LAM is the answer to this burden.
It is a solution both for the flow of the various stages of production deployment (development -> test -> acceptance -> production) as well as for synchronizing the development between project members.
To use it, please follow the steps described in the documentation at https://github.com/finalist/liferay-lam/blob/master/README.md#usage
LAM is a component that lets you define your configuration and 'tech' content in a DSL and store this properly alongside the code for your project. Just deploy the scripts as a module and the component will make sure the portal is updated with the current version of the DSL scripts. It is quite similar to existing tooling in the SQL world, like Liquibase or Flyway.
In real life, projects that implement a Liferay portal will often have to deal not only with a few particular portlets, or hooks, but deliver the whole package of a working, interacting site. So this also includes configuring roles, user groups, custom fields, page sets, structures, templates etc. There are some tools/techniques available in Liferay but they do not cover the whole spectrum.
LAM is the answer to this burden.
It is a solution both for the flow of the various stages of production deployment (development -> test -> acceptance -> production) as well as for synchronizing the development between project members.
To use it, please follow the steps described in the documentation at https://github.com/finalist/liferay-lam/blob/master/README.md#usage
Developer
Publisher Date
January 23, 2024
Deployment Method
Liferay Self-Hosted
Liferay PaaS
App Type
Version
Supported Versions
Standard Price
Help and Support
Share Link
HTML Example
A paragraph is a self-contained unit of a discourse in writing dealing with a particular point or idea. Paragraphs are usually an expected part of formal writing, used to organize longer prose.