@salla.sa/twilight-components
Version:
Salla Web Component
178 lines (177 loc) • 8.64 kB
JavaScript
/*!
* Crafted with ❤ by Salla
*/
import { Host, h } from "@stencil/core";
// Read-only recommendations key confirmed safe for client-side use by the API team — no write access or sensitive scope.
const XSELL_API_KEY = 'VOL5WaZu2YROp0RplCZAr1RplhL9FFGQ';
export class SallaBoughtTogether {
constructor() {
/**
* Maximum number of recommendations to fetch. Max is 4.
*/
this.limit = 3;
this.recommendations = [];
this.selectedIds = new Set();
this.isLoading = true;
this.canRender = true;
this.isAdding = false;
this.title = salla.lang.get('pages.products.bought_together_title');
this.subtitle = salla.lang.get('pages.products.bought_together_subtitle');
this.buyTogetherFor = salla.lang.get('pages.products.buy_together_for');
this.resolvedProductId = 0;
salla.lang.onLoaded(() => {
this.title = salla.lang.get('pages.products.bought_together_title');
this.subtitle = salla.lang.get('pages.products.bought_together_subtitle');
this.buyTogetherFor = salla.lang.get('pages.products.buy_together_for');
});
}
componentWillLoad() {
salla.onReady()
.then(() => {
if (!salla.config.get('store.settings.product.bought_together')) {
this.canRender = false;
return;
}
const id = salla.config.get('page.id');
if (!id) {
this.canRender = false;
return;
}
this.resolvedProductId = id;
return salla.api.request('https://api.salla.dev/1/indexes/*/recommendations', {
requests: [{
indexName: 'products',
model: 'xsell-v1',
priceThreshold: 2,
objectID: String(id),
maxRecommendations: this.limit,
}]
}, 'post', { headers: { 'X-Algolia-Api-Key': XSELL_API_KEY, 'X-Query-Enrichment': 'false', 'X-source': 'store' } });
})
.then((response) => {
if (!response)
return;
const hits = response?.results?.[0]?.hits ?? [];
if (!hits.length) {
this.canRender = false;
return;
}
const ids = [String(this.resolvedProductId), ...hits.map(h => h.objectID)];
return salla.product.fetch({ source: 'selected', source_value: ids });
})
.then((response) => {
if (!response)
return;
const products = response?.data ?? [];
if (!products.length) {
this.canRender = false;
return;
}
const mainProduct = products.find(p => String(p.id) === String(this.resolvedProductId));
const rest = products.filter(p => String(p.id) !== String(this.resolvedProductId));
if (!rest.length) {
this.canRender = false;
return;
}
this.recommendations = mainProduct ? [mainProduct, ...rest] : rest;
this.selectedIds = new Set(this.recommendations.map((p) => p.id));
})
.catch(() => {
this.canRender = false;
})
.finally(() => {
this.isLoading = false;
});
}
toggleProduct(id) {
const next = new Set(this.selectedIds);
next.has(id) ? next.delete(id) : next.add(id);
this.selectedIds = next;
}
get totalPrice() {
const total = this.recommendations
.filter(p => this.selectedIds.has(p.id))
// p.price is a plain float in the API response (e.g. 1150.58) — the TS type (any[]) is misleading.
.reduce((sum, p) => sum + (p.price ?? 0), 0);
const decimals = salla.config.get('store.currency.decimals') ?? 2;
const factor = Math.pow(10, decimals);
return salla.money(Math.round(total * factor) / factor);
}
async addSelectedToCart() {
if (this.isAdding || !this.selectedIds.size)
return;
this.isAdding = true;
try {
// Intentional: quickAdd is used for all items including the main product without options — business decision by product team.
const results = await Promise.allSettled(Array.from(this.selectedIds).map(id => salla.cart.quickAdd(id)));
if (results.some(r => r.status === 'rejected')) {
salla.error(salla.lang.get('common.messages.error'));
}
}
finally {
this.isAdding = false;
}
}
getSkeletonView() {
return (h(Host, { class: "s-bought-together-entry" }, h("div", { class: "s-bought-together-skeleton" }, h("div", { class: "s-bought-together-skeleton-header" }, h("salla-skeleton", { height: "16px", width: "35%" }), h("salla-skeleton", { height: "10px", width: "60%" })), Array(4).fill(null).map((_, i) => (h("div", { key: i, class: "s-bought-together-skeleton-item" }, h("salla-skeleton", { height: "22px", width: "22px" }), h("salla-skeleton", { height: "48px", width: "48px" }), h("div", { class: "s-bought-together-skeleton-info" }, h("salla-skeleton", { height: "12px", width: "55%", style: { marginBottom: '3px' } }), h("salla-skeleton", { height: "10px", width: "25%" }))))), h("salla-skeleton", { height: "44px", width: "100%" }))));
}
render() {
if (!this.canRender)
return null;
if (this.isLoading)
return this.getSkeletonView();
if (!this.recommendations.length)
return null;
return (h(Host, { class: "s-bought-together-entry" }, h("div", { class: "s-bought-together-header" }, h("h3", { class: "s-bought-together-title" }, this.title), h("p", { class: "s-bought-together-subtitle" }, this.subtitle)), h("div", { class: "s-bought-together-list" }, this.recommendations.map((product) => (h("div", { key: product.id, class: `s-bought-together-item${!this.selectedIds.has(product.id) ? ' s-bought-together-item--unchecked' : ''}` }, h("label", { class: "s-bought-together-checkbox-label" }, h("input", { type: "checkbox", class: "s-bought-together-checkbox", checked: this.selectedIds.has(product.id), onChange: () => this.toggleProduct(product.id) }), h("span", { class: "s-bought-together-checkmark" })), h("a", { class: "s-bought-together-item-link", href: product.url }, h("img", { class: "s-bought-together-item-img", src: product.image?.url || salla.url.cdn('images/s-empty.png'), alt: product.image?.alt || product.name, loading: "lazy", decoding: "async", onError: e => {
e.currentTarget.onerror = null;
e.currentTarget.src = salla.url.cdn('images/s-empty.png');
} }), h("span", { class: "s-bought-together-item-name" }, product.name)), h("span", { class: "s-bought-together-item-price", innerHTML: product.price != null ? salla.money(product.price) : '' }))))), h("salla-button", { class: "s-bought-together-btn", disabled: !this.selectedIds.size || this.isAdding, loading: this.isAdding, onClick: () => this.addSelectedToCart() }, h("span", { innerHTML: `${this.buyTogetherFor} ${this.totalPrice}` }))));
}
static get is() { return "salla-bought-together"; }
static get originalStyleUrls() {
return {
"$": ["salla-bought-together.scss"]
};
}
static get styleUrls() {
return {
"$": ["salla-bought-together.css"]
};
}
static get properties() {
return {
"limit": {
"type": "number",
"attribute": "limit",
"mutable": false,
"complexType": {
"original": "number",
"resolved": "number",
"references": {}
},
"required": false,
"optional": false,
"docs": {
"tags": [],
"text": "Maximum number of recommendations to fetch. Max is 4."
},
"getter": false,
"setter": false,
"reflect": false,
"defaultValue": "3"
}
};
}
static get states() {
return {
"recommendations": {},
"selectedIds": {},
"isLoading": {},
"canRender": {},
"isAdding": {},
"title": {},
"subtitle": {},
"buyTogetherFor": {}
};
}
}