@wallfar/ocd-studio-core-sdk
Version:
Helper SDK for our OneClick Studio modules
463 lines (462 loc) • 18 kB
JavaScript
import { ref, computed, watch, unref, toRefs } from 'vue';
import { useAsyncData, useHead } from 'nuxt/app';
import { fetchCollectionsFirebase, fetchProductFirebase, fetchProductsFirebase, fetchShippingOptionsFirebase } from './providers/firebase.js';
import { createProductConfigurator } from './productConfigurator.js';
import { validateAddress } from '../shared/webshop/validateAddress.js';
class Webshop {
constructor(config) {
this.cart = ref([]);
this.taxRate = ref(0);
this._shippingOptions = ref(null);
this._shippingOption = ref(null);
this._activePromoCode = ref(null);
this._collections = ref(null);
this._deliveryAddress = ref({
firstName: '',
lastName: '',
company: '',
streetLine1: '',
streetLine2: '',
streetLine3: '',
city: '',
stateOrProvince: '',
countyOrDistrict: '',
postalCode: '',
country: '',
phoneNumber: '',
email: '',
instructions: '',
latitude: 0,
longitude: 0
});
this.config = config;
if (!this.config.defaultBrand)
this.config.defaultBrand = '';
if (!this.config.defaultCurrency)
this.config.defaultCurrency = 'USD';
if (!this.config.defaultLocale)
this.config.defaultLocale = 'en-US';
this.cart.value = localStorage.getItem('webshop_cart') ? JSON.parse(localStorage.getItem('webshop_cart') || '[]') : [];
this._activePromoCode.value = localStorage.getItem('webshop_promoCode') ? JSON.parse(localStorage.getItem('webshop_promoCode') || 'null') : null;
this._shippingOption.value = localStorage.getItem('webshop_shippingOption') ? JSON.parse(localStorage.getItem('webshop_shippingOption') || 'null') : null;
const saved = localStorage.getItem('webshop_deliveryAddress');
if (saved) {
try {
const data = JSON.parse(saved);
Object.assign(this._deliveryAddress.value, data);
}
catch (e) {
console.error('Could not parse saved address:', e);
}
}
watch(this.cart, () => {
localStorage.setItem('webshop_cart', JSON.stringify(this.cart.value));
}, { deep: true });
watch(this._activePromoCode, () => {
localStorage.setItem('webshop_promoCode', JSON.stringify(this._activePromoCode.value));
}, { deep: true });
watch(this._deliveryAddress, () => {
localStorage.setItem('webshop_deliveryAddress', JSON.stringify(this._deliveryAddress.value));
}, { deep: true });
watch(this._shippingOption, () => {
localStorage.setItem('webshop_shippingOption', JSON.stringify(this._shippingOption.value));
}, { deep: true });
this.refreshCollections();
this.refreshShippingOptions();
}
async refreshCollections() {
if (this.config.provider === 'firebase') {
this._collections.value = await fetchCollectionsFirebase(this.config.firebase);
return this._collections.value;
}
throw new Error(`Provider ${this.config.provider} not supported.`);
}
async refreshShippingOptions() {
if (this.config.provider === 'firebase') {
this._shippingOptions.value = await fetchShippingOptionsFirebase(this.config.firebase);
return this._shippingOptions.value;
}
throw new Error(`Provider ${this.config.provider} not supported.`);
}
async fetchProduct(slug) {
if (this.config.provider === 'firebase') {
return fetchProductFirebase(this.config.firebase, slug);
}
throw new Error(`Provider ${this.config.provider} not supported.`);
}
/**
* Sends a notification to a user.
*
* @param product: Product created in the Products module from whitelabel core modules package.
*/
setProductSeo(product) {
if (!product) {
throw new Error('Product is required');
}
useHead({
title: product.title,
meta: [
{ name: 'description', content: product.description },
{ property: 'og:title', content: product.title },
{ property: 'og:description', content: product.description },
{ property: 'og:image', content: product.media[0] },
{ property: 'og:url', content: `https://yourdomain.com/products/${product.slug}` },
{ property: 'og:type', content: 'product' },
{ name: 'twitter:card', content: 'summary_large_image' },
{ name: 'twitter:title', content: product.title },
{ name: 'twitter:description', content: product.description },
{ name: 'twitter:image', content: product.media[0] }
],
script: [
{
type: 'application/ld+json',
innerHTML: JSON.stringify({
'@context': 'https://schema.org/',
'@type': 'Product',
name: product.title,
image: product.media[0],
description: product.description,
sku: product.sku,
mpn: product.mpn,
gtin13: product.gtin,
brand: {
'@type': 'Brand',
name: product.brand || this.config.defaultBrand
},
offers: {
'@type': 'Offer',
url: `https://yourdomain.com/products/${product.slug}`,
priceCurrency: product.currency || 'EUR',
price: product.price,
availability: product.stock > 0 ? 'https://schema.org/InStock' : 'https://schema.org/OutOfStock',
itemCondition: 'https://schema.org/NewCondition'
}
})
}
]
});
}
createConfigurator(product) {
return createProductConfigurator(product);
}
async addToCart(product, variant, quantity = 1) {
const requiredKeys = ['id', 'title', 'slug', 'price', 'currency', 'media'];
const missing = requiredKeys.filter(key => !product[key]);
if (missing.length) {
throw new Error(`Product is missing required properties: ${missing.join(', ')}`);
}
const index = this.cart.value.findIndex(i => i.productId === product.id && deepEqual(i.variant, variant));
// Get thumbnail, price & stock from the variant
const configurator = this.createConfigurator(product);
const selectedVariant = configurator.getVariant(variant || {});
console.log("index", index);
if (index !== -1) {
this.cart.value[index].quantity += quantity;
}
else {
let newProd = JSON.parse(JSON.stringify({
productId: product.id,
title: product.title,
slug: product.slug,
thumbnail: selectedVariant?.image || product.media[0],
price: selectedVariant?.price || product.price,
currency: product.currency,
stock: selectedVariant?.stock || product.stock,
quantity,
variant: JSON.parse(JSON.stringify(variant))
}));
this.cart.value.push(newProd);
}
}
removeFromCart(productId, variant) {
this.cart.value = this.cart.value.filter(i => !(i.productId === productId &&
JSON.stringify(i.variant) === JSON.stringify(variant)));
}
decreaseItem(productId, variant) {
const idx = this.cart.value.findIndex(i => i.productId === productId &&
JSON.stringify(i.variant) === JSON.stringify(variant));
if (idx !== -1) {
if (this.cart.value[idx].quantity > 1) {
this.cart.value[idx].quantity -= 1;
}
else {
this.cart.value.splice(idx, 1);
}
}
}
updateQuantity(productId, variant, newQty) {
const idx = this.cart.value.findIndex(i => i.productId === productId &&
JSON.stringify(i.variant) === JSON.stringify(variant));
if (idx !== -1) {
if (newQty <= 0) {
this.cart.value.splice(idx, 1);
}
else {
this.cart.value[idx].quantity = newQty;
}
}
}
async validatePromoCode(code) {
try {
if (this.config.promoCodeValidationRequest) {
return await this.config.promoCodeValidationRequest(code);
}
}
catch (err) {
return {
valid: false,
error: "Code is invalid"
};
}
}
updateDeliveryAddress(updates) {
Object.assign(this._deliveryAddress.value, updates);
}
// 💡 Accessors
get cartItems() {
return this.cart.value;
}
get activePromoCode() {
return this._activePromoCode.value;
}
get collections() {
return this._collections;
}
get itemCount() {
return this.cart.value.reduce((sum, item) => sum + item.quantity, 0);
}
get subtotal() {
return this.cart.value.reduce((sum, item) => sum + item.price * item.quantity, 0);
}
get deliveryAddress() {
return toRefs(this._deliveryAddress.value);
}
get shippingOptions() {
if (!this._shippingOptions?.value || this._shippingOptions.value.length === 0)
return [];
return this._shippingOptions.value.reduce((options, option) => {
if (!option)
return options;
if (option.status !== 'published')
return options;
const validCountry = !option.countries || option.countries.length === 0 || option.countries.includes(this.country);
if (!validCountry)
return options;
let validCondition = option.condition === 'always';
if (!validCondition) {
if (option.condition === 'price_based') {
const MIN = option.condition_min || 0;
const MAX = option.condition_max || Infinity;
validCondition = this.subtotal >= MIN && this.subtotal <= MAX;
}
else if (option.condition === 'weight_based') {
// TODO
}
}
if (validCountry && validCondition) {
options.push(option);
}
return options;
}, []);
}
get shippingOption() {
return this._shippingOption.value;
}
set shippingOption(option) {
this._shippingOption.value = option;
}
get shippingFee() {
if (this._activePromoCode.value?.type === 'free_shipping') {
return 0;
}
if (this._shippingOption.value) {
return this._shippingOptions.value?.find(opt => opt.id === this._shippingOption.value)?.price || 0;
}
return 0;
}
get discount() {
if (!this._activePromoCode.value)
return 0;
if (this._activePromoCode.value.type === 'percentage') {
return (this.subtotal * ((this._activePromoCode.value.value || 0) / 100)) || 0;
}
if (this._activePromoCode.value.type === 'fixed') {
return this._activePromoCode.value.value || 0;
}
return 0;
}
get tax() {
return this.taxRate.value * (this.subtotal - this.discount);
}
get total() {
const total = this.subtotal + this.tax + this.shippingFee - this.discount;
return total > 0 ? total : 0;
}
get country() {
return this._deliveryAddress.value.country;
}
set country(val) {
this._deliveryAddress.value.country = val;
}
// 💳 Controls
async applyPromoCode(code) {
code = code.trim().toUpperCase();
try {
if (this.config.provider === 'firebase') {
const result = await this.validatePromoCode(code);
if (!result || !result.valid || !result.promo)
throw new Error('Code is invalid');
this._activePromoCode.value = result.promo;
return result;
}
else {
throw new Error(`Provider ${this.config.provider} not supported.`);
}
}
catch (error) {
throw error;
}
}
async createPaymentLink() {
try {
if (!this.cart.value || this.cart.value.length === 0)
throw new Error('No products in cart');
if (!this._shippingOption.value)
throw new Error('No shipping option selected');
if (validateAddress(this._deliveryAddress.value).length > 0)
throw new Error('Invalid address');
if (this.config.createPaymentLinkRequest) {
let checkoutData = {
cart: this.cart.value?.map(item => ({
productId: item.productId,
variant: item.variant,
quantity: item.quantity
})),
shippingOption: this._shippingOption.value,
activePromoCode: this._activePromoCode.value ? this._activePromoCode.value.code : null,
deliveryAddress: this._deliveryAddress.value
};
return await this.config.createPaymentLinkRequest(checkoutData);
}
}
catch (err) {
const inner = err.data?.message ?? (typeof err.data === 'string' ? err.data : null) ?? err.message;
throw new Error(inner);
}
}
removePromoCode() {
this._activePromoCode.value = null;
}
clearCart() {
this.cart.value = [];
this._activePromoCode.value = null;
}
// helpers
formatPrice(price, currency, locale) {
try {
return new Intl.NumberFormat(locale || this.config.defaultLocale, {
style: 'currency',
currency: currency || this.config.defaultCurrency
}).format(price);
}
catch {
// fallback if currency code is unknown or formatting fails
return `${currency} ${price.toFixed(2)}`;
}
}
// useProduct
useProduct(slug) {
const slugKey = computed(() => unref(slug));
const key = computed(() => `product-${slugKey.value}`);
const selection = ref({});
const getSelectedVariant = ref(() => undefined);
const { data, pending, error, refresh } = useAsyncData(key, async () => {
const s = typeof slugKey.value === 'string' ? slugKey.value : slugKey.value();
const prod = await fetchProductFirebase(this.config.firebase, s);
if (!prod) {
throw new Error(`Product "${s}" not found`);
}
const cfg = createProductConfigurator(prod);
selection.value = cfg.selection;
getSelectedVariant.value = cfg.getSelectedVariant;
return prod;
});
const product = computed(() => data.value);
const selectedVariant = computed(() => getSelectedVariant.value());
return {
product,
pending,
error,
refresh,
selection,
selectedVariant
};
}
// useCollection
useCollection(slug) {
const slugKey = computed(() => unref(slug));
const sort = ref('created-descending');
const _products = ref([]);
const lastFetchedItemId = ref('');
const collection = computed(() => {
const s = slugKey.value ? (typeof slugKey.value === 'string' ? slugKey.value : slugKey.value()) : undefined;
return this._collections.value?.find(c => c.slug === s);
});
const products = computed(() => _products.value);
const pending = ref(false);
const error = ref(null);
const refresh = () => this.refreshCollections();
watch(slugKey, () => {
reset();
loadProducts();
});
watch(sort, () => {
reset();
loadProducts();
});
const reset = () => {
_products.value = [];
lastFetchedItemId.value = '';
pending.value = false;
error.value = null;
};
const loadProducts = async () => {
pending.value = true;
error.value = null;
try {
const prods = await fetchProductsFirebase(this.config.firebase, collection.value?.id, sort.value, lastFetchedItemId.value, this.config.itemsPerPage);
_products.value = prods || [];
}
catch (err) {
error.value = err;
}
finally {
pending.value = false;
}
};
loadProducts();
return {
collection,
products,
pending,
error,
refresh,
sort,
};
}
}
export default Webshop;
function deepEqual(a, b) {
function isObject(o) {
return o !== null && typeof o === 'object';
}
if (!isObject(a) || !isObject(b)) {
return a === b;
}
const aKeys = Object.keys(a);
const bKeys = Object.keys(b);
if (aKeys.length !== bKeys.length)
return false;
return aKeys.every(key => b.hasOwnProperty(key) &&
deepEqual(a[key], b[key]));
}