Skip to content

Instantly share code, notes, and snippets.

@gijsbotje
Last active December 17, 2024 10:40
Show Gist options
  • Save gijsbotje/cb631ff6e59095d142f875c0eab7a6e3 to your computer and use it in GitHub Desktop.
Save gijsbotje/cb631ff6e59095d142f875c0eab7a6e3 to your computer and use it in GitHub Desktop.
Afosto 2.0 storefront scripts
<!-- twig/layouts/storefrontScripts.twig -->
<!-- Styling voor de error meldingen d.m.v. toastify-js -->
<link rel="stylesheet" type="text/css" href="https://cdn.jsdelivr.net/npm/toastify-js/src/toastify.min.css">
<script type="module">
import 'https://esm.sh/preact/debug';
import { h, render, Fragment } from 'https://esm.sh/preact';
import { useEffect, useState, useRef } from 'https://esm.sh/preact/hooks';
import { html } from 'https://esm.sh/htm/preact';
import Toastify from 'https://esm.sh/toastify-js';
import { createStorefrontClient } from 'https://esm.sh/@afosto/storefront@3';
import { getDomain } from 'https://esm.sh/tldts';
const getErrorMessage = error => {
const errorResponse = error?.response || {};
const errorResponseData = errorResponse?.data || {};
const errorResponseError = errorResponseData?.error || {};
const gqlResponseErrors = errorResponse?.errors || [];
const [firstGqlError] = gqlResponseErrors || [];
const gqlErrorExtensions = firstGqlError?.extensions || {};
const pointers = errorResponseError?.details?.pointers || firstGqlError?.extensions?.pointers;
const [firstPointer] = pointers || [];
return (
firstPointer?.message ||
errorResponseError?.message ||
errorResponseData?.message ||
firstGqlError?.message
);
};
const formatPrice = (value) => {
const intl = Intl.NumberFormat(`${document.documentElement.lang}-${document.documentElement.lang.toUpperCase()}`, {
style: 'currency',
currency: document.body.dataset.afCurrencyIso,
});
const decimal = intl.formatToParts(value / 100).find(part => part.type === 'decimal').value;
return intl.format(value / 100).replace(`${decimal}00`, `${decimal}-`);
};
const CouponForm = ({ coupons }) => {
const [value, setValue] = useState('');
const [errorMessage, setErrorMessage] = useState(null);
const [isSubmitting, setIsSubmitting] = useState(false);
const handleAddCouponToCart = async e => {
e.preventDefault();
try {
setIsSubmitting(true);
const updatedCart = await window.Storefront.addCouponToCart(value);
if (!!updatedCart) {
setValue('');
$('body').trigger('cart:updated', updatedCart);
} else {
alert('something went wrong');
}
} catch (error) {
$("#add-response-modal .modal-header .modal-title").html($("#add-response-modal").data("error-title"));
$("#add-response-modal .modal-body").addClass('text-center');
$("#add-response-modal .modal-body").html('<span class="fa-stack fa-3x text-warning"><i class="far fa-circle fa-stack-2x"></i><i class="fas fa-exclamation fa-stack-1x"></i></span><p class="lead mt-15">' + getErrorMessage(error) + '</p>');
$("#add-response-modal").modal('show');
} finally {
setIsSubmitting(false);
}
};
const handleRemoveCouponToCart = async code => {
try {
setIsSubmitting(true);
const updatedCart = await window.Storefront.removeCouponFromCart(code);
$('body').trigger('cart:updated', updatedCart);
} catch (error) {
$("#add-response-modal .modal-header .modal-title").html($("#add-response-modal").data("error-title"));
$("#add-response-modal .modal-body").addClass('text-center');
$("#add-response-modal .modal-body").html('<span class="fa-stack fa-3x text-warning"><i class="far fa-circle fa-stack-2x"></i><i class="fas fa-exclamation fa-stack-1x"></i></span><p class="lead mt-15">' + getErrorMessage(error) + '</p>');
$("#add-response-modal").modal('show');
} finally {
setIsSubmitting(false);
}
};
const handleChange = e => {
setValue(e.target.value);
};
const handleInput = () => {
setErrorMessage(null);
};
return html`
<form onSubmit="${handleAddCouponToCart}">
<div class="">
${(coupons || []).map(({ code }, index) => html`
<div class="panel panel-default d-flex justify-content-between align-items-center">
<div class="py-4 px-12">
${code || null}
</div>
<button class="btn btn-danger btn-sm" type="button" onClick="${() => handleRemoveCouponToCart(code)}">
<i class="fal fa-times" />
</button>
</div>
`)}
</div>
<div class="input-group">
<input class="form-control" placeholder="Coupon code toevoegen" onChange="${handleChange}" onInput="${handleInput}" value="${value}" />
<span class="input-group-btn">
<button type="submit" class="btn btn-primary" disabled="${isSubmitting}">
<i class="fal fa-angle-right" />
</button>
</span>
</div>
${errorMessage && errorMessage}
</form>
`;
};
const ShareCartDialog = () => {
const id = window.Storefront.getCartTokenFromStorage();
const link = `${window.location.origin}/cart?hash=${id}`;
useEffect(() => {
if (id) {
$('[data-toggle="tooltip"]').tooltip();
$('.copy-to-clipboard').tooltip({
container: '.input-group-btn',
trigger: 'click'
}).on('mouseout', function(){
$(this).tooltip('hide');
});
new Clipboard('.copy-to-clipboard');
// new Clipboard('#copy-field');
$('input[data-clipboard-text]').on('click, focus', function() {
$(this).select();
});
}
}, [id]);
return html`
<div class="modal fade" tabindex="-1" role="dialog" id="share-cart-dialog">
<div class="modal-dialog" role="document">
<div class="modal-content">
<div class="modal-header">
<button type="button" class="close" data-dismiss="modal" aria-label="Close"><span aria-hidden="true">×</span></button>
<h4 class="modal-title">{{'Winkelwagen delen'|t}}</h4>
</div>
<div class="modal-body text-center">
<div class="row">
<div class="col-md-4 col-xs-3">
<a class="text-black" href="https://www.facebook.com/sharer/sharer.php?u=${link}&t=" rel="noopener noreferrer" target="_blank" title="{{"Share on Facebook"|t}}" >
<i class="fab fa-3x fa-facebook" />
</a>
</div>
<div class="col-md-4 visible-xs visible-sm col-xs-3">
<a class="text-black" href="whatsapp://send?text=${link}">
<i class="fab fa-3x fa-whatsapp" />
</a>
</div>
<div class="col-md-4 col-xs-3">
<a class="text-black" href="mailto:?subject=&body=${link}" rel="noopener noreferrer" target="_self" title="{{"Send email"|t}}">
<i class="fas fa-3x fa-envelope" />
</a>
</div>
</div>
</div>
<div class="modal-footer text-left">
<p class="text-center">
{{' Kopieer deze link om op je website te plaatsen of om te gebruiken in een bericht'|t}}
</p>
<div class="form-group">
<div class="input-group">
<input class="form-control" value="${link}" id="copy-cart-url-field" data-clipboard-text="${link}" />
<div class="input-group-btn">
<button
type="button"
class="copy-to-clipboard btn btn-secondary"
data-clipboard-target="#copy-cart-url-field"
data-toggle="tooltip"
data-placement="bottom"
title="{{'Gekopieerd!'|t}}"
>
{{'Kopieer'|t}}
</button>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
`;
};
const CopyCartLink = ({ id }) => {
const handleShowShareDialog = () => {
$('#share-cart-dialog').modal('show');
};
return html`
<button class="btn btn-link btn-block" data-toggle="modal" data-target="#share-cart-dialog">Winkelwagen delen</button>
`;
};
const CartPageSummary = ({ cart, checkoutUrl, isLoading }) => {
const { subtotal, total, totalExcludingVat, vat, adjustments, fees, coupons, id } = cart || {};
const { shipping: shippingFees, payment: paymentFees } = fees || {};
return html`
<div class="cost-summary px-15">
<div class="cost-summary-inner">
<strong class="h4">
{{'Checkout'|t}}
</strong>
<div class="d-flex justify-content-between align-items-center">
<span class="h6 my-5 text-muted">
{{'Subtotaal'|t}}
</span>
<span class="h5 my-5 text-uppercase${isLoading ? 'text-muted' : ''}">
${isLoading ? '-' : formatPrice(subtotal || 0)}
</span>
</div>
${(adjustments || []).map(({ description, amount, isDiscount, isPercentage, outcome }, index) => html`
<div class="d-flex justify-content-between align-items-center${index > 0 ? ' mt-20' : ''}">
<span class="h6 my-5 text-muted">
${description}${isPercentage ? ` (${amount}%)` : ''}
</span>
<span class="h5 my-5">
${formatPrice((outcome.amount || 0) * (isDiscount ? -1 : 1) )}
</span>
</div>
`)}
${(shippingFees || []).map(({ description, total }, index) => html`
<div class="d-flex justify-content-between align-items-center${index > 0 ? ' mt-20' : ''}">
<span class="h6 my-5 text-muted">
${description}
</span>
<span class="h5 my-5">
${formatPrice(total)}
</span>
</div>
`)}
${(paymentFees || []).map(({ description, total }, index) => html`
<div class="d-flex justify-content-between align-items-center${index > 0 ? ' mt-20' : ''}">
<span class="h6 my-5 text-muted">
${description}
</span>
<span class="h5 my-5">
${formatPrice(total)}
</span>
</div>
`)}
<div class="d-flex justify-content-between align-items-center">
<span class="h6 my-5 text-muted">
{{'Totaal excl. BTW'|t}}
</span>
<span class="h5 my-5${isLoading ? 'text-muted' : ''}">
${isLoading ? '-' : formatPrice(totalExcludingVat || 0)}
</span>
</div>
${(vat || []).map(({ rate, amount }, index) => html`
<div class="d-flex justify-content-between align-items-center${index > 0 ? ' mt-20' : ''}">
<span class="h6 my-5 text-muted">
BTW ${rate || 0}%
</span>
<span class="h5 my-5">
${formatPrice(amount || 0)}
</span>
</div>
`)}
<hr />
<div class="d-flex justify-content-between align-items-center mb-32">
<span class="h6 my-0 text-muted">
{{'Totaal'|t}}
</span>
<span class="h4 my-0${isLoading ? 'text-muted' : ''}">
${isLoading ? '-' : formatPrice(total || 0)}
</span>
</div>
<${CouponForm} coupons="${coupons}" />
// <${PayPalButton} options="${paypalOptions}" />
<a class="btn btn-success btn-block mt-16" href="${checkoutUrl}" title="{{'Verder met bestellen'|t}}">
{{'Verder met bestellen'|t}}
</a>
<${CopyCartLink} id="${id}" />
</div>
</div>
`;
};
const ItemsGrid = ({ items, onRemove, onChangeQuantity, isLoading }) => {
const increaseQuantity = item => onChangeQuantity(item, item.quantity + 1);
const decreaseQuantity = item => onChangeQuantity(item, item.quantity - 1);
return html`
<div class="cart-grid px-15 d-flex flex-column">
<div class="cart-grid-header">
<div class="cart-grid-header-action"></div>
<div class="cart-grid-header-information">
<span>
{{'Informatie'|t}}
</span>
</div>
<div class="cart-grid-header-single-price">
<span>
{{'Stuksprijs'|t}}
</span>
</div>
<div class="cart-grid-header-quantity">
<span>
{{'Aantal'|t}}
</span>
</div>
<div class="cart-grid-header-subtotal">
<span>
{{'Subtotaal'|t}}
</span>
</div>
</div>
${!isLoading ? items.map(item => {
const { adjustments, label, sku, total, subtotal, quantity, ids, details, image } = item || {};
const unitPrice = details[0] && details[0].pricing && details[0].pricing.amount || 0;
return html`
<div class="cart-grid-item-container" key="${sku}">
<div class="cart-grid-item">
<div class="cart-grid-item-action hidden-xs hidden-sm">
<button
type="button"
class="btn btn-link text-danger btn-block btn-icon p-10"
onClick="${() => onRemove(item)}"
>
<i class="fa fa-times"></i>
</button>
</div>
<div class="cart-grid-item-information">
<div class="cart-grid-item-information-image">
<a href="{{cartItem.url}}" title="${label || sku}">
<div class="lazyload-wrapper lazyload-square">
<img loading="lazy" src="${image}" alt="${'{{'Afbeelding van [name]'|t}}'.replace('[name]', label || sku) }" class="lazyload-cover" />
</div>
</a>
</div>
<div class="cart-grid-item-information-inner">
<strong class="h5 cart-grid-item-product-name">
${label || sku}
</strong>
<div class="visible-xs visible-sm">
<div class="d-flex">
<div class="flex-100 d-flex align-items-center">
<label class="h6 text-muted d-block mr-10">
{{'Aantal'|t}}
</label>
<div class="quantity-counter">
<button
type="button"
class="btn"
onClick="${() => decreaseQuantity(item)}"
>
<i class="fa fa-minus"></i>
</button>
<div class="mx-10">
${quantity}
</div>
<button
type="button"
class="btn"
onClick="${() => increaseQuantity(item)}"
>
<i class="fa fa-plus"></i>
</button>
</div>
</div>
</div>
<hr class="light my-10"/>
<div class="d-flex align-items-end flex-wrap">
<div>
${adjustments.length > 0 && html`
<div class="d-flex justify-content-between align-items-center">
<span class="h6 my-0 text-muted mr-10">
{{'Subtotaal'|t}}
</span>
<span class="h6 my-0">
${formatPrice(subtotal)}
</span>
</div>
${adjustments.map((adjustment, idx) => html`
<div class="">
<span class="h6 my-0 text-muted mr-10">
${adjustment.description} ${adjustment.isPercentage ? `(${adjustment.amount}%)` : ''}
</span>
<span class="h6 my-0">
${formatPrice(adjustment.outcome.amount * (adjustment.isDiscount ? -1 : 1))}
</span>
</div>
`)}
`}
<div class="d-flex justify-content-between align-items-center my-8">
<span class="h6 my-0 text-muted mr-10">
{{'Totaal'|t}}
</span>
<span class="h5 my-0">
${formatPrice(total)}
</span>
</div>
</div>
<button
type="button"
class="btn btn-link btn-sm text-danger btn-icon p-5 ml-auto"
onClick="${() => onRemove(item)}"
>
<i class="fa fa-times"></i>
<span>
{{'Verwijderen'|t}}
</span>
</button>
</div>
</div>
</div>
<div class="cart-grid-item-single-price hidden-xs hidden-sm text-right">
<div class="h5">
${formatPrice(unitPrice)}
</div>
</div>
<div class="cart-grid-item-quantity hidden-xs hidden-sm">
<div class="quantity-counter">
<button
type="button"
class="btn"
onClick="${() => decreaseQuantity(item)}"
>
<i class="fa fa-minus fa-sm"></i>
</button>
<div class="mx-10">
${quantity}
</div>
<button
type="button"
class="btn"
onClick="${() => increaseQuantity(item)}"
>
<i class="fa fa-plus fa-sm"></i>
</button>
</div>
</div>
<div class="cart-grid-item-subtotal hidden-xs hidden-sm text-right">
<span class="h5 ${adjustments.length > 0 ? 'text-through text-muted' : ''}">
${formatPrice(subtotal)}
</span>
</div>
</div>
</div>
${(adjustments || []).length > 0 && html`
<div class="cart-grid-item-adjustments hidden-xs hidden-sm">
${adjustments.map((adjustment, idx) => html`
<div class="cart-grid-item-adjustments-description">
${adjustment.description} ${adjustment.isPercentage ? `(${adjustment.amount}%)` : ''}
</div>
<div class="cart-grid-item-adjustments-outcome">
${formatPrice(adjustment.outcome.amount * (adjustment.isDiscount ? -1 : 1))}
</div>
<div class="cart-grid-item-adjustments-new-total">
${idx + 1 === adjustments.length ? html`
<span class="h5 my-0">
${formatPrice(total)}
</span>
` : ''}
</div>
`)}
</div>
`}
</div>
`;
}) : ''}
</div>
`;
};
const ProductCard = ({ product }) => {
return html`
<div class="col-xs-6 col-sm-4 col-md-4 col-lg-3 mb-30">
<article class="product-grid-item h-pr-100">
<div class="product-image">
<a href="${product.url}" title="${product.name}">
<div class="lazyload-wrapper lazyload-square">
<img data-src="${product.image_default.thumbs[400]}" alt="Foto van ${product.name}" title="${product.name}" class="lazyload"/>
</div>
<div class="product-name">
<strong>
${product.name}
</strong>
</div>
</a>
</div>
<div class="product-info">
<div class="product-info-description hidden-xs list-visible">
<a data-af-href="${product.url}" class="product-name list-visible" title="${product.name}">
<strong>${product.name}</strong>
</a>
</div>
<div class="product-action">
<div class="product-action-text">
<strong class="product-price">
${product.has_discount && html`
<small>
<s>
${formatPrice(product.original_price * 100)}
</s>
</small>
`}
<span>${formatPrice(product.price * 100)}</span>
</strong>
<div class="product-action-buttons">
${!product.available && html`
<button type="button" disabled class="btn btn-primary btn-sm" aria-label="{{'Toevoegen aan winkelwagen'|t}}">
<i class="fa fa-shopping-cart fa-lg"> </i> &nbsp;
<i class="fa fa-plus fa-lg"> </i>
</button>
`}
${product.available && product.price > 0 && html`
${product.has_options ? html`
<div
class="btn btn-primary btn-sm quick-view-link"
data-qv-product="${product.url}"
data-qv-ajax-element="#quick-view-modal .modal-content"
data-qv-ajax-input="#quick-view-content > *"
data-product-url="${product.url}"
data-skip-drawer
>
<i class="fa fa-shopping-cart fa-lg mr-12"> </i>
<i class="fa fa-plus fa-lg"> </i>
</div>
` : html`
<form
action="${product.cart_url}"
method="POST"
id="product-form-${product.id}"
data-cart-url="${location.origin}/cart"
class="grid-product-form"
data-product-url="${product.url}"
data-skip-drawer
data-form-input="[{'product_id' : '${product.id}', 'quantity' : '1', 'price' : '${product.price}'}]"
>
<input type="hidden" value="${product.price}" name="price"/>
<input type="hidden" value="${product.id}" name="product_id"/>
<input type="hidden" value="${product.sku}" name="sku"/>
<input type="hidden" value="1" name="quantity"/>
<button type="submit" class="btn btn-primary btn-sm" aria-label="{{'Toevoegen aan winkelwagen'|t}}">
<i class="fa fa-shopping-cart fa-lg mr-12"></i> <i class="fa fa-plus fa-lg"> </i>
</button>
</form>
`}
`}
${product.product_wishlist_url && html`
<button
type="button"
class="btn btn-sm btn-primary-inverse wishlist-link"
data-toggle="modal"
data-target="#wishlist-modal"
data-wl-product="${product.product_wishlist_url}"
data-wl-ajax-element="#wishlist-modal .modal-body"
data-wl-ajax-input="#wishlist-content > *"
aria-label="{{'Toevoegen aan wishlist'|t}}"
>
<i class="fa fa-heart"></i>
</button>
`}
</div>
</div>
</div>
</div>
</article>
</div>
`;
};
const CrossSellProducts = ({ cartItems }) => {
const [crossSellItems, setCrossSellItems] = useState([]);
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
const fetchCrossSellItems = async (skus) => {
try {
const url = new URL(`${window.location.origin}/cart/cross`);
url.searchParams.set('sku', skus.join(','));
const response = await fetch(url.toString(), {
headers: {
Accept: 'application/json'
}
});
const data = await response.json();
setCrossSellItems(data || []);
} catch (error) {
console.log(error);
}
};
if (cartItems.length > 0) {
fetchCrossSellItems(cartItems.map(({ sku }) => sku));
}
}, [cartItems]);
if (crossSellItems.length === 0) {
return null;
}
return html`
<hr />
<h3>
{{'Anderen bestelden ook'|t}}
</h3>
<div class="product-grid grid">
${crossSellItems.map(item => {
return html`
<${ProductCard} key="${item.id}" product="${item || {}}" />
`;
})}
</div>
`;
};
const CartPage = () => {
const [cart, setCart] = useState({});
const [isLoadingCart, setIsLoadingCart] = useState(true);
const [error, setError] = useState(null);
const checkoutUrl = cart && cart.checkout && cart.checkout.url || '';
const handleRemoveItems = async item => {
const updatedCart = await window.Storefront.removeCartItems(item.ids);
$('body').trigger('cart:updated', updatedCart);
$(document).trigger('af.cart.remove', [[{
"price": ((item.details?.at(0)?.pricing?.amount || 0) / 100).toFixed(2),
"product_id": item.sku,
"sku": item.sku,
"quantity": item.quantity,
}]]);
};
const handleChangeQuantity = async (item, quantity) => {
try {
if (!quantity || quantity === item.quantity) {
return;
}
let cartResponse;
if (quantity > item.quantity) {
cartResponse = await window.Storefront.addCartItems([
{
sku: item.sku,
quantity: quantity - item.quantity,
},
]);
$(document).trigger('af.cart.add', [[{
"price": ((item.details?.at(0)?.pricing?.amount || 0) / 100).toFixed(2),
"product_id": item.sku,
"sku": item.sku,
"quantity": quantity - item.quantity,
}]]);
} else {
const difference = item.quantity - quantity;
const ids = [...(item.ids || [])].reverse().slice(0, difference);
cartResponse = await window.Storefront.removeCartItems(ids);
$(document).trigger('af.cart.remove', [[{
"price": ((item.details?.at(0)?.pricing?.amount || 0) / 100).toFixed(2),
"product_id": item.sku,
"sku": item.sku,
"quantity": difference,
}]]);
}
$('body').trigger('cart:updated', cartResponse);
} catch(error) {
$("#add-response-modal .modal-header .modal-title").html($("#add-response-modal").data("error-title"));
$("#add-response-modal .modal-body").addClass('text-center');
$("#add-response-modal .modal-body").html('<span class="fa-stack fa-3x text-warning"><i class="far fa-circle fa-stack-2x"></i><i class="fas fa-exclamation fa-stack-1x"></i></span><p class="lead mt-15">' + getErrorMessage(error) + '</p>');
$("#add-response-modal").modal('show');
}
};
useEffect(() => {
const updateCart = (event, val) => {
setCart(val);
if (isLoadingCart) {
setIsLoadingCart(false);
}
};
$('body').on('cart:updated', updateCart);
return () => {
$('body').off('cart:updated', updateCart);
}
}, []);
return html`
<section id="contentcart" class="container mb-20 cart-version-flex${isLoadingCart ? ' loading-ajax' : ''}">
<div class="mb-15">
<div class="cart-header d-flex align-items-center justify-content-between">
<h1 class="my-0 cart-title">
{{'Winkelwagen'|t}}
</h1>
${(cart && cart.items && cart.items.length > 0) && !isLoadingCart && html`
<a class="btn btn-success visible-sm visible-xs" href="${checkoutUrl}">
{{'Afrekenen'|t}}
</a>`}
</div>
<div class="clearfix"></div>
</div>
${error && html`
<div class="alert alert-warning">${error}</div>
`}
${(cart && cart.items && cart.items.length > 0) || isLoadingCart ? html`
<div class="d-flex flex-wrap flex-md-nowrap mx-n15">
<${ItemsGrid} isLoading="${isLoadingCart}" items="${cart.items || []}" onRemove="${handleRemoveItems}" onChangeQuantity="${handleChangeQuantity}" />
<${CartPageSummary} isLoading="${isLoadingCart}" cart="${cart}" checkoutUrl="${checkoutUrl}" />
</div>
<${CrossSellProducts} cartItems="${cart?.items || []}" />
` : html`
<h3 class="text-center text-warning">
{{'Uw winkelwagen is leeg.'|t}}
<br />
<br />
<a class="center btn btn-primary" href="{{home_url}}">{{'Bekijk onze producten'|t}}</a>
</h3>
`}
</section>
`;
};
const CartDrawerItem = ({ item, onRemove, onChangeQuantity }) => {
const { label, sku, image, ids, quantity, subtotal, total, adjustments } = item;
const increaseQuantity = item => onChangeQuantity(item, item.quantity + 1);
const decreaseQuantity = item => onChangeQuantity(item, item.quantity - 1);
return html`
<div class="cart-overview-item">
<div class="cart-overview-item-inner d-flex gap-8">
<div class="cart-overview-item-delete align-self-center">
<a class="text-primary" onClick="${() => onRemove(item)}">
<i class="fa fa-times text-danger fa-sm"></i>
</a>
</div>
<div class="cart-overview-item-image px-5">
<div class="d-block icon-75">
<div class="lazyload-wrapper lazyload-square">
<img class="lazyload lazyload-cover" data-src="${image}" alt="${'{{'Afbeelding van [name]'|t}}'.replace('[name]', label || sku) }"/>
</div>
</div>
</div>
<div class="cart-overview-item-group d-flex flex-column justify-content-between flex-fill">
<div class="cart-overview-item-name">
${label || sku}
</div>
${adjustments.length > 0 && html`
<div class="d-flex justify-content-between text-muted mt-8">
<small>
{{'Subtotaal'|t}}
</small>
<small>
${formatPrice(subtotal)}
</small>
</div>
${adjustments.map(adjustment => html`
<div class="d-flex justify-content-between text-muted">
<small>
${adjustment.description}${adjustment.isPercentage ? ` (${adjustment.amount}%)` : ''}
</small>
<small>
${formatPrice(adjustment.outcome.amount * (adjustment.isDiscount ? -1 : 1))}
</small>
</div>
`)}
`}
<div class="d-flex justify-content-between${adjustments.length > 0 ? ' mt-12' : ''}">
<div class="cart-overview-item-quantity align-self-end">
<div class="quantity-counter">
<button
type="button"
class="btn"
aria-label="{{'aantal min 1'|t}}"
onClick="${() => decreaseQuantity(item)}"
>
<i class="fa fa-minus fa-sm"></i>
</button>
<div class="mx-10">
${quantity}
</div>
<button
type="button"
class="btn"
aria-label="{{'aantal plus 1'|t}}"
onClick="${() => increaseQuantity(item)}"
>
<i class="fa fa-plus fa-sm"></i>
</button>
</div>
</div>
<div class="cart-overview-item-price text-right">
<strong>
${formatPrice(total)}
</strong>
</div>
</div>
</div>
</div>
</div>
`;
};
const CartDrawer = () => {
const [cart, setCart] = useState(null);
const [isLoadingCart, setIsLoadingCart] = useState(false);
const drawerRef = useRef(null);
const handleRemoveItems = async item => {
const updatedCart = await window.Storefront.removeCartItems(item.ids);
setCart(updatedCart);
$('body').trigger('cart:updated', updatedCart);
$(document).trigger('af.cart.remove', [[{
"price": ((item.details?.at(0)?.pricing?.amount || 0) / 100).toFixed(2),
"product_id": item.sku,
"sku": item.sku,
"quantity": item.quantity,
}]]);
};
const handleChangeQuantity = async (item, quantity) => {
try {
if (!quantity || quantity === item.quantity) {
return;
}
let cartResponse;
if (quantity > item.quantity) {
cartResponse = await window.Storefront.addCartItems([
{
sku: item.sku,
quantity: quantity - item.quantity,
},
]);
$(document).trigger('af.cart.add', [[{
"price": ((item.details?.at(0)?.pricing?.amount || 0) / 100).toFixed(2),
"product_id": item.sku,
"sku": item.sku,
"quantity": quantity - item.quantity,
}]]);
} else {
const difference = item.quantity - quantity;
const ids = [...(item.ids || [])].reverse().slice(0, difference);
cartResponse = await window.Storefront.removeCartItems(ids);
$(document).trigger('af.cart.remove', [[{
"price": ((item.details?.at(0)?.pricing?.amount || 0) / 100).toFixed(2),
"product_id": item.sku,
"sku": item.sku,
"quantity": difference,
}]]);
}
setCart(cartResponse);
$('body').trigger('cart:updated', cartResponse);
} catch(error) {
$("#add-response-modal .modal-header .modal-title").html($("#add-response-modal").data("error-title"));
$("#add-response-modal .modal-body").addClass('text-center');
$("#add-response-modal .modal-body").html('<span class="fa-stack fa-3x text-warning"><i class="far fa-circle fa-stack-2x"></i><i class="fas fa-exclamation fa-stack-1x"></i></span><p class="lead mt-15">' + getErrorMessage(error) + '</p>');
$("#add-response-modal").modal('show');
}
};
const getCartData = async () => {
setIsLoadingCart(true);
let tokenIsValid = true;
const { search = '', pathname } = window?.location || {};
const params = new URLSearchParams(search);
const cartToken = params.get('hash');
const isCartPage = pathname === '/cart';
const intent = isCartPage ? 'VIEW_CART' : null;
const cartResponse = await window.Storefront.getCart(cartToken, intent).catch(() => {
if (cartToken) {
tokenIsValid = false;
return window.Storefront.getCart(null, intent);
}
return Promise.reject(new Error('Cart not found'));
});
if (cartResponse && tokenIsValid && cartToken) {
await window.Storefront.storeCartTokenInStorage(cartToken);
}
// window.Storefront.getCart().then(response => {
setIsLoadingCart(false);
$('body').trigger('cart:updated', cartResponse);
// });
};
useEffect(() => {
getCartData();
}, []);
useEffect(() => {
const updateCart = (event, val) => {
if(!!val) {
setCart(val);
}
if (isLoadingCart) {
setIsLoadingCart(false);
}
};
$('body').on('cart:updated', updateCart);
return () => {
$('body').off('cart:updated', updateCart);
}
}, []);
return html`
<div class="drawer drawer-right" id="cart-drawer" ref="${drawerRef}">
<div class="drawer-header">
<div class="h4 m-0 fw-700">
{{"Winkelwagen"|t}}
</div>
<button class="btn btn-link m-4 p-0 icon-40 mr-n12" type="button" data-dismiss="drawer" aria-label="{{'Sluit winkelwagen'|t}}">
<i class="fa fa-times icon-size-20 text-black"></i>
</button>
</div>
<div class="drawer-body configurator-drawer-inner p-0">
${((cart && cart.items) || []).length === 0 ? html`
<div class="text-center">
<i class="fa fa-cart-plus text-primary fa-4x mt-40 mb-40"></i>
<span class="h4 mb-12">{{"Uw winkelwagen is leeg"|t}}</span>
<p class=" mb-40">{{"Bekijk het aanbod op onze website en voeg producten toe aan je winkelwagen."|t}}</p>
<button type="button" class="btn btn-primary" data-dismiss="drawer">
{{'Verder winkelen'|t}}
</button>
</div>
` : html`
<div class="cart-preview-items py-8">
${((cart && cart.items) || []).map(item => html`
<${CartDrawerItem}
key="${item.sku}"
item="${item}"
onRemove="${handleRemoveItems}"
onChangeQuantity="${handleChangeQuantity}"
/>
`)}
</div>
`}
</div>
${((cart && cart.items) || []).length ? html`
<div class="drawer-footer drawer-footer-sticky pb-32 px-32">
<hr class="mb-20" />
${(cart.adjustments || []).map(({ description, amount, isDiscount, isPercentage, outcome }, index) => html`
<div class="d-flex justify-content-between my-5">
<small>
${description}${isPercentage ? ` (${amount}%)` : ''}
</small>
<small>
${formatPrice((outcome.amount || 0) * (isDiscount ? -1 : 1) )}
</small>
</div>
`)}
${(cart.fees.shipping || []).map(({ description, total }, index) => html`
<div class="d-flex justify-content-between my-5">
<small>
${description}
</small>
<small>
${formatPrice(total)}
</small>
</div>
`)}
${(cart.fees.payment || []).map(({ description, total }, index) => html`
<div class="d-flex justify-content-between my-5">
<small>
${description}
</small>
<small>
${formatPrice(total)}
</small>
</div>
`)}
<div class="d-flex justify-content-between py-20">
<span>
{{'Totaal'|t}}
</span>
<span>
${formatPrice(cart.total)}
</span>
</div>
<hr class="mt-0 mb-32" />
<a class="btn btn-primary btn-block" href="{{cart_url}}">
{{'Afrekenen'|t}}
</a>
</div>
` : ''}
</div>
`;
};
const AccountDrawer = () => {
const [channel, setChannel] = useState(null);
const accountLink = channel?.links?.find(({ type }) => type === 'MY_ACCOUNT')?.value;
const [account, setAccount] = useState(window.Storefront.getUser());
const getChannelData = async () => {
const channelResponse = await window.Storefront.getChannel();
setChannel(channelResponse);
};
const handleSignOut = () => {
window.Storefront.signOut();
setAccount(window.Storefront.getUser());
location.reload();
};
useEffect(() => {
getChannelData();
}, []);
return html`
<div class="drawer drawer-right" id="account-drawer">
<div class="drawer-header">
<div class="h4 m-0 fw-700">
{{"Account"|t}}
</div>
<button class="btn btn-link m-4 p-0 icon-40 mr-n12" type="button" data-dismiss="drawer" aria-label="{{'Sluit winkelwagen'|t}}">
<i class="fa fa-times icon-size-20 text-black"></i>
</button>
</div>
<div class="drawer-body configurator-drawer-inner px-0 py-16 mx-0">
<ul class="nav nav-pills nav-stacked">
<li>
<a href="${accountLink}/account/orders" class="text-black">
<i class="fa-solid fa-receipt fa-fw mr-12"></i> {{"Mijn bestellingen"|t}}
</a>
</li>
<li>
<a href="${accountLink}/account/details" class="text-black">
<i class="fa-solid fa-address-card fa-fw mr-12"></i> {{"Mijn gegevens"|t}}
</a>
</li>
<li>
<a href="${accountLink}/account/addresses" class="text-black">
<i class="fa-solid fa-location-dot fa-fw mr-12"></i> {{"Mijn adressen"|t}}
</a>
</li>
<li>
<a href="${accountLink}/account/preferences" class="text-black">
<i class="fa-solid fa-sliders fa-fw mr-12"></i> {{"Mijn voorkeuren"|t}}
</a>
</li>
<li role="separator">
<hr class="my-8" />
</li>
<li>
<a onClick="${handleSignOut}" class="text-black">
<i class="fa-solid fa-right-from-bracket fa-fw mr-12"></i> {{"Uitloggen"|t}}
</a>
</li>
</ul>
</div>
</div>
`;
};
const AccountMenu = () => {
const [channel, setChannel] = useState(null);
const accountLink = channel?.links?.find(({ type }) => type === 'MY_ACCOUNT')?.value;
const [account, setAccount] = useState(window.Storefront.getUser());
const getChannelData = async () => {
const channelResponse = await window.Storefront.getChannel();
setChannel(channelResponse);
};
const handleSignOut = () => {
window.Storefront.signOut();
setAccount(window.Storefront.getUser());
location.reload();
};
useEffect(() => {
getChannelData();
}, []);
$('[data-toggle="tooltip"]').tooltip();
if (account) {
return html`
<a data-toggle="drawer" data-target="#account-drawer">
<i class="fa fa-user-check"></i>
<br />
<span class="hidden-xs account-link-label">${account.givenName}</span>
</a>
`;
}
return html`
<a href="${accountLink}" data-toggle="tooltip" data-placement="bottom" title="Inloggen">
<i class="fa fa-user-times"></i>
<br />
<span class="hidden-xs account-link-label">{{"Inloggen"|t}}</span>
</a>
`;
};
const StockUpdateSubscribeDialog = () => {
const [product, setProduct] = useState(null);
const [isSubmitting, setIsSubmitting] = useState(false);
const [isSubmitted, setIsSubmitted] = useState(false);
const [submittedData, setSubmittedData] = useState(null);
const { name, sku } = product || {};
const handleSubmit = async (event) => {
event.preventDefault();
$('#stock-update-email').parsley().removeError('serverError');
try {
const formIsValid = $('#stock-update-email').parsley().isValid();
if (!formIsValid) {
$('#stock-update-email').parsley().validate();
return;
}
setIsSubmitting(true);
const form = event.target;
const formData = new FormData(form);
const formValues = {};
formData.forEach((value, key) => {
formValues[key] = value;
});
const response = await window.Storefront.createStockUpdateSubscription(formValues);
setSubmittedData(formValues);
setIsSubmitted(true);
} catch (e) {
$('#stock-update-email').parsley().addError('serverError', {message: getErrorMessage(e) });
}
};
useEffect(() => {
$('#stock-update-email').parsley({
successClass: "form-val-success",
errorClass: "form-val-error",
errorsContainer: function(el) {
return el.$element.parents('.form-group').find('.field-error-container');
},
classHandler: function(el) {
return el.$element.closest(".validation-container");
},
errorsWrapper: "<ul class='list-unstyled text-danger'></ul>",
});
$('body').on('click', '[data-toggle="stock-update"]', function() {
$('#stock-update-subscribe-dialog').modal('show');
setProduct({
name: $(this).data('name'),
sku: $(this).data('sku'),
});
});
$('#stock-update-subscribe-dialog').on('hidden.bs.modal', () => {
$('#stock-update-form').trigger('reset');
setIsSubmitted(false);
setProduct(null);
setSubmittedData(null);
setIsSubmitting(false);
$('#stock-update-email').parsley({
successClass: "form-val-success",
errorClass: "form-val-error",
errorsContainer: function(el) {
return el.$element.parents('.form-group').find('.field-error-container');
},
classHandler: function(el) {
return el.$element.closest(".validation-container");
},
errorsWrapper: "<ul class='list-unstyled text-danger'></ul>",
});
});
}, []);
return html`
<form id="stock-update-form" novalidate="" onSubmit="${handleSubmit}">
<div class="modal fade" tabindex="-1" role="dialog" id="stock-update-subscribe-dialog">
<div class="modal-dialog" role="document">
<div class="modal-content">
<div class="modal-header">
<button type="button" class="close" data-dismiss="modal" aria-label="Close">
<span aria-hidden="true">×</span>
</button>
<div class="h4 modal-title">
${!isSubmitted && html`
{{'Blijf op de hoogte'|t}}
`}
${isSubmitted && html`
<i class="fa fa-check-circle text-success mr-12" aria-hidden="true" />
{{'We houden je op de hoogte'|t}}
`}
</div>
</div>
<div class="modal-body">
${!isSubmitted && html`
<p>
Helaas is <strong>${name}</strong> op dit moment uitverkocht.
Vul je e-mailadres in en ontvang een bericht zodra het weer op voorraad is.
</p>
<div class="form-group">
<label for="stock-update-email">E-mailadres</label>
<input type="hidden" name="sku" value="${sku}" />
<div class="validation-container">
<input type="email" class="form-control" name="email" id="stock-update-email" required="" />
</div>
<div class="field-error-container"></div>
</div>
`}
${isSubmitted && html`
<p>
Wanneer <strong>${name}</strong> leverbaar is, ontvang je een e-mailbericht op
<strong>${submittedData.email}</strong>.
</p>
`}
</div>
${!isSubmitted && html`
<div class="modal-footer" id="stock-update-dialog-footer-form">
<button type="submit" class="btn btn-info" disabled="${isSubmitting}">
{{'Houd mij op de hoogte'|t}}
</button>
</div>
`}
</div>
</div>
</div>
</form>
`;
};
const StockUpdateActionDialog = () => {
const { action, token } = Object.fromEntries(new URLSearchParams(location.search));
const [isLoading, setIsLoading] = useState(action !== 'unsubscribe_stock_updates');
const [isSuccessful, setIsSuccessful] = useState(false);
const [errorMessage, setErrorMessage] = useState(null);
const handleApprove = async () => {
try {
const response = await window.Storefront.approveStockUpdateSubscription(token);
console.log(response);
setIsSuccessful(response.isSuccessful);
} catch (error) {
setErrorMessage(getErrorMessage(error));
} finally {
setIsLoading(false);
}
};
const handleUnsubscribe = async () => {
try {
const response = await window.Storefront.removeStockUpdateSubscription(token);
console.log(response);
setIsSuccessful(response.isSuccessful);
} catch (error) {
setErrorMessage(getErrorMessage(error));
} finally {
setIsLoading(false);
}
};
useEffect(() => {
$('#stock-update-dialog').modal('show');
$('#stock-update-dialog').on('shown.bs.modal', function() {
switch(action) {
case 'approve_stock_updates':
handleApprove();
break;
case 'unsubscribe_stock_updates':
break;
default:
setIsLoading(false);
setErrorMessage(`Unknown action ${action} provided`);
}
});
}, []);
return html`
<div class="modal fade" tabindex="-1" role="dialog" id="stock-update-dialog">
<div class="modal-dialog" role="document">
<div class="modal-content">
<div class="modal-header">
<button type="button" class="close" data-dismiss="modal" aria-label="Close">
<span aria-hidden="true">×</span>
</button>
<h4 class="modal-title">
${isLoading && !errorMessage && action === 'approve_stock_updates' && '{{'Aanmelden...'|t}}'}
${!isLoading && !errorMessage && action === 'approve_stock_updates' && html`
<i class="fa fa-fw fa-check-circle text-success" /> {{'E-mailadres bevestigd'|t}}
`}
${action === 'unsubscribe_stock_updates' && !errorMessage && html`
${!isLoading && !isSuccessful && !errorMessage && html`
{{'Weet je zeker dat je wilt uitschrijven?'|t}}
`}
${isLoading && !errorMessage && '{{'Uitschrijven...'|t}}'}
${!isLoading && isSuccessful && !errorMessage && html`
<i class="fa fa-fw fa-check-circle text-success" />{{'Uitgeschreven'|t}}
`}
`}
${!!errorMessage && html`
<i class="fa fa-fw fa-times-circle text-danger" /> {{'Er is iets mis gegaan'|t}}
`}
</h4>
</div>
<div class="modal-body">
${isLoading && html`
<div class="d-flex justify-content-center py-24">
<i class="fa fa-circle-notch fa-spin fa-2x" />
</div>
`}
${!isLoading && !errorMessage && action === 'approve_stock_updates' && html`
<p>
Je e-mailadres is bevestigd. Wanneer we jouw producten weer op voorraad hebben sturen we een mail.
</p>
`}
${!isLoading && !errorMessage && !isSuccessful && action === 'unsubscribe_stock_updates' && html`
<p>
Wanneer je je uitschrijft voor voorraad updates krijg geen e-mails meer met voorraad updates.
</p>
`}
${!isLoading && !errorMessage && isSuccessful && action === 'unsubscribe_stock_updates' && html`
<p>
Je bent uitgeschreven van voorraad updates. Je ontvangt kun geen e-mails meer over voorraad updates.
</p>
`}
${!!errorMessage && html`
<p>${errorMessage}</p>
`}
</div>
${!isLoading && !errorMessage && !isSuccessful && action === 'unsubscribe_stock_updates' && html`
<div class="modal-footer">
<button
type="button"
class="btn btn-danger"
disable="${isLoading}"
onClick="${handleUnsubscribe}"
>
{{'Uitschrijven'|t}}
</button>
</div>
`}
</div>
</div>
</div>
`;
};
const setupStorefront = () => {
const client = createStorefrontClient({
domain: getDomain(window.location.host),
storefrontToken: '{{pluginData.orm.storefront}}',
graphQLClientOptions: {
headers: {
"Accept-Language": document.documentElement.lang,
}
}
});
if (window.visitor_id) {
client.setSessionID(window.visitor_id);
}
const renderCartDrawer = () => {
if (!document.getElementById('cart-drawer-container')) {
const container = document.createElement('div');
container.id = 'cart-drawer-container';
document.body.appendChild(container);
}
return render(html`<${CartDrawer} />`, document.getElementById('cart-drawer-container'));
};
const renderShareCartDialog = () => {
if (!document.getElementById('share-cart-dialog-container')) {
const container = document.createElement('div');
container.id = 'share-cart-dialog-container';
document.body.appendChild(container);
}
return render(html`<${ShareCartDialog} />`, document.getElementById('share-cart-dialog-container'));
};
const renderCartPage = () => {
if (document.getElementById('cart-container')) {
return render(html`<${CartPage} />`, document.getElementById('cart-container'));
}
console.warn('[Afosto Storefront] No element found with id "cart-container" to mount the CartPage on. Add the id to an element or check your storefrontScripts.twig file.')
};
const renderAccountDisplay = () => {
if (document.getElementById('account-display')) {
return render(html`<${AccountMenu} />`, document.getElementById('account-display'));
}
console.warn('[Afosto Storefront] No element found with id "account-display" to mount the AccountMenu on. Add the id to an element or check your storefrontScripts.twig file.')
};
const renderAccountDrawer = () => {
if (!document.getElementById('account-drawer-container')) {
const container = document.createElement('div');
container.id = 'account-drawer-container';
document.body.appendChild(container);
}
return render(html`<${AccountDrawer} />`, document.getElementById('account-drawer-container'));
};
const renderStockUpdateSubscribeDialog = () => {
if (!document.getElementById('stock-update-subscribe-dialog-wrapper')) {
const container = document.createElement('div');
container.id = 'stock-update-subscribe-dialog-wrapper';
document.body.appendChild(container);
}
return render(html`<${StockUpdateSubscribeDialog} />`, document.getElementById('stock-update-subscribe-dialog-wrapper'));
};
const renderStockUpdateActionDialog = () => {
if (!document.getElementById('stock-update-action-dialog-wrapper')) {
const container = document.createElement('div');
container.id = 'stock-update-action-dialog-wrapper';
document.body.appendChild(container);
}
return render(html`<${StockUpdateActionDialog} />`, document.getElementById('stock-update-action-dialog-wrapper'));
};
return {
...client,
client,
renderCartDrawer,
renderShareCartDialog,
renderCartPage,
renderAccountDisplay,
renderAccountDrawer,
renderStockUpdateSubscribeDialog,
renderStockUpdateActionDialog,
};
};
const afostoStorefront = setupStorefront();
window.Storefront = afostoStorefront;
const initializeCart = async () => {
afostoStorefront.renderCartDrawer();
afostoStorefront.renderAccountDisplay();
afostoStorefront.renderAccountDrawer();
if ("{{ type }}" === 'cart') {
afostoStorefront.renderCartPage();
afostoStorefront.renderShareCartDialog();
}
if ("{{ type }}" === 'product') {
afostoStorefront.renderStockUpdateSubscribeDialog();
}
if (!!location.search.match(/approve_stock_updates|unsubscribe_stock_updates/g)) {
afostoStorefront.renderStockUpdateActionDialog();
}
};
document.addEventListener("readystatechange", (event) => {
if (document.readyState === 'complete') {
initializeCart().catch(() => {
// Do nothing.
})
}
});
</script>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment