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 shadcn@latest add https://uipkge.dev/r/lit/flash-sale-offer-bar.json$npx shadcn@latest add https://uipkge.dev/r/lit/flash-sale-offer-bar.json$yarn dlx shadcn@latest add https://uipkge.dev/r/lit/flash-sale-offer-bar.json$bunx shadcn@latest add https://uipkge.dev/r/lit/flash-sale-offer-bar.jsonManifest:
https://uipkge.dev/r/lit/flash-sale-offer-bar.jsonInstalls to:src/uip/blocks/flash-sale-offer-bar/Variants
Loading interactive previews…
Loading interactive previews…
Props
| Name | Type / Values | Default | Required |
|---|---|---|---|
sale-title | string | Lightning Deal: Limited Quantity Batch | optional |
end-time-iso | string | — | optional |
claimed-percent | number | 78 | optional |
items-left | number | 14 | optional |
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
}HTMLElementTagNameMapinterface HTMLElementTagNameMap {
'uip-flash-sale-offer-bar': UipFlashSaleOfferBar
}
}Files installed (1)
src/uip/blocks/flash-sale-offer-bar/flash-sale-offer-bar.ts10 kB
import { LitElement, css, html, nothing } from 'lit' import { Flame, ShieldCheck, Tag } from 'lucide' import { tailwind } from '../../lib/styles' import { ThemeController } from '../../lib/theme' import { icon } from '../../lib/icon' // Side-effect imports: register the composed elements for standalone use // (consumers who install only this block still get working primitives). import '../../components/badge/badge' import '../../components/button/button' export interface TierOffer { id: string quantity: number discountPct: number label: string badgeText?: string popular?: boolean } const defaultTiers: 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' }, ] /** * <uip-flash-sale-offer-bar> — the registry FlashSaleOfferBar block as a web component. * * Class strings and tier data are React's verbatim. React state becomes * { state: true } fields; the 1s countdown interval is cleared in * disconnectedCallback. `tiers` is a property (attribute: false — set it from * JS). `onSelectTier` (tier) / `onClaimDeal` become `select-tier` / * `claim-deal` CustomEvents (bubbles, composed; tier in `detail`). React's * `currentTier` prop is declared but never read by the source, so it is not * ported. */ export class UipFlashSaleOfferBar extends LitElement { static shadowRootOptions = { ...LitElement.shadowRootOptions, delegatesFocus: true } // :host display can't be a utility class (the host has no template of its own). static styles = [tailwind, css`:host { display: block; }`] static properties = { saleTitle: { attribute: 'sale-title' }, endTimeIso: { attribute: 'end-time-iso' }, claimedPercent: { type: Number, attribute: 'claimed-percent' }, itemsLeft: { type: Number, attribute: 'items-left' }, tiers: { attribute: false }, selectedTierId: { state: true }, hours: { state: true }, minutes: { state: true }, seconds: { state: true }, } saleTitle = 'Lightning Deal: Limited Quantity Batch' endTimeIso?: string claimedPercent = 78 itemsLeft = 14 tiers: TierOffer[] = defaultTiers private selectedTierId = '' private hours = '06' private minutes = '42' private seconds = '19' private timer?: number constructor() { super() new ThemeController(this) } connectedCallback() { super.connectedCallback() this.setAttribute('data-uipkge', '') this.setAttribute('data-slot', 'flash-sale-offer-bar') const list = this.tiers ?? [] this.selectedTierId = list[1]?.id || list[0]?.id || '' this.updateCountdown() this.timer = window.setInterval(() => this.updateCountdown(), 1000) } disconnectedCallback() { super.disconnectedCallback() if (this.timer) window.clearInterval(this.timer) } willUpdate(changed: Map<string, unknown>) { if (changed.has('tiers')) { const list = this.tiers ?? [] if (!list.some((t) => t.id === this.selectedTierId)) { this.selectedTierId = list[1]?.id || list[0]?.id || '' } } } private get targetTime(): string { if (this.endTimeIso) return this.endTimeIso return new Date(Date.now() + 6 * 3600 * 1000 + 42 * 60 * 1000 + 19 * 1000).toISOString() } private updateCountdown() { const target = new Date(this.targetTime).getTime() const now = Date.now() const diff = Math.max(0, target - 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.hours = String(h).padStart(2, '0') this.minutes = String(m).padStart(2, '0') this.seconds = String(s).padStart(2, '0') } private handleSelect(tier: TierOffer) { this.selectedTierId = tier.id this.dispatchEvent(new CustomEvent('select-tier', { detail: { ...tier }, bubbles: true, composed: true })) } render() { const tiers = this.tiers ?? [] return html` <div data-slot="flash-sale-offer-bar" class="border-border bg-card w-full overflow-hidden rounded-xl border shadow-xs"> <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"> ${icon(Flame, 'flame', '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">${this.saleTitle}</span> <uip-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 </uip-badge> </div> <p class="text-muted-foreground text-xs">Price locks automatically once added to cart.</p> </div> </div> <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">${this.hours}h</span> <span>:</span> <span class="border-border bg-background rounded border px-1.5 py-0.5 shadow-2xs">${this.minutes}m</span> <span>:</span> <span class="border-border bg-background text-destructive rounded border px-1.5 py-0.5 shadow-2xs" >${this.seconds}s</span > </div> </div> </div> <div class="space-y-4 p-4"> <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"> ${icon(Flame, 'flame', 'text-warning size-3.5')} <span>${this.claimedPercent}% Claimed</span> </span> <span class="text-destructive font-mono font-medium">Only ${this.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:${this.claimedPercent}%" ></div> </div> </div> <div class="grid grid-cols-1 gap-2.5 sm:grid-cols-3"> ${tiers.map( (tier) => html`<button type="button" class=${`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' }`} @click=${() => this.handleSelect(tier)} > ${tier.popular ? html`<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>` : nothing} <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> <uip-badge variant="secondary" class="text-success font-mono text-xs font-bold"> -${tier.discountPct}% OFF </uip-badge> </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=${`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' }`} > ✓ </div> </div> </button>`, )} </div> <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"> ${icon(ShieldCheck, 'shield-check', 'text-success size-4')} <span>30-day price-match guarantee · Free express 2-day dispatch</span> </div> <uip-button size="sm" class="gap-1.5 text-xs shadow-xs" @click=${() => this.dispatchEvent(new CustomEvent('claim-deal', { bubbles: true, composed: true }))} > ${icon(Tag, 'tag', 'size-3.5')} <span>Apply Tier Discount to Cart</span> </uip-button> </div> </div> </div> ` } } customElements.get('uip-flash-sale-offer-bar') || customElements.define('uip-flash-sale-offer-bar', UipFlashSaleOfferBar) declare global { interface HTMLElementTagNameMap { 'uip-flash-sale-offer-bar': UipFlashSaleOfferBar } }
Raw manifest:https://uipkge.dev/r/lit/flash-sale-offer-bar.json