Skip to main content

Complete Example

This example demonstrates a complete shopping cart implementation using the Storefront API.

Try it Live

You can try the working example here: Live Demo

HTML Setup

Include the required dependencies and configuration in your HTML:

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<title>My Storefront</title>
<style>
body {
font-family:
-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
max-width: 1200px;
margin: 0 auto;
padding: 1rem;
}
.config-panel {
background: #f5f5f5;
padding: 1rem;
border-radius: 8px;
margin-bottom: 2rem;
}
.config-panel h2 {
margin-top: 0;
}
.config-panel label {
display: block;
margin-bottom: 0.5rem;
font-weight: bold;
}
.config-panel input {
width: 100%;
padding: 0.5rem;
margin-bottom: 1rem;
border: 1px solid #ccc;
border-radius: 4px;
font-family: monospace;
}
.config-panel button {
padding: 0.75rem 1.5rem;
background: #28a745;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 1rem;
}
.config-panel button:hover {
background: #218838;
}
.config-panel button:disabled {
background: #ccc;
cursor: not-allowed;
}
.products-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
gap: 1rem;
margin-bottom: 2rem;
}
.product-card {
border: 1px solid #ddd;
padding: 1rem;
border-radius: 8px;
}
.product-card h3 {
margin: 0 0 0.5rem 0;
}
.product-card .price {
font-weight: bold;
color: #2a9d8f;
}
.product-card .validity {
font-size: 0.9em;
color: #666;
}
.product-card button {
margin-top: 1rem;
padding: 0.5rem 1rem;
background: #007bff;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
}
.product-card button:hover {
background: #0056b3;
}
.cart-item {
display: flex;
align-items: center;
gap: 1rem;
padding: 0.5rem;
border-bottom: 1px solid #eee;
}
.item-quantity {
display: flex;
align-items: center;
gap: 0.5rem;
}
.item-quantity button {
width: 24px;
height: 24px;
border: 1px solid #ccc;
background: #fff;
cursor: pointer;
}
.hidden {
display: none;
}
.status-message {
padding: 0.5rem;
margin-top: 0.5rem;
border-radius: 4px;
}
.status-message.error {
background: #f8d7da;
color: #721c24;
}
.status-message.success {
background: #d4edda;
color: #155724;
}
</style>
</head>

<body>
<h1>Voucher Shop</h1>

<!-- Configuration Panel -->
<div class="config-panel" id="config-panel">
<h2>Configuration</h2>
<p>Configure the API settings before connecting:</p>

<label for="config-host">API Host:</label>
<input
type="text"
id="config-host"
value="papi.skchase.com"
placeholder="e.g., papi.skchase.com"
/>

<label for="config-channel">Sales Channel ID:</label>
<input
type="text"
id="config-channel"
value="b5842168-9eee-b684-8eb1-19fa58475184"
placeholder="e.g., b5842168-9eee-b684-8eb1-19fa58475184"
/>

<label for="config-checkout">Checkout Domain:</label>
<input
type="text"
id="config-checkout"
value="hotela.skchase.com"
placeholder="e.g., hotela.skchase.com"
/>

<button id="btn-connect">Connect and Load Products</button>
<div id="config-status"></div>
</div>

<!-- Main Content (hidden until connected) -->
<div id="main-content" class="hidden">
<!-- Products Section -->
<h2>Available Vouchers</h2>
<div id="products-container" class="products-grid">
<p>Loading products...</p>
</div>

<!-- Cart Section -->
<h2>Your Basket <span class="cart-count">(0 items)</span></h2>
<div id="cart-container">
<div class="cart-items">
<p>Your cart is empty</p>
</div>
<div class="cart-totals">
<p>Total: <span class="total-price">0.00</span></p>
<p>Discount: <span class="discount-amount">0.00</span></p>
<p>Final: <span class="final-price">0.00</span></p>
</div>
<a href="#" id="btn-checkout">Proceed to Checkout</a>
</div>
</div>

<!-- Dependencies -->
<script src="https://cdnjs.cloudflare.com/ajax/libs/microsoft-signalr/6.0.1/signalr.min.js"></script>

