nostrmarket/static/js/market.js
2023-07-18 16:40:43 +02:00

908 lines
29 KiB
JavaScript

const market = async () => {
Vue.component(VueQrcode.name, VueQrcode)
const NostrTools = window.NostrTools
const defaultRelays = [
'wss://relay.damus.io',
'wss://relay.snort.social',
'wss://nostr-pub.wellorder.net',
'wss://nostr.zebedee.cloud',
'wss://nostr.walletofsatoshi.com'
]
const eventToObj = event => {
event.content = JSON.parse(event.content) || null
return {
...event,
...Object.values(event.tags).reduce((acc, tag) => {
let [key, value] = tag
if (key == 't') {
return { ...acc, [key]: [...(acc[key] || []), value] }
} else {
return { ...acc, [key]: value }
}
}, {})
}
}
await Promise.all([
productCard('static/components/product-card/product-card.html'),
customerMarket('static/components/customer-market/customer-market.html'),
customerStall('static/components/customer-stall/customer-stall.html'),
productDetail('static/components/product-detail/product-detail.html'),
shoppingCart('static/components/shopping-cart/shopping-cart.html'),
shoppingCartList('static/components/shopping-cart-list/shopping-cart-list.html'),
shoppingCartCheckout('static/components/shopping-cart-checkout/shopping-cart-checkout.html'),
customerOrders('static/components/customer-orders/customer-orders.html'),
chatDialog('static/components/chat-dialog/chat-dialog.html'),
marketConfig('static/components/market-config/market-config.html'),
userConfig('static/components/user-config/user-config.html'),
userChat('static/components/user-chat/user-chat.html')
])
new Vue({
el: '#vue',
mixins: [windowMixin],
data: function () {
return {
account: null,
accountMetadata: null,
accountDialog: {
show: false,
data: {
watchOnly: false,
key: null
}
},
merchants: [],
shoppingCarts: [],
checkoutCart: null,
checkoutStall: null,
activePage: 'market',
activeOrderId: null,
dmSubscriptions: {},
qrCodeDialog: {
data: {
payment_request: null,
message: null,
},
dismissMsg: null,
show: false
},
searchNostr: false,
filterCategories: [],
drawer: true,
pubkeys: new Set(),
relays: new Set(),
events: [],
stalls: [],
products: [],
orders: {},
profiles: new Map(),
searchText: null,
inputPubkey: null,
inputRelay: null,
activePage: 'market',
activeStall: null,
activeProduct: null,
pool: null,
config: null,
configDialog: {
show: false,
data: {
name: null,
about: null,
ui: {
picture: null,
banner: null,
theme: null
}
}
},
naddr: null,
loading: false
}
},
computed: {
filterProducts() {
let products = this.products.filter(p => this.hasCategory(p.categories))
if (this.activeStall) {
products = products.filter(p => p.stall_id == this.activeStall)
}
if (!this.searchText || this.searchText.length < 2) return products
const searchText = this.searchText.toLowerCase()
return products.filter(p => {
return (
p.name.toLowerCase().includes(searchText) ||
(p.description &&
p.description.toLowerCase().includes(searchText)) ||
(p.categories &&
p.categories.toString().toLowerCase().includes(searchText))
)
})
},
stallName() {
return this.stalls.find(s => s.id == this.activeStall)?.name || 'Stall'
},
productName() {
return (
this.products.find(p => p.id == this.activeProduct)?.name || 'Product'
)
},
isLoading() {
return this.$q.loading.isActive
},
hasExtension() {
return window.nostr
},
isValidAccountKey() {
return isValidKey(this.accountDialog.data.key)
},
canEditConfig() {
return this.account && this.account.pubkey == this.config?.pubkey
},
allCartsItemCount() {
return this.shoppingCarts.map(s => s.products).flat().reduce((t, p) => t + p.orderedQuantity, 0)
},
allCategories() {
const categories = this.products.map(p => p.categories).flat().filter(c => !!c)
const countedCategories = categories.reduce((all, c) => {
all[c] = (all[c] || 0) + 1
return all
}, {})
const x = Object.keys(countedCategories)
.map(category => ({
category,
count: countedCategories[category],
selected: this.filterCategories.indexOf(category) !== -1
}))
.sort((a, b) => b.count - a.count)
console.log('### x', x)
return x
}
},
async created() {
this.merchants = this.$q.localStorage.getItem('nostrmarket.merchants') || []
this.shoppingCarts = this.$q.localStorage.getItem('nostrmarket.shoppingCarts') || []
this.account = this.$q.localStorage.getItem('nostrmarket.account') || null
const prefix = 'nostrmarket.orders.'
const orderKeys = this.$q.localStorage.getAllKeys().filter(k => k.startsWith(prefix))
orderKeys.forEach(k => {
const pubkey = k.substring(prefix.length)
this.orders[pubkey] = this.$q.localStorage.getItem(k)
})
//console.log("UUID", crypto.randomUUID())
// Check for stored merchants and relays on localStorage
try {
let merchants = this.$q.localStorage.getItem(`diagonAlley.merchants`)
let relays = this.$q.localStorage.getItem(`diagonAlley.relays`)
if (merchants && merchants.length) {
this.pubkeys = new Set(merchants)
}
if (this.account) {
this.pubkeys.add(this.account.pubkey)
}
if (relays && relays.length) {
this.relays = new Set(relays)
} else {
this.relays = new Set(defaultRelays)
}
} catch (e) {
console.error(e)
}
let params = new URLSearchParams(window.location.search)
let merchant_pubkey = params.get('merchant_pubkey')
let stall_id = params.get('stall_id')
let product_id = params.get('product_id')
let naddr = params.get('naddr')
if (naddr) {
try {
let { type, data } = NostrTools.nip19.decode(naddr)
if (type == 'naddr' && data.kind == '30019') { // just double check
this.config = {
d: data.identifier,
pubkey: data.pubkey,
relays: data.relays
}
}
this.naddr = naddr
} catch (err) {
console.error(err)
}
}
// What component to render on start
if (stall_id) {
if (product_id) {
this.activeProduct = product_id
}
this.activePage = 'stall'
this.activeStall = stall_id
}
if (merchant_pubkey && !this.pubkeys.has(merchant_pubkey)) {
await LNbits.utils
.confirmDialog(
`We found a merchant pubkey in your request. Do you want to add it to the merchants list?`
)
.onOk(async () => {
await this.addPubkey(merchant_pubkey)
})
}
// Get notes from Nostr
await this.initNostr()
this.$q.loading.hide()
await this.listenForIncommingDms(this.merchants.map(m => ({ publicKey: m.publicKey, since: this.lastDmForPubkey(m.publicKey) })))
},
methods: {
async deleteAccount() {
await LNbits.utils
.confirmDialog(
`This will delete all stored data. If you didn't backup the Key Pair (Private and Public Keys), you will lose it. Continue?`
)
.onOk(() => {
window.localStorage.removeItem('nostrmarket.account')
this.account = null
})
},
async createAccount(useExtension = false) {
console.log('### createAccount',)
let nip07
if (useExtension) {
await this.getFromExtension()
nip07 = true
}
if (isValidKey(this.accountDialog.data.key, 'nsec')) {
let { key, watchOnly } = this.accountDialog.data
if (key.startsWith('n')) {
let { type, data } = NostrTools.nip19.decode(key)
key = data
}
this.$q.localStorage.set('nostrmarket.account', {
privkey: watchOnly ? null : key,
pubkey: watchOnly ? key : NostrTools.getPublicKey(key),
useExtension: nip07 ?? false
})
this.accountDialog.data = {
watchOnly: false,
key: null
}
this.accountDialog.show = false
this.account = this.$q.localStorage.getItem('nostrmarket.account')
}
this.accountDialog.show = false
},
generateKeyPair() {
this.accountDialog.data.key = NostrTools.generatePrivateKey()
this.accountDialog.data.watchOnly = false
},
async getFromExtension() {
this.accountDialog.data.key = await window.nostr.getPublicKey()
this.accountDialog.data.watchOnly = true
return
},
openAccountDialog() {
this.accountDialog.show = true
},
editConfigDialog() {
if (this.canEditConfig && this.config?.opts) {
let { name, about, ui } = this.config.opts
this.configDialog.data = { name, about, ui }
this.configDialog.data.identifier = this.config?.d
}
this.openConfigDialog()
},
openConfigDialog() {
if (!this.account) {
this.$q.notify({
message: `You need to be logged in first.`,
color: 'negative',
icon: 'warning'
})
return
}
this.configDialog.show = true
},
async sendConfig() {
let { name, about, ui } = this.configDialog.data
let merchants = Array.from(this.pubkeys)
let identifier = this.configDialog.data.identifier ?? crypto.randomUUID()
let event = {
...(await NostrTools.getBlankEvent()),
kind: 30019,
content: JSON.stringify({ name, about, ui, merchants }),
created_at: Math.floor(Date.now() / 1000),
tags: [['d', identifier]],
pubkey: this.account.pubkey
}
event.id = NostrTools.getEventHash(event)
try {
if (this.account.useExtension) {
event = await window.nostr.signEvent(event)
} else if (this.account.privkey) {
event.sig = await NostrTools.signEvent(event, this.account.privkey)
}
let pub = this.pool.publish(Array.from(this.relays), event)
pub.on('ok', () => console.debug(`Config event was sent`))
pub.on('failed', error => console.error(error))
} catch (err) {
console.error(err)
this.$q.notify({
message: `Error signing event.`,
color: 'negative',
icon: 'warning'
})
return
}
this.naddr = NostrTools.nip19.naddrEncode({
pubkey: event.pubkey,
kind: 30019,
identifier: identifier,
relays: Array.from(this.relays)
})
this.config = this.configDialog.data
this.resetConfig()
return
},
resetConfig() {
this.configDialog = {
show: false,
identifier: null,
data: {
name: null,
about: null,
ui: {
picture: null,
banner: null,
theme: null
}
}
}
},
async updateData(events) {
console.log('### updateData', events)
if (events.length < 1) {
this.$q.notify({
message: 'No matches were found!'
})
return
}
let products = new Map()
let stalls = new Map()
this.stalls.forEach(s => stalls.set(s.id, s))
this.products.forEach(p => products.set(p.id, p))
events.map(eventToObj).map(e => {
if (e.kind == 0) {
this.profiles.set(e.pubkey, e.content)
if (e.pubkey == this.account?.pubkey) {
this.accountMetadata = this.profiles.get(this.account.pubkey)
}
this.merchants.filter(m => m.publicKey === e.pubkey).forEach(m => m.profile = e.content)
return
} else if (e.kind == 30018) {
//it's a product `d` is the prod. id
products.set(e.d, { ...e.content, pubkey: e.pubkey, id: e.d, categories: e.t })
} else if (e.kind == 30017) {
// it's a stall `d` is the stall id
stalls.set(e.d, { ...e.content, pubkey: e.pubkey, id: e.d, pubkey: e.pubkey })
}
})
this.stalls = await Array.from(stalls.values())
this.products = Array.from(products.values())
.map(obj => {
let stall = this.stalls.find(s => s.id == obj.stall_id)
if (!stall) return
obj.stallName = stall.name
obj.images = obj.images || [obj.image]
if (obj.currency != 'sat') {
obj.formatedPrice = this.getAmountFormated(
obj.price,
obj.currency
)
}
return obj
})
.filter(f => f)
console.log('### products', this.products)
},
async initNostr() {
this.$q.loading.show()
const pool = new NostrTools.SimplePool()
// If there is an naddr in the URL, get it and parse content
if (this.config) {
// add relays to the set
this.config.relays.forEach(r => this.relays.add(r))
await pool.get(this.config.relays, {
kinds: [30019],
limit: 1,
authors: [this.config.pubkey],
'#d': [this.config.d]
}).then(event => {
if (!event) return
let content = JSON.parse(event.content)
this.config = { ... this.config, opts: content }
// add merchants
this.config.opts?.merchants.forEach(m => this.pubkeys.add(m))
// change theme
let { theme } = this.config.opts?.ui
theme && document.body.setAttribute('data-theme', theme)
}).catch(err => console.error(err))
}
let relays = Array.from(this.relays)
const authors = Array.from(this.pubkeys).concat(this.merchants.map(m => m.publicKey))
console.log('### stupid', authors)
// Get metadata and market data from the pubkeys
await pool
.list(relays, [
{
kinds: [0, 30017, 30018], // for production kind is 30017
authors
}
])
.then(async events => {
console.log('### stupid response', events)
if (!events || events.length == 0) return
await this.updateData(events)
})
this.$q.loading.hide()
this.pool = pool
this.poolSubscribe()
return
},
async poolSubscribe() {
const authors = Array.from(this.pubkeys).concat(this.merchants.map(m => m.publicKey))
console.log('### poolSubscribe.authors', authors)
this.poolSub = this.pool.sub(Array.from(this.relays), [
{
kinds: [0, 30017, 30018],
authors,
since: Date.now() / 1000
}
])
this.poolSub.on(
'event',
event => {
this.updateData([event])
},
{ id: 'masterSub' } //pass ID to cancel previous sub
)
},
navigateTo(page, opts = { stall: null, product: null, pubkey: null }) {
console.log("### navigateTo", page, opts)
let { stall, product, pubkey } = opts
let url = new URL(window.location)
if (pubkey) url.searchParams.set('merchant_pubkey', pubkey)
if (stall && !pubkey) {
pubkey = this.stalls.find(s => s.id == stall).pubkey
url.searchParams.set('merchant_pubkey', pubkey)
}
if (page === 'stall' || page === 'product') {
if (stall) {
this.activeStall = stall
url.searchParams.set('stall_id', stall)
this.activeProduct = product
if (product) {
url.searchParams.set('product_id', product)
}
}
} else {
this.activeStall = null
this.activeProduct = null
url.searchParams.delete('merchant_pubkey')
url.searchParams.delete('stall_id')
url.searchParams.delete('product_id')
this.setActivePage('market')
}
window.history.pushState({}, '', url)
this.activePage = page
},
copyText: function (text) {
var notify = this.$q.notify
Quasar.utils.copyToClipboard(text).then(function () {
notify({
message: 'Copied to clipboard!',
position: 'bottom'
})
})
},
getAmountFormated(amount, unit = 'USD') {
return LNbits.utils.formatCurrency(amount, unit)
},
async addPubkey(pubkey) {
if (!pubkey) {
pubkey = String(this.inputPubkey).trim()
}
let regExp = /^#([0-9a-f]{3}){1,2}$/i
if (pubkey.startsWith('n')) {
try {
let { type, data } = NostrTools.nip19.decode(pubkey)
if (type === 'npub') pubkey = data
else if (type === 'nprofile') {
pubkey = data.pubkey
givenRelays = data.relays
}
} catch (err) {
console.error(err)
}
} else if (regExp.test(pubkey)) {
pubkey = pubkey
}
this.pubkeys.add(pubkey)
this.inputPubkey = null
this.$q.localStorage.set(
`diagonAlley.merchants`,
Array.from(this.pubkeys)
)
this.initNostr()
},
removePubkey(pubkey) {
// Needs a hack for Vue reactivity
let pubkeys = this.pubkeys
pubkeys.delete(pubkey)
this.profiles.delete(pubkey)
this.pubkeys = new Set(Array.from(pubkeys))
this.$q.localStorage.set(
`diagonAlley.merchants`,
Array.from(this.pubkeys)
)
this.initNostr()
},
async addRelay() {
let relay = String(this.inputRelay).trim()
if (!relay.startsWith('ws')) {
console.debug('invalid url')
return
}
this.relays.add(relay)
this.$q.localStorage.set(`diagonAlley.relays`, Array.from(this.relays))
this.inputRelay = null
this.initNostr()
},
removeRelay(relay) {
// Needs a hack for Vue reactivity
let relays = this.relays
relays.delete(relay)
this.relays = new Set(Array.from(relays))
this.$q.localStorage.set(`diagonAlley.relays`, Array.from(this.relays))
this.initNostr()
},
setActivePage(page = 'market') {
this.activePage = page
},
addMerchant(publicKey) {
console.log('### addMerchat', publicKey)
this.merchants.unshift({
publicKey,
profile: null
})
this.$q.localStorage.set('nostrmarket.merchants', this.merchants)
this.initNostr() // todo: improve
},
removeMerchant(publicKey) {
console.log('### removeMerchat', publicKey)
this.merchants = this.merchants.filter(m => m.publicKey !== publicKey)
this.$q.localStorage.set('nostrmarket.merchants', this.merchants)
this.products = this.products.filter(p => p.pubkey !== publicKey)
this.stalls = this.stalls.filter(p => p.pubkey !== publicKey)
this.initNostr() // todo: improve
},
addProductToCart(item) {
console.log('### addProductToCart', item)
let stallCart = this.shoppingCarts.find(s => s.id === item.stall_id)
if (!stallCart) {
stallCart = {
id: item.stall_id,
products: []
}
this.shoppingCarts.push(stallCart)
}
stallCart.merchant = this.merchants.find(m => m.publicKey === item.pubkey)
let product = stallCart.products.find(p => p.id === item.id)
if (!product) {
product = { ...item, orderedQuantity: 0 }
stallCart.products.push(product)
}
product.orderedQuantity = Math.min(product.quantity, item.orderedQuantity || (product.orderedQuantity + 1))
this.$q.localStorage.set('nostrmarket.shoppingCarts', this.shoppingCarts)
this.$q.notify({
type: 'positive',
message: 'Product added to cart!'
})
},
removeProductFromCart(item) {
const stallCart = this.shoppingCarts.find(c => c.id === item.stallId)
if (stallCart) {
stallCart.products = stallCart.products.filter(p => p.id !== item.productId)
if (!stallCart.products.length) {
this.shoppingCarts = this.shoppingCarts.filter(s => s.id !== item.stallId)
}
this.$q.localStorage.set('nostrmarket.shoppingCarts', this.shoppingCarts)
}
},
removeCart(cartId) {
this.shoppingCarts = this.shoppingCarts.filter(s => s.id !== cartId)
this.$q.localStorage.set('nostrmarket.shoppingCarts', this.shoppingCarts)
},
checkoutStallCart(cart) {
this.checkoutCart = cart
this.checkoutStall = this.stalls.find(s => s.id === cart.id)
this.setActivePage('shopping-cart-checkout')
},
async placeOrder({ event, order, cartId }) {
if (!this.account.privkey) {
this.openAccountDialog()
return
}
try {
this.activeOrderId = order.id
event.content = await NostrTools.nip04.encrypt(
this.account.privkey,
this.checkoutStall.pubkey,
JSON.stringify(order)
)
event.id = NostrTools.getEventHash(event)
event.sig = await NostrTools.signEvent(event, this.account.privkey)
this.sendOrderEvent(event)
this.persistOrderUpdate(this.checkoutStall.pubkey, event.created_at, order)
this.removeCart(cartId)
this.setActivePage('shopping-cart-list')
} catch (error) {
console.warn(error)
this.$q.notify({
type: 'warning',
message: 'Failed to place order!'
})
}
},
sendOrderEvent(event) {
const pub = this.pool.publish(Array.from(this.relays), event)
this.$q.notify({
type: 'positive',
message: 'The order has been placed!'
})
this.qrCodeDialog = {
data: {
payment_request: null,
message: null,
},
dismissMsg: null,
show: true
}
pub.on('ok', () => {
this.qrCodeDialog.show = true
})
pub.on('failed', (error) => {
// do not show to user. It is possible that only one relay has failed
console.error(error)
})
},
async listenForIncommingDms(from) {
console.log('### from', from)
if (!this.account?.privkey) {
return
}
try {
const filters = [{
kinds: [4],
'#p': [this.account.pubkey],
}, {
kinds: [4],
authors: [this.account.pubkey],
}]
console.log('### filters', filters)
const subs = this.pool.sub(Array.from(this.relays), filters)
subs.on('event', async event => {
const receiverPubkey = event.tags.find(([k, v]) => k === 'p' && v && v !== '')[1]
const isSentByMe = event.pubkey === this.account.pubkey
if (receiverPubkey !== this.account.pubkey && !isSentByMe) {
console.log('Unexpected DM. Dropped!')
return
}
this.persistDMEvent(event)
const peerPubkey = isSentByMe ? receiverPubkey : event.pubkey
await this.handleIncommingDm(event, peerPubkey)
})
return subs
} catch (err) {
console.error(`Error: ${err}`)
}
},
async handleIncommingDm(event, peerPubkey) {
try {
const plainText = await NostrTools.nip04.decrypt(
this.account.privkey,
peerPubkey,
event.content
)
console.log('### plainText', plainText)
if (!isJson(plainText)) return
const jsonData = JSON.parse(plainText)
console.log('###### type: ', jsonData.type)
if ([0, 1, 2].indexOf(jsonData.type) !== -1) {
this.persistOrderUpdate(peerPubkey, event.created_at, jsonData)
}
if (jsonData.type === 1) {
this.handlePaymentRequest(jsonData)
} else if (jsonData.type === 2) {
this.handleOrderStatusUpdate(jsonData)
}
} catch (e) {
console.warn('Unable to handle incomming DM', e)
}
},
handlePaymentRequest(json) {
if (json.id && (json.id !== this.activeOrderId)) {
// not for active order, store somewehre else
return
}
if (!json.payment_options?.length) {
this.qrCodeDialog.data.message = json.message || 'Unexpected error'
return
}
const paymentRequest = json.payment_options.find(o => o.type == 'ln')
.link
if (!paymentRequest) return
this.qrCodeDialog.data.payment_request = paymentRequest
this.qrCodeDialog.dismissMsg = this.$q.notify({
timeout: 0,
message: 'Waiting for payment...'
})
},
handleOrderStatusUpdate(jsonData) {
if (jsonData.id && (jsonData.id !== this.activeOrderId)) {
// not for active order, store somewehre else
return
}
if (this.qrCodeDialog.dismissMsg) {
this.qrCodeDialog.dismissMsg()
}
this.qrCodeDialog.show = false
const message = jsonData.shipped ? 'Order shipped' : jsonData.paid ? 'Order paid' : 'Order notification'
this.$q.notify({
type: 'positive',
message: message,
caption: jsonData.message || ''
})
},
persistDMEvent(event) {
const dms = this.$q.localStorage.getItem(`nostrmarket.dm.${event.pubkey}`) || { events: [], lastCreatedAt: 0 }
const existingEvent = dms.events.find(e => e.id === event.id)
if (existingEvent) return
dms.events.push(event)
dms.events.sort((a, b) => a - b)
dms.lastCreatedAt = dms.events[dms.events.length - 1].created_at
console.log('### dms', dms)
this.$q.localStorage.set(`nostrmarket.dm.${event.pubkey}`, dms)
},
lastDmForPubkey(pubkey) {
const dms = this.$q.localStorage.getItem(`nostrmarket.dm.${pubkey}`)
if (!dms) return 0
return dms.lastCreatedAt
},
persistOrderUpdate(pubkey, eventCreatedAt, orderUpdate) {
console.log('### persistOrderUpdate', pubkey, eventCreatedAt, orderUpdate)
let orders = this.$q.localStorage.getItem(`nostrmarket.orders.${pubkey}`) || []
const orderIndex = orders.findIndex(o => o.id === orderUpdate.id)
if (orderIndex === -1) {
orders.unshift({
...orderUpdate,
eventCreatedAt,
createdAt: eventCreatedAt
})
this.orders[pubkey] = orders
this.orders = { ...this.orders }
this.$q.localStorage.set(`nostrmarket.orders.${pubkey}`, orders)
return
}
let order = orders[orderIndex]
if (orderUpdate.type === 0) {
order.createdAt = eventCreatedAt
order = { ...order, ...orderUpdate, message: order.message || orderUpdate.message }
} else {
order = order.eventCreatedAt < eventCreatedAt ? { ...order, ...orderUpdate } : { ...orderUpdate, ...order }
}
orders.splice(orderIndex, 1, order)
this.orders[pubkey] = orders
this.orders = { ...this.orders }
this.$q.localStorage.set(`nostrmarket.orders.${pubkey}`, orders)
},
showInvoiceQr(invoice) {
if (!invoice) return
this.qrCodeDialog = {
data: {
payment_request: invoice
},
dismissMsg: null,
show: true
}
},
toggleCategoryFilter(category) {
const index = this.filterCategories.indexOf(category)
if (index === -1) {
this.filterCategories.push(category)
} else {
this.filterCategories.splice(index, 1)
}
},
hasCategory(categories = []) {
if (!this.filterCategories?.length) return true
for (const cat of categories) {
if (this.filterCategories.indexOf(cat) !== -1) return true
}
return false
},
}
})
}
market()