Skip to main content

Bigcommerce Real-Time-Pricing configuration

Operation process

BigCommerce Setting(Optional)

If you need to use it in cart or checkout, you should change this behavior to allow promotions on overridden prices in Store Settings under Promotions and Coupons in the control panel.

Set default sales location ID in Shophive

Add script for Store

For the config:

  • secretKey: The secret key to connect the connection. (Contact our team to get it)

  • apiUrl: Shophive project address.

For price list:

  • productIdAttribute: Locate the element corresponding element to the bigcommerce product id and get the latest prices.

  • priceSelector: When retrieving the latest prices, the elements matching this selector will be loaded and updated with the latest prices once the API request is successful.

For cart:

  • cartIdAttribute: Locate the element corresponding to the BigCommerce cart ID and retrieve the latest prices for the products in this cart.

For checkout:

  • needRefreshPage: If it's true, the page will refresh after getting the latest price.

  • location_name: To avoid multiple page refreshes, store the name in localstorage.

<script src="https://cdn.socket.io/4.5.4/socket.io.min.js"></script>
<script>
const config = {
secretKey: 'xxx',
url: 'https://a1.shophive.ai',
// product list setting
productList: {
isActive: true,
productIdAttribute: 'data-entity-id',
priceSelector: '[data-product-price-without-tax]',
productPreviewSelector: '.productPreview',
},
// cart setting
cart: {
isActive: true,
cartIdAttribute: 'a1-cart-id',
productIdAttribute: 'a1-cart-product-id'
},
// cart preivew setting
cartPreview: {
isActive: true,
productIdAttribute: 'a1-preview-cart-product-id',
priceSelector: '.previewCartItem-price span',
quantitySelector: '.previewCartItem-price'
},
// checkout preivew setting
checkoutPreview: {
isActive: true,
cartIdAttribute: 'a1-preview-checkout-id',
totalPriceSelector: '.previewCartCheckout-price',
productIdAttribute: 'a1-preview-checkout-product-id',
priceSelector: '.productView-price'
},
need_websocket: true,
defaultPriceNotification: 'Failed to update prices. Please try again later.',
// when get error, tip after price
priceTip: ' (Not real time price from ERP)',
// use for store refresh time
sessionName: 'a1-cart-timestamp',
// use for store original price
priceAttribute: 'a1-original-price',
localCacheKey: 'shophive-product-price-cache',
localCacheTime: 86400, // unit: second
}

