/** * Free low-stock "Only N left" text under the variation form. * * Reads the single-source b3wvs_low_stock count that StockState adds to the * WooCommerce variation payload and renders a plain, theme-styled line when a * variation is at/under its low-stock threshold. The Pro feature is a distinctly * styled badge (color/shape/pulse); this free version is plain text only and * imports nothing from src/frontend/extension/. * * @package B3VariationSwatches */ const VARIATION_FORM_SELECTOR = "form.variations_form"; const TEXT_CLASS = "b3-wvs-low-stock-text"; export interface LowStockSettings { enabled: boolean; format: string; hideOnBackorder: boolean; } export interface WooVariationStock { b3wvs_low_stock?: number | false; /** WooCommerce's native flag from get_available_variation(). */ backorders_allowed?: boolean | number | string; /** Legacy/custom payload flag; still honored when present. */ is_on_backorder?: boolean | number | string; } /** * Normalize a PHP/JSON boolean-ish flag. Only genuine "true" shapes count, so * `false`, `"false"`, `"0"`, `0`, `""`, `null` and `undefined` are all false. */ const isTruthyFlag = (value: unknown): boolean => { if (value === true || value === 1) return true; if (typeof value === "string") { const normalized = value.trim().toLowerCase(); return normalized === "1" || normalized === "true" || normalized === "yes"; } return false; }; /** Whether the variation is available on backorder (native or legacy flag). */ export const isVariationOnBackorder = (variation: WooVariationStock): boolean => isTruthyFlag(variation.backorders_allowed) || isTruthyFlag(variation.is_on_backorder); /** Read the low-stock settings from the localized payload (with safe defaults). */ export const readLowStockSettings = (): LowStockSettings => { const settings = typeof b3WvsData !== "undefined" && b3WvsData?.settings ? b3WvsData.settings : null; const rawFormat = settings?.low_stock_text_format; const format = typeof rawFormat === "string" && rawFormat.includes("{qty}") ? rawFormat : "Only {qty} left"; return { enabled: Boolean(settings?.low_stock_text_enabled), format, hideOnBackorder: settings?.low_stock_text_hide_on_backorder !== false, }; }; /** Resolve the low-stock count to display, or null when nothing should show. */ export const resolveLowStockCount = ( variation: WooVariationStock | undefined, settings: LowStockSettings, ): number | null => { if (!settings.enabled || !variation) return null; const count = variation.b3wvs_low_stock; if (typeof count !== "number" || !Number.isFinite(count) || count <= 0) { return null; } if (settings.hideOnBackorder && isVariationOnBackorder(variation)) { return null; } return Math.floor(count); }; /** Build the display string. Plain text only (rendered via textContent). */ export const formatLowStockText = (format: string, qty: number): string => (format && format.includes("{qty}") ? format : "Only {qty} left").replace( /\{qty\}/g, String(qty), ); const getTextElement = ( form: HTMLElement, ): { element: HTMLElement; created: boolean } => { const existing = form.querySelector(`.${TEXT_CLASS}`); if (existing) return { element: existing, created: false }; // WooCommerce replaces `.single_variation` while resolving selections. Mount // the live region in its stable wrapper so announcements survive that update. const host = form.querySelector(".single_variation_wrap") || form.querySelector(".woocommerce-variation") || form; const element = form.ownerDocument.createElement("p"); element.className = TEXT_CLASS; element.setAttribute("role", "status"); element.setAttribute("aria-live", "polite"); host.appendChild(element); return { element, created: true }; }; export const updateLowStockTextForForm = ( form: HTMLElement, variation?: WooVariationStock, ): void => { const settings = readLowStockSettings(); const qty = resolveLowStockCount(variation, settings); if (qty == null) { const existing = form.querySelector(`.${TEXT_CLASS}`); if (existing) existing.textContent = ""; return; } const { element, created } = getTextElement(form); const text = formatLowStockText(settings.format, qty); if (!created) { element.textContent = text; return; } // Give assistive technology a chance to observe the empty live region before // its first message is inserted. window.setTimeout(() => { if (element.isConnected) element.textContent = text; }, 0); }; let listenersBound = false; /** Bind the WooCommerce variation listeners (idempotent). */ export const initLowStockText = (): void => { if (listenersBound) return; const jq = typeof window !== "undefined" ? (window as { jQuery?: unknown }).jQuery : undefined; if (typeof jq !== "function") return; listenersBound = true; document .querySelectorAll(VARIATION_FORM_SELECTOR) .forEach((form) => getTextElement(form)); const $ = jq as (el: unknown) => { on: ( events: string, selector: string, handler: (this: HTMLElement, ...args: unknown[]) => void, ) => void; }; $(document.body).on( "found_variation", VARIATION_FORM_SELECTOR, function (this: HTMLElement, _event: unknown, variation: unknown) { updateLowStockTextForForm( this, variation as WooVariationStock | undefined, ); }, ); $(document.body).on( "reset_data hide_variation", VARIATION_FORM_SELECTOR, function (this: HTMLElement) { const existing = this.querySelector(`.${TEXT_CLASS}`); if (existing) existing.textContent = ""; }, ); };