UNPKG

@salla.sa/twilight-components

Version:
659 lines (658 loc) 27.7 kB
/*! * Crafted with ❤ by Salla */ import { Host, h } from "@stencil/core"; export class SallaCountDown { constructor() { /** * The size of the count down * */ this.size = 'md'; /** * The color of the count down * */ this.color = 'dark'; /** * The digits lang to show in the count down * */ this.digits = 'auto'; /** * If true, hides days segment when days = 0, and hides hours segment when both days and hours = 0. * Matches salla-timer behaviour. Off by default for backward compatibility. * */ this.autoSegments = false; this.daysLabel = salla.lang.getWithDefault('pages.checkout.day', 'يوم'); this.hoursLabel = salla.lang.getWithDefault('pages.checkout.hour', 'ساعة'); this.minutesLabel = salla.lang.getWithDefault('pages.checkout.minute', 'دقيقة'); this.secondsLabel = salla.lang.getWithDefault('pages.checkout.second', 'ثانية'); this.endLabel = salla.lang.getWithDefault('pages.checkout.offer_ended', 'انتهت مدة العرض'); this.invalidDate = salla.lang.getWithDefault('blocks.buy_as_gift.incorrect_date', 'الرجاء إدخال الموعد بشكل صحيح'); this.offerEnded = false; this.parseFailed = false; this.showDays = true; this.showHours = true; this.days = this.number(0); this.hours = this.number(0); this.minutes = this.number(0); this.seconds = this.number(0); salla.lang.onLoaded(() => { this.daysLabel = salla.lang.getWithDefault('pages.checkout.day', 'يوم'); this.hoursLabel = salla.lang.getWithDefault('pages.checkout.hour', 'ساعة'); this.minutesLabel = salla.lang.getWithDefault('pages.checkout.minute', 'دقيقة'); this.invalidDate = salla.lang.getWithDefault('blocks.buy_as_gift.incorrect_date', 'الرجاء إدخال الموعد بشكل صحيح'); this.secondsLabel = salla.lang.getWithDefault('pages.checkout.second', 'ثانية'); this.endLabel = salla.lang.getWithDefault('pages.checkout.offer_ended', 'انتهت مدة العرض'); }); if (this.date && this.isValidDate(this.date)) { this.startCountDown(); } } /** * End the count down * */ async endCountDown() { clearInterval(this.countInterval); this.offerEnded = true; this.days = this.number(0); this.hours = this.number(0); this.minutes = this.number(0); this.seconds = this.number(0); } componentWillLoad() { if (typeof this.preOrder === 'string') { try { this.normalizedPreOrder = JSON.parse(this.preOrder); } catch { this.normalizedPreOrder = undefined; } } else { this.normalizedPreOrder = this.preOrder; } if (this.normalizedPreOrder?.end_date) { this.date = this.normalizedPreOrder.end_date; } if (this.date && this.isValidDate(this.date)) { this.startCountDown(); } } /** * Normalize US-style date formats to ISO "YYYY-MM-DD[ HH:mm:ss]". */ normalizeDate(date) { // "MM-DD-YYYY,HH:mm:ss am/pm" const dateTimeMatch = date.match(/^(\d{1,2})-(\d{1,2})-(\d{4}),(\d{1,2}):(\d{2}):(\d{2})\s*(am|pm)$/i); if (dateTimeMatch) { const [, month, day, year, rawHour, min, sec, period] = dateTimeMatch; let hour = parseInt(rawHour, 10); if (period.toLowerCase() === 'pm' && hour < 12) hour += 12; if (period.toLowerCase() === 'am' && hour === 12) hour = 0; const hh = String(hour).padStart(2, '0'); const mm = String(month).padStart(2, '0'); const dd = String(day).padStart(2, '0'); return `${year}-${mm}-${dd} ${hh}:${min}:${sec}`; } // "MM-DD-YYYY" (date only, 4-digit year at the end distinguishes it from ISO YYYY-MM-DD) const dateOnlyMatch = date.match(/^(\d{1,2})-(\d{1,2})-(\d{4})$/); if (dateOnlyMatch) { const [, month, day, year] = dateOnlyMatch; const mm = String(month).padStart(2, '0'); const dd = String(day).padStart(2, '0'); return `${year}-${mm}-${dd}`; } // Zero-pad "YYYY-M-D" shapes since V8's ISO parser rejects unpadded month/day. const isoLooseMatch = date.match(/^(\d{4})-(\d{1,2})-(\d{1,2})(?:[ T](\d{1,2}:\d{2}:\d{2}))?$/); if (isoLooseMatch) { const [, year, month, day, time] = isoLooseMatch; const mm = String(month).padStart(2, '0'); const dd = String(day).padStart(2, '0'); return time ? `${year}-${mm}-${dd} ${time}` : `${year}-${mm}-${dd}`; } return date; } isValidDate(date) { const normalized = this.normalizeDate(date); let dateHasDashes = normalized.includes('-'), dateParts = normalized.split(' '), testedDate; if (dateHasDashes) { testedDate = dateParts[0].replace(/-/g, '/'); } else { testedDate = dateParts[0]; } return !isNaN(Date.parse(testedDate)); } number(digit) { return salla.helpers.number(digit, this.digits === 'en'); } startCountDown() { const normalized = this.normalizeDate(this.date); const isIsoFormat = normalized.includes('-'); const isDateOnly = this.endOfDay || normalized.split(' ').length === 1; let countDownTime; if (isIsoFormat) { // Backend KSA date — parse as UTC+3 explicitly const dateStr = isDateOnly ? `${normalized.split(' ')[0]}T23:59:59+03:00` : `${normalized.replace(' ', 'T')}+03:00`; countDownTime = new Date(dateStr).getTime(); } else { // "MMM DD, YYYY HH:mm:ss" or "MM/DD/YYYY HH:mm:ss am/pm" format const countDownDate = new Date(normalized); if (isDateOnly) { countDownDate.setHours(23, 59, 59, 999); } countDownTime = countDownDate.getTime(); } // Safety net: if parsing fails despite passing isValidDate, show the // invalid date message instead of ticking with NaN values. if (Number.isNaN(countDownTime)) { salla.logger.warn(`[salla-count-down] unable to parse date: "${this.date}"`); this.parseFailed = true; return; } const tick = () => { const distance = countDownTime - Date.now(); const dRaw = Math.floor(distance / (1000 * 60 * 60 * 24)); const hRaw = Math.floor((distance % (1000 * 60 * 60 * 24)) / (1000 * 60 * 60)); this.days = this.number(dRaw); this.hours = this.number(hRaw); this.minutes = this.number(Math.floor((distance % (1000 * 60 * 60)) / (1000 * 60))); this.seconds = this.number(Math.floor((distance % (1000 * 60)) / 1000)); if (this.autoSegments) { this.showDays = dRaw > 0; this.showHours = dRaw > 0 || hRaw > 0; } if (distance < 0) { this.endCountDown(); } }; this.countInterval = setInterval(tick, 1000); tick(); } renderCountDown() { const listStyle = this.listPadding ? { paddingLeft: this.listPadding, paddingRight: this.listPadding } : undefined; const resolvedBoxColor = this.boxTheme ? `var(--color-${this.boxTheme})` : this.boxColor; const boxStyle = this.boxed && resolvedBoxColor ? { backgroundColor: resolvedBoxColor } : undefined; return (h("ul", { class: `s-count-down-list ${this.boxed ? 's-count-down-boxed' : ''} ${this.offerEnded ? 's-count-down-ended' : ''} s-count-down-${this.size} s-count-down-${this.color}`, style: listStyle }, h("li", { class: "s-count-down-item", style: boxStyle }, h("div", { class: "s-count-down-item-value" }, this.seconds), this.labeled && h("div", { class: "s-count-down-item-label" }, this.secondsLabel)), h("li", { class: "s-count-down-item", style: boxStyle }, h("div", { class: "s-count-down-item-value" }, this.minutes), this.labeled && h("div", { class: "s-count-down-item-label" }, this.minutesLabel)), (!this.autoSegments || this.showHours) && (h("li", { class: "s-count-down-item", style: boxStyle }, h("div", { class: "s-count-down-item-value" }, this.hours), this.labeled && h("div", { class: "s-count-down-item-label" }, this.hoursLabel))), (!this.autoSegments || this.showDays) && (h("li", { class: "s-count-down-item", style: boxStyle }, h("div", { class: "s-count-down-item-value" }, this.days), this.labeled && h("div", { class: "s-count-down-item-label" }, this.daysLabel))))); } renderInvalidDate() { return h("div", { class: "s-count-down-text-center" }, this.invalidDate); } renderOfferEnded() { return h("div", { class: "s-count-down-end-text" }, !!this.endText ? this.endText : this.endLabel); } renderPreOrderToBeAvailableOn() { if (!this.normalizedPreOrder?.availability_date || !this.isValidDate(this.normalizedPreOrder?.availability_date)) return null; return (h("div", { class: "s-count-down-info-message" }, h("i", { class: "sicon-info" }), h("span", null, salla.lang.getWithDefault('pages.products.expected_to_be', 'متوقَّع توفُّره بتاريخ:')), h("span", null, new Date(this.normalizedPreOrder?.availability_date).toLocaleDateString(salla.lang.locale, { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric', })))); } renderPreOrderCountDown() { if (!this.normalizedPreOrder?.activate_countdown) return null; return (h("div", { class: "s-count-down-pre-order-container" }, h("p", { class: "text-sm " }, salla.lang.getWithDefault('pages.products.pre_order_ends_in', 'ينتهي الطلب المسبق خلال:')), this.renderCountDown())); } renderPreOrder() { return (h("div", null, typeof this.normalizedPreOrder === 'object' && this.normalizedPreOrder?.availability_date && this.renderPreOrderToBeAvailableOn(), this.renderPreOrderCountDown())); } renderPrefixText() { if (!this.prefixText) return null; return h("span", { class: "s-count-down-prefix-text" }, this.prefixText); } renderButton() { if (!this.withButton || !this.buttonText) return null; return h("salla-button", { color: "primary", size: "medium", href: this.buttonHref }, this.buttonIcon ? h("i", { class: this.buttonIcon }) : null, h("span", null, this.buttonText)); } renderContent() { if (!this.date) return null; if (!this.isValidDate(this.date) || this.parseFailed) { return this.renderInvalidDate(); } if (this.preOrder) { return this.renderPreOrder(); } return this.renderCountDown(); } render() { return (h(Host, { key: 'b9b5d0e038afdf2dd88da15eb711752d2a9a45af', class: `s-count-down-wrapper ${this.preOrder && this.isValidDate(this.date) ? 's-count-down-pre-order' : ''} ${this.horizontal ? 's-count-down-horizontal' : ''} ${this.withButton ? 's-count-down-with-button' : ''} ${this.offerEnded ? 's-count-down-ended' : ''}` }, this.renderPrefixText(), this.renderContent(), this.offerEnded && this.renderOfferEnded(), this.renderButton())); } static get is() { return "salla-count-down"; } static get originalStyleUrls() { return { "$": ["salla-count-down.scss"] }; } static get styleUrls() { return { "$": ["salla-count-down.css"] }; } static get properties() { return { "date": { "type": "string", "attribute": "date", "mutable": false, "complexType": { "original": "string", "resolved": "string", "references": {} }, "required": false, "optional": false, "docs": { "tags": [], "text": "The date to count down to.\n\nSupported formats:\n\n Parsed as KSA time (UTC+3):\n - \"YYYY-MM-DD HH:mm:ss\" (e.g. \"2023-05-22 16:37:52\")\n - \"YYYY-M-D HH:mm:ss\" (e.g. \"2023-5-22 16:37:52\") unpadded month/day accepted\n - \"YYYY-MM-DD\" (e.g. \"2023-05-22\") counts down to 23:59:59 of that day\n - \"YYYY-M-D\" (e.g. \"2026-5-15\", \"2026-05-5\") unpadded month/day accepted\n - \"MM-DD-YYYY\" (e.g. \"04-28-2026\") counts down to 23:59:59 of that day\n - \"MM-DD-YYYY,HH:mm:ss am/pm\" (e.g. \"12-23-2026,03:23:00 pm\")\n\n Parsed as browser local time:\n - \"YYYY/MM/DD HH:mm:ss\" (e.g. \"2023/05/22 16:37:52\")\n - \"YYYY/MM/DD\" (e.g. \"2023/05/22\") counts down to 23:59:59 of that day\n - \"MM/DD/YYYY HH:mm:ss\" (e.g. \"05/22/2023 16:37:52\")\n - \"MM/DD/YYYY\" (e.g. \"05/22/2023\") counts down to 23:59:59 of that day" }, "getter": false, "setter": false, "reflect": false }, "preOrder": { "type": "string", "attribute": "pre-order", "mutable": false, "complexType": { "original": "PreOrder | string", "resolved": "PreOrder | string", "references": { "PreOrder": { "location": "global", "id": "global::PreOrder" } } }, "required": false, "optional": true, "docs": { "tags": [], "text": "The pre-order date\nFormat: { availability_date: string, end_date: string }\navailability_date: The date to count down to Format: MMM DD, YYYY HH:mm:ss (e.g. Jan 2, 2023 16:37:52)\nend_date: The date to count down to Format: MMM DD, YYYY HH:mm:ss (e.g. Jan 2, 2023 16:37:52)" }, "getter": false, "setter": false, "reflect": false }, "horizontal": { "type": "boolean", "attribute": "horizontal", "mutable": false, "complexType": { "original": "boolean", "resolved": "boolean", "references": {} }, "required": false, "optional": true, "docs": { "tags": [], "text": "If true, applies compact horizontal layout" }, "getter": false, "setter": false, "reflect": false }, "withButton": { "type": "boolean", "attribute": "with-button", "mutable": false, "complexType": { "original": "boolean", "resolved": "boolean", "references": {} }, "required": false, "optional": true, "docs": { "tags": [], "text": "If true, renders a slot for an action button alongside the countdown" }, "getter": false, "setter": false, "reflect": false }, "prefixText": { "type": "string", "attribute": "prefix-text", "mutable": false, "complexType": { "original": "string", "resolved": "string", "references": {} }, "required": false, "optional": true, "docs": { "tags": [], "text": "The prefix text to show before the countdown" }, "getter": false, "setter": false, "reflect": false }, "buttonHref": { "type": "string", "attribute": "button-href", "mutable": false, "complexType": { "original": "string", "resolved": "string", "references": {} }, "required": false, "optional": true, "docs": { "tags": [], "text": "The button href to show with the countdown" }, "getter": false, "setter": false, "reflect": false }, "buttonText": { "type": "string", "attribute": "button-text", "mutable": false, "complexType": { "original": "string", "resolved": "string", "references": {} }, "required": false, "optional": true, "docs": { "tags": [], "text": "The button text to show with the countdown" }, "getter": false, "setter": false, "reflect": false }, "buttonIcon": { "type": "string", "attribute": "button-icon", "mutable": false, "complexType": { "original": "string", "resolved": "string", "references": {} }, "required": false, "optional": true, "docs": { "tags": [], "text": "The button icon to show with the countdown" }, "getter": false, "setter": false, "reflect": false }, "boxed": { "type": "boolean", "attribute": "boxed", "mutable": false, "complexType": { "original": "boolean", "resolved": "boolean", "references": {} }, "required": false, "optional": false, "docs": { "tags": [], "text": "If true, the count down numbers will be appear in a boxes" }, "getter": false, "setter": false, "reflect": false }, "size": { "type": "string", "attribute": "size", "mutable": false, "complexType": { "original": "'sm' | 'md' | 'lg'", "resolved": "\"lg\" | \"md\" | \"sm\"", "references": {} }, "required": false, "optional": false, "docs": { "tags": [], "text": "The size of the count down" }, "getter": false, "setter": false, "reflect": false, "defaultValue": "'md'" }, "color": { "type": "string", "attribute": "color", "mutable": false, "complexType": { "original": "'primary' | 'light' | 'dark'", "resolved": "\"dark\" | \"light\" | \"primary\"", "references": {} }, "required": false, "optional": false, "docs": { "tags": [], "text": "The color of the count down" }, "getter": false, "setter": false, "reflect": false, "defaultValue": "'dark'" }, "labeled": { "type": "boolean", "attribute": "labeled", "mutable": false, "complexType": { "original": "boolean", "resolved": "boolean", "references": {} }, "required": false, "optional": false, "docs": { "tags": [], "text": "Show labels for each count down number" }, "getter": false, "setter": false, "reflect": false }, "endText": { "type": "string", "attribute": "end-text", "mutable": false, "complexType": { "original": "string", "resolved": "string", "references": {} }, "required": false, "optional": false, "docs": { "tags": [], "text": "The text to show when the count down ends" }, "getter": false, "setter": false, "reflect": false }, "boxColor": { "type": "string", "attribute": "box-color", "mutable": false, "complexType": { "original": "string", "resolved": "string", "references": {} }, "required": false, "optional": false, "docs": { "tags": [], "text": "Background color of each box when boxed is enabled.\nAccepts any valid CSS color value (e.g. '#1a3c34', 'var(--color-primary)')" }, "getter": false, "setter": false, "reflect": false }, "boxTheme": { "type": "string", "attribute": "box-theme", "mutable": false, "complexType": { "original": "string", "resolved": "string", "references": {} }, "required": false, "optional": false, "docs": { "tags": [], "text": "Theme color name to use as the box background when boxed is enabled.\nMaps to the corresponding CSS variable (e.g. 'primary' \u2192 var(--color-primary)).\nAvailable values: 'primary' | 'primary-dark' | 'primary-light' | 'primary-reverse' | 'main'\nTakes precedence over box-color when both are set." }, "getter": false, "setter": false, "reflect": false }, "listPadding": { "type": "string", "attribute": "list-padding", "mutable": false, "complexType": { "original": "string", "resolved": "string", "references": {} }, "required": false, "optional": false, "docs": { "tags": [], "text": "Horizontal padding applied to the list (e.g. '20px', '1rem', '5%').\nDefaults to no padding." }, "getter": false, "setter": false, "reflect": false }, "digits": { "type": "string", "attribute": "digits", "mutable": false, "complexType": { "original": "'en' | 'auto'", "resolved": "\"auto\" | \"en\"", "references": {} }, "required": false, "optional": false, "docs": { "tags": [], "text": "The digits lang to show in the count down" }, "getter": false, "setter": false, "reflect": false, "defaultValue": "'auto'" }, "endOfDay": { "type": "boolean", "attribute": "end-of-day", "mutable": false, "complexType": { "original": "boolean", "resolved": "boolean", "references": {} }, "required": false, "optional": false, "docs": { "tags": [], "text": "If true, the count down will end at the end of the day" }, "getter": false, "setter": false, "reflect": false }, "autoSegments": { "type": "boolean", "attribute": "auto-segments", "mutable": false, "complexType": { "original": "boolean", "resolved": "boolean", "references": {} }, "required": false, "optional": false, "docs": { "tags": [], "text": "If true, hides days segment when days = 0, and hides hours segment when both days and hours = 0.\nMatches salla-timer behaviour. Off by default for backward compatibility." }, "getter": false, "setter": false, "reflect": false, "defaultValue": "false" } }; } static get states() { return { "daysLabel": {}, "hoursLabel": {}, "minutesLabel": {}, "secondsLabel": {}, "endLabel": {}, "invalidDate": {}, "offerEnded": {}, "parseFailed": {}, "countInterval": {}, "days": {}, "hours": {}, "minutes": {}, "seconds": {}, "showDays": {}, "showHours": {} }; } static get methods() { return { "endCountDown": { "complexType": { "signature": "() => Promise<void>", "parameters": [], "references": { "Promise": { "location": "global", "id": "global::Promise" } }, "return": "Promise<void>" }, "docs": { "text": "End the count down", "tags": [] } } }; } }