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 Name | Is Required? | Type | Description |
---|---|---|---|
location_id | String | ERP location ID. The default value is Shophive default sales location ID. | |
ecommerce_customer_id | √ | String | Ecommerce Customer ID. Shophive will find the ERP Customer ID if it comes from Shophive. |
need_cart_info | Boolean | If set to true, the cart information will be returned. (The cart_id should be required.) | |
need_origin_data | Boolean | If set to true, the original ERP data will be returned. | |
cart_id | String | Ecommerce Cart ID. If provided with the cart id, the product price of this shopping cart will be updated. | |
contract_uid | String | Optional. 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_id | String | Optional. Populate this tag with a ship_to_id if ship-to-specific prices are required. | |
extra | Object | Used to place custom fields that can copy all parameters. | |
products | √ | Array | List of product items. Example:[{ ecommerce_product_id: '5165', quantity: 10 }] |
For the product fields:
Field Name | Is Required? | Type | Description |
---|---|---|---|
ecommerce_product_id | √ | String | Ecommerce Product ID. Shophive will find the ERP Product ID if it comes from Shophive. |
quantity | √ | Number | Quantity tags should be populated with the order quantity. |
unit_name | String | Optional. 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_size | String | Optional. Should be populated with the unit size corresponding to unit_name . Defaults to P21's default sale unit size if omitted. | |
default_pricing_uom | String | Optional. If pricing UOM is different from order quantity UOM, specify it here. | |
default_pricing_unit_size | String | Optional. Unit size associated with the UOM passed in default_pricing_uom . | |
net_price | Number | Leave blank to let P21 calculate the price. If populated, P21 will return the specified price. | |
price | String | Used to compare prices by socket. | |
extra | Object | Used 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 Name | Type | Description |
---|---|---|
server_time | Number | The timestamp when Shophive got the price. |
is_cart_changed | Boolean | Indicates whether the cart has been updated. |
Products
Field Name | Type | Description |
---|---|---|
is_success | Boolean | Whether P21 was successfully requested or the price from cache was obtained. |
failed_reason | String | Shows the failure reason if an error occurred. |
ecommerce_product_id | String | Ecommerce Product ID. |
item_id | String | ERP item ID. |
inv_mast_uid | String | ERP inv_mast_uid. |
list_price | String | List price. |
net_price | String | Net price. |
location_name | String | Location name associated with the location ID passed in the request. |
got_price_at | Number | The timestamp when Shophive got the price. |
unit_name | String | Order quantity UOM. Defaults to P21's sales UOM if not specified in request. |
unit_size | String | Unit size associated with the unit name (UOM). |
unit_cost | String | Supplier cost of the item if GetCostType is set to "Supplier" in the request. |
free_quantity | String | Free quantity at the location. |
pricing_uom | String | Unit used for pricing. |
remaining_free_quantity | String | Returned only if GetRemainingFreeQuantity is set to TRUE . |
pricing_unit_size | String | Unit size associated with the pricing UOM. |
default_pricing_uom | String | Turnaround tag. Returns the DefaultPricingUOM sent in the request. |
default_pricing_unit_size | String | Turnaround tag. Returns the DefaultPricingUnitSize sent in the request. |
Cart Info (required cart id and need_cart_info field)
Field Name | Type | Description |
---|---|---|
base_amount | Number | Sum of cart line-item amounts before discounts, coupons, or taxes. |
cart_amount | Number | Sum of cart line-item amounts minus discounts and coupons, including taxes. |
currency | Object | The currency info for cart and checkout. ISO-4217 code, e.g. "USD". |
items | Array | List 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 Name | Type | Description |
---|---|---|
a1_secret_key | String | Secret key in Shophive. |
body | Object | Same as API request body. |
need_details | Boolean | Need 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 Name | Type | Description |
---|---|---|
is_success | Boolean | Request success or not. |
message | String | Error message. |
had_price_changed | Boolean | Product 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. |
data | Object | Same as API response data. |