Menu

Change language

GitHub repo

Frequently Bought Together

blockcommerce

E-commerce frequently bought together bundle builder with interactive product preview cards, checkbox selection, dynamic bundle discount calculation, and one-click cart checkout.

Installation

$npx uipkge-ng@latest add frequently-bought-together
Manifest:https://uipkge.dev/r/angular/frequently-bought-together.jsonInstalls to:src/app/components/blocks/frequently-bought-together/

Variants

Loading interactive previews…

Schema

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

FrequentlyBoughtTogetherItem
interface FrequentlyBoughtTogetherItem {
  id: string
  name: string
  subtitle: string
  price: number
  image: string
  isMain?: boolean
}

Files installed (2)

  • src/app/components/blocks/frequently-bought-together/frequently-bought-together.component.ts10.1 kB
    // Edit this file to change the bundle items and copy. State is local -- wire the cart button
    // to your own cart API.
    import { Component, computed, signal, ChangeDetectionStrategy } from '@angular/core'
    import { LucideAngularModule, Plus, ShoppingBag, Truck } from 'lucide-angular'
    import { cn } from '@/lib/utils'
    import { UiBadgeComponent } from '@/ui/badge/badge.component'
    import { UiButtonComponent } from '@/ui/button/button.component'
    import { UiCardComponent } from '@/ui/card/card.component'
    import { UiCheckboxComponent } from '@/ui/checkbox/checkbox.component'
    
    export interface FrequentlyBoughtTogetherItem {
      id: string
      name: string
      subtitle: string
      price: number
      image: string
      isMain?: boolean
    }
    
    const items: FrequentlyBoughtTogetherItem[] = [
      {
        id: 'headphones',
        name: 'Pro Studio Headphones',
        subtitle: 'Space Black · Over-Ear Wireless ANC',
        price: 299.0,
        image: 'https://images.unsplash.com/photo-1505740420928-5e560c06d30e?w=500&auto=format&fit=crop&q=80',
        isMain: true,
      },
      {
        id: 'case',
        name: 'Premium Hard Shell Case',
        subtitle: 'Weather-resistant EVA shell with plush velvet lining',
        price: 45.0,
        image: 'https://images.unsplash.com/photo-1583394838336-acd977736f90?w=500&auto=format&fit=crop&q=80',
      },
      {
        id: 'cable',
        name: 'Braided 3.5mm Audiophile Cable',
        subtitle: '1.5m Silver-plated OFC copper with gold-plated jacks',
        price: 25.0,
        image: 'https://images.unsplash.com/photo-1546435770-a3e426bf472b?w=500&auto=format&fit=crop&q=80',
      },
    ]
    
    function formatCurrency(val: number): string {
      return '$' + val.toFixed(2)
    }
    
    /**
     * FrequentlyBoughtTogether -- the React block, 1:1: a bundle builder card with a product
     * preview row (deselected items fade to grayscale), a checkbox list of the items, and a
     * summary box with a live 15% bundle discount (two or more items), the delivery note and
     * an "Add to Cart" button whose label follows the selection.
     *
     * lucide-angular wraps each <svg> in a <lucide-icon> host, so the Button's `has-[>svg]:px-4`
     * never matches: the cart button carries the `px-4` React's size="lg" resolves to.
     */
    @Component({
      changeDetection: ChangeDetectionStrategy.Eager,
      selector: 'ui-frequently-bought-together, [ui-frequently-bought-together]',
      standalone: true,
      // React renders the Card itself: the host stays out of layout.
      host: { class: 'contents' },
      imports: [LucideAngularModule, UiBadgeComponent, UiButtonComponent, UiCardComponent, UiCheckboxComponent],
      template: `
        <div ui-card data-slot="frequently-bought-together" class="mx-auto w-full max-w-4xl space-y-6 p-6 shadow-xs">
          <!-- Header -->
          <div class="space-y-1.5">
            <h2 class="text-foreground text-xl font-bold tracking-tight sm:text-2xl">Frequently Bought Together</h2>
            <p class="text-muted-foreground text-sm">
              Pair with these recommended essentials for maximum performance and save 15% on the bundle
            </p>
          </div>
    
          <!-- Product Visual Row -->
          <div class="grid grid-cols-1 items-center gap-3 sm:grid-cols-[1fr_auto_1fr_auto_1fr] sm:gap-4">
            @for (item of items; track item.id; let index = $index) {
              <div [class]="previewClass(item.id)">
                <div
                  class="border-border bg-background relative mb-3 aspect-square w-full overflow-hidden rounded-lg border"
                >
                  <img [src]="item.image" [alt]="item.name" class="size-full object-cover" />
                  @if (item.isMain) {
                    <span
                      ui-badge
                      variant="secondary"
                      class="bg-background/90 text-foreground absolute top-2 left-2 text-xs font-medium backdrop-blur-xs"
                    >
                      This item
                    </span>
                  }
                </div>
                <span class="text-foreground line-clamp-1 text-xs leading-tight font-semibold sm:text-sm">
                  {{ item.name }}
                </span>
                <span class="text-foreground mt-1 text-xs font-semibold tabular-nums sm:text-sm">
                  {{ formatCurrency(item.price) }}
                </span>
              </div>
    
              @if (index < items.length - 1) {
                <div
                  class="border-border/50 text-muted-foreground bg-muted mx-auto flex size-8 shrink-0 items-center justify-center rounded-full border"
                  aria-hidden="true"
                >
                  <lucide-icon [img]="Plus" class="size-4" />
                </div>
              }
            }
          </div>
    
          <!-- Item Selection Checkbox List -->
          <div class="space-y-2.5">
            @for (item of items; track item.id) {
              <div
                role="button"
                tabindex="0"
                [class]="rowClass(item.id)"
                (click)="toggleItem(item.id)"
                (keydown)="onRowKeydown($event, item.id)"
              >
                <div class="flex items-start gap-3 sm:items-center">
                  <ui-checkbox
                    [id]="item.id"
                    [checked]="isSelected(item.id)"
                    class="pointer-events-none mt-0.5 sm:mt-0"
                    tabindex="-1"
                  />
                  <div class="space-y-0.5">
                    <div class="flex flex-wrap items-center gap-2">
                      <span class="text-foreground text-sm font-medium">
                        @if (item.isMain) {
                          <span class="font-semibold">This item: </span>
                        }
                        {{ item.name }}
                      </span>
                      @if (item.isMain) {
                        <span ui-badge variant="secondary" class="text-xs">Main Item</span>
                      }
                    </div>
                    <p class="text-muted-foreground text-xs">{{ item.subtitle }}</p>
                  </div>
                </div>
                <span class="text-foreground shrink-0 text-sm font-semibold tabular-nums">
                  {{ formatCurrency(item.price) }}
                </span>
              </div>
            }
          </div>
    
          <!-- Price & Bundle Checkout Summary Box -->
          <div
            class="border-border bg-muted/30 flex flex-col gap-4 rounded-xl border p-4 sm:p-5 md:flex-row md:items-center md:justify-between"
          >
            <div class="space-y-1.5">
              <div class="text-muted-foreground text-xs font-medium tracking-wider uppercase">
                {{ selectedCount() >= 2 ? 'Bundle Price' : 'Total Price' }}
              </div>
              <div class="flex flex-wrap items-baseline gap-2.5">
                <span class="text-foreground text-2xl font-bold tracking-tight tabular-nums sm:text-3xl">
                  {{ formatCurrency(finalPrice()) }}
                </span>
                @if (hasDiscount()) {
                  <span class="text-muted-foreground text-sm font-medium tabular-nums line-through sm:text-base">
                    {{ formatCurrency(originalTotal()) }}
                  </span>
                }
                @if (hasDiscount()) {
                  <span ui-badge variant="outline" class="border-success/30 bg-success/10 text-success text-xs font-medium">
                    Save {{ formatCurrency(discountAmount()) }} (15% Bundle Discount)
                  </span>
                }
              </div>
              <div class="text-muted-foreground flex items-center gap-1.5 text-xs">
                <lucide-icon [img]="Truck" class="text-success size-3.5 shrink-0" />
                <span>{{ deliveryNote() }}</span>
              </div>
            </div>
    
            <button
              ui-button
              size="lg"
              [disabled]="selectedCount() === 0"
              class="w-full gap-2 px-4 font-semibold shadow-xs md:w-auto"
            >
              <lucide-icon [img]="ShoppingBag" class="size-4" />
              <span>{{ buttonText() }}</span>
            </button>
          </div>
        </div>
      `,
    })
    export class UiFrequentlyBoughtTogetherComponent {
      protected readonly Plus = Plus
      protected readonly ShoppingBag = ShoppingBag
      protected readonly Truck = Truck
      protected readonly items = items
      protected readonly formatCurrency = formatCurrency
    
      readonly selectedIds = signal<string[]>(['headphones', 'case', 'cable'])
    
      readonly selectedItems = computed(() => items.filter((item) => this.selectedIds().includes(item.id)))
      readonly selectedCount = computed(() => this.selectedItems().length)
      readonly originalTotal = computed(() => this.selectedItems().reduce((sum, item) => sum + item.price, 0))
      readonly discountRate = computed(() => (this.selectedCount() >= 2 ? 0.15 : 0))
      readonly discountAmount = computed(() => this.originalTotal() * this.discountRate())
      readonly finalPrice = computed(() => this.originalTotal() - this.discountAmount())
      readonly hasDiscount = computed(() => this.discountRate() > 0)
    
      readonly buttonText = computed(() => {
        const count = this.selectedCount()
        if (count === items.length) return `Add all ${items.length} to Cart`
        if (count > 1) return `Add ${count} selected to Cart`
        if (count === 1) return 'Add 1 selected to Cart'
        return 'Select items to add'
      })
    
      readonly deliveryNote = computed(() => {
        const count = this.selectedCount()
        if (count >= 2) return 'Free express delivery included with this bundle'
        if (count === 1) return 'Standard delivery included'
        return 'Select items to view delivery options'
      })
    
      isSelected(id: string): boolean {
        return this.selectedIds().includes(id)
      }
    
      toggleItem(id: string): void {
        this.selectedIds.update((prev) => (prev.includes(id) ? prev.filter((itemId) => itemId !== id) : [...prev, id]))
      }
    
      onRowKeydown(event: KeyboardEvent, id: string): void {
        if (event.key === ' ' || event.key === 'Enter') {
          event.preventDefault()
          this.toggleItem(id)
        }
      }
    
      previewClass(id: string): string {
        return cn(
          'border-border bg-muted/20 relative flex flex-col rounded-xl border p-3 text-left transition-colors duration-200 sm:p-4',
          this.isSelected(id) ? 'opacity-100' : 'opacity-40 grayscale',
        )
      }
    
      rowClass(id: string): string {
        return cn(
          'border-border focus-visible:ring-ring flex cursor-pointer items-start justify-between gap-3 rounded-lg border p-3.5 transition-colors select-none focus-visible:ring-2 focus-visible:outline-none sm:items-center',
          this.isSelected(id)
            ? 'bg-card hover:border-primary/40'
            : 'border-border/60 bg-muted/10 opacity-60 hover:opacity-80',
        )
      }
    }
    
  • src/app/components/blocks/frequently-bought-together/index.ts0.1 kB

Raw manifest:https://uipkge.dev/r/angular/frequently-bought-together.json