Price Drop Alert Card
blockcommerceE-commerce wishlist price tracker with 90-day price history chart, dynamic target price threshold slider, multi-channel alert configurator, and active tracked items table.
Installation
$pnpm dlx uipkge-ng@latest add price-drop-alert-card$npx uipkge-ng@latest add price-drop-alert-card$yarn dlx uipkge-ng@latest add price-drop-alert-card$bunx uipkge-ng@latest add price-drop-alert-cardManifest:
https://uipkge.dev/r/angular/price-drop-alert-card.jsonInstalls to:src/app/components/blocks/price-drop-alert-card/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.
TrackedProductinterface TrackedProduct {
id: string
name: string
variant: string
category: string
sku: string
image: string
currentPrice: number
targetPrice: number
lowest90d: number
channel: PriceAlertChannel
status: 'monitoring' | 'reached'
active: boolean
}npm dependencies
Theming
CSS custom properties referenced in this item. Override any of them in your :root or per-element to retheme.
--primaryFiles installed (2)
src/app/components/blocks/price-drop-alert-card/price-drop-alert-card.component.ts35.1 kB
import { Component, Input, OnDestroy, computed, signal, ChangeDetectionStrategy } from '@angular/core' import { BellRing, Check, History, LucideAngularModule, Mail, RefreshCw, ShieldCheck, Smartphone, Tag, Trash2, TrendingDown, Zap, } from 'lucide-angular' import { cn } from '@/lib/utils' import { UiBadgeComponent } from '@/ui/badge/badge.component' import { UiButtonComponent } from '@/ui/button/button.component' import { UiCardComponent, UiCardContentComponent, UiCardDescriptionComponent, UiCardFooterComponent, UiCardHeaderComponent, UiCardTitleComponent, } from '@/ui/card/card.component' import { UiRadioGroupComponent, UiRadioGroupItemComponent } from '@/ui/radio-group/radio-group.component' import { UiSeparatorComponent } from '@/ui/separator/separator.component' import { UiSliderComponent } from '@/ui/slider/slider.component' import { UiSwitchComponent } from '@/ui/switch/switch.component' import { UiTableBodyComponent, UiTableCellComponent, UiTableComponent, UiTableHeadComponent, UiTableHeaderComponent, UiTableRowComponent, } from '@/ui/table/table.component' export type PriceAlertChannel = 'email' | 'sms' export interface TrackedProduct { id: string name: string variant: string category: string sku: string image: string currentPrice: number targetPrice: number lowest90d: number channel: PriceAlertChannel status: 'monitoring' | 'reached' active: boolean } const initialTrackedProducts: TrackedProduct[] = [ { id: 'prod-1', name: 'Pro Studio Wireless Headphones', variant: 'Space Black', category: 'Audio', sku: 'SKU-8842', image: 'https://images.unsplash.com/photo-1505740420928-5e560c06d30e?w=300&auto=format&fit=crop&q=80', currentPrice: 299.0, targetPrice: 280.0, lowest90d: 279.0, channel: 'email', status: 'monitoring', active: true, }, { id: 'prod-2', name: 'Ergonomic Mechanical Keyboard', variant: 'RGB / Cherry MX Brown', category: 'Peripherals', sku: 'SKU-4109', image: 'https://images.unsplash.com/photo-1587829741301-dc798b83add3?w=300&auto=format&fit=crop&q=80', currentPrice: 119.0, targetPrice: 120.0, lowest90d: 119.0, channel: 'sms', status: 'reached', active: true, }, { id: 'prod-3', name: 'Ultra-Wide Curved Monitor 34"', variant: 'WQHD 144Hz IPS', category: 'Displays', sku: 'SKU-9011', image: 'https://images.unsplash.com/photo-1527443224154-c4a3942d3acf?w=300&auto=format&fit=crop&q=80', currentPrice: 679.0, targetPrice: 599.0, lowest90d: 629.0, channel: 'email', status: 'monitoring', active: true, }, ] /** * E-commerce wishlist price tracker: product hero, a 90-day price history SVG chart whose * amber threshold line follows the target slider, the alert configurator (delivery channel * radio, target price slider with quick presets, in-stock switch, confirmation banner) and * the active tracked items table. Angular port of the React `PriceDropAlertCard` block * (same markup, classes, copy and sample data). */ @Component({ changeDetection: ChangeDetectionStrategy.Eager, selector: 'ui-price-drop-alert-card, [ui-price-drop-alert-card]', standalone: true, imports: [ LucideAngularModule, UiBadgeComponent, UiButtonComponent, UiCardComponent, UiCardContentComponent, UiCardDescriptionComponent, UiCardFooterComponent, UiCardHeaderComponent, UiCardTitleComponent, UiRadioGroupComponent, UiRadioGroupItemComponent, UiSeparatorComponent, UiSliderComponent, UiSwitchComponent, UiTableBodyComponent, UiTableCellComponent, UiTableComponent, UiTableHeadComponent, UiTableHeaderComponent, UiTableRowComponent, ], host: { '[attr.data-slot]': '"price-drop-alert-card"', '[class]': 'hostClass', }, template: ` <!-- Header bar --> <div class="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between"> <div class="space-y-1"> <div class="flex items-center gap-2"> <h2 class="text-foreground text-2xl font-semibold tracking-tight sm:text-3xl">Price Drop Tracker</h2> <span ui-badge variant="outline" class="border-success/30 bg-success/10 text-success gap-1.5 text-xs font-semibold" > <span class="bg-success size-1.5 animate-pulse rounded-full"></span> Live Sync </span> </div> <p class="text-muted-foreground text-sm"> Monitor historical market fluctuations, set custom discount thresholds, and receive real-time notifications. </p> </div> <div class="flex items-center gap-2"> <button ui-button variant="outline" size="sm" class="gap-1.5 text-xs" [disabled]="isRefreshing()" (click)="triggerRefresh()" > <lucide-icon [img]="RefreshCw" [class]="cn('size-3.5', isRefreshing() && 'text-primary animate-spin')" /> <span>{{ isRefreshing() ? 'Checking prices…' : 'Check All Prices' }}</span> </button> </div> </div> <!-- 1. Product Wishlist Hero --> <ui-card class="border-border bg-card overflow-hidden shadow-xs"> <div class="grid grid-cols-1 gap-6 p-6 lg:grid-cols-12 lg:gap-8"> <!-- Thumbnail column --> <div class="relative flex flex-col items-center justify-center lg:col-span-4"> <div class="border-border/80 bg-muted/30 relative aspect-square w-full max-w-72 overflow-hidden rounded-xl border" > <img src="https://images.unsplash.com/photo-1505740420928-5e560c06d30e?w=800&auto=format&fit=crop&q=80" alt="Pro Studio Wireless Headphones - Space Black" class="size-full object-cover object-center transition-transform duration-300 hover:scale-105" /> <div class="absolute top-3 left-3 flex flex-col gap-1.5"> <span ui-badge variant="secondary" class="bg-background/90 text-foreground border-border/40 border text-xs font-medium backdrop-blur-xs" > Audio · SKU-8842 </span> </div> <div class="absolute right-3 bottom-3"> <span ui-badge class="bg-success bg-success text-xs font-semibold text-white shadow-xs"> Lowest in 90 Days </span> </div> </div> </div> <!-- Product detail column --> <div class="flex flex-col justify-between space-y-4 lg:col-span-8"> <div class="space-y-2"> <div class="text-muted-foreground flex flex-wrap items-center gap-2 text-xs"> <span class="text-foreground font-medium">Sony Acoustic Systems</span> <span>•</span> <span>Model WH-1000XM-Pro</span> <span>•</span> <span class="text-warning inline-flex items-center gap-1 font-medium"> ★ 4.8 <span class="text-muted-foreground">(1,420 reviews)</span> </span> </div> <h3 class="text-foreground text-xl font-semibold tracking-tight sm:text-2xl"> Pro Studio Wireless Headphones - Space Black </h3> <p class="text-muted-foreground text-sm leading-relaxed"> Flagship hybrid active noise cancelling with beryllium dynamic drivers, 38-hour battery longevity, lossless LDAC codec support, and ultra-plush memory foam acoustic isolation cushions. </p> </div> <!-- Price breakdown strip --> <div class="border-border/60 bg-muted/20 rounded-xl border p-4"> <div class="flex flex-wrap items-baseline gap-3 sm:gap-4"> <div class="flex items-baseline gap-1.5"> <span class="text-foreground text-3xl font-semibold tracking-tight tabular-nums sm:text-4xl"> \${{ currentPrice.toFixed(2) }} </span> <span class="text-muted-foreground text-sm tabular-nums line-through"> \${{ msrpPrice.toFixed(2) }} </span> </div> <div class="flex flex-wrap items-center gap-2"> <span class="border-success/25 bg-success/10 text-success inline-flex items-center gap-1 rounded-full border px-2.5 py-1 text-xs font-semibold" > <lucide-icon [img]="TrendingDown" class="size-3.5" /> Save $50.00 (-14.3%) </span> <span class="border-border bg-background text-muted-foreground inline-flex items-center gap-1 rounded-full border px-2.5 py-1 text-xs font-medium" > <lucide-icon [img]="Tag" class="text-warning size-3" /> 90-Day Low: <span class="text-foreground font-semibold tabular-nums">\${{ lowest90dPrice.toFixed(2) }}</span> </span> </div> </div> <div class="text-muted-foreground border-border/40 mt-3 flex flex-wrap items-center gap-4 border-t pt-3 text-xs" > <span class="text-success inline-flex items-center gap-1.5 font-medium"> <lucide-icon [img]="Check" class="size-3.5" /> In Stock (14 left) </span> <span>•</span> <span class="inline-flex items-center gap-1.5"> <lucide-icon [img]="ShieldCheck" class="text-muted-foreground size-3.5" /> 30-Day Price Match Guarantee </span> <span>•</span> <span>Free 2-Day Priority Delivery</span> </div> </div> </div> </div> </ui-card> <!-- 2. 90-Day Price History SVG Trend Chart --> <ui-card class="border-border bg-card shadow-xs"> <ui-card-header class="pb-3"> <div class="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between"> <div class="space-y-1"> <ui-card-title class="flex items-center gap-2 text-base font-semibold sm:text-lg"> <lucide-icon [img]="History" class="text-primary size-4" /> 90-Day Price History & Target Threshold </ui-card-title> <ui-card-description class="text-xs sm:text-sm"> Retail price trajectory from May through August with real-time target price alert line. </ui-card-description> </div> <!-- Quick trend stats pills --> <div class="flex flex-wrap items-center gap-2 text-xs"> <div class="border-border/80 bg-muted/40 rounded-md border px-2.5 py-1"> <span class="text-muted-foreground">High: </span> <span class="text-foreground font-semibold tabular-nums">$349.00</span> </div> <div class="border-border/80 bg-muted/40 rounded-md border px-2.5 py-1"> <span class="text-muted-foreground">Avg: </span> <span class="text-foreground font-semibold tabular-nums">$324.50</span> </div> <div class="border-success/30 bg-success/10 text-success rounded-md border px-2.5 py-1"> <span class="opacity-80">90d Low: </span> <span class="font-bold tabular-nums">$279.00</span> </div> </div> </div> </ui-card-header> <ui-card-content class="pt-2"> <div class="border-border/60 bg-muted/10 relative w-full overflow-x-auto rounded-lg border p-2 sm:p-4"> <svg viewBox="0 0 600 180" class="h-auto w-full max-w-[560px] min-w-full overflow-visible select-none" preserveAspectRatio="xMidYMid meet" > <defs> <linearGradient id="angular-price-gradient" x1="0" y1="0" x2="0" y2="1"> <stop offset="0%" stop-color="var(--primary)" stop-opacity="0.28" /> <stop offset="100%" stop-color="var(--primary)" stop-opacity="0.0" /> </linearGradient> </defs> <!-- Y-Axis Grid Lines & Labels --> <line x1="50" y1="20" x2="575" y2="20" class="stroke-border/40 stroke-1" stroke-dasharray="3 3" /> <text x="42" y="24" text-anchor="end" class="fill-muted-foreground font-mono text-xs tabular-nums"> $360 </text> <line x1="50" y1="63.3" x2="575" y2="63.3" class="stroke-border/40 stroke-1" stroke-dasharray="3 3" /> <text x="42" y="67" text-anchor="end" class="fill-muted-foreground font-mono text-xs tabular-nums"> $320 </text> <line x1="50" y1="106.6" x2="575" y2="106.6" class="stroke-border/40 stroke-1" stroke-dasharray="3 3" /> <text x="42" y="110" text-anchor="end" class="fill-muted-foreground font-mono text-xs tabular-nums"> $280 </text> <line x1="50" y1="150" x2="575" y2="150" class="stroke-border/40 stroke-1" /> <text x="42" y="154" text-anchor="end" class="fill-muted-foreground font-mono text-xs tabular-nums"> $240 </text> <!-- Price Area Fill --> <path d="M 60,31.9 C 105,31.9 105,53.6 150,53.6 C 200,53.6 200,31.9 250,31.9 C 300,31.9 300,107.8 350,107.8 C 405,107.8 405,64.4 460,64.4 C 510,64.4 510,86.1 560,86.1 L 560,150 L 60,150 Z" fill="url(#angular-price-gradient)" /> <!-- Price Trajectory Line --> <path d="M 60,31.9 C 105,31.9 105,53.6 150,53.6 C 200,53.6 200,31.9 250,31.9 C 300,31.9 300,107.8 350,107.8 C 405,107.8 405,64.4 460,64.4 C 510,64.4 510,86.1 560,86.1" fill="none" class="stroke-primary stroke-2" stroke-linecap="round" stroke-linejoin="round" /> <!-- Target Price Dynamic Threshold Line (Amber Dashed) --> <line x1="50" [attr.y1]="targetY()" x2="575" [attr.y2]="targetY()" class="stroke-amber-500 stroke-2 transition-colors duration-150 ease-out" stroke-dasharray="4 4" /> <!-- Dynamic Target Label Tag --> <g [attr.transform]="targetTransform()" class="transition-colors duration-150 ease-out"> <rect x="-96" y="-18" width="96" height="18" rx="4" class="fill-warning dark:fill-amber-600" /> <text x="-48" y="-5" text-anchor="middle" class="fill-white font-mono text-xs font-bold tracking-tight"> Alert: \${{ targetPrice().toFixed(0) }}.00 </text> </g> <!-- Historical Data Points & Annotations --> <circle cx="60" cy="31.9" r="3.5" class="fill-background stroke-primary stroke-2" /> <circle cx="150" cy="53.6" r="3.5" class="fill-background stroke-primary stroke-2" /> <circle cx="250" cy="31.9" r="3.5" class="fill-background stroke-primary stroke-2" /> <!-- Pt 4: Jul 26 ($279 - 90d Low) --> <circle cx="350" cy="107.8" r="5" class="stroke-background fill-success stroke-2" /> <rect x="306" y="117" width="88" height="18" rx="4" class="fill-success/10 stroke-emerald-500/30 stroke-1" /> <text x="350" y="130" text-anchor="middle" class="dark:fill-success fill-emerald-600 font-mono text-xs font-bold" > 90d Low · $279 </text> <circle cx="460" cy="64.4" r="3.5" class="fill-background stroke-primary stroke-2" /> <!-- Pt 6: Aug 21 (Current $299) --> <circle cx="560" cy="86.1" r="5" class="fill-primary stroke-background stroke-2" /> <rect x="508" y="58" width="80" height="18" rx="4" class="fill-primary/10 stroke-primary/30 stroke-1" /> <text x="548" y="71" text-anchor="middle" class="fill-primary font-mono text-xs font-bold"> Now · $299.00 </text> <!-- X-Axis Timeline Milestones --> <text x="60" y="168" text-anchor="middle" class="fill-muted-foreground font-mono text-xs">May 22</text> <text x="150" y="168" text-anchor="middle" class="fill-muted-foreground font-mono text-xs">Jun 15</text> <text x="250" y="168" text-anchor="middle" class="fill-muted-foreground font-mono text-xs">Jul 05</text> <text x="350" y="168" text-anchor="middle" class="fill-muted-foreground font-mono text-xs">Jul 26</text> <text x="460" y="168" text-anchor="middle" class="fill-muted-foreground font-mono text-xs">Aug 10</text> <text x="560" y="168" text-anchor="middle" class="fill-foreground font-mono text-xs font-semibold"> Today </text> </svg> </div> <div class="text-muted-foreground mt-3 flex items-center justify-between text-xs"> <div class="flex items-center gap-2"> <span class="bg-warning size-2 rounded-full"></span> <span>Amber dashed threshold line updates automatically as you move the target slider below.</span> </div> <span class="font-mono text-xs">Updated 4m ago</span> </div> </ui-card-content> </ui-card> <!-- 3. Configure Price Drop Alert Form --> <ui-card class="border-border bg-card shadow-xs"> <ui-card-header> <ui-card-title class="flex items-center gap-2 text-base font-semibold sm:text-lg"> <lucide-icon [img]="BellRing" class="text-primary size-4" /> Configure Price Drop Alert </ui-card-title> <ui-card-description class="text-xs sm:text-sm"> Set your notification channel and custom trigger price. We check pricing every 15 minutes across verified retailers. </ui-card-description> </ui-card-header> <ui-card-content class="space-y-6"> <!-- Step 1: Alert Delivery Method Radio --> <div class="space-y-3"> <label class="text-foreground text-sm font-semibold">1. Alert Delivery Channel</label> <ui-radio-group [value]="alertMethod()" (valueChange)="setAlertMethod($event)" class="grid grid-cols-1 gap-3 sm:grid-cols-2" > <label for="angular-method-email" [class]="methodClass('email')"> <ui-radio-group-item id="angular-method-email" value="email" /> <div class="space-y-0.5"> <div class="text-foreground flex items-center gap-1.5 text-sm font-semibold"> <lucide-icon [img]="Mail" class="text-muted-foreground size-3.5" /> Email Notification </div> <p class="text-muted-foreground font-mono text-xs">customer@example.com</p> </div> </label> <label for="angular-method-sms" [class]="methodClass('sms')"> <ui-radio-group-item id="angular-method-sms" value="sms" /> <div class="space-y-0.5"> <div class="text-foreground flex items-center gap-1.5 text-sm font-semibold"> <lucide-icon [img]="Smartphone" class="text-muted-foreground size-3.5" /> SMS Push Notification </div> <p class="text-muted-foreground font-mono text-xs">+1 (555) 019-2834</p> </div> </label> </ui-radio-group> </div> <ui-separator /> <!-- Step 2: Target Price Slider & Input --> <div class="space-y-4"> <div class="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between"> <div class="space-y-0.5"> <label class="text-foreground text-sm font-semibold">2. Target Price Alert Threshold</label> <p class="text-muted-foreground text-xs"> Trigger an alert when the item price drops to or below this amount. </p> </div> <!-- Target Price Display --> <div class="flex items-center gap-2"> <div class="border-border bg-muted/40 flex items-center rounded-lg border px-3 py-1.5"> <span class="text-muted-foreground mr-1.5 text-xs font-medium">Target:</span> <span class="text-foreground font-mono text-lg font-semibold tabular-nums"> \${{ targetPrice().toFixed(2) }} </span> </div> </div> </div> <!-- Slider component --> <div class="space-y-2 pt-2"> <ui-slider [value]="[targetPrice()]" (valueChange)="onSliderChange($event)" [min]="200" [max]="349" [step]="1" class="w-full" /> <!-- Min/Max labels --> <div class="text-muted-foreground flex justify-between font-mono text-xs"> <span>$200.00 (Deep Deal)</span> <span class="text-warning font-semibold">Target: \${{ targetPrice().toFixed(2) }}</span> <span>$349.00 (MSRP)</span> </div> </div> <!-- Savings calculation box & quick presets --> <div class="border-border/80 bg-muted/20 flex flex-col gap-3 rounded-lg border p-3.5 sm:flex-row sm:items-center sm:justify-between" > <div class="flex items-center gap-2"> <lucide-icon [img]="Tag" class="text-success size-4 shrink-0" /> <div class="text-xs"> @if (targetPrice() < currentPrice) { <span class="text-foreground font-medium">Notify when price drops below </span> <span class="text-success font-mono font-semibold">\${{ targetPrice().toFixed(2) }}</span> <span class="text-muted-foreground"> — saves an extra </span> <span class="text-foreground font-mono font-semibold">\${{ additionalSavings().toFixed(2) }}</span> <span class="text-muted-foreground"> ({{ additionalSavingsPercent() }}% additional drop)</span> } @else if (targetPrice() === currentPrice) { <span class="text-foreground font-medium">Matches current price </span> <span class="text-primary font-mono font-semibold">\${{ currentPrice.toFixed(2) }}</span> <span class="text-muted-foreground"> — triggers on any new price movement</span> } @else { <span class="text-foreground font-medium"> Target is set above current price (\${{ currentPrice.toFixed(2) }}). Alert triggers immediately. </span> } </div> </div> <!-- Quick preset buttons --> <div class="flex shrink-0 flex-wrap items-center gap-1.5"> <button ui-button variant="outline" size="sm" class="h-7 px-2 font-mono text-xs" (click)="handlePresetClick(289)" > $289 (-$10) </button> <button ui-button variant="outline" size="sm" class="border-success/40 text-success h-7 px-2 font-mono text-xs" (click)="handlePresetClick(279)" > $279 (Low) </button> <button ui-button variant="outline" size="sm" class="h-7 px-2 font-mono text-xs" (click)="handlePresetClick(249)" > $249 (-$50) </button> </div> </div> </div> <ui-separator /> <!-- Step 3: In-Stock Notification Switch --> <div class="border-border/80 flex flex-col gap-3 rounded-lg border p-4 sm:flex-row sm:items-center sm:justify-between" > <div class="space-y-0.5"> <div class="flex items-center gap-2"> <lucide-icon [img]="Zap" class="text-warning size-4" /> <label for="angular-stock-switch" class="text-foreground cursor-pointer text-sm font-semibold"> In-Stock & Lightning Flash Deal Notification </label> </div> <p class="text-muted-foreground text-xs"> Send instant high-priority alert if available stock falls below 5 units or if a limited lightning coupon goes live. </p> </div> <button ui-switch id="angular-stock-switch" [checked]="inStockNotification()" (checkedChange)="inStockNotification.set($event)" ></button> </div> <!-- Alert confirmation banner (shown if saved) --> @if (alertSaved()) { <div class="border-success/30 bg-success/10 text-success flex items-start gap-3 rounded-lg border p-4"> <div class="bg-success mt-0.5 shrink-0 rounded-full p-1 text-white"> <lucide-icon [img]="Check" class="size-3.5" /> </div> <div class="space-y-1 text-xs"> <p class="text-success dark:text-foreground text-sm font-semibold"> Price Drop Alert Configured Successfully! </p> <p> We'll monitor <span class="font-semibold">Pro Studio Wireless Headphones</span> every 15 minutes and dispatch an alert to <span class="font-mono font-semibold">{{ alertDestination() }}</span> the moment the price reaches <span class="font-mono font-semibold">\${{ targetPrice().toFixed(2) }}</span> or lower. </p> </div> </div> } </ui-card-content> <ui-card-footer class="border-border/60 flex flex-col gap-3 border-t pt-4 sm:flex-row sm:items-center sm:justify-between" > <div class="text-muted-foreground flex items-center gap-1.5 text-xs"> <lucide-icon [img]="ShieldCheck" class="text-success size-4 shrink-0" /> <span>Zero spam guarantee. You can pause or cancel alert subscriptions anytime.</span> </div> <button ui-button class="w-full gap-2 font-semibold sm:w-auto" (click)="handleSetAlert()"> <lucide-icon [img]="BellRing" class="size-4" /> <span>{{ alertSaved() ? 'Update Price Drop Alert' : 'Set Price Drop Alert' }}</span> </button> </ui-card-footer> </ui-card> <!-- 4. Active Tracked Products Mini Table --> <ui-card class="border-border bg-card shadow-xs"> <ui-card-header class="pb-3"> <div class="flex flex-col gap-1 sm:flex-row sm:items-center sm:justify-between"> <div> <ui-card-title class="text-base font-semibold sm:text-lg">Active Tracked Wishlist Items</ui-card-title> <ui-card-description class="text-xs sm:text-sm"> Real-time monitoring list currently linked to your notification profile. </ui-card-description> </div> <span ui-badge variant="secondary" class="w-fit font-mono text-xs"> {{ trackedProducts().length }} items active </span> </div> </ui-card-header> <ui-card-content> <div class="border-border/60 overflow-x-auto rounded-lg border"> <div ui-table> <thead ui-table-header> <tr ui-table-row class="bg-muted/40 hover:bg-muted/40"> <th ui-table-head class="text-xs font-semibold">Product</th> <th ui-table-head class="text-right text-xs font-semibold">Current Price</th> <th ui-table-head class="text-right text-xs font-semibold">Target Alert</th> <th ui-table-head class="text-right text-xs font-semibold">90-Day Low</th> <th ui-table-head class="text-xs font-semibold">Channel</th> <th ui-table-head class="text-xs font-semibold">Status</th> <th ui-table-head class="text-right text-xs font-semibold">Actions</th> </tr> </thead> <tbody ui-table-body> @for (item of trackedProducts(); track item.id) { <tr ui-table-row class="transition-colors"> <!-- Product name & image --> <td ui-table-cell class="py-3"> <div class="flex items-center gap-3"> <img [src]="item.image" [alt]="item.name" class="border-border/60 size-10 shrink-0 rounded-md border object-cover" /> <div class="min-w-0 space-y-0.5"> <p class="text-foreground max-w-56 truncate text-xs font-semibold sm:max-w-xs"> {{ item.name }} </p> <p class="text-muted-foreground truncate text-xs"> {{ item.variant }} · <span class="font-mono">{{ item.sku }}</span> </p> </div> </div> </td> <!-- Current Price --> <td ui-table-cell class="text-foreground py-3 text-right font-mono text-xs font-semibold tabular-nums" > \${{ item.currentPrice.toFixed(2) }} </td> <!-- Target Price --> <td ui-table-cell class="text-warning text-warning py-3 text-right font-mono text-xs font-semibold tabular-nums" > \${{ item.targetPrice.toFixed(2) }} </td> <!-- 90-Day Low --> <td ui-table-cell class="text-muted-foreground py-3 text-right font-mono text-xs tabular-nums"> \${{ item.lowest90d.toFixed(2) }} </td> <!-- Alert Channel --> <td ui-table-cell class="py-3"> <span ui-badge variant="outline" class="gap-1 text-xs font-normal capitalize"> @if (item.channel === 'email') { <lucide-icon [img]="Mail" class="text-muted-foreground size-3" /> } @else { <lucide-icon [img]="Smartphone" class="text-muted-foreground size-3" /> } {{ item.channel }} </span> </td> <!-- Status Badge --> <td ui-table-cell class="py-3"> @if (item.status === 'reached') { <span ui-badge variant="outline" class="border-success/30 bg-success/15 text-success gap-1 text-xs font-semibold" > <lucide-icon [img]="Check" class="size-3" /> Target Reached! </span> } @else { <span ui-badge variant="secondary" class="text-muted-foreground gap-1 text-xs font-medium"> <lucide-icon [img]="TrendingDown" class="text-warning size-3" /> Monitoring (-\${{ (item.currentPrice - item.targetPrice).toFixed(0) }}) </span> } </td> <!-- Actions --> <td ui-table-cell class="py-3 text-right"> <div class="flex items-center justify-end gap-1.5"> <button ui-button variant="ghost" size="sm" class="text-muted-foreground hover:text-destructive size-8 p-0" title="Remove Tracker" (click)="handleRemoveProduct(item.id)" > <lucide-icon [img]="Trash2" class="size-3.5" /> </button> </div> </td> </tr> } </tbody> </div> </div> </ui-card-content> </ui-card> `, }) export class UiPriceDropAlertCardComponent implements OnDestroy { @Input('class') className?: string protected readonly cn = cn protected readonly BellRing = BellRing protected readonly Check = Check protected readonly History = History protected readonly Mail = Mail protected readonly RefreshCw = RefreshCw protected readonly ShieldCheck = ShieldCheck protected readonly Smartphone = Smartphone protected readonly Tag = Tag protected readonly Trash2 = Trash2 protected readonly TrendingDown = TrendingDown protected readonly Zap = Zap readonly currentPrice = 299.0 readonly msrpPrice = 349.0 readonly lowest90dPrice = 279.0 readonly alertMethod = signal<PriceAlertChannel>('email') readonly targetPrice = signal(280) readonly inStockNotification = signal(true) readonly alertSaved = signal(false) readonly isRefreshing = signal(false) readonly trackedProducts = signal<TrackedProduct[]>(initialTrackedProducts.map((p) => ({ ...p }))) // Dynamic savings calculations readonly additionalSavings = computed(() => Math.max(0, this.currentPrice - this.targetPrice())) readonly additionalSavingsPercent = computed(() => this.currentPrice > 0 ? ((this.additionalSavings() / this.currentPrice) * 100).toFixed(1) : '0.0', ) // Dynamic SVG Chart Target Line Calculation // Y range: $240 (y=150) to $360 (y=20), height=130, span=120 readonly targetY = computed(() => { const clampedTarget = Math.max(240, Math.min(360, this.targetPrice())) return 150 - ((clampedTarget - 240) / 120) * 130 }) readonly targetTransform = computed(() => `translate(565, ${this.targetY()})`) readonly alertDestination = computed(() => this.alertMethod() === 'email' ? '[email protected]' : '+1 (555) 019-2834', ) private refreshTimer: ReturnType<typeof setTimeout> | undefined get hostClass(): string { return cn('mx-auto block w-full max-w-5xl space-y-6', this.className) } methodClass(method: PriceAlertChannel): string { return cn( 'flex cursor-pointer items-center gap-3 rounded-lg border p-3.5 transition-colors', this.alertMethod() === method ? 'border-primary bg-primary/5 ring-primary/20 shadow-xs ring-1' : 'border-border bg-card hover:bg-muted/40', ) } setAlertMethod(value: string): void { this.alertMethod.set(value === 'sms' ? 'sms' : 'email') } onSliderChange(value: number[]): void { const next = value[0] if (next !== undefined) this.targetPrice.set(next) } handleSetAlert(): void { this.alertSaved.set(true) } handlePresetClick(price: number): void { this.targetPrice.set(price) } handleRemoveProduct(id: string): void { this.trackedProducts.update((prev) => prev.filter((p) => p.id !== id)) } triggerRefresh(): void { this.isRefreshing.set(true) clearTimeout(this.refreshTimer) this.refreshTimer = setTimeout(() => { this.isRefreshing.set(false) }, 600) } ngOnDestroy(): void { clearTimeout(this.refreshTimer) } }src/app/components/blocks/price-drop-alert-card/index.ts0.1 kB
export { UiPriceDropAlertCardComponent, type PriceAlertChannel, type TrackedProduct, } from './price-drop-alert-card.component'
Raw manifest:https://uipkge.dev/r/angular/price-drop-alert-card.json