@salla.sa/twilight-components
Version:
Salla Web Component
659 lines (658 loc) • 27.7 kB
JavaScript
/*!
* 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": []
}
}
};
}
}