Menu

Change language

GitHub repo

Price Drop Alert Card

blockcommerce

E-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

$npx uipkge-ng@latest add price-drop-alert-card
Manifest:https://uipkge.dev/r/angular/price-drop-alert-card.jsonInstalls to:src/app/components/blocks/price-drop-alert-card/

Variants

Loading interactive previews…

Schema

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

TrackedProduct
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
}

Theming

CSS custom properties referenced in this item. Override any of them in your :root or per-element to retheme.

--primary

Files 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 &amp; 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&#64;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 &amp; 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

Raw manifest:https://uipkge.dev/r/angular/price-drop-alert-card.json