Merge pull request #13 from lnbits/checkout-login-flow

Ordering flow UI
This commit is contained in:
Vlad Stan 2023-03-13 16:17:20 +02:00 committed by GitHub
commit b10531763e
5 changed files with 259 additions and 88 deletions

View file

@ -43,7 +43,7 @@ async def m001_initial(db):
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
stall_id TEXT NOT NULL, stall_id TEXT NOT NULL,
name TEXT NOT NULL, name TEXT NOT NULL,
image TEXT DEFAULT, image TEXT,
price REAL NOT NULL, price REAL NOT NULL,
quantity INTEGER NOT NULL, quantity INTEGER NOT NULL,
category_list TEXT DEFAULT '[]', category_list TEXT DEFAULT '[]',

View file

@ -8,8 +8,14 @@
> >
<q-card> <q-card>
<q-bar> <q-bar>
<q-icon name="chat" /> <q-btn dense flat icon="chat" label="Chat" @click="isChat = true" />
<div>Chat Box</div> <q-btn
dense
flat
icon="receipt_long"
label="Orders"
@click="isChat = false"
/>
<q-space></q-space> <q-space></q-space>
@ -17,49 +23,64 @@
<q-tooltip content-class="bg-white text-primary">Close</q-tooltip> <q-tooltip content-class="bg-white text-primary">Close</q-tooltip>
</q-btn> </q-btn>
</q-bar> </q-bar>
<div v-if="isChat">
<q-card-section <q-card-section
style=" class="q-ml-auto"
height: calc(100vh - 120px); style="
overflow-y: scroll; width: 100%;
display: flex; max-width: 720px;
flex-direction: column-reverse; height: calc(100vh - 120px);
" overflow-y: scroll;
> display: flex;
<q-chat-message flex-direction: column-reverse;
:key="index" "
v-for="(message, index) in sortedMessages" >
:name="message.sender" <q-chat-message
:text="[message.msg]" :key="index"
:sent="message.sender == 'Me'" v-for="(message, index) in sortedMessages"
:bg-color="message.sender == 'Me' ? 'white' : 'light-green-2'" :name="message.sender"
:stamp="message.timestamp" :text="[message.msg]"
size="6" :sent="message.sender == 'Me'"
/> :bg-color="message.sender == 'Me' ? 'white' : 'light-green-2'"
</q-card-section> :stamp="message.timestamp"
<q-card-actions> size="6"
<q-form @submit="sendMessage" class="full-width chat-input"> />
<q-input </q-card-section>
ref="newMessage" <q-card-actions>
v-model="newMessage" <q-form @submit="sendMessage" class="full-width chat-input">
placeholder="Message" <q-input
class="full-width" ref="newMessage"
dense v-model="newMessage"
outlined placeholder="Message"
> class="full-width"
<template> dense
<q-btn outlined
round >
dense <template>
flat <q-btn
type="submit" round
icon="send" dense
color="primary" flat
/> type="submit"
</template> icon="send"
</q-input> color="primary"
</q-form> />
</q-card-actions> </template>
</q-input>
</q-form>
</q-card-actions>
</div>
<div v-else>
<q-card-section>
<q-table
title="Orders"
:data="ordersList"
:columns="ordersTable.columns"
:pagination.sync="ordersTable.pagination"
row-key="id"
/>
</q-card-section>
</div>
<q-inner-loading :showing="loading"> <q-inner-loading :showing="loading">
<q-spinner-cube size="50px" color="primary" /> <q-spinner-cube size="50px" color="primary" />
</q-inner-loading> </q-inner-loading>

View file

