Kitisak Fluke · Updated March 18, 2026
A lightweight saved list (favorites/wishlist) feature using localStorage and jQuery. No backend required. Data persists across browser sessions indefinitely.
Include saved-list.js in your layout, after jQuery and before your closing </body> tag.
script_tag src="/layout_bootstrap/js/saved-list.js"localStorage under the key _tat_saved_product_ids as a JSON array of strings.tat:savedlist:changed is triggered on window. Any part of the page can listen to this event.localStorage.All data is stored under a prefixed key to avoid conflicts with other scripts on the same domain.
The SavedList object is available globally as window.SavedList.
// Get all saved IDs (e.g. to send to your server)
const ids = SavedList.getAll();
// ["42", "87", "103"]
// Check if a product is saved
if (SavedList.has("42")) {
console.log("Product 42 is saved");
}
// Manually toggle a product
const added = SavedList.toggle("42");
console.log(added ? "Added" : "Removed");
// Clear all saved items
SavedList.clear();
$(window).on("tat:savedlist:changed", function(e, ids) {
console.log("Saved list updated:", ids);
});
Add a save button inside each product card. The button must have:
tat-sl-btn — used by the script to find all buttons on page loaddata-product-id — the unique ID of the product<i> tag with class tat-sl-heart-{id} — used to update the icon statestyle="pointer-events: none;" on the <i> tag — prevents the icon from intercepting the click event<button
type="button"
class="tat-sl-btn"
data-product-id="42"
aria-label="Save this product"
style="z-index: 10; position: absolute; top: 0; right: 0;"
>
<i class="fa-regular fa-heart" id="tat-sl-heart-42" style="pointer-events: none;"></i>
</button>
{% for p in products %}
<div class="position-relative">
<!-- product image -->
<img src="{{p.primary_media.original}}" alt="{{p.title}}">
<!-- save button -->
<button
type="button"
class="tat-sl-btn position-absolute top-0 end-0 border-0 bg-transparent"
data-product-id="{{p.id}}"
aria-label="Save {{p.title}}"
style="z-index: 10;"
>
<i class="fa-regular fa-heart fs-3 text-white tat-sl-heart-{{p.id}}" style="pointer-events: none;"></i>
</button>
<!-- overlay link — must have lower z-index than the button -->
<a href="{{p.permalink}}" class="position-absolute top-0 start-0 w-100 h-100" style="z-index: 5;"></a>
</div>
{% endfor %}
Important: If your card has a full-size overlay <a> tag, the save button must have a higher z-index than the overlay link. Use inline style="z-index" values to avoid conflicts with CSS utility classes that may or may not be defined in your project.
To customise the icon classes, edit the tatUpdateHeartIcon function in saved-list.js.
Add this anywhere in your navbar. It shows a heart icon and a badge with the count of saved items. The badge is hidden when the count is zero.
<a href="/saved" title="Saved list">
<i class="fa-regular fa-heart" id="tat-sl-navbar-heart-icon"></i>
<span id="tat-sl-count-badge" class="d-none">0</span>
</a>
To customise icon classes or badge style, edit the tatSyncNavbar function in saved-list.js.
The saved list page is intentionally not included in saved-list.js. It lives only on the saved list page itself. It reads IDs from SavedList.getAll(), fetches product data from the API, and renders the cards.
Replace [AGENCY TOKEN] in the script below with your actual token.
<p>You have <span id="saved-list-count">0</span> saved resort(s).</p>
<p id="saved-empty-msg"></p>
<div class="row g-4" id="saved-list-container"></div>
Place this script at the bottom of the saved list page, after saved-list.js.
document.addEventListener('DOMContentLoaded', () => {
const ids = SavedList.getAll();
const container = document.getElementById('saved-list-container');
const emptyMsg = document.getElementById('saved-empty-msg');
document.getElementById('saved-list-count').innerText = ids.length;
if (!ids.length) {
emptyMsg.textContent = 'No favorite resorts at the moment.';
return;
}
if (emptyMsg) emptyMsg.remove();
const authHeader = 'Basic ' + btoa('agency:[AGENCY TOKEN]');
fetch(`https://api.gttwl.net/post?page_size=100&ids=${ids.join(',')}`, {
method: 'GET',
headers: {
'Content-Type': 'application/json',
'Authorization': authHeader
}
})
.then(r => r.json())
.then(({ data: { entries: products } }) => {
container.innerHTML = products.map(p => {
const price = p?.custom_fields?.price_category;
const resortSize = p?.custom_fields?.resort_size;
const weddingSize = p?.custom_fields?.wedding_size;
return `
<div class="col-lg-6 col-xl-4" data-aos="fade-up">
<div class="bg-white rounded-4 overflow-hidden position-relative shadow h-100">
<div class="position-relative">
<img src="${p?.primary_media?.large || ''}" class="w-100" alt="${p?.title || ''}">
</div>
<div class="px-3 pb-5 pt-3">
<h5 class="lh-sm fs-4 text-primary">${p?.title || ''}</h5>
<div class="d-flex justify-content-between position-absolute bottom-0 start-0 w-100 px-3 pb-3 fs-tiny gap-4">
<div class="d-flex flex-row">
${price && price.length > 1 ? `
<div class="d-flex align-items-center lh-sm me-3">
<i class="fa-solid fa-dollar-sign"></i>
<span class="ms-1">${price}</span>
</div>` : ''}
${resortSize && resortSize.length > 1 ? `
<div class="d-flex align-items-center lh-sm me-3">
<i class="fa-solid fa-bed"></i>
<span class="ms-1">${resortSize}</span>
</div>` : ''}
${weddingSize && weddingSize.length > 1 ? `
<div class="d-flex align-items-center lh-sm me-3" title="wedding group">
<i class="fa-solid fa-users"></i>
<span class="ms-1">2 - ${weddingSize}</span>
</div>` : ''}
<div class="d-flex align-items-center lh-sm" title="accessible">
<i class="fa-solid fa-wheelchair"></i>
</div>
</div>
<button class="btn btn-sm btn-outline-danger" style="z-index: 10;" onclick="removeSaved('${p.id}', this)">
<i class="fa-solid fa-trash" style="pointer-events: none;"></i>
</button>
</div>
</div>
<a href="${p?.permalink || '#'}" class="position-absolute top-0 start-0 w-100 h-100" style="z-index: 5;"></a>
</div>
</div>
`;
}).join('');
})
.catch(err => {
console.error('Fetch error:', err);
container.innerHTML = '<p class="text-danger">Failed to load saved items.</p>';
});
});
function removeSaved(id, btn) {
SavedList.toggle(id);
// Remove the card column wrapper
const card = btn.closest('.col-lg-6');
if (card) card.remove();
// Update count display
document.getElementById('saved-list-count').innerText = SavedList.count();
// Show empty message if no cards remain
if (!document.querySelectorAll('#saved-list-container .col-lg-6').length) {
document.getElementById('saved-list-container').innerHTML =
'<p class="text-muted">No saved items.</p>';
}
}
removeSaved function calls SavedList.toggle(id) which also triggers tat:savedlist:changed, so the navbar badge updates automatically.style="z-index: 10;" and the overlay link uses style="z-index: 5;" so the button stays clickable above the card overlay.<i> inside the remove button has style="pointer-events: none;" so clicks always land on the button element, not the icon.page_size=100). If a user saves more than 100 items, only the first 100 will be returned.These functions are exposed on window and can be called from anywhere.
<a> tag, the button's z-index must be higher than the overlay. Use inline style="z-index: 10;" on the button and style="z-index: 5;" on the overlay link.<i> icon has style="pointer-events: none;" so clicks pass through to the button and not the icon.saved-list.js is loaded after jQuery.tat-sl-btn and attribute data-product-id.tat-sl-heart-{id} matching the same product ID value.id="tat-sl-navbar-heart-icon" and id="tat-sl-count-badge" exist in the DOM when the page loads.d-none. The script removes this class when count is greater than zero.localStorage. Check that the user is not browsing in private or incognito mode — localStorage does not persist after the session ends in private mode.