Flash Sale Offer Bar
blockecommerceLimited-time flash sale countdown deal bar with stock claim progress meter, tiered multi-buy volume discounts, and 1-click cart activation.
Installation
$pnpm dlx uipkge-ng@latest add flash-sale-offer-bar$npx uipkge-ng@latest add flash-sale-offer-bar$yarn dlx uipkge-ng@latest add flash-sale-offer-bar$bunx uipkge-ng@latest add flash-sale-offer-barManifest:
https://uipkge.dev/r/angular/flash-sale-offer-bar.jsonInstalls to:src/app/components/blocks/flash-sale-offer-bar/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.
TierOfferinterface TierOffer {
id: string
quantity: number
discountPct: number
label: string
badgeText?: string
popular?: boolean
}TimeLeftinterface TimeLeft {
hours: string
minutes: string
seconds: string
}npm dependencies
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
export { UiFlashSaleOfferBarComponent, type TierOffer, type TimeLeft } from './flash-sale-offer-bar.component'
Raw manifest:https://uipkge.dev/r/angular/flash-sale-offer-bar.json