<!-- Your storefront script -->
<script src="storefront.js"></script>
</body>
</html>

JavaScript Implementation

/**
* Storefront Cart Implementation
*/
class StorefrontCart {
constructor(containerElement, productsContainer, config) {
// Configuration from passed config object
this.apiHost = config.apiHost;
this.salesChannelId = config.salesChannelId;
this.checkoutDomain = config.checkoutDomain;
this.currency = "GBP"; // Will be determined from products

// DOM elements
this.container = containerElement;
this.itemsContainer = containerElement.querySelector(".cart-items");
this.productsContainer = productsContainer;

// State
this.connection = null;
this.basketId = null;
this.products = [];
this.items = [];
this.promoCode = null;
}

/**
* Initialize the cart
*/
async init() {
this.basketId = this.getOrCreateBasketId();

await this.createConnection();
this.registerEventHandlers();
await this.connection.start();

await this.connection.invoke(
"CreateBasket",
this.basketId,
this.salesChannelId,
);

// Load products and current basket state
await this.loadProducts();
await this.resumeShopping();

this.updateCheckoutLink();
}

/**
* Load available products from the API
*/
async loadProducts() {
try {
const products = await this.connection.invoke(
"GetVouchers",
this.salesChannelId,
);

if (products && products.length > 0) {
// Filter to only show active products
this.products = products.filter((p) => p.isActive);

// Set currency from first product
if (this.products.length > 0 && this.products[0].price) {
this.currency = this.products[0].price.currency;
}

this.renderProducts();
} else {
this.productsContainer.innerHTML =
"<p>No products available for this sales channel.</p>";
}
} catch (err) {
console.error("Failed to load products:", err);
this.productsContainer.innerHTML =
"<p>Failed to load products. Please refresh the page.</p>";
}
}

/**
* Render products grid
*/
renderProducts() {
const formatter = new Intl.NumberFormat("en-US", {
style: "currency",
currency: this.currency,
});

this.productsContainer.innerHTML = this.products
.map(
(product) => `
<div class="product-card" data-product-id="${product.id}">
<h3>${product.name}</h3>
<p>${product.marketingDescription}</p>
<p class="price">${formatter.format(parseFloat(product.price.amount))}</p>
<p class="validity">Valid for ${product.validity.validMonths} months</p>
<button class="add-to-cart-btn">Add to Basket</button>
</div>
`,
)
.join("");

// Attach click handlers to add-to-cart buttons
this.productsContainer
.querySelectorAll(".add-to-cart-btn")
.forEach((button) => {
button.addEventListener("click", async (e) => {
const productId = e.target.closest(".product-card").dataset.productId;
button.disabled = true;
button.textContent = "Adding...";

await this.addItem(productId);

button.disabled = false;
button.textContent = "Add to Basket";
});
});
}

/**
* Create SignalR connection
*/
async createConnection() {
var hubUrl = "https://" + this.apiHost + "/public/hub/storefront";

this.connection = new signalR.HubConnectionBuilder()
.withUrl(hubUrl)
.withAutomaticReconnect()
.configureLogging(signalR.LogLevel.Information)
.build();

this.connection.onreconnected(async () => {
await this.resumeShopping();
});
}

/**
* Register event handlers
*/
registerEventHandlers() {
this.connection.on("BasketUpdated", (basket) =>
this.onBasketUpdated(basket),
);
this.connection.on("SomethingHappened", (error) => this.onError(error));
}

/**
* Handle basket updates
*/
onBasketUpdated(basket) {
this.promoCode = basket.promoCode;

// Group items by product for display
this.items = this.groupItemsByProduct(basket.items || []);

// Handle settled orders
if (basket.stage === "Settled") {
this.resetBasket();
return;
}

this.render();
}

/**
* Handle errors
*/
async onError(error) {
switch (error.errorCode) {
case "PromoCodeDoesntExist":
this.showError("Invalid promo code");
break;
case "UnavailableProduct":
this.showError("Product unavailable");
break;
case "OrderWasProcessed":
await this.resetBasket();
break;
default:
console.error("Error:", error);
}
}

/**
* Resume shopping session
*/
async resumeShopping() {
const basket = await this.connection.invoke(
"ResumeShopping",
this.basketId,
this.salesChannelId,
);

if (basket) {
this.onBasketUpdated(basket);
}
}

/**
* Add item to cart
*/
async addItem(productId) {
await this.connection.invoke("AddItem", productId);
}

/**
* Remove item from cart
*/
async removeItem(productId, removeAll = false) {
const item = this.items.find((i) => i.productId === productId);
if (!item) return;

const lineItemIds = removeAll ? item.lineItemIds : [item.lineItemIds[0]];

await this.connection.invoke("RemoveItems", lineItemIds);
}

/**
* Apply promo code
*/
async applyPromoCode(code) {
await this.connection.invoke("ApplyPromoCode", code);
}

/**
* Remove promo code
*/
async removePromoCode() {
await this.connection.invoke("RemovePromoCode");
}

/**
* Group line items by product
*/
groupItemsByProduct(items) {
const grouped = {};

items.forEach((item) => {
if (!grouped[item.productId]) {
grouped[item.productId] = {
...item,
quantity: 0,
lineItemIds: [],
};
}
grouped[item.productId].quantity++;
grouped[item.productId].lineItemIds.push(item.lineItemId);
});

return Object.values(grouped);
}

/**
* Generate a UUID v4
*/
generateUUID() {
return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(
/[xy]/g,
function (c) {
const r = (Math.random() * 16) | 0;
const v = c === "x" ? r : (r & 0x3) | 0x8;
return v.toString(16);
},
);
}

/**
* Get or create basket ID
*/
getOrCreateBasketId() {
const storageKey = "basket-" + this.salesChannelId;

// Check URL first (for returning from checkout)
const urlParams = new URLSearchParams(window.location.search);
const basketIdFromUrl = urlParams.get("basketId");

if (basketIdFromUrl) {
localStorage.setItem(storageKey, basketIdFromUrl);
return basketIdFromUrl;
}

// Check local storage
const stored = localStorage.getItem(storageKey);
if (stored) return stored;

// Generate new ID
const newId = this.generateUUID();
localStorage.setItem(storageKey, newId);
return newId;
}

/**
* Reset basket after order completion
*/
async resetBasket() {
const storageKey = "basket-" + this.salesChannelId;
const newId = this.generateUUID();
localStorage.setItem(storageKey, newId);
this.basketId = newId;

await this.connection.invoke(
"CreateBasket",
this.basketId,
this.salesChannelId,
);
this.items = [];
this.promoCode = null;
this.render();
}

/**
* Update checkout link
*/
updateCheckoutLink() {
const checkoutButton = document.getElementById("btn-checkout");
if (!checkoutButton) return;

const url = new URL(this.checkoutDomain + "/checkout");
url.searchParams.set("basketId", this.basketId);
url.searchParams.set("returnUrl", window.location.href);
url.searchParams.set("successCallbackUrl", window.location.href);

checkoutButton.href = url.toString();
}

/**
* Render cart UI
*/
render() {
// Calculate totals
let total = 0;
let finalTotal = 0;

this.items.forEach((item) => {
total += parseFloat(item.retailPrice.amount) * item.quantity;
finalTotal += parseFloat(item.quotedPrice.amount) * item.quantity;
});

const discount = total - finalTotal;

// Format currency
const formatter = new Intl.NumberFormat("en-US", {
style: "currency",
currency: this.currency,
});

// Update cart count in header
const totalItems = this.items.reduce((sum, item) => sum + item.quantity, 0);
document.querySelector(".cart-count").textContent =
"(" + totalItems + " items)";

// Update totals display
document.querySelector(".total-price").textContent =
formatter.format(total);
document.querySelector(".discount-amount").textContent =
formatter.format(-discount);
document.querySelector(".final-price").textContent =
formatter.format(finalTotal);

// Render items
this.itemsContainer.innerHTML =
this.items.length === 0
? "<p>Your cart is empty</p>"
: this.items.map((item) => this.renderItem(item, formatter)).join("");

// Attach item event listeners
this.attachItemListeners();

console.log(
"Basket updated: " +
totalItems +
" items, total: " +
formatter.format(finalTotal),
);
}

/**
* Render single item
*/
renderItem(item, formatter) {
return (
'<div class="cart-item" data-product-id="' +
item.productId +
'">' +
'<div class="item-name">' +
item.productName +
"</div>" +
'<div class="item-quantity">' +
'<button class="decrement">-</button>' +
"<span>" +
item.quantity +
"</span>" +
'<button class="increment">+</button>' +
"</div>" +
'<div class="item-price">' +
formatter.format(parseFloat(item.retailPrice.amount)) +
"</div>" +
'<button class="remove">Remove</button>' +
"</div>"
);
}

/**
* Attach event listeners to cart items
*/
attachItemListeners() {
this.itemsContainer.querySelectorAll(".cart-item").forEach((el) => {
const productId = el.dataset.productId;

el.querySelector(".decrement").addEventListener("click", () => {
this.removeItem(productId, false);
});

el.querySelector(".increment").addEventListener("click", () => {
this.addItem(productId);
});

el.querySelector(".remove").addEventListener("click", () => {
this.removeItem(productId, true);
});
});
}

/**
* Show error message
*/
showError(message) {
// Implement your error display logic
console.error(message);
}
}

