Saved List Feature

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.


Requirements

  • jQuery (any recent version)
  • Font Awesome 6 (for heart icons)
  • Bootstrap 5 (optional, for layout classes used in examples)

Installation

Include saved-list.js in your layout, after jQuery and before your closing </body> tag.

script_tag src="/layout_bootstrap/js/saved-list.js"

How It Works

  • Product IDs are stored in localStorage under the key _tat_saved_product_ids as a JSON array of strings.
  • When the saved list changes, a jQuery event tat:savedlist:changed is triggered on window. Any part of the page can listen to this event.
  • On page load, all save buttons on the page are scanned and their heart icon state is restored from localStorage.
  • The navbar badge and icon are also synced on load and on every change.

localStorage Key

All data is stored under a prefixed key to avoid conflicts with other scripts on the same domain.

KeyValue
_tat_saved_product_idsJSON array of product ID strings. Example: ["42","87","103"]

JavaScript API

The SavedList object is available globally as window.SavedList.

MethodReturnsDescription
SavedList.getAll()Array<string>Returns all saved product IDs.
SavedList.toggle(id)booleanAdds the ID if not saved, removes it if already saved. Returns true if added, false if removed.
SavedList.has(id)booleanReturns true if the given ID is currently saved.
SavedList.count()numberReturns the number of saved items.
SavedList.clear()voidRemoves all saved IDs and triggers the changed event.

Example Usage

// 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();

Events

EventTriggered onDescription
tat:savedlist:changedwindowFired whenever the saved list is modified (toggle or clear). The event passes the updated array of IDs as the second argument.

Listening to changes

$(window).on("tat:savedlist:changed", function(e, ids) {
  console.log("Saved list updated:", ids);
});

Part 1: Save Button (Product Card)

Add a save button inside each product card. The button must have:

  • Class tat-sl-btn — used by the script to find all buttons on page load
  • Attribute data-product-id — the unique ID of the product
  • An <i> tag with class tat-sl-heart-{id} — used to update the icon state
  • style="pointer-events: none;" on the <i> tag — prevents the icon from intercepting the click event

HTML

<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>

Liquid Template (loop)

{% 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.

Icon States

StateIcon class applied
Not savedfa-regular fa-heart fs-3 text-white
Savedfa-solid fa-heart fs-3 text-danger

To customise the icon classes, edit the tatUpdateHeartIcon function in saved-list.js.


Part 2: Navbar Button

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.

Required IDs

ElementID
Heart icon <i>tat-sl-navbar-heart-icon
Count badge <span>tat-sl-count-badge

HTML

<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>

Navbar Icon States

StateIcon class appliedBadge
Empty listfa-regular fa-heart fs-5Hidden (d-none)
Has saved itemsfa-solid fa-heart fs-5 text-dangerVisible, shows count

To customise icon classes or badge style, edit the tatSyncNavbar function in saved-list.js.


Part 3: Saved List Page

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.

API Endpoint

DetailValue
MethodGET
URLhttps://api.gttwl.net/post?page_size=100&ids={id},{id},{id}
AuthHTTP Basic — username: agency, password: your agency token
Response shape{ data: { entries: [ ...products ] } }

Replace [AGENCY TOKEN] in the script below with your actual token.

Required HTML elements on the page

Element IDPurpose
saved-list-countDisplays the number of saved items. Updated on load and on remove.
saved-empty-msgShown when the saved list is empty. Removed from DOM when items exist.
saved-list-containerThe grid container where product cards are rendered.

HTML

<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>

Page Script

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>';
  }
}

Notes

  • The removeSaved function calls SavedList.toggle(id) which also triggers tat:savedlist:changed, so the navbar badge updates automatically.
  • The remove button uses style="z-index: 10;" and the overlay link uses style="z-index: 5;" so the button stays clickable above the card overlay.
  • The <i> inside the remove button has style="pointer-events: none;" so clicks always land on the button element, not the icon.
  • The API returns up to 100 results per call (page_size=100). If a user saves more than 100 items, only the first 100 will be returned.

Global Functions Reference

These functions are exposed on window and can be called from anywhere.

FunctionDescription
tatUpdateHeartIcon(id, isSaved)Updates the heart icon for a given product ID. Pass true for saved state, false for unsaved.
tatSyncNavbar()Re-reads the saved list count and updates the navbar heart icon and badge. Called automatically on load and on every change.

DOM IDs and Classes Reference

SelectorTypeUsed for
.tat-sl-btnClassSave button on product cards. Script scans for these on page load to restore saved state.
.tat-sl-heart-{id}ClassThe <i> icon inside each save button. Replace {id} with the product ID.
#tat-sl-navbar-heart-iconIDThe <i> icon in the navbar saved list button.
#tat-sl-count-badgeIDThe badge element in the navbar that shows the saved count.

Troubleshooting

Heart button not clickable

  • If your card has a full-size overlay <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.
  • Make sure the <i> icon has style="pointer-events: none;" so clicks pass through to the button and not the icon.

Icon state not restored on page load

  • Make sure saved-list.js is loaded after jQuery.
  • Make sure the button has class tat-sl-btn and attribute data-product-id.
  • Make sure the icon has class tat-sl-heart-{id} matching the same product ID value.

Navbar badge not showing

  • Make sure id="tat-sl-navbar-heart-icon" and id="tat-sl-count-badge" exist in the DOM when the page loads.
  • The badge must start with class d-none. The script removes this class when count is greater than zero.

Saved data lost after browser close

  • This should not happen with localStorage. Check that the user is not browsing in private or incognito mode — localStorage does not persist after the session ends in private mode.