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 uipkge-ng@latest add product-detail-page$npx uipkge-ng@latest add product-detail-page$yarn dlx uipkge-ng@latest add product-detail-page$bunx uipkge-ng@latest add product-detail-pageManifest:
https://uipkge.dev/r/angular/product-detail-page.jsonInstalls to:src/app/components/blocks/product-detail-page/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.
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 }[]
}BundleIteminterface BundleItem {
id: string
name: string
detail: string
price: number
}SpecRowinterface SpecRow {
label: string
value: string
}npm dependencies
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
export { UiProductDetailPageComponent, variants, type ProductVariant } from './product-detail-page.component'
Raw manifest:https://uipkge.dev/r/angular/product-detail-page.json