// Initialize on connect button click
document
.getElementById("btn-connect")
.addEventListener("click", async function () {
var btn = this;
var statusEl = document.getElementById("config-status");

// Read configuration values
var config = {
apiHost: document.getElementById("config-host").value.trim(),
salesChannelId: document.getElementById("config-channel").value.trim(),
checkoutDomain:
"https://" + document.getElementById("config-checkout").value.trim(),
};

// Validate inputs
if (!config.apiHost || !config.salesChannelId) {
statusEl.className = "status-message error";
statusEl.textContent = "Please fill in all configuration fields.";
return;
}

// Disable button and show loading state
btn.disabled = true;
btn.textContent = "Connecting...";
statusEl.className = "status-message";
statusEl.textContent = "Establishing connection...";

try {
var cartContainer = document.getElementById("cart-container");
var productsContainer = document.getElementById("products-container");

// Create and initialize the cart
window.cart = new StorefrontCart(
cartContainer,
productsContainer,
config,
);
await window.cart.init();

// Success - hide config panel and show main content
statusEl.className = "status-message success";
statusEl.textContent = "Connected successfully!";

setTimeout(function () {
document.getElementById("config-panel").classList.add("hidden");
document.getElementById("main-content").classList.remove("hidden");
}, 500);
} catch (err) {
console.error("Connection failed:", err);
statusEl.className = "status-message error";
statusEl.textContent = "Connection failed: " + err.message;
btn.disabled = false;
btn.textContent = "Connect and Load Products";
}
});

