361 lines
11 KiB
JavaScript
361 lines
11 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]
|
|
}
|
|
|
|
// Monero pricing
|
|
let XMR_PRICE
|
|
const getXmrPrice = async () => {
|
|
let res; try {
|
|
res = await fetch('https://api.coingecko.com/api/v3/simple/price?ids=monero&vs_currencies=usd')
|
|
} catch (err) { console.error(`Failed to fetch CoinGecko price: ${err}`) }
|
|
let parsedRes; try {
|
|
parsedRes = await res.json()
|
|
} catch (err) { console.error(`Error parsing CoinGecko response: ${err}`) }
|
|
console.log(`Monero is now worth ${formatUSD(parsedRes.monero.usd)}`)
|
|
return XMR_PRICE = parsedRes.monero.usd
|
|
}
|
|
getXmrPrice()
|
|
setInterval(getXmrPrice,MONERO_PRICECHECK_SEC*1000)
|
|
const usdToXmr = (usd) => Number(usd / XMR_PRICE)
|
|
|
|
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()}"}`)
|
|
}
|
|
|
|
// 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.totalxmr = ko.pureComputed( () =>
|
|
Math.round( 10000 * usdToXmr(self.total()) )/10000
|
|
)
|
|
self.totalxmr_pretty = ko.pureComputed( () =>
|
|
`${self.totalxmr()} XMR` )
|
|
|
|
// Payment method
|
|
self.payment_method = ko.observable('USD')
|
|
|
|
// 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.checkoutButtonText = ko.pureComputed(() => {
|
|
if (self.isLoading())
|
|
return '📃 Creating order...'
|
|
else if (self.subtotal()>0)
|
|
if (self.payment_method()==='USD')
|
|
return `📃 Create order for ${self.total_pretty()}`
|
|
else if (self.payment_method()==='XMR')
|
|
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 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(),
|
|
},
|
|
shippingAddress: {
|
|
name: self.shipname(),
|
|
addr1: self.addr1(),
|
|
addr2: self.addr2(),
|
|
city: self.city(),
|
|
state: self.state(),
|
|
zip: self.zip(),
|
|
},
|
|
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,
|
|
shippingAmount: Math.round(self.shipping()*100)/100,
|
|
paymentMethod: self.payment_method(),
|
|
total: self.total(),
|
|
totalxmr: self.totalxmr(),
|
|
created: new Date(),
|
|
}
|
|
|
|
// Send to server
|
|
const res = await fetch(`${API_DOMAIN}/order`, {
|
|
method: 'POST', mode: 'cors',
|
|
headers: {'Content-Type':'application/json'},
|
|
body: ko.toJSON(order),
|
|
})
|
|
if (res.ok) { // paymentIntent created
|
|
let parsedRes; try {
|
|
parsedRes = await res.json()
|
|
} catch (err) { console.error(`Failed to parse JSON: ${err}`) }
|
|
if (self.payment_method()==='USD')
|
|
window.location = `/shop/checkout/stripe/?order=${parsedRes.id}`
|
|
else if (self.payment_method()==='XMR')
|
|
window.location = `/shop/order/monero/?order=${parsedRes.id}`
|
|
else {
|
|
alert(`Invalid payment method: ${self.payment_method()}`)
|
|
self.isLoading(false)
|
|
}
|
|
} else { // paymentIntent.create() failed
|
|
alert(await res.text())
|
|
self.isLoading(false)
|
|
}
|
|
|
|
}
|
|
|
|
// 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())
|