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
- Configure the API host, sales channel ID, and checkout domain in the configuration panel
- Click Connect to initialize the
StorefrontCartand connect to the SignalR hub - GetVouchers is called to fetch available products, which are rendered as cards
- When the user clicks Add to Basket, the
AddItemmethod is invoked - The server responds with a BasketUpdated event containing the new basket state
- 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();
});