How can I add a cart panel to my store?
This tutorial will provide the code needed to setup a cart panel slide-out system for your store.
First, lets add the layouts/partials/cart-panel.twig code:
<div id="cart-panel" class="cart-panel isolate fixed z-[999] bottom-0 right-0 max-h-full overflow-y-auto overscroll-contain h-[90%] md:h-dvh w-full md:max-w-2xl bg-white [body.cart-panel--open_&]:shadow-[0_0_50px_0_rgba(0,0,0,0.3)]">
<button type="button" class="absolute z-10 right-0 top-0 flex items-center justify-center p-3 m-4" onclick="document.getElementById('cart-panel-cover').click()" aria-label="Close cart">
<svg viewBox="0 0 24 24" fill="currentColor" class="size-6">
<path fill-rule="evenodd" d="M5.47 5.47a.75.75 0 0 1 1.06 0L12 10.94l5.47-5.47a.75.75 0 1 1 1.06 1.06L13.06 12l5.47 5.47a.75.75 0 1 1-1.06 1.06L12 13.06l-5.47 5.47a.75.75 0 0 1-1.06-1.06L10.94 12 5.47 6.53a.75.75 0 0 1 0-1.06Z" clip-rule="evenodd" />
</svg>
</button>
<div class="relative px-4 pt-6 lg:pt-12 pb-6 lg:pb-12 mx-auto min-h-full w-full max-w-xl items-center text-center">
<h2 class="copy copy-heading text-base lg:text-lg font-medium">My Cart</h2>
<div class="pt-2.5 md:pt-3.5 lg:pt-6 pb-2.5 md:pb-3.5 lg:pb-6">
{% if cart().items is empty %}
<div class="space-y-4">
<p class="copy copy-minor-heading leading-6 text-sm">Your Shopping Cart is Empty</p>
<div class="copy copy-text text-sm leading-6 [&_a]:underline">You haven't put any items in your shopping cart.</div>
</div>
{% else %}
{% for item in cart().items %}
{% if item.isVisible() %}
{% snippet "cart-item" %}
{% endif %}
{% endfor %}
{% endif %}
</div>
<div class="pb-2.5 md:pb-3.5 lg:pb-6 w-full text-sm text-left">
<form action="{{ route("cart.discount") }}" method="post">
{{ csrf_field() }}
<div class="flex flex-col space-y-2">
<label for="cart-panel-discount-input" class="copy copy-text text-sm">Discount Code?</label>
<div class="flex space-x-2">
<input id="cart-panel-discount-input"
type="text"
class="border border-gray-300 w-full px-2.5 py-2"
placeholder="Enter Code"
name="code"
autocapitalize="off"
autocomplete="off"
spellcheck="false" />
<button type="submit" class="px-5 py-3 text-xs font-medium transition border border-[color:#000000] text-[color:#000000] hover:text-[color:#030712]">Apply</button>
</div>
</div>
</form>
</div>
<div class="w-full [&>*]:py-1 text-sm">
<div class="flex">
<div class="font-medium">Subtotal</div>
<div class="flex-1 text-right">{{ cart().subtotal }}</div>
</div>
{% for discount in cart().discounts %}
<div class="flex">
<div class="font-medium">
{% if discount.code is not empty %}
Discount ({{ discount.code }})
<form action="{{ route("cart.discount") }}" method="post">
{{ method_field("delete") }}
{{ csrf_field() }}
<button type="submit"
name="key" value="{{ discount.key }}"
class="mt-3 inline-block text-xs font-semibold text-gray-600 transition hover:text-gray-900 lg:mt-0">
× remove
</button>
</form>
{% else %}
{{ discount.name }}
{% endif %}
</div>
<div class="flex-1 text-right">- {{ discount.price.inc }}</div>
</div>
{% endfor %}
{% if cart().shippable %}
<div class="flex">
<div class="font-medium">Shipping</div>
<div class="flex-1 text-right">
{% if shipping %}
{{ shipping.inc }}
{% else %}
<span class="text-xs text-gray-600">Calculated at next step</span>
{% endif %}
</div>
</div>
{% endif %}
{% for surcharge in cart().surcharges %}
<div class="flex">
<div class="font-medium">{{ surcharge.name }}</div>
<div class="flex-1 text-right">{{ surcharge.total_price }}</div>
</div>
{% endfor %}
{% for voucher in cart().vouchers %}
<div class="flex">
<div class="font-medium">
{% if voucher.code is not empty %}
Voucher ({{ voucher.code }})
{% else %}
Voucher
{% endif %}
<form action="{{ route("cart.discount") }}" method="post">
{{ method_field("delete") }}
{{ csrf_field() }}
<button type="submit"
name="key" value="{{ voucher.key }}"
class="mt-3 inline-block text-xs font-semibold text-gray-600 transition hover:text-gray-900 lg:mt-0">
× remove
</button>
</form>
</div>
<div class="flex-1 text-right">- {{ voucher.price.inc }}</div>
</div>
{% endfor %}
<div class="flex text-base">
<div class="flex flex-wrap items-center text-left">
<span class="block w-full font-medium">Total</span>
{% if cart().total.hasTax() %}
<span class="block text-xs text-gray-600">Including taxes of {{ cart().total.tax }}</span>
{% endif %}
</div>
<div class="flex-1 text-right text-lg font-medium">{{ cart().total.inc }}</div>
</div>
</div>
<div class="sticky bottom-0 pt-2.5 md:pt-3.5 lg:pt-6 pb-1 lg:pb-2.5">
<a href="{{ route('checkout') }}" class="px-5 py-3 font-medium transition bg-[color:#000000] text-[color:#FFFFFF] hover:bg-[color:#030712] cta-button inline-block mx-auto text-sm text-center w-full">Checkout Securely</a>
</div>
</div>
</div>
{% js %}
<div id="cart-panel-cover" class="cart-cover cart-cover--panel fixed inset-0 z-[998]" style="background-color: rgba(0, 0, 0, 0.3)"></div>
<script>
!(() => {
let timeout, animate = true;
document.querySelector('body').classList.add('reduced-animation');
const open = () => {
clearTimeout(timeout);
if (animate) {
document.querySelector('body').classList.remove('reduced-animation');
}
document.querySelector('body').classList.add('cart-panel--open');
timeout = setTimeout(() => document.querySelector('body').classList.add('reduced-animation'), 500);
}
const close = () => {
clearTimeout(timeout);
if (animate) {
document.querySelector('body').classList.remove('reduced-animation');
}
document.querySelector('body').classList.remove('cart-panel--open');
timeout = setTimeout(() => document.querySelector('body').classList.add('reduced-animation'), 500);
}
AeroEvents.on('product.added-to-cart', function (data) {
open();
const parser = new DOMParser();
const doc = parser.parseFromString(data.cart_panel, 'text/html');
document.getElementById('cart-panel').innerHTML = doc.querySelector('#cart-panel').innerHTML;
});
window.addEventListener('DOMContentLoaded', () => {
document.querySelector('#cart-panel-cover').addEventListener('click', close);
document.querySelectorAll('.header-link--cart').forEach((el) => el.addEventListener('click', (e) => {
e.preventDefault();
open();
}));
});
})();
</script>
{% endjs %}
{% css %}
<style>
.cart-panel { transform: translateY(100%); @media (min-width: 768px) { transform: translateX(100%); } }
.cart-cover { transform: translateX(100%); opacity: 0; }
.cart-panel--open .cart-panel { transform: translateY(0); @media (min-width: 768px) { transform: translateX(0); } }
.cart-panel--open .cart-cover--panel { transform: translateX(0); opacity: 1; }
body:not(.reduced-animation) .cart-panel { transition: transform 300ms; }
body:not(.reduced-animation) .cart-cover { transition: opacity 300ms; }
</style>
{% endcss %}
This file uses the snippets/cart-item.twig snippet:
<div class="flex flex-wrap py-1.5 first:pt-0 last:pb-0 text-left">
<div class="flex items-start py-2 pr-2 text-xs lg:py-4 max-w-[80px]">
{% if item.url %}<a href="{{ item.url }}" aria-label="{{ item.name }}">{% endif %}
{% set image_width = 320 %}
{% set image_height = 320 %}
{% if item.image %}
<img src="{{ image_factory(image_width, image_height, item.image.file).crop() }}"
srcset="{{ image_factory(image_width, image_height, item.image.file).crop().retina() }} 2x"
width="{{ image_width }}" height="{{ image_height }}" loading="lazy"
class="block w-full"
alt="{{ item.image.alt|default(item.name) }}">
{% else %}
<img class="no-image block w-full" width="{{ image_width }}" height="{{ image_height }}"
src="{{ image_factory(image_width, image_height).placeholder() }}"
alt="{{ item.name }}" />
{% endif %}
{% if item.url %}</a>{% endif %}
</div>
<div class="relative flex flex-1 flex-wrap">
<div class="w-full py-2 lg:w-4/6 lg:flex-1 lg:p-4">
<div class="flex">
{% if item.url %}<a href="{{ item.url }}" class="inline-block text-sm no-underline lg:hover:underline">{% endif %}
{{ item.name }}
{% if item.url %}</a>{% endif %}
</div>
{% if item.options|length %}
<div class="mt-2 text-xs text-gray-600">
{% for option in item.options %}
<div class="mt-px">
{% set value = option.display_value|default(option.value) %}
{% if value is empty %}
<span class="font-medium">{{ option.name }}</span>
{% else %}
<span class="font-medium">{{ option.name }}:</span> {{ value }}
{% endif %}
{% if option.hasPrice() %}
({{ option.price.inc }})
{% endif %}
{% if option.hasFiles() %}
[{{ option.fileCount() }} attached files]
{% endif %}
</div>
{% endfor %}
</div>
{% endif %}
{% if item.hasSubItems() %}
<div class="text-grey-600 mt-2 text-sm">
{% for sub_item in item.subItems %}
{% if sub_item.isHidden() %}
<div class="{% if errors.has(sub_item.id) or sub_item.hasError() %}text-red-800{% endif %}">
{{ sub_item.quantity }} x {{ sub_item.name }}
{% if not sub_item.isFree() %}
– {{ sub_item.subtotal.inc }}
{% endif %}
{% if errors.has(sub_item.id) or sub_item.hasError() %}
<span class="mt-3 block w-full text-sm font-medium text-red-800" role="alert">{{ errors.first(sub_item.id)|default(sub_item.error) }}</span>
{% endif %}
{% if sub_item.canBeRemoved() %}
<form class="ml-1 inline-block" action="{{ route("cart.update") }}" method="post">
{{ method_field("put") }}
{{ csrf_field() }}
<input type="hidden" name="cart_item_id" value="{{ sub_item.id }}">
(<button type="submit" name="quantity" value="0" class="inline-block text-xs font-semibold text-gray-600 transition hover:text-gray-900">× remove</button>)
</form>
{% endif %}
</div>
{% endif %}
{% endfor %}
</div>
{% endif %}
{% for file in item.files %}
{% if file.hasExtension(["jpg", "jpeg", "png"]) %}
<img class="mt-1 mr-1 block"
width="50" height="50" loading="lazy"
src="{{ image_factory(50, 50, file.path).contain() }}"
srcset="{{ image_factory(50, 50, file.path).contain().retina() }}" alt="">
{% endif %}
{% endfor %}
{% if errors.has(item.id) or item.hasError() %}
<span class="mt-3 block w-full text-sm font-medium text-red-800" role="alert">{{ errors.first(item.id)|default(item.error) }}</span>
{% endif %}
</div>
<div class="flex w-full flex-wrap items-center justify-end py-2 lg:w-2/6 lg:flex-col lg:p-4 lg:pr-0">
<div class="flex-1 order-1 w-1/2 text-right lg:order-none lg:w-full">
{% if not item.isFree() %}
<div class="block text-sm {% if item.isReduced() %}text-red-700{% endif %}">
<span class="price">{{ item.subtotal.inc }}</span>
</div>
{% if item.isReduced() %}
<div class="block text-xs line-through">
<span class="price">{{ item.fullSubtotal.inc }}</span>
</div>
{% endif %}
{% if item.quantity > 1 %}
{% set unit_price %}
<span class="price">{{ item.price.inc }}</span>
{% endset %}
<div class="block text-xs text-gray-600">{{ unit_price }} each</div>
{% endif %}
{% endif %}
</div>
<div class="w-1/2 lg:order-1 lg:mt-2 lg:w-full lg:text-right">
{% set quantity %}
<form action="{{ route("cart.update") }}" method="post" class="inline-flex items-center">
{{ method_field("put") }}
{{ csrf_field() }}
<input type="hidden" name="cart_item_id" value="{{ item.id }}">
{% if item.canBeChanged() %}
<select name="quantity"
class="ml-2 font-medium transition border border-[color:#000000] text-[color:#000000] hover:text-[color:#030712] py-1.5 pl-1.5 text-xs"
onchange="this.form.submit()">
{% if item.canBeRemoved() %}
<option value="0">0</option>
{% endif %}
{% for qty in range(item.quantity > 2 ? item.quantity - 1 : 1, item.quantity + 5) %}
<option value="{{ qty }}" {% if qty == item.quantity %}selected{% endif %}>{{ qty }}</option>
{% endfor %}
</select>
{% else %}
<span class="ml-1">{{ item.quantity }}</span>
{% endif %}
{% if item.canBeRemoved() %}
<button type="submit" class="button button--icon ml-2 -mr-1.5 hidden p-1 sm:flex"
name="quantity" value="0" aria-label="Remove item">
<svg class="h-5 w-5" width="24" height="24" fill="none" viewBox="0 0 24 24">
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M6.75 7.75L7.59115 17.4233C7.68102 18.4568 8.54622 19.25 9.58363 19.25H14.4164C15.4538 19.25 16.319 18.4568 16.4088 17.4233L17.25 7.75" />
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M9.75 7.5V6.75C9.75 5.64543 10.6454 4.75 11.75 4.75H12.25C13.3546 4.75 14.25 5.64543 14.25 6.75V7.5" />
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M5 7.75H19" />
</svg>
</button>
{% endif %}
</form>
{% endset %}
<div class="flex items-center whitespace-nowrap text-xs lg:justify-end">
<span class="font-medium text-gray-600">Quantity:</span> {{ quantity }}
</div>
</div>
</div>
</div>
</div>
Then we must extend the \Aero\Store\Http\Responses\CartItemAdd response builder in order to update the cart panel:
\Aero\Store\Http\Responses\CartItemAdd::extend(function (\Aero\Store\Http\Responses\CartItemAdd $action, \Closure $next) {
$action->setData('cart_panel', (string) view('layouts.partials.cart_panel'));
});
The cart_panel is retrieved by our product.added-to-cart listener and used to effectively re-render the cart panel to use the new cart data.
Now we can include the cart panel in header.twig:
{% include "layouts/partials/cart-panel.twig" %}
Finally, if you want the cart panel to open when the cart button (typically an anchor tag with href={{ route('cart') }}) is clicked (rather than redirecting to the cart page), then add the header-link--cart class to it.