Menu

Change language

GitHub repo

Flash Sale Offer Bar

blockecommerce

Limited-time flash sale countdown deal bar with stock claim progress meter, tiered multi-buy volume discounts, and 1-click cart activation.

Installation

$npx uipkge-ng@latest add flash-sale-offer-bar
Manifest:https://uipkge.dev/r/angular/flash-sale-offer-bar.jsonInstalls to:src/app/components/blocks/flash-sale-offer-bar/

Variants

Loading interactive previews…

Schema

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

TierOffer
interface TierOffer {
  id: string
  quantity: number
  discountPct: number
  label: string
  badgeText?: string
  popular?: boolean
}
TimeLeft
interface TimeLeft {
  hours: string
  minutes: string
  seconds: string
}

npm dependencies

Includes

Files installed (2)

  • src/app/components/blocks/flash-sale-offer-bar/flash-sale-offer-bar.component.ts9.4 kB
    // Limited-time flash sale countdown deal bar with stock claim progress meter, tiered
    // multi-buy volume discounts, and 1-click cart activation. Port of the React block 1:1.
    import {
      ChangeDetectionStrategy,
      Component,
      DestroyRef,
      EventEmitter,
      Input,
      Output,
      afterNextRender,
      inject,
      signal,
    } from '@angular/core'
    import { Flame, LucideAngularModule, ShieldCheck, Tag } from 'lucide-angular'
    import { cn } from '@/lib/utils'
    import { UiBadgeComponent } from '@/ui/badge/badge.component'
    import { UiButtonComponent } from '@/ui/button/button.component'
    
    export interface TierOffer {
      id: string
      quantity: number
      discountPct: number
      label: string
      badgeText?: string
      popular?: boolean
    }
    
    export interface TimeLeft {
      hours: string
      minutes: string
      seconds: string
    }
    
    const DEFAULT_TIERS: TierOffer[] = [
      { id: 't1', quantity: 1, discountPct: 15, label: 'Standard Pack', badgeText: 'Save $45' },
      {
        id: 't2',
        quantity: 2,
        discountPct: 25,
        label: 'Duo Studio Bundle',
        badgeText: 'Save $150 · Best Value',
        popular: true,
      },
      { id: 't3', quantity: 3, discountPct: 35, label: 'Team / Lab Triad', badgeText: 'Save $315' },
    ]
    
    @Component({
      changeDetection: ChangeDetectionStrategy.Eager,
      selector: 'ui-flash-sale-offer-bar, [ui-flash-sale-offer-bar]',
      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],
      template: `
        <div data-slot="flash-sale-offer-bar" [class]="rootClass">
          <!-- Deal Header Banner -->
          <div
            class="border-border bg-muted/40 flex flex-col gap-3 border-b p-4 sm:flex-row sm:items-center sm:justify-between"
          >
            <div class="flex items-center gap-2.5">
              <div class="border-border bg-background flex size-8 items-center justify-center rounded-lg border shadow-2xs">
                <lucide-icon [img]="Flame" class="size-4 fill-orange-500/20 text-orange-500" />
              </div>
              <div>
                <div class="flex items-center gap-2">
                  <span class="text-foreground text-sm font-semibold tracking-tight">{{ saleTitle }}</span>
                  <span ui-badge variant="destructive" class="h-5 gap-1 px-1.5 font-mono text-xs uppercase">
                    <span class="bg-background size-1.5 animate-pulse rounded-full"></span>
                    Live
                  </span>
                </div>
                <p class="text-muted-foreground text-xs">Price locks automatically once added to cart.</p>
              </div>
            </div>
    
            <!-- Live Clock Digits -->
            <div class="flex items-center gap-2 self-start sm:self-auto">
              <span class="text-muted-foreground text-xs font-medium">Ends in:</span>
              <div class="text-foreground flex items-center gap-1 font-mono text-xs font-semibold">
                <span class="border-border bg-background rounded border px-1.5 py-0.5 shadow-2xs"
                  >{{ timeLeft().hours }}h</span
                >
                <span>:</span>
                <span class="border-border bg-background rounded border px-1.5 py-0.5 shadow-2xs"
                  >{{ timeLeft().minutes }}m</span
                >
                <span>:</span>
                <span class="border-border bg-background text-destructive rounded border px-1.5 py-0.5 shadow-2xs"
                  >{{ timeLeft().seconds }}s</span
                >
              </div>
            </div>
          </div>
    
          <div class="space-y-4 p-4">
            <!-- Claim Progress Bar -->
            <div class="space-y-1.5">
              <div class="flex items-center justify-between text-xs">
                <span class="text-foreground flex items-center gap-1.5 font-medium">
                  <lucide-icon [img]="Flame" class="text-warning size-3.5" />
                  <span>{{ claimedPercent }}% Claimed</span>
                </span>
                <span class="text-destructive font-mono font-medium">Only {{ itemsLeft }} units remaining in stock</span>
              </div>
              <div class="bg-muted h-2 w-full overflow-hidden rounded-full">
                <div
                  class="h-full rounded-full bg-gradient-to-r from-orange-500 to-rose-500 transition-[width] duration-500"
                  [style.width]="claimedPercent + '%'"
                ></div>
              </div>
            </div>
    
            <!-- Tier Multi-Buy Cards Grid -->
            <div class="grid grid-cols-1 gap-2.5 sm:grid-cols-3">
              @for (tier of tiers; track tier.id) {
                <button
                  type="button"
                  [class]="tierCardClass(tier)"
                  (click)="handleSelectTier(tier)"
                  [attr.aria-pressed]="selectedTierId() === tier.id"
                >
                  <!-- Popular ribbon -->
                  @if (tier.popular) {
                    <span
                      class="border-border bg-primary text-primary-foreground absolute -top-2.5 right-3 rounded-full border px-2 py-0.5 text-xs font-semibold shadow-xs"
                    >
                      MOST POPULAR
                    </span>
                  }
    
                  <div class="space-y-1">
                    <div class="flex items-center justify-between">
                      <span class="text-foreground text-xs font-semibold"
                        >Buy {{ tier.quantity }} {{ tier.quantity > 1 ? 'Units' : 'Unit' }}</span
                      >
                      <span ui-badge variant="secondary" class="text-success font-mono text-xs font-bold">
                        -{{ tier.discountPct }}% OFF
                      </span>
                    </div>
                    <div class="text-muted-foreground text-xs font-medium">{{ tier.label }}</div>
                  </div>
    
                  <div class="border-border/60 mt-3 flex items-center justify-between border-t pt-2 text-xs">
                    <span class="text-muted-foreground">{{ tier.badgeText }}</span>
                    <div [class]="tierCheckClass(tier)">✓</div>
                  </div>
                </button>
              }
            </div>
    
            <!-- Footer Guarantee & CTA Banner -->
            <div
              class="border-border bg-muted/20 flex flex-col gap-2 rounded-lg border p-3 text-xs sm:flex-row sm:items-center sm:justify-between"
            >
              <div class="text-muted-foreground flex items-center gap-2">
                <lucide-icon [img]="ShieldCheck" class="text-success size-4" />
                <span>30-day price-match guarantee · Free express 2-day dispatch</span>
              </div>
              <button ui-button size="sm" class="gap-1.5 text-xs shadow-xs" (click)="claimDeal.emit()">
                <lucide-icon [img]="Tag" class="size-3.5" />
                <span>Apply Tier Discount to Cart</span>
              </button>
            </div>
          </div>
        </div>
      `,
    })
    export class UiFlashSaleOfferBarComponent {
      protected readonly Flame = Flame
      protected readonly ShieldCheck = ShieldCheck
      protected readonly Tag = Tag
    
      @Input() saleTitle = 'Lightning Deal: Limited Quantity Batch'
      @Input() endTimeIso?: string
      @Input() claimedPercent = 78
      @Input() itemsLeft = 14
      /** Accepted for Vue/React API parity (the default selection is the popular tier). */
      @Input() currentTier = 2
      @Input() tiers: TierOffer[] = DEFAULT_TIERS.map((t) => ({ ...t }))
      @Input('class') className?: string
    
      @Output() tierSelect = new EventEmitter<TierOffer>()
      @Output() claimDeal = new EventEmitter<void>()
    
      readonly selectedTierId = signal('')
      readonly timeLeft = signal<TimeLeft>({ hours: '06', minutes: '42', seconds: '19' })
    
      private defaultEndIso: string | null = null
      private timer: ReturnType<typeof setInterval> | undefined
    
      constructor() {
        inject(DestroyRef).onDestroy(() => clearInterval(this.timer))
        // Browser only, after the first render (the Vue onMounted / React useEffect).
        afterNextRender(() => {
          this.tick()
          this.timer = setInterval(() => this.tick(), 1000)
        })
      }
    
      ngOnInit(): void {
        if (!this.selectedTierId()) {
          this.selectedTierId.set(this.tiers[1]?.id ?? this.tiers[0]?.id ?? '')
        }
      }
    
      get rootClass(): string {
        return cn('border-border bg-card w-full overflow-hidden rounded-xl border shadow-xs', this.className)
      }
    
      tierCardClass(tier: TierOffer): string {
        return cn(
          'relative flex flex-col justify-between rounded-lg border p-3 text-left transition-colors',
          this.selectedTierId() === tier.id
            ? 'border-primary bg-primary/5 ring-primary ring-1'
            : 'border-border bg-card hover:border-muted-foreground/40 hover:bg-muted/30',
        )
      }
    
      tierCheckClass(tier: TierOffer): string {
        return cn(
          'flex size-4 items-center justify-center rounded-full border text-xs font-bold',
          this.selectedTierId() === tier.id
            ? 'border-primary bg-primary text-primary-foreground'
            : 'border-muted-foreground/40',
        )
      }
    
      targetIso(): string {
        if (this.endTimeIso) return this.endTimeIso
        if (!this.defaultEndIso) {
          this.defaultEndIso = new Date(Date.now() + 6 * 3600 * 1000 + 42 * 60 * 1000 + 19 * 1000).toISOString()
        }
        return this.defaultEndIso
      }
    
      tick(): void {
        const target = new Date(this.targetIso()).getTime()
        const diff = Math.max(0, target - Date.now())
        const h = Math.floor(diff / (1000 * 60 * 60))
        const m = Math.floor((diff % (1000 * 60 * 60)) / (1000 * 60))
        const s = Math.floor((diff % (1000 * 60)) / 1000)
        this.timeLeft.set({
          hours: String(h).padStart(2, '0'),
          minutes: String(m).padStart(2, '0'),
          seconds: String(s).padStart(2, '0'),
        })
      }
    
      handleSelectTier(tier: TierOffer): void {
        this.selectedTierId.set(tier.id)
        this.tierSelect.emit(tier)
      }
    }
    
  • src/app/components/blocks/flash-sale-offer-bar/index.ts0.1 kB

Raw manifest:https://uipkge.dev/r/angular/flash-sale-offer-bar.json