@salla.sa/twilight-components
Version:
Salla Web Component
306 lines (305 loc) • 14.8 kB
JavaScript
/*!
* Crafted with ❤ by Salla
*/
import { Host, h } from "@stencil/core";
import { DiscountType } from "./interfaces";
import { TIERED_OFFER_CONFIG } from "./constants";
import { TierUtils } from "./utils";
export class SallaTieredOffer {
constructor() {
this.currentTierIndex = 0;
this.canRender = false;
this.isLoading = false;
this.handleTabClick = (tierName) => {
this.activeTabTier = tierName;
};
salla.lang.onLoaded(() => {
this.loyaltyProgramText = salla.lang.get('blocks.tiered_offer.program_title');
this.currentTierText = salla.lang.get('blocks.tiered_offer.current_tier');
this.discountText = salla.lang.get('blocks.tiered_offer.discount');
this.benefitText = salla.lang.get('blocks.tiered_offer.tier_description');
});
}
async componentWillLoad() {
try {
await salla.onReady();
if (!this.checkFeatureEnabled()) {
return;
}
if (!this.checkCartExists()) {
return;
}
this.isLoading = true;
const offerData = await this.fetchTieredOfferData();
if (!offerData) {
return;
}
this.initializeComponent(offerData);
}
catch (error) {
this.canRender = false;
salla.logger.warn('salla-tiered-offer:: ', error);
}
finally {
this.isLoading = false;
}
}
checkFeatureEnabled() {
return salla.config.get('store.features')?.includes('tiered-offer');
}
checkCartExists() {
return !!salla.storage.get("cart")?.summary?.count;
}
validateCouponCompatibility(offerData, cartCoupon) {
// If coupon is applied but offer doesn't support coupons, widget should be hidden
return !(cartCoupon && !offerData.details?.applied_with_coupon);
}
shouldHideForGuest() {
return salla.config.isGuest();
}
shouldHideUntilThreshold(details, tierIndex) {
return !!(details?.hide_until_threshold && tierIndex === 0);
}
resolveTierName(tiers, index) {
return TierUtils.getTierNameByIndex(tiers, index) || '';
}
async fetchTieredOfferData() {
try {
const { data } = await salla.api.cart.offers();
const tieredOffer = data.find(offer => offer.type === "tiered_offer");
if (!tieredOffer) {
return null;
}
// Hide widget if coupon is applied and offer doesn't support coupons
if (!this.validateCouponCompatibility(tieredOffer, salla.storage.get("cart")?.coupon)) {
this.canRender = false;
return null;
}
return tieredOffer;
}
catch (error) {
throw error;
}
}
initializeComponent(apiOfferData) {
this.apiOfferData = apiOfferData;
// Map tiered details data to component format
this.tieredOfferData = this.mapApiDataToTiers(this.apiOfferData);
this.loyaltyProgramText = this.apiOfferData.title || this.loyaltyProgramText;
// Find current tier index based on current_value from API
const currentValue = this.apiOfferData.details?.current_value || 0;
const currentTierIndex = this.findCurrentTierIndex(currentValue);
// Hide entire widget for unauthenticated users
if (this.shouldHideForGuest()) {
this.canRender = false;
return;
}
// Only hide until threshold for merchants with the flag enabled (e.g. Arabian Oud)
if (this.shouldHideUntilThreshold(this.apiOfferData.details, currentTierIndex)) {
this.canRender = false;
return;
}
this.currentTierIndex = currentTierIndex;
this.currentTier = this.resolveTierName(this.tieredOfferData.tiers, currentTierIndex);
// Default to first tab so tabs always have visible content
this.activeTabTier = this.currentTier || this.tieredOfferData.tiers[0]?.tier_name;
this.canRender = true;
}
componentDidLoad() {
salla.event.on("cart::updated", async (updatedCart) => {
const offerData = await this.fetchTieredOfferData();
if (offerData) {
this.updateOfferValues(updatedCart, offerData);
}
});
}
mapApiDataToTiers(apiData) {
// Sort discounts by min_spend to create proper tier order
const sortedDiscounts = [...apiData.details.discounts].sort((a, b) => a.min_spend - b.min_spend);
const tiers = sortedDiscounts.map((discount, index) => ({
name: discount.name,
tier_name: discount.name,
discount: discount.type === DiscountType.PERCENTAGE ? `${salla.helpers.number(discount.value)}%` : salla.money(discount.value),
icon: TIERED_OFFER_CONFIG.staticIcons[index] || TIERED_OFFER_CONFIG.staticIcons[0],
threshold: discount.min_spend,
text_color: TIERED_OFFER_CONFIG.staticColors[index],
index: index + 1
}));
return {
current_tier: TierUtils.getTierNameByIndex(tiers, this.findCurrentTierIndex(apiData.details.current_value, tiers)),
tiers
};
}
findCurrentTierIndex(currentValue, tiers = this.tieredOfferData?.tiers) {
return TierUtils.findCurrentTierIndex(currentValue, tiers || []);
}
updateOfferValues(updatedCart, freshOfferData) {
if (!freshOfferData || !this.tieredOfferData) {
return;
}
if (!freshOfferData.details?.discounts?.length) {
this.canRender = false;
return;
}
// Check coupon compatibility
if (!this.validateCouponCompatibility(freshOfferData, updatedCart.coupon)) {
this.canRender = false;
return;
}
// Hide entire widget for unauthenticated users
if (this.shouldHideForGuest()) {
this.canRender = false;
return;
}
// Build fresh tier map so index computation uses up-to-date thresholds
const freshTieredData = this.mapApiDataToTiers(freshOfferData);
const currentValue = freshOfferData.details?.current_value || 0;
const newTierIndex = this.findCurrentTierIndex(currentValue, freshTieredData.tiers);
// Only hide until threshold for merchants with the flag enabled (e.g. Arabian Oud)
if (this.shouldHideUntilThreshold(freshOfferData.details, newTierIndex)) {
this.canRender = false;
return;
}
// Offer progress is based on historical purchases only — skip state update on cart changes
// Use strict === false to avoid treating an omitted flag as false
if (freshOfferData.details?.with_current_cart === false) {
this.canRender = true;
return;
}
// All guards passed — update state atomically
this.apiOfferData = freshOfferData;
this.tieredOfferData = freshTieredData;
this.canRender = true;
this.currentTierIndex = newTierIndex;
// Tiers are 1-based; fall back to first tier when no tier has been reached yet
this.currentTier = this.resolveTierName(freshTieredData.tiers, newTierIndex);
// Update active tab if needed; default to first tab when no tier reached
const resolvedTab = this.currentTier || freshTieredData.tiers[0]?.tier_name;
if (this.activeTabTier !== resolvedTab) {
this.activeTabTier = resolvedTab;
}
}
getLoadingSkeletonView() {
return h(Host, null, h("div", { class: "s-tiered-offer-container" }, h("div", { class: "s-tiered-offer-skeleton-wrapper" }, h("div", { class: "s-tiered-offer-skeleton-title" }, h("salla-skeleton", { height: "16px", width: "40%" })), h("div", { class: "s-tiered-offer-skeleton-subtitle" }, h("salla-skeleton", { height: "20px", width: "60%" })), h("div", { class: "s-tiered-offer-skeleton-progress" }, h("salla-skeleton", { height: "16px", width: "100%" })), h("div", { class: "s-tiered-offer-skeleton-tabs" }, h("salla-skeleton", { height: "50px", width: "100%" })))));
}
getCurrentTierData() {
return TierUtils.findTierByIndex(this.tieredOfferData.tiers, this.currentTierIndex) || this.tieredOfferData.tiers[0];
}
getProgressWidth() {
const currentValue = this.apiOfferData.details?.current_value || 0;
const totalTiers = this.tieredOfferData.tiers.length;
// Use existing method to find current tier index (which already handles the logic)
const currentTierIndex = this.findCurrentTierIndex(currentValue);
// Convert to 0-based index for progress calculation
const completedTiers = currentTierIndex;
return (completedTiers / totalTiers) * 100;
}
calculateMilestonePosition(index, totalTiers) {
if (totalTiers === 1) {
return 'calc(100% - 18px)';
}
const milestonePercent = ((index + 1) / totalTiers) * 100;
const offsetPercent = 8 / totalTiers;
return `${Math.max(2, Math.min(95, milestonePercent - offsetPercent))}%`;
}
getMilestoneStyles(tier, index, totalTiers) {
const isRtl = document.documentElement.dir === 'rtl';
const adjustedPercent = this.calculateMilestonePosition(index, totalTiers);
const alignItems = tier.name.length > 6 ? 'flex-end' : 'center';
return {
[isRtl ? 'right' : 'left']: adjustedPercent,
alignItems,
position: 'absolute',
transform: isRtl ? 'translateX(50%)' : 'translateX(-50%)'
};
}
getMilestoneIconStyles(tier, isCurrent, isReached) {
if (isCurrent) {
return { color: tier.text_color };
}
if (isReached) {
return { color: 'var(--color-primary)' };
}
return {};
}
renderProgressBarBackground(progressWidth) {
return [
h("div", { class: "s-tiered-offer-progress-bg" }),
h("div", { class: "s-tiered-offer-progress-fill", style: { width: `${progressWidth}%` } })
];
}
renderMilestoneIcon(tier, isCurrent, isReached) {
const iconClasses = [
's-tiered-offer-milestone-icon',
isCurrent ? 's-tiered-offer-milestone-active' : '',
isReached ? 's-tiered-offer-milestone-completed' : ''
].filter(Boolean).join(' ');
return (h("div", { class: iconClasses, style: this.getMilestoneIconStyles(tier, isCurrent, isReached) }, h("span", { innerHTML: tier.icon })));
}
renderMilestoneName(tier, isCurrent) {
return (h("div", { class: "s-tiered-offer-milestone-name" }, isCurrent && (h("span", { style: { color: tier.text_color } }, tier.name))));
}
renderMilestones(currentValue, totalTiers) {
const containerClass = totalTiers === 1
? 's-tiered-offer-single-milestone-justify-end'
: 's-tiered-offer-milestones';
return (h("div", { class: "s-tiered-offer-milestones-wrapper" }, h("div", { class: containerClass }, this.tieredOfferData.tiers.map((tier, index) => {
const isReached = currentValue >= tier.threshold;
const isCurrent = tier.tier_name === this.currentTier;
return (h("div", { class: "s-tiered-offer-milestone", style: this.getMilestoneStyles(tier, index, totalTiers) }, this.renderMilestoneIcon(tier, isCurrent, isReached), this.renderMilestoneName(tier, isCurrent)));
}))));
}
renderProgressBar() {
const progressWidth = this.getProgressWidth();
const totalTiers = this.tieredOfferData.tiers.length;
const currentValue = this.apiOfferData.details?.current_value || 0;
return (h("div", { class: "s-tiered-offer-progress-container" }, h("div", { class: "s-tiered-offer-progress-wrapper" }, this.renderProgressBarBackground(progressWidth), this.renderMilestones(currentValue, totalTiers))));
}
renderTabs() {
return (h("div", { class: "s-tiered-offer-tabs-container" }, h("div", { class: "s-tiered-offer-tab-headers" }, this.tieredOfferData.tiers.map(tier => {
const isActive = tier.tier_name === this.activeTabTier;
return (h("button", { class: `s-tiered-offer-tab-header ${isActive ? 's-tiered-offer-tab-active' : ''}`, onClick: () => this.handleTabClick(tier.tier_name) }, h("span", null, tier.name)));
})), h("div", { class: "s-tiered-offer-tab-content-wrapper" }, (() => {
const activeTier = TierUtils.findTierByName(this.tieredOfferData.tiers, this.activeTabTier);
if (!activeTier)
return null;
return (h("div", { class: "s-tiered-offer-tab-content" }, h("div", { class: "s-tiered-offer-tab-content-inner" }, h("div", { class: "s-tiered-offer-discount-section" }, h("p", { class: "s-tiered-offer-discount-title" }, this.discountText, " ", h("span", { innerHTML: activeTier.discount })), h("p", { class: "s-tiered-offer-benefit-description" }, this.benefitText, " ", activeTier.name)))));
})())));
}
render() {
if (this.isLoading) {
return this.getLoadingSkeletonView();
}
if (!this.canRender || !this.tieredOfferData) {
return null;
}
const currentTierData = this.getCurrentTierData();
return (h(Host, null, h("div", { class: "s-tiered-offer-container" }, h("div", { class: "s-tiered-offer-header" }, h("div", { class: "s-tiered-offer-header-content" }, h("p", { class: "s-tiered-offer-program-title" }, this.loyaltyProgramText), this.currentTier && (h("div", { class: "s-tiered-offer-current-tier-name-wrapper" }, h("h2", { class: "s-tiered-offer-current-tier" }, this.currentTierText, " ", h("span", { class: "s-tiered-offer-tier-name" }, currentTierData.name)), h("span", { innerHTML: currentTierData.icon }))))), this.renderProgressBar(), this.renderTabs())));
}
static get is() { return "salla-tiered-offer"; }
static get originalStyleUrls() {
return {
"$": ["salla-tiered-offer.scss"]
};
}
static get styleUrls() {
return {
"$": ["salla-tiered-offer.css"]
};
}
static get states() {
return {
"tieredOfferData": {},
"apiOfferData": {},
"currentTierIndex": {},
"currentTier": {},
"activeTabTier": {},
"canRender": {},
"isLoading": {},
"loyaltyProgramText": {},
"currentTierText": {},
"discountText": {},
"benefitText": {}
};
}
}