Commit: f0438c2

Commit Details

SHAf0438c2cbc2d838bb66a4d8129dd25b5d48d28ee
Treeb8e53d050248c8b632fa3de86003489acae4670b
Author<f69e50@finnacloud.com> 1766443042 +0300
Committer<f69e50@finnacloud.com> 1766443042 +0300
Message
increment once more
GPG Signature
-----BEGIN PGP SIGNATURE-----

iQJSBAABCAA8FiEEWJb139mJI+vZ81KkoAIVSUsXI0oFAmlJyCIeHHNvcGhpYS5l
cmFzbGFuQGZpbm5hY2xvdWQuY29tAAoJEKACFUlLFyNKtrgP/04FZPSa4Hmx9WpR
gOamzjIXc+1+pBvGQDairLZU6rtDSgNkoryrsqOOLIXZWkkxZ70q8/aXQBFThr+t
YyDIz85u9GiwPOV8o1X4sxzF7bupOd6YmOOPqS2vco1aibpB8w9N9EwlMzW95Otw
vnQ/h0kz8MTp0wfXeRJqvLg8DVPnLgW70ly1TZQe19jEA4NwBZOyK2ksTis8iycX
HUM4WW8Velo8O+OtygAbwISr2dILKvkclTLn9kgfpN5esvBvJu4K+xA5T5alDchc
PY3FFeZteQf3GX4v0EH8K3c/q8OcQFMxuRGO31uYGqNbjbOB7zdZVs3N0B1m1efv
vhCgZ6gyxlz0kiozgO6Sx5WFOzHuTlguCK+x0JOtzCokRL/VLiPzmW5Umt280eJz
AlD7u7Dah3khoqDkzXfCL1lxch9tRMVMUYWRMayxC3iculCFM6OCLgzY9hay5y1j
WP1ZOdy/x9jKPoLv5USnw0KlH4rZIHH+aDMDEzWmWUWYDpShylG7sKafXg75bnho
5LbAfCucnDrkjN+rx1dxaKrZswQlIJ8iZ5Q6DzHnDzl4TaFcN1E3exS+xp46H7va
SbR94UfpCyjpTQ2hHdOS7XHINFUDIzWF3mUZ8gqyEANiqpgJ+RcOGX66s69oILpr
vD20SIdMHWDqJH3G6GMNsg1g5/QU
=YrcG
-----END PGP SIGNATURE-----

โœ“ Verified

File: src/main/resources/static/js/app.js

