www/_src/assets/scripts/cart.js

363 lines
12 KiB
JavaScript

'use strict'
const formatUSD = (v) => v.toLocaleString(undefined, {
style: "currency", currency: "USD" })
const zipToState = (zip) => ZIPS_TO_STATES[zip.substring(0,3)]
const zipToZone = (zip) => ZIPS_TO_ZONES[zip.substring(0,3)]
const getUspsRates = (zip, weight, volume) => {
return USPS_SHIPPING_RATES['40'][zipToZone(zip)-1]*Math.floor(weight/40)
+ USPS_SHIPPING_RATES[String(Math.ceil(weight%40))][zipToZone(zip)-1]
}
class CartItem { constructor(data) {
let self = this
self.name = data.name
self.pid = data.pid
self.sid = data.sid
self.price = data.price
self.weight = data.weight
self.volume = data.volume
self.link = data.link
self.pretty_price = formatUSD(parseFloat(data.price))
self.qty = ko.observable(data.qty)
self.maxQty = (self.sid)? 1:99
self.subtotal = ko.pureComputed(() => {
return formatUSD(self.price * self.qty())
})
// Subscribe to changes in qty
self.qty.subscribe((qty) => {
if (qty===0||qty==='0')
localStorage.removeItem(`cart_${self.pid}/${self.sid}`)
else localStorage.setItem(`cart_${self.pid}/${self.sid}`,
`{"name":"${self.name}","qty":"${qty}","price":"${self.price}","link":"${self.link}","weight":"${self.weight}","volume":"${self.volume}"}`
)
recountCart()
}, self)
} }
class Cart { constructor() {
let self = this
self.isLoading = ko.observable(false)
// Cart items
self.items = ko.observableArray([])
self.removeItem = (item) => {
item.qty(0)
localStorage.removeItem(`cart_${item.pid}/${item.sid}`)
}
self.itemMinus = (item) => {
const newval = Number(item.qty()) - 1
if (newval>=0) item.qty(newval)
}
self.itemPlus = (item) => {
const newval = Number(item.qty()) + 1
if (newval<=item.maxQty) item.qty(newval)
}
self.clearCart = () => {
if (confirm('Are you sure you want to remove all the items from your cart? '))
self.items().forEach((item) => {
self.removeItem(item)
})
}
// Total cart weight and volume
self.weight = ko.pureComputed(() =>
self.items().reduce((acc,cur) =>
acc+(parseFloat(cur.qty())*parseFloat(cur.weight))
,0)
)
self.volume = ko.pureComputed(() =>
self.items().reduce((acc,cur) => acc+(parseFloat(cur.qty())*parseFloat(cur.volume)), 0)
)
// Subscribe to changes in shipping
const updateShippingLocalStorage = (s) =>
localStorage.setItem('shipping',`{"name":"${self.shipname()}","addr1":"${self.addr1()}","addr2":"${self.addr2()}","city":"${self.city()}","state":"${self.state()}","zip":"${self.zip()}"}`)
// Subscribe to changes in contact
const updateContactLocalStorage = (s) =>
localStorage.setItem('contact',`{"name":"${self.contactname()}","phone":"${self.phone()}","email":"${self.email()}"}`)
// Subscribe to changes in payment method
const updatePaymentPreferenceLocalStorage = (s) =>
localStorage.setItem('payment_preference',self.payment_method())
// Shipping address
self.shipname = ko.observable('')
self.shipname.subscribe(updateShippingLocalStorage, self)
self.contactname = ko.observable('')
self.contactname.subscribe(updateContactLocalStorage, self)
self.phone = ko.observable('')
self.phone.subscribe(updateContactLocalStorage, self)
self.email = ko.observable('')
self.email.subscribe(updateContactLocalStorage, self)
self.addr1 = ko.observable('')
self.addr1.subscribe(updateShippingLocalStorage, self)
self.addr2 = ko.observable('')
self.addr2.subscribe(updateShippingLocalStorage, self)
self.city = ko.observable('')
self.city.subscribe(updateShippingLocalStorage, self)
self.zip = ko.observable('')
self.zip.subscribe(updateShippingLocalStorage, self)
self.hasZip = ko.pureComputed( () => (/[0-9]{5}/.test(self.zip())) )
self.state = ko.pureComputed( () => (self.hasZip())?zipToState(self.zip()):'' )
self.state.subscribe(updateShippingLocalStorage, self)
// Sales tax
self.tax = ko.pureComputed(() => {
// Set tax rates here
if (self.state()==='CO') return 0.049
else return 0
})
self.tax_pretty = ko.pureComputed(() => {
if (!self.hasZip()) return '(Enter zip)'
else return formatUSD(self.subtotal()*self.tax())
})
// Shipping
self.shipping = ko.pureComputed(() => (self.hasZip()&&self.items().length>0)?
getUspsRates(
self.zip().substring(0,3),
self.weight(),
self.volume() * 1.15, // 15% packing inefficiency
)
: 0 )
self.shipping_pretty = ko.pureComputed(() =>
(!self.hasZip())?'(Enter zip)':formatUSD(self.shipping())
)
// Subtotal
self.subtotal = ko.pureComputed(() =>
self.items().reduce(
(acc, cur) => acc + (cur.price * cur.qty()), 0
)
)
self.subtotal_pretty = ko.pureComputed(() =>
formatUSD(self.subtotal())
)
// Total before stripe processing fee
self.preTotal = ko.pureComputed(() => {
const subtotal = self.items().reduce(
(acc, cur) => acc + (cur.price * cur.qty()), 0
)
const tax = (typeof self.tax()==='number')?self.tax():0
const shipping = (typeof self.shipping()==='number')?self.shipping():0
return subtotal + Math.round(subtotal * tax *100)/100 + shipping
})
// Processing fees
self.processing = ko.pureComputed(() => {
if (self.subtotal()===0)
return 0
else if (self.payment_method()==='USD')
return Math.round(((self.preTotal()*0.0298661174047) + 0.308959835221)*100)/100
else if (self.payment_method()==='XMR')
return 0
else if (self.payment_method()==='BTC')
return 0
else return '(Provide payment method)'
})
self.processing_pretty = ko.pureComputed(() =>
formatUSD(self.processing())
)
// Total
self.total = ko.pureComputed(() => Math.round(
(self.preTotal() + self.processing())*100
)/100)
self.total_pretty = ko.pureComputed(() =>
formatUSD(self.total())
)
// XMR
self.xmr_price = ko.observable(0)
self.totalxmr = ko.pureComputed( () =>
(self.xmr_price()===0)? 0 : Math.round(
10000 * Number(self.total() / self.xmr_price())
)/10000
)
self.totalxmr_pretty = ko.pureComputed( () =>
(self.totalxmr()===0)?'Getting xmr price...':
`${self.totalxmr()} XMR` )
// Monero pricing
const getXmrPrice = async () => {
let res; try {
//res = await fetch('https://localmonero.co/web/ticker?currencyCode=USD')
res = await fetch('https://api.coingecko.com/api/v3/simple/price?ids=monero&vs_currencies=usd')
} catch (err) {
return console.error(`Failed to fetch CoinGecko price: ${err}`)
}; let parsedRes; try {
parsedRes = await res.json()
} catch (err) {
return console.error(`Error parsing CoinGecko response: ${err}`)
}
console.log(`Monero is now worth ${formatUSD(parsedRes.monero.usd)}`)
self.xmr_price(parsedRes.monero.usd)
}; getXmrPrice()
setInterval(getXmrPrice, MONERO_PRICECHECK_SEC*1000)
// Payment method
self.payment_method = ko.observable((localStorage.payment_preference==null)?'USD':localStorage.payment_preference)
self.payment_method.subscribe(updatePaymentPreferenceLocalStorage, self)
// Checkout
self.checkoutEnabled = ko.pureComputed(() =>
self.subtotal() > 0
&& self.hasZip()
&& self.shipname().length > 0
&& self.addr1().length > 0
&& self.city().length > 0
&& self.contactname().length > 0
&& self.phone().length > 0
&& self.email().length > 0
&& (self.xmr_price()!=0 || self.payment_method!='XMR')
)
self.checkoutButtonText = ko.pureComputed(() => {
if (self.isLoading()) return '📃 Creating order...'
else if (self.subtotal()>0 && self.payment_method()==='USD')
return `📃 Create order for ${self.total_pretty()}`
else if (self.subtotal()>0 && self.payment_method()==='XMR' && self.xmr_price()!=0)
return `📃 Create order for ${self.totalxmr_pretty()}`
else return '📃 Create order'
})
self.checkoutButtonHelp = ko.pureComputed(() => {
if (self.total()===0) return 'Add items to your cart first'
else if (!self.hasZip()) return 'Enter shipping ZIP first'
else if (self.shipname().length===0) return 'Enter shipping address name'
else if (self.addr1().length===0) return 'Enter an address where your items should be shipped'
else if (self.city().length===0) return 'Enter a city where your order should be shipped'
else if (self.contactname().length===0) return 'Enter a contact name'
else if (self.phone().length===0) return 'Enter a phone number for your order'
else if (self.email().length===0) return 'Enter an email for your order'
else if (self.xmr_price()==0 && self.payment_method=='XMR') return 'Please wait for the current monero price to be checked...'
else return 'Click to place order'
})
self.checkout = async () => {
self.isLoading(true)
// Prepare order
const order = {
items: self.items()
.filter( (i) => (i.qty()>0) )
.map( (i) => {
i.qty = i.qty()
i.subtotal = i.subtotal()
return i
} ),
contact: {
name: self.contactname(),
phone: self.phone(),
email: self.email(),
},
shipping: {
name: self.shipname(),
address: {
addr1: self.addr1(),
addr2: self.addr2(),
city: self.city(),
state: self.state(),
zip: self.zip(),
},
amount: Math.round(self.shipping()*100)/100,
},
subtotal: Math.round(self.subtotal()*100)/100,
tax: self.tax(),
taxAmount: Math.round(self.tax() * self.subtotal() * 100)/100,
processing: Math.round(self.processing()*100)/100,
paymentMethod: self.payment_method(),
total: self.total(),
totalxmr: self.totalxmr(),
created: new Date(),
}
// Send to server
let parsedRes; try {
const res = await fetch(`${API_DOMAIN}/order`, {
method: 'POST', mode: 'cors',
headers: {'Content-Type':'application/json'},
body: ko.toJSON(order),
})
if (!res.ok) throw await res.text()
else try { parsedRes = await res.json()}
catch (err) { throw `Failed to parse response:\n${err}` }
} catch (err) {
alert('Failed to create order! Please try again later')
console.error(err)
} finally { self.isLoading(false) }
window.location = `/shop/order/?id=${parsedRes.id}&key=${parsedRes.key}`
}
// On first load, read from localStorage
Object.keys(localStorage).forEach(i => {
if (i.substring(0,5)==='cart_') {
const parsed_item = JSON.parse(localStorage[i])
const split_item = i.slice(5).split('/')
self.items.push(new CartItem({
name: parsed_item.name,
pid: split_item[0],
sid: split_item[1],
link: parsed_item.link,
price: parsed_item.price,
qty: parseFloat(parsed_item.qty),
weight: parsed_item.weight,
volume: parsed_item.volume,
}))
} else if (i==='shipping') {
const parsed_item = JSON.parse(localStorage[i])
self.shipname(parsed_item.name)
self.addr1(parsed_item.addr1)
self.addr2(parsed_item.addr2)
self.city(parsed_item.city)
self.zip(parsed_item.zip)
} else if (i==='contact') {
const parsed_item = JSON.parse(localStorage[i])
self.contactname(parsed_item.name)
self.phone(parsed_item.phone)
self.email(parsed_item.email)
}
})
// Changes made on other windows; update from localStorage
// TODO Clean this up, make sure it still works
window.addEventListener('storage', (e) => {
if (!e) return
else if (e.key.substring(0,5)==='cart_') {
recountCart()
const newVal = JSON.parse(e.newValue)
if (self.items().reduce(
(acc, cur) => {
acc.push(`cart_${cur.pid}/${cur.sid}`)
return acc
},[]).includes(e.key)) {
// Modify item already in cart
const item = self.items().filter((item) =>
`cart_${item.pid}/${item.sid}` === e.key)[0]
if (newVal) item.qty(newVal.qty)
else {
item.qty(0)
localStorage.removeItem(e.key) // Sometimes knockout keeps it from deleting
}
} else {
// Add new item to cart
const split_item = e.key.slice(5).split('/')
self.items.push(new CartItem({
name: newVal.name,
pid: split_item[0],
sid: split_item[1],
link: newVal.link,
price: newVal.price,
qty: newVal.qty,
weight: newVal.weight,
volume: newVal.volume,
}))
}
} else return
})
} }
ko.applyBindings(new Cart())