class RealTimePricing {
constructor() {
this.customerID = null
this.initData()
this.init()
}

initData(){
this.products = []
this.previewProducts = []
this.cartQuantities = []
this.cartID = null
this.productDetailQuantity = 0
this.chekcoutPreviewCartID = null
this.priceCache = new Map()
}

async init() {
setInterval(() => {
this.getCustomerID()
if (this.customerID) {
switch(true){
case(config.productList.isActive):
this.checkProductList()
case(config.cartPreview.isActive):
this.checkCartPreviewProductList()
case(config.cart.isActive):
this.checkCart()
case(config.checkoutPreview.isActive):
this.checkcheckoutPreview()
}
}
}, 10)
}

// PDP & PLP
getProducts() {
const parseProduct = (element) => {
const ecommerce_product_id = element.getAttribute(config.productList.productIdAttribute)
const priceElement = element.querySelector(config.productList.priceSelector)
const quantityElement = document.getElementById('qty[]')
const quantity = quantityElement ? Number(quantityElement.value || 1) : 1
this.setOriginPrice(priceElement)
return {
ecommerce_product_id,
priceElement,
quantity,
}
}

const productViewModal = document.querySelector(config.productList.productPreviewSelector)
if (productViewModal) {
return [parseProduct(productViewModal)]
}
return Array.from(document.querySelectorAll(`[${config.productList.productIdAttribute}]`)).map(parseProduct)
}

checkProductList() {
const newProducts = this.getProducts()
if (newProducts.length) {
const hasChanged = this.isProductListChanged(this.products, newProducts)
if (hasChanged) {
this.products = newProducts
this.updatePrices(this.products)
}
}
}

isProductListChanged(preProducts, newProducts) {
const normalizedPreProducts = this.normalizeProducts(preProducts);
const normalizedNewProducts = this.normalizeProducts(newProducts);

if (normalizedPreProducts.length !== normalizedNewProducts.length) {
return true;
}

return normalizedNewProducts.some((newProduct) => {
const existingProduct = normalizedPreProducts.find(
(product) => product.ecommerce_product_id === newProduct.ecommerce_product_id
);
return !existingProduct || existingProduct.quantity !== newProduct.quantity;
});
}

normalizeProducts(products) {
const productMap = new Map();
products.forEach(product => {
if (productMap.has(product.ecommerce_product_id)) {
productMap.get(product.ecommerce_product_id).quantity += product.quantity;
} else {

productMap.set(product.ecommerce_product_id, { ...product });
}
});
return Array.from(productMap.values());
}

loadCacheMap() {
if (this.priceCache && this.priceCache.size > 0) {
return this.priceCache
}

try {
const raw = localStorage.getItem(config.localCacheKey)
if (!raw) {
this.priceCache = new Map()
return this.priceCache
}

const parsed = JSON.parse(raw)
const now = Date.now()
const filtered = parsed.filter(([_, value]) => {
return !value.timestamp || now - value.timestamp < config.localCacheTime * 1000
})

this.priceCache = new Map(filtered)
return this.priceCache
} catch (e) {
this.priceCache = new Map()
return this.priceCache
}
}

saveCacheMap(map) {
this.priceCache = map
try {
const arr = Array.from(map.entries())
localStorage.setItem(config.localCacheKey, JSON.stringify(arr))
} catch (e) {}
}

getCachedPrice(productId, quantity) {
const map = this.loadCacheMap()
const key = `${productId}_${quantity}`
const entry = map.get(key)
return entry?.data ?? null
}

setCachedPrice(productId, quantity, data) {
const map = this.loadCacheMap()
const key = `${productId}_${quantity}`
map.set(key, { data, timestamp: Date.now() })
this.saveCacheMap(map)
}

async handlePriceDOM(params) {
const {
newProducts,
preProducts
} = params

preProducts.forEach((preProduct) => {
const existProduct = newProducts.find((newProduct) => newProduct.ecommerce_product_id === preProduct.ecommerce_product_id && newProduct.is_success)
if (existProduct) {
preProduct.priceElement.textContent = `$${parseFloat(existProduct.net_price).toFixed(2)}`
} else {
preProduct.priceElement.textContent = preProduct.priceElement.getAttribute(config.priceAttribute) + config.priceTip
}
})
}

async updatePrices(products) {
const preProducts = []
const needGetPriceProducts = []
products.forEach((product) => {
const cachedProduct = this.getCachedPrice(product.ecommerce_product_id, product.quantity)
if(product.priceElement){
if (cachedProduct) {
product.priceElement.textContent = `$${parseFloat(cachedProduct.net_price).toFixed(2)}`
preProducts.push(product)
} else {
product.priceElement.textContent = 'Loading...'
needGetPriceProducts.push(product)
}
}
})

if (!needGetPriceProducts.length) return

try {
const data = await this.fetchRealTimePrice({
products: needGetPriceProducts.map((product) => ({
ecommerce_product_id: product.ecommerce_product_id,
quantity: product.quantity,
price: String(product.priceElement.getAttribute(config.priceAttribute)).split('$')?.[1]
})),
})

await this.handlePriceDOM({
newProducts: data.products || [],
preProducts: needGetPriceProducts
})

try {
(data.products || []).forEach(product => {
if(product.is_success){
this.setCachedPrice(product.ecommerce_product_id, product.quantity, product)
}
})
} catch(error){
console.log('Cache Error: ', error)
}
} catch (error) {
this.showNotification()
this.handlePriceDOM({
newProducts: [],
preProducts: needGetPriceProducts
})
}
}

// Cart
getCartId(cartAttribute) {
const cartId = document.querySelector(`[${cartAttribute}]`)?.getAttribute(cartAttribute) || "{{checkout.id}}" || null
return cartId
}

checkCart() {
const cartId = this.getCartId(config.cart.cartIdAttribute)
if (cartId) {
if (cartId !== this.cartID) {
const newQuantities = this.getCartQuantities()
this.cartQuantities = newQuantities
if (this.shouldUpdateCart()) {
this.cartID = cartId
this.getRealTimeCart()
} else {
this.cartID = cartId
if(cartId){
this.sendMessage({
ecommerce_customer_id: String(this.customerID),
cart_id: this.chekcoutPreviewCartID || this.cartID || '',
need_origin_data: false,
need_cart_info: true,
products: [],
})

if(localStorage.getItem('a1-failed-products')){
this.showNotification('The prices of some products are not the latest prices.')
localStorage.removeItem('a1-failed-products')
}
}
}
} else {
const newQuantities = this.getCartQuantities()

const hasChanged = this.cartQuantities.length !== newQuantities.length
|| newQuantities.some((newQuantity) => {
const existQuantity = this.cartQuantities.find((quantity) => quantity.id === newQuantity.id)
return !existQuantity || existQuantity.quantity !== newQuantity.quantity
})

const isAnyInputFocused = () => {
const inputs = document.querySelectorAll(`[${config.cart.productIdAttribute}] input`);
return Array.from(inputs).some(input => input === document.activeElement);
}

if (hasChanged && !isAnyInputFocused()) {
this.cartQuantities = newQuantities
this.getRealTimeCart()
}
}
}
}

getCartQuantities() {
return Array.from(document.querySelectorAll(`[${config.cart.productIdAttribute}] input`)).map((element) => ({
id: element.name,
quantity: element.value
}))
}

async getRealTimeCart() {
this.showPageLoading()
try {
const data = await this.fetchRealTimePrice({
products: this.products.map((product) => ({
ecommerce_product_id: product.ecommerce_product_id,
})),
})
const failedProducts = (data?.products || []).filter((item) => !item.is_success)
if(failedProducts.length){
localStorage.setItem('a1-failed-products', JSON.stringify(failedProducts));
}


if (data) {
this.refreshPage()
} else {
this.hidePageLoading()
}

} catch (error) {
this.hidePageLoading()
this.showNotification()
}
}

// Checkout Preiview
async checkcheckoutPreview() {
const cartId = document.querySelector(`[${config.checkoutPreview.cartIdAttribute}]`)?.getAttribute(config.checkoutPreview.cartIdAttribute)

if (cartId !== this.chekcoutPreviewCartID) {
this.chekcoutPreviewCartID = cartId

const totalPriceElement = document.querySelector(config.checkoutPreview.totalPriceSelector)
const itemElement = document.querySelector(`[${config.checkoutPreview.productIdAttribute}]`)

if (cartId && totalPriceElement) {
const priceElement = itemElement.querySelector(config.checkoutPreview.priceSelector)
const quantity = priceElement.textContent.split('×')[0] + '×'
const price = priceElement.textContent.split('×')[1]
try {
this.setOriginPrice(totalPriceElement)
this.setOriginPrice(priceElement, price)

totalPriceElement.textContent = 'Loading...'
priceElement.textContent = 'Loading...'

// BC has delay
await this.fetchRealTimePrice({
cart_id: cartId,
products: []
})
const data = await this.fetchRealTimePrice({
cart_id: cartId,
products: []
})

const newProduct = (data?.cart_info?.items || []).find((item) => String(item.ecommerce_product_id) === itemElement.getAttribute(config.checkoutPreview.productIdAttribute))
if (data) {
totalPriceElement.textContent = '$' + data.cart_info.cart_amount
}
if (newProduct) {
priceElement.textContent = quantity + '$' + newProduct.price
} else {
throw new Error("Can't get real time price")
}
} catch (error) {
totalPriceElement.textContent = totalPriceElement.getAttribute(config.priceAttribute) + config.priceTip
priceElement.textContent = quantity + priceElement.getAttribute(config.priceAttribute) + config.priceTip
this.showNotification()
}
}
} else {
this.chekcoutPreviewCartID = cartId
}
}

// Cart Priview
checkCartPreviewProductList() {
const newProducts = this.getCartPreviewProducts() || []
if (newProducts.length) {
const hasChanged = this.isProductListChanged(this.previewProducts, newProducts)
if (hasChanged) {
this.previewProducts = newProducts
this.updatePrices(this.previewProducts)
}
}
}

getCartPreviewProducts() {
const parseProduct = (element) => {
const ecommerce_product_id = element.getAttribute(config.cartPreview.productIdAttribute)
const priceElement = element.querySelector(config.cartPreview.priceSelector)
const quantityElement = element.querySelector(config.cartPreview.quantitySelector);
let quantity = 1
if (quantityElement) {
const quantityText = quantityElement.textContent.trim();
const quantityMatch = quantityText.match(/(\d+)\s×/);

if (quantityMatch && quantityMatch[1]) {
quantity = parseInt(quantityMatch[1], 10);
}
}

this.setOriginPrice(priceElement)

return {
ecommerce_product_id,
priceElement,
quantity,
}
}

return Array.from(document.querySelectorAll(`[${config.cartPreview.productIdAttribute}]`)).map(parseProduct)
}

// Global
getCustomerID() {
const customer = {{{json customer}}}
if(customer?.id){
this.customerID = customer.id
}
}

setOriginPrice(priceElement, price) {
if (priceElement && !priceElement.getAttribute(config.priceAttribute)) {
priceElement.setAttribute(config.priceAttribute, price || priceElement.textContent)
}
}

subscribeToUpdates() {
if (this.websocket && this.websocket.readyState === WebSocket.OPEN) {
this.websocket.send(JSON.stringify({ action: "real-time-pricing", data: {} }));
}
}

async sendMessage(body) {
if(!config.need_websocket){
return
}
if (!this.socket) {
this.connectWebSocket()
} else {
await this.sleep(10 * 1000)
}
const message = {
a1_secret_key: config.secretKey,
body,
};
this.socket.emit("real-time-pricing", message);
}

async sleep(milliseconds) {
return new Promise((resolve) => setTimeout(resolve, milliseconds));
};

connectWebSocket() {
if(this.socket){
return
}
this.socket = io(`${config.url}/api/v1/socket`, {
transports: ["websocket"],
path: "/api/v1/socket"
});
this.socket.on("real-time-pricing", async (data) => {
if(data.had_price_changed){
this.showNotification('The product price has changed, the latest price will be updated soon', 'tip')
await this.sleep(3000)
this.initData()
}
});
}

async fetchRealTimePrice(params) {
const {
products,
} = params
const headers = {
'Content-Type': 'application/json',
'A1-Secret-Key': config.secretKey,
}
console.log('fetch real time price: ', {
ecommerce_customer_id: this.customerID,
cart_id: this.cartID,
products,
})
const requestBody = {
ecommerce_customer_id: String(this.customerID),
cart_id: this.chekcoutPreviewCartID || this.cartID || '',
need_origin_data: false,
need_cart_info: true,
products,
}
const response = await fetch(config.url + '/api/v1/products/real-time-pricing', {
method: 'POST',
headers,
body: JSON.stringify(requestBody),
})
if (!response.ok) {
throw new Error('Failed to fetch prices')
}
const data = await response.json()
try {
if(!this.cartID){
const newPriceProducts = products.map((item) => {
const product = (data.data.products || []).find((i) => i.ecommerce_product_id === item.ecommerce_product_id && i.is_success)
return product ? {
...item,
price: String(product.net_price),
} : item
})

this.sendMessage({
...requestBody,
products: newPriceProducts,
})
}

}catch(error){
console.log('Websocket error: ', error)
}
return data?.data
}

refreshPage() {
sessionStorage.setItem(config.sessionName, this.getCurrentTimestamp())
location.reload()
}

getCurrentTimestamp() {
return new Date().getTime();
}

shouldUpdateCart() {
const lastUpdated = sessionStorage.getItem(config.sessionName);

if (!lastUpdated) {
return true;
}

const currentTimestamp = this.getCurrentTimestamp();
const timeDifferenceInSec = (currentTimestamp - lastUpdated) / 1000;

if (timeDifferenceInSec < 5) {
return false;
}
return true;
}

showNotification(message = config.defaultPriceNotification, type = "error") {
const notification = document.createElement('div')
notification.className = 'a1-notification'
notification.textContent = message

const colorObject = {
error: '#ff4d4f',
tip: '#1890ff',
}

Object.assign(notification.style, {
position: 'fixed',
top: '20px',
right: '20px',
backgroundColor: colorObject[type],
color: '#fff',
padding: '10px 20px',
borderRadius: '5px',
boxShadow: '0 2px 5px rgba(0, 0, 0, 0.2)',
zIndex: 10001,
fontSize: '14px',
animation: 'fade-in-out 3s forwards',
})
document.body.appendChild(notification)
setTimeout(() => {
notification.remove()
}, 3000)
}

showPageLoading() {
if (document.getElementById('a1-loading-overlay')) return;

const loadingOverlay = document.createElement('div');
loadingOverlay.id = 'a1-loading-overlay';
loadingOverlay.style.position = 'fixed';
loadingOverlay.style.top = '0';
loadingOverlay.style.left = '0';
loadingOverlay.style.width = '100%';
loadingOverlay.style.height = '100%';
loadingOverlay.style.backgroundColor = 'rgba(255,255,255,1)';
loadingOverlay.style.zIndex = '999999';
loadingOverlay.style.display = 'flex';
loadingOverlay.style.justifyContent = 'center';
loadingOverlay.style.alignItems = 'center';
loadingOverlay.style.color = 'black';
loadingOverlay.style.fontSize = '24px';

loadingOverlay.innerHTML = 'Loading for latest price...';

document.body.appendChild(loadingOverlay);
}

hidePageLoading() {
const loadingOverlay = document.getElementById('a1-loading-overlay');
if (loadingOverlay) {
loadingOverlay.remove();
}
}
}

