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 uipkge-ng@latest add product-detail-page
Manifest:https://uipkge.dev/r/angular/product-detail-page.jsonInstalls to:src/app/components/blocks/product-detail-page/

Variants

Loading interactive previews…

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 }[]
}
BundleItem
interface BundleItem {
  id: string
  name: string
  detail: string
  price: number
}
SpecRow
interface SpecRow {
  label: string
  value: string
}

Files installed (2)

  • src/app/components/blocks/product-detail-page/product-detail-page.component.ts31.2 kB
    // Flagship e-commerce product detail page: breadcrumb, multi-angle gallery with finish &
    // cable selectors, stock urgency, warranty upsell, flash-sale strip, bundle, specs,
    // reviews, and Q&A. Self-contained single-file port of the Vue ProductDetailPage +
    // ProductGalleryBuyBox 1:1 (sibling sections are inlined so the block installs alone).
    import { ChangeDetectionStrategy, Component, Input, computed, signal } from '@angular/core'
    import {
      BadgeCheck,
      ChevronRight,
      Cpu,
      Flame,
      Heart,
      HelpCircle,
      LucideAngularModule,
      MessagesSquare,
      Minus,
      Package,
      Plus,
      RotateCcw,
      Share2,
      ShieldCheck,
      ShoppingCart,
      Star,
      ThumbsUp,
      Truck,
      Zap,
    } from 'lucide-angular'
    import { cn } from '@/lib/utils'
    import { UiBadgeComponent } from '@/ui/badge/badge.component'
    import { UiButtonComponent } from '@/ui/button/button.component'
    import { UiInputComponent } from '@/ui/input/input.component'
    import { UiSeparatorComponent } from '@/ui/separator/separator.component'
    
    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 variants: 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',
          },
        ],
      },
    ]
    
    type CableKey = '4.4mm' | '3.5mm' | 'xlr'
    
    interface BundleItem {
      id: string
      name: string
      detail: string
      price: number
    }
    
    const BUNDLE_ITEMS: BundleItem[] = [
      { id: 'bundle-cable', name: 'Apex Balanced 4.4mm Upgrade Cable', detail: 'OCC copper, 1.5m', price: 89 },
      { id: 'bundle-case', name: 'Studio Hard Travel Case', detail: 'Molded, humidity control', price: 129 },
      { id: 'bundle-pads', name: 'Replacement Lambskin Earpads', detail: 'Magnetic mount pair', price: 59 },
    ]
    
    interface SpecRow {
      label: string
      value: string
    }
    
    const SPEC_ROWS: SpecRow[] = [
      { label: 'Driver', value: '106mm planar magnetic, 32Ω impedance' },
      { label: 'Frequency Response', value: '8Hz – 55kHz (±3dB)' },
      { label: 'THD', value: '< 0.05% @ 1kHz, 100dB SPL' },
      { label: 'Weight', value: '420g without cable' },
      { label: 'Cable', value: 'Detachable dual 3.5mm, 2m balanced' },
      { label: 'Model', value: 'APX-950-PRO · Studio Edition 2026' },
    ]
    
    @Component({
      changeDetection: ChangeDetectionStrategy.Eager,
      selector: 'ui-product-detail-page, [ui-product-detail-page]',
      standalone: true,
      // React renders the root <div> itself: the host stays out of layout and `class` goes to the root.
      host: { '[attr.class]': '"contents"' },
      imports: [LucideAngularModule, UiBadgeComponent, UiButtonComponent, UiInputComponent, UiSeparatorComponent],
      template: `
        <div data-slot="product-detail-page" [class]="rootClass">
          <!-- Breadcrumb Navigation Bar -->
          <nav class="text-muted-foreground flex items-center gap-1.5 text-xs" aria-label="Breadcrumb">
            <span class="hover:text-foreground cursor-pointer">Storefront</span>
            <lucide-icon [img]="ChevronRight" class="size-3.5" />
            <span class="hover:text-foreground cursor-pointer">Pro Audio & Reference</span>
            <lucide-icon [img]="ChevronRight" class="size-3.5" />
            <span class="hover:text-foreground cursor-pointer">Planar Magnetic</span>
            <lucide-icon [img]="ChevronRight" class="size-3.5" />
            <span class="text-foreground font-medium">APX-950-PRO Reference Monitor</span>
          </nav>
    
          <!-- Main Hero Split: Gallery & Buy Box -->
          <div class="grid grid-cols-1 gap-8 lg:grid-cols-12 lg:gap-12">
            <!-- Left Column: Interactive Image Gallery -->
            <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]="activeImageUrl()"
                  [alt]="currentVariant().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">
                  <span ui-badge variant="default" class="h-6 font-mono text-xs font-semibold uppercase">
                    2026 Flagship
                  </span>
                  <span ui-badge variant="secondary" class="text-success h-6 font-mono text-xs">
                    Laboratory Calibrated
                  </span>
                </div>
    
                <div class="absolute top-3 right-3 flex flex-col gap-2">
                  <button
                    type="button"
                    [class]="wishlistBtnClass()"
                    (click)="isWishlisted.set(!isWishlisted())"
                    aria-label="Toggle wishlist"
                  >
                    <lucide-icon [img]="Heart" class="size-4" [class.fill-current]="isWishlisted()" />
                  </button>
                  <button
                    type="button"
                    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)="shareLink()"
                    aria-label="Share product"
                  >
                    <lucide-icon [img]="Share2" class="size-4" />
                  </button>
                </div>
              </div>
    
              <div class="grid grid-cols-4 gap-3">
                @for (img of currentVariant().images; track img.id; let idx = $index) {
                  <button type="button" [class]="thumbClass(idx)" (click)="activeImageIndex.set(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">
                  <lucide-icon [img]="Truck" class="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">
                  <lucide-icon [img]="RotateCcw" class="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">
                  <lucide-icon [img]="ShieldCheck" class="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>
    
            <!-- Right Column: Buy Box & Product Configuration -->
            <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: {{ currentVariant().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">
                    <lucide-icon [img]="Star" class="fill-warning size-3.5" />
                    <lucide-icon [img]="Star" class="fill-warning size-3.5" />
                    <lucide-icon [img]="Star" class="fill-warning size-3.5" />
                    <lucide-icon [img]="Star" class="fill-warning size-3.5" />
                    <lucide-icon [img]="Star" class="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>
    
              <ui-separator />
    
              <div class="space-y-1.5">
                <div class="flex items-baseline gap-3">
                  <span class="text-foreground text-3xl font-extrabold tracking-tight">
                    \${{ currentVariant().price }}
                  </span>
                  <span class="text-muted-foreground text-base line-through"> \${{ currentVariant().originalPrice }} </span>
                  <span ui-badge variant="secondary" class="text-success font-mono text-xs font-bold">
                    Save \${{ currentVariant().originalPrice - currentVariant().price }} (20% OFF)
                  </span>
                </div>
                <p class="text-muted-foreground text-xs">
                  Interest-free installments of \${{ (currentVariant().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">{{ currentVariant().name }}</span>
                </div>
    
                <div class="flex items-center gap-3">
                  @for (v of finishVariants; track v.id) {
                    <button type="button" [class]="swatchClass(v.id)" (click)="selectVariant(v)" [attr.aria-label]="v.name">
                      <span
                        class="size-6 rounded-full border border-black/10 shadow-2xs"
                        [style.backgroundColor]="v.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]="cableClass('4.4mm')" (click)="selectedCable.set('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]="cableClass('3.5mm')" (click)="selectedCable.set('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]="cableClass('xlr')" (click)="selectedCable.set('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]="addExtendedWarranty()"
                  (change)="addExtendedWarranty.set($any($event.target).checked)"
                  class="border-border text-primary focus:ring-primary mt-0.5 size-4 rounded"
                />
                <span class="space-y-0.5">
                  <span 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>
                  </span>
                  <span class="text-muted-foreground block text-xs">
                    Covers accidental drops, driver voice-coil failure, and free earpad replacement.
                  </span>
                </span>
              </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">
                    <lucide-icon [img]="Flame" class="size-3.5 fill-current" />
                    <span>Fulfillment Hub Stock Level</span>
                  </div>
                  <span class="text-foreground font-mono font-bold">{{ currentVariant().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"
                      class="text-muted-foreground hover:text-foreground flex size-9 items-center justify-center"
                      (click)="decrementQty()"
                      aria-label="Decrease quantity"
                    >
                      <lucide-icon [img]="Minus" class="size-3.5" />
                    </button>
                    <span class="text-foreground w-8 text-center font-mono text-xs font-semibold">{{ quantity() }}</span>
                    <button
                      type="button"
                      class="text-muted-foreground hover:text-foreground flex size-9 items-center justify-center"
                      (click)="incrementQty()"
                      aria-label="Increase quantity"
                    >
                      <lucide-icon [img]="Plus" class="size-3.5" />
                    </button>
                  </div>
    
                  <button ui-button class="h-10 flex-1 gap-2 font-medium shadow-xs" (click)="handleAddToCart()">
                    <lucide-icon [img]="ShoppingCart" class="size-4" />
                    <span>Add to Cart · \${{ finalPrice() }}</span>
                  </button>
                </div>
    
                <button
                  ui-button
                  variant="secondary"
                  class="border-border h-10 w-full gap-2 border font-semibold shadow-2xs"
                  (click)="handleExpressBuy()"
                >
                  <lucide-icon [img]="Zap" class="fill-warning text-warning size-4" />
                  <span>Instant 1-Click Checkout (Apple Pay / G-Pay)</span>
                </button>
    
                @if (cartNotice(); as notice) {
                  <p class="border-success/30 bg-success/10 text-success rounded-lg border px-3 py-2 text-xs font-medium">
                    {{ notice }}
                  </p>
                }
              </div>
    
              <div class="border-border bg-muted/20 flex items-center gap-2 rounded-lg border p-2.5 text-xs">
                <lucide-icon [img]="Truck" class="text-muted-foreground size-4 shrink-0" />
                <span class="text-muted-foreground">Deliver to:</span>
                <ui-input
                  [value]="zipCode()"
                  (valueChange)="zipCode.set($event)"
                  maxlength="5"
                  class="h-7 w-20 text-center font-mono text-xs"
                />
                <span class="text-foreground font-medium">Fast Delivery: <strong>Tomorrow, 2:00 PM</strong></span>
              </div>
            </div>
          </div>
    
          <!-- Section 2: Flash Sale & Multi-Buy Tier Deal Bar -->
          <div class="space-y-3">
            <div class="border-destructive/30 bg-destructive/5 rounded-xl border p-4 sm:p-5">
              <div class="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
                <div class="flex items-center gap-2">
                  <lucide-icon [img]="Flame" class="text-destructive size-4 fill-current" />
                  <p class="text-foreground text-sm font-semibold">Audio Pro Week: Limited Master Batch 04</p>
                </div>
                <p class="text-muted-foreground font-mono text-xs">
                  {{ claimedPercent }}% claimed · Only {{ currentVariant().stockCount }} left at this price
                </p>
              </div>
              <div class="bg-destructive/20 mt-3 h-2 overflow-hidden rounded-full">
                <div class="bg-destructive h-full rounded-full" [style.width]="claimedPercent + '%'"></div>
              </div>
            </div>
          </div>
    
          <!-- Section 3: Frequently Bought Together Bundle -->
          <div class="space-y-4">
            <div class="flex items-center gap-2">
              <lucide-icon [img]="Package" class="text-primary size-4" />
              <h2 class="text-foreground text-lg font-semibold tracking-tight">Frequently Bought Together</h2>
            </div>
            <div class="border-border bg-card grid grid-cols-1 gap-3 rounded-xl border p-4 sm:grid-cols-3">
              @for (item of bundleItems; track item.id) {
                <div class="border-border bg-muted/20 rounded-lg border p-3">
                  <p class="text-foreground text-xs font-semibold">{{ item.name }}</p>
                  <p class="text-muted-foreground mt-0.5 text-xs">{{ item.detail }}</p>
                  <p class="text-foreground mt-1.5 font-mono text-xs font-bold">+\${{ item.price }}</p>
                </div>
              }
            </div>
            <button ui-button size="sm" class="gap-1.5 text-xs shadow-xs" (click)="addBundleToCart()">
              <lucide-icon [img]="ShoppingCart" class="size-3.5" />
              <span>Add 3 Items to Cart · \${{ bundleTotal }}</span>
            </button>
          </div>
    
          <!-- Section 4: Engineering & Technical Specifications Sheet -->
          <div class="space-y-4">
            <div class="flex items-center gap-2">
              <lucide-icon [img]="Cpu" class="text-primary size-4" />
              <h2 class="text-foreground text-lg font-semibold tracking-tight">Engineering & Technical Specifications</h2>
              <span ui-badge variant="outline" class="font-mono text-xs">APX-950-PRO</span>
            </div>
            <div class="border-border overflow-hidden rounded-xl border">
              @for (row of specRows; track row.label; let last = $last) {
                <div
                  class="grid grid-cols-[160px_1fr] gap-3 px-4 py-2.5 text-xs"
                  [class.border-b]="!last"
                  [class.border-border]="!last"
                >
                  <span class="text-muted-foreground font-medium">{{ row.label }}</span>
                  <span class="text-foreground">{{ row.value }}</span>
                </div>
              }
            </div>
          </div>
    
          <!-- Section 5: Verified Customer Reviews & Photo Breakdown -->
          <div class="space-y-4">
            <div class="flex items-center gap-2">
              <lucide-icon [img]="Star" class="text-warning fill-warning size-4" />
              <h2 class="text-foreground text-lg font-semibold tracking-tight">Verified Customer Reviews</h2>
              <span ui-badge variant="secondary" class="font-mono text-xs">4.9 · 342 reviews</span>
            </div>
            <div class="grid grid-cols-1 gap-3 md:grid-cols-2">
              <div class="border-border bg-card rounded-xl border p-4">
                <div class="flex items-center justify-between">
                  <p class="text-foreground text-xs font-semibold">Marcus T. · Mix Engineer, Berlin</p>
                  <span ui-badge variant="outline" class="border-success/30 bg-success/10 text-success gap-1 text-xs">
                    <lucide-icon [img]="BadgeCheck" class="size-3" />
                    Verified buyer
                  </span>
                </div>
                <div class="text-warning mt-2 flex items-center gap-0.5">
                  <lucide-icon [img]="Star" class="fill-warning size-3" />
                  <lucide-icon [img]="Star" class="fill-warning size-3" />
                  <lucide-icon [img]="Star" class="fill-warning size-3" />
                  <lucide-icon [img]="Star" class="fill-warning size-3" />
                  <lucide-icon [img]="Star" class="fill-warning size-3" />
                </div>
                <p class="text-muted-foreground mt-2 text-xs leading-relaxed">
                  Translation is scary good — mixes move to monitors with zero surprises. The 4.4mm balanced drive has
                  headroom for days.
                </p>
                <button
                  ui-button
                  variant="ghost"
                  size="sm"
                  class="text-muted-foreground mt-2 h-auto gap-1 p-0 text-xs"
                  (click)="markHelpful('review-1')"
                >
                  <lucide-icon [img]="ThumbsUp" class="size-3" />
                  <span>Helpful ({{ helpfulCount('review-1') }})</span>
                </button>
              </div>
              <div class="border-border bg-card rounded-xl border p-4">
                <div class="flex items-center justify-between">
                  <p class="text-foreground text-xs font-semibold">Yuki S. · Mastering, Tokyo</p>
                  <span ui-badge variant="outline" class="border-success/30 bg-success/10 text-success gap-1 text-xs">
                    <lucide-icon [img]="BadgeCheck" class="size-3" />
                    Verified buyer
                  </span>
                </div>
                <div class="text-warning mt-2 flex items-center gap-0.5">
                  <lucide-icon [img]="Star" class="fill-warning size-3" />
                  <lucide-icon [img]="Star" class="fill-warning size-3" />
                  <lucide-icon [img]="Star" class="fill-warning size-3" />
                  <lucide-icon [img]="Star" class="fill-warning size-3" />
                  <lucide-icon [img]="Star" class="fill-warning size-3" />
                </div>
                <p class="text-muted-foreground mt-2 text-xs leading-relaxed">
                  Wore them for a 9-hour session with no fatigue. Earpads and clamping force are the best I have tested at
                  any price.
                </p>
                <button
                  ui-button
                  variant="ghost"
                  size="sm"
                  class="text-muted-foreground mt-2 h-auto gap-1 p-0 text-xs"
                  (click)="markHelpful('review-2')"
                >
                  <lucide-icon [img]="ThumbsUp" class="size-3" />
                  <span>Helpful ({{ helpfulCount('review-2') }})</span>
                </button>
              </div>
            </div>
          </div>
    
          <!-- Section 6: Customer & Staff Q&A Community -->
          <div class="space-y-4">
            <div class="flex items-center gap-2">
              <lucide-icon [img]="MessagesSquare" class="text-primary size-4" />
              <h2 class="text-foreground text-lg font-semibold tracking-tight">Customer & Staff Q&A</h2>
            </div>
            <p class="text-muted-foreground text-xs">
              Questions about the Apex Pro Reference Planar Headphones, answered by owners and factory staff.
            </p>
            <div class="space-y-3">
              <div class="border-border bg-card rounded-xl border p-4">
                <p class="text-foreground flex items-start gap-2 text-xs font-semibold">
                  <lucide-icon [img]="HelpCircle" class="text-primary mt-0.5 size-3.5 shrink-0" />
                  Does the APX-950-PRO need a dedicated headphone amp?
                </p>
                <p class="text-muted-foreground mt-2 pl-5.5 text-xs leading-relaxed">
                  <span class="text-success font-semibold">Staff answer:</span> At 32Ω it runs fine from a modern interface,
                  but a balanced 4.4mm output tightens sub-bass control noticeably.
                </p>
              </div>
              <div class="border-border bg-card rounded-xl border p-4">
                <p class="text-foreground flex items-start gap-2 text-xs font-semibold">
                  <lucide-icon [img]="HelpCircle" class="text-primary mt-0.5 size-3.5 shrink-0" />
                  Are the earpads user-replaceable?
                </p>
                <p class="text-muted-foreground mt-2 pl-5.5 text-xs leading-relaxed">
                  <span class="text-success font-semibold">Staff answer:</span> Yes — magnetic mounts swap in seconds, and
                  Apex Care+ covers one free replacement pair.
                </p>
              </div>
            </div>
            <div class="flex flex-col gap-2 sm:flex-row">
              <ui-input
                [value]="question()"
                (valueChange)="question.set($event)"
                placeholder="Ask about fit, drivability, or warranty..."
                class="flex-1 text-xs"
              />
              <button ui-button size="sm" class="gap-1.5 text-xs" (click)="askQuestion()">
                <lucide-icon [img]="MessagesSquare" class="size-3.5" />
                Ask a Question
              </button>
            </div>
          </div>
        </div>
      `,
    })
    export class UiProductDetailPageComponent {
      protected readonly BadgeCheck = BadgeCheck
      protected readonly ChevronRight = ChevronRight
      protected readonly Cpu = Cpu
      protected readonly Flame = Flame
      protected readonly Heart = Heart
      protected readonly HelpCircle = HelpCircle
      protected readonly MessagesSquare = MessagesSquare
      protected readonly Minus = Minus
      protected readonly Package = Package
      protected readonly Plus = Plus
      protected readonly RotateCcw = RotateCcw
      protected readonly Share2 = Share2
      protected readonly ShieldCheck = ShieldCheck
      protected readonly ShoppingCart = ShoppingCart
      protected readonly Star = Star
      protected readonly ThumbsUp = ThumbsUp
      protected readonly Truck = Truck
      protected readonly Zap = Zap
    
      protected readonly finishVariants = variants
      protected readonly bundleItems = BUNDLE_ITEMS
      protected readonly bundleTotal = BUNDLE_ITEMS.reduce((acc, item) => acc + item.price, 0)
      protected readonly specRows = SPEC_ROWS
      protected readonly claimedPercent = 82
    
      @Input() set initialVariantId(v: string | undefined) {
        if (v) this.selectedVariantId.set(v)
      }
      @Input('class') className?: string
    
      readonly selectedVariantId = signal(variants[0].id)
      readonly activeImageIndex = signal(0)
      readonly quantity = signal(1)
      readonly selectedCable = signal<CableKey>('4.4mm')
      readonly isWishlisted = signal(false)
      readonly addExtendedWarranty = signal(true)
      readonly zipCode = signal('94103')
      readonly cartNotice = signal<string | null>(null)
      readonly question = signal('')
      readonly helpfulVotes = signal<Record<string, number>>({ 'review-1': 24, 'review-2': 11 })
    
      readonly currentVariant = computed(() => variants.find((v) => v.id === this.selectedVariantId()) ?? variants[0])
    
      readonly activeImageUrl = computed(() => {
        const variant = this.currentVariant()
        return variant.images[this.activeImageIndex()]?.url ?? variant.images[0].url
      })
    
      readonly finalPrice = computed(() => {
        let base = this.currentVariant().price * this.quantity()
        if (this.addExtendedWarranty()) base += 49
        return base
      })
    
      get rootClass(): string {
        return cn('bg-background text-foreground w-full space-y-10', this.className)
      }
    
      wishlistBtnClass(): string {
        return cn(
          'flex size-8 items-center justify-center rounded-full border shadow-2xs backdrop-blur-xs transition-colors border-border bg-background/90',
          this.isWishlisted() ? 'text-destructive' : 'text-muted-foreground hover:text-foreground',
        )
      }
    
      thumbClass(idx: number): string {
        return cn(
          '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',
        )
      }
    
      swatchClass(id: string): string {
        return cn(
          'group relative flex size-9 items-center justify-center rounded-full border transition-colors',
          this.selectedVariantId() === id
            ? 'border-primary ring-primary ring-offset-background ring-2 ring-offset-2'
            : 'border-border hover:border-muted-foreground',
        )
      }
    
      cableClass(key: CableKey): string {
        return cn(
          'rounded-lg border p-2.5 text-center transition-colors',
          this.selectedCable() === key
            ? 'border-primary bg-primary/5 text-primary ring-primary font-semibold ring-1'
            : 'border-border bg-card text-foreground hover:bg-muted/40',
        )
      }
    
      helpfulCount(id: string): number {
        return this.helpfulVotes()[id] ?? 0
      }
    
      markHelpful(id: string): void {
        this.helpfulVotes.update((prev) => ({ ...prev, [id]: (prev[id] ?? 0) + 1 }))
      }
    
      selectVariant(v: ProductVariant): void {
        this.selectedVariantId.set(v.id)
        this.activeImageIndex.set(0)
      }
    
      incrementQty(): void {
        if (this.quantity() < this.currentVariant().stockCount) {
          this.quantity.update((q) => q + 1)
        }
      }
    
      decrementQty(): void {
        if (this.quantity() > 1) {
          this.quantity.update((q) => q - 1)
        }
      }
    
      handleAddToCart(): void {
        // The Vue/React blocks use window.alert here; Angular renders the same confirmation
        // copy inline so it stays testable under jsdom and never blocks the event loop.
        this.cartNotice.set(
          `Added ${this.quantity()}x ${this.currentVariant().name} (${this.selectedCable()}) to cart. Total: $${this.finalPrice()}`,
        )
      }
    
      shareLink(): void {
        this.cartNotice.set('Share link copied to clipboard!')
      }
    
      handleExpressBuy(): void {
        this.cartNotice.set(`Initiating Express Apple Pay checkout for $${this.finalPrice()}...`)
      }
    
      addBundleToCart(): void {
        this.cartNotice.set(`Added bundle (3 items) to cart. Total: $${this.bundleTotal}`)
      }
    
      askQuestion(): void {
        const q = this.question().trim()
        if (!q) return
        this.cartNotice.set('Question posted — factory staff typically reply within 24 hours.')
        this.question.set('')
      }
    }
    
  • src/app/components/blocks/product-detail-page/index.ts0.1 kB

Raw manifest:https://uipkge.dev/r/angular/product-detail-page.json