How It Works

  1. Configure the API host, sales channel ID, and checkout domain in the configuration panel
  2. Click Connect to initialize the StorefrontCart and connect to the SignalR hub
  3. GetVouchers is called to fetch available products, which are rendered as cards
  4. When the user clicks Add to Basket, the AddItem method is invoked
  5. The server responds with a BasketUpdated event containing the new basket state
  6. The cart UI automatically updates to show the added item and new totals

The initialization flow:

User enters config and clicks "Connect"
|
v
StorefrontCart created with config
|
v
SignalR connection established
|
v
CreateBasket called
|
v
GetVouchers fetches products
|
v
Products rendered, ready to shop

The add-to-cart flow:

User clicks "Add to Basket"
|
v
connection.invoke("AddItem", productId)
|
v
Server processes request
|
v
Server emits "BasketUpdated" event
|
v
onBasketUpdated() handler fires
|
v
Cart UI re-renders with new items

Promo Code Form

To add promo code support, include a form in your HTML:

<form id="promo-form">
<input type="text" id="promo-input" placeholder="Enter promo code" />
<button type="submit">Apply</button>
<button type="button" id="remove-promo">Remove</button>
</form>

Then handle the form submission:

document.getElementById("promo-form").addEventListener("submit", async (e) => {
e.preventDefault();
const code = document.getElementById("promo-input").value.trim();
if (code) {
await window.cart.applyPromoCode(code);
}
});

document.getElementById("remove-promo").addEventListener("click", async () => {
await window.cart.removePromoCode();
});