diff --git a/static/components/chat-dialog/chat-dialog.html b/static/components/chat-dialog/chat-dialog.html new file mode 100644 index 0000000..a428044 --- /dev/null +++ b/static/components/chat-dialog/chat-dialog.html @@ -0,0 +1,68 @@ +
+ + + + + +
Chat Box
+ + + + + Close + +
+ + + + + + + + + + + + + + +
+
+
diff --git a/static/components/chat-dialog/chat-dialog.js b/static/components/chat-dialog/chat-dialog.js new file mode 100644 index 0000000..c111e27 --- /dev/null +++ b/static/components/chat-dialog/chat-dialog.js @@ -0,0 +1,157 @@ +async function chatDialog(path) { + const template = await loadTemplateAsync(path) + + Vue.component('chat-dialog', { + name: 'chat-dialog', + template, + + props: ['account', 'merchant', 'relays'], + data: function () { + return { + dialog: false, + loading: false, + pool: null, + nostrMessages: [], + newMessage: '' + } + }, + computed: { + sortedMessages() { + return this.nostrMessages.sort((a, b) => b.timestamp - a.timestamp) + } + }, + methods: { + async startDialog() { + this.dialog = true + await this.startPool() + }, + async closeDialog() { + this.dialog = false + await this.pool.close(Array.from(this.relays)) + }, + async startPool() { + this.loading = true + this.pool = new NostrTools.SimplePool() + let messagesMap = new Map() + let sub = this.pool.sub(Array.from(this.relays), [ + { + kinds: [4], + authors: [this.account.pubkey] + }, + { + kinds: [4], + '#p': [this.account.pubkey] + } + ]) + sub.on('eose', () => { + this.loading = false + this.nostrMessages = Array.from(messagesMap.values()) + }) + sub.on('event', async event => { + let mine = event.pubkey == this.account.pubkey + let sender = mine + ? event.tags.find(([k, v]) => k === 'p' && v && v !== '')[1] + : event.pubkey + + try { + let plaintext + if (this.account.privkey) { + plaintext = await NostrTools.nip04.decrypt( + this.account.privkey, + sender, + event.content + ) + } else if (this.account.useExtension && this.hasNip07) { + plaintext = await window.nostr.nip04.decrypt( + sender, + event.content + ) + } + messagesMap.set(event.id, { + msg: plaintext, + timestamp: timeFromNow(event.created_at * 1000), + sender: `${mine ? 'Me' : 'Merchant'}` + }) + } catch { + console.error('Unable to decrypt message!') + } + }) + setTimeout(() => { + this.nostrMessages = Array.from(messagesMap.values()) + this.loading = false + }, 5000) + }, + async sendMessage() { + if (this.newMessage && this.newMessage.length < 1) return + let event = { + ...(await NostrTools.getBlankEvent()), + kind: 4, + created_at: Math.floor(Date.now() / 1000), + tags: [['p', this.merchant]], + pubkey: this.account.pubkey, + content: await this.encryptMsg() + } + event.id = NostrTools.getEventHash(event) + event.sig = this.signEvent(event) + for (const url of Array.from(this.relays)) { + try { + let relay = NostrTools.relayInit(url) + relay.on('connect', () => { + console.debug(`connected to ${relay.url}`) + }) + relay.on('error', () => { + console.debug(`failed to connect to ${relay.url}`) + }) + + await relay.connect() + let pub = relay.publish(event) + pub.on('ok', () => { + console.debug(`${relay.url} has accepted our event`) + }) + pub.on('failed', reason => { + console.debug(`failed to publish to ${relay.url}: ${reason}`) + }) + this.newMessage = '' + } catch (e) { + console.error(e) + } + } + }, + async encryptMsg() { + try { + let cypher + if (this.account.privkey) { + cypher = await NostrTools.nip04.encrypt( + this.account.privkey, + this.merchant, + this.newMessage + ) + } else if (this.account.useExtension && this.hasNip07) { + cypher = await window.nostr.nip04.encrypt( + this.merchant, + this.newMessage + ) + } + return cypher + } catch (e) { + console.error(e) + } + }, + async signEvent(event) { + if (this.account.privkey) { + event.sig = await NostrTools.signEvent(event, this.account.privkey) + } else if (this.account.useExtension && this.hasNip07) { + event = await window.nostr.signEvent(event) + } + return event + } + }, + created() { + setTimeout(() => { + if (window.nostr) { + this.hasNip07 = true + } + }, 1000) + } + }) +} diff --git a/static/components/customer-market/customer-market.html b/static/components/customer-market/customer-market.html new file mode 100644 index 0000000..ce52d87 --- /dev/null +++ b/static/components/customer-market/customer-market.html @@ -0,0 +1,16 @@ +
+ + + + + +
+
+ +
+
+
diff --git a/static/components/customer-market/customer-market.js b/static/components/customer-market/customer-market.js new file mode 100644 index 0000000..45d5bea --- /dev/null +++ b/static/components/customer-market/customer-market.js @@ -0,0 +1,18 @@ +async function customerMarket(path) { + const template = await loadTemplateAsync(path) + Vue.component('customer-market', { + name: 'customer-market', + template, + + props: ['products', 'change-page'], + data: function () { + return {} + }, + methods: { + changePageM(page, opts) { + this.$emit('change-page', page, opts) + } + }, + created() {} + }) +} diff --git a/static/components/customer-stall/customer-stall.html b/static/components/customer-stall/customer-stall.html new file mode 100644 index 0000000..d6afaba --- /dev/null +++ b/static/components/customer-stall/customer-stall.html @@ -0,0 +1,244 @@ +
+ + + + + + + + + +
+ +
+ +
+
+
+
+ +
+
+ + + + + + + + + It seems you haven't logged in. You can: +
    +
  1. + enter your public and private keys bellow (to sign the order + message) +
  2. +
  3. use a Nostr Signer Extension (NIP07)
  4. +
  5. + fill out the required fields, without keys, and download the + order and send as a direct message to the merchant on any + Nostr client +
  6. +
+
+ + Use a Nostr browser extension + Download the order and send manually + +
+
+ + + + + + + + +

Select the shipping zone:

+
+ +
+
+ Total: {{ stall.currency != 'sat' ? getAmountFormated(finalCost, + stall.currency) : finalCost + 'sats' }} +
+
+ Download Order + Checkout + Cancel +
+
+
+
+ + + + + +
+ Copy invoice + Close +
+ + + +
+
+
diff --git a/static/components/customer-stall/customer-stall.js b/static/components/customer-stall/customer-stall.js new file mode 100644 index 0000000..b417c91 --- /dev/null +++ b/static/components/customer-stall/customer-stall.js @@ -0,0 +1,328 @@ +async function customerStall(path) { + const template = await loadTemplateAsync(path) + + Vue.component('customer-stall', { + name: 'customer-stall', + template, + + props: [ + 'account', + 'stall', + 'products', + 'product-detail', + 'change-page', + 'relays' + ], + data: function () { + return { + loading: false, + isPwd: true, + cart: { + total: 0, + size: 0, + products: new Map() + }, + cartMenu: [], + hasNip07: false, + customerPubkey: null, + customerPrivkey: null, + customerUseExtension: null, + activeOrder: null, + checkoutDialog: { + show: false, + data: { + pubkey: null + } + }, + qrCodeDialog: { + data: { + payment_request: null + }, + show: false + } + } + }, + computed: { + product() { + if (this.productDetail) { + return this.products.find(p => p.id == this.productDetail) + } + }, + finalCost() { + if (!this.checkoutDialog.data.shippingzone) return this.cart.total + + let zoneCost = this.stall.shipping.find( + z => z.id == this.checkoutDialog.data.shippingzone + ) + return +this.cart.total + zoneCost.cost + } + }, + methods: { + changePageS(page, opts) { + this.$emit('change-page', page, opts) + }, + getAmountFormated(amount, unit = 'USD') { + return LNbits.utils.formatCurrency(amount, unit) + }, + addToCart(item) { + console.log('add to cart', item) + let prod = this.cart.products + if (prod.has(item.id)) { + let qty = prod.get(item.id).quantity + prod.set(item.id, { + ...prod.get(item.id), + quantity: qty + 1 + }) + } else { + prod.set(item.id, { + name: item.name, + quantity: 1, + price: item.price, + image: item?.images[0] || null + }) + } + this.$q.notify({ + type: 'positive', + message: `${item.name} added to cart`, + icon: 'thumb_up' + }) + this.cart.products = prod + this.updateCart(+item.price) + }, + removeFromCart(item) { + this.cart.products.delete(item.id) + this.updateCart(+item.price, true) + }, + updateCart(price, del = false) { + console.log(this.cart, this.cartMenu) + if (del) { + this.cart.total -= price + this.cart.size-- + } else { + this.cart.total += price + this.cart.size++ + } + this.cartMenu = Array.from(this.cart.products, item => { + return {id: item[0], ...item[1]} + }) + console.log(this.cart, this.cartMenu) + }, + resetCart() { + this.cart = { + total: 0, + size: 0, + products: new Map() + } + }, + async downloadOrder() { + return + }, + async getFromExtension() { + this.customerPubkey = await window.nostr.getPublicKey() + this.customerUseExtension = true + this.checkoutDialog.data.pubkey = this.customerPubkey + }, + openCheckout() { + // Check if user is logged in + if (this.customerPubkey) { + this.checkoutDialog.data.pubkey = this.customerPubkey + if (this.customerPrivkey && !useExtension) { + this.checkoutDialog.data.privkey = this.customerPrivkey + } + } + this.checkoutDialog.show = true + }, + resetCheckout() { + this.checkoutDialog = { + show: false, + data: { + pubkey: null + } + } + }, + closeQrCodeDialog() { + this.qrCodeDialog.dismissMsg() + this.qrCodeDialog.show = false + }, + async placeOrder() { + this.loading = true + LNbits.utils + .confirmDialog( + `Send the order to the merchant? You should receive a message with the payment details.` + ) + .onOk(async () => { + let orderData = this.checkoutDialog.data + let orderObj = { + name: orderData?.username, + address: orderData.address, + message: orderData?.message, + contact: { + nostr: this.customerPubkey, + phone: null, + email: orderData?.email + }, + items: Array.from(this.cart.products, p => { + return {product_id: p[0], quantity: p[1].quantity} + }) + } + let created_at = Math.floor(Date.now() / 1000) + orderObj.id = await hash( + [this.customerPubkey, created_at, JSON.stringify(orderObj)].join( + ':' + ) + ) + this.activeOrder = orderObj.id + let event = { + ...(await NostrTools.getBlankEvent()), + kind: 4, + created_at, + tags: [['p', this.stall.pubkey]], + pubkey: this.customerPubkey + } + if (this.customerPrivkey) { + event.content = await NostrTools.nip04.encrypt( + this.customerPrivkey, + this.stall.pubkey, + JSON.stringify(orderObj) + ) + } else if (this.customerUseExtension && this.hasNip07) { + event.content = await window.nostr.nip04.encrypt( + this.stall.pubkey, + JSON.stringify(orderObj) + ) + let userRelays = Object.keys( + (await window.nostr?.getRelays?.()) || [] + ) + if (userRelays.length != 0) { + userRelays.map(r => this.relays.add(r)) + } + } + event.id = NostrTools.getEventHash(event) + if (this.customerPrivkey) { + event.sig = await NostrTools.signEvent( + event, + this.customerPrivkey + ) + } else if (this.customerUseExtension && this.hasNip07) { + event = await window.nostr.signEvent(event) + } + + await this.sendOrder(event) + }) + }, + async sendOrder(order) { + for (const url of Array.from(this.relays)) { + try { + let relay = NostrTools.relayInit(url) + relay.on('connect', () => { + console.log(`connected to ${relay.url}`) + }) + relay.on('error', () => { + console.log(`failed to connect to ${relay.url}`) + }) + + await relay.connect() + let pub = relay.publish(order) + pub.on('ok', () => { + console.log(`${relay.url} has accepted our event`) + relay.close() + }) + pub.on('failed', reason => { + console.log(`failed to publish to ${relay.url}: ${reason}`) + relay.close() + }) + } catch (err) { + console.error(`Error: ${err}`) + } + } + this.loading = false + this.resetCheckout() + this.resetCart() + this.qrCodeDialog.show = true + this.qrCodeDialog.dismissMsg = this.$q.notify({ + timeout: 0, + message: 'Waiting for invoice from merchant...' + }) + this.listenMessages() + }, + async listenMessages() { + console.log('LISTEN') + try { + const pool = new NostrTools.SimplePool() + const filters = [ + { + kinds: [4], + '#p': [this.customerPubkey] + } + ] + let relays = Array.from(this.relays) + let subs = pool.sub(relays, filters) + subs.on('event', async event => { + let mine = event.pubkey == this.customerPubkey + let sender = mine + ? event.tags.find(([k, v]) => k === 'p' && v && v !== '')[1] + : event.pubkey + + try { + let plaintext + if (this.customerPrivkey) { + plaintext = await NostrTools.nip04.decrypt( + this.customerPrivkey, + sender, + event.content + ) + } else if (this.customerUseExtension && this.hasNip07) { + plaintext = await window.nostr.nip04.decrypt( + sender, + event.content + ) + } + console.log(`${mine ? 'Me' : 'Merchant'}: ${plaintext}`) + + this.messageFilter(plaintext, cb => Promise.resolve(pool.close)) + } catch { + console.error('Unable to decrypt message!') + } + }) + } catch (err) { + console.error(`Error: ${err}`) + } + }, + messageFilter(text, cb = () => {}) { + if (!isJson(text)) return + let json = JSON.parse(text) + if (json.id != this.activeOrder) return + if (json?.payment_options) { + this.qrCodeDialog.data.payment_request = json.payment_options.find( + o => o.type == 'ln' + ).link + this.qrCodeDialog.dismissMsg = this.$q.notify({ + timeout: 0, + message: 'Waiting for payment...' + }) + } else if (json?.paid) { + this.closeQrCodeDialog() + this.$q.notify({ + type: 'positive', + message: 'Sats received, thanks!', + icon: 'thumb_up' + }) + this.activeOrder = null + Promise.resolve(cb()) + } else { + return + } + } + }, + created() { + this.customerPubkey = this.account?.pubkey + this.customerPrivkey = this.account?.privkey + this.customerUseExtension = this.account?.useExtension + setTimeout(() => { + if (window.nostr) { + this.hasNip07 = true + } + }, 1000) + } + }) +} diff --git a/static/components/product-card/product-card.html b/static/components/product-card/product-card.html new file mode 100644 index 0000000..95f86e2 --- /dev/null +++ b/static/components/product-card/product-card.html @@ -0,0 +1,90 @@ + + + + + Add to cart +
+
{{ product.name }}
+
+ + +
+ + +
+
{{ product.stallName }}
+ + {{ product.price }} satsBTC {{ (product.price / 1e8).toFixed(8) }} + + + {{ product.formatedPrice }} + + {{ product.quantity }} left +
+
+ {{cat}} +
+
+

{{ product.description }}

+
+
+ + + + + Stall: {{ product.stallName }} + {{ $parent.activeStall }} +
+ + View details + + + Visit Stall + +
+
+
diff --git a/static/components/product-card/product-card.js b/static/components/product-card/product-card.js new file mode 100644 index 0000000..5e049df --- /dev/null +++ b/static/components/product-card/product-card.js @@ -0,0 +1,14 @@ +async function productCard(path) { + const template = await loadTemplateAsync(path) + Vue.component('product-card', { + name: 'product-card', + template, + + props: ['product', 'change-page', 'add-to-cart', 'is-stall'], + data: function () { + return {} + }, + methods: {}, + created() {} + }) +} diff --git a/static/components/product-detail/product-detail.html b/static/components/product-detail/product-detail.html new file mode 100644 index 0000000..20f3d90 --- /dev/null +++ b/static/components/product-detail/product-detail.html @@ -0,0 +1,74 @@ +
+
+
+ + + + +
+
+
+
+
+
{{ product.name }}
+
+ {{cat}} +
+
{{ product.description }}
+
+ + {{ product.price }} satsBTC {{ (product.price / 1e8).toFixed(8) }} + + + {{ product.formatedPrice }} + + {{ product.quantity > 0 ? 'In stock.' : 'Out of stock.' }} +
+
+ + +
+
+ +
+
+
diff --git a/static/components/product-detail/product-detail.js b/static/components/product-detail/product-detail.js new file mode 100644 index 0000000..d55b653 --- /dev/null +++ b/static/components/product-detail/product-detail.js @@ -0,0 +1,17 @@ +async function productDetail(path) { + const template = await loadTemplateAsync(path) + Vue.component('product-detail', { + name: 'product-detail', + template, + + props: ['product', 'add-to-cart'], + data: function () { + return { + slide: 1 + } + }, + computed: {}, + methods: {}, + created() {} + }) +} diff --git a/static/components/shopping-cart/shopping-cart.html b/static/components/shopping-cart/shopping-cart.html new file mode 100644 index 0000000..2864cf3 --- /dev/null +++ b/static/components/shopping-cart/shopping-cart.html @@ -0,0 +1,51 @@ + + + {{ cart.size }} + + + + + + {{p.quantity}} x + + + + + + + + + {{ p.name }} + + + + + {{p.currency != 'sat' ? p.formatedPrice : p.price + 'sats'}} + + + + + + +
+ +
diff --git a/static/components/shopping-cart/shopping-cart.js b/static/components/shopping-cart/shopping-cart.js new file mode 100644 index 0000000..8f6902d --- /dev/null +++ b/static/components/shopping-cart/shopping-cart.js @@ -0,0 +1,16 @@ +async function shoppingCart(path) { + const template = await loadTemplateAsync(path) + + Vue.component('shopping-cart', { + name: 'shopping-cart', + template, + + props: ['cart', 'cart-menu', 'remove-from-cart', 'reset-cart'], + data: function () { + return {} + }, + computed: {}, + methods: {}, + created() {} + }) +} diff --git a/static/images/blank-avatar.webp b/static/images/blank-avatar.webp new file mode 100644 index 0000000..513b0f3 Binary files /dev/null and b/static/images/blank-avatar.webp differ diff --git a/static/js/market.js b/static/js/market.js new file mode 100644 index 0000000..52fed25 --- /dev/null +++ b/static/js/market.js @@ -0,0 +1,341 @@ +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' + ] + const eventToObj = event => { + event.content = JSON.parse(event.content) + return { + ...event, + ...Object.values(event.tags).reduce((acc, tag) => { + let [key, value] = tag + return {...acc, [key]: [...(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'), + chatDialog('static/components/chat-dialog/chat-dialog.html') + ]) + + new Vue({ + el: '#vue', + mixins: [windowMixin], + data: function () { + return { + account: null, + accountDialog: { + show: false, + data: { + watchOnly: false, + key: null + } + }, + drawer: false, + pubkeys: new Set(), + relays: new Set(), + events: [], + stalls: [], + products: [], + profiles: new Map(), + searchText: null, + inputPubkey: null, + inputRelay: null, + activePage: 'market', + activeStall: null, + activeProduct: null + } + }, + computed: { + filterProducts() { + let products = this.products + if (this.activeStall) { + products = products.filter(p => p.stall_id == this.activeStall) + } + const searchText = this.searchText.toLowerCase() + if (!searchText || searchText.length < 2) return products + return products.filter(p => { + return ( + p.name.toLowerCase().includes(searchText) || + p.description.toLowerCase().includes(searchText) || + p.categories.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 + }, + isValidKey() { + return this.accountDialog.data.key + ?.toLowerCase() + ?.match(/^[0-9a-f]{64}$/) + } + }, + async created() { + // Check for user stored + this.account = this.$q.localStorage.getItem('diagonAlley.account') || null + + // 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 (relays && relays.length) { + this.relays = new Set([...defaultRelays, ...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') + + // 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() + }, + methods: { + naddr() { + let naddr = NostrTools.nip19.naddrEncode({ + identifier: '1234', + pubkey: + 'c1415f950a1e3431de2bc5ee35144639e2f514cf158279abff9ed77d50118796', + kind: 30018, + relays: defaultRelays + }) + console.log(naddr) + }, + 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('diagonAlley.account') + this.account = null + }) + }, + async createAccount(useExtension = false) { + let nip07 + if (useExtension) { + await this.getFromExtension() + nip07 = true + } + if (this.isValidKey) { + let {key, watchOnly} = this.accountDialog.data + this.$q.localStorage.set('diagonAlley.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('diagonAlley.account') + } + }, + 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 + }, + async initNostr() { + this.$q.loading.show() + const pool = new NostrTools.SimplePool() + let relays = Array.from(this.relays) + let products = new Map() + let stalls = new Map() + // Get metadata and market data from the pubkeys + let sub = await pool + .list(relays, [ + { + kinds: [0, 30017, 30018], // for production kind is 30017 + authors: Array.from(this.pubkeys) + } + ]) + .then(events => { + console.log(events) + this.events = events || [] + this.events.map(eventToObj).map(e => { + if (e.kind == 0) { + this.profiles.set(e.pubkey, e.content) + return + } else if (e.kind == 30018) { + //it's a product `d` is the prod. id + products.set(e.d, {...e.content, id: e.d[0], categories: e.t}) + } else if (e.kind == 30017) { + // it's a stall `d` is the stall id + stalls.set(e.d, {...e.content, id: e.d[0], pubkey: e.pubkey}) + return + } + }) + }) + await Promise.resolve(sub) + 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) + obj.stallName = stall.name + obj.images = [obj.image] + if (obj.currency != 'sat') { + obj.formatedPrice = this.getAmountFormated(obj.price, obj.currency) + } + return obj + }) + this.$q.loading.hide() + pool.close(relays) + return + }, + navigateTo(page, opts = {stall: null, product: null, pubkey: null}) { + 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) + } + + switch (page) { + case 'stall': + if (stall) { + this.activeStall = stall + url.searchParams.set('stall_id', stall) + if (product) { + this.activeProduct = product + url.searchParams.set('product_id', product) + } + } + break + default: + this.activeStall = null + this.activeProduct = null + url.searchParams.delete('merchant_pubkey') + url.searchParams.delete('stall_id') + url.searchParams.delete('product_id') + break + } + + window.history.pushState({}, '', url) + this.activePage = page + }, + getAmountFormated(amount, unit = 'USD') { + return LNbits.utils.formatCurrency(amount, unit) + }, + async addPubkey(pubkey) { + console.log(pubkey, this.inputPubkey) + 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 + } + console.log(pubkey) + this.pubkeys.add(pubkey) + this.inputPubkey = null + } catch (err) { + console.error(err) + } + } else if (regExp.test(pubkey)) { + pubkey = pubkey + } + this.pubkeys.add(pubkey) + this.$q.localStorage.set( + `diagonAlley.merchants`, + Array.from(this.pubkeys) + ) + await 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) + ) + Promise.resolve(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 + await 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)) + } + } + }) +} + +market() diff --git a/static/js/utils.js b/static/js/utils.js index 83e886b..86c4d00 100644 --- a/static/js/utils.js +++ b/static/js/utils.js @@ -25,3 +25,79 @@ function imgSizeFit(img, maxWidth = 1024, maxHeight = 768) { ) return {width: img.naturalWidth * ratio, height: img.naturalHeight * ratio} } + +async function hash(string) { + const utf8 = new TextEncoder().encode(string) + const hashBuffer = await crypto.subtle.digest('SHA-256', utf8) + const hashArray = Array.from(new Uint8Array(hashBuffer)) + const hashHex = hashArray + .map(bytes => bytes.toString(16).padStart(2, '0')) + .join('') + return hashHex +} + +function isJson(str) { + if (typeof str !== 'string') { + return false + } + try { + JSON.parse(str) + return true + } catch (error) { + return false + } +} + +function timeFromNow(time) { + // Get timestamps + let unixTime = new Date(time).getTime() + if (!unixTime) return + let now = new Date().getTime() + + // Calculate difference + let difference = unixTime / 1000 - now / 1000 + + // Setup return object + let tfn = {} + + // Check if time is in the past, present, or future + tfn.when = 'now' + if (difference > 0) { + tfn.when = 'future' + } else if (difference < -1) { + tfn.when = 'past' + } + + // Convert difference to absolute + difference = Math.abs(difference) + + // Calculate time unit + if (difference / (60 * 60 * 24 * 365) > 1) { + // Years + tfn.unitOfTime = 'years' + tfn.time = Math.floor(difference / (60 * 60 * 24 * 365)) + } else if (difference / (60 * 60 * 24 * 45) > 1) { + // Months + tfn.unitOfTime = 'months' + tfn.time = Math.floor(difference / (60 * 60 * 24 * 45)) + } else if (difference / (60 * 60 * 24) > 1) { + // Days + tfn.unitOfTime = 'days' + tfn.time = Math.floor(difference / (60 * 60 * 24)) + } else if (difference / (60 * 60) > 1) { + // Hours + tfn.unitOfTime = 'hours' + tfn.time = Math.floor(difference / (60 * 60)) + } else if (difference / 60 > 1) { + // Minutes + tfn.unitOfTime = 'minutes' + tfn.time = Math.floor(difference / 60) + } else { + // Seconds + tfn.unitOfTime = 'seconds' + tfn.time = Math.floor(difference) + } + + // Return time from now data + return `${tfn.time} ${tfn.unitOfTime}` +} diff --git a/templates/nostrmarket/market.html b/templates/nostrmarket/market.html new file mode 100644 index 0000000..baa9715 --- /dev/null +++ b/templates/nostrmarket/market.html @@ -0,0 +1,289 @@ +{% extends "public.html" %} {% block page %} + + + + Settings + + +
+
+ + Delete account data +
+
+ Login or Create account +
+ +
+
+ + + + + + + + + + {%raw%} + + + + + + + + {{ profiles.get(pub).name }} + {{ `${pub.slice(0, 5)}...${pub.slice(-5)}` + }} + {{ pub }} + + + + + {%endraw%} + + + + + + + + + + + + + + {%raw%} + + {{ url }} + + + + + {%endraw%} + + + + + + +
+
+ +
+
+ + + {%raw%} + + {{ activePage }} + + {%endraw%} + + + + +
+
+ + +
+ + + + +
Account Setup
+ + +
+ +

Type your Nostr private key or generate a new one.

+ You can also use a Nostr-capable extension. +
+ + + + + + + + + + Is this a Public Key? + + If not using an Nostr capable extension, you'll have to sign + events manually! Better to use a Private Key that you can delete + later, or just generate an ephemeral key pair to use in the + Marketplace! + + + + + + + + + + +
+
+
+{% endblock %} {% block scripts %} + + + + + + + + + + + +{% endblock %} diff --git a/views.py b/views.py index ca8e1f7..3b757fb 100644 --- a/views.py +++ b/views.py @@ -1,7 +1,7 @@ import json from http import HTTPStatus -from fastapi import Depends, Request +from fastapi import Depends, Query, Request from fastapi.templating import Jinja2Templates from loguru import logger from starlette.responses import HTMLResponse @@ -20,3 +20,11 @@ async def index(request: Request, user: User = Depends(check_user_exists)): "nostrmarket/index.html", {"request": request, "user": user.dict()}, ) + + +@nostrmarket_ext.get("/market", response_class=HTMLResponse) +async def market(request: Request): + return nostrmarket_renderer().TemplateResponse( + "nostrmarket/market.html", + {"request": request}, + )