UNPKG

@wallfar/ocd-studio-core-sdk

Version:

Helper SDK for our OneClick Studio modules

463 lines (462 loc) 18 kB
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])); }