Cart Components Intermediate 10 min read
Cart Totals and Discount Code Input
Build cart total displays with subtotals, discounts, shipping estimates, and taxes. Handle multiple discount types and real-time calculations.
The cart footer is where customers make their final decision. Clear pricing with discounts, estimated shipping, and a prominent checkout button helps convert browsers to buyers.
Theme Integration
This component is part of the cart drawer component hierarchy:
snippets/cart-drawer.liquid (included in theme.liquid)└── <div id="cart-drawer-root"> └── CartDrawer (React) └── CartFooter ← You are here ├── CartTotals └── CartDiscountsSee Cart Drawer Architecture for the complete Liquid snippet setup and cart totals data.
Data Source
| Prop/State | Source | Liquid Field |
|---|---|---|
cart.totalPrice | Zustand store | cart.total_price |
cart.totalDiscount | Zustand store | cart.total_discount |
cart.shippingPrice | Zustand store / API | Calculated via /cart/shipping_rates.json |
cart.taxesIncluded | Zustand store | cart.taxes_included |
cart.discountCodes | Zustand store | cart.discount_codes |
subtotal | Derived | Sum of item.linePrice |
code | Local state | - (user input) |
isApplying | Local state | - |
error | Local state | - |
CartFooter Component
import type { Cart } from '@/types/cart';import { formatMoney } from '@/utils/money';import { Button } from '@/components/ui';import { CartDiscounts } from './CartDiscounts';import { CartTotals } from './CartTotals';import styles from './CartFooter.module.css';
interface CartFooterProps { cart: Cart; // Full cart data with items and totals.}
/** * CartFooter displays totals and checkout button. * Sticks to bottom of the drawer. */export function CartFooter({ cart }: CartFooterProps) { // Calculate if there are any discounts applied. const hasDiscounts = cart.totalDiscount > 0 || cart.discountCodes.length > 0;
return ( <footer className={styles.footer}> {/* Discount codes section */} {hasDiscounts && <CartDiscounts cart={cart} />}
{/* Totals breakdown */} <CartTotals cart={cart} />
{/* Checkout button */} <div className={styles.checkout}> <Button as="a" href="/checkout" variant="primary" size="large" fullWidth > Checkout · {formatMoney(cart.totalPrice)} </Button>
{/* Continue shopping link */} <a href="/collections/all" className={styles.continueLink}> Continue Shopping </a> </div>
{/* Trust badges / payment icons */} <div className={styles.trust}> <PaymentIcons /> </div> </footer> );}
/** * Payment method icons for trust signals. */function PaymentIcons() { return ( <div className={styles.paymentIcons}> <span className={styles.trustText}>Secure checkout with</span> <div className={styles.icons}> <VisaIcon /> <MastercardIcon /> <AmexIcon /> <PaypalIcon /> <ApplePayIcon /> </div> </div> );}
// SVG icons for payment methods (simplified)function VisaIcon() { return <span className={styles.icon} title="Visa">💳</span>;}function MastercardIcon() { return <span className={styles.icon} title="Mastercard">💳</span>;}function AmexIcon() { return <span className={styles.icon} title="American Express">💳</span>;}function PaypalIcon() { return <span className={styles.icon} title="PayPal">💳</span>;}function ApplePayIcon() { return <span className={styles.icon} title="Apple Pay">💳</span>;}.footer { /* Sticky footer at bottom of drawer. */ position: sticky; bottom: 0; padding: 1rem 1.25rem; background-color: var(--color-background); border-top: 1px solid var(--color-border); /* Shadow to indicate scrollable content above. */ box-shadow: 0 -4px 12px rgba(0, 0, 0, 0.05);}
.checkout { display: flex; flex-direction: column; gap: 0.75rem; margin-top: 1rem;}
.continueLink { text-align: center; color: var(--color-text-muted); font-size: 0.875rem; text-decoration: underline;}
.continueLink:hover { color: var(--color-text);}
.trust { margin-top: 1rem; padding-top: 1rem; border-top: 1px solid var(--color-border);}
.paymentIcons { display: flex; flex-direction: column; align-items: center; gap: 0.5rem;}
.trustText { font-size: 0.75rem; color: var(--color-text-muted);}
.icons { display: flex; gap: 0.5rem;}
.icon { font-size: 1.25rem; opacity: 0.7;}CartTotals Component
import type { Cart } from '@/types/cart';import { formatMoney } from '@/utils/money';import styles from './CartTotals.module.css';
interface CartTotalsProps { cart: Cart;}
/** * CartTotals shows the breakdown of subtotal, discounts, shipping, and total. * Uses a definition list for semantic markup. */export function CartTotals({ cart }: CartTotalsProps) { // Calculate subtotal (before discounts). const subtotal = cart.items.reduce((sum, item) => sum + item.linePrice, 0);
// Check if shipping is calculated. const hasShipping = cart.shippingPrice !== null;
return ( <dl className={styles.totals}> {/* Subtotal */} <div className={styles.row}> <dt className={styles.label}>Subtotal</dt> <dd className={styles.value}>{formatMoney(subtotal)}</dd> </div>
{/* Discounts (if any) */} {cart.totalDiscount > 0 && ( <div className={`${styles.row} ${styles.discount}`}> <dt className={styles.label}>Discounts</dt> <dd className={styles.value}>-{formatMoney(cart.totalDiscount)}</dd> </div> )}
{/* Shipping estimate */} <div className={styles.row}> <dt className={styles.label}>Shipping</dt> <dd className={styles.value}> {hasShipping ? ( cart.shippingPrice === 0 ? ( <span className={styles.free}>Free</span> ) : ( formatMoney(cart.shippingPrice!) ) ) : ( <span className={styles.calculated}>Calculated at checkout</span> )} </dd> </div>
{/* Taxes (if calculated) */} {cart.taxesIncluded && ( <div className={styles.row}> <dt className={styles.label}>Taxes</dt> <dd className={styles.value}> <span className={styles.included}>Included</span> </dd> </div> )}
{/* Total */} <div className={`${styles.row} ${styles.total}`}> <dt className={styles.totalLabel}>Total</dt> <dd className={styles.totalValue}>{formatMoney(cart.totalPrice)}</dd> </div> </dl> );}.totals { margin: 0; display: flex; flex-direction: column; gap: 0.5rem;}
.row { display: flex; justify-content: space-between; align-items: center;}
.label { font-size: 0.875rem; color: var(--color-text-muted);}
.value { margin: 0; font-size: 0.875rem;}
/* Discount row styling. */.discount .value { color: var(--color-success);}
/* Free shipping badge. */.free { color: var(--color-success); font-weight: 500;}
/* "Calculated at checkout" styling. */.calculated { color: var(--color-text-muted); font-size: 0.8125rem;}
.included { color: var(--color-text-muted); font-size: 0.8125rem;}
/* Total row - emphasized. */.total { margin-top: 0.5rem; padding-top: 0.75rem; border-top: 1px solid var(--color-border);}
.totalLabel { font-size: 1rem; font-weight: 600; color: var(--color-text);}
.totalValue { margin: 0; font-size: 1.125rem; font-weight: 700;}CartDiscounts Component
import { useState } from 'react';import type { Cart, DiscountCode } from '@/types/cart';import { useCart } from '@/stores/cart';import { Button, Input } from '@/components/ui';import styles from './CartDiscounts.module.css';
interface CartDiscountsProps { cart: Cart;}
/** * CartDiscounts shows applied discount codes with remove functionality * and an input to add new codes. */export function CartDiscounts({ cart }: CartDiscountsProps) { const { applyDiscount, removeDiscount } = useCart(); const [code, setCode] = useState(''); const [isApplying, setIsApplying] = useState(false); const [error, setError] = useState<string | null>(null);
// Handle discount code submission. const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); if (!code.trim()) return;
setIsApplying(true); setError(null);
try { await applyDiscount(code.trim()); setCode(''); // Clear input on success. } catch (err) { setError('Invalid discount code'); } finally { setIsApplying(false); } };
// Handle removing a discount code. const handleRemove = async (discountCode: string) => { try { await removeDiscount(discountCode); } catch (err) { setError('Failed to remove discount'); } };
return ( <div className={styles.discounts}> {/* Applied discount codes */} {cart.discountCodes.length > 0 && ( <div className={styles.applied}> {cart.discountCodes.map((discount) => ( <DiscountTag key={discount.code} discount={discount} onRemove={() => handleRemove(discount.code)} /> ))} </div> )}
{/* Add discount code form */} <form onSubmit={handleSubmit} className={styles.form}> <Input type="text" placeholder="Discount code" value={code} onChange={(e) => setCode(e.target.value)} disabled={isApplying} aria-label="Discount code" /> <Button type="submit" variant="secondary" size="small" loading={isApplying} disabled={!code.trim()} > Apply </Button> </form>
{/* Error message */} {error && ( <p className={styles.error} role="alert"> {error} </p> )} </div> );}
/** * DiscountTag shows an applied discount with remove button. */interface DiscountTagProps { discount: DiscountCode; onRemove: () => void;}
function DiscountTag({ discount, onRemove }: DiscountTagProps) { return ( <div className={styles.tag}> <span className={styles.tagIcon}>🏷️</span> <span className={styles.tagCode}>{discount.code}</span> {discount.applicable && ( <span className={styles.tagAmount}> -{discount.type === 'percentage' ? `${discount.amount}%` : formatMoney(discount.amount)} </span> )} <button type="button" className={styles.tagRemove} onClick={onRemove} aria-label={`Remove discount ${discount.code}`} > × </button> </div> );}
// Import formatMoney for displaying discount amounts.import { formatMoney } from '@/utils/money';.discounts { padding-bottom: 1rem; margin-bottom: 1rem; border-bottom: 1px solid var(--color-border);}
/* Applied discount codes. */.applied { display: flex; flex-wrap: wrap; gap: 0.5rem; margin-bottom: 0.75rem;}
/* Individual discount tag. */.tag { display: inline-flex; align-items: center; gap: 0.375rem; padding: 0.25rem 0.5rem; background-color: var(--color-success-light, #ecfdf5); border: 1px solid var(--color-success); border-radius: var(--radius-full); font-size: 0.8125rem;}
.tagIcon { font-size: 0.875rem;}
.tagCode { font-weight: 500; color: var(--color-success-dark, #065f46); text-transform: uppercase;}
.tagAmount { color: var(--color-success);}
.tagRemove { display: flex; align-items: center; justify-content: center; width: 1.25rem; height: 1.25rem; padding: 0; margin-left: 0.25rem; border: none; background: transparent; color: var(--color-success-dark, #065f46); font-size: 1rem; line-height: 1; cursor: pointer; border-radius: var(--radius-full); transition: background-color 0.15s ease;}
.tagRemove:hover { background-color: rgba(0, 0, 0, 0.1);}
/* Discount code form. */.form { display: flex; gap: 0.5rem;}
.form input { flex: 1;}
/* Error message. */.error { margin: 0.5rem 0 0; font-size: 0.8125rem; color: var(--color-error);}Shipping Estimate Component
import { useState, useEffect } from 'react';import { useCart } from '@/stores/cart';import { Input, Button } from '@/components/ui';import { formatMoney } from '@/utils/money';import styles from './ShippingEstimate.module.css';
/** * ShippingEstimate lets users calculate shipping before checkout. * Uses Shopify's shipping rates API. */export function ShippingEstimate() { const { cart } = useCart(); const [zip, setZip] = useState(''); const [country, setCountry] = useState('US'); const [isCalculating, setIsCalculating] = useState(false); const [rates, setRates] = useState<ShippingRate[] | null>(null); const [error, setError] = useState<string | null>(null);
// Fetch shipping rates from Shopify. const calculateShipping = async () => { if (!zip.trim()) return;
setIsCalculating(true); setError(null);
try { const response = await fetch('/cart/shipping_rates.json', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ shipping_address: { zip, country, }, }), });
if (!response.ok) { throw new Error('Unable to calculate shipping'); }
const data = await response.json(); setRates(data.shipping_rates); } catch (err) { setError('Unable to calculate shipping for this address'); setRates(null); } finally { setIsCalculating(false); } };
return ( <div className={styles.estimate}> <h4 className={styles.title}>Estimate Shipping</h4>
<div className={styles.form}> <select className={styles.select} value={country} onChange={(e) => setCountry(e.target.value)} > <option value="US">United States</option> <option value="CA">Canada</option> <option value="GB">United Kingdom</option> {/* Add more countries as needed */} </select>
<Input type="text" placeholder="ZIP / Postal code" value={zip} onChange={(e) => setZip(e.target.value)} />
<Button type="button" variant="secondary" size="small" onClick={calculateShipping} loading={isCalculating} > Calculate </Button> </div>
{/* Display shipping rates */} {rates && rates.length > 0 && ( <ul className={styles.rates}> {rates.map((rate) => ( <li key={rate.name} className={styles.rate}> <span className={styles.rateName}>{rate.name}</span> <span className={styles.ratePrice}> {rate.price === '0.00' ? 'Free' : formatMoney(parseFloat(rate.price) * 100)} </span> </li> ))} </ul> )}
{/* No rates message */} {rates && rates.length === 0 && ( <p className={styles.noRates}>No shipping options available for this address.</p> )}
{/* Error message */} {error && <p className={styles.error}>{error}</p>} </div> );}
interface ShippingRate { name: string; price: string; delivery_days: number | null;}Cart Type Definitions
// src/types/cart.ts - Extended for totals and discounts
export interface DiscountCode { code: string; // The discount code string. applicable: boolean; // Whether the code is currently valid. type: 'percentage' | 'fixed_amount'; // Type of discount. amount: number; // Discount amount (percentage or cents).}
export interface Cart { token: string; itemCount: number; totalPrice: number; // Final price in cents. totalDiscount: number; // Total discount amount in cents. currency: string; items: CartLineItem[]; discountCodes: DiscountCode[]; // Applied discount codes. shippingPrice: number | null; // Shipping cost in cents, null if not calculated. taxesIncluded: boolean; // Whether prices include tax. note: string | null; // Cart note.}
export interface CartLineItem { key: string; productId: number; variantId: number; title: string; variantTitle: string; quantity: number; price: number; // Unit price in cents. linePrice: number; // Total price for this line (price * quantity). discountedPrice: number; // Price after line-level discounts. image: string | null; url: string; handle: string; available: boolean; properties: Record<string, string>;}Key Takeaways
- Clear pricing: Show subtotal, discounts, shipping, and total separately
- Discount codes: Support applying and removing discount codes
- Shipping estimates: Allow calculating shipping before checkout
- Trust signals: Display payment icons to build confidence
- Sticky footer: Keep checkout button visible while scrolling
- Semantic markup: Use definition lists for totals breakdown
In the next lesson, we’ll add cart notes and custom attributes support.
Finished this lesson?
Mark it complete to track your progress.
Discussion
Loading comments...