@ -9,15 +9,70 @@ async function chatDialog(path) {
data: function () { data: function () {
return { return {
dialog: false, dialog: false,
isChat: true,
loading: false, loading: false,
pool: null, pool: null,
nostrMessages: [], nostrMessages: [],
newMessage: '' newMessage: '',
ordersTable: {
columns: [
{
name: 'id',
align: 'left',
label: 'ID',
field: 'id'
},
{
name: 'created_at',
align: 'left',
label: 'Created/Updated',
field: 'created_at',
sortable: true
},
{
name: 'paid',
align: 'left',
label: 'Paid',
field: 'paid',
sortable: true
},
{
name: 'shipped',
align: 'left',
label: 'Shipped',
field: 'shipped',
sortable: true
},
{
name: 'invoice',
align: 'left',
label: 'Invoice',
field: row =>
row.payment_options &&
row.payment_options.find(p => p.type == 'ln').link
}
],
pagination: {
rowsPerPage: 10
}
}
} }
}, },
computed: { computed: {
sortedMessages() { sortedMessages() {
return this.nostrMessages.sort((a, b) => b.timestamp - a.timestamp) return this.nostrMessages.sort((a, b) => b.created_at - a.created_at)
},
ordersList() {
let orders = this.nostrMessages
.sort((a, b) => b.created_at - a.created_at)
.filter(o => isJson(o.msg))
.reduce((acc, cur) => {
const obj = JSON.parse(cur.msg)
const key = obj.id
const curGroup = acc[key] ?? {created_at: cur.timestamp}
return {...acc, [key]: {...curGroup, ...obj}}
}, {})
return Object.values(orders)
} }
}, },
methods: { methods: {
@ -49,9 +104,7 @@ async function chatDialog(path) {
}) })
sub.on('event', async event => { sub.on('event', async event => {
let mine = event.pubkey == this.account.pubkey let mine = event.pubkey == this.account.pubkey
let sender = mine let sender = mine ? this.merchant : event.pubkey
? event.tags.find(([k, v]) => k === 'p' && v && v !== '')[1]
: event.pubkey
try { try {
let plaintext let plaintext
@ -67,19 +120,19 @@ async function chatDialog(path) {
event.content event.content
) )
} }
messagesMap.set(event.id, { if (plaintext) {
msg: plaintext, messagesMap.set(event.id, {
timestamp: timeFromNow(event.created_at * 1000), created_at: event.created_at,
sender: `${mine ? 'Me' : 'Merchant'}` msg: plaintext,
}) timestamp: timeFromNow(event.created_at * 1000),
sender: `${mine ? 'Me' : 'Merchant'}`
})
this.nostrMessages = Array.from(messagesMap.values())
}
} catch { } catch {
console.error('Unable to decrypt message!') console.error('Unable to decrypt message!')
} }
}) })
setTimeout(() => {
this.nostrMessages = Array.from(messagesMap.values())
this.loading = false
}, 5000)
}, },
async sendMessage() { async sendMessage() {
if (this.newMessage && this.newMessage.length < 1) return if (this.newMessage && this.newMessage.length < 1) return

View file

@ -15,7 +15,7 @@
<q-toolbar-title></q-toolbar-title> <q-toolbar-title></q-toolbar-title>
<chat-dialog <chat-dialog
v-if="this.customerPrivkey || this.customerUseExtension" v-if="this.customerPrivkey || this.customerUseExtension"
:account="account" :account="account ? account : dropIn"
:merchant="stall.pubkey" :merchant="stall.pubkey"
:relays="relays" :relays="relays"
/> />
@ -80,13 +80,17 @@
</li> </li>
<li>use a Nostr Signer Extension (NIP07)</li> <li>use a Nostr Signer Extension (NIP07)</li>
<li> <li>
fill out the required fields, without keys, and download the generate a key pair to make the order (you should backup up
order and send as a direct message to the merchant on any your keys)
Nostr client </li>
<li>
fill out the required fields and with your public key,
download the order and send as a direct message to the
merchant on any Nostr client
</li> </li>
</ol> </ol>
</q-card-section> </q-card-section>
<q-card-actions v-if="hasNip07" align="right"> <q-card-actions align="right">
<q-btn <q-btn
v-if="hasNip07" v-if="hasNip07"
unelevated unelevated
@ -96,13 +100,12 @@
><q-tooltip>Use a Nostr browser extension</q-tooltip></q-btn ><q-tooltip>Use a Nostr browser extension</q-tooltip></q-btn
> >
<q-btn <q-btn
v-if="!this.account && !this.customerPubkey && !this.customerPrivkey"
unelevated unelevated
@click="downloadOrder" @click="generateKeyPair"
color="primary" color="primary"
label="Download order" label="Generate Keys"
><q-tooltip ><q-tooltip>Generate a new key pair</q-tooltip></q-btn
>Download the order and send manually</q-tooltip
></q-btn
> >
</q-card-actions> </q-card-actions>
</q-card> </q-card>
@ -123,7 +126,8 @@
:type="isPwd ? 'password' : 'text'" :type="isPwd ? 'password' : 'text'"
v-if="!customerUseExtension" v-if="!customerUseExtension"
v-model.trim="checkoutDialog.data.privkey" v-model.trim="checkoutDialog.data.privkey"
hint="Enter your private key or see bellow for instructions" label="Private key *optional"
hint="Enter your private key"
> >
<template v-slot:append> <template v-slot:append>
<q-icon <q-icon
@ -169,12 +173,13 @@
</div> </div>
<div class="row q-mt-lg"> <div class="row q-mt-lg">
<q-btn <q-btn
v-if="!checkoutDialog.data.pubkey && !checkoutDialog.data.privkey" v-if="!customerUseExtension && !checkoutDialog.data.privkey"
:loading="loading" :loading="loading"
unelevated unelevated
color="primary" color="primary"
:disable="checkoutDialog.data.address == null :disable="checkoutDialog.data.address == null
|| checkoutDialog.data.shippingzone == null" || checkoutDialog.data.shippingzone == null
|| checkoutDialog.data.pubkey == null"
@click="downloadOrder" @click="downloadOrder"
>Download Order</q-btn >Download Order</q-btn
> >
@ -213,12 +218,16 @@
<a :href="'lightning:' + qrCodeDialog.data.payment_request"> <a :href="'lightning:' + qrCodeDialog.data.payment_request">
<q-responsive :ratio="1" class="q-mx-xl"> <q-responsive :ratio="1" class="q-mx-xl">
<qrcode <qrcode
v-if="qrCodeDialog.data.payment_request"
:value="qrCodeDialog.data.payment_request" :value="qrCodeDialog.data.payment_request"
:options="{width: 340}" :options="{width: 340}"
class="rounded-borders" class="rounded-borders"
></qrcode> ></qrcode>
</q-responsive> </q-responsive>
</a> </a>
<q-inner-loading :showing="loading">
<q-spinner-cube size="50px" color="primary" />
</q-inner-loading>
</div> </div>
<div class="row q-mt-lg"> <div class="row q-mt-lg">
<q-btn <q-btn
@ -236,9 +245,43 @@
>Close</q-btn >Close</q-btn
> >
</div> </div>
<q-inner-loading :showing="loading"> </q-card>
<q-spinner-cube size="50px" color="primary" /> </q-dialog>
</q-inner-loading> <!-- ORDER DOWNLOAD DIALOG -->
<q-dialog v-model="downloadOrderDialog.show" position="top">
<q-card class="q-pa-lg q-pt-xl lnbits__dialog-card">
<q-card-section>
<div class="text-h6">Order data</div>
<div class="text-subtitle1">Merchant pubkey</div>
<div class="text-subtitle2" @click="copyText(stall.pubkey)">
{{ `${stall.pubkey.slice(0,5)}...${stall.pubkey.slice(-5)}` }}
<sup>
<q-icon name="content_copy" style="color: #ccc; font-size: 1.2em" />
</sup>
<q-tooltip>Click to copy</q-tooltip>
</div>
<p>
Send the bellow code as a message, to the merchant pubkey, in any
Nostr client
</p>
</q-card-section>
<q-separator dark inset />
<q-card-section>
<pre
style="font-size: 0.65rem; overflow-x: auto"
><code>{{JSON.stringify(downloadOrderDialog.data, null, 2)}}</code></pre>
</q-card-section>
<div class="row q-mt-lg">
<q-btn
outline
color="primary"
@click="copyText(JSON.stringify(downloadOrderDialog.data))"
>Copy order</q-btn
>
<q-btn v-close-popup flat color="grey" class="q-ml-auto">Close</q-btn>
</div>
</q-card> </q-card>
</q-dialog> </q-dialog>
</div> </div>

View file

@ -39,6 +39,10 @@ async function customerStall(path) {
payment_request: null payment_request: null
}, },
show: false show: false
},
downloadOrderDialog: {
show: false,
data: {}
} }
} }
}, },
@ -55,12 +59,28 @@ async function customerStall(path) {
z => z.id == this.checkoutDialog.data.shippingzone z => z.id == this.checkoutDialog.data.shippingzone
) )
return +this.cart.total + zoneCost.cost return +this.cart.total + zoneCost.cost
},
dropIn() {
return {
privkey: this.customerPrivkey,
pubkey: this.customerPubkey,
useExtension: this.customerUseExtension
}
} }
}, },
methods: { methods: {
changePageS(page, opts) { changePageS(page, opts) {
this.$emit('change-page', page, opts) this.$emit('change-page', page, opts)
}, },
copyText: function (text) {
var notify = this.$q.notify
Quasar.utils.copyToClipboard(text).then(function () {
notify({
message: 'Copied to clipboard!',
position: 'bottom'
})
})
},
getAmountFormated(amount, unit = 'USD') { getAmountFormated(amount, unit = 'USD') {
return LNbits.utils.formatCurrency(amount, unit) return LNbits.utils.formatCurrency(amount, unit)
}, },
@ -115,18 +135,46 @@ async function customerStall(path) {
} }
}, },
async downloadOrder() { async downloadOrder() {
return let created_at = Math.floor(Date.now() / 1000)
let orderData = this.checkoutDialog.data
let orderObj = {
name: orderData?.username,
address: orderData.address,
message: orderData?.message,
contact: {
nostr: orderData.pubkey,
phone: null,
email: orderData?.email
},
items: Array.from(this.cart.products, p => {
return {product_id: p[0], quantity: p[1].quantity}
})
}
orderObj.id = await hash(
[orderData.pubkey, created_at, JSON.stringify(orderObj)].join(':')
)
this.downloadOrderDialog.data = orderObj
this.downloadOrderDialog.show = true
this.resetCheckout()
this.resetCart()
}, },
async getFromExtension() { async getFromExtension() {
this.customerPubkey = await window.nostr.getPublicKey() this.customerPubkey = await window.nostr.getPublicKey()
this.customerUseExtension = true this.customerUseExtension = true
this.checkoutDialog.data.pubkey = this.customerPubkey this.checkoutDialog.data.pubkey = this.customerPubkey
}, },
async generateKeyPair() {
this.customerPrivkey = NostrTools.generatePrivateKey()
this.customerPubkey = NostrTools.getPublicKey(this.customerPrivkey)
this.customerUseExtension = false
this.checkoutDialog.data.pubkey = this.customerPubkey
this.checkoutDialog.data.privkey = this.customerPrivkey
},
openCheckout() { openCheckout() {
// Check if user is logged in // Check if user is logged in
if (this.customerPubkey) { if (this.customerPubkey) {
this.checkoutDialog.data.pubkey = this.customerPubkey this.checkoutDialog.data.pubkey = this.customerPubkey
if (this.customerPrivkey && !useExtension) { if (this.customerPrivkey && !this.useExtension) {
this.checkoutDialog.data.privkey = this.customerPrivkey this.checkoutDialog.data.privkey = this.customerPrivkey
} }
} }
@ -219,6 +267,8 @@ async function customerStall(path) {
}) })
relay.on('error', () => { relay.on('error', () => {
console.log(`failed to connect to ${relay.url}`) console.log(`failed to connect to ${relay.url}`)
relay.close()
return
}) })
await relay.connect() await relay.connect()
@ -239,17 +289,20 @@ async function customerStall(path) {
this.resetCheckout() this.resetCheckout()
this.resetCart() this.resetCart()
this.qrCodeDialog.show = true this.qrCodeDialog.show = true
this.qrCodeDialog.dismissMsg = this.$q.notify({ this.$q.notify({
timeout: 0,
message: 'Waiting for invoice from merchant...' message: 'Waiting for invoice from merchant...'
}) })
this.listenMessages() this.listenMessages()
}, },
async listenMessages() { async listenMessages() {
console.log('LISTEN') this.loading = true
try { try {
const pool = new NostrTools.SimplePool() const pool = new NostrTools.SimplePool()
const filters = [ const filters = [
{
kinds: [4],
authors: [this.stall.pubkey]
},
{ {
kinds: [4], kinds: [4],
'#p': [this.customerPubkey] '#p': [this.customerPubkey]
@ -262,7 +315,6 @@ async function customerStall(path) {
let sender = mine let sender = mine
? event.tags.find(([k, v]) => k === 'p' && v && v !== '')[1] ? event.tags.find(([k, v]) => k === 'p' && v && v !== '')[1]
: event.pubkey : event.pubkey
try { try {
let plaintext let plaintext
if (this.customerPrivkey) { if (this.customerPrivkey) {
@ -277,7 +329,7 @@ async function customerStall(path) {
event.content event.content
) )
} }
console.log(`${mine ? 'Me' : 'Merchant'}: ${plaintext}`) //console.log(`${mine ? 'Me' : 'Merchant'}: ${plaintext}`)
this.messageFilter(plaintext, cb => Promise.resolve(pool.close)) this.messageFilter(plaintext, cb => Promise.resolve(pool.close))
} catch { } catch {
@ -292,15 +344,17 @@ async function customerStall(path) {
if (!isJson(text)) return if (!isJson(text)) return
let json = JSON.parse(text) let json = JSON.parse(text)
if (json.id != this.activeOrder) return if (json.id != this.activeOrder) return
if (json?.payment_options) { if (json.payment_options) {
this.qrCodeDialog.data.payment_request = json.payment_options.find( let payment_request = json.payment_options.find(o => o.type == 'ln')
o => o.type == 'ln' .link
).link if (!payment_request) return
this.loading = false
this.qrCodeDialog.data.payment_request = payment_request
this.qrCodeDialog.dismissMsg = this.$q.notify({ this.qrCodeDialog.dismissMsg = this.$q.notify({
timeout: 0, timeout: 0,
message: 'Waiting for payment...' message: 'Waiting for payment...'
}) })
} else if (json?.paid) { } else if (json.paid) {
this.closeQrCodeDialog() this.closeQrCodeDialog()
this.$q.notify({ this.$q.notify({
type: 'positive', type: 'positive',