const style = document.createElement('style')
style.textContent = `
@keyframes fade-in-out {
0% { opacity: 0; transform: translateY(-10px); }
10% { opacity: 1; transform: translateY(0); }
90% { opacity: 1; transform: translateY(0); }
100% { opacity: 0; transform: translateY(-10px); }
}
`
document.head.appendChild(style)

let realTimePricing
if (!realTimePricing) {
realTimePricing = new RealTimePricing()
}
</script>

Add attribute

Go to the Modify page:

Add the attribute corresponding to config (You can customize the names, but they should correspond to each other):

Cart preview:

Cart:

Checkout preview:

API Document

API Request Body ( filter )

Example:

{
ecommerce_customer_id: '7566',
location_id: '40',
products: [
{
ecommerce_product_id: '5165',
quantity: 10,
}
]
}
Field NameIs Required?TypeDescription
location_idStringERP location ID. The default value is Shophive default sales location ID.
ecommerce_customer_idStringEcommerce Customer ID. Shophive will find the ERP Customer ID if it comes from Shophive.
need_cart_infoBooleanIf set to true, the cart information will be returned. (The cart_id should be required.)
need_origin_dataBooleanIf set to true, the original ERP data will be returned.
cart_idStringEcommerce Cart ID. If provided with the cart id, the product price of this shopping cart will be updated.
contract_uidStringOptional. If contract-specific prices are required, populate this tag with contract_uid. Value must exist in job_price_hdr.job_price_hdr_uid and must be a valid active contract.
ship_to_idStringOptional. Populate this tag with a ship_to_id if ship-to-specific prices are required.
extraObjectUsed to place custom fields that can copy all parameters.
productsArrayList of product items. Example:
[{ ecommerce_product_id: '5165', quantity: 10 }]

