Cart Drawer
blockcommerceShopping 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
$pnpm dlx uipkge-ng@latest add cart-drawer$npx uipkge-ng@latest add cart-drawer$yarn dlx uipkge-ng@latest add cart-drawer$bunx uipkge-ng@latest add cart-drawerManifest:
https://uipkge.dev/r/angular/cart-drawer.jsonInstalls to:src/app/components/blocks/cart-drawer/Variants
Loading interactive previews…
Loading interactive previews…
Schema
Type aliases exported from this item's source. Use these to shape the data you pass in.
CartIteminterface CartItem {
id: string
name: string
variant: string
price: number
originalPrice?: number
qty: number
inStock?: boolean
image: string
}CartCheckoutEventinterface CartCheckoutEvent {
items: CartItem[]
total: number
}CartQuantityEventinterface 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
export { UiCartDrawerComponent, DEFAULT_CART_ITEMS, type CartItem, type CartDrawerVariant, type CartCheckoutEvent, type CartQuantityEvent, } from './cart-drawer.component'
Raw manifest:https://uipkge.dev/r/angular/cart-drawer.json