Merge pull request #6 from lnbits/customer-stallUI

Customer stall UI
This commit is contained in:
Vlad Stan 2023-03-09 12:10:35 +02:00 committed by GitHub
commit cb1c6446aa
17 changed files with 1808 additions and 1 deletions

View 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>

View 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)
}
})
}

View 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>

View 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() {}
})
}

View 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>

View 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)
}
})
}

View 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>

View 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() {}
})
}

View 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>

View 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() {}
})
}

View 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>

View 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() {}
})
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

341
static/js/market.js Normal file
View 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()

View file

@ -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}`
}

View 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 %}

View file

@ -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},
)