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 uipkge-ng@latest add cart-drawer
Manifest:https://uipkge.dev/r/angular/cart-drawer.jsonInstalls to:src/app/components/blocks/cart-drawer/

Variants

Loading interactive previews…

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
}
CartCheckoutEvent
interface CartCheckoutEvent {
  items: CartItem[]
  total: number
}
CartQuantityEvent
interface CartQuantityEvent {
  id: string
  qty: number
}

Files installed (2)

  • src/app/components/blocks/cart-drawer/cart-drawer.component.ts35.1 kB
    import { Component, EventEmitter, Input, Output, type OnInit, signal, ChangeDetectionStrategy } from '@angular/core'
    import {
      Leaf,
      Lock,
      LucideAngularModule,
      Minus,
      Plus,
      RotateCcw,
      ShieldCheck,
      ShoppingBag,
      Tag,
      Trash2,
      Truck,
      X,
    } from 'lucide-angular'
    import { cn } from '@/lib/utils'
    import { UiBadgeComponent } from '@/ui/badge/badge.component'
    import { UiButtonComponent } from '@/ui/button/button.component'
    import { UiInputComponent } from '@/ui/input/input.component'
    import { UiProgressComponent } from '@/ui/progress/progress.component'
    import { UiSeparatorComponent } from '@/ui/separator/separator.component'
    
    export interface CartItem {
      id: string
      name: string
      variant: string
      price: number
      originalPrice?: number
      qty: number
      inStock?: boolean
      image: string
    }
    
    export type CartDrawerVariant = 'drawer' | 'page'
    
    export interface CartCheckoutEvent {
      items: CartItem[]
      total: number
    }
    
    export interface CartQuantityEvent {
      id: string
      qty: number
    }
    
    export const DEFAULT_CART_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',
      },
    ]
    
    const copyItems = (items: CartItem[]): CartItem[] => items.map((item) => ({ ...item }))
    
    /**
     * CartDrawer -- the React block, 1:1: a slide-over cart (`variant="drawer"`) or a full cart
     * page (`variant="page"`) with a free-shipping meter, quantity steppers, promo codes
     * (SUMMER20 / SAVE20 = $20, SAVE10 = $10, anything else = $15), an order summary with shipping
     * and tax, trust badges and an empty state whose button restores the sample items.
     * Emits `checkout`, `continueShopping`, `close`, `updateQuantity`, `removeItem`,
     * `applyPromo` and `removePromo` where React calls the matching `on*` prop.
     */
    @Component({
      changeDetection: ChangeDetectionStrategy.Eager,
      selector: 'ui-cart-drawer, [ui-cart-drawer]',
      standalone: true,
      // React renders the root <div> itself: the host stays out of layout and `class` goes to the root.
      host: { '[attr.class]': '"contents"' },
      imports: [
        LucideAngularModule,
        UiBadgeComponent,
        UiButtonComponent,
        UiInputComponent,
        UiProgressComponent,
        UiSeparatorComponent,
      ],
      template: `
        <div data-slot="cart-drawer" [class]="rootClass">
          @if (variant === 'drawer') {
            <!-- DRAWER VARIANT -->
            <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"
            >
              <!-- Drawer Header -->
              <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>
                  @if (totalItemsCount > 0) {
                    <span ui-badge variant="secondary" class="tabular-nums">{{ totalItemsCount }}</span>
                  }
                </div>
                <button ui-button variant="ghost" size="icon-sm" aria-label="Close cart drawer" (click)="close.emit()">
                  <lucide-icon [img]="X" class="size-4" />
                </button>
              </div>
    
              <!-- Free Shipping Meter -->
              @if (cartItems().length > 0) {
                <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">
                    @if (isFreeShippingUnlocked) {
                      <span class="text-primary flex items-center gap-1.5 font-medium">
                        <lucide-icon [img]="Truck" class="size-3.5" />
                        You've unlocked Free Express Shipping!
                      </span>
                    } @else {
                      <span class="text-muted-foreground">
                        🎉 You're
                        <strong class="text-foreground font-semibold tabular-nums">{{
                          formatCurrency(amountAwayFromFreeShipping)
                        }}</strong>
                        away from Free Shipping!
                      </span>
                    }
                    <span class="text-muted-foreground font-medium tabular-nums">{{ shippingProgress }}%</span>
                  </div>
                  <ui-progress [value]="shippingProgress" class="mt-2 h-1.5" />
                </div>
              }
    
              <!-- Drawer Body: Line Items or Empty State -->
              <div class="flex-1 overflow-y-auto p-5">
                @if (cartItems().length === 0) {
                  <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">
                      <lucide-icon [img]="ShoppingBag" class="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't added any products to your cart yet. Explore our latest items!
                    </p>
                    <button ui-button class="mt-5 gap-2" size="sm" (click)="resetCart()">
                      <lucide-icon [img]="ShoppingBag" class="size-3.5" />
                      Explore Products
                    </button>
                  </div>
                } @else {
                  <div class="space-y-4">
                    <ul class="divide-border/60 divide-y" role="list">
                      @for (item of cartItems(); track item.id) {
                        <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>
                                <button
                                  ui-button
                                  variant="ghost"
                                  size="icon-sm"
                                  class="text-muted-foreground hover:text-destructive -mt-1 -mr-1.5 size-7 shrink-0"
                                  [attr.aria-label]="'Remove ' + item.name + ' from cart'"
                                  (click)="dropItem(item.id)"
                                >
                                  <lucide-icon [img]="Trash2" class="size-3.5" />
                                </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">
                                <button
                                  ui-button
                                  variant="ghost"
                                  size="icon-sm"
                                  class="size-7 rounded-none rounded-l-md"
                                  [attr.aria-label]="'Decrease quantity for ' + item.name"
                                  (click)="stepQuantity(item.id, -1)"
                                >
                                  <lucide-icon [img]="Minus" class="size-3" />
                                </button>
                                <span class="w-7 text-center text-xs font-medium tabular-nums">{{ item.qty }}</span>
                                <button
                                  ui-button
                                  variant="ghost"
                                  size="icon-sm"
                                  class="size-7 rounded-none rounded-r-md"
                                  [attr.aria-label]="'Increase quantity for ' + item.name"
                                  (click)="stepQuantity(item.id, 1)"
                                >
                                  <lucide-icon [img]="Plus" class="size-3" />
                                </button>
                              </div>
    
                              <div class="text-right">
                                @if (item.originalPrice) {
                                  <span class="text-muted-foreground mr-1.5 text-xs tabular-nums line-through">{{
                                    formatCurrency(item.originalPrice * item.qty)
                                  }}</span>
                                }
                                <span class="text-foreground text-sm font-semibold tabular-nums">{{
                                  formatCurrency(item.price * item.qty)
                                }}</span>
                              </div>
                            </div>
                          </div>
                        </li>
                      }
                    </ul>
    
                    <ui-separator class="my-3" />
    
                    <!-- Promo Code Section -->
                    <div class="space-y-2">
                      @if (appliedPromo()) {
                        <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">
                            <lucide-icon [img]="Tag" class="text-primary size-3.5" />
                            <span class="text-foreground font-medium">{{ appliedPromo() }}</span>
                            <span class="text-success text-success font-semibold tabular-nums"
                              >-{{ formatCurrency(promoDiscount()) }}</span
                            >
                          </div>
                          <button
                            ui-button
                            variant="ghost"
                            size="icon-sm"
                            class="text-muted-foreground hover:text-foreground size-6"
                            aria-label="Remove promo code"
                            (click)="handleRemovePromo()"
                          >
                            <lucide-icon [img]="X" class="size-3.5" />
                          </button>
                        </div>
                      } @else {
                        <div class="flex gap-2">
                          <ui-input
                            [value]="promoCodeInput()"
                            placeholder="Promo code (e.g. SUMMER20)"
                            size="small"
                            class="text-xs"
                            (valueChange)="promoCodeInput.set($event)"
                            (keydown.enter)="onPromoEnter($event)"
                          />
                          <button
                            ui-button
                            variant="outline"
                            size="sm"
                            class="shrink-0 text-xs"
                            [disabled]="!promoCodeInput().trim()"
                            (click)="handleApplyPromo()"
                          >
                            Apply
                          </button>
                        </div>
                      }
                      @if (promoSuccess()) {
                        <p class="text-success text-xs font-medium">{{ promoSuccess() }}</p>
                      }
                      @if (promoError()) {
                        <p class="text-destructive text-xs">{{ promoError() }}</p>
                      }
                    </div>
                  </div>
                }
              </div>
    
              <!-- Drawer Footer: Summary & Actions -->
              @if (cartItems().length > 0) {
                <div class="border-border/60 bg-card shrink-0 space-y-3 border-t p-5">
                  <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">{{ formatCurrency(subtotal) }}</span>
                    </div>
    
                    @if (promoDiscount() > 0) {
                      <div class="text-success flex justify-between">
                        <span>Coupon ({{ appliedPromo() }})</span>
                        <span class="font-medium tabular-nums">-{{ formatCurrency(promoDiscount()) }}</span>
                      </div>
                    }
    
                    <div class="flex justify-between">
                      <span class="text-muted-foreground">Estimated Shipping</span>
                      @if (shippingFee === 0) {
                        <span class="text-success font-medium">Free</span>
                      } @else {
                        <span class="text-foreground font-medium tabular-nums">{{ formatCurrency(shippingFee) }}</span>
                      }
                    </div>
    
                    <div class="flex justify-between">
                      <span class="text-muted-foreground">Estimated Tax</span>
                      <span class="text-foreground font-medium tabular-nums">{{ formatCurrency(estimatedTax) }}</span>
                    </div>
    
                    <ui-separator class="my-2" />
    
                    <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">{{ formatCurrency(total) }}</span>
                    </div>
                  </div>
    
                  <div class="space-y-2 pt-1">
                    <button
                      ui-button
                      class="w-full justify-center gap-2 font-medium shadow-xs"
                      size="default"
                      (click)="handleCheckout()"
                    >
                      <lucide-icon [img]="Lock" class="size-3.5" />
                      Proceed to Checkout
                    </button>
    
                    <button
                      ui-button
                      variant="ghost"
                      class="text-muted-foreground hover:text-foreground w-full text-xs"
                      size="sm"
                      (click)="continueShopping.emit()"
                    >
                      Continue Shopping
                    </button>
                  </div>
    
                  <!-- Guarantee badges -->
                  <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">
                      <lucide-icon [img]="RotateCcw" class="size-3.5" />
                      <span>30-day returns</span>
                    </div>
                    <div class="flex flex-col items-center gap-1">
                      <lucide-icon [img]="ShieldCheck" class="size-3.5" />
                      <span>256-bit secure</span>
                    </div>
                    <div class="flex flex-col items-center gap-1">
                      <lucide-icon [img]="Leaf" class="size-3.5" />
                      <span>Carbon neutral</span>
                    </div>
                  </div>
                </div>
              }
            </div>
          } @else {
            <!-- FULL PAGE VARIANT -->
            <div class="mx-auto w-full max-w-6xl space-y-6">
              <!-- Page Header -->
              <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>
                @if (totalItemsCount > 0) {
                  <span ui-badge variant="outline" class="w-fit text-xs tabular-nums"
                    >{{ totalItemsCount }} items in cart</span
                  >
                }
              </div>
    
              <!-- Free Shipping Banner -->
              @if (cartItems().length > 0) {
                <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">
                      @if (isFreeShippingUnlocked) {
                        <lucide-icon [img]="Truck" class="text-primary size-4" />
                        <span class="text-primary font-semibold">
                          You've unlocked Free Express Shipping on this order!
                        </span>
                      } @else {
                        <span class="text-muted-foreground">
                          🎉 You're
                          <strong class="text-foreground font-semibold tabular-nums">{{
                            formatCurrency(amountAwayFromFreeShipping)
                          }}</strong>
                          away from Free Shipping!
                        </span>
                      }
                    </div>
                    <span class="text-muted-foreground text-xs font-medium tabular-nums"
                      >{{ shippingProgress }}% completed</span
                    >
                  </div>
                  <ui-progress [value]="shippingProgress" class="mt-2.5 h-2" />
                </div>
              }
    
              <!-- Empty State for Page -->
              @if (cartItems().length === 0) {
                <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">
                    <lucide-icon [img]="ShoppingBag" class="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>
                  <button ui-button class="mt-6 gap-2" size="default" (click)="resetCart()">
                    <lucide-icon [img]="ShoppingBag" class="size-4" />
                    Explore Products
                  </button>
                </div>
              } @else {
                <!-- Page Grid: Items (Left) & Order Summary (Right) -->
                <div class="grid grid-cols-1 items-start gap-8 lg:grid-cols-12">
                  <!-- Left: Line Items list -->
                  <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">
                      @for (item of cartItems(); track item.id) {
                        <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"
                                  >· {{ 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">
                              <button
                                ui-button
                                variant="ghost"
                                size="icon-sm"
                                class="size-8 rounded-none rounded-l-md"
                                [attr.aria-label]="'Decrease quantity for ' + item.name"
                                (click)="stepQuantity(item.id, -1)"
                              >
                                <lucide-icon [img]="Minus" class="size-3.5" />
                              </button>
                              <span class="w-8 text-center text-xs font-medium tabular-nums">{{ item.qty }}</span>
                              <button
                                ui-button
                                variant="ghost"
                                size="icon-sm"
                                class="size-8 rounded-none rounded-r-md"
                                [attr.aria-label]="'Increase quantity for ' + item.name"
                                (click)="stepQuantity(item.id, 1)"
                              >
                                <lucide-icon [img]="Plus" class="size-3.5" />
                              </button>
                            </div>
    
                            <div class="w-24 text-right">
                              @if (item.originalPrice) {
                                <span class="text-muted-foreground block text-xs tabular-nums line-through">{{
                                  formatCurrency(item.originalPrice * item.qty)
                                }}</span>
                              }
                              <span class="text-foreground text-base font-semibold tabular-nums">{{
                                formatCurrency(item.price * item.qty)
                              }}</span>
                            </div>
    
                            <button
                              ui-button
                              variant="ghost"
                              size="icon-sm"
                              class="text-muted-foreground hover:text-destructive size-8"
                              [attr.aria-label]="'Remove ' + item.name + ' from cart'"
                              (click)="dropItem(item.id)"
                            >
                              <lucide-icon [img]="Trash2" class="size-4" />
                            </button>
                          </div>
                        </div>
                      }
                    </div>
    
                    <!-- Bottom bar: Guarantees -->
                    <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">
                          <lucide-icon [img]="RotateCcw" class="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">
                          <lucide-icon [img]="ShieldCheck" class="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">
                          <lucide-icon [img]="Leaf" class="size-4" />
                        </div>
                        <div>
                          <p class="font-medium">Carbon Neutral</p>
                          <p class="text-muted-foreground">Offset delivery emissions</p>
                        </div>
                      </div>
                    </div>
                  </div>
    
                  <!-- Right: Sticky Order Summary Card -->
                  <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>
    
                      <!-- Promo Input -->
                      <div class="space-y-2">
                        <label class="text-muted-foreground text-xs font-medium">Have a coupon code?</label>
                        @if (appliedPromo()) {
                          <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">
                              <lucide-icon [img]="Tag" class="text-primary size-3.5" />
                              <span class="text-foreground font-medium">{{ appliedPromo() }}</span>
                              <span class="text-success text-success font-semibold tabular-nums"
                                >-{{ formatCurrency(promoDiscount()) }}</span
                              >
                            </div>
                            <button
                              ui-button
                              variant="ghost"
                              size="icon-sm"
                              class="text-muted-foreground hover:text-foreground size-6"
                              aria-label="Remove coupon"
                              (click)="handleRemovePromo()"
                            >
                              <lucide-icon [img]="X" class="size-3.5" />
                            </button>
                          </div>
                        } @else {
                          <div class="flex gap-2">
                            <ui-input
                              [value]="promoCodeInput()"
                              placeholder="Enter code (SUMMER20)"
                              size="small"
                              class="text-xs"
                              (valueChange)="promoCodeInput.set($event)"
                              (keydown.enter)="onPromoEnter($event)"
                            />
                            <button
                              ui-button
                              variant="outline"
                              size="sm"
                              class="shrink-0 text-xs"
                              [disabled]="!promoCodeInput().trim()"
                              (click)="handleApplyPromo()"
                            >
                              Apply
                            </button>
                          </div>
                        }
                      </div>
    
                      <ui-separator />
    
                      <!-- Cost Breakdown -->
                      <div class="space-y-2 text-xs">
                        <div class="flex justify-between">
                          <span class="text-muted-foreground">Subtotal</span>
                          <span class="text-foreground font-medium tabular-nums">{{ formatCurrency(subtotal) }}</span>
                        </div>
    
                        @if (promoDiscount() > 0) {
                          <div class="text-success flex justify-between">
                            <span>Coupon ({{ appliedPromo() }})</span>
                            <span class="font-medium tabular-nums">-{{ formatCurrency(promoDiscount()) }}</span>
                          </div>
                        }
    
                        <div class="flex justify-between">
                          <span class="text-muted-foreground">Estimated Shipping</span>
                          @if (shippingFee === 0) {
                            <span class="text-success font-medium">Free</span>
                          } @else {
                            <span class="text-foreground font-medium tabular-nums">{{ formatCurrency(shippingFee) }}</span>
                          }
                        </div>
    
                        <div class="flex justify-between">
                          <span class="text-muted-foreground">Estimated Tax (8%)</span>
                          <span class="text-foreground font-medium tabular-nums">{{ formatCurrency(estimatedTax) }}</span>
                        </div>
    
                        <ui-separator class="my-2" />
    
                        <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">{{ formatCurrency(total) }}</span>
                        </div>
                      </div>
    
                      <button
                        ui-button
                        class="w-full justify-center gap-2 font-medium shadow-xs"
                        size="lg"
                        (click)="handleCheckout()"
                      >
                        <lucide-icon [img]="Lock" class="size-4" />
                        Proceed to Checkout
                      </button>
    
                      <button
                        ui-button
                        variant="ghost"
                        class="text-muted-foreground hover:text-foreground w-full text-xs"
                        size="sm"
                        (click)="continueShopping.emit()"
                      >
                        Continue Shopping
                      </button>
                    </div>
                  </div>
                </div>
              }
            </div>
          }
        </div>
      `,
    })
    export class UiCartDrawerComponent implements OnInit {
      @Input() variant: CartDrawerVariant = 'drawer'
      /** Line items. A new array resets the cart to it (React re-seeds on an `items` change). */
      @Input() set items(value: CartItem[] | null | undefined) {
        this.cartItems.set(copyItems(value ?? DEFAULT_CART_ITEMS))
      }
      @Input() currency = 'USD'
      @Input() freeShippingThreshold = 300
      @Input() taxRate = 0.08
      /** Seeds the applied coupon once (a $20 discount), like React's initial state. */
      @Input() initialPromoCode = ''
      @Input('class') className?: string
    
      @Output() readonly checkout = new EventEmitter<CartCheckoutEvent>()
      @Output() readonly continueShopping = new EventEmitter<void>()
      @Output() readonly close = new EventEmitter<void>()
      @Output() readonly updateQuantity = new EventEmitter<CartQuantityEvent>()
      @Output() readonly removeItem = new EventEmitter<string>()
      @Output() readonly applyPromo = new EventEmitter<string>()
      @Output() readonly removePromo = new EventEmitter<void>()
    
      readonly cartItems = signal<CartItem[]>(copyItems(DEFAULT_CART_ITEMS))
      readonly promoCodeInput = signal('')
      readonly appliedPromo = signal('')
      readonly promoDiscount = signal(0)
      readonly promoError = signal('')
      readonly promoSuccess = signal('')
    
      protected readonly Leaf = Leaf
      protected readonly Lock = Lock
      protected readonly Minus = Minus
      protected readonly Plus = Plus
      protected readonly RotateCcw = RotateCcw
      protected readonly ShieldCheck = ShieldCheck
      protected readonly ShoppingBag = ShoppingBag
      protected readonly Tag = Tag
      protected readonly Trash2 = Trash2
      protected readonly Truck = Truck
      protected readonly X = X
    
      ngOnInit(): void {
        // React seeds its useState once from `initialPromoCode`; inputs land after construction.
        this.appliedPromo.set(this.initialPromoCode)
        this.promoDiscount.set(this.initialPromoCode ? 20 : 0)
      }
    
      protected get rootClass(): string {
        return cn('w-full', this.className)
      }
    
      get totalItemsCount(): number {
        return this.cartItems().reduce((acc, item) => acc + item.qty, 0)
      }
    
      get subtotal(): number {
        return this.cartItems().reduce((acc, item) => acc + item.price * item.qty, 0)
      }
    
      get isFreeShippingUnlocked(): boolean {
        return this.subtotal >= this.freeShippingThreshold && this.cartItems().length > 0
      }
    
      get amountAwayFromFreeShipping(): number {
        return Math.max(0, this.freeShippingThreshold - this.subtotal)
      }
    
      get shippingProgress(): number {
        if (this.cartItems().length === 0) return 0
        return Math.min(100, Math.round((this.subtotal / this.freeShippingThreshold) * 100))
      }
    
      get shippingFee(): number {
        if (this.cartItems().length === 0) return 0
        return this.isFreeShippingUnlocked ? 0 : 15
      }
    
      get discountedSubtotal(): number {
        return Math.max(0, this.subtotal - this.promoDiscount())
      }
    
      get estimatedTax(): number {
        if (this.cartItems().length === 0) return 0
        return Number((this.discountedSubtotal * this.taxRate).toFixed(2))
      }
    
      get total(): number {
        if (this.cartItems().length === 0) return 0
        return Number((this.discountedSubtotal + this.shippingFee + this.estimatedTax).toFixed(2))
      }
    
      formatCurrency(amount: number): string {
        try {
          return new Intl.NumberFormat('en-US', {
            style: 'currency',
            currency: this.currency,
            minimumFractionDigits: 2,
          }).format(amount)
        } catch {
          return `$${amount.toFixed(2)}`
        }
      }
    
      stepQuantity(id: string, delta: number): void {
        const item = this.cartItems().find((i) => i.id === id)
        if (!item) return
        const newQty = item.qty + delta
        if (newQty <= 0) {
          this.cartItems.update((prev) => prev.filter((i) => i.id !== id))
          this.removeItem.emit(id)
          return
        }
        this.cartItems.update((prev) => prev.map((i) => (i.id === id ? { ...i, qty: newQty } : i)))
        this.updateQuantity.emit({ id, qty: newQty })
      }
    
      dropItem(id: string): void {
        this.cartItems.update((prev) => prev.filter((item) => item.id !== id))
        this.removeItem.emit(id)
      }
    
      onPromoEnter(event: Event): void {
        event.preventDefault()
        this.handleApplyPromo()
      }
    
      handleApplyPromo(): void {
        this.promoError.set('')
        this.promoSuccess.set('')
        const trimmed = this.promoCodeInput().trim().toUpperCase()
        if (!trimmed) return
    
        if (trimmed === 'SUMMER20' || trimmed === 'SAVE20') {
          this.appliedPromo.set(trimmed)
          this.promoDiscount.set(20)
          this.promoSuccess.set('$20.00 coupon discount applied!')
        } else if (trimmed === 'SAVE10') {
          this.appliedPromo.set(trimmed)
          this.promoDiscount.set(10)
          this.promoSuccess.set('$10.00 coupon discount applied!')
        } else {
          this.appliedPromo.set(trimmed)
          this.promoDiscount.set(15)
          this.promoSuccess.set('Promo code applied!')
        }
        this.promoCodeInput.set('')
        this.applyPromo.emit(trimmed)
      }
    
      handleRemovePromo(): void {
        this.appliedPromo.set('')
        this.promoDiscount.set(0)
        this.promoSuccess.set('')
        this.promoError.set('')
        this.removePromo.emit()
      }
    
      handleCheckout(): void {
        this.checkout.emit({ items: this.cartItems(), total: this.total })
      }
    
      resetCart(): void {
        this.cartItems.set(copyItems(DEFAULT_CART_ITEMS))
      }
    }
    
  • src/app/components/blocks/cart-drawer/index.ts0.2 kB

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