Menu

Change language

GitHub repo

Announcement — Countdown Bar

blockmarketing

Top strip counting down to a deadline in days, hours, minutes and seconds, with the offer line, an inline CTA, and a dismiss control that keeps it closed for the session.

Installation

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

Variants

Loading interactive previews…

Schema

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

CountdownPart
interface CountdownPart {
  label: string
  value: number
}

npm dependencies

Files installed (2)

  • src/app/components/blocks/announcement-countdown-bar/announcement-countdown-bar.component.ts5.2 kB
    // Top strip counting down to `deadline` in days, hours, minutes and seconds, with
    // the offer line, an inline CTA, and a dismiss control that keeps it closed for the
    // session. Port of the Vue/React block 1:1. The digits only appear once the timer is
    // running (the server has no clock the client agrees with); tabular-nums plus
    // fixed-width cells keep the bar from reflowing each second.
    import {
      ChangeDetectionStrategy,
      Component,
      DestroyRef,
      Input,
      OnInit,
      afterNextRender,
      computed,
      inject,
      signal,
    } from '@angular/core'
    import { ArrowRight, LucideAngularModule, Timer, X } from 'lucide-angular'
    import { UiBadgeComponent } from '@/ui/badge/badge.component'
    import { UiButtonComponent } from '@/ui/button/button.component'
    import { UiSeparatorComponent } from '@/ui/separator/separator.component'
    
    export interface CountdownPart {
      label: string
      value: number
    }
    
    @Component({
      changeDetection: ChangeDetectionStrategy.Eager,
      selector: 'ui-announcement-countdown-bar, [ui-announcement-countdown-bar]',
      standalone: true,
      // The block renders its own root <div>: keep this wrapper out of the layout.
      host: { '[attr.class]': '"contents"' },
      imports: [LucideAngularModule, UiBadgeComponent, UiButtonComponent, UiSeparatorComponent],
      template: `
        @if (!dismissed()) {
          <div data-slot="announcement-countdown-bar" class="border-border bg-muted/60 border-b backdrop-blur">
            <div class="mx-auto flex min-h-11 max-w-6xl flex-wrap items-center gap-x-4 gap-y-2 px-6 py-2 text-sm">
              <span ui-badge variant="secondary" class="shrink-0 gap-1.5">
                <lucide-icon [img]="Timer" class="size-3" aria-hidden="true" />
                Ends soon
              </span>
    
              <p class="min-w-0">
                <span class="font-medium">Annual plans are 20% off</span>
                <span class="text-muted-foreground"> for teams that start before the quarter closes.</span>
              </p>
    
              <!-- tabular-nums + fixed-width cells keep the bar from reflowing each second. -->
              @if (remaining() !== null) {
                <div class="text-muted-foreground flex items-center gap-1 font-mono text-xs">
                  @for (part of parts(); track part.label) {
                    <span class="tabular-nums">
                      <span class="text-foreground inline-block min-w-[2ch] text-right font-semibold">{{
                        pad2(part.value)
                      }}</span
                      >{{ part.label }}
                    </span>
                  }
                </div>
              }
    
              <div class="ml-auto flex shrink-0 items-center gap-1">
                <button ui-button variant="ghost" size="sm" class="h-7">
                  Claim it
                  <lucide-icon [img]="ArrowRight" [class]="'ml-1 size-3.5'" aria-hidden="true" />
                </button>
                <ui-separator orientation="vertical" class="hidden h-5 sm:block" />
                <button
                  ui-button
                  variant="ghost"
                  size="icon"
                  class="size-7"
                  aria-label="Dismiss announcement"
                  (click)="dismiss()"
                >
                  <lucide-icon [img]="X" class="size-3.5" aria-hidden="true" />
                </button>
              </div>
            </div>
          </div>
        }
      `,
    })
    export class UiAnnouncementCountdownBarComponent implements OnInit {
      /** ISO timestamp the countdown runs to. */
      @Input() deadline = ''
      /** sessionStorage key holding the dismissal. */
      @Input() storageKey = 'uipkge:countdown-dismissed'
    
      // Null until the browser timer starts: the server has no clock the client agrees with.
      readonly remaining = signal<number | null>(null)
      readonly dismissed = signal(false)
      readonly targetMs = signal(0)
    
      protected readonly Timer = Timer
      protected readonly X = X
      protected readonly ArrowRight = ArrowRight
    
      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(() => {
          try {
            this.dismissed.set(window.sessionStorage.getItem(this.storageKey) === '1')
          } catch {
            this.dismissed.set(false)
          }
          this.tick()
          this.timer = setInterval(() => this.tick(), 1000)
        })
      }
    
      ngOnInit(): void {
        const parsed = this.deadline ? Date.parse(this.deadline) : Number.NaN
        // Fall back to 72 hours out so the demo and a mis-set prop still read sensibly.
        this.targetMs.set(Number.isNaN(parsed) ? Date.now() + 72 * 60 * 60 * 1000 : parsed)
      }
    
      readonly parts = computed<CountdownPart[]>(() => {
        const total = Math.floor(Math.max(this.remaining() ?? 0, 0) / 1000)
        return [
          { label: 'd', value: Math.floor(total / 86400) },
          { label: 'h', value: Math.floor((total % 86400) / 3600) },
          { label: 'm', value: Math.floor((total % 3600) / 60) },
          { label: 's', value: total % 60 },
        ]
      })
    
      tick(): void {
        this.remaining.set(this.targetMs() - Date.now())
      }
    
      pad2(n: number): string {
        return String(n).padStart(2, '0')
      }
    
      dismiss(): void {
        this.dismissed.set(true)
        try {
          window.sessionStorage.setItem(this.storageKey, '1')
        } catch {
          // Blocked storage: the dismissal still holds for this page view.
        }
      }
    }
    
  • src/app/components/blocks/announcement-countdown-bar/index.ts0.1 kB

Raw manifest:https://uipkge.dev/r/angular/announcement-countdown-bar.json