For the product fields:

Field NameIs Required?TypeDescription
ecommerce_product_idStringEcommerce Product ID. Shophive will find the ERP Product ID if it comes from Shophive.
quantityNumberQuantity tags should be populated with the order quantity.
unit_nameStringOptional. Should be populated with the Unit name of the order quantity. Must match one of the unit names associated with the item. Defaults to P21's default sales UOM if omitted.
unit_sizeStringOptional. Should be populated with the unit size corresponding to unit_name. Defaults to P21's default sale unit size if omitted.
default_pricing_uomStringOptional. If pricing UOM is different from order quantity UOM, specify it here.
default_pricing_unit_sizeStringOptional. Unit size associated with the UOM passed in default_pricing_uom.
net_priceNumberLeave blank to let P21 calculate the price. If populated, P21 will return the specified price.
priceStringUsed to compare prices by socket.
extraObjectUsed to place custom fields that can copy all parameters.

API Response Body ( filter )

Example:

{
server_time: '521351355',
cart_info: {
base_amount: 405.65,
cart_amount: 405.65,
currency: {
code: "USD",
},
items: [
{
id: '907a0ef4-95d4-4bd1-b81e-08307397c0e5',
ecommerce_product_id: 1645,
price: 125.55,
quantity: 3,
}
]
},
products: [
{
is_success: true,
failed_reason: null,
ecommerce_product_id: '1645',
item_id: 'WKLKM546',
inv_mast_uid: '531',
list_price: '200.00',
net_price: '125.55',
location_name: 'Los Angeles',
got_price_at: '5643513213',
free_quantity: '2521',
unit_name: '',
unit_size: '',
pricing_uom: '',
default_pricing_uom: '',
remaining_free_quantity: '',
pricing_unit_size: '',
default_pricing_unit_size: '',
list_of_item_location_quantities: {
item_location_quantity: {
location_id: '100220',
free_quantity: '135.00000',
lead_time_days: 0,
}
}
}
]
}
Field NameTypeDescription
server_timeNumberThe timestamp when Shophive got the price.
is_cart_changedBooleanIndicates whether the cart has been updated.

