Product Detail Page
blockcommerceFlagship e-commerce product detail page with multi-angle gallery, finish & cable selectors, stock urgency counter, warranty upsell, flash sale countdown bar, frequently bought together bundle, technical specs sheet, customer reviews breakdown, and community Q&A.
Installation
$pnpm dlx shadcn@latest add https://uipkge.dev/r/lit/product-detail-page.json$npx shadcn@latest add https://uipkge.dev/r/lit/product-detail-page.json$yarn dlx shadcn@latest add https://uipkge.dev/r/lit/product-detail-page.json$bunx shadcn@latest add https://uipkge.dev/r/lit/product-detail-page.jsonManifest:
https://uipkge.dev/r/lit/product-detail-page.jsonInstalls to:src/uip/blocks/product-detail-page/Variants
Loading interactive previews…
Loading interactive previews…
Props
| Name | Type / Values | Default | Required |
|---|---|---|---|
selected-variant-id | string | var-black | optional |
Schema
Type aliases exported from this item's source. Use these to shape the data you pass in.
ProductVariantinterface ProductVariant {
id: string
name: string
colorHex: string
sku: string
inStock: boolean
stockCount: number
price: number
originalPrice: number
images: { id: string; url: string; alt: string }[]
}HTMLElementTagNameMapinterface HTMLElementTagNameMap {
'uip-product-detail-page': UipProductDetailPage
}
}Files installed (1)
src/uip/blocks/product-detail-page/product-detail-page.ts21.5 kB
import { LitElement, css, html } from 'lit' import { ChevronRight, Flame, Heart, Minus, Plus, RotateCcw, Share2, ShieldCheck, ShoppingCart, Star, Truck, Zap, } from 'lucide' 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/input/input' import type { UipInput } from '../../components/input/input' import '../../components/separator/separator' export interface ProductVariant { id: string name: string colorHex: string sku: string inStock: boolean stockCount: number price: number originalPrice: number images: { id: string; url: string; alt: string }[] } export const productVariants: ProductVariant[] = [ { id: 'var-black', name: 'Obsidian Matte Black', colorHex: '#18181b', sku: 'APX-950-BLK', inStock: true, stockCount: 14, price: 599, originalPrice: 749, images: [ { id: 'img-1', url: 'https://images.unsplash.com/photo-1546435770-a3e426bf472b?w=900&auto=format&fit=crop&q=80', alt: 'Apex Pro Reference Studio Headphones on Wood Desk', }, { id: 'img-2', url: 'https://images.unsplash.com/photo-1505740420928-5e560c06d30e?w=900&auto=format&fit=crop&q=80', alt: 'Apex Pro CNC Aluminum Driver Open Back View', }, { id: 'img-3', url: 'https://images.unsplash.com/photo-1583394838336-acd977736f90?w=900&auto=format&fit=crop&q=80', alt: 'Apex Pro Lambskin Magnetic Earpads Angle', }, { id: 'img-4', url: 'https://images.unsplash.com/photo-1484704849700-f032a568e944?w=900&auto=format&fit=crop&q=80', alt: 'Apex Pro Studio Audio Cable Interconnects', }, ], }, { id: 'var-silver', name: 'Lunar Silver Aluminum', colorHex: '#94a3b8', sku: 'APX-950-SLV', inStock: true, stockCount: 6, price: 599, originalPrice: 749, images: [ { id: 'img-2', url: 'https://images.unsplash.com/photo-1505740420928-5e560c06d30e?w=900&auto=format&fit=crop&q=80', alt: 'Apex Pro Lunar Silver Open Back View', }, { id: 'img-1', url: 'https://images.unsplash.com/photo-1546435770-a3e426bf472b?w=900&auto=format&fit=crop&q=80', alt: 'Apex Pro Silver Edition Desk Shot', }, { id: 'img-3', url: 'https://images.unsplash.com/photo-1583394838336-acd977736f90?w=900&auto=format&fit=crop&q=80', alt: 'Apex Pro Earpads Detail', }, ], }, { id: 'var-navy', name: 'Midnight Studio Navy', colorHex: '#1e293b', sku: 'APX-950-NVY', inStock: true, stockCount: 3, price: 629, originalPrice: 779, images: [ { id: 'img-3', url: 'https://images.unsplash.com/photo-1583394838336-acd977736f90?w=900&auto=format&fit=crop&q=80', alt: 'Apex Pro Midnight Navy Studio Edition', }, { id: 'img-1', url: 'https://images.unsplash.com/photo-1546435770-a3e426bf472b?w=900&auto=format&fit=crop&q=80', alt: 'Apex Pro Navy Perspective', }, { id: 'img-2', url: 'https://images.unsplash.com/photo-1505740420928-5e560c06d30e?w=900&auto=format&fit=crop&q=80', alt: 'Apex Pro Navy Driver Housing', }, ], }, ] /** * <uip-product-detail-page> — the registry ProductDetailPage block as a web component. * * Class strings, variants, and gallery/buy-box behavior are React's verbatim. * React state becomes { state: true } fields; `selectedVariantId` is an * attribute. React's `alert()` calls become `add-to-cart` (variant, cable, * quantity, warranty, total) / `express-buy` (total) / `share` CustomEvents * (bubbles, composed; payloads in `detail`). * * One deliberate departure: React composes five sibling blocks below the * buy box (flash-sale-offer-bar, frequently-bought-together, * technical-specs-sheet, customer-reviews, product-qa-community), but blocks * never import sibling blocks (one block = one install unit). This element * renders the breadcrumb + gallery/buy-box hero split; compose the sibling * <uip-*> blocks after it on the page, as the demo does. */ export class UipProductDetailPage 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 = { selectedVariantId: { attribute: 'selected-variant-id' }, activeImageIndex: { state: true }, quantity: { state: true }, selectedCable: { state: true }, isWishlisted: { state: true }, addExtendedWarranty: { state: true }, zipCode: { state: true }, } selectedVariantId = 'var-black' private activeImageIndex = 0 private quantity = 1 private selectedCable: '4.4mm' | '3.5mm' | 'xlr' = '4.4mm' private isWishlisted = false private addExtendedWarranty = true private zipCode = '94103' constructor() { super() new ThemeController(this) } connectedCallback() { super.connectedCallback() this.setAttribute('data-uipkge', '') this.setAttribute('data-slot', 'product-detail-page') } private get currentVariant(): ProductVariant { return productVariants.find((v) => v.id === this.selectedVariantId) || productVariants[0]! } private get activeImageUrl() { const v = this.currentVariant return v.images[this.activeImageIndex]?.url || v.images[0]!.url } private get finalPrice() { let total = this.currentVariant.price * this.quantity if (this.addExtendedWarranty) total += 49 return total } private selectVariant(v: ProductVariant) { const changed = v.id !== this.selectedVariantId this.selectedVariantId = v.id this.activeImageIndex = 0 if (changed) { this.dispatchEvent( new CustomEvent('variant-change', { detail: { variantId: v.id }, bubbles: true, composed: true }), ) } } private incrementQty() { if (this.quantity < this.currentVariant.stockCount) this.quantity += 1 } private decrementQty() { if (this.quantity > 1) this.quantity -= 1 } private handleAddToCart() { this.dispatchEvent( new CustomEvent('add-to-cart', { detail: { variantId: this.currentVariant.id, cable: this.selectedCable, quantity: this.quantity, warranty: this.addExtendedWarranty, total: this.finalPrice, }, bubbles: true, composed: true, }), ) } private handleExpressBuy() { this.dispatchEvent( new CustomEvent('express-buy', { detail: { total: this.finalPrice }, bubbles: true, composed: true }), ) } render() { const v = this.currentVariant return html` <div data-slot="product-detail-page" class="bg-background text-foreground w-full space-y-10"> <nav class="text-muted-foreground flex items-center gap-1.5 text-xs"> <span class="hover:text-foreground cursor-pointer">Storefront</span> ${icon(ChevronRight, 'chevron-right', 'size-3.5')} <span class="hover:text-foreground cursor-pointer">Pro Audio & Reference</span> ${icon(ChevronRight, 'chevron-right', 'size-3.5')} <span class="hover:text-foreground cursor-pointer">Planar Magnetic</span> ${icon(ChevronRight, 'chevron-right', 'size-3.5')} <span class="text-foreground font-medium">APX-950-PRO Reference Monitor</span> </nav> <div class="grid grid-cols-1 gap-8 lg:grid-cols-12 lg:gap-12"> <div class="space-y-4 lg:col-span-7"> <div class="border-border bg-muted/30 relative aspect-4/3 w-full overflow-hidden rounded-xl border shadow-xs"> <img src=${this.activeImageUrl} alt=${v.name} class="size-full object-cover transition-transform duration-300 hover:scale-105" /> <div class="absolute top-3 left-3 flex flex-wrap gap-1.5"> <uip-badge variant="default" class="h-6 font-mono text-xs font-semibold uppercase">2026 Flagship</uip-badge> <uip-badge variant="secondary" class="text-success h-6 font-mono text-xs">Laboratory Calibrated</uip-badge> </div> <div class="absolute top-3 right-3 flex flex-col gap-2"> <button type="button" aria-label="Toggle wishlist" class=${`border-border bg-background/90 flex size-8 items-center justify-center rounded-full border shadow-2xs backdrop-blur-xs transition-colors ${ this.isWishlisted ? 'text-destructive' : 'text-muted-foreground hover:text-foreground' }`} @click=${() => { this.isWishlisted = !this.isWishlisted }} > ${icon(Heart, 'heart', `size-4 ${this.isWishlisted ? 'fill-current' : ''}`)} </button> <button type="button" aria-label="Share product" class="border-border bg-background/90 text-muted-foreground hover:text-foreground flex size-8 items-center justify-center rounded-full border shadow-2xs backdrop-blur-xs transition-colors" @click=${() => this.dispatchEvent(new CustomEvent('share', { bubbles: true, composed: true }))} > ${icon(Share2, 'share-2', 'size-4')} </button> </div> </div> <div class="grid grid-cols-4 gap-3"> ${v.images.map( (img, idx) => html`<button type="button" class=${`bg-muted/20 relative aspect-4/3 overflow-hidden rounded-lg border transition-colors ${ this.activeImageIndex === idx ? 'border-primary ring-primary/40 ring-2' : 'border-border opacity-70 hover:opacity-100' }`} @click=${() => { this.activeImageIndex = idx }} > <img src=${img.url} alt=${img.alt} class="size-full object-cover" /> </button>`, )} </div> <div class="border-border bg-muted/20 grid grid-cols-3 gap-3 rounded-lg border p-3 text-center text-xs"> <div class="flex flex-col items-center gap-1"> ${icon(Truck, 'truck', 'text-primary size-4')} <span class="text-foreground font-medium">Free 2-Day Air</span> <span class="text-muted-foreground text-xs">Orders over $150</span> </div> <div class="border-border/80 flex flex-col items-center gap-1 border-x px-2"> ${icon(RotateCcw, 'rotate-ccw', 'text-success size-4')} <span class="text-foreground font-medium">45-Day Trial</span> <span class="text-muted-foreground text-xs">Zero-risk return</span> </div> <div class="flex flex-col items-center gap-1"> ${icon(ShieldCheck, 'shield-check', 'text-info size-4')} <span class="text-foreground font-medium">3-Yr Warranty</span> <span class="text-muted-foreground text-xs">Direct factory support</span> </div> </div> </div> <div class="space-y-6 lg:col-span-5"> <div class="space-y-2"> <div class="text-muted-foreground flex items-center gap-2 font-mono text-xs"> <span>SKU: ${v.sku}</span> <span>·</span> <span>Made in Germany</span> </div> <h1 class="text-foreground text-2xl font-bold tracking-tight sm:text-3xl"> Apex Pro Reference Planar Studio Headphones </h1> <div class="flex items-center gap-2 pt-1 text-xs"> <div class="text-warning flex items-center gap-1"> ${icon(Star, 'star', 'fill-warning size-3.5')} ${icon(Star, 'star', 'fill-warning size-3.5')} ${icon(Star, 'star', 'fill-warning size-3.5')} ${icon(Star, 'star', 'fill-warning size-3.5')} ${icon(Star, 'star', 'fill-warning size-3.5')} </div> <span class="text-foreground font-semibold">4.9</span> <span class="text-muted-foreground">(342 Verified Mastering Reviews)</span> </div> </div> <uip-separator></uip-separator> <div class="space-y-1.5"> <div class="flex items-baseline gap-3"> <span class="text-foreground text-3xl font-extrabold tracking-tight"> $${v.price} </span> <span class="text-muted-foreground text-base line-through"> $${v.originalPrice} </span> <uip-badge variant="secondary" class="text-success font-mono text-xs font-bold"> Save $${v.originalPrice - v.price} (20% OFF) </uip-badge> </div> <p class="text-muted-foreground text-xs"> Interest-free installments of $${(v.price / 4).toFixed(2)}/mo with Affirm or Klarna. </p> </div> <div class="space-y-2.5"> <div class="flex items-center justify-between text-xs"> <span class="text-foreground font-medium">Finish / Enclosure:</span> <span class="text-muted-foreground font-mono font-semibold">${v.name}</span> </div> <div class="flex items-center gap-3"> ${productVariants.map( (opt) => html`<button type="button" aria-label=${opt.name} class=${`group relative flex size-9 items-center justify-center rounded-full border transition-colors ${ this.selectedVariantId === opt.id ? 'border-primary ring-primary ring-offset-background ring-2 ring-offset-2' : 'border-border hover:border-muted-foreground' }`} @click=${() => this.selectVariant(opt)} > <span class="size-6 rounded-full border border-black/10 shadow-2xs" style="background-color:${opt.colorHex}"></span> </button>`, )} </div> </div> <div class="space-y-2.5"> <div class="flex items-center justify-between text-xs"> <span class="text-foreground font-medium">Primary Studio Cable Termination:</span> <span class="text-primary font-mono text-xs">Included in package</span> </div> <div class="grid grid-cols-3 gap-2 text-xs"> <button type="button" class=${`rounded-lg border p-2.5 text-center transition-colors ${ this.selectedCable === '4.4mm' ? 'border-primary bg-primary/5 text-primary ring-primary font-semibold ring-1' : 'border-border bg-card text-foreground hover:bg-muted/40' }`} @click=${() => { this.selectedCable = '4.4mm' }} > <div class="font-mono text-xs font-semibold">4.4mm Balanced</div> <div class="text-muted-foreground text-xs">Pentaconn TRRRS</div> </button> <button type="button" class=${`rounded-lg border p-2.5 text-center transition-colors ${ this.selectedCable === '3.5mm' ? 'border-primary bg-primary/5 text-primary ring-primary font-semibold ring-1' : 'border-border bg-card text-foreground hover:bg-muted/40' }`} @click=${() => { this.selectedCable = '3.5mm' }} > <div class="font-mono text-xs font-semibold">3.5mm SE + 6.35mm</div> <div class="text-muted-foreground text-xs">Screw Adapter</div> </button> <button type="button" class=${`rounded-lg border p-2.5 text-center transition-colors ${ this.selectedCable === 'xlr' ? 'border-primary bg-primary/5 text-primary ring-primary font-semibold ring-1' : 'border-border bg-card text-foreground hover:bg-muted/40' }`} @click=${() => { this.selectedCable = 'xlr' }} > <div class="font-mono text-xs font-semibold">4-Pin XLR Pro</div> <div class="text-muted-foreground text-xs">Neutrik Studio</div> </button> </div> </div> <label class="border-border bg-muted/20 hover:bg-muted/30 flex cursor-pointer items-start gap-3 rounded-lg border p-3 text-xs transition-colors" > <input type="checkbox" ?checked=${this.addExtendedWarranty} class="border-border text-primary focus:ring-primary mt-0.5 size-4 rounded" @change=${(e: Event) => { this.addExtendedWarranty = (e.target as HTMLInputElement).checked }} /> <div class="space-y-0.5"> <div class="flex items-center justify-between"> <span class="text-foreground font-semibold">Add 3-Year Apex Care+ Accidental Protection</span> <span class="text-foreground font-mono font-bold">+$49.00</span> </div> <p class="text-muted-foreground text-xs">Covers accidental drops, driver voice-coil failure, and free earpad replacement.</p> </div> </label> <div class="space-y-1.5 rounded-lg border border-orange-500/30 bg-orange-500/5 p-3 text-xs"> <div class="flex items-center justify-between"> <div class="flex items-center gap-1.5 font-semibold text-orange-600 dark:text-orange-400"> ${icon(Flame, 'flame', 'size-3.5 fill-current')} <span>Fulfillment Hub Stock Level</span> </div> <span class="text-foreground font-mono font-bold">${v.stockCount} units remaining</span> </div> <p class="text-muted-foreground text-xs">High demand: 18 sound engineers have this item in their active checkout carts.</p> </div> <div class="space-y-3 pt-2"> <div class="flex items-center gap-3"> <div class="border-border bg-background flex h-10 items-center rounded-lg border shadow-2xs"> <button type="button" aria-label="Decrease quantity" class="text-muted-foreground hover:text-foreground flex size-9 items-center justify-center" @click=${() => this.decrementQty()} > ${icon(Minus, 'minus', 'size-3.5')} </button> <span class="text-foreground w-8 text-center font-mono text-xs font-semibold">${this.quantity}</span> <button type="button" aria-label="Increase quantity" class="text-muted-foreground hover:text-foreground flex size-9 items-center justify-center" @click=${() => this.incrementQty()} > ${icon(Plus, 'plus', 'size-3.5')} </button> </div> <uip-button class="h-10 flex-1 gap-2 font-medium shadow-xs" @click=${() => this.handleAddToCart()}> ${icon(ShoppingCart, 'shopping-cart', 'size-4')} <span>Add to Cart · $${this.finalPrice}</span> </uip-button> </div> <uip-button variant="secondary" class="border-border h-10 w-full gap-2 border font-semibold shadow-2xs" @click=${() => this.handleExpressBuy()}> ${icon(Zap, 'zap', 'fill-warning text-warning size-4')} <span>Instant 1-Click Checkout (Apple Pay / G-Pay)</span> </uip-button> </div> <div class="border-border bg-muted/20 flex items-center gap-2 rounded-lg border p-2.5 text-xs"> ${icon(Truck, 'truck', 'text-muted-foreground size-4 shrink-0')} <span class="text-muted-foreground">Deliver to:</span> <uip-input .value=${this.zipCode} maxlength="5" class="h-7 w-20 text-center font-mono text-xs" @input=${(e: Event) => { this.zipCode = (e.target as UipInput).value ?? '' }} ></uip-input> <span class="text-foreground font-medium">Fast Delivery: <strong>Tomorrow, 2:00 PM</strong></span> </div> </div> </div> </div> ` } } customElements.get('uip-product-detail-page') || customElements.define('uip-product-detail-page', UipProductDetailPage) declare global { interface HTMLElementTagNameMap { 'uip-product-detail-page': UipProductDetailPage } }
Raw manifest:https://uipkge.dev/r/lit/product-detail-page.json