Menu

Change language

GitHub repo

Cart Drawer

blockcommerce

Shopping cart drawer & full cart page view featuring free shipping progress meter, interactive line items with quantity steppers and variant tags, promo code application, order summary with tax & shipping calculation, trust badges, and empty state.

Installation

$npx shadcn@latest add https://uipkge.dev/r/lit/cart-drawer.json
Manifest:https://uipkge.dev/r/lit/cart-drawer.jsonInstalls to:src/uip/blocks/cart-drawer/

Variants

Loading interactive previews…

Props

NameType / ValuesDefaultRequired
variant'drawer' | 'page'draweroptional
currencystringUSDoptional
free-shipping-thresholdnumber300optional
tax-ratenumber0.08optional
initial-promo-codestring—optional

Schema

Type aliases exported from this item's source. Use these to shape the data you pass in.

CartItem
interface CartItem {
  id: string
  name: string
  variant: string
  price: number
  originalPrice?: number
  qty: number
  inStock?: boolean
  image: string
}
HTMLElementTagNameMap
interface HTMLElementTagNameMap {
    'uip-cart-drawer': UipCartDrawer
  }
}

Files installed (1)

  • src/uip/blocks/cart-drawer/cart-drawer.ts28.9 kB
    import { LitElement, css, html, nothing } from 'lit'
    import { Leaf, Lock, Minus, Plus, RotateCcw, ShieldCheck, ShoppingBag, Tag, Trash2, Truck, X } from 'lucide'
    import { cn } from '../../lib/utils'
    import { tailwind } from '../../lib/styles'
    import { ThemeController } from '../../lib/theme'
    import { icon } from '../../lib/icon'
    // Side-effect imports: register the composed elements for standalone use
    // (consumers who install only this block still get working primitives).
    import '../../components/badge/badge'
    import '../../components/button/button'
    import '../../components/input/input'
    import type { UipInput } from '../../components/input/input'
    import '../../components/progress/progress'
    import '../../components/separator/separator'
    
    export interface CartItem {
      id: string
      name: string
      variant: string
      price: number
      originalPrice?: number
      qty: number
      inStock?: boolean
      image: string
    }
    
    const DEFAULT_ITEMS: CartItem[] = [
      {
        id: 'item-1',
        name: 'Aero Minimalist Runner',
        variant: 'Size: 10.5 · Color: Matte Black',
        price: 120,
        originalPrice: 140,
        qty: 1,
        inStock: true,
        image: 'https://images.unsplash.com/photo-1542291026-7eec264c27ff?w=300&auto=format&fit=crop&q=80',
      },
      {
        id: 'item-2',
        name: 'Technical Shell Parka',
        variant: 'Size: L · Color: Mineral Gray',
        price: 95,
        originalPrice: 110,
        qty: 1,
        inStock: true,
        image: 'https://images.unsplash.com/photo-1591047139829-d91aecb6caea?w=300&auto=format&fit=crop&q=80',
      },
      {
        id: 'item-3',
        name: 'Minimalist Leather Cardholder',
        variant: 'Size: Slim · Color: Saddle Brown',
        price: 70,
        originalPrice: 85,
        qty: 1,
        inStock: true,
        image: 'https://images.unsplash.com/photo-1627123424574-724758594e93?w=300&auto=format&fit=crop&q=80',
      },
    ]
    
    /**
     * <uip-cart-drawer> — the registry CartDrawer block as a web component.
     *
     * Class strings are React's CartDrawer verbatim. React state (useState) becomes
     * { state: true } fields; `items` is a property (attribute: false — set it from
     * JS, like the demo's empty-cart story does). `onCheckout` (items, total) /
     * `onContinueShopping` / `onClose` / `onUpdateQuantity` (id, qty) /
     * `onRemoveItem` (id) / `onApplyPromo` (code) / `onRemovePromo` become
     * `checkout` / `continue-shopping` / `close` / `update-quantity` /
     * `remove-item` / `apply-promo` / `remove-promo` CustomEvents (bubbles,
     * composed; payloads in `detail`). React's `promoError` state is never set by
     * the source (every non-empty code succeeds) so it is not ported.
     */
    export class UipCartDrawer extends LitElement {
      static shadowRootOptions = { ...LitElement.shadowRootOptions, delegatesFocus: true }
      // :host display can't be a utility class (the host has no template of its own).
      static styles = [tailwind, css`:host { display: block; }`]
    
      static properties = {
        variant: { reflect: true },
        currency: {},
        freeShippingThreshold: { type: Number, attribute: 'free-shipping-threshold' },
        taxRate: { type: Number, attribute: 'tax-rate' },
        initialPromoCode: { attribute: 'initial-promo-code' },
        items: { attribute: false },
        cartItems: { state: true },
        promoCodeInput: { state: true },
        appliedPromo: { state: true },
        promoDiscount: { state: true },
        promoSuccess: { state: true },
      }
    
      variant: 'drawer' | 'page' = 'drawer'
      currency = 'USD'
      freeShippingThreshold = 300
      taxRate = 0.08
      initialPromoCode = ''
      items: CartItem[] = DEFAULT_ITEMS
    
      private cartItems: CartItem[] = []
      private promoCodeInput = ''
      private appliedPromo = ''
      private promoDiscount = 0
      private promoSuccess = ''
    
      constructor() {
        super()
        new ThemeController(this)
      }
    
      connectedCallback() {
        super.connectedCallback()
        this.setAttribute('data-uipkge', '')
        this.setAttribute('data-slot', 'cart-drawer')
      }
    
      willUpdate(changed: Map<string, unknown>) {
        if (changed.has('items')) {
          this.cartItems = (this.items ?? []).map((item) => ({ ...item }))
        }
        if (changed.has('initialPromoCode')) {
          this.appliedPromo = this.initialPromoCode
          this.promoDiscount = this.initialPromoCode ? 20 : 0
        }
      }
    
      private get totalItemsCount() {
        return this.cartItems.reduce((acc, item) => acc + item.qty, 0)
      }
    
      private get subtotal() {
        return this.cartItems.reduce((acc, item) => acc + item.price * item.qty, 0)
      }
    
      private get isFreeShippingUnlocked() {
        return this.subtotal >= this.freeShippingThreshold && this.cartItems.length > 0
      }
    
      private get amountAwayFromFreeShipping() {
        return Math.max(0, this.freeShippingThreshold - this.subtotal)
      }
    
      private get shippingProgress() {
        if (this.cartItems.length === 0) return 0
        return Math.min(100, Math.round((this.subtotal / this.freeShippingThreshold) * 100))
      }
    
      private get shippingFee() {
        if (this.cartItems.length === 0) return 0
        return this.isFreeShippingUnlocked ? 0 : 15
      }
    
      private get discountedSubtotal() {
        return Math.max(0, this.subtotal - this.promoDiscount)
      }
    
      private get estimatedTax() {
        if (this.cartItems.length === 0) return 0
        return Number((this.discountedSubtotal * this.taxRate).toFixed(2))
      }
    
      private get total() {
        if (this.cartItems.length === 0) return 0
        return Number((this.discountedSubtotal + this.shippingFee + this.estimatedTax).toFixed(2))
      }
    
      private formatCurrency(amount: number) {
        try {
          return new Intl.NumberFormat('en-US', {
            style: 'currency',
            currency: this.currency,
            minimumFractionDigits: 2,
          }).format(amount)
        } catch {
          return `$${amount.toFixed(2)}`
        }
      }
    
      private updateQuantity(id: string, delta: number) {
        const next: CartItem[] = []
        for (const item of this.cartItems) {
          if (item.id !== id) {
            next.push(item)
            continue
          }
          const newQty = item.qty + delta
          if (newQty <= 0) {
            this.dispatchEvent(new CustomEvent('remove-item', { detail: { id }, bubbles: true, composed: true }))
            continue
          }
          this.dispatchEvent(new CustomEvent('update-quantity', { detail: { id, qty: newQty }, bubbles: true, composed: true }))
          next.push({ ...item, qty: newQty })
        }
        this.cartItems = next
      }
    
      private removeItem(id: string) {
        this.cartItems = this.cartItems.filter((item) => item.id !== id)
        this.dispatchEvent(new CustomEvent('remove-item', { detail: { id }, bubbles: true, composed: true }))
      }
    
      private handleApplyPromo() {
        this.promoSuccess = ''
        const trimmed = this.promoCodeInput.trim().toUpperCase()
        if (!trimmed) return
        if (trimmed === 'SUMMER20' || trimmed === 'SAVE20') {
          this.appliedPromo = trimmed
          this.promoDiscount = 20
          this.promoSuccess = '$20.00 coupon discount applied!'
        } else if (trimmed === 'SAVE10') {
          this.appliedPromo = trimmed
          this.promoDiscount = 10
          this.promoSuccess = '$10.00 coupon discount applied!'
        } else {
          this.appliedPromo = trimmed
          this.promoDiscount = 15
          this.promoSuccess = 'Promo code applied!'
        }
        this.promoCodeInput = ''
        this.dispatchEvent(new CustomEvent('apply-promo', { detail: { code: trimmed }, bubbles: true, composed: true }))
      }
    
      private handleRemovePromo() {
        this.appliedPromo = ''
        this.promoDiscount = 0
        this.promoSuccess = ''
        this.dispatchEvent(new CustomEvent('remove-promo', { bubbles: true, composed: true }))
      }
    
      private handleCheckout() {
        this.dispatchEvent(
          new CustomEvent('checkout', {
            detail: { items: this.cartItems.map((i) => ({ ...i })), total: this.total },
            bubbles: true,
            composed: true,
          }),
        )
      }
    
      private resetCart() {
        this.cartItems = DEFAULT_ITEMS.map((item) => ({ ...item }))
      }
    
      private renderPromoRow(placeholder: string) {
        if (this.appliedPromo) {
          return html`<div class="bg-primary/5 border-primary/20 flex items-center justify-between rounded-lg border px-3 py-2 text-xs">
            <div class="flex items-center gap-2">
              ${icon(Tag, 'tag', 'text-primary size-3.5')}
              <span class="text-foreground font-medium">${this.appliedPromo}</span>
              <span class="text-success text-success font-semibold tabular-nums">-${this.formatCurrency(this.promoDiscount)}</span>
            </div>
            <uip-button
              variant="ghost"
              size="icon-sm"
              class="text-muted-foreground hover:text-foreground size-6"
              aria-label="Remove promo code"
              @click=${() => this.handleRemovePromo()}
            >
              ${icon(X, 'x', 'size-3.5')}
            </uip-button>
          </div>`
        }
        return html`<div class="flex gap-2">
          <uip-input
            .value=${this.promoCodeInput}
            placeholder=${placeholder}
            size="small"
            class="text-xs"
            @input=${(e: Event) => {
              this.promoCodeInput = (e.target as UipInput).value ?? ''
            }}
            @keydown=${(e: KeyboardEvent) => {
              if (e.key === 'Enter') {
                e.preventDefault()
                this.handleApplyPromo()
              }
            }}
          ></uip-input>
          <uip-button
            variant="outline"
            size="sm"
            class="shrink-0 text-xs"
            ?disabled=${!this.promoCodeInput.trim()}
            @click=${() => this.handleApplyPromo()}
          >
            Apply
          </uip-button>
        </div>`
      }
    
      private renderCostRows(page: boolean) {
        return html`<div class="space-y-1.5 text-xs">
          <div class="flex justify-between">
            <span class="text-muted-foreground">Subtotal</span>
            <span class="text-foreground font-medium tabular-nums">${this.formatCurrency(this.subtotal)}</span>
          </div>
          ${this.promoDiscount > 0
            ? html`<div class="text-success flex justify-between">
                <span>Coupon (${this.appliedPromo})</span>
                <span class="font-medium tabular-nums">-${this.formatCurrency(this.promoDiscount)}</span>
              </div>`
            : nothing}
          <div class="flex justify-between">
            <span class="text-muted-foreground">Estimated Shipping</span>
            ${this.shippingFee === 0
              ? html`<span class="text-success font-medium">Free</span>`
              : html`<span class="text-foreground font-medium tabular-nums">${this.formatCurrency(this.shippingFee)}</span>`}
          </div>
          <div class="flex justify-between">
            <span class="text-muted-foreground">${page ? 'Estimated Tax (8%)' : 'Estimated Tax'}</span>
            <span class="text-foreground font-medium tabular-nums">${this.formatCurrency(this.estimatedTax)}</span>
          </div>
          <uip-separator class="my-2"></uip-separator>
          ${page
            ? html`<div class="flex flex-wrap items-baseline justify-between gap-x-2 gap-y-0.5 text-base font-semibold">
                <span>Total</span>
                <span class="tabular-nums">${this.formatCurrency(this.total)}</span>
              </div>`
            : html`<div class="flex flex-wrap items-baseline justify-between gap-x-2 gap-y-0.5 text-sm">
                <span class="font-semibold">Total</span>
                <span class="font-semibold tabular-nums">${this.formatCurrency(this.total)}</span>
              </div>`}
        </div>`
      }
    
      render() {
        return html`
          <div data-slot="cart-drawer" class=${cn('w-full')}>
            ${this.variant === 'drawer' ? this.renderDrawer() : this.renderPage()}
          </div>
        `
      }
    
      private renderDrawer() {
        return html`<div
          class="bg-card text-card-foreground border-border/80 mx-auto flex h-full max-h-[820px] min-h-[640px] w-full max-w-md flex-col overflow-hidden rounded-2xl border shadow-xs"
        >
          <div class="border-border/60 flex shrink-0 items-center justify-between border-b px-5 py-4">
            <div class="flex items-center gap-2">
              <h2 class="text-base font-semibold tracking-tight">Shopping Cart</h2>
              ${this.totalItemsCount > 0
                ? html`<uip-badge variant="secondary" class="tabular-nums">${this.totalItemsCount}</uip-badge>`
                : nothing}
            </div>
            <uip-button
              variant="ghost"
              size="icon-sm"
              aria-label="Close cart drawer"
              @click=${() => this.dispatchEvent(new CustomEvent('close', { bubbles: true, composed: true }))}
            >
              ${icon(X, 'x', 'size-4')}
            </uip-button>
          </div>
    
          ${this.cartItems.length > 0
            ? html`<div class="bg-muted/30 border-border/40 shrink-0 border-b px-5 py-3.5">
                <div class="flex items-center justify-between gap-2 text-xs">
                  ${this.isFreeShippingUnlocked
                    ? html`<span class="text-primary flex items-center gap-1.5 font-medium">
                        ${icon(Truck, 'truck', 'size-3.5')} You&apos;ve unlocked Free Express Shipping!
                      </span>`
                    : html`<span class="text-muted-foreground">
                        🎉 You&apos;re
                        <strong class="text-foreground font-semibold tabular-nums"
                          >${this.formatCurrency(this.amountAwayFromFreeShipping)}</strong
                        >
                        away from Free Shipping!
                      </span>`}
                  <span class="text-muted-foreground font-medium tabular-nums">${this.shippingProgress}%</span>
                </div>
                <uip-progress value=${this.shippingProgress} class="mt-2 h-1.5"></uip-progress>
              </div>`
            : nothing}
    
          <div class="flex-1 overflow-y-auto p-5">
            ${this.cartItems.length === 0
              ? html`<div class="flex h-full flex-col items-center justify-center py-12 text-center">
                  <div class="bg-muted text-muted-foreground mb-4 flex size-14 items-center justify-center rounded-full">
                    ${icon(ShoppingBag, 'shopping-bag', 'size-7 stroke-[1.5]')}
                  </div>
                  <h3 class="text-base font-semibold">Your cart is empty</h3>
                  <p class="text-muted-foreground mt-1 max-w-xs text-xs leading-relaxed">
                    Looks like you haven&apos;t added any products to your cart yet. Explore our latest items!
                  </p>
                  <uip-button class="mt-5 gap-2" size="sm" @click=${() => this.resetCart()}>
                    ${icon(ShoppingBag, 'shopping-bag', 'size-3.5')} Explore Products
                  </uip-button>
                </div>`
              : html`<div class="space-y-4">
                  <ul class="divide-border/60 divide-y" role="list">
                    ${this.cartItems.map(
                      (item) => html`<li class="flex gap-3.5 py-4 first:pt-0 last:pb-0">
                        <div class="border-border/60 bg-muted/40 relative size-20 shrink-0 overflow-hidden rounded-lg border shadow-xs">
                          <img src=${item.image} alt=${item.name} class="size-full object-cover" />
                        </div>
                        <div class="flex min-w-0 flex-1 flex-col justify-between">
                          <div>
                            <div class="flex items-start justify-between gap-2">
                              <h3 class="text-foreground truncate text-sm leading-snug font-medium">${item.name}</h3>
                              <uip-button
                                variant="ghost"
                                size="icon-sm"
                                class="text-muted-foreground hover:text-destructive -mt-1 -mr-1.5 size-7 shrink-0"
                                aria-label=${`Remove ${item.name} from cart`}
                                @click=${() => this.removeItem(item.id)}
                              >
                                ${icon(Trash2, 'trash-2', 'size-3.5')}
                              </uip-button>
                            </div>
                            <p class="text-muted-foreground mt-0.5 text-xs">${item.variant}</p>
                            <div class="mt-1 flex items-center gap-1.5">
                              <span class="bg-success size-1.5 rounded-full"></span>
                              <span class="text-success text-xs font-medium">In stock</span>
                            </div>
                          </div>
                          <div class="mt-2.5 flex items-center justify-between">
                            <div class="border-border/80 bg-background flex items-center rounded-md border shadow-xs">
                              <uip-button
                                variant="ghost"
                                size="icon-sm"
                                class="size-7 rounded-none rounded-l-md"
                                aria-label=${`Decrease quantity for ${item.name}`}
                                @click=${() => this.updateQuantity(item.id, -1)}
                              >
                                ${icon(Minus, 'minus', 'size-3')}
                              </uip-button>
                              <span class="w-7 text-center text-xs font-medium tabular-nums">${item.qty}</span>
                              <uip-button
                                variant="ghost"
                                size="icon-sm"
                                class="size-7 rounded-none rounded-r-md"
                                aria-label=${`Increase quantity for ${item.name}`}
                                @click=${() => this.updateQuantity(item.id, 1)}
                              >
                                ${icon(Plus, 'plus', 'size-3')}
                              </uip-button>
                            </div>
                            <div class="text-right">
                              ${item.originalPrice
                                ? html`<span class="text-muted-foreground mr-1.5 text-xs tabular-nums line-through"
                                    >${this.formatCurrency(item.originalPrice * item.qty)}</span
                                  >`
                                : nothing}
                              <span class="text-foreground text-sm font-semibold tabular-nums"
                                >${this.formatCurrency(item.price * item.qty)}</span
                              >
                            </div>
                          </div>
                        </div>
                      </li>`,
                    )}
                  </ul>
                  <uip-separator class="my-3"></uip-separator>
                  <div class="space-y-2">
                    ${this.renderPromoRow('Promo code (e.g. SUMMER20)')}
                    ${this.promoSuccess
                      ? html`<p class="text-success text-xs font-medium">${this.promoSuccess}</p>`
                      : nothing}
                  </div>
                </div>`}
          </div>
    
          ${this.cartItems.length > 0
            ? html`<div class="border-border/60 bg-card shrink-0 space-y-3 border-t p-5">
                ${this.renderCostRows(false)}
                <div class="space-y-2 pt-1">
                  <uip-button class="w-full justify-center gap-2 font-medium shadow-xs" size="default" @click=${() => this.handleCheckout()}>
                    ${icon(Lock, 'lock', 'size-3.5')} Proceed to Checkout
                  </uip-button>
                  <uip-button
                    variant="ghost"
                    class="text-muted-foreground hover:text-foreground w-full text-xs"
                    size="sm"
                    @click=${() => this.dispatchEvent(new CustomEvent('continue-shopping', { bubbles: true, composed: true }))}
                  >
                    Continue Shopping
                  </uip-button>
                </div>
                <div class="border-border/40 text-muted-foreground grid grid-cols-1 gap-2 border-t pt-3 text-center text-xs sm:grid-cols-3">
                  <div class="flex flex-col items-center gap-1">
                    ${icon(RotateCcw, 'rotate-ccw', 'size-3.5')}
                    <span>30-day returns</span>
                  </div>
                  <div class="flex flex-col items-center gap-1">
                    ${icon(ShieldCheck, 'shield-check', 'size-3.5')}
                    <span>256-bit secure</span>
                  </div>
                  <div class="flex flex-col items-center gap-1">
                    ${icon(Leaf, 'leaf', 'size-3.5')}
                    <span>Carbon neutral</span>
                  </div>
                </div>
              </div>`
            : nothing}
        </div>`
      }
    
      private renderPage() {
        return html`<div class="mx-auto w-full max-w-6xl space-y-6">
          <div class="flex flex-col gap-1 sm:flex-row sm:items-center sm:justify-between">
            <div>
              <h1 class="text-2xl font-bold tracking-tight">Shopping Cart</h1>
              <p class="text-muted-foreground mt-0.5 text-xs">Review your selected items and calculate shipping before checkout.</p>
            </div>
            ${this.totalItemsCount > 0
              ? html`<uip-badge variant="outline" class="w-fit text-xs tabular-nums">${this.totalItemsCount} items in cart</uip-badge>`
              : nothing}
          </div>
    
          ${this.cartItems.length > 0
            ? html`<div class="bg-card border-border/80 rounded-xl border p-4 shadow-xs">
                <div class="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
                  <div class="flex items-center gap-2 text-xs">
                    ${this.isFreeShippingUnlocked
                      ? html`${icon(Truck, 'truck', 'text-primary size-4')}
                          <span class="text-primary font-semibold">You&apos;ve unlocked Free Express Shipping on this order!</span>`
                      : html`<span class="text-muted-foreground">
                          🎉 You&apos;re
                          <strong class="text-foreground font-semibold tabular-nums"
                            >${this.formatCurrency(this.amountAwayFromFreeShipping)}</strong
                          >
                          away from Free Shipping!
                        </span>`}
                  </div>
                  <span class="text-muted-foreground text-xs font-medium tabular-nums">${this.shippingProgress}% completed</span>
                </div>
                <uip-progress value=${this.shippingProgress} class="mt-2.5 h-2"></uip-progress>
              </div>`
            : nothing}
    
          ${this.cartItems.length === 0
            ? html`<div class="bg-card border-border/80 flex flex-col items-center justify-center rounded-2xl border py-16 text-center shadow-xs">
                <div class="bg-muted text-muted-foreground mb-4 flex size-16 items-center justify-center rounded-full">
                  ${icon(ShoppingBag, 'shopping-bag', 'size-8 stroke-[1.5]')}
                </div>
                <h2 class="text-lg font-semibold">Your cart is currently empty</h2>
                <p class="text-muted-foreground mt-1 max-w-sm text-sm">
                  Before you proceed to checkout you must add some products to your shopping cart.
                </p>
                <uip-button class="mt-6 gap-2" size="default" @click=${() => this.resetCart()}>
                  ${icon(ShoppingBag, 'shopping-bag', 'size-4')} Explore Products
                </uip-button>
              </div>`
            : html`<div class="grid grid-cols-1 items-start gap-8 lg:grid-cols-12">
                <div class="space-y-4 lg:col-span-8">
                  <div class="bg-card border-border/80 divide-border/60 divide-y rounded-2xl border shadow-xs">
                    ${this.cartItems.map(
                      (item) => html`<div class="flex flex-col gap-4 p-5 sm:flex-row sm:items-center sm:justify-between">
                        <div class="flex items-center gap-4">
                          <div class="border-border/60 bg-muted/40 relative size-20 shrink-0 overflow-hidden rounded-lg border shadow-xs">
                            <img src=${item.image} alt=${item.name} class="size-full object-cover" />
                          </div>
                          <div>
                            <h3 class="text-foreground text-sm font-semibold">${item.name}</h3>
                            <p class="text-muted-foreground mt-0.5 text-xs">${item.variant}</p>
                            <div class="mt-1.5 flex items-center gap-2">
                              <span class="bg-success size-1.5 rounded-full"></span>
                              <span class="text-success text-xs font-medium">In stock</span>
                              <span class="text-muted-foreground text-xs font-normal tabular-nums">
                                · ${this.formatCurrency(item.price)} each
                              </span>
                            </div>
                          </div>
                        </div>
                        <div class="flex items-center justify-between gap-6 sm:justify-end">
                          <div class="border-border/80 bg-background flex items-center rounded-md border shadow-xs">
                            <uip-button
                              variant="ghost"
                              size="icon-sm"
                              class="size-8 rounded-none rounded-l-md"
                              aria-label=${`Decrease quantity for ${item.name}`}
                              @click=${() => this.updateQuantity(item.id, -1)}
                            >
                              ${icon(Minus, 'minus', 'size-3.5')}
                            </uip-button>
                            <span class="w-8 text-center text-xs font-medium tabular-nums">${item.qty}</span>
                            <uip-button
                              variant="ghost"
                              size="icon-sm"
                              class="size-8 rounded-none rounded-r-md"
                              aria-label=${`Increase quantity for ${item.name}`}
                              @click=${() => this.updateQuantity(item.id, 1)}
                            >
                              ${icon(Plus, 'plus', 'size-3.5')}
                            </uip-button>
                          </div>
                          <div class="w-24 text-right">
                            ${item.originalPrice
                              ? html`<span class="text-muted-foreground block text-xs tabular-nums line-through"
                                  >${this.formatCurrency(item.originalPrice * item.qty)}</span
                                >`
                              : nothing}
                            <span class="text-foreground text-base font-semibold tabular-nums"
                              >${this.formatCurrency(item.price * item.qty)}</span
                            >
                          </div>
                          <uip-button
                            variant="ghost"
                            size="icon-sm"
                            class="text-muted-foreground hover:text-destructive size-8"
                            aria-label=${`Remove ${item.name} from cart`}
                            @click=${() => this.removeItem(item.id)}
                          >
                            ${icon(Trash2, 'trash-2', 'size-4')}
                          </uip-button>
                        </div>
                      </div>`,
                    )}
                  </div>
    
                  <div class="bg-card border-border/80 grid grid-cols-1 gap-4 rounded-xl border p-4 shadow-xs sm:grid-cols-3">
                    <div class="flex items-center gap-3 text-xs">
                      <div class="bg-muted text-foreground flex size-8 shrink-0 items-center justify-center rounded-md">
                        ${icon(RotateCcw, 'rotate-ccw', 'size-4')}
                      </div>
                      <div>
                        <p class="font-medium">Free 30-Day Returns</p>
                        <p class="text-muted-foreground">Hassle-free return policy</p>
                      </div>
                    </div>
                    <div class="flex items-center gap-3 text-xs">
                      <div class="bg-muted text-foreground flex size-8 shrink-0 items-center justify-center rounded-md">
                        ${icon(ShieldCheck, 'shield-check', 'size-4')}
                      </div>
                      <div>
                        <p class="font-medium">256-Bit SSL Security</p>
                        <p class="text-muted-foreground">Bank-grade data encryption</p>
                      </div>
                    </div>
                    <div class="flex items-center gap-3 text-xs">
                      <div class="bg-muted text-foreground flex size-8 shrink-0 items-center justify-center rounded-md">
                        ${icon(Leaf, 'leaf', 'size-4')}
                      </div>
                      <div>
                        <p class="font-medium">Carbon Neutral</p>
                        <p class="text-muted-foreground">Offset delivery emissions</p>
                      </div>
                    </div>
                  </div>
                </div>
    
                <div class="space-y-4 lg:sticky lg:top-6 lg:col-span-4">
                  <div class="bg-card border-border/80 space-y-4 rounded-2xl border p-5 shadow-xs">
                    <h2 class="text-base font-semibold">Order Summary</h2>
                    <div class="space-y-2">
                      <label class="text-muted-foreground text-xs font-medium">Have a coupon code?</label>
                      ${this.renderPromoRow('Enter code (SUMMER20)')}
                    </div>
                    <uip-separator></uip-separator>
                    ${this.renderCostRows(true)}
                    <uip-button class="w-full justify-center gap-2 font-medium shadow-xs" size="lg" @click=${() => this.handleCheckout()}>
                      ${icon(Lock, 'lock', 'size-4')} Proceed to Checkout
                    </uip-button>
                    <uip-button
                      variant="ghost"
                      class="text-muted-foreground hover:text-foreground w-full text-xs"
                      size="sm"
                      @click=${() => this.dispatchEvent(new CustomEvent('continue-shopping', { bubbles: true, composed: true }))}
                    >
                      Continue Shopping
                    </uip-button>
                  </div>
                </div>
              </div>`}
        </div>`
      }
    }
    
    customElements.get('uip-cart-drawer') || customElements.define('uip-cart-drawer', UipCartDrawer)
    
    declare global {
      interface HTMLElementTagNameMap {
        'uip-cart-drawer': UipCartDrawer
      }
    }
    

Raw manifest:https://uipkge.dev/r/lit/cart-drawer.json