1 const API_BASE = '/api';
2
3 function getOrCreateSessionId() {
4 let sessionId = localStorage.getItem('cart_session_id');
5 if (!sessionId) {
6 sessionId = 'sess_' + Math.random().toString(36).substr(2, 9) + Date.now().toString(36);
7 localStorage.setItem('cart_session_id', sessionId);
8 }
9 return sessionId;
10 }
11
12
13 // Cart management (localStorage)
14 const cart = {
15 get() {
16 try {
17 return JSON.parse(localStorage.getItem('cart') || '[]');
18 } catch {
19 return [];
20 }
21 },
22 set(items) {
23 localStorage.setItem('cart', JSON.stringify(items));
24 updateCartCount();
25 },
26 add(product, quantity = 1) {
27 const items = this.get();
28 // Convert both to strings for comparison to handle type mismatches
29 const existing = items.find(item => String(item.productId) === String(product.id));
30
31 if (existing) {
32 existing.quantity += quantity;
33 } else {
34 items.push({
35 productId: product.id,
36 productName: product.name,
37 price: product.price,
38 currency: product.currency,
39 image: product.image,
40 quantity
41 });
42 }
43
44 this.set(items);
45 },
46 remove(productId) {
47 const items = this.get().filter(item => String(item.productId) !== String(productId));
48 this.set(items);
49 },
50 update(productId, quantity) {
51 const items = this.get();
52 // Convert both to strings for comparison to handle type mismatches
53 const item = items.find(i => String(i.productId) === String(productId));
54 if (item) {
55 if (quantity <= 0) {
56 this.remove(productId);
57 } else {
58 item.quantity = quantity;
59 this.set(items);
60 }
61 }
62 },
63 clear() {
64 this.set([]);
65 }
66 };
67
68 // Current page for pagination
69 let currentPage = 1;
70 const pageSize = 12;
71
72 // Load products on page load
73 document.addEventListener('DOMContentLoaded', async () => {
74 await checkAndShowRegionSelector();
75 await loadProducts(currentPage);
76 setupModal();
77 updateCartCount();
78 });
79
80 function updateCartCount() {
81 const count = cart.get().reduce((sum, item) => sum + item.quantity, 0);
82 const cartBadge = document.getElementById('cartCount');
83 if (cartBadge) {
84 cartBadge.textContent = count;
85 cartBadge.style.display = count > 0 ? 'flex' : 'none';
86 }
87 }
88
89 async function loadProducts(page = 1) {
90 const loading = document.getElementById('loading');
91 const error = document.getElementById('error');
92 const grid = document.getElementById('productsGrid');
93
94 try {
95 loading.style.display = 'block';
96 error.style.display = 'none';
97 grid.innerHTML = '';
98
99 // Get user's selected region for currency conversion
100 const selectedRegion = getSelectedRegion();
101 const headers = {};
102 if (selectedRegion) {
103 headers['X-User-Region'] = selectedRegion.code;
104 }
105
106 const response = await fetch(`${API_BASE}/products?page=${page}&size=${pageSize}`, { headers });
107 const data = await response.json();
108
109 if (!data.success) {
110 throw new Error(data.error || 'Failed to load products');
111 }
112
113 currentPage = page;
114
115 // Convert prices if needed
116 if (selectedRegion && data.products) {
117 data.products = await convertProductPrices(data.products, selectedRegion.currency);
118 }
119
120 if (data.products.length === 0) {
121 grid.innerHTML = '<p style="text-align: center; grid-column: 1/-1; padding: 2rem;">No products available. Check back soon!</p>';
122 } else {
123 data.products.forEach(product => {
124 grid.appendChild(createProductCard(product));
125 });
126 }
127
128 loading.style.display = 'none';
129 } catch (err) {
130 loading.style.display = 'none';
131 error.style.display = 'block';
132 error.textContent = `Error: ${err.message}`;
133 }
134 }
135
136 function createProductCard(product) {
137 const card = document.createElement('div');
138 card.className = 'product-card';
139
140 const image = product.image || '';
141 const imageElement = image && image.trim() && (image.startsWith('http') || image.startsWith('/'))
142 ? `<img src="${image}" alt="${product.name}" class="product-image" onerror="this.style.display='none'; this.nextElementSibling.style.display='flex';">`
143 : '';
144
145 const stockInfo = getStockInfo(product.stock);
146
147 card.innerHTML = `
148 ${imageElement}
149 ${!imageElement ? '<div class="product-image">No Image</div>' : ''}
150 <div class="product-info">
151 <h3 class="product-name">${escapeHtml(product.name)}</h3>
152 <p class="product-description">${escapeHtml(product.description || '')}</p>
153 <div class="product-price">${product.currency} ${(product.price / 100).toFixed(2)}</div>
154 ${stockInfo}
155 <button class="btn" onclick="addToCart('${product.id}', this)" ${product.stock === 0 ? 'disabled' : ''}>
156 ${product.stock === 0 ? 'Out of Stock' : 'Add to Cart'}
157 </button>
158 </div>
159 `;
160
161 return card;
162 }
163
164 function getStockInfo(stock) {
165 if (stock === null) return '';
166 if (stock === 0) return '<div class="product-stock out">Out of Stock</div>';
167 if (stock < 10) return `<div class="product-stock low">Only ${stock} left</div>`;
168 return `<div class="product-stock">${stock} in stock</div>`;
169 }
170
171 function escapeHtml(text) {
172 const div = document.createElement('div');
173 div.textContent = text;
174 return div.innerHTML;
175 }
176
177 async function addToCart(productId, element) {
178 try {
179 const sessionId = getOrCreateSessionId();
180
181 // Calculate new total quantity
182 const currentCart = cart.get();
183 const existingItem = currentCart.find(i => i.productId === productId);
184 const newQuantity = (existingItem ? existingItem.quantity : 0) + 1;
185
186 // Try to reserve stock first
187 const reserveResponse = await fetchWithCsrf(`${API_BASE}/cart/reserve`, {
188 method: 'POST',
189 headers: {
190 'Content-Type': 'application/json',
191 'x-session-id': sessionId
192 },
193 body: JSON.stringify({ productId, quantity: newQuantity })
194 });
195
196 const reserveData = await reserveResponse.json();
197 if (!reserveData.success) {
198 throw new Error(reserveData.error || 'Failed to reserve stock');
199 }
200
201 const response = await fetch(`${API_BASE}/products/${productId}`);
202 const data = await response.json();
203
204 if (!data.success) {
205 throw new Error(data.error || 'Product not found');
206 }
207
208 const product = data.product;
209
210 // Check stock
211 if (product.stock !== null && product.stock <= 0) {
212 showNotification('This product is out of stock', 'warning');
213 return;
214 }
215
216 cart.add(product, 1);
217
218 // Show feedback
219 const btn = element;
220 const originalText = btn.textContent;
221 btn.textContent = 'Added!';
222 btn.disabled = true;
223 setTimeout(() => {
224 btn.textContent = originalText;
225 btn.disabled = false;
226 }, 1000);
227
228 showNotification('Product added to cart!', 'success', 3000);
229 } catch (err) {
230 showNotification(`Error: ${err.message}`, 'error');
231 throw err;
232 }
233 }
234
235 let currentProduct = null;
236
237 async function openProductModal(productId) {
238 const modal = document.getElementById('productModal');
239 const content = document.getElementById('modalContent');
240
241 try {
242 const response = await fetch(`${API_BASE}/products/${productId}`);
243 const data = await response.json();
244
245 if (!data.success) {
246 throw new Error(data.error || 'Product not found');
247 }
248
249 currentProduct = data.product;
250 content.innerHTML = createModalContent(data.product);
251 modal.style.display = 'block';
252 // Re-attach close button after content update
253 const closeBtn = modal.querySelector('.close');
254 if (closeBtn) {
255 closeBtn.onclick = closeModal;
256 }
257 } catch (err) {
258 showNotification(`Error: ${err.message}`, 'error');
259 }
260 }
261
262 function createModalContent(product) {
263 const image = product.image || '';
264 const imageElement = image && image.trim() && (image.startsWith('http') || image.startsWith('/'))
265 ? `<img src="${image}" alt="${product.name}" style="width: 100%; max-height: 300px; object-fit: cover; border-radius: 8px; margin-bottom: 1.5rem;" onerror="this.style.display='none'; this.nextElementSibling.style.display='flex';">`
266 : '';
267
268 return `
269 <h2 style="margin-bottom: 1rem;">${escapeHtml(product.name)}</h2>
270 ${imageElement}
271 ${!imageElement ? '<div style="width: 100%; height: 300px; display: flex; align-items: center; justify-content: center; background: #f0f0f0; color: #999; border-radius: 8px; margin-bottom: 1.5rem;">No Image</div>' : ''}
272 <p style="margin-bottom: 1rem; color: #666;">${escapeHtml(product.description || '')}</p>
273 <div style="font-size: 2rem; font-weight: 700; color: var(--primary-color); margin-bottom: 1.5rem;">
274 ${product.currency} ${(product.price / 100).toFixed(2)}
275 </div>
276
277 <div class="form-group">
278 <label>Quantity</label>
279 <input type="number" id="productQuantity" min="1" value="1" max="${product.stock || ''}" style="width: 100px;">
280 </div>
281
282 <button type="button" class="btn btn-success" onclick="addToCartFromModal('${product.id}')">
283 Add to Cart
284 </button>
285 `;
286 }
287
288 async function addToCartFromModal(productId) {
289 const quantity = parseInt(document.getElementById('productQuantity').value) || 1;
290
291 try {
292 const sessionId = getOrCreateSessionId();
293
294 // Calculate new total quantity
295 const currentCart = cart.get();
296 const existingItem = currentCart.find(i => i.productId === productId);
297 const newQuantity = (existingItem ? existingItem.quantity : 0) + quantity;
298
299 // Try to reserve stock first
300 const reserveResponse = await fetchWithCsrf(`${API_BASE}/cart/reserve`, {
301 method: 'POST',
302 headers: {
303 'Content-Type': 'application/json',
304 'x-session-id': sessionId
305 },
306 body: JSON.stringify({ productId, quantity: newQuantity })
307 });
308
309 const reserveData = await reserveResponse.json();
310 if (!reserveData.success) {
311 throw new Error(reserveData.error || 'Failed to reserve stock');
312 }
313
314 const response = await fetch(`${API_BASE}/products/${productId}`);
315 const data = await response.json();
316
317 if (!data.success) {
318 throw new Error(data.error || 'Product not found');
319 }
320
321 const product = data.product;
322
323 // Check stock
324 if (product.stock !== null && product.stock < quantity) {
325 showNotification(`Only ${product.stock} available in stock`, 'warning');
326 return;
327 }
328
329 cart.add(product, quantity);
330 closeModal();
331
332 // Show notification
333 showNotification(`Added ${quantity} item(s) to cart!`, 'success', 3000);
334 } catch (err) {
335 showNotification(`Error: ${err.message}`, 'error');
336 }
337 }
338
339 function setupModal() {
340 const modal = document.getElementById('productModal');
341 const closeBtn = modal?.querySelector('.close');
342
343 if (closeBtn) {
344 closeBtn.onclick = closeModal;
345 }
346
347 if (modal) {
348 // Close on overlay click (outside modal content)
349 modal.addEventListener('click', function(event) {
350 if (event.target === modal) {
351 closeModal();
352 }
353 });
354
355 // Close on Escape key
356 document.addEventListener('keydown', function(event) {
357 if (event.key === 'Escape' && modal.style.display === 'block') {
358 closeModal();
359 }
360 });
361 }
362 }
363
364 function closeModal() {
365 const modal = document.getElementById('productModal');
366 if (modal) {
367 modal.style.display = 'none';
368 currentProduct = null;
369 }
370 }
371
372 // Region Selector
373 async function checkAndShowRegionSelector() {
374 const selectedRegion = getSelectedRegion();
375
376 if (!selectedRegion) {
377 // First visit, show region selector
378 await showRegionSelector();
379 } else {
380 // Update current region display
381 updateRegionDisplay(selectedRegion);
382 }
383 }
384
385 // Store regions globally for filtering
386 let allRegions = [];
387
388 async function showRegionSelector() {
389 const modal = document.getElementById('regionSelectorModal');
390 const selector = document.getElementById('regionSelector');
391
392 modal.style.display = 'flex';
393
394 try {
395 const response = await fetch(`${API_BASE}/regions/enabled`);
396 const data = await response.json();
397
398 if (!data.success) {
399 throw new Error('Failed to load regions');
400 }
401
402 allRegions = data.regions;
403
404 if (allRegions.length === 0) {
405 selector.innerHTML = '<p style="text-align: center; color: var(--text-light);">No regions available at the moment.</p>';
406 return;
407 }
408
409 // Sort regions alphabetically
410 allRegions.sort((a, b) => a.countryName.localeCompare(b.countryName));
411
412 // Render the region selector with search
413 renderRegionSelector(allRegions);
414
415 } catch (err) {
416 selector.innerHTML = `<div class="error">Error: ${err.message}</div>`;
417 }
418 }
419
420 async function renderRegionSelector(regions) {
421 const selector = document.getElementById('regionSelector');
422
423 // SVG search icon
424 const searchIcon = `
425 <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
426 <circle cx="11" cy="11" r="8"></circle>
427 <line x1="21" y1="21" x2="16.65" y2="16.65"></line>
428 </svg>
429 `;
430
431 // Fetch flags for all regions
432 const countryCodes = regions.map(r => r.countryCode);
433 const flags = await fetchFlags(countryCodes);
434
435 selector.innerHTML = `
436 <div class="region-search-container">
437 <div class="region-search-icon">${searchIcon}</div>
438 <input
439 type="text"
440 id="regionSearch"
441 class="region-search-input"
442 placeholder="Search for a country or currency..."
443 autocomplete="off"
444 />
445 </div>
446
447 <div class="region-list-container">
448 <div class="region-list" id="allRegionsList">
449 ${regions.map(region => createRegionOption(region, flags[region.countryCode])).join('')}
450 </div>
451
452 <div id="noResults" class="no-results" style="display: none;">
453 <p>No regions found matching your search</p>
454 <small>Try searching by country name or currency code</small>
455 </div>
456 </div>
457 `;
458
459 // Add search functionality
460 const searchInput = document.getElementById('regionSearch');
461 searchInput.addEventListener('input', (e) => filterRegions(e.target.value));
462
463 // Focus search input
464 setTimeout(() => searchInput.focus(), 100);
465 }
466
467 // Fetch flags from API
468 async function fetchFlags(countryCodes) {
469 try {
470 const response = await fetch(`${API_BASE}/flags/batch`, {
471 method: 'POST',
472 headers: {
473 'Content-Type': 'application/json'
474 },
475 body: JSON.stringify({ countryCodes })
476 });
477
478 const data = await response.json();
479
480 if (data.success) {
481 return data.flags;
482 }
483 } catch (err) {
484 console.error('Failed to fetch flags:', err);
485 }
486
487 return {};
488 }
489
490 function createRegionOption(region, flagData) {
491 const arrowSvg = `
492 <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
493 <polyline points="9 18 15 12 9 6"></polyline>
494 </svg>
495 `;
496
497 // Use flag if available, otherwise fallback SVG
498 const flagContent = flagData && flagData.startsWith('data:image')
499 ? `<img src="${flagData}" alt="${region.countryCode}" class="region-flag-img" />`
500 : flagData || `<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
501 <circle cx="12" cy="12" r="10"></circle>
502 <line x1="2" y1="12" x2="22" y2="12"></line>
503 <path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"></path>
504 </svg>`;
505
506 return `
507 <div class="region-option"
508 onclick="selectRegion('${region.countryCode}', '${escapeHtml(region.countryName)}', '${region.languageCode}', '${region.currencyCode}')"
509 data-country="${region.countryName.toLowerCase()}"
510 data-currency="${region.currencyCode.toLowerCase()}"
511 data-code="${region.countryCode.toLowerCase()}">
512 <div class="region-icon">${flagContent}</div>
513 <div class="region-info">
514 <div class="region-name">${escapeHtml(region.countryName)}</div>
515 <div class="region-details">${region.currencyCode} ยท ${region.languageCode.toUpperCase()}</div>
516 </div>
517 <div class="region-arrow">${arrowSvg}</div>
518 </div>
519 `;
520 }
521
522 function filterRegions(searchTerm) {
523 const term = searchTerm.toLowerCase().trim();
524 const allOptions = document.querySelectorAll('.region-option');
525 const noResults = document.getElementById('noResults');
526 const regionList = document.getElementById('allRegionsList');
527 let visibleCount = 0;
528
529 if (!term) {
530 // Show all regions
531 allOptions.forEach(option => option.style.display = 'flex');
532 noResults.style.display = 'none';
533 if (regionList) regionList.style.display = 'flex';
534 return;
535 }
536
537 // Filter regions
538 allOptions.forEach(option => {
539 const country = option.dataset.country || '';
540 const currency = option.dataset.currency || '';
541 const code = option.dataset.code || '';
542
543 const matches = country.includes(term) || currency.includes(term) || code.includes(term);
544
545 if (matches) {
546 option.style.display = 'flex';
547 visibleCount++;
548 } else {
549 option.style.display = 'none';
550 }
551 });
552
553 // Show/hide no results message
554 if (visibleCount === 0) {
555 noResults.style.display = 'block';
556 if (regionList) regionList.style.display = 'none';
557 } else {
558 noResults.style.display = 'none';
559 if (regionList) regionList.style.display = 'flex';
560 }
561 }
562
563 function getSelectedRegion() {
564 const stored = localStorage.getItem('selectedRegion');
565 return stored ? JSON.parse(stored) : null;
566 }
567
568 async function convertProductPrices(products, targetCurrency) {
569 if (!targetCurrency || targetCurrency === 'USD') {
570 return products; // No conversion needed
571 }
572
573 // Get exchange rate
574 try {
575 const response = await fetch(`${API_BASE}/currency/rate/USD/${targetCurrency}`);
576 const data = await response.json();
577
578 if (data.success && data.rate) {
579 // Convert each product price
580 return products.map(product => ({
581 ...product,
582 price: Math.round(product.price * data.rate),
583 currency: targetCurrency,
584 originalPrice: product.price,
585 originalCurrency: 'USD'
586 }));
587 }
588 } catch (err) {
589 console.error('Failed to convert prices:', err);
590 }
591
592 return products;
593 }
594
595 function selectRegion(code, name, language, currency) {
596 const region = { code, name, language, currency };
597 localStorage.setItem('selectedRegion', JSON.stringify(region));
598
599 // Close modal
600 document.getElementById('regionSelectorModal').style.display = 'none';
601
602 // Update display
603 updateRegionDisplay(region);
604
605 // Show notification
606 showNotification(`Welcome! Shopping in ${name} (${currency})`, 'success', 4000);
607
608 // Reload products with new region
609 loadProducts(1);
610 }
611
612 function updateRegionDisplay(region) {
613 const display = document.getElementById('currentRegion');
614 if (display) {
615 display.textContent = `${region.name} (${region.currency})`;
616 }
617 }
618
619 // Make functions available globally
620 window.cart = cart;
621 window.addToCart = addToCart;
622 window.openProductModal = openProductModal;
623 window.addToCartFromModal = addToCartFromModal;
624 window.closeModal = closeModal;
625 window.showRegionSelector = showRegionSelector;
626 window.selectRegion = selectRegion;
627 window.loadProducts = loadProducts;
628