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 shadcn@latest add https://uipkge.dev/r/lit/frequently-bought-together.json$npx shadcn@latest add https://uipkge.dev/r/lit/frequently-bought-together.json$yarn dlx shadcn@latest add https://uipkge.dev/r/lit/frequently-bought-together.json$bunx shadcn@latest add https://uipkge.dev/r/lit/frequently-bought-together.jsonManifest:
https://uipkge.dev/r/lit/frequently-bought-together.jsonInstalls to:src/uip/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.
BundleIteminterface BundleItem {
id: string
name: string
subtitle: string
price: number
image: string
isMain?: boolean
}HTMLElementTagNameMapinterface HTMLElementTagNameMap {
'uip-frequently-bought-together': UipFrequentlyBoughtTogether
}
}Files installed (1)
src/uip/blocks/frequently-bought-together/frequently-bought-together.ts10.1 kB
import { LitElement, css, html } from 'lit' import { Plus, ShoppingBag, Truck } 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/card/card' import '../../components/checkbox/checkbox' interface BundleItem { id: string name: string subtitle: string price: number image: string isMain?: boolean } const bundleItems: BundleItem[] = [ { 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) { return '$' + val.toFixed(2) } /** * <uip-frequently-bought-together> — the registry FrequentlyBoughtTogether * block as a web component. * * Class strings and bundle data are React's verbatim. React state becomes a * { state: true } field. Like React, the block takes no props and fires no * events; selection, discount math, and button/delivery copy are internal. */ export class UipFrequentlyBoughtTogether 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 = { selectedIds: { state: true }, } private selectedIds: string[] = ['headphones', 'case', 'cable'] constructor() { super() new ThemeController(this) } connectedCallback() { super.connectedCallback() this.setAttribute('data-uipkge', '') this.setAttribute('data-slot', 'frequently-bought-together') } private toggleItem(id: string) { this.selectedIds = this.selectedIds.includes(id) ? this.selectedIds.filter((itemId) => itemId !== id) : [...this.selectedIds, id] } private get selectedItems() { return bundleItems.filter((item) => this.selectedIds.includes(item.id)) } private get buttonText() { const n = this.selectedItems.length if (n === bundleItems.length) return `Add all ${bundleItems.length} to Cart` if (n > 1) return `Add ${n} selected to Cart` if (n === 1) return 'Add 1 selected to Cart' return 'Select items to add' } private get deliveryNote() { const n = this.selectedItems.length if (n >= 2) return 'Free express delivery included with this bundle' if (n === 1) return 'Standard delivery included' return 'Select items to view delivery options' } render() { const selectedCount = this.selectedItems.length const originalTotal = this.selectedItems.reduce((sum, item) => sum + item.price, 0) const discountRate = selectedCount >= 2 ? 0.15 : 0 const discountAmount = originalTotal * discountRate const finalPrice = originalTotal - discountAmount const hasDiscount = discountRate > 0 return html` <!-- data-slot lives on this wrapper: <uip-card> stamps its own data-slot="card" on connect, which would clobber the block slot. --> <div data-slot="frequently-bought-together" class="mx-auto w-full max-w-4xl"> <uip-card class="p-6 shadow-xs"> <!-- flex column + gap: space-y-* can't target slotted children (host page preflight wins), so the stack rhythm lives here. --> <div class="flex flex-col gap-6"> <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> <div class="grid grid-cols-1 items-center gap-3 sm:grid-cols-[1fr_auto_1fr_auto_1fr] sm:gap-4"> ${bundleItems.map( (item, index) => html`${index > 0 ? html`<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" > ${icon(Plus, 'plus', 'size-4')} </div>` : null} <div class=${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.selectedIds.includes(item.id) ? 'opacity-100' : 'opacity-40 grayscale', )} > <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" /> ${item.isMain ? html`<uip-badge variant="secondary" class="bg-background/90 text-foreground absolute top-2 left-2 text-xs font-medium backdrop-blur-xs" > This item </uip-badge>` : null} </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>`, )} </div> <div class="space-y-2.5"> ${bundleItems.map( (item) => html`<div role="button" tabindex="0" class=${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.selectedIds.includes(item.id) ? 'bg-card hover:border-primary/40' : 'border-border/60 bg-muted/10 opacity-60 hover:opacity-80', )} @click=${() => this.toggleItem(item.id)} @keydown=${(e: KeyboardEvent) => { if (e.key === ' ' || e.key === 'Enter') { e.preventDefault() this.toggleItem(item.id) } }} > <div class="flex items-start gap-3 sm:items-center"> <uip-checkbox .checked=${this.selectedIds.includes(item.id)} class="pointer-events-none mt-0.5 sm:mt-0" tabindex="-1" ></uip-checkbox> <div class="space-y-0.5"> <div class="flex flex-wrap items-center gap-2"> <span class="text-foreground text-sm font-medium"> ${item.isMain ? html`<span class="font-semibold">This item: </span>` : null}${item.name} </span> ${item.isMain ? html`<uip-badge variant="secondary" class="text-xs">Main Item</uip-badge>` : null} </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> <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 > ${hasDiscount ? html`<span class="text-muted-foreground text-sm font-medium tabular-nums line-through sm:text-base" >${formatCurrency(originalTotal)}</span >` : null} ${hasDiscount ? html`<uip-badge variant="outline" class="border-success/30 bg-success/10 text-success text-xs font-medium"> Save ${formatCurrency(discountAmount)} (15% Bundle Discount) </uip-badge>` : null} </div> <div class="text-muted-foreground flex items-center gap-1.5 text-xs"> ${icon(Truck, 'truck', 'text-success size-3.5 shrink-0')} <span>${this.deliveryNote}</span> </div> </div> <uip-button size="lg" ?disabled=${selectedCount === 0} class="w-full gap-2 font-semibold shadow-xs md:w-auto"> ${icon(ShoppingBag, 'shopping-bag', 'size-4')} <span>${this.buttonText}</span> </uip-button> </div> </div> </uip-card> </div> ` } } customElements.get('uip-frequently-bought-together') || customElements.define('uip-frequently-bought-together', UipFrequentlyBoughtTogether) declare global { interface HTMLElementTagNameMap { 'uip-frequently-bought-together': UipFrequentlyBoughtTogether } }
Raw manifest:https://uipkge.dev/r/lit/frequently-bought-together.json