Products

Field NameTypeDescription
is_successBooleanWhether P21 was successfully requested or the price from cache was obtained.
failed_reasonStringShows the failure reason if an error occurred.
ecommerce_product_idStringEcommerce Product ID.
item_idStringERP item ID.
inv_mast_uidStringERP inv_mast_uid.
list_priceStringList price.
net_priceStringNet price.
location_nameStringLocation name associated with the location ID passed in the request.
got_price_atNumberThe timestamp when Shophive got the price.
unit_nameStringOrder quantity UOM. Defaults to P21's sales UOM if not specified in request.
unit_sizeStringUnit size associated with the unit name (UOM).
unit_costStringSupplier cost of the item if GetCostType is set to "Supplier" in the request.
free_quantityStringFree quantity at the location.
pricing_uomStringUnit used for pricing.
remaining_free_quantityStringReturned only if GetRemainingFreeQuantity is set to TRUE.
pricing_unit_sizeStringUnit size associated with the pricing UOM.
default_pricing_uomStringTurnaround tag. Returns the DefaultPricingUOM sent in the request.
default_pricing_unit_sizeStringTurnaround tag. Returns the DefaultPricingUnitSize sent in the request.

Cart Info (required cart id and need_cart_info field)

Field NameTypeDescription
base_amountNumberSum of cart line-item amounts before discounts, coupons, or taxes.
cart_amountNumberSum of cart line-item amounts minus discounts and coupons, including taxes.
currencyObjectThe currency info for cart and checkout. ISO-4217 code, e.g. "USD".
itemsArrayList of items with id, price, ecommerce_product_id, quantity.

currency 示例 JSON:

{
code: "USD",
}

items 示例 JSON:

{
id: '907a0ef4-95d4-4bd1-b81e-08307397c0e5',
ecommerce_product_id: 1645,
price: 125.55,
quantity: 3,
}

Socket Request Message

Example:

{ 
a1_secret_key: "xxx",
body: {
cart_id: "",
ecommerce_customer_id: "xxx",
products: [
{
ecommerce_product_id: "1645",
quantity: 1,
price: '186.000000'
}
]
}
}
Field NameTypeDescription
a1_secret_keyStringSecret key in Shophive.
bodyObjectSame as API request body.
need_detailsBooleanNeed more details in response or not.

Socket Response Message

Example:

{ 
is_success: true,
had_price_changed: true,
}

{
is_success: false,
message: 'Please enter correct secret key and body.'
}
Field NameTypeDescription
is_successBooleanRequest success or not.
messageStringError message.
had_price_changedBooleanProduct list: The price has changed, the page needs to get the latest price again. Cart or Checkout: The latest price has been updated in BC/Shopify, the page needs to be refreshed.
dataObjectSame as API response data.