commit
cb1c6446aa
17 changed files with 1808 additions and 1 deletions
68
static/components/chat-dialog/chat-dialog.html
Normal file
68
static/components/chat-dialog/chat-dialog.html
Normal file
|
|
@ -0,0 +1,68 @@
|
||||||
|
<div class="q-mr-md">
|
||||||
|
<q-btn dense round flat icon="chat" @click="startDialog" />
|
||||||
|
<q-dialog
|
||||||
|
v-model="dialog"
|
||||||
|
maximized
|
||||||
|
transition-show="slide-up"
|
||||||
|
transition-hide="slide-down"
|
||||||
|
>
|
||||||
|
<q-card>
|
||||||
|
<q-bar>
|
||||||
|
<q-icon name="chat" />
|
||||||
|
<div>Chat Box</div>
|
||||||
|
|
||||||
|
<q-space></q-space>
|
||||||
|
|
||||||
|
<q-btn dense flat icon="close" @click="closeDialog">
|
||||||
|
<q-tooltip content-class="bg-white text-primary">Close</q-tooltip>
|
||||||
|
</q-btn>
|
||||||
|
</q-bar>
|
||||||
|
|
||||||
|
<q-card-section
|
||||||
|
style="
|
||||||
|
height: calc(100vh - 120px);
|
||||||
|
overflow-y: scroll;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column-reverse;
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<q-chat-message
|
||||||
|
:key="index"
|
||||||
|
v-for="(message, index) in sortedMessages"
|
||||||
|
:name="message.sender"
|
||||||
|
:text="[message.msg]"
|
||||||
|
:sent="message.sender == 'Me'"
|
||||||
|
:bg-color="message.sender == 'Me' ? 'white' : 'light-green-2'"
|
||||||
|
:stamp="message.timestamp"
|
||||||
|
size="6"
|
||||||
|
/>
|
||||||
|
</q-card-section>
|
||||||
|
<q-card-actions>
|
||||||
|
<q-form @submit="sendMessage" class="full-width chat-input">
|
||||||
|
<q-input
|
||||||
|
ref="newMessage"
|
||||||
|
v-model="newMessage"
|
||||||
|
placeholder="Message"
|
||||||
|
class="full-width"
|
||||||
|
dense
|
||||||
|
outlined
|
||||||
|
>
|
||||||
|
<template>
|
||||||
|
<q-btn
|
||||||
|
round
|
||||||
|
dense
|
||||||
|
flat
|
||||||
|
type="submit"
|
||||||
|
icon="send"
|
||||||
|
color="primary"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</q-input>
|
||||||
|
</q-form>
|
||||||
|
</q-card-actions>
|
||||||
|
<q-inner-loading :showing="loading">
|
||||||
|
<q-spinner-cube size="50px" color="primary" />
|
||||||
|
</q-inner-loading>
|
||||||
|
</q-card>
|
||||||
|
</q-dialog>
|
||||||
|
</div>
|
||||||
157
static/components/chat-dialog/chat-dialog.js
Normal file
157
static/components/chat-dialog/chat-dialog.js
Normal file
|
|
@ -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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
16
static/components/customer-market/customer-market.html
Normal file
16
static/components/customer-market/customer-market.html
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
<div>
|
||||||
|
<q-toolbar>
|
||||||
|
<q-breadcrumbs>
|
||||||
|
<q-breadcrumbs-el label="Market" icon="home"></q-breadcrumbs-el>
|
||||||
|
</q-breadcrumbs>
|
||||||
|
</q-toolbar>
|
||||||
|
<div class="row q-col-gutter-md">
|
||||||
|
<div
|
||||||
|
class="col-xs-12 col-sm-6 col-md-4 col-lg-3"
|
||||||
|
v-for="(item, idx) in products"
|
||||||
|
:key="idx"
|
||||||
|
>
|
||||||
|
<product-card :product="item" @change-page="changePageM"></product-card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
18
static/components/customer-market/customer-market.js
Normal file
18
static/components/customer-market/customer-market.js
Normal file
|
|
@ -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() {}
|
||||||
|
})
|
||||||
|
}
|
||||||
244
static/components/customer-stall/customer-stall.html
Normal file
244
static/components/customer-stall/customer-stall.html
Normal file
|
|
@ -0,0 +1,244 @@
|
||||||
|
<div>
|
||||||
|
<q-toolbar>
|
||||||
|
<q-breadcrumbs class="cursor">
|
||||||
|
<q-breadcrumbs-el
|
||||||
|
label="Market"
|
||||||
|
icon="home"
|
||||||
|
@click="$emit('change-page', 'market')"
|
||||||
|
style="cursor: pointer"
|
||||||
|
></q-breadcrumbs-el>
|
||||||
|
<q-breadcrumbs-el
|
||||||
|
:label="stall?.name || 'Stall'"
|
||||||
|
icon="widgets"
|
||||||
|
></q-breadcrumbs-el>
|
||||||
|
</q-breadcrumbs>
|
||||||
|
<q-toolbar-title></q-toolbar-title>
|
||||||
|
<chat-dialog
|
||||||
|
v-if="this.customerPrivkey || this.customerUseExtension"
|
||||||
|
:account="account"
|
||||||
|
:merchant="stall.pubkey"
|
||||||
|
:relays="relays"
|
||||||
|
/>
|
||||||
|
<shopping-cart
|
||||||
|
:cart="cart"
|
||||||
|
:cart-menu="cartMenu"
|
||||||
|
@remove-from-cart="removeFromCart"
|
||||||
|
@reset-cart="resetCart"
|
||||||
|
@open-checkout="openCheckout"
|
||||||
|
></shopping-cart>
|
||||||
|
</q-toolbar>
|
||||||
|
<div class="row">
|
||||||
|
<product-detail
|
||||||
|
class="col-12"
|
||||||
|
v-if="productDetail && product"
|
||||||
|
:product="product"
|
||||||
|
@add-to-cart="addToCart"
|
||||||
|
></product-detail>
|
||||||
|
<div class="col-12 q-my-lg">
|
||||||
|
<q-separator></q-separator>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="row q-col-gutter-md">
|
||||||
|
<div
|
||||||
|
class="col-xs-12 col-sm-6 col-md-4 col-lg-3"
|
||||||
|
v-for="(item, idx) in products"
|
||||||
|
:key="idx"
|
||||||
|
>
|
||||||
|
<product-card
|
||||||
|
:product="item"
|
||||||
|
@change-page="changePageS"
|
||||||
|
@add-to-cart="addToCart"
|
||||||
|
:is-stall="true"
|
||||||
|
></product-card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- BEGIN CHECKOUT DIALOG -->
|
||||||
|
<q-dialog v-model="checkoutDialog.show" position="top">
|
||||||
|
<q-card class="q-pa-lg q-pt-xl lnbits__dialog-card">
|
||||||
|
<q-form @submit="placeOrder" class="q-gutter-md">
|
||||||
|
<q-input
|
||||||
|
filled
|
||||||
|
dense
|
||||||
|
v-model.trim="checkoutDialog.data.username"
|
||||||
|
label="Name *optional"
|
||||||
|
></q-input>
|
||||||
|
<q-expansion-item
|
||||||
|
v-if="!account"
|
||||||
|
dense
|
||||||
|
dense-toggle
|
||||||
|
expand-separator
|
||||||
|
icon="person_off"
|
||||||
|
label="Not logged in?"
|
||||||
|
>
|
||||||
|
<q-card>
|
||||||
|
<q-card-section>
|
||||||
|
It seems you haven't logged in. You can:
|
||||||
|
<ol>
|
||||||
|
<li>
|
||||||
|
enter your public and private keys bellow (to sign the order
|
||||||
|
message)
|
||||||
|
</li>
|
||||||
|
<li>use a Nostr Signer Extension (NIP07)</li>
|
||||||
|
<li>
|
||||||
|
fill out the required fields, without keys, and download the
|
||||||
|
order and send as a direct message to the merchant on any
|
||||||
|
Nostr client
|
||||||
|
</li>
|
||||||
|
</ol>
|
||||||
|
</q-card-section>
|
||||||
|
<q-card-actions v-if="hasNip07" align="right">
|
||||||
|
<q-btn
|
||||||
|
v-if="hasNip07"
|
||||||
|
unelevated
|
||||||
|
@click="getFromExtension"
|
||||||
|
color="primary"
|
||||||
|
label="Get from Extension"
|
||||||
|
><q-tooltip>Use a Nostr browser extension</q-tooltip></q-btn
|
||||||
|
>
|
||||||
|
<q-btn
|
||||||
|
unelevated
|
||||||
|
@click="downloadOrder"
|
||||||
|
color="primary"
|
||||||
|
label="Download order"
|
||||||
|
><q-tooltip
|
||||||
|
>Download the order and send manually</q-tooltip
|
||||||
|
></q-btn
|
||||||
|
>
|
||||||
|
</q-card-actions>
|
||||||
|
</q-card>
|
||||||
|
</q-expansion-item>
|
||||||
|
<q-input
|
||||||
|
filled
|
||||||
|
dense
|
||||||
|
:readonly="Boolean(customerPubkey)"
|
||||||
|
v-model.trim="checkoutDialog.data.pubkey"
|
||||||
|
label="Public key"
|
||||||
|
hint="Enter your public key"
|
||||||
|
>
|
||||||
|
</q-input>
|
||||||
|
<q-input
|
||||||
|
filled
|
||||||
|
dense
|
||||||
|
:readonly="Boolean(customerPrivkey)"
|
||||||
|
:type="isPwd ? 'password' : 'text'"
|
||||||
|
v-if="!customerUseExtension"
|
||||||
|
v-model.trim="checkoutDialog.data.privkey"
|
||||||
|
hint="Enter your private key or see bellow for instructions"
|
||||||
|
>
|
||||||
|
<template v-slot:append>
|
||||||
|
<q-icon
|
||||||
|
:name="isPwd ? 'visibility_off' : 'visibility'"
|
||||||
|
class="cursor-pointer"
|
||||||
|
@click="isPwd = !isPwd"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</q-input>
|
||||||
|
<q-input
|
||||||
|
filled
|
||||||
|
dense
|
||||||
|
v-model.trim="checkoutDialog.data.address"
|
||||||
|
label="Address"
|
||||||
|
></q-input>
|
||||||
|
<q-input
|
||||||
|
v-model="checkoutDialog.data.email"
|
||||||
|
filled
|
||||||
|
dense
|
||||||
|
type="email"
|
||||||
|
label="Email *optional"
|
||||||
|
hint="Merchant may not use email"
|
||||||
|
></q-input>
|
||||||
|
<q-input
|
||||||
|
v-model="checkoutDialog.data.message"
|
||||||
|
filled
|
||||||
|
dense
|
||||||
|
type="text"
|
||||||
|
label="Message *optional"
|
||||||
|
></q-input>
|
||||||
|
<p>Select the shipping zone:</p>
|
||||||
|
<div class="row q-mt-lg">
|
||||||
|
<q-option-group
|
||||||
|
:options="stall.shipping.map(s => ({label: s.countries.toString(), value: s.id}))"
|
||||||
|
type="radio"
|
||||||
|
emit-value
|
||||||
|
v-model="checkoutDialog.data.shippingzone"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="row q-mt-lg">
|
||||||
|
Total: {{ stall.currency != 'sat' ? getAmountFormated(finalCost,
|
||||||
|
stall.currency) : finalCost + 'sats' }}
|
||||||
|
</div>
|
||||||
|
<div class="row q-mt-lg">
|
||||||
|
<q-btn
|
||||||
|
v-if="!checkoutDialog.data.pubkey && !checkoutDialog.data.privkey"
|
||||||
|
:loading="loading"
|
||||||
|
unelevated
|
||||||
|
color="primary"
|
||||||
|
:disable="checkoutDialog.data.address == null
|
||||||
|
|| checkoutDialog.data.shippingzone == null"
|
||||||
|
@click="downloadOrder"
|
||||||
|
>Download Order</q-btn
|
||||||
|
>
|
||||||
|
<q-btn
|
||||||
|
v-else
|
||||||
|
:loading="loading"
|
||||||
|
unelevated
|
||||||
|
color="primary"
|
||||||
|
:disable="checkoutDialog.data.address == null
|
||||||
|
|| checkoutDialog.data.shippingzone == null
|
||||||
|
|| checkoutDialog.data.pubkey == null"
|
||||||
|
type="submit"
|
||||||
|
>Checkout</q-btn
|
||||||
|
>
|
||||||
|
<q-btn
|
||||||
|
v-close-popup
|
||||||
|
flat
|
||||||
|
@click="checkoutDialog = {show: false, data: {pubkey: null}}"
|
||||||
|
color="grey"
|
||||||
|
class="q-ml-auto"
|
||||||
|
>Cancel</q-btn
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</q-form>
|
||||||
|
</q-card>
|
||||||
|
</q-dialog>
|
||||||
|
<!-- END CHECKOUT DIALOG -->
|
||||||
|
<!-- INVOICE DIALOG -->
|
||||||
|
<q-dialog
|
||||||
|
v-model="qrCodeDialog.show"
|
||||||
|
position="top"
|
||||||
|
@hide="closeQrCodeDialog"
|
||||||
|
>
|
||||||
|
<q-card class="q-pa-lg q-pt-xl lnbits__dialog-card">
|
||||||
|
<div class="text-center q-mb-lg">
|
||||||
|
<a :href="'lightning:' + qrCodeDialog.data.payment_request">
|
||||||
|
<q-responsive :ratio="1" class="q-mx-xl">
|
||||||
|
<qrcode
|
||||||
|
:value="qrCodeDialog.data.payment_request"
|
||||||
|
:options="{width: 340}"
|
||||||
|
class="rounded-borders"
|
||||||
|
></qrcode>
|
||||||
|
</q-responsive>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<div class="row q-mt-lg">
|
||||||
|
<q-btn
|
||||||
|
outline
|
||||||
|
color="grey"
|
||||||
|
@click="copyText(qrCodeDialog.data.payment_request)"
|
||||||
|
>Copy invoice</q-btn
|
||||||
|
>
|
||||||
|
<q-btn
|
||||||
|
@click="closeQrCodeDialog"
|
||||||
|
v-close-popup
|
||||||
|
flat
|
||||||
|
color="grey"
|
||||||
|
class="q-ml-auto"
|
||||||
|
>Close</q-btn
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
<q-inner-loading :showing="loading">
|
||||||
|
<q-spinner-cube size="50px" color="primary" />
|
||||||
|
</q-inner-loading>
|
||||||
|
</q-card>
|
||||||
|
</q-dialog>
|
||||||
|
</div>
|
||||||
328
static/components/customer-stall/customer-stall.js
Normal file
328
static/components/customer-stall/customer-stall.js
Normal file
|
|
@ -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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
90
static/components/product-card/product-card.html
Normal file
90
static/components/product-card/product-card.html
Normal file
|
|
@ -0,0 +1,90 @@
|
||||||
|
<q-card class="card--product">
|
||||||
|
<q-img
|
||||||
|
:src="product.image ? product.image : '/nostrmarket/static/images/placeholder.png'"
|
||||||
|
alt="Product Image"
|
||||||
|
loading="lazy"
|
||||||
|
spinner-color="white"
|
||||||
|
fit="contain"
|
||||||
|
height="300px"
|
||||||
|
></q-img>
|
||||||
|
|
||||||
|
<q-card-section class="q-pb-xs q-pt-md">
|
||||||
|
<q-btn
|
||||||
|
v-if="isStall"
|
||||||
|
round
|
||||||
|
:disabled="product.quantity < 1"
|
||||||
|
color="primary"
|
||||||
|
icon="shopping_cart"
|
||||||
|
size="lg"
|
||||||
|
style="
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
right: 0;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
"
|
||||||
|
@click="$emit('add-to-cart', product)"
|
||||||
|
><q-tooltip> Add to cart </q-tooltip></q-btn
|
||||||
|
>
|
||||||
|
<div class="row no-wrap items-center">
|
||||||
|
<div class="col text-subtitle2 ellipsis-2-lines">{{ product.name }}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- <q-rating v-model="stars" color="orange" :max="5" readonly size="17px"></q-rating> -->
|
||||||
|
</q-card-section>
|
||||||
|
|
||||||
|
<q-card-section class="q-py-sm">
|
||||||
|
<div>
|
||||||
|
<div class="text-caption text-weight-bolder">{{ product.stallName }}</div>
|
||||||
|
<span v-if="product.currency == 'sat'">
|
||||||
|
<span class="text-h6">{{ product.price }} sats</span
|
||||||
|
><span class="q-ml-sm text-grey-6"
|
||||||
|
>BTC {{ (product.price / 1e8).toFixed(8) }}</span
|
||||||
|
>
|
||||||
|
</span>
|
||||||
|
<span v-else>
|
||||||
|
<span class="text-h6">{{ product.formatedPrice }}</span>
|
||||||
|
</span>
|
||||||
|
<span class="q-ml-md text-caption text-green-8 text-weight-bolder q-mt-md"
|
||||||
|
>{{ product.quantity }} left</span
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
<div v-if="product.categories" class="text-subtitle1">
|
||||||
|
<q-chip v-for="(cat, i) in product.categories" :key="i" dense
|
||||||
|
>{{cat}}</q-chip
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="text-caption text-grey ellipsis-2-lines"
|
||||||
|
style="min-height: 40px"
|
||||||
|
>
|
||||||
|
<p v-if="product.description">{{ product.description }}</p>
|
||||||
|
</div>
|
||||||
|
</q-card-section>
|
||||||
|
|
||||||
|
<q-separator></q-separator>
|
||||||
|
|
||||||
|
<q-card-actions>
|
||||||
|
<span>Stall: {{ product.stallName }}</span>
|
||||||
|
<span>{{ $parent.activeStall }}</span>
|
||||||
|
<div class="q-ml-auto">
|
||||||
|
<q-btn
|
||||||
|
flat
|
||||||
|
class="text-weight-bold text-capitalize q-ml-auto"
|
||||||
|
dense
|
||||||
|
color="primary"
|
||||||
|
@click="$emit('change-page', 'stall', {stall: product.stall_id, product: product.id})"
|
||||||
|
>
|
||||||
|
View details
|
||||||
|
</q-btn>
|
||||||
|
<q-btn
|
||||||
|
flat
|
||||||
|
class="text-weight-bold text-capitalize q-ml-auto"
|
||||||
|
dense
|
||||||
|
color="primary"
|
||||||
|
@click="$emit('change-page', 'stall', {stall: product.stall_id})"
|
||||||
|
>
|
||||||
|
Visit Stall
|
||||||
|
</q-btn>
|
||||||
|
</div>
|
||||||
|
</q-card-actions>
|
||||||
|
</q-card>
|
||||||
14
static/components/product-card/product-card.js
Normal file
14
static/components/product-card/product-card.js
Normal file
|
|
@ -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() {}
|
||||||
|
})
|
||||||
|
}
|
||||||
74
static/components/product-detail/product-detail.html
Normal file
74
static/components/product-detail/product-detail.html
Normal file
|
|
@ -0,0 +1,74 @@
|
||||||
|
<div class="row q-mt-sm">
|
||||||
|
<div class="col-lg-5 col-md-5 col-sm-12 col-xs-12">
|
||||||
|
<div class="q-pa-md">
|
||||||
|
<q-carousel
|
||||||
|
v-if="product.images"
|
||||||
|
swipeable
|
||||||
|
animated
|
||||||
|
v-model="slide"
|
||||||
|
thumbnails
|
||||||
|
infinite
|
||||||
|
v-for="(img, i) in product.images"
|
||||||
|
:key="i"
|
||||||
|
>
|
||||||
|
<q-carousel-slide
|
||||||
|
:name="i + 1"
|
||||||
|
:img-src="img"
|
||||||
|
style="/*background-size: contain; background-repeat: no-repeat*/"
|
||||||
|
></q-carousel-slide>
|
||||||
|
</q-carousel>
|
||||||
|
<q-img
|
||||||
|
v-if="!product.images"
|
||||||
|
src="/nostrmarket/static/images/placeholder.png"
|
||||||
|
:ratio="16/9"
|
||||||
|
></q-img>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-lg-7 col-md-7 col-sm-12 col-xs-12">
|
||||||
|
<div class="row">
|
||||||
|
<div
|
||||||
|
class="col-lg-7 col-md-7 col-sm-12 col-xs-12"
|
||||||
|
:class="$q.platform.is.desktop ? '' : 'q-px-md'"
|
||||||
|
>
|
||||||
|
<div class="text-subtitle1 q-mt-sm q-pt-xs">{{ product.name }}</div>
|
||||||
|
<div v-if="product.categories" class="text-subtitle1">
|
||||||
|
<q-chip v-for="(cat, i) in product.categories" :key="i" dense
|
||||||
|
>{{cat}}</q-chip
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
<div class="q-mt-sm text-weight-bold">{{ product.description }}</div>
|
||||||
|
<div>
|
||||||
|
<span v-if="product.currency == 'sat'">
|
||||||
|
<span class="text-h6">{{ product.price }} sats</span
|
||||||
|
><span class="q-ml-sm text-grey-6"
|
||||||
|
>BTC {{ (product.price / 1e8).toFixed(8) }}</span
|
||||||
|
>
|
||||||
|
</span>
|
||||||
|
<span v-else>
|
||||||
|
<span class="text-h6">{{ product.formatedPrice }}</span>
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
class="q-ml-md text-caption text-green-8 text-weight-bolder q-mt-md"
|
||||||
|
>{{ product.quantity > 0 ? 'In stock.' : 'Out of stock.' }}</span
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
<div class="q-mt-md">
|
||||||
|
<q-btn
|
||||||
|
class="q-mt-md"
|
||||||
|
color="primary"
|
||||||
|
icon="shopping_cart"
|
||||||
|
label="Add to cart"
|
||||||
|
@click="$emit('add-to-cart', product)"
|
||||||
|
/>
|
||||||
|
<q-btn
|
||||||
|
class="q-mt-md q-ml-md"
|
||||||
|
color="primary"
|
||||||
|
icon="share"
|
||||||
|
label="Share"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- RATING TO BE DONE -->
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
17
static/components/product-detail/product-detail.js
Normal file
17
static/components/product-detail/product-detail.js
Normal file
|
|
@ -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() {}
|
||||||
|
})
|
||||||
|
}
|
||||||
51
static/components/shopping-cart/shopping-cart.html
Normal file
51
static/components/shopping-cart/shopping-cart.html
Normal file
|
|
@ -0,0 +1,51 @@
|
||||||
|
<q-btn dense round flat icon="shopping_cart">
|
||||||
|
<q-badge v-if="cart.size" color="red" class="text-bold" floating>
|
||||||
|
{{ cart.size }}
|
||||||
|
</q-badge>
|
||||||
|
<q-menu v-if="cart.size">
|
||||||
|
<q-list style="min-width: 100px">
|
||||||
|
<q-item :key="p.id" v-for="p in cartMenu">
|
||||||
|
<q-item-section side>
|
||||||
|
<span>{{p.quantity}} x </span>
|
||||||
|
</q-item-section>
|
||||||
|
<q-item-section avatar v-if="p.image">
|
||||||
|
<q-avatar color="primary">
|
||||||
|
<img size="sm" :src="p.image" />
|
||||||
|
</q-avatar>
|
||||||
|
</q-item-section>
|
||||||
|
|
||||||
|
<q-item-section>
|
||||||
|
<q-item-label>{{ p.name }}</q-item-label>
|
||||||
|
</q-item-section>
|
||||||
|
|
||||||
|
<q-item-section side>
|
||||||
|
<span>
|
||||||
|
{{p.currency != 'sat' ? p.formatedPrice : p.price + 'sats'}}
|
||||||
|
<q-btn
|
||||||
|
class="q-ml-md"
|
||||||
|
round
|
||||||
|
color="red"
|
||||||
|
size="xs"
|
||||||
|
icon="close"
|
||||||
|
@click="$emit('remove-from-cart', p)"
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
</q-item-section>
|
||||||
|
</q-item>
|
||||||
|
<q-separator />
|
||||||
|
</q-list>
|
||||||
|
<div class="row q-pa-md q-gutter-md">
|
||||||
|
<q-btn
|
||||||
|
color="primary"
|
||||||
|
icon-right="checkout"
|
||||||
|
label="Checkout"
|
||||||
|
@click="$emit('open-checkout')"
|
||||||
|
></q-btn>
|
||||||
|
<q-btn
|
||||||
|
class="q-ml-lg"
|
||||||
|
flat
|
||||||
|
color="primary"
|
||||||
|
label="Reset"
|
||||||
|
@click="$emit('reset-cart')"
|
||||||
|
></q-btn></div></q-menu
|
||||||
|
></q-btn>
|
||||||
16
static/components/shopping-cart/shopping-cart.js
Normal file
16
static/components/shopping-cart/shopping-cart.js
Normal file
|
|
@ -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() {}
|
||||||
|
})
|
||||||
|
}
|
||||||
BIN
static/images/blank-avatar.webp
Normal file
BIN
static/images/blank-avatar.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 11 KiB |
341
static/js/market.js
Normal file
341
static/js/market.js
Normal file
|
|
@ -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()
|
||||||
|
|
@ -25,3 +25,79 @@ function imgSizeFit(img, maxWidth = 1024, maxHeight = 768) {
|
||||||
)
|
)
|
||||||
return {width: img.naturalWidth * ratio, height: img.naturalHeight * ratio}
|
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}`
|
||||||
|
}
|
||||||
|
|
|
||||||
289
templates/nostrmarket/market.html
Normal file
289
templates/nostrmarket/market.html
Normal file
|
|
@ -0,0 +1,289 @@
|
||||||
|
{% extends "public.html" %} {% block page %}
|
||||||
|
<q-layout view="hHh Lpr lff">
|
||||||
|
<q-drawer v-model="drawer" side="left">
|
||||||
|
<q-toolbar class="bg-primary text-white shadow-2">
|
||||||
|
<q-toolbar-title>Settings</q-toolbar-title>
|
||||||
|
<q-btn flat round dense icon="close" @click="drawer = !drawer"></q-btn>
|
||||||
|
</q-toolbar>
|
||||||
|
<div>
|
||||||
|
<div v-if="account" class="bg-transparent q-ma-md">
|
||||||
|
<!-- <q-avatar size="56px" class="q-mb-sm">
|
||||||
|
<img src="https://cdn.quasar.dev/img/boy-avatar.png" />
|
||||||
|
</q-avatar>
|
||||||
|
<div class="text-weight-bold">Razvan Stoenescu</div>
|
||||||
|
<div>@rstoenescu</div> -->
|
||||||
|
<q-btn
|
||||||
|
label="Delete data"
|
||||||
|
class="q-mt-md"
|
||||||
|
color="primary"
|
||||||
|
@click="deleteAccount"
|
||||||
|
><q-tooltip>Delete account data</q-tooltip></q-btn
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
<div v-else class="q-pa-md">
|
||||||
|
<q-btn
|
||||||
|
label="Login"
|
||||||
|
class="q-mt-md"
|
||||||
|
color="primary"
|
||||||
|
@click="accountDialog.show = true"
|
||||||
|
><q-tooltip>Login or Create account</q-tooltip></q-btn
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
<q-separator></q-separator>
|
||||||
|
</div>
|
||||||
|
<div class="q-pa-md">
|
||||||
|
<q-list padding>
|
||||||
|
<q-expansion-item
|
||||||
|
expand-separator
|
||||||
|
icon="perm_identity"
|
||||||
|
label="Merchants"
|
||||||
|
caption="Add/Remove pubkeys"
|
||||||
|
>
|
||||||
|
<q-card>
|
||||||
|
<q-card-section>
|
||||||
|
<q-input
|
||||||
|
filled
|
||||||
|
v-model="inputPubkey"
|
||||||
|
@keydown.enter="addPubkey(null)"
|
||||||
|
type="text"
|
||||||
|
label="Pubkey/Npub"
|
||||||
|
hint="Add merchants"
|
||||||
|
>
|
||||||
|
<q-btn @click="addPubkey(null)" dense flat icon="add"></q-btn>
|
||||||
|
</q-input>
|
||||||
|
<q-list class="q-mt-md">
|
||||||
|
<q-item v-for="pub in Array.from(pubkeys)" :key="pub">
|
||||||
|
{%raw%}
|
||||||
|
<q-item-section avatar>
|
||||||
|
<q-avatar>
|
||||||
|
<img
|
||||||
|
v-if="profiles.get(pub) && profiles.get(pub)?.picture"
|
||||||
|
:src="profiles.get(pub).picture"
|
||||||
|
/>
|
||||||
|
<img
|
||||||
|
v-else
|
||||||
|
src="/nostrmarket/static/images/blank-avatar.webp"
|
||||||
|
/>
|
||||||
|
</q-avatar>
|
||||||
|
</q-item-section>
|
||||||
|
<q-item-section>
|
||||||
|
<q-item-label
|
||||||
|
v-if="profiles.get(pub) && profiles.get(pub)?.name"
|
||||||
|
>{{ profiles.get(pub).name }}</q-item-label
|
||||||
|
>
|
||||||
|
<q-item-label v-else
|
||||||
|
>{{ `${pub.slice(0, 5)}...${pub.slice(-5)}`
|
||||||
|
}}</q-item-label
|
||||||
|
>
|
||||||
|
<q-tooltip>{{ pub }}</q-tooltip>
|
||||||
|
</q-item-section>
|
||||||
|
<q-item-section side>
|
||||||
|
<q-btn
|
||||||
|
class="gt-xs"
|
||||||
|
size="12px"
|
||||||
|
flat
|
||||||
|
dense
|
||||||
|
round
|
||||||
|
icon="delete"
|
||||||
|
@click="removePubkey(pub)"
|
||||||
|
/>
|
||||||
|
</q-item-section>
|
||||||
|
{%endraw%}
|
||||||
|
</q-item>
|
||||||
|
</q-list>
|
||||||
|
</q-card-section>
|
||||||
|
</q-card>
|
||||||
|
</q-expansion-item>
|
||||||
|
<q-expansion-item
|
||||||
|
expand-separator
|
||||||
|
icon="perm_identity"
|
||||||
|
label="Relays"
|
||||||
|
caption="Add/Remove relays"
|
||||||
|
>
|
||||||
|
<q-card>
|
||||||
|
<q-card-section>
|
||||||
|
<q-input
|
||||||
|
filled
|
||||||
|
v-model="inputRelay"
|
||||||
|
@keydown.enter="addRelay"
|
||||||
|
type="text"
|
||||||
|
label="Relay URL"
|
||||||
|
hint="Add relays"
|
||||||
|
>
|
||||||
|
<q-btn @click="addRelay" dense flat icon="add"></q-btn>
|
||||||
|
</q-input>
|
||||||
|
<q-list dense class="q-mt-md">
|
||||||
|
<q-item v-for="url in Array.from(relays)" :key="url">
|
||||||
|
{%raw%}
|
||||||
|
<q-item-section>
|
||||||
|
<q-item-label>{{ url }}</q-item-label>
|
||||||
|
</q-item-section>
|
||||||
|
<q-item-section side>
|
||||||
|
<q-btn
|
||||||
|
class="gt-xs"
|
||||||
|
size="12px"
|
||||||
|
flat
|
||||||
|
dense
|
||||||
|
round
|
||||||
|
icon="delete"
|
||||||
|
@click="removeRelay(url)"
|
||||||
|
/>
|
||||||
|
</q-item-section>
|
||||||
|
{%endraw%}
|
||||||
|
</q-item>
|
||||||
|
</q-list>
|
||||||
|
</q-card-section>
|
||||||
|
</q-card>
|
||||||
|
</q-expansion-item>
|
||||||
|
</q-list>
|
||||||
|
</div>
|
||||||
|
</q-drawer>
|
||||||
|
<q-page-container>
|
||||||
|
<div class="row q-mb-md">
|
||||||
|
<div class="col-12 q-gutter-y-md">
|
||||||
|
<q-toolbar>
|
||||||
|
<q-btn flat round dense icon="menu" @click="drawer = !drawer"></q-btn>
|
||||||
|
{%raw%}
|
||||||
|
<q-toolbar-title style="text-transform: capitalize">
|
||||||
|
{{ activePage }}
|
||||||
|
</q-toolbar-title>
|
||||||
|
{%endraw%}
|
||||||
|
<q-input
|
||||||
|
class="float-left q-ml-md"
|
||||||
|
standout
|
||||||
|
square
|
||||||
|
dense
|
||||||
|
outlined
|
||||||
|
clearable
|
||||||
|
v-model.trim="searchText"
|
||||||
|
label="Search for products"
|
||||||
|
>
|
||||||
|
<template v-slot:append>
|
||||||
|
<q-icon v-if="!searchText" name="search" />
|
||||||
|
</template>
|
||||||
|
</q-input>
|
||||||
|
</q-toolbar>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<customer-stall
|
||||||
|
v-if="!isLoading && activeStall"
|
||||||
|
:stall="stalls.find(stall => stall.id == activeStall)"
|
||||||
|
:products="filterProducts"
|
||||||
|
:product-detail="activeProduct"
|
||||||
|
:relays="relays"
|
||||||
|
:account="account"
|
||||||
|
@change-page="navigateTo"
|
||||||
|
></customer-stall>
|
||||||
|
<customer-market
|
||||||
|
v-else
|
||||||
|
:products="filterProducts"
|
||||||
|
@change-page="navigateTo"
|
||||||
|
></customer-market>
|
||||||
|
</q-page-container>
|
||||||
|
<!-- ACCOUNT DIALOG -->
|
||||||
|
<q-dialog v-model="accountDialog.show" persistent>
|
||||||
|
<q-card style="min-width: 350px">
|
||||||
|
<q-card-section class="row items-center q-pb-none">
|
||||||
|
<div class="text-h6">Account Setup</div>
|
||||||
|
<q-space></q-space>
|
||||||
|
<q-btn icon="close" flat round dense v-close-popup></q-btn>
|
||||||
|
</q-card-section>
|
||||||
|
<q-card-section>
|
||||||
|
<p>Type your Nostr private key or generate a new one.</p>
|
||||||
|
<small> You can also use a Nostr-capable extension. </small>
|
||||||
|
</q-card-section>
|
||||||
|
|
||||||
|
<q-card-section class="q-pt-none">
|
||||||
|
<q-input
|
||||||
|
dense
|
||||||
|
label="Private key (hex)"
|
||||||
|
v-model="accountDialog.data.key"
|
||||||
|
autofocus
|
||||||
|
@keyup.enter="createAccount"
|
||||||
|
:error="!isValidKey"
|
||||||
|
></q-input>
|
||||||
|
|
||||||
|
<q-item tag="label">
|
||||||
|
<q-item-section avatar top>
|
||||||
|
<q-checkbox v-model="accountDialog.data.watchOnly"></q-checkbox>
|
||||||
|
</q-item-section>
|
||||||
|
<q-item-section>
|
||||||
|
<q-item-label>Is this a Public Key?</q-item-label>
|
||||||
|
<q-item-label caption>
|
||||||
|
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!
|
||||||
|
</q-item-label>
|
||||||
|
</q-item-section>
|
||||||
|
</q-item>
|
||||||
|
</q-card-section>
|
||||||
|
|
||||||
|
<q-card-actions align="right" class="text-primary">
|
||||||
|
<q-btn
|
||||||
|
v-if="hasExtension"
|
||||||
|
flat
|
||||||
|
label="Use Public Key from Extension"
|
||||||
|
@click="() => createAccount(true)"
|
||||||
|
></q-btn>
|
||||||
|
<q-btn
|
||||||
|
v-if="isValidKey"
|
||||||
|
label="Add key"
|
||||||
|
color="primary"
|
||||||
|
@click="() => createAccount()"
|
||||||
|
></q-btn>
|
||||||
|
<q-btn v-else flat label="Generate" @click="generateKeyPair"></q-btn>
|
||||||
|
</q-card-actions>
|
||||||
|
</q-card>
|
||||||
|
</q-dialog>
|
||||||
|
</q-layout>
|
||||||
|
{% endblock %} {% block scripts %}
|
||||||
|
<script src="https://unpkg.com/nostr-tools/lib/nostr.bundle.js"></script>
|
||||||
|
|
||||||
|
<script src="{{ url_for('nostrmarket_static', path='js/utils.js') }}"></script>
|
||||||
|
<script src="{{ url_for('nostrmarket_static', path='components/product-card/product-card.js') }}"></script>
|
||||||
|
<script src="{{ url_for('nostrmarket_static', path='components/customer-market/customer-market.js') }}"></script>
|
||||||
|
<script src="{{ url_for('nostrmarket_static', path='components/customer-stall/customer-stall.js') }}"></script>
|
||||||
|
<script src="{{ url_for('nostrmarket_static', path='components/product-detail/product-detail.js') }}"></script>
|
||||||
|
<script src="{{ url_for('nostrmarket_static', path='components/shopping-cart/shopping-cart.js') }}"></script>
|
||||||
|
<script src="{{ url_for('nostrmarket_static', path='components/chat-dialog/chat-dialog.js') }}"></script>
|
||||||
|
<script src="{{ url_for('nostrmarket_static', path='js/market.js') }}"></script>
|
||||||
|
<style scoped>
|
||||||
|
.q-field__native span {
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-container {
|
||||||
|
position: relative;
|
||||||
|
display: grid;
|
||||||
|
grid-template-rows: 1fr auto;
|
||||||
|
/*height: calc(100vh - 200px);*/
|
||||||
|
height: 70vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-box {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column-reverse;
|
||||||
|
padding: 1rem;
|
||||||
|
overflow-y: auto;
|
||||||
|
margin-left: auto;
|
||||||
|
width: 50%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-messages {
|
||||||
|
width: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-other {
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-input {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
align-items: end;
|
||||||
|
margin-top: 1rem;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
{% endblock %}
|
||||||
10
views.py
10
views.py
|
|
@ -1,7 +1,7 @@
|
||||||
import json
|
import json
|
||||||
from http import HTTPStatus
|
from http import HTTPStatus
|
||||||
|
|
||||||
from fastapi import Depends, Request
|
from fastapi import Depends, Query, Request
|
||||||
from fastapi.templating import Jinja2Templates
|
from fastapi.templating import Jinja2Templates
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
from starlette.responses import HTMLResponse
|
from starlette.responses import HTMLResponse
|
||||||
|
|
@ -20,3 +20,11 @@ async def index(request: Request, user: User = Depends(check_user_exists)):
|
||||||
"nostrmarket/index.html",
|
"nostrmarket/index.html",
|
||||||
{"request": request, "user": user.dict()},
|
{"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},
|
||||||
|
)
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue