import { ATTR_COUNTDOWN_CONFIG, SELECTOR_ANNOUNCEMENT } from "./constants"; export const countdownUnits = ["days", "hours", "minutes", "seconds"] as const; export type CountdownUnit = (typeof countdownUnits)[number]; export type DayLabels = { many: string; few: string; one: string; }; export interface CountdownLabels { days?: string | DayLabels; hours?: string; minutes?: string; seconds?: string; } export interface CountdownConfig { targetDate: string; units?: CountdownUnit[]; labels?: CountdownLabels; } export interface CountdownState { days: number; hours: number; minutes: number; seconds: number; } const unitMs: Record = { days: 24 * 60 * 60 * 1000, hours: 60 * 60 * 1000, minutes: 60 * 1000, seconds: 1000, }; export const getVisibleUnits = (units?: CountdownUnit[]): CountdownUnit[] => { if (!units?.length) { return [...countdownUnits]; } return countdownUnits.filter((unit) => units.includes(unit)); }; export const normalizeDayLabels = ( value: string | DayLabels | undefined, fallback: DayLabels, ): DayLabels => { if (typeof value === "string" && value.trim()) { return { many: value, few: value, one: value, }; } if ( value && typeof value === "object" && typeof value.many === "string" && typeof value.few === "string" && typeof value.one === "string" ) { return value; } return fallback; }; export const getTargetDate = (value: string): number => { const timestamp = new Date(value).getTime(); return Number.isNaN(timestamp) ? 0 : timestamp; }; export const calculateState = ( targetDate: string, units: CountdownUnit[], ): CountdownState => { const target = getTargetDate(targetDate); let remaining = Math.max(0, target - Date.now()); const values: CountdownState = { days: 0, hours: 0, minutes: 0, seconds: 0, }; units.forEach((unit) => { const currentValue = Math.floor(remaining / unitMs[unit]); values[unit] = currentValue; remaining -= currentValue * unitMs[unit]; }); return values; }; export abstract class BaseCountdownStatic { element: HTMLElement; config: CountdownConfig; liveRegion: HTMLElement | null; timerId?: number; observer?: IntersectionObserver; private instanceKey: string; private defaultConfig: CountdownConfig; private configOverrides: Partial; constructor( element: HTMLElement, config: Partial | undefined, options: { defaultConfig: CountdownConfig; instanceKey: string; }, ) { this.element = element; this.defaultConfig = options.defaultConfig; this.instanceKey = options.instanceKey; this.configOverrides = { ...(config ?? {}) }; this.config = { ...options.defaultConfig, ...(config ?? {}) }; this.liveRegion = null; this.tick = this.tick.bind(this); this.start = this.start.bind(this); this.stop = this.stop.bind(this); (this.element as any)[this.instanceKey] = this; } protected readDataConfig(): Partial { const attr = this.element.getAttribute(ATTR_COUNTDOWN_CONFIG); if (!attr) { return {}; } try { return JSON.parse(attr); } catch (error) { console.warn("Invalid JSON in data-countdown-config attribute", error); return {}; } } protected getLiveRegion(): HTMLElement | null { return this.element.querySelector(SELECTOR_ANNOUNCEMENT); } protected scheduleNextTick(): void { this.stop(); const nextDelay = 1000 - (Date.now() % 1000) || 1000; this.timerId = window.setTimeout(this.tick, nextDelay); } private tick(): void { this.render(); this.scheduleNextTick(); } private setupObserver(): void { if (typeof IntersectionObserver === "undefined") { this.start(); return; } this.observer = new IntersectionObserver((entries) => { entries.forEach((entry) => { if (entry.isIntersecting) { this.start(); } else { this.stop(); } }); }); this.observer.observe(this.element); } init(): void { const elementConfig = this.readDataConfig(); this.config = { ...this.defaultConfig, ...elementConfig, ...this.configOverrides, }; this.liveRegion = this.getLiveRegion(); this.collectElements(); this.render(); this.setupObserver(); } start(): void { this.render(); this.scheduleNextTick(); } stop(): void { if (this.timerId) { window.clearTimeout(this.timerId); this.timerId = undefined; } } update(config?: Partial): void { this.stop(); this.observer?.disconnect(); this.destroyElements(); this.configOverrides = { ...this.configOverrides, ...(config ?? {}), }; this.init(); } destroy(): void { this.stop(); this.observer?.disconnect(); this.destroyElements(); (this.element as any)[this.instanceKey] = null; } protected abstract collectElements(): void; protected abstract render(): void; protected abstract destroyElements(): void; }