Frequently Bought Together
blockcommerceE-commerce frequently bought together bundle builder with interactive product preview cards, checkbox selection, dynamic bundle discount calculation, and one-click cart checkout.
Installation
$pnpm dlx uipkge-ng@latest add frequently-bought-together$npx uipkge-ng@latest add frequently-bought-together$yarn dlx uipkge-ng@latest add frequently-bought-together$bunx uipkge-ng@latest add frequently-bought-togetherManifest:
https://uipkge.dev/r/angular/frequently-bought-together.jsonInstalls to:src/app/components/blocks/frequently-bought-together/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.
FrequentlyBoughtTogetherIteminterface FrequentlyBoughtTogetherItem {
id: string
name: string
subtitle: string
price: number
image: string
isMain?: boolean
}npm dependencies
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
export { UiFrequentlyBoughtTogetherComponent, type FrequentlyBoughtTogetherItem, } from './frequently-bought-together.component'
Raw manifest:https://uipkge.dev/r/angular/frequently-bought-together.json