Menu

Change language

GitHub repo

Product Detail Page

blockcommerce

Flagship 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

$npx shadcn@latest add https://uipkge.dev/r/lit/product-detail-page.json
Manifest:https://uipkge.dev/r/lit/product-detail-page.jsonInstalls to:src/uip/blocks/product-detail-page/

Variants

Loading interactive previews…

Props

NameType / ValuesDefaultRequired
selected-variant-idstringvar-blackoptional

Schema

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

ProductVariant
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 }[]
}
HTMLElementTagNameMap
interface 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