Extract market place (#78)
* chore: remove marketplace components * feat: add static marketplace * feat: add entry point for static marketplace * doc: add comment * chore: include nostr-bundle.js
|
|
@ -1,95 +0,0 @@
|
|||
<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-btn dense flat icon="chat" label="Chat" @click="isChat = true" />
|
||||
<q-btn
|
||||
dense
|
||||
flat
|
||||
icon="receipt_long"
|
||||
label="Orders"
|
||||
@click="isChat = false"
|
||||
/>
|
||||
|
||||
<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>
|
||||
<div v-if="isChat">
|
||||
<q-card-section
|
||||
class="q-ml-auto"
|
||||
style="
|
||||
width: 100%;
|
||||
max-width: 720px;
|
||||
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.json ? 'purple-2' : message.sender == 'Me' ? 'white' : 'light-green-2'"
|
||||
:stamp="message.timestamp"
|
||||
size="6"
|
||||
><template v-slot:avatar v-if="message.json">
|
||||
<q-icon
|
||||
color="secondary"
|
||||
name="smart_toy"
|
||||
class="q-message-avatar q-message-avatar--received"
|
||||
/> </template
|
||||
></q-chat-message>
|
||||
</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>
|
||||
</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-spinner-comment size="50px" color="primary" />
|
||||
</q-inner-loading>
|
||||
</q-card>
|
||||
</q-dialog>
|
||||
</div>
|
||||
|
|
@ -1,224 +0,0 @@
|
|||
async function chatDialog(path) {
|
||||
const template = await loadTemplateAsync(path)
|
||||
|
||||
Vue.component('chat-dialog', {
|
||||
name: 'chat-dialog',
|
||||
template,
|
||||
|
||||
props: ['account', 'merchant', 'relays', 'pool'],
|
||||
data: function () {
|
||||
return {
|
||||
dialog: false,
|
||||
isChat: true,
|
||||
loading: false,
|
||||
messagesMap: new Map(),
|
||||
nostrMessages: [],
|
||||
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: {
|
||||
sortedMessages() {
|
||||
return this.nostrMessages.sort((a, b) => b.created_at - a.created_at)
|
||||
},
|
||||
ordersList() {
|
||||
let orders = this.nostrMessages
|
||||
.filter(o => o.json)
|
||||
.sort((a, b) => b.created_at - a.created_at)
|
||||
.reduce((acc, cur) => {
|
||||
const obj = cur.json
|
||||
const key = obj.id
|
||||
const curGroup = acc[key] ?? {created_at: cur.timestamp}
|
||||
return {...acc, [key]: {...curGroup, ...obj}}
|
||||
}, {})
|
||||
|
||||
return Object.values(orders)
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
async startDialog() {
|
||||
this.dialog = true
|
||||
await this.startPool()
|
||||
},
|
||||
async closeDialog() {
|
||||
this.dialog = false
|
||||
await this.sub.unsub()
|
||||
},
|
||||
async startPool() {
|
||||
this.loading = true
|
||||
let relays = Array.from(this.relays)
|
||||
let filters = [
|
||||
{
|
||||
kinds: [4],
|
||||
authors: [this.account.pubkey]
|
||||
},
|
||||
{
|
||||
kinds: [4],
|
||||
'#p': [this.account.pubkey]
|
||||
}
|
||||
]
|
||||
let events = await this.pool.list(relays, filters)
|
||||
|
||||
for (const event of events) {
|
||||
await this.processMessage(event)
|
||||
}
|
||||
|
||||
this.nostrMessages = Array.from(this.messagesMap.values())
|
||||
this.loading = false
|
||||
|
||||
let sub = this.pool.sub(
|
||||
relays,
|
||||
filters.map(f => ({...f, since: Date.now() / 1000}))
|
||||
)
|
||||
sub.on('event', async event => {
|
||||
await this.processMessage(event)
|
||||
this.nostrMessages = Array.from(this.messagesMap.values())
|
||||
})
|
||||
this.sub = sub
|
||||
},
|
||||
async processMessage(event) {
|
||||
let mine = event.pubkey == this.account.pubkey
|
||||
let sender = mine ? this.merchant : 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)
|
||||
}
|
||||
if (plaintext) {
|
||||
let {text, json} = this.filterJsonMsg(plaintext)
|
||||
this.messagesMap.set(event.id, {
|
||||
created_at: event.created_at,
|
||||
msg: text,
|
||||
timestamp: timeFromNow(event.created_at * 1000),
|
||||
sender: `${mine ? 'Me' : 'Merchant'}`,
|
||||
json
|
||||
})
|
||||
}
|
||||
return null
|
||||
} catch {
|
||||
console.debug('Unable to decrypt message! Not for us...')
|
||||
return null
|
||||
}
|
||||
},
|
||||
filterJsonMsg(text) {
|
||||
let json = null
|
||||
|
||||
if (!isJson(text)) return {text, json}
|
||||
|
||||
json = JSON.parse(text)
|
||||
if (json.message) {
|
||||
text = json.message
|
||||
} else if (json.items) {
|
||||
text = `Order placed!<br />OrderID: ${json.id}`
|
||||
} else if (json.payment_options) {
|
||||
text = `Invoice for order: ${json.id} <br /><a href="lightning:${
|
||||
json.payment_options.find(o => o.type == 'ln')?.link
|
||||
}" target="_blank" class="text-secondary">LN ⚡︎</a>`
|
||||
}
|
||||
|
||||
return {text, json}
|
||||
},
|
||||
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)
|
||||
|
||||
let pub = this.pool.publish(Array.from(this.relays), event)
|
||||
pub.on('ok', () => console.debug(`Event was sent`))
|
||||
pub.on('failed', error => console.error(error))
|
||||
this.newMessage = ''
|
||||
},
|
||||
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)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
|
@ -1,17 +0,0 @@
|
|||
<div>
|
||||
|
||||
|
||||
<q-infinite-scroll v-if="showProducts" @load="onLoad" :offset="250">
|
||||
<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 partialProducts" :key="idx">
|
||||
<product-card :product="item" @change-page="changePageM" @add-to-cart="addToCart"></product-card>
|
||||
</div>
|
||||
</div>
|
||||
<template v-if="lastProductIndex < filteredProducts.length" v-slot:loading>
|
||||
<div class="row justify-center q-my-md">
|
||||
<q-spinner-dots color="primary" size="40px" />
|
||||
</div>
|
||||
</template>
|
||||
</q-infinite-scroll>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -1,66 +0,0 @@
|
|||
async function customerMarket(path) {
|
||||
const template = await loadTemplateAsync(path)
|
||||
Vue.component('customer-market', {
|
||||
name: 'customer-market',
|
||||
template,
|
||||
|
||||
props: ['filtered-products', 'search-text', 'filter-categories'],
|
||||
data: function () {
|
||||
return {
|
||||
search: null,
|
||||
partialProducts: [],
|
||||
productsPerPage: 12,
|
||||
startIndex: 0,
|
||||
lastProductIndex: 0,
|
||||
showProducts: true,
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
searchText: function () {
|
||||
this.refreshProducts()
|
||||
},
|
||||
filteredProducts: function () {
|
||||
this.refreshProducts()
|
||||
},
|
||||
filterCategories: function () {
|
||||
this.refreshProducts()
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
refreshProducts: function () {
|
||||
this.showProducts = false
|
||||
this.partialProducts = []
|
||||
|
||||
this.startIndex = 0
|
||||
this.lastProductIndex = Math.min(this.filteredProducts.length, this.productsPerPage)
|
||||
this.partialProducts.push(...this.filteredProducts.slice(0, this.lastProductIndex))
|
||||
|
||||
setTimeout(() => { this.showProducts = true }, 0)
|
||||
},
|
||||
|
||||
addToCart(item) {
|
||||
this.$emit('add-to-cart', item)
|
||||
},
|
||||
changePageM(page, opts) {
|
||||
this.$emit('change-page', page, opts)
|
||||
},
|
||||
|
||||
onLoad(_, done) {
|
||||
setTimeout(() => {
|
||||
if (this.startIndex >= this.filteredProducts.length) {
|
||||
done()
|
||||
return
|
||||
}
|
||||
this.startIndex = this.lastProductIndex
|
||||
this.lastProductIndex = Math.min(this.filteredProducts.length, this.lastProductIndex + this.productsPerPage)
|
||||
this.partialProducts.push(...this.filteredProducts.slice(this.startIndex, this.lastProductIndex))
|
||||
done()
|
||||
}, 100)
|
||||
}
|
||||
},
|
||||
created() {
|
||||
this.lastProductIndex = Math.min(this.filteredProducts.length, 24)
|
||||
this.partialProducts.push(...this.filteredProducts.slice(0, this.lastProductIndex))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
|
@ -1,145 +0,0 @@
|
|||
<div>
|
||||
<q-card v-if="!merchantOrders?.length" bordered class="q-mb-md">
|
||||
<q-card-section>
|
||||
<strong>No orders!</strong>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
<div v-for="merchant in merchantOrders">
|
||||
<q-card bordered class="q-mb-md">
|
||||
<q-item>
|
||||
<q-item-section avatar>
|
||||
<q-avatar>
|
||||
<img v-if="merchant.profile?.picture" :src="merchant.profile?.picture">
|
||||
<img v-else src="/nostrmarket/static/images/blank-avatar.webp">
|
||||
</q-avatar>
|
||||
</q-item-section>
|
||||
|
||||
<q-item-section>
|
||||
<q-item-label>
|
||||
<strong>
|
||||
<span v-text="merchant.profile?.name"></span>
|
||||
</strong>
|
||||
</q-item-label>
|
||||
<q-item-label caption>
|
||||
<span v-text="merchant.pubkey"></span>
|
||||
</q-item-label>
|
||||
</q-item-section>
|
||||
|
||||
</q-item>
|
||||
|
||||
<q-separator />
|
||||
|
||||
|
||||
<q-card-section class="col-12">
|
||||
<q-list>
|
||||
<div v-for="order in merchant.orders" :key="order.id" class="q-mb-md">
|
||||
<q-expansion-item dense expand-separator>
|
||||
|
||||
<template v-slot:header>
|
||||
<q-item-section class="q-mt-sm">
|
||||
<q-item-label><strong> <span v-text="order.stallName"></span> </strong>
|
||||
<q-badge @click="showInvoice(order)" v-if="order.invoice?.human_readable_part?.amount"
|
||||
color="orange" class="q-ml-lg">
|
||||
<span v-text="formatCurrency(order.invoice?.human_readable_part?.amount / 1000, 'sat')"></span>
|
||||
</q-badge></q-item-label>
|
||||
|
||||
<q-item-label>
|
||||
<div class="text-caption text-grey ellipsis-2-lines">
|
||||
<p v-if="order.createdAt"><span v-text="fromNow(order.createdAt)"></span></p>
|
||||
</div>
|
||||
</q-item-label>
|
||||
</q-item-section>
|
||||
|
||||
<q-item-section side>
|
||||
<q-item-label>
|
||||
|
||||
<q-badge :color="order.paid ? 'green' : 'gray'"><span
|
||||
v-text="order.paid ? 'Paid' : 'Not Paid'"></span></q-badge>
|
||||
<q-badge :color="order.shipped ? 'green' : 'gray'"><span
|
||||
v-text="order.shipped ? 'Shipped' : 'Not Shipped'"></span></q-badge>
|
||||
</q-item-label>
|
||||
<q-item-label>
|
||||
<div class="text-caption text-grey ellipsis-2-lines">
|
||||
<p>
|
||||
<span v-text="order.items?.length"></span>
|
||||
<span v-text="order.items?.length === 1 ? 'product': 'products'"></span>
|
||||
</p>
|
||||
</div>
|
||||
</q-item-label>
|
||||
</q-item-section>
|
||||
</template>
|
||||
<q-separator></q-separator>
|
||||
<q-card-section class="col-12">
|
||||
<q-item-section>
|
||||
<q-item-label> <strong>Order ID: </strong> <span v-text="order.id"></span>
|
||||
</q-item-label>
|
||||
</q-item-section>
|
||||
|
||||
</q-card-section>
|
||||
<q-separator></q-separator>
|
||||
<q-card-section horizontal>
|
||||
|
||||
<q-card-section class="col-7">
|
||||
<q-item-section class="q-mt-sm">
|
||||
<q-item-label> <strong>Products</strong></q-item-label>
|
||||
</q-item-section>
|
||||
|
||||
<q-item v-for="product in order.products" :key="product.id">
|
||||
<q-item-section avatar>
|
||||
<q-avatar>
|
||||
<img v-if="product.images && product.images[0] || product.image"
|
||||
:src="product.images[0] || product.image" />
|
||||
<img v-else src="/nostrmarket/static/images/placeholder.png" />
|
||||
</q-avatar>
|
||||
</q-item-section>
|
||||
<q-item-section class="q-mt-sm">
|
||||
<q-item-label></q-item-label>
|
||||
<q-item-label>
|
||||
<strong>{{ product.orderedQuantity}} x {{ product.name}} </strong></q-item-label>
|
||||
<q-item-label>
|
||||
<div class="text-caption text-grey ellipsis-2-lines">
|
||||
<p>{{ product.description }}</p>
|
||||
</div>
|
||||
</q-item-label>
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
|
||||
</q-card-section>
|
||||
<q-separator vertical />
|
||||
<q-card-section>
|
||||
<q-item-section class="q-mt-md q-ml-sm">
|
||||
<q-item-label> <strong>Shipping Zone: </strong>
|
||||
<span v-text="order.shippingZone?.name || ''"></span>
|
||||
</q-item-label>
|
||||
</q-item-section>
|
||||
<q-item-section v-if="order.message" class="q-mt-md q-ml-sm">
|
||||
<q-item-label> <strong>Message: </strong>
|
||||
<span v-text="order.message"></span>
|
||||
</q-item-label>
|
||||
</q-item-section>
|
||||
<q-item-section class="q-mt-md q-ml-sm">
|
||||
<q-item-label> <strong>Invoice: </strong>
|
||||
<q-badge @click="showInvoice(order)" v-if="order.invoice?.human_readable_part?.amount"
|
||||
color="orange" class="cursor-pointer">
|
||||
<span v-text="formatCurrency(order.invoice?.human_readable_part?.amount / 1000, 'sat')"></span>
|
||||
</q-badge>
|
||||
<!-- <q-badge v-if="!order.paid">Request Invoice Again</q-badge> -->
|
||||
</q-item-label>
|
||||
</q-item-section>
|
||||
</q-card-section>
|
||||
</q-card-section>
|
||||
|
||||
<q-separator class="q-mb-xl"></q-separator>
|
||||
</q-expansion-item>
|
||||
<q-separator></q-separator>
|
||||
</div>
|
||||
</q-list>
|
||||
</q-card-section>
|
||||
|
||||
|
||||
|
||||
</q-card>
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
|
@ -1,94 +0,0 @@
|
|||
async function customerOrders(path) {
|
||||
const template = await loadTemplateAsync(path)
|
||||
|
||||
Vue.component('customer-orders', {
|
||||
name: 'orders',
|
||||
template,
|
||||
|
||||
props: ['orders', 'products', 'stalls', 'merchants'],
|
||||
data: function () {
|
||||
return {}
|
||||
},
|
||||
computed: {
|
||||
merchantOrders: function () {
|
||||
return Object.keys(this.orders)
|
||||
.map(pubkey => ({
|
||||
pubkey,
|
||||
profile: this.merchantProfile(pubkey),
|
||||
orders: this.orders[pubkey].map(this.enrichOrder)
|
||||
}
|
||||
))
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
enrichOrder: function (order) {
|
||||
const stall = this.stallForOrder(order)
|
||||
return {
|
||||
...order,
|
||||
stallName: stall?.name || 'Stall',
|
||||
shippingZone: stall?.shipping?.find(s => s.id === order.shipping_id) || { id: order.shipping_id, name: order.shipping_id },
|
||||
invoice: this.invoiceForOrder(order),
|
||||
products: this.getProductsForOrder(order)
|
||||
}
|
||||
},
|
||||
stallForOrder: function (order) {
|
||||
try {
|
||||
const productId = order.items && order.items[0]?.product_id
|
||||
if (!productId) return
|
||||
const product = this.products.find(p => p.id === productId)
|
||||
if (!product) return
|
||||
const stall = this.stalls.find(s => s.id === product.stall_id)
|
||||
if (!stall) return
|
||||
return stall
|
||||
} catch (error) {
|
||||
console.log(error)
|
||||
}
|
||||
|
||||
},
|
||||
invoiceForOrder: function (order) {
|
||||
try {
|
||||
const lnPaymentOption = order?.payment_options?.find(p => p.type === 'ln')
|
||||
if (!lnPaymentOption?.link) return
|
||||
return decode(lnPaymentOption.link)
|
||||
} catch (error) {
|
||||
console.warn(error)
|
||||
}
|
||||
},
|
||||
|
||||
merchantProfile: function (pubkey) {
|
||||
const merchant = this.merchants.find(m => m.publicKey === pubkey)
|
||||
return merchant?.profile
|
||||
},
|
||||
|
||||
getProductsForOrder: function (order) {
|
||||
if (!order?.items?.length) return []
|
||||
|
||||
return order.items.map(i => {
|
||||
const product = this.products.find(p => p.id === i.product_id) || { id: i.product_id, name: i.product_id }
|
||||
return {
|
||||
...product,
|
||||
orderedQuantity: i.quantity
|
||||
}
|
||||
})
|
||||
},
|
||||
|
||||
showInvoice: function (order) {
|
||||
if (order.paid) return
|
||||
const invoice = order?.payment_options?.find(p => p.type === 'ln').link
|
||||
if (!invoice) return
|
||||
this.$emit('show-invoice', invoice)
|
||||
},
|
||||
|
||||
formatCurrency: function (value, unit) {
|
||||
return formatCurrency(value, unit)
|
||||
},
|
||||
|
||||
fromNow: function (date) {
|
||||
if (!date) return ''
|
||||
return moment(date * 1000).fromNow()
|
||||
}
|
||||
},
|
||||
created() {
|
||||
}
|
||||
})
|
||||
}
|
||||
|
|
@ -1,60 +0,0 @@
|
|||
<div v-if="showStalls" class="row q-col-gutter-md">
|
||||
<div v-for="stall of stalls" :key="stall.id" class="col-xs-12 col-sm-6 col-md-4 col-lg-3">
|
||||
<q-card class="card--product">
|
||||
<q-card-section class="q-pb-xs q-pt-md">
|
||||
<div class="q-pa-md q-gutter-sm" style="height: 80px">
|
||||
<q-avatar v-for="(image, i) of stall.images" :key="i" size="40px" class="overlapping"
|
||||
:style="`left: ${i * 25}px; border: 2px solid white; position: absolute`">
|
||||
<img :src="image">
|
||||
</q-avatar>
|
||||
</div>
|
||||
</q-card-section>
|
||||
|
||||
<q-card-section class="q-pb-xs q-pt-md">
|
||||
<div class="row no-wrap items-center">
|
||||
<div class="col text-subtitle2 ellipsis-2-lines">{{ stall.name }}</div>
|
||||
</div>
|
||||
|
||||
</q-card-section>
|
||||
<q-separator />
|
||||
|
||||
<q-card-section class="q-pl-sm">
|
||||
<div>
|
||||
<span class="text-caption text-green-8 text-weight-bolder q-mt-md"><span v-text="stall.productCount"></span>
|
||||
products</span>
|
||||
<span v-text="stall.currency" class="float-right"></span>
|
||||
</div>
|
||||
</q-card-section>
|
||||
|
||||
<q-card-section class="q-pl-sm">
|
||||
<div v-if="stall.categories" class="text-subtitle1">
|
||||
<q-virtual-scroll :items="stall.categories || []" virtual-scroll-horizontal>
|
||||
<template v-slot="{ item, index }">
|
||||
<q-chip :key="index" dense><span v-text="item"></span></q-chip>
|
||||
</template>
|
||||
</q-virtual-scroll>
|
||||
</div>
|
||||
<div v-else class="text-subtitle1">
|
||||
 
|
||||
</div>
|
||||
|
||||
<div class="text-caption text-grey ellipsis-2-lines" style="min-height: 40px">
|
||||
<p>{{ stall.description || '' }}</p>
|
||||
</div>
|
||||
|
||||
|
||||
</q-card-section>
|
||||
|
||||
<q-separator></q-separator>
|
||||
|
||||
<q-card-actions>
|
||||
<div class="q-ml-auto">
|
||||
<q-btn flat class="text-weight-bold text-capitalize q-ml-auto float-left" dense color="primary"
|
||||
@click="$emit('change-page', 'stall', {stall: stall.id})">
|
||||
Visit Stall
|
||||
</q-btn>
|
||||
</div>
|
||||
</q-card-actions>
|
||||
</q-card>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -1,27 +0,0 @@
|
|||
async function customerStallList(path) {
|
||||
const template = await loadTemplateAsync(path)
|
||||
|
||||
Vue.component('customer-stall-list', {
|
||||
name: 'customer-stall-list',
|
||||
template,
|
||||
|
||||
props: ['stalls'],
|
||||
data: function () {
|
||||
return {
|
||||
showStalls: true
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
stalls() {
|
||||
this.showProducts = false
|
||||
setTimeout(() => { this.showProducts = true }, 0)
|
||||
}
|
||||
},
|
||||
computed: {},
|
||||
methods: {
|
||||
|
||||
},
|
||||
created() {
|
||||
}
|
||||
})
|
||||
}
|
||||
|
|
@ -1,18 +0,0 @@
|
|||
<div>
|
||||
<div id="product-focus-area"></div>
|
||||
<div v-if="productDetail && product" class="row">
|
||||
<div class="col-12 auto-width">
|
||||
<product-detail :product="product" @add-to-cart="addToCart"></product-detail>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
|
||||
</div>
|
||||
|
|
@ -1,37 +0,0 @@
|
|||
async function customerStall(path) {
|
||||
const template = await loadTemplateAsync(path)
|
||||
|
||||
Vue.component('customer-stall', {
|
||||
name: 'customer-stall',
|
||||
template,
|
||||
|
||||
props: [
|
||||
'stall',
|
||||
'products',
|
||||
'product-detail',
|
||||
],
|
||||
data: function () {
|
||||
return {
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
product() {
|
||||
if (this.productDetail) {
|
||||
return this.products.find(p => p.id == this.productDetail)
|
||||
}
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
changePageS(page, opts) {
|
||||
if (page === 'stall' && opts?.product) {
|
||||
document.getElementById('product-focus-area')?.scrollIntoView()
|
||||
}
|
||||
this.$emit('change-page', page, opts)
|
||||
},
|
||||
addToCart(item) {
|
||||
this.$emit('add-to-cart', item)
|
||||
},
|
||||
|
||||
}
|
||||
})
|
||||
}
|
||||
|
|
@ -1,191 +0,0 @@
|
|||
<q-card>
|
||||
<q-card-section>
|
||||
<div class="q-pt-md">
|
||||
<div class="q-gutter-y-md">
|
||||
<q-tabs v-model="tab" active-color="primary" align="justify">
|
||||
<q-tab name="merchants" label="Merchants" @update="val => tab = val.name"></q-tab>
|
||||
<q-tab name="relays" label="Relays" @update="val => tab = val.name"></q-tab>
|
||||
<q-tab name="marketplace" label="Look And Feel" @update="val => tab = val.name"></q-tab>
|
||||
|
||||
</q-tabs>
|
||||
</div>
|
||||
</div>
|
||||
</q-card-section>
|
||||
<q-separator />
|
||||
<q-card-section>
|
||||
<div class="q-pa-md">
|
||||
<div class="q-gutter-y-md">
|
||||
|
||||
<q-tab-panels v-model="tab">
|
||||
<q-tab-panel name="merchants">
|
||||
<q-list v-if="!readNotes.merchants" class="q-mb-lg" bordered>
|
||||
<q-item>
|
||||
<q-item-section avatar>
|
||||
<q-avatar>
|
||||
<q-icon color="primary" name="info" size="xl" />
|
||||
</q-avatar>
|
||||
</q-item-section>
|
||||
<q-item-section class="q-mt-sm q-ml-lg">
|
||||
<q-item-label><strong>Note</strong></q-item-label>
|
||||
<q-item-label>
|
||||
<div class="text-caption">
|
||||
<ul>
|
||||
<li><span class="text-subtitle1">
|
||||
Here all the mercants of the marketplace are listed.
|
||||
</span>
|
||||
</li>
|
||||
<li>
|
||||
<span class="text-subtitle1">
|
||||
You can easily add a new merchant by
|
||||
entering its public key in the input below.
|
||||
</span>
|
||||
</li>
|
||||
<li>
|
||||
<span class="text-subtitle1">
|
||||
When a merchant is added all its products and stalls will be available in the Market page.
|
||||
</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</q-item-label>
|
||||
</q-item-section>
|
||||
<q-item-section side>
|
||||
<q-btn @click="markNoteAsRead('merchants')" size="lg" outline color="primary" label="Got it!"
|
||||
icon="check_small" />
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
</q-list>
|
||||
<div>
|
||||
<q-input outlined v-model="merchantPubkey" @keydown.enter="addMerchant" type="text" label="Pubkey/Npub"
|
||||
hint="Add merchants">
|
||||
<q-btn @click="addMerchant" dense flat icon="add"></q-btn>
|
||||
</q-input>
|
||||
<q-list class="q-mt-md">
|
||||
<q-item v-for="{publicKey, profile} in merchants" :key="publicKey">
|
||||
|
||||
<q-item-section avatar>
|
||||
<q-avatar>
|
||||
<img v-if="profile?.picture" :src="profile.picture" />
|
||||
<img v-else src="/nostrmarket/static/images/blank-avatar.webp" />
|
||||
</q-avatar>
|
||||
</q-item-section>
|
||||
<q-item-section class="q-mt-sm">
|
||||
<q-item-label><strong>{{ profile?.name}}</strong></q-item-label>
|
||||
<q-item-label>
|
||||
<div class="text-caption text-grey ellipsis-2-lines">
|
||||
<p>{{ publicKey }}</p>
|
||||
</div>
|
||||
</q-item-label>
|
||||
<q-tooltip>{{ publicKey }}</q-tooltip>
|
||||
</q-item-section>
|
||||
<q-item-section side>
|
||||
<q-btn size="12px" flat dense round icon="delete" @click="removeMerchant(publicKey)" />
|
||||
</q-item-section>
|
||||
|
||||
</q-item>
|
||||
</q-list>
|
||||
</div>
|
||||
</q-tab-panel>
|
||||
<q-tab-panel name="relays">
|
||||
<div>
|
||||
<div>
|
||||
<q-input outlined v-model="relayUrl" @keydown.enter="addRelay" type="text" label="wss://"
|
||||
hint="Add realays">
|
||||
<q-btn @click="addRelay" dense flat icon="add"></q-btn>
|
||||
</q-input>
|
||||
<q-list class="q-mt-md">
|
||||
<q-item v-for="relay in relays" :key="relay">
|
||||
|
||||
<q-item-section avatar>
|
||||
<q-avatar>
|
||||
<q-icon name="router"></q-icon>
|
||||
</q-avatar>
|
||||
</q-item-section>
|
||||
<q-item-section class="q-mt-sm">
|
||||
<q-item-label><strong>{{ relay}}</strong></q-item-label>
|
||||
</q-item-section>
|
||||
<q-item-section side>
|
||||
<q-btn size="12px" flat dense round icon="delete" @click="removeRelay(relay)" />
|
||||
</q-item-section>
|
||||
|
||||
</q-item>
|
||||
</q-list>
|
||||
</div>
|
||||
</div>
|
||||
</q-tab-panel>
|
||||
<q-tab-panel name="marketplace">
|
||||
<q-list v-if="!readNotes.marketUi" class="q-mb-lg" bordered>
|
||||
<q-item>
|
||||
<q-item-section avatar>
|
||||
<q-avatar>
|
||||
<q-icon color="primary" name="info" size="xl" />
|
||||
</q-avatar>
|
||||
</q-item-section>
|
||||
<q-item-section class="q-mt-sm q-ml-lg">
|
||||
<q-item-label><strong>Note</strong></q-item-label>
|
||||
<q-item-label>
|
||||
<div class="text-caption">
|
||||
<ul>
|
||||
<li><span class="text-subtitle1">
|
||||
Here one can customize the look and feel of the Market.
|
||||
</span>
|
||||
</li>
|
||||
<li>
|
||||
<span class="text-subtitle1">
|
||||
When the Market Profile is shared (via <code>naddr</code> ) these customisations will be
|
||||
available to the
|
||||
customers.
|
||||
</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</q-item-label>
|
||||
</q-item-section>
|
||||
<q-item-section side>
|
||||
<q-btn @click="markNoteAsRead('marketUi')" size="lg" outline color="primary" label="Got it!"
|
||||
icon="check_small" />
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
</q-list>
|
||||
<div class="q-mb-md"> <strong>Information</strong></div>
|
||||
<q-input @change="updateUiConfig" outlined v-model="configData.name" type="text" label="Market Name"
|
||||
hint="Short name for the market" class="q-mb-md">
|
||||
</q-input>
|
||||
<q-input @change="updateUiConfig" outlined v-model="configData.about" type="textarea" rows="3"
|
||||
label="Marketplace Description"
|
||||
hint="It will be displayed on top of the banner image. Can be a longer text." class="q-mb-lg"></q-input>
|
||||
|
||||
<div class="q-mb-md q-mt-lg">
|
||||
<strong>UI Configurations</strong>
|
||||
</div>
|
||||
|
||||
<q-input @change="updateUiConfig" outlined v-model="configData.ui.picture" type="text" label="Logo"
|
||||
hint="It will be displayed next to the search input. Can be png, jpg, ico, gif, svg." class="q-mb-md">
|
||||
</q-input>
|
||||
<q-input @change="updateUiConfig" outlined v-model="configData.ui.banner" type="text" label="Banner"
|
||||
hint="It represents the visual identity of the market. Can be png, jpg, ico, gif, svg." class="q-mb-md">
|
||||
</q-input>
|
||||
<q-select @input="updateUiConfig" filled v-model="configData.ui.theme"
|
||||
hint="The colors of the market will vary based on the theme. It applies to all components (buttons, labels, inputs, etc)"
|
||||
:options="themeOptions" label="Marketplace Theme"></q-select>
|
||||
|
||||
<q-checkbox @input="updateUiConfig" v-model="configData.ui.darkMode" label="Dark Mode"
|
||||
size="sm"></q-checkbox>
|
||||
|
||||
</q-tab-panel>
|
||||
|
||||
</q-tab-panels>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</q-card-section>
|
||||
<q-separator />
|
||||
<q-card-section>
|
||||
<div class="float-right">
|
||||
<q-btn @click="clearAllData" flat label="Clear All Data" icon="delete" class="q-ml-lg" color="negative"></q-btn>
|
||||
<q-btn @click="publishNaddr" flat label="Share Market Profile" icon="share" class="q-ml-lg"
|
||||
color="primary"></q-btn>
|
||||
</div>
|
||||
</q-card-section>
|
||||
<q-card-section></q-card-section>
|
||||
</q-card>
|
||||
|
|
@ -1,111 +0,0 @@
|
|||
async function marketConfig(path) {
|
||||
const template = await loadTemplateAsync(path)
|
||||
Vue.component('market-config', {
|
||||
name: 'market-config',
|
||||
props: ['merchants', 'relays', 'config-ui', 'read-notes'],
|
||||
template,
|
||||
|
||||
data: function () {
|
||||
return {
|
||||
tab: 'merchants',
|
||||
merchantPubkey: null,
|
||||
relayUrl: null,
|
||||
configData: {
|
||||
identifier: null,
|
||||
name: null,
|
||||
about: null,
|
||||
ui: {
|
||||
picture: null,
|
||||
banner: null,
|
||||
theme: null,
|
||||
darkMode: false
|
||||
}
|
||||
},
|
||||
themeOptions: [
|
||||
'classic',
|
||||
'bitcoin',
|
||||
'flamingo',
|
||||
'cyber',
|
||||
'freedom',
|
||||
'mint',
|
||||
'autumn',
|
||||
'monochrome',
|
||||
'salvador'
|
||||
]
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
addMerchant: async function () {
|
||||
if (!isValidKey(this.merchantPubkey, 'npub')) {
|
||||
this.$q.notify({
|
||||
message: 'Invalid Public Key!',
|
||||
type: 'warning'
|
||||
})
|
||||
return
|
||||
}
|
||||
const publicKey = isValidKeyHex(this.merchantPubkey) ? this.merchantPubkey : NostrTools.nip19.decode(this.merchantPubkey).data
|
||||
this.$emit('add-merchant', publicKey)
|
||||
this.merchantPubkey = null
|
||||
},
|
||||
removeMerchant: async function (publicKey) {
|
||||
this.$emit('remove-merchant', publicKey)
|
||||
},
|
||||
addRelay: async function () {
|
||||
const relayUrl = (this.relayUrl || '').trim()
|
||||
if (!relayUrl.startsWith('wss://') && !relayUrl.startsWith('ws://')) {
|
||||
this.relayUrl = null
|
||||
this.$q.notify({
|
||||
timeout: 5000,
|
||||
type: 'warning',
|
||||
message: `Invalid relay URL.`,
|
||||
caption: "Should start with 'wss://'' or 'ws://'"
|
||||
})
|
||||
return
|
||||
}
|
||||
try {
|
||||
new URL(relayUrl);
|
||||
this.$emit('add-relay', relayUrl)
|
||||
} catch (error) {
|
||||
this.$q.notify({
|
||||
timeout: 5000,
|
||||
type: 'warning',
|
||||
message: `Invalid relay URL.`,
|
||||
caption: `Error: ${error}`
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
this.relayUrl = null
|
||||
},
|
||||
removeRelay: async function (relay) {
|
||||
this.$emit('remove-relay', relay)
|
||||
},
|
||||
updateUiConfig: function () {
|
||||
const { name, about, ui } = this.configData
|
||||
console.log('### this.info', { name, about, ui })
|
||||
this.$emit('ui-config-update', { name, about, ui })
|
||||
},
|
||||
publishNaddr() {
|
||||
this.$emit('publish-naddr')
|
||||
},
|
||||
clearAllData() {
|
||||
this.$emit('clear-all-data')
|
||||
},
|
||||
markNoteAsRead(noteId) {
|
||||
this.$emit('note-read', noteId)
|
||||
}
|
||||
},
|
||||
created: async function () {
|
||||
if (this.configUi) {
|
||||
this.configData = {
|
||||
...this.configData,
|
||||
...this.configUi,
|
||||
ui: {
|
||||
...this.configData.ui, ...(this.configUi.ui || {})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
})
|
||||
}
|
||||
|
|
@ -1,67 +0,0 @@
|
|||
<q-card class="card--product">
|
||||
<q-img
|
||||
:src="(product.images && product.images.length > 0 && product.images[0]) ? product.images[0] : '/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 round :disabled="product.quantity < 1" color="primary" rounded 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-card-section>
|
||||
|
||||
<q-card-section class="q-py-sm">
|
||||
<div>
|
||||
<span v-if="product.currency == 'sat'">
|
||||
<span class="text-h6">{{ product.price }} sats</span><q-tooltip> BTC {{ (product.price /
|
||||
1e8).toFixed(8) }}</q-tooltip>
|
||||
</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-virtual-scroll :items="product.categories || []" virtual-scroll-horizontal>
|
||||
<template v-slot="{ item, index }">
|
||||
<q-chip :key="index" dense><span v-text="item"></span></q-chip>
|
||||
</template>
|
||||
</q-virtual-scroll>
|
||||
</div>
|
||||
<div v-else class="text-subtitle1">
|
||||
 
|
||||
</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>
|
||||
<div class="text-caption text-weight-bolder">{{ product.stallName }}</div>
|
||||
</q-card-actions>
|
||||
|
||||
<q-separator></q-separator>
|
||||
|
||||
<q-card-actions>
|
||||
<div class="q-ml-auto">
|
||||
<q-btn v-if="!isStall" flat class="text-weight-bold text-capitalize q-ml-auto float-left" dense color="primary"
|
||||
@click="$emit('change-page', 'stall', {stall: product.stall_id})">
|
||||
Visit Stall
|
||||
</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, product: product.id})">
|
||||
View details
|
||||
</q-btn>
|
||||
|
||||
</div>
|
||||
</q-card-actions>
|
||||
</q-card>
|
||||
|
|
@ -1,14 +0,0 @@
|
|||
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() {}
|
||||
})
|
||||
}
|
||||
|
|
@ -1,83 +0,0 @@
|
|||
<div class="row">
|
||||
<div class="col-lg-5 col-md-5 col-sm-12 col-xs-12 q-mt-sm">
|
||||
<div class="q-pr-md" v-if="product.images && product.images[0]">
|
||||
<q-carousel
|
||||
swipeable
|
||||
animated
|
||||
v-model="slide"
|
||||
thumbnails
|
||||
infinite
|
||||
arrows
|
||||
transition-prev="slide-right"
|
||||
transition-next="slide-left"
|
||||
navigation-icon="radio_button_unchecked"
|
||||
control-type="regular"
|
||||
control-color="secondary"
|
||||
control-text-color="white"
|
||||
>
|
||||
<q-carousel-slide
|
||||
v-for="(img, i) in product.images"
|
||||
:name="i + 1"
|
||||
:key="i"
|
||||
:img-src="img"
|
||||
></q-carousel-slide>
|
||||
</q-carousel>
|
||||
|
||||
</div>
|
||||
<div v-else class="q-pr-md">
|
||||
<q-img 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 q-mt-sm">
|
||||
<q-card>
|
||||
<q-card-section>
|
||||
<div class="row">
|
||||
<div
|
||||
class="col-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. ${product.quantity} left.` : 'Out of stock.' }}</span
|
||||
>
|
||||
</div>
|
||||
<div class="q-mt-md">
|
||||
<q-btn
|
||||
class="q-mt-md"
|
||||
color="primary"
|
||||
rounded
|
||||
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>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -1,17 +0,0 @@
|
|||
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() {}
|
||||
})
|
||||
}
|
||||
|
|
@ -1,153 +0,0 @@
|
|||
<div>
|
||||
<q-card v-if="cart && stall" bordered class="q-mb-md">
|
||||
<q-item>
|
||||
<q-item-section avatar>
|
||||
<q-avatar>
|
||||
<img v-if="cart.merchant?.profile?.picture" :src="cart.merchant?.profile?.picture">
|
||||
<img v-else src="/nostrmarket/static/images/blank-avatar.webp">
|
||||
</q-avatar>
|
||||
</q-item-section>
|
||||
|
||||
<q-item-section>
|
||||
<q-item-label>
|
||||
<strong>
|
||||
<span v-text="cart.products[0]?.stallName"></span>
|
||||
</strong>
|
||||
</q-item-label>
|
||||
<q-item-label caption>
|
||||
By <span v-text="cart.merchant?.profile?.name || cart.merchant?.publicKey"></span>
|
||||
</q-item-label>
|
||||
</q-item-section>
|
||||
<q-item-section side>
|
||||
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
|
||||
<q-separator />
|
||||
<q-card-section v-if="orderConfirmed">
|
||||
<div class="row q-mt-md q-ml-md q-pr-md">
|
||||
<div class="col-xs-12 col-sm-12 col-md-2 q-mt-md">
|
||||
<strong>Message:</strong>
|
||||
</div>
|
||||
<div class="col-xs-12 col-sm-12 col-md-10">
|
||||
<q-input v-model.trim="contactData.message" outlined type="textarea" rows="3"
|
||||
label="Message (optional)" hint="Message merchant about additional order needs"></q-input>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row q-mt-md q-ml-md q-pr-md">
|
||||
<div class="col-xs-12 col-sm-12 col-md-2 q-mt-md">
|
||||
<strong>Address:</strong>
|
||||
</div>
|
||||
<div class="col-xs-12 col-sm-12 col-md-10">
|
||||
<q-input v-model.trim="contactData.address" outlined type="textarea" rows="3"
|
||||
label="Address (optional)" hint="Must provide for physical shipping">
|
||||
</q-input>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row q-mt-md q-ml-md q-pr-md">
|
||||
<div class="col-xs-12 col-sm-12 col-md-2 q-mt-md">
|
||||
<strong>Email:</strong>
|
||||
</div>
|
||||
<div class="col-xs-12 col-sm-12 col-md-10">
|
||||
<q-input v-model.trim="contactData.email" type="email" outlined label="Email (optional)"
|
||||
hint="Merchant may not use email"></q-input>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row q-mt-md q-ml-md q-pr-md">
|
||||
<div class="col-xs-12 col-sm-12 col-md-2 q-mt-md">
|
||||
<strong>Npub:</strong>
|
||||
</div>
|
||||
<div class="col-xs-12 col-sm-12 col-md-10">
|
||||
<q-input v-model.trim="contactData.npub" outlined label="Alternative Npub (optional)"
|
||||
hint="Use a different Npub to communicate with the merchant"></q-input>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
</q-card-section>
|
||||
|
||||
<q-card-section v-else horizontal>
|
||||
<q-card-section class="col-7">
|
||||
<div class="row q-mt-md">
|
||||
<div class="col-xs-12 col-sm-12 col-md-4">
|
||||
<strong>Subtotal:</strong>
|
||||
</div>
|
||||
<div class="col-xs-12 col-sm-12 col-md-4">
|
||||
<strong>{{formatCurrency(cartTotal, stall.currency)}}</strong>
|
||||
</div>
|
||||
<div class="col-xs-12 col-sm-12 col-md-4">
|
||||
|
||||
</div>
|
||||
</div>
|
||||
<div class="row q-mt-md">
|
||||
<div class="col-xs-12 col-sm-12 col-md-4">
|
||||
<strong>Shipping:</strong>
|
||||
</div>
|
||||
<div class="col-xs-12 col-sm-12 col-md-4">
|
||||
<strong v-if="shippingZone">{{formatCurrency(shippingZone.cost, stall.currency)}}</strong>
|
||||
</div>
|
||||
<div class="col-xs-12 col-sm-12 col-md-4">
|
||||
<q-btn-dropdown unelevated color="secondary" rounded :label="shippingZoneLabel">
|
||||
<q-item v-for="zone of stall.shipping" @click="selectShippingZone(zone)" :key="zone.id"
|
||||
clickable v-close-popup>
|
||||
<q-item-section>
|
||||
<q-item-label><span v-text="zone.name"></span></q-item-label>
|
||||
<q-item-label caption><span
|
||||
v-text="zone.countries?.join(', ')"></span></q-item-label>
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
</q-btn-dropdown>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
<q-separator class="q-mt-sm" />
|
||||
<div class="row q-mt-md">
|
||||
<div class="col-xs-12 col-sm-12 col-md-4">
|
||||
<strong>Total:</strong>
|
||||
</div>
|
||||
<div class="col-xs-12 col-sm-12 col-md-4">
|
||||
<strong>{{formatCurrency(cartTotalWithShipping, stall.currency)}}</strong>
|
||||
</div>
|
||||
<div class="col-xs-12 col-sm-12 col-md-4">
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
</q-card-section>
|
||||
|
||||
<q-separator vertical />
|
||||
|
||||
<q-card-section>
|
||||
<strong>Payment Method</strong>
|
||||
<q-option-group v-model="paymentMethod" :options="paymentOptions" color="green" disable />
|
||||
</q-card-section>
|
||||
</q-card-section>
|
||||
<q-separator />
|
||||
|
||||
<q-card-actions align="right">
|
||||
<div v-if="orderConfirmed">
|
||||
<q-btn @click="orderConfirmed = false" flat color="grey">
|
||||
Back
|
||||
</q-btn>
|
||||
<q-btn @click="placeOrder()" flat color="primary">
|
||||
Place Order
|
||||
</q-btn>
|
||||
</div>
|
||||
<div v-else>
|
||||
<q-btn @click="goToShoppingCart" flat color="grey">
|
||||
Back
|
||||
</q-btn>
|
||||
<q-btn @click="confirmOrder" flat color="primary">
|
||||
Confirm
|
||||
</q-btn>
|
||||
</div>
|
||||
|
||||
</q-card-actions>
|
||||
</q-card>
|
||||
|
||||
|
||||
</div>
|
||||
|
|
@ -1,127 +0,0 @@
|
|||
async function shoppingCartCheckout(path) {
|
||||
const template = await loadTemplateAsync(path)
|
||||
|
||||
Vue.component('shopping-cart-checkout', {
|
||||
name: 'shopping-cart-checkout',
|
||||
template,
|
||||
|
||||
props: ['cart', 'stall', 'customer-pubkey'],
|
||||
data: function () {
|
||||
return {
|
||||
orderConfirmed: false,
|
||||
paymentMethod: 'ln',
|
||||
shippingZone: null,
|
||||
contactData: {
|
||||
email: null,
|
||||
npub: null,
|
||||
address: null,
|
||||
message: null
|
||||
},
|
||||
paymentOptions: [
|
||||
{
|
||||
label: 'Lightning Network',
|
||||
value: 'ln'
|
||||
},
|
||||
{
|
||||
label: 'BTC Onchain',
|
||||
value: 'btc'
|
||||
},
|
||||
{
|
||||
label: 'Cashu',
|
||||
value: 'cashu'
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
cartTotal() {
|
||||
if (!this.cart.products?.length) return 0
|
||||
return this.cart.products.reduce((t, p) => p.price + t, 0)
|
||||
},
|
||||
cartTotalWithShipping() {
|
||||
if (!this.shippingZone) return this.cartTotal
|
||||
return this.cartTotal + this.shippingZone.cost
|
||||
},
|
||||
shippingZoneLabel() {
|
||||
if (!this.shippingZone) {
|
||||
return 'Shipping Zone'
|
||||
}
|
||||
const zoneName = this.shippingZone.name.substring(0, 10)
|
||||
if (this.shippingZone?.name.length < 10) {
|
||||
return zoneName
|
||||
}
|
||||
return zoneName + '...'
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
formatCurrency: function (value, unit) {
|
||||
return formatCurrency(value, unit)
|
||||
},
|
||||
selectShippingZone: function (zone) {
|
||||
this.shippingZone = zone
|
||||
},
|
||||
|
||||
confirmOrder: function () {
|
||||
if (!this.shippingZone) {
|
||||
this.$q.notify({
|
||||
timeout: 5000,
|
||||
type: 'warning',
|
||||
message: 'Please select a shipping zone!',
|
||||
})
|
||||
return
|
||||
}
|
||||
this.orderConfirmed = true
|
||||
},
|
||||
async placeOrder() {
|
||||
if (!this.shippingZone) {
|
||||
this.$q.notify({
|
||||
timeout: 5000,
|
||||
type: 'warning',
|
||||
message: 'Please select a shipping zone!',
|
||||
})
|
||||
return
|
||||
}
|
||||
if (!this.customerPubkey) {
|
||||
this.$emit('login-required')
|
||||
return
|
||||
}
|
||||
const order = {
|
||||
address: this.contactData.address,
|
||||
message: this.contactData.message,
|
||||
contact: {
|
||||
nostr: this.contactData.npub,
|
||||
email: this.contactData.email
|
||||
},
|
||||
items: Array.from(this.cart.products, p => {
|
||||
return { product_id: p.id, quantity: p.orderedQuantity }
|
||||
}),
|
||||
shipping_id: this.shippingZone.id,
|
||||
type: 0
|
||||
}
|
||||
const created_at = Math.floor(Date.now() / 1000)
|
||||
order.id = await hash(
|
||||
[this.customerPubkey, created_at, JSON.stringify(order)].join(':')
|
||||
)
|
||||
|
||||
const event = {
|
||||
...(await NostrTools.getBlankEvent()),
|
||||
kind: 4,
|
||||
created_at,
|
||||
tags: [['p', this.stall.pubkey]],
|
||||
pubkey: this.customerPubkey
|
||||
}
|
||||
|
||||
this.$emit('place-order', { event, order, cartId: this.cart.id })
|
||||
|
||||
},
|
||||
goToShoppingCart: function () {
|
||||
this.$emit('change-page', 'shopping-cart-list')
|
||||
}
|
||||
},
|
||||
created() {
|
||||
if (this.stall.shipping?.length === 1) {
|
||||
this.shippingZone = this.stall.shipping[0]
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
|
@ -1,98 +0,0 @@
|
|||
<div>
|
||||
<q-card v-if="!carts?.length" bordered class="q-mb-md">
|
||||
<q-card-section>
|
||||
<strong>No products in cart!</strong>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
<div v-for="cart in carts">
|
||||
|
||||
<q-card bordered class="q-mb-md">
|
||||
<q-item>
|
||||
<q-item-section avatar>
|
||||
<q-avatar>
|
||||
<img v-if="cart.merchant?.profile?.picture" :src="cart.merchant?.profile?.picture">
|
||||
<img v-else src="/nostrmarket/static/images/blank-avatar.webp">
|
||||
</q-avatar>
|
||||
</q-item-section>
|
||||
|
||||
<q-item-section>
|
||||
<q-item-label>
|
||||
<strong>
|
||||
<span v-text="cart.products[0]?.stallName"></span>
|
||||
</strong>
|
||||
</q-item-label>
|
||||
<q-item-label caption>
|
||||
By <span v-text="cart.merchant?.profile?.name || cart.merchant?.publicKey"></span>
|
||||
</q-item-label>
|
||||
</q-item-section>
|
||||
<q-item-section side>
|
||||
<div>
|
||||
<q-btn @click="removeCart(cart.id)" flat color="pink">
|
||||
Clear Cart
|
||||
</q-btn>
|
||||
</div>
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
|
||||
<q-separator />
|
||||
|
||||
<q-card-section horizontal>
|
||||
|
||||
<q-card-section class="col-12">
|
||||
<q-list class="q-mt-md">
|
||||
<q-item v-for="product in cart.products" :key="product.id">
|
||||
|
||||
<q-item-section avatar>
|
||||
<q-avatar>
|
||||
<img v-if="product.images[0] || product.image" :src="product.images[0] || product.image" />
|
||||
<img v-else src="/nostrmarket/static/images/placeholder.png" />
|
||||
</q-avatar>
|
||||
</q-item-section>
|
||||
<q-item-section class="q-mt-sm">
|
||||
<q-item-label>{{ product.name}}</q-item-label>
|
||||
<q-item-label>
|
||||
<div class="text-caption text-grey ellipsis-2-lines">
|
||||
<p>{{ product.description }}</p>
|
||||
</div>
|
||||
</q-item-label>
|
||||
</q-item-section>
|
||||
<q-item-section class="q-mt-sm">
|
||||
<q-item-label><strong>{{ formatCurrency(product.price, product.currency)}}</strong></q-item-label>
|
||||
<q-item-label></q-item-label>
|
||||
</q-item-section>
|
||||
|
||||
<q-item-section class="q-ma-sm">
|
||||
<q-input v-model.number="product.orderedQuantity" @change="quantityChanged(product)" type="number"
|
||||
rounded outlined min="1" :max="product.quantity"></q-input>
|
||||
</q-item-section>
|
||||
|
||||
<q-item-section>
|
||||
<q-item-label><strong>{{ formatCurrency(product.price * product.orderedQuantity, product.currency)}}
|
||||
</strong></q-item-label>
|
||||
</q-item-section>
|
||||
<q-item-section side>
|
||||
<div>
|
||||
<q-btn flat dense round icon="delete" @click="removeProduct(product.stall_id, product.id)" />
|
||||
</div>
|
||||
</q-item-section>
|
||||
|
||||
</q-item>
|
||||
|
||||
</q-list>
|
||||
</q-card-section>
|
||||
</q-card-section>
|
||||
<q-separator />
|
||||
|
||||
|
||||
<q-card-actions align="right">
|
||||
|
||||
Total: <strong class="q-ma-md"> {{cartTotalFormatted(cart)}} </strong>
|
||||
<q-btn @click="proceedToCheckout(cart)" flat color="primary">
|
||||
Proceed to Checkout
|
||||
</q-btn>
|
||||
</q-card-actions>
|
||||
</q-card>
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
|
@ -1,37 +0,0 @@
|
|||
async function shoppingCartList(path) {
|
||||
const template = await loadTemplateAsync(path)
|
||||
|
||||
Vue.component('shopping-cart-list', {
|
||||
name: 'shopping-cart-list',
|
||||
template,
|
||||
|
||||
props: ['carts'],
|
||||
data: function () {
|
||||
return {}
|
||||
},
|
||||
computed: {},
|
||||
methods: {
|
||||
formatCurrency: function (value, unit) {
|
||||
return formatCurrency(value, unit)
|
||||
},
|
||||
cartTotalFormatted(cart) {
|
||||
if (!cart.products?.length) return ""
|
||||
const total = cart.products.reduce((t, p) => p.price + t, 0)
|
||||
return formatCurrency(total, cart.products[0].currency)
|
||||
},
|
||||
removeProduct: function (stallId, productId) {
|
||||
this.$emit('remove-from-cart', { stallId, productId })
|
||||
},
|
||||
removeCart: function (stallId) {
|
||||
this.$emit('remove-cart', stallId)
|
||||
},
|
||||
quantityChanged: function (product) {
|
||||
this.$emit('add-to-cart', product)
|
||||
},
|
||||
proceedToCheckout: function(cart){
|
||||
this.$emit('checkout-cart', cart)
|
||||
}
|
||||
},
|
||||
created() { }
|
||||
})
|
||||
}
|
||||
|
|
@ -1,61 +0,0 @@
|
|||
<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 :id="p.id" :key="p.id" v-for="p in cartMenu">
|
||||
<q-item-section style="flex-flow: row">
|
||||
<q-btn color="red" size="xs" icon="remove" @click="remove(p.id)" />
|
||||
<q-input
|
||||
v-model.number="p.quantity"
|
||||
@change="addQty(p.id, p.quantity)"
|
||||
type="number"
|
||||
dense
|
||||
standout
|
||||
:max="products.find(pr => pr.id == p.id).quantity"
|
||||
></q-input>
|
||||
|
||||
<q-btn color="green" size="xs" icon="add" @click="add(p.id)" />
|
||||
</q-item-section>
|
||||
<q-item-section avatar v-if="p.image">
|
||||
<q-avatar color="primary">
|
||||
<img :src="p.image" />
|
||||
</q-avatar>
|
||||
</q-item-section>
|
||||
|
||||
<q-item-section top class="q-mx-sm">
|
||||
<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="removeProduct(p.id)"
|
||||
/>
|
||||
</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-auto"
|
||||
flat
|
||||
color="primary"
|
||||
label="Reset"
|
||||
@click="$emit('reset-cart')"
|
||||
></q-btn></div></q-menu
|
||||
></q-btn>
|
||||
|
|
@ -1,61 +0,0 @@
|
|||
async function shoppingCart(path) {
|
||||
const template = await loadTemplateAsync(path)
|
||||
|
||||
Vue.component('shopping-cart', {
|
||||
name: 'shopping-cart',
|
||||
template,
|
||||
|
||||
props: [
|
||||
'cart',
|
||||
'cart-menu',
|
||||
'add-to-cart',
|
||||
'remove-from-cart',
|
||||
'update-qty',
|
||||
'reset-cart',
|
||||
'products'
|
||||
],
|
||||
data: function () {
|
||||
return {}
|
||||
},
|
||||
computed: {},
|
||||
methods: {
|
||||
add(id) {
|
||||
this.$emit(
|
||||
'add-to-cart',
|
||||
this.products.find(p => p.id == id)
|
||||
)
|
||||
},
|
||||
remove(id) {
|
||||
this.$emit(
|
||||
'remove-from-cart',
|
||||
this.products.find(p => p.id == id)
|
||||
)
|
||||
},
|
||||
removeProduct(id) {
|
||||
this.$emit(
|
||||
'remove-from-cart',
|
||||
this.products.find(p => p.id == id),
|
||||
true
|
||||
)
|
||||
},
|
||||
addQty(id, qty) {
|
||||
if (qty == 0) {
|
||||
return this.removeProduct(id)
|
||||
}
|
||||
let product = this.products.find(p => p.id == id)
|
||||
if (product.quantity < qty) {
|
||||
this.$q.notify({
|
||||
type: 'warning',
|
||||
message: `${product.name} only has ${product.quantity} units!`,
|
||||
icon: 'production_quantity_limits'
|
||||
})
|
||||
let objIdx = this.cartMenu.findIndex(pr => pr.id == id)
|
||||
this.cartMenu[objIdx].quantity = this.cart.products.get(id).quantity
|
||||
return
|
||||
}
|
||||
this.$emit('update-qty', id, qty)
|
||||
}
|
||||
},
|
||||
created() {}
|
||||
})
|
||||
}
|
||||
|
|
@ -1,8 +1,6 @@
|
|||
async function stallDetails(path) {
|
||||
const template = await loadTemplateAsync(path)
|
||||
|
||||
const pica = window.pica()
|
||||
|
||||
Vue.component('stall-details', {
|
||||
name: 'stall-details',
|
||||
template,
|
||||
|
|
|
|||
|
|
@ -1,8 +0,0 @@
|
|||
<q-card>
|
||||
<div class="q-pa-md">
|
||||
<div class="q-gutter-y-md">
|
||||
User Chat
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</q-card>
|
||||
|
|
@ -1,19 +0,0 @@
|
|||
async function userChat(path) {
|
||||
const template = await loadTemplateAsync(path)
|
||||
Vue.component('user-chat', {
|
||||
name: 'user-chat',
|
||||
props: ['user',],
|
||||
template,
|
||||
|
||||
data: function () {
|
||||
return {
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
|
||||
},
|
||||
created: async function () {
|
||||
|
||||
}
|
||||
})
|
||||
}
|
||||
|
|
@ -1,47 +0,0 @@
|
|||
<q-card>
|
||||
<q-card-section v-if="account">
|
||||
<div class="row">
|
||||
<div class="col-10">
|
||||
<q-input v-model="account.npub" readonly disbled outlined :hint="account.pubkey" type="text" label="Public Key"
|
||||
class="q-mb-md">
|
||||
<template v-slot:append>
|
||||
<q-btn @click="copyText(account.npub)" icon="content_copy" label="Npub" flat
|
||||
color="gray float-right q-mt-sm"></q-btn>
|
||||
</template>
|
||||
</q-input>
|
||||
</div>
|
||||
|
||||
<div class="col-2 auto-width">
|
||||
<q-btn @click="copyText(account.pubkey)" icon="content_copy" label="Hex" flat
|
||||
color="gray float-right q-mt-sm"></q-btn>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-10">
|
||||
<q-input v-model="account.nsec" readonly disbled outlined type="password" label="Private Key" class="q-mb-md">
|
||||
<template v-slot:append>
|
||||
<q-btn @click="copyText(account.nsec)" icon="content_copy" label="Nsec" flat
|
||||
color="gray float-right q-mt-sm"></q-btn>
|
||||
</template>
|
||||
</q-input>
|
||||
</div>
|
||||
|
||||
<div class="col-2 auto-width">
|
||||
<q-btn @click="copyText(account.privkey)" icon="content_copy" label="Hex" flat
|
||||
color="gray float-right q-mt-sm"></q-btn>
|
||||
</div>
|
||||
</div>
|
||||
</q-card-section>
|
||||
<q-separator />
|
||||
<q-card-section>
|
||||
<div v-if="account" class="float-right">
|
||||
<q-btn @click="logout" flat label="Logout" icon="logout" class="q-ml-lg" color="primary"></q-btn>
|
||||
</div>
|
||||
<div v-else>
|
||||
<strong>No Account</strong>
|
||||
</div>
|
||||
</q-card-section>
|
||||
<q-card-section></q-card-section>
|
||||
|
||||
</q-card>
|
||||
|
|
@ -1,30 +0,0 @@
|
|||
async function userConfig(path) {
|
||||
const template = await loadTemplateAsync(path)
|
||||
Vue.component('user-config', {
|
||||
name: 'user-config',
|
||||
props: ['account'],
|
||||
template,
|
||||
|
||||
data: function () {
|
||||
return {
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
logout: async function () {
|
||||
LNbits.utils
|
||||
.confirmDialog(
|
||||
'Please make sure you save your private key! You will not be able to recover it later!'
|
||||
)
|
||||
.onOk(async () => {
|
||||
this.$emit('logout')
|
||||
})
|
||||
},
|
||||
copyText(text) {
|
||||
this.$emit('copy-text', text)
|
||||
}
|
||||
},
|
||||
created: async function () {
|
||||
|
||||
}
|
||||
})
|
||||
}
|
||||
|
|
@ -1,977 +0,0 @@
|
|||
const market = async () => {
|
||||
Vue.component(VueQrcode.name, VueQrcode)
|
||||
|
||||
const NostrTools = window.NostrTools
|
||||
|
||||
const defaultRelays = [
|
||||
'wss://relay.damus.io',
|
||||
'wss://relay.snort.social',
|
||||
'wss://nostr-pub.wellorder.net',
|
||||
'wss://nostr.zebedee.cloud',
|
||||
'wss://nostr.walletofsatoshi.com'
|
||||
]
|
||||
const eventToObj = event => {
|
||||
try {
|
||||
event.content = JSON.parse(event.content) || null
|
||||
} catch {
|
||||
event.content = null
|
||||
}
|
||||
|
||||
|
||||
return {
|
||||
...event,
|
||||
...Object.values(event.tags).reduce((acc, tag) => {
|
||||
let [key, value] = tag
|
||||
if (key == 't') {
|
||||
return { ...acc, [key]: [...(acc[key] || []), value] }
|
||||
} else {
|
||||
return { ...acc, [key]: value }
|
||||
}
|
||||
}, {})
|
||||
}
|
||||
}
|
||||
await Promise.all([
|
||||
productCard('static/components/product-card/product-card.html'),
|
||||
customerMarket('static/components/customer-market/customer-market.html'),
|
||||
customerStall('static/components/customer-stall/customer-stall.html'),
|
||||
customerStallList('static/components/customer-stall-list/customer-stall-list.html'),
|
||||
productDetail('static/components/product-detail/product-detail.html'),
|
||||
shoppingCart('static/components/shopping-cart/shopping-cart.html'),
|
||||
shoppingCartList('static/components/shopping-cart-list/shopping-cart-list.html'),
|
||||
shoppingCartCheckout('static/components/shopping-cart-checkout/shopping-cart-checkout.html'),
|
||||
customerOrders('static/components/customer-orders/customer-orders.html'),
|
||||
chatDialog('static/components/chat-dialog/chat-dialog.html'),
|
||||
marketConfig('static/components/market-config/market-config.html'),
|
||||
userConfig('static/components/user-config/user-config.html'),
|
||||
userChat('static/components/user-chat/user-chat.html')
|
||||
])
|
||||
|
||||
new Vue({
|
||||
el: '#vue',
|
||||
mixins: [windowMixin],
|
||||
data: function () {
|
||||
return {
|
||||
account: null,
|
||||
accountMetadata: null,
|
||||
accountDialog: {
|
||||
show: false,
|
||||
data: {
|
||||
watchOnly: false,
|
||||
key: null
|
||||
}
|
||||
},
|
||||
|
||||
merchants: [],
|
||||
shoppingCarts: [],
|
||||
checkoutCart: null,
|
||||
checkoutStall: null,
|
||||
|
||||
activePage: 'market',
|
||||
activeOrderId: null,
|
||||
dmSubscriptions: {},
|
||||
|
||||
qrCodeDialog: {
|
||||
data: {
|
||||
payment_request: null,
|
||||
message: null,
|
||||
},
|
||||
dismissMsg: null,
|
||||
show: false
|
||||
},
|
||||
|
||||
|
||||
filterCategories: [],
|
||||
groupByStall: false,
|
||||
|
||||
relays: new Set(),
|
||||
events: [],
|
||||
stalls: [],
|
||||
products: [],
|
||||
orders: {},
|
||||
|
||||
bannerImage: null,
|
||||
logoImage: null,
|
||||
isLoading: false,
|
||||
|
||||
|
||||
profiles: new Map(),
|
||||
searchText: null,
|
||||
inputPubkey: null,
|
||||
inputRelay: null,
|
||||
activePage: 'market',
|
||||
activeStall: null,
|
||||
activeProduct: null,
|
||||
pool: null,
|
||||
config: {
|
||||
opts: null
|
||||
},
|
||||
|
||||
|
||||
defaultBanner: '/nostrmarket/static/images/nostr-cover.png',
|
||||
defaultLogo: '/nostrmarket/static/images/nostr-avatar.png',
|
||||
readNotes: {
|
||||
merchants: false,
|
||||
marketUi: false
|
||||
}
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
config(n, o) {
|
||||
if (!n?.opts?.ui?.banner) {
|
||||
this.bannerImage = this.defaultBanner
|
||||
} else {
|
||||
this.bannerImage = null
|
||||
setTimeout(() => {
|
||||
this.bannerImage = this.sanitizeImageSrc(n?.opts?.ui?.banner, this.defaultBanner), 1
|
||||
})
|
||||
}
|
||||
if (!n?.opts?.ui?.picture) {
|
||||
this.logoImage = this.defaultLogo
|
||||
} else {
|
||||
this.logoImage = null
|
||||
setTimeout(() => {
|
||||
this.logoImage = this.sanitizeImageSrc(n?.opts?.ui?.picture, this.defaultLogo), 1
|
||||
})
|
||||
}
|
||||
|
||||
},
|
||||
searchText(n, o) {
|
||||
if (!n) return
|
||||
if (n.toLowerCase().startsWith('naddr')) {
|
||||
try {
|
||||
const { type, data } = NostrTools.nip19.decode(n)
|
||||
if (type !== 'naddr' || data.kind !== 30019) return
|
||||
LNbits.utils
|
||||
.confirmDialog('Do you want to import this market profile?')
|
||||
.onOk(async () => {
|
||||
await this.checkMarketNaddr(n)
|
||||
this.searchText = ''
|
||||
})
|
||||
} catch { }
|
||||
|
||||
}
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
|
||||
filterProducts() {
|
||||
let products = this.products.filter(p => this.hasCategory(p.categories))
|
||||
if (this.activeStall) {
|
||||
products = products.filter(p => p.stall_id == this.activeStall)
|
||||
}
|
||||
if (!this.searchText || this.searchText.length < 2) return products
|
||||
const searchText = this.searchText.toLowerCase()
|
||||
return products.filter(p => (
|
||||
p.name.toLowerCase().includes(searchText) ||
|
||||
(p.description &&
|
||||
p.description.toLowerCase().includes(searchText)) ||
|
||||
(p.categories &&
|
||||
p.categories.toString().toLowerCase().includes(searchText))
|
||||
)
|
||||
)
|
||||
},
|
||||
filterStalls() {
|
||||
const stalls = this.stalls
|
||||
.map(s => (
|
||||
{
|
||||
...s,
|
||||
categories: this.allStallCatgories(s.id),
|
||||
images: this.allStallImages(s.id).slice(0, 8),
|
||||
productCount: this.products.filter(p => p.stall_id === s.id).length
|
||||
}))
|
||||
.filter(p => this.hasCategory(p.categories))
|
||||
|
||||
if (!this.searchText || this.searchText.length < 2) return stalls
|
||||
|
||||
const searchText = this.searchText.toLowerCase()
|
||||
return this.stalls.filter(s => (
|
||||
s.name.toLowerCase().includes(searchText) ||
|
||||
(s.description &&
|
||||
s.description.toLowerCase().includes(searchText)) ||
|
||||
(s.categories &&
|
||||
s.categories.toString().toLowerCase().includes(searchText))
|
||||
))
|
||||
},
|
||||
stallName() {
|
||||
return this.stalls.find(s => s.id == this.activeStall)?.name || 'Stall'
|
||||
},
|
||||
productName() {
|
||||
return (
|
||||
this.products.find(p => p.id == this.activeProduct)?.name || 'Product'
|
||||
)
|
||||
},
|
||||
hasExtension() {
|
||||
return window.nostr
|
||||
},
|
||||
isValidAccountKey() {
|
||||
return isValidKey(this.accountDialog.data.key)
|
||||
},
|
||||
|
||||
|
||||
allCartsItemCount() {
|
||||
return this.shoppingCarts.map(s => s.products).flat().reduce((t, p) => t + p.orderedQuantity, 0)
|
||||
},
|
||||
|
||||
allCategories() {
|
||||
const categories = this.products.map(p => p.categories).flat().filter(c => !!c)
|
||||
const countedCategories = categories.reduce((all, c) => {
|
||||
all[c] = (all[c] || 0) + 1
|
||||
return all
|
||||
}, {})
|
||||
const x = Object.keys(countedCategories)
|
||||
.map(category => ({
|
||||
category,
|
||||
count: countedCategories[category],
|
||||
selected: this.filterCategories.indexOf(category) !== -1
|
||||
}))
|
||||
.sort((a, b) => b.count - a.count)
|
||||
return x
|
||||
}
|
||||
},
|
||||
|
||||
async created() {
|
||||
this.bannerImage = this.defaultBanner
|
||||
this.logoImage = this.defaultLogo
|
||||
|
||||
this.restoreFromStorage()
|
||||
|
||||
const params = new URLSearchParams(window.location.search)
|
||||
|
||||
await this.checkMarketNaddr(params.get('naddr'))
|
||||
await this.handleQueryParams(params)
|
||||
|
||||
|
||||
// Get notes from Nostr
|
||||
await this.initNostr()
|
||||
|
||||
|
||||
|
||||
await this.listenForIncommingDms(this.merchants.map(m => ({ publicKey: m.publicKey, since: this.lastDmForPubkey(m.publicKey) })))
|
||||
this.isLoading = false
|
||||
},
|
||||
methods: {
|
||||
async handleQueryParams(params) {
|
||||
const merchantPubkey = params.get('merchant')
|
||||
const stallId = params.get('stall')
|
||||
const productId = params.get('product')
|
||||
|
||||
// What component to render on start
|
||||
if (stallId) {
|
||||
this.setActivePage('customer-stall')
|
||||
if (productId) {
|
||||
this.activeProduct = productId
|
||||
}
|
||||
this.activeStall = stallId
|
||||
}
|
||||
if (merchantPubkey && !(this.merchants.find(m => m.publicKey === merchantPubkey))) {
|
||||
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.merchants.push({ publicKey: merchantPubkey, profile: null })
|
||||
})
|
||||
}
|
||||
|
||||
},
|
||||
restoreFromStorage() {
|
||||
this.merchants = this.$q.localStorage.getItem('nostrmarket.merchants') || []
|
||||
this.shoppingCarts = this.$q.localStorage.getItem('nostrmarket.shoppingCarts') || []
|
||||
|
||||
this.account = this.$q.localStorage.getItem('nostrmarket.account') || null
|
||||
|
||||
const uiConfig = this.$q.localStorage.getItem('nostrmarket.marketplaceConfig') || { ui: { darkMode: false } }
|
||||
|
||||
// trigger the `watch` logic
|
||||
this.config = { ...this.config, opts: { ...this.config.opts, ...uiConfig } }
|
||||
this.applyUiConfigs(this.config)
|
||||
|
||||
|
||||
const prefix = 'nostrmarket.orders.'
|
||||
const orderKeys = this.$q.localStorage.getAllKeys().filter(k => k.startsWith(prefix))
|
||||
orderKeys.forEach(k => {
|
||||
const pubkey = k.substring(prefix.length)
|
||||
this.orders[pubkey] = this.$q.localStorage.getItem(k)
|
||||
})
|
||||
|
||||
const relays = this.$q.localStorage.getItem('nostrmarket.relays')
|
||||
this.relays = new Set(relays?.length ? relays : defaultRelays)
|
||||
|
||||
const readNotes = this.$q.localStorage.getItem('nostrmarket.readNotes') || {}
|
||||
this.readNotes = { ...this.readNotes, ...readNotes }
|
||||
},
|
||||
applyUiConfigs(config = {}) {
|
||||
const { name, about, ui } = config?.opts || {}
|
||||
this.$q.localStorage.set('nostrmarket.marketplaceConfig', { name, about, ui })
|
||||
if (config.opts?.ui?.theme) {
|
||||
document.body.setAttribute('data-theme', this.config.opts.ui.theme)
|
||||
this.$q.localStorage.set('lnbits.theme', this.config.opts.ui.theme)
|
||||
}
|
||||
const newDarkMode = config.opts?.ui?.darkMode
|
||||
if (newDarkMode !== undefined) {
|
||||
const oldDarkMode = this.$q.localStorage.getItem('lnbits.darkMode')
|
||||
if (newDarkMode !== oldDarkMode) {
|
||||
this.$q.dark.toggle()
|
||||
this.$q.localStorage.set('lnbits.darkMode', newDarkMode)
|
||||
}
|
||||
|
||||
}
|
||||
},
|
||||
|
||||
async createAccount(useExtension = false) {
|
||||
let nip07
|
||||
if (useExtension) {
|
||||
await this.getFromExtension()
|
||||
nip07 = true
|
||||
}
|
||||
if (isValidKey(this.accountDialog.data.key, 'nsec')) {
|
||||
let { key, watchOnly } = this.accountDialog.data
|
||||
if (key.startsWith('n')) {
|
||||
let { type, data } = NostrTools.nip19.decode(key)
|
||||
key = data
|
||||
}
|
||||
const privkey = watchOnly ? null : key
|
||||
const pubkey = watchOnly ? key : NostrTools.getPublicKey(key)
|
||||
this.$q.localStorage.set('nostrmarket.account', {
|
||||
privkey,
|
||||
pubkey,
|
||||
nsec: NostrTools.nip19.nsecEncode(key),
|
||||
npub: NostrTools.nip19.npubEncode(pubkey),
|
||||
|
||||
useExtension: nip07 ?? false
|
||||
})
|
||||
this.accountDialog.data = {
|
||||
watchOnly: false,
|
||||
key: null
|
||||
}
|
||||
this.accountDialog.show = false
|
||||
this.account = this.$q.localStorage.getItem('nostrmarket.account')
|
||||
}
|
||||
this.accountDialog.show = false
|
||||
},
|
||||
generateKeyPair() {
|
||||
this.accountDialog.data.key = NostrTools.generatePrivateKey()
|
||||
this.accountDialog.data.watchOnly = false
|
||||
},
|
||||
async getFromExtension() {
|
||||
this.accountDialog.data.key = await window.nostr.getPublicKey()
|
||||
this.accountDialog.data.watchOnly = true
|
||||
return
|
||||
},
|
||||
openAccountDialog() {
|
||||
this.accountDialog.show = true
|
||||
},
|
||||
|
||||
|
||||
async updateUiConfig(data) {
|
||||
const { name, about, ui } = data
|
||||
this.config = { ...this.config, opts: { ...this.config.opts, name, about, ui } }
|
||||
this.applyUiConfigs(this.config)
|
||||
},
|
||||
|
||||
async updateData(events) {
|
||||
console.log('### updateData', events)
|
||||
if (events.length < 1) {
|
||||
this.$q.notify({
|
||||
message: 'No matches were found!'
|
||||
})
|
||||
return
|
||||
}
|
||||
let products = new Map()
|
||||
let stalls = new Map()
|
||||
const deleteEventsIds = events
|
||||
.filter(e => e.kind === 5)
|
||||
.map(e => (e.tags || []).filter(t => t[0] === 'e'))
|
||||
.flat()
|
||||
.map(t => t[1])
|
||||
.filter(t => !!t)
|
||||
|
||||
|
||||
this.stalls.forEach(s => stalls.set(s.id, s))
|
||||
this.products.forEach(p => products.set(p.id, p))
|
||||
|
||||
events.map(eventToObj).map(e => {
|
||||
if (e.kind == 0) {
|
||||
this.profiles.set(e.pubkey, e.content)
|
||||
if (e.pubkey == this.account?.pubkey) {
|
||||
this.accountMetadata = this.profiles.get(this.account.pubkey)
|
||||
}
|
||||
this.merchants.filter(m => m.publicKey === e.pubkey).forEach(m => m.profile = e.content)
|
||||
return
|
||||
} else if (e.kind == 5) {
|
||||
console.log('### delete event', e)
|
||||
} else if (e.kind == 30018) {
|
||||
//it's a product `d` is the prod. id
|
||||
products.set(e.d, { ...e.content, pubkey: e.pubkey, id: e.d, categories: e.t, eventId: e.id })
|
||||
} else if (e.kind == 30017) {
|
||||
// it's a stall `d` is the stall id
|
||||
stalls.set(e.d, { ...e.content, pubkey: e.pubkey, id: e.d, pubkey: e.pubkey })
|
||||
}
|
||||
})
|
||||
|
||||
this.stalls = await Array.from(stalls.values())
|
||||
|
||||
this.products = Array.from(products.values())
|
||||
.map(obj => {
|
||||
const stall = this.stalls.find(s => s.id == obj.stall_id)
|
||||
if (!stall) return
|
||||
obj.stallName = stall.name
|
||||
obj.images = obj.images || [obj.image]
|
||||
if (obj.currency != 'sat') {
|
||||
obj.formatedPrice = this.getAmountFormated(
|
||||
obj.price,
|
||||
obj.currency
|
||||
)
|
||||
}
|
||||
return obj
|
||||
})
|
||||
.filter(p => p && (deleteEventsIds.indexOf(p.eventId)) === -1)
|
||||
console.log('### this.products', this.products)
|
||||
},
|
||||
|
||||
async initNostr() {
|
||||
this.isLoading = true
|
||||
this.pool = new NostrTools.SimplePool()
|
||||
|
||||
const relays = Array.from(this.relays)
|
||||
|
||||
const authors = this.merchants.map(m => m.publicKey)
|
||||
const events = await this.pool.list(relays, [{ kinds: [0, 30017, 30018], authors }])
|
||||
if (!events || events.length == 0) return
|
||||
await this.updateData(events)
|
||||
|
||||
const lastEvent = events.sort((a, b) => b.created_at - a.created_at)[0]
|
||||
this.poolSubscribe(lastEvent.created_at)
|
||||
this.isLoading = false
|
||||
},
|
||||
async poolSubscribe(since) {
|
||||
const authors = this.merchants.map(m => m.publicKey)
|
||||
this.pool
|
||||
.sub(Array.from(this.relays), [{ kinds: [0, 5, 30017, 30018], authors, since }])
|
||||
.on(
|
||||
'event',
|
||||
event => {
|
||||
this.updateData([event])
|
||||
},
|
||||
{ id: 'masterSub' } //pass ID to cancel previous sub
|
||||
)
|
||||
},
|
||||
|
||||
async checkMarketNaddr(naddr) {
|
||||
if (!naddr) return
|
||||
|
||||
try {
|
||||
const { type, data } = NostrTools.nip19.decode(naddr)
|
||||
if (type !== 'naddr' || data.kind !== 30019) return // just double check
|
||||
this.config = {
|
||||
d: data.identifier,
|
||||
pubkey: data.pubkey,
|
||||
relays: data.relays
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(err)
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
try {
|
||||
// add relays to the set
|
||||
const pool = new NostrTools.SimplePool()
|
||||
this.config.relays.forEach(r => this.relays.add(r))
|
||||
const event = await pool.get(this.config.relays, {
|
||||
kinds: [30019],
|
||||
limit: 1,
|
||||
authors: [this.config.pubkey],
|
||||
'#d': [this.config.d]
|
||||
})
|
||||
|
||||
if (!event) return
|
||||
|
||||
this.config = { ... this.config, opts: JSON.parse(event.content) }
|
||||
|
||||
this.addMerchants(this.config.opts?.merchants)
|
||||
this.applyUiConfigs(this.config)
|
||||
|
||||
} catch (error) {
|
||||
console.warn(error)
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
navigateTo(page, opts = { stall: null, product: null, pubkey: null }) {
|
||||
console.log("### navigateTo", page, opts)
|
||||
|
||||
const { stall, product, pubkey } = opts
|
||||
const url = new URL(window.location)
|
||||
|
||||
const merchantPubkey = pubkey || this.stalls.find(s => s.id == stall)?.pubkey
|
||||
url.searchParams.set('merchant', merchantPubkey)
|
||||
|
||||
if (page === 'stall' || page === 'product') {
|
||||
if (stall) {
|
||||
this.activeStall = stall
|
||||
this.setActivePage('customer-stall')
|
||||
url.searchParams.set('stall', stall)
|
||||
|
||||
this.activeProduct = product
|
||||
if (product) {
|
||||
url.searchParams.set('product', product)
|
||||
} else {
|
||||
url.searchParams.delete('product')
|
||||
}
|
||||
}
|
||||
} else {
|
||||
this.activeStall = null
|
||||
this.activeProduct = null
|
||||
|
||||
url.searchParams.delete('merchant')
|
||||
url.searchParams.delete('stall')
|
||||
url.searchParams.delete('product')
|
||||
|
||||
this.setActivePage('market')
|
||||
}
|
||||
|
||||
window.history.pushState({}, '', url)
|
||||
// this.activePage = page
|
||||
},
|
||||
copyUrl: function () {
|
||||
this.copyText(window.location)
|
||||
},
|
||||
copyText: function (text) {
|
||||
var notify = this.$q.notify
|
||||
Quasar.utils.copyToClipboard(text).then(function () {
|
||||
notify({
|
||||
message: 'Copied to clipboard!',
|
||||
position: 'bottom'
|
||||
})
|
||||
})
|
||||
},
|
||||
getAmountFormated(amount, unit = 'USD') {
|
||||
return LNbits.utils.formatCurrency(amount, unit)
|
||||
},
|
||||
|
||||
setActivePage(page = 'market') {
|
||||
this.activePage = page
|
||||
},
|
||||
async addRelay(relayUrl) {
|
||||
let relay = String(relayUrl).trim()
|
||||
|
||||
this.relays.add(relay)
|
||||
this.$q.localStorage.set(`nostrmarket.relays`, Array.from(this.relays))
|
||||
this.initNostr() // todo: improve
|
||||
},
|
||||
removeRelay(relayUrl) {
|
||||
this.relays.delete(relayUrl)
|
||||
this.relays = new Set(Array.from(this.relays))
|
||||
this.$q.localStorage.set(`nostrmarket.relays`, Array.from(this.relays))
|
||||
this.initNostr() // todo: improve
|
||||
},
|
||||
|
||||
|
||||
|
||||
addMerchant(publicKey) {
|
||||
this.merchants.unshift({
|
||||
publicKey,
|
||||
profile: null
|
||||
})
|
||||
this.$q.localStorage.set('nostrmarket.merchants', this.merchants)
|
||||
this.initNostr() // todo: improve
|
||||
},
|
||||
addMerchants(publicKeys = []) {
|
||||
const merchantsPubkeys = this.merchants.map(m => m.publicKey)
|
||||
|
||||
const newMerchants = publicKeys
|
||||
.filter(p => merchantsPubkeys.indexOf(p) === -1)
|
||||
.map(p => ({ publicKey: p, profile: null }))
|
||||
this.merchants.unshift(...newMerchants)
|
||||
this.$q.localStorage.set('nostrmarket.merchants', this.merchants)
|
||||
this.initNostr() // todo: improve
|
||||
},
|
||||
removeMerchant(publicKey) {
|
||||
this.merchants = this.merchants.filter(m => m.publicKey !== publicKey)
|
||||
this.$q.localStorage.set('nostrmarket.merchants', this.merchants)
|
||||
this.products = this.products.filter(p => p.pubkey !== publicKey)
|
||||
this.stalls = this.stalls.filter(p => p.pubkey !== publicKey)
|
||||
this.initNostr() // todo: improve
|
||||
},
|
||||
|
||||
addProductToCart(item) {
|
||||
let stallCart = this.shoppingCarts.find(s => s.id === item.stall_id)
|
||||
if (!stallCart) {
|
||||
stallCart = {
|
||||
id: item.stall_id,
|
||||
products: []
|
||||
}
|
||||
this.shoppingCarts.push(stallCart)
|
||||
}
|
||||
stallCart.merchant = this.merchants.find(m => m.publicKey === item.pubkey)
|
||||
|
||||
let product = stallCart.products.find(p => p.id === item.id)
|
||||
if (!product) {
|
||||
product = { ...item, orderedQuantity: 0 }
|
||||
stallCart.products.push(product)
|
||||
|
||||
}
|
||||
product.orderedQuantity = Math.min(product.quantity, item.orderedQuantity || (product.orderedQuantity + 1))
|
||||
|
||||
this.$q.localStorage.set('nostrmarket.shoppingCarts', this.shoppingCarts)
|
||||
|
||||
this.$q.notify({
|
||||
type: 'positive',
|
||||
message: 'Product added to cart!'
|
||||
})
|
||||
},
|
||||
|
||||
removeProductFromCart(item) {
|
||||
const stallCart = this.shoppingCarts.find(c => c.id === item.stallId)
|
||||
if (stallCart) {
|
||||
stallCart.products = stallCart.products.filter(p => p.id !== item.productId)
|
||||
if (!stallCart.products.length) {
|
||||
this.shoppingCarts = this.shoppingCarts.filter(s => s.id !== item.stallId)
|
||||
}
|
||||
this.$q.localStorage.set('nostrmarket.shoppingCarts', this.shoppingCarts)
|
||||
}
|
||||
},
|
||||
removeCart(cartId) {
|
||||
this.shoppingCarts = this.shoppingCarts.filter(s => s.id !== cartId)
|
||||
this.$q.localStorage.set('nostrmarket.shoppingCarts', this.shoppingCarts)
|
||||
},
|
||||
|
||||
checkoutStallCart(cart) {
|
||||
this.checkoutCart = cart
|
||||
this.checkoutStall = this.stalls.find(s => s.id === cart.id)
|
||||
this.setActivePage('shopping-cart-checkout')
|
||||
},
|
||||
|
||||
async placeOrder({ event, order, cartId }) {
|
||||
if (!this.account?.privkey) {
|
||||
this.openAccountDialog()
|
||||
return
|
||||
}
|
||||
try {
|
||||
this.activeOrderId = order.id
|
||||
event.content = await NostrTools.nip04.encrypt(
|
||||
this.account.privkey,
|
||||
this.checkoutStall.pubkey,
|
||||
JSON.stringify(order)
|
||||
)
|
||||
|
||||
event.id = NostrTools.getEventHash(event)
|
||||
event.sig = await NostrTools.signEvent(event, this.account.privkey)
|
||||
|
||||
this.sendOrderEvent(event)
|
||||
this.persistOrderUpdate(this.checkoutStall.pubkey, event.created_at, order)
|
||||
this.removeCart(cartId)
|
||||
this.setActivePage('shopping-cart-list')
|
||||
} catch (error) {
|
||||
console.warn(error)
|
||||
this.$q.notify({
|
||||
type: 'warning',
|
||||
message: 'Failed to place order!'
|
||||
})
|
||||
}
|
||||
},
|
||||
|
||||
sendOrderEvent(event) {
|
||||
const pub = this.pool.publish(Array.from(this.relays), event)
|
||||
this.$q.notify({
|
||||
type: 'positive',
|
||||
message: 'The order has been placed!'
|
||||
})
|
||||
this.qrCodeDialog = {
|
||||
data: {
|
||||
payment_request: null,
|
||||
message: null,
|
||||
},
|
||||
dismissMsg: null,
|
||||
show: true
|
||||
}
|
||||
pub.on('ok', () => {
|
||||
this.qrCodeDialog.show = true
|
||||
})
|
||||
pub.on('failed', (error) => {
|
||||
// do not show to user. It is possible that only one relay has failed
|
||||
console.error(error)
|
||||
})
|
||||
},
|
||||
|
||||
async listenForIncommingDms(from) {
|
||||
if (!this.account?.privkey) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const filters = [{
|
||||
kinds: [4],
|
||||
'#p': [this.account.pubkey],
|
||||
}, {
|
||||
kinds: [4],
|
||||
authors: [this.account.pubkey],
|
||||
}]
|
||||
|
||||
const subs = this.pool.sub(Array.from(this.relays), filters)
|
||||
subs.on('event', async event => {
|
||||
const receiverPubkey = event.tags.find(([k, v]) => k === 'p' && v && v !== '')[1]
|
||||
const isSentByMe = event.pubkey === this.account.pubkey
|
||||
if (receiverPubkey !== this.account.pubkey && !isSentByMe) {
|
||||
console.warn('Unexpected DM. Dropped!')
|
||||
return
|
||||
}
|
||||
this.persistDMEvent(event)
|
||||
const peerPubkey = isSentByMe ? receiverPubkey : event.pubkey
|
||||
await this.handleIncommingDm(event, peerPubkey)
|
||||
})
|
||||
return subs
|
||||
} catch (err) {
|
||||
console.error(`Error: ${err}`)
|
||||
}
|
||||
},
|
||||
async handleIncommingDm(event, peerPubkey) {
|
||||
try {
|
||||
|
||||
const plainText = await NostrTools.nip04.decrypt(
|
||||
this.account.privkey,
|
||||
peerPubkey,
|
||||
event.content
|
||||
)
|
||||
console.log('### plainText', plainText)
|
||||
if (!isJson(plainText)) return
|
||||
|
||||
const jsonData = JSON.parse(plainText)
|
||||
if ([0, 1, 2].indexOf(jsonData.type) !== -1) {
|
||||
this.persistOrderUpdate(peerPubkey, event.created_at, jsonData)
|
||||
}
|
||||
if (jsonData.type === 1) {
|
||||
this.handlePaymentRequest(jsonData)
|
||||
|
||||
} else if (jsonData.type === 2) {
|
||||
this.handleOrderStatusUpdate(jsonData)
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('Unable to handle incomming DM', e)
|
||||
}
|
||||
},
|
||||
|
||||
handlePaymentRequest(json) {
|
||||
if (json.id && (json.id !== this.activeOrderId)) {
|
||||
// not for active order, store somewehre else
|
||||
return
|
||||
}
|
||||
if (!json.payment_options?.length) {
|
||||
this.qrCodeDialog.data.message = json.message || 'Unexpected error'
|
||||
return
|
||||
}
|
||||
|
||||
const paymentRequest = json.payment_options.find(o => o.type == 'ln')
|
||||
.link
|
||||
if (!paymentRequest) return
|
||||
this.qrCodeDialog.data.payment_request = paymentRequest
|
||||
this.qrCodeDialog.dismissMsg = this.$q.notify({
|
||||
timeout: 10000,
|
||||
message: 'Waiting for payment...'
|
||||
})
|
||||
|
||||
},
|
||||
|
||||
handleOrderStatusUpdate(jsonData) {
|
||||
if (jsonData.id && (jsonData.id !== this.activeOrderId)) {
|
||||
// not for active order, store somewehre else
|
||||
return
|
||||
}
|
||||
if (this.qrCodeDialog.dismissMsg) {
|
||||
this.qrCodeDialog.dismissMsg()
|
||||
}
|
||||
this.qrCodeDialog.show = false
|
||||
const message = jsonData.shipped ? 'Order shipped' : jsonData.paid ? 'Order paid' : 'Order notification'
|
||||
this.$q.notify({
|
||||
type: 'positive',
|
||||
message: message,
|
||||
caption: jsonData.message || ''
|
||||
})
|
||||
},
|
||||
|
||||
persistDMEvent(event) {
|
||||
const dms = this.$q.localStorage.getItem(`nostrmarket.dm.${event.pubkey}`) || { events: [], lastCreatedAt: 0 }
|
||||
const existingEvent = dms.events.find(e => e.id === event.id)
|
||||
if (existingEvent) return
|
||||
|
||||
dms.events.push(event)
|
||||
dms.events.sort((a, b) => a - b)
|
||||
dms.lastCreatedAt = dms.events[dms.events.length - 1].created_at
|
||||
this.$q.localStorage.set(`nostrmarket.dm.${event.pubkey}`, dms)
|
||||
},
|
||||
|
||||
lastDmForPubkey(pubkey) {
|
||||
const dms = this.$q.localStorage.getItem(`nostrmarket.dm.${pubkey}`)
|
||||
if (!dms) return 0
|
||||
return dms.lastCreatedAt
|
||||
},
|
||||
|
||||
persistOrderUpdate(pubkey, eventCreatedAt, orderUpdate) {
|
||||
let orders = this.$q.localStorage.getItem(`nostrmarket.orders.${pubkey}`) || []
|
||||
const orderIndex = orders.findIndex(o => o.id === orderUpdate.id)
|
||||
|
||||
if (orderIndex === -1) {
|
||||
orders.unshift({
|
||||
...orderUpdate,
|
||||
eventCreatedAt,
|
||||
createdAt: eventCreatedAt
|
||||
})
|
||||
this.orders[pubkey] = orders
|
||||
this.orders = { ...this.orders }
|
||||
this.$q.localStorage.set(`nostrmarket.orders.${pubkey}`, orders)
|
||||
return
|
||||
}
|
||||
|
||||
let order = orders[orderIndex]
|
||||
|
||||
if (orderUpdate.type === 0) {
|
||||
order.createdAt = eventCreatedAt
|
||||
order = { ...order, ...orderUpdate, message: order.message || orderUpdate.message }
|
||||
} else {
|
||||
order = order.eventCreatedAt < eventCreatedAt ? { ...order, ...orderUpdate } : { ...orderUpdate, ...order }
|
||||
}
|
||||
|
||||
orders.splice(orderIndex, 1, order)
|
||||
this.orders[pubkey] = orders
|
||||
this.orders = { ...this.orders }
|
||||
this.$q.localStorage.set(`nostrmarket.orders.${pubkey}`, orders)
|
||||
},
|
||||
|
||||
showInvoiceQr(invoice) {
|
||||
if (!invoice) return
|
||||
this.qrCodeDialog = {
|
||||
data: {
|
||||
payment_request: invoice
|
||||
},
|
||||
dismissMsg: null,
|
||||
show: true
|
||||
}
|
||||
},
|
||||
|
||||
toggleCategoryFilter(category) {
|
||||
const index = this.filterCategories.indexOf(category)
|
||||
if (index === -1) {
|
||||
this.filterCategories.push(category)
|
||||
} else {
|
||||
this.filterCategories.splice(index, 1)
|
||||
}
|
||||
},
|
||||
|
||||
hasCategory(categories = []) {
|
||||
if (!this.filterCategories?.length) return true
|
||||
for (const cat of categories) {
|
||||
if (this.filterCategories.indexOf(cat) !== -1) return true
|
||||
}
|
||||
return false
|
||||
},
|
||||
allStallCatgories(stallId) {
|
||||
const categories = this.products.filter(p => p.stall_id === stallId).map(p => p.categories).flat().filter(c => !!c)
|
||||
return Array.from(new Set(categories))
|
||||
},
|
||||
allStallImages(stallId) {
|
||||
const images = this.products.filter(p => p.stall_id === stallId).map(p => p.images && p.images[0]).filter(i => !!i)
|
||||
return Array.from(new Set(images))
|
||||
},
|
||||
|
||||
sanitizeImageSrc(src, defaultValue) {
|
||||
try {
|
||||
if (src) {
|
||||
new URL(src)
|
||||
return src
|
||||
}
|
||||
} catch { }
|
||||
return defaultValue
|
||||
},
|
||||
|
||||
async publishNaddr() {
|
||||
if (!this.account?.privkey) {
|
||||
this.openAccountDialog()
|
||||
this.$q.notify({
|
||||
message: 'Login Required!',
|
||||
icon: 'warning'
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
const merchants = Array.from(this.merchants.map(m => m.publicKey))
|
||||
const { name, about, ui } = this.config?.opts || {}
|
||||
const content = { merchants, name, about, ui }
|
||||
const identifier = this.config.identifier ?? crypto.randomUUID()
|
||||
const event = {
|
||||
...(await NostrTools.getBlankEvent()),
|
||||
kind: 30019,
|
||||
content: JSON.stringify(content),
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
tags: [['d', identifier]],
|
||||
pubkey: this.account.pubkey
|
||||
}
|
||||
event.id = NostrTools.getEventHash(event)
|
||||
try {
|
||||
event.sig = await NostrTools.signEvent(event, this.account.privkey)
|
||||
|
||||
const pub = this.pool.publish(Array.from(this.relays), event)
|
||||
pub.on('ok', () => {
|
||||
console.debug(`Config event was sent`)
|
||||
})
|
||||
pub.on('failed', error => {
|
||||
console.error(error)
|
||||
})
|
||||
} catch (err) {
|
||||
console.error(err)
|
||||
this.$q.notify({
|
||||
message: 'Cannot publish market profile',
|
||||
caption: `Error: ${err}`,
|
||||
color: 'negative'
|
||||
})
|
||||
return
|
||||
}
|
||||
const naddr = NostrTools.nip19.naddrEncode({
|
||||
pubkey: event.pubkey,
|
||||
kind: 30019,
|
||||
identifier: identifier,
|
||||
relays: Array.from(this.relays)
|
||||
})
|
||||
this.copyText(naddr)
|
||||
},
|
||||
|
||||
logout() {
|
||||
window.localStorage.removeItem('nostrmarket.account')
|
||||
window.location.href = window.location.origin + window.location.pathname;
|
||||
this.account = null
|
||||
this.accountMetadata = null
|
||||
},
|
||||
|
||||
clearAllData() {
|
||||
LNbits.utils
|
||||
.confirmDialog(
|
||||
'This will remove all information about merchants, products, relays and others. ' +
|
||||
'You will NOT be logged out. Do you want to proceed?'
|
||||
)
|
||||
.onOk(async () => {
|
||||
this.$q.localStorage.getAllKeys()
|
||||
.filter(key => key !== 'nostrmarket.account')
|
||||
.forEach(key => window.localStorage.removeItem(key))
|
||||
|
||||
this.merchants = []
|
||||
this.relays = []
|
||||
this.orders = []
|
||||
this.config = { opts: null }
|
||||
this.shoppingCarts = []
|
||||
this.checkoutCart = null
|
||||
window.location.href = window.location.origin + window.location.pathname;
|
||||
|
||||
})
|
||||
|
||||
},
|
||||
markNoteAsRead(noteId) {
|
||||
this.readNotes[noteId] = true
|
||||
this.$q.localStorage.set('nostrmarket.readNotes', this.readNotes)
|
||||
}
|
||||
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
market()
|
||||
8705
static/js/nostr.bundle.js
Normal file
1
static/market/assets/ErrorNotFound.db627eb7.js
Normal file
|
|
@ -0,0 +1 @@
|
|||
import{_ as t,y as o,A as s,aU as a,D as e,E as n,aQ as r}from"./index.725caa24.js";const c=o({name:"ErrorNotFound"}),l={class:"fullscreen bg-blue text-white text-center q-pa-md flex flex-center"},d=e("div",{style:{"font-size":"30vh"}}," 404 ",-1),i=e("div",{class:"text-h2",style:{opacity:".4"}}," Oops. Nothing here... ",-1);function _(p,f,u,h,x,m){return s(),a("div",l,[e("div",null,[d,i,n(r,{class:"q-mt-xl",color:"white","text-color":"blue",unelevated:"",to:"/",label:"Go Home","no-caps":""})])])}var N=t(c,[["render",_]]);export{N as default};
|
||||
BIN
static/market/assets/KFOkCnqEu92Fr1MmgVxIIzQ.34e9582c.woff
Normal file
BIN
static/market/assets/KFOlCnqEu92Fr1MmEU9fBBc-.9ce7f3ac.woff
Normal file
BIN
static/market/assets/KFOlCnqEu92Fr1MmSU5fBBc-.bf14c7d7.woff
Normal file
BIN
static/market/assets/KFOlCnqEu92Fr1MmWUlfBBc-.e0fd57c0.woff
Normal file
BIN
static/market/assets/KFOlCnqEu92Fr1MmYUtfBBc-.f6537e32.woff
Normal file
BIN
static/market/assets/KFOmCnqEu92Fr1Mu4mxM.f2abf7fb.woff
Normal file
1
static/market/assets/MainLayout.421c5479.js
Normal file
18
static/market/assets/MarketPage.2aa781b5.js
Normal file
1
static/market/assets/QResizeObserver.bcb70109.js
Normal file
|
|
@ -0,0 +1 @@
|
|||
import{r as g,s as z,o as c,c as y,f,n as w,F as v,h as R,g as O,k as b}from"./index.725caa24.js";function x(){const r=g(!z.value);return r.value===!1&&c(()=>{r.value=!0}),r}const m=typeof ResizeObserver!="undefined",h=m===!0?{}:{style:"display:block;position:absolute;top:0;left:0;right:0;bottom:0;height:100%;width:100%;overflow:hidden;pointer-events:none;z-index:-1;",url:"about:blank"};var L=y({name:"QResizeObserver",props:{debounce:{type:[String,Number],default:100}},emits:["resize"],setup(r,{emit:p}){let i=null,t,o={width:-1,height:-1};function s(e){e===!0||r.debounce===0||r.debounce==="0"?u():i===null&&(i=setTimeout(u,r.debounce))}function u(){if(i!==null&&(clearTimeout(i),i=null),t){const{offsetWidth:e,offsetHeight:n}=t;(e!==o.width||n!==o.height)&&(o={width:e,height:n},p("resize",o))}}const{proxy:a}=O();if(m===!0){let e;const n=l=>{t=a.$el.parentNode,t?(e=new ResizeObserver(s),e.observe(t),u()):l!==!0&&v(()=>{n(!0)})};return c(()=>{n()}),f(()=>{i!==null&&clearTimeout(i),e!==void 0&&(e.disconnect!==void 0?e.disconnect():t&&e.unobserve(t))}),w}else{let l=function(){i!==null&&(clearTimeout(i),i=null),n!==void 0&&(n.removeEventListener!==void 0&&n.removeEventListener("resize",s,b.passive),n=void 0)},d=function(){l(),t&&t.contentDocument&&(n=t.contentDocument.defaultView,n.addEventListener("resize",s,b.passive),u())};const e=x();let n;return c(()=>{v(()=>{t=a.$el,t&&d()})}),f(l),a.trigger=s,()=>{if(e.value===!0)return R("object",{style:h.style,tabindex:-1,type:"text/html",data:h.url,"aria-hidden":"true",onLoad:d})}}}});export{L as Q};
|
||||
BIN
static/market/assets/flUhRq6tzZclQEJ-Vdg-IuiaDsNa.fd84f88b.woff
Normal file
1
static/market/assets/index.5f2eed21.css
Normal file
5
static/market/assets/index.725caa24.js
Normal file
BIN
static/market/favicon.ico
Normal file
|
After Width: | Height: | Size: 245 KiB |
BIN
static/market/icons/favicon-128x128.png
Normal file
|
After Width: | Height: | Size: 47 KiB |
BIN
static/market/icons/favicon-16x16.png
Normal file
|
After Width: | Height: | Size: 967 B |
BIN
static/market/icons/favicon-32x32.png
Normal file
|
After Width: | Height: | Size: 3.4 KiB |
BIN
static/market/icons/favicon-64x64.png
Normal file
|
After Width: | Height: | Size: 13 KiB |
BIN
static/market/icons/favicon-96x96.png
Normal file
|
After Width: | Height: | Size: 27 KiB |
BIN
static/market/images/bitcoin-shop.png
Normal file
|
After Width: | Height: | Size: 5.9 KiB |
BIN
static/market/images/blank-avatar.webp
Normal file
|
After Width: | Height: | Size: 11 KiB |
BIN
static/market/images/nostr-avatar.png
Normal file
|
After Width: | Height: | Size: 784 KiB |
BIN
static/market/images/nostr-cover.png
Normal file
|
After Width: | Height: | Size: 1.1 MiB |
BIN
static/market/images/placeholder.png
Normal file
|
After Width: | Height: | Size: 2.8 KiB |
27
static/market/index.html
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
<!DOCTYPE html>
|
||||
<html>
|
||||
|
||||
<head>
|
||||
<title>Nostr Market App</title>
|
||||
<meta charset=utf-8>
|
||||
<meta name=description content="A Nostr marketplace">
|
||||
<meta name=format-detection content="telephone=no">
|
||||
<meta name=msapplication-tap-highlight content=no>
|
||||
<meta name=viewport content="user-scalable=no,initial-scale=1,maximum-scale=1,minimum-scale=1,width=device-width">
|
||||
<script src="/nostrmarket/static/market/js/nostr.bundle.js"></script>
|
||||
<script src="/nostrmarket/static/market/js/bolt11-decoder.js"></script>
|
||||
<script src="/nostrmarket/static/market/js/utils.js"></script>
|
||||
<link rel=icon type=image/png sizes=128x128 href="/nostrmarket/static/market/icons/favicon-128x128.png">
|
||||
<link rel=icon type=image/png sizes=96x96 href="/nostrmarket/static/market/icons/favicon-96x96.png">
|
||||
<link rel=icon type=image/png sizes=32x32 href="/nostrmarket/static/market/icons/favicon-32x32.png">
|
||||
<link rel=icon type=image/png sizes=16x16 href="/nostrmarket/static/market/icons/favicon-16x16.png">
|
||||
<link rel=icon type=image/ico href="/nostrmarket/static/market/favicon.ico">
|
||||
<script type="module" crossorigin src="/nostrmarket/static/market/assets/index.725caa24.js"></script>
|
||||
<link rel="stylesheet" href="/nostrmarket/static/market/assets/index.5f2eed21.css">
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div id=q-app></div>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
347
static/market/js/bolt11-decoder.js
Normal file
|
|
@ -0,0 +1,347 @@
|
|||
//TODO - A reader MUST check that the signature is valid (see the n tagged field)
|
||||
//TODO - Tagged part of type f: the fallback on-chain address should be decoded into an address format
|
||||
//TODO - A reader MUST check that the SHA-2 256 in the h field exactly matches the hashed description.
|
||||
//TODO - A reader MUST use the n field to validate the signature instead of performing signature recovery if a valid n field is provided.
|
||||
|
||||
function decode(paymentRequest) {
|
||||
let input = paymentRequest.toLowerCase()
|
||||
let splitPosition = input.lastIndexOf('1')
|
||||
let humanReadablePart = input.substring(0, splitPosition)
|
||||
let data = input.substring(splitPosition + 1, input.length - 6)
|
||||
let checksum = input.substring(input.length - 6, input.length)
|
||||
if (
|
||||
!verify_checksum(humanReadablePart, bech32ToFiveBitArray(data + checksum))
|
||||
) {
|
||||
throw 'Malformed request: checksum is incorrect' // A reader MUST fail if the checksum is incorrect.
|
||||
}
|
||||
return {
|
||||
human_readable_part: decodeHumanReadablePart(humanReadablePart),
|
||||
data: decodeData(data, humanReadablePart),
|
||||
checksum: checksum
|
||||
}
|
||||
}
|
||||
|
||||
function decodeHumanReadablePart(humanReadablePart) {
|
||||
let prefixes = ['lnbc', 'lntb', 'lnbcrt', 'lnsb']
|
||||
let prefix
|
||||
prefixes.forEach(value => {
|
||||
if (humanReadablePart.substring(0, value.length) === value) {
|
||||
prefix = value
|
||||
}
|
||||
})
|
||||
if (prefix == null) throw 'Malformed request: unknown prefix' // A reader MUST fail if it does not understand the prefix.
|
||||
let amount = decodeAmount(
|
||||
humanReadablePart.substring(prefix.length, humanReadablePart.length)
|
||||
)
|
||||
return {
|
||||
prefix: prefix,
|
||||
amount: amount
|
||||
}
|
||||
}
|
||||
|
||||
function decodeData(data, humanReadablePart) {
|
||||
let date32 = data.substring(0, 7)
|
||||
let dateEpoch = bech32ToInt(date32)
|
||||
let signature = data.substring(data.length - 104, data.length)
|
||||
let tagData = data.substring(7, data.length - 104)
|
||||
let decodedTags = decodeTags(tagData)
|
||||
let value = bech32ToFiveBitArray(date32 + tagData)
|
||||
value = fiveBitArrayTo8BitArray(value, true)
|
||||
value = textToHexString(humanReadablePart).concat(byteArrayToHexString(value))
|
||||
return {
|
||||
time_stamp: dateEpoch,
|
||||
tags: decodedTags,
|
||||
signature: decodeSignature(signature),
|
||||
signing_data: value
|
||||
}
|
||||
}
|
||||
|
||||
function decodeSignature(signature) {
|
||||
let data = fiveBitArrayTo8BitArray(bech32ToFiveBitArray(signature))
|
||||
let recoveryFlag = data[data.length - 1]
|
||||
let r = byteArrayToHexString(data.slice(0, 32))
|
||||
let s = byteArrayToHexString(data.slice(32, data.length - 1))
|
||||
return {
|
||||
r: r,
|
||||
s: s,
|
||||
recovery_flag: recoveryFlag
|
||||
}
|
||||
}
|
||||
|
||||
function decodeAmount(str) {
|
||||
let multiplier = str.charAt(str.length - 1)
|
||||
let amount = str.substring(0, str.length - 1)
|
||||
if (amount.substring(0, 1) === '0') {
|
||||
throw 'Malformed request: amount cannot contain leading zeros'
|
||||
}
|
||||
amount = Number(amount)
|
||||
if (amount < 0 || !Number.isInteger(amount)) {
|
||||
throw 'Malformed request: amount must be a positive decimal integer' // A reader SHOULD fail if amount contains a non-digit
|
||||
}
|
||||
|
||||
switch (multiplier) {
|
||||
case '':
|
||||
return 'Any amount' // A reader SHOULD indicate if amount is unspecified
|
||||
case 'p':
|
||||
return amount / 10
|
||||
case 'n':
|
||||
return amount * 100
|
||||
case 'u':
|
||||
return amount * 100000
|
||||
case 'm':
|
||||
return amount * 100000000
|
||||
default:
|
||||
// A reader SHOULD fail if amount is followed by anything except a defined multiplier.
|
||||
throw 'Malformed request: undefined amount multiplier'
|
||||
}
|
||||
}
|
||||
|
||||
function decodeTags(tagData) {
|
||||
let tags = extractTags(tagData)
|
||||
let decodedTags = []
|
||||
tags.forEach(value =>
|
||||
decodedTags.push(decodeTag(value.type, value.length, value.data))
|
||||
)
|
||||
return decodedTags
|
||||
}
|
||||
|
||||
function extractTags(str) {
|
||||
let tags = []
|
||||
while (str.length > 0) {
|
||||
let type = str.charAt(0)
|
||||
let dataLength = bech32ToInt(str.substring(1, 3))
|
||||
let data = str.substring(3, dataLength + 3)
|
||||
tags.push({
|
||||
type: type,
|
||||
length: dataLength,
|
||||
data: data
|
||||
})
|
||||
str = str.substring(3 + dataLength, str.length)
|
||||
}
|
||||
return tags
|
||||
}
|
||||
|
||||
function decodeTag(type, length, data) {
|
||||
switch (type) {
|
||||
case 'p':
|
||||
if (length !== 52) break // A reader MUST skip over a 'p' field that does not have data_length 52
|
||||
return {
|
||||
type: type,
|
||||
length: length,
|
||||
description: 'payment_hash',
|
||||
value: byteArrayToHexString(
|
||||
fiveBitArrayTo8BitArray(bech32ToFiveBitArray(data))
|
||||
)
|
||||
}
|
||||
case 'd':
|
||||
return {
|
||||
type: type,
|
||||
length: length,
|
||||
description: 'description',
|
||||
value: bech32ToUTF8String(data)
|
||||
}
|
||||
case 'n':
|
||||
if (length !== 53) break // A reader MUST skip over a 'n' field that does not have data_length 53
|
||||
return {
|
||||
type: type,
|
||||
length: length,
|
||||
description: 'payee_public_key',
|
||||
value: byteArrayToHexString(
|
||||
fiveBitArrayTo8BitArray(bech32ToFiveBitArray(data))
|
||||
)
|
||||
}
|
||||
case 'h':
|
||||
if (length !== 52) break // A reader MUST skip over a 'h' field that does not have data_length 52
|
||||
return {
|
||||
type: type,
|
||||
length: length,
|
||||
description: 'description_hash',
|
||||
value: data
|
||||
}
|
||||
case 'x':
|
||||
return {
|
||||
type: type,
|
||||
length: length,
|
||||
description: 'expiry',
|
||||
value: bech32ToInt(data)
|
||||
}
|
||||
case 'c':
|
||||
return {
|
||||
type: type,
|
||||
length: length,
|
||||
description: 'min_final_cltv_expiry',
|
||||
value: bech32ToInt(data)
|
||||
}
|
||||
case 'f':
|
||||
let version = bech32ToFiveBitArray(data.charAt(0))[0]
|
||||
if (version < 0 || version > 18) break // a reader MUST skip over an f field with unknown version.
|
||||
data = data.substring(1, data.length)
|
||||
return {
|
||||
type: type,
|
||||
length: length,
|
||||
description: 'fallback_address',
|
||||
value: {
|
||||
version: version,
|
||||
fallback_address: data
|
||||
}
|
||||
}
|
||||
case 'r':
|
||||
data = fiveBitArrayTo8BitArray(bech32ToFiveBitArray(data))
|
||||
let pubkey = data.slice(0, 33)
|
||||
let shortChannelId = data.slice(33, 41)
|
||||
let feeBaseMsat = data.slice(41, 45)
|
||||
let feeProportionalMillionths = data.slice(45, 49)
|
||||
let cltvExpiryDelta = data.slice(49, 51)
|
||||
return {
|
||||
type: type,
|
||||
length: length,
|
||||
description: 'routing_information',
|
||||
value: {
|
||||
public_key: byteArrayToHexString(pubkey),
|
||||
short_channel_id: byteArrayToHexString(shortChannelId),
|
||||
fee_base_msat: byteArrayToInt(feeBaseMsat),
|
||||
fee_proportional_millionths: byteArrayToInt(
|
||||
feeProportionalMillionths
|
||||
),
|
||||
cltv_expiry_delta: byteArrayToInt(cltvExpiryDelta)
|
||||
}
|
||||
}
|
||||
default:
|
||||
// reader MUST skip over unknown fields
|
||||
}
|
||||
}
|
||||
|
||||
function polymod(values) {
|
||||
let GEN = [0x3b6a57b2, 0x26508e6d, 0x1ea119fa, 0x3d4233dd, 0x2a1462b3]
|
||||
let chk = 1
|
||||
values.forEach(value => {
|
||||
let b = chk >> 25
|
||||
chk = ((chk & 0x1ffffff) << 5) ^ value
|
||||
for (let i = 0; i < 5; i++) {
|
||||
if (((b >> i) & 1) === 1) {
|
||||
chk ^= GEN[i]
|
||||
} else {
|
||||
chk ^= 0
|
||||
}
|
||||
}
|
||||
})
|
||||
return chk
|
||||
}
|
||||
|
||||
function expand(str) {
|
||||
let array = []
|
||||
for (let i = 0; i < str.length; i++) {
|
||||
array.push(str.charCodeAt(i) >> 5)
|
||||
}
|
||||
array.push(0)
|
||||
for (let i = 0; i < str.length; i++) {
|
||||
array.push(str.charCodeAt(i) & 31)
|
||||
}
|
||||
return array
|
||||
}
|
||||
|
||||
function verify_checksum(hrp, data) {
|
||||
hrp = expand(hrp)
|
||||
let all = hrp.concat(data)
|
||||
let bool = polymod(all)
|
||||
return bool === 1
|
||||
}
|
||||
|
||||
const bech32CharValues = 'qpzry9x8gf2tvdw0s3jn54khce6mua7l'
|
||||
|
||||
function byteArrayToInt(byteArray) {
|
||||
let value = 0
|
||||
for (let i = 0; i < byteArray.length; ++i) {
|
||||
value = (value << 8) + byteArray[i]
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
function bech32ToInt(str) {
|
||||
let sum = 0
|
||||
for (let i = 0; i < str.length; i++) {
|
||||
sum = sum * 32
|
||||
sum = sum + bech32CharValues.indexOf(str.charAt(i))
|
||||
}
|
||||
return sum
|
||||
}
|
||||
|
||||
function bech32ToFiveBitArray(str) {
|
||||
let array = []
|
||||
for (let i = 0; i < str.length; i++) {
|
||||
array.push(bech32CharValues.indexOf(str.charAt(i)))
|
||||
}
|
||||
return array
|
||||
}
|
||||
|
||||
function fiveBitArrayTo8BitArray(int5Array, includeOverflow) {
|
||||
let count = 0
|
||||
let buffer = 0
|
||||
let byteArray = []
|
||||
int5Array.forEach(value => {
|
||||
buffer = (buffer << 5) + value
|
||||
count += 5
|
||||
if (count >= 8) {
|
||||
byteArray.push((buffer >> (count - 8)) & 255)
|
||||
count -= 8
|
||||
}
|
||||
})
|
||||
if (includeOverflow && count > 0) {
|
||||
byteArray.push((buffer << (8 - count)) & 255)
|
||||
}
|
||||
return byteArray
|
||||
}
|
||||
|
||||
function bech32ToUTF8String(str) {
|
||||
let int5Array = bech32ToFiveBitArray(str)
|
||||
let byteArray = fiveBitArrayTo8BitArray(int5Array)
|
||||
|
||||
let utf8String = ''
|
||||
for (let i = 0; i < byteArray.length; i++) {
|
||||
utf8String += '%' + ('0' + byteArray[i].toString(16)).slice(-2)
|
||||
}
|
||||
return decodeURIComponent(utf8String)
|
||||
}
|
||||
|
||||
function byteArrayToHexString(byteArray) {
|
||||
return Array.prototype.map
|
||||
.call(byteArray, function (byte) {
|
||||
return ('0' + (byte & 0xff).toString(16)).slice(-2)
|
||||
})
|
||||
.join('')
|
||||
}
|
||||
|
||||
function textToHexString(text) {
|
||||
let hexString = ''
|
||||
for (let i = 0; i < text.length; i++) {
|
||||
hexString += text.charCodeAt(i).toString(16)
|
||||
}
|
||||
return hexString
|
||||
}
|
||||
|
||||
function epochToDate(int) {
|
||||
let date = new Date(int * 1000)
|
||||
return date.toUTCString()
|
||||
}
|
||||
|
||||
function isEmptyOrSpaces(str) {
|
||||
return str === null || str.match(/^ *$/) !== null
|
||||
}
|
||||
|
||||
function toFixed(x) {
|
||||
if (Math.abs(x) < 1.0) {
|
||||
var e = parseInt(x.toString().split('e-')[1])
|
||||
if (e) {
|
||||
x *= Math.pow(10, e - 1)
|
||||
x = '0.' + new Array(e).join('0') + x.toString().substring(2)
|
||||
}
|
||||
} else {
|
||||
var e = parseInt(x.toString().split('+')[1])
|
||||
if (e > 20) {
|
||||
e -= 20
|
||||
x /= Math.pow(10, e)
|
||||
x += new Array(e + 1).join('0')
|
||||
}
|
||||
}
|
||||
return x
|
||||
}
|
||||
8705
static/market/js/nostr.bundle.js
Normal file
167
static/market/js/utils.js
Normal file
|
|
@ -0,0 +1,167 @@
|
|||
var NostrTools = window.NostrTools
|
||||
|
||||
var defaultRelays = [
|
||||
'wss://relay.damus.io',
|
||||
'wss://relay.snort.social',
|
||||
'wss://nostr-pub.wellorder.net',
|
||||
'wss://nostr.zebedee.cloud',
|
||||
'wss://nostr.walletofsatoshi.com'
|
||||
]
|
||||
var eventToObj = event => {
|
||||
try {
|
||||
event.content = JSON.parse(event.content) || null
|
||||
} catch {
|
||||
event.content = null
|
||||
}
|
||||
|
||||
|
||||
return {
|
||||
...event,
|
||||
...Object.values(event.tags).reduce((acc, tag) => {
|
||||
let [key, value] = tag
|
||||
if (key == 't') {
|
||||
return { ...acc, [key]: [...(acc[key] || []), value] }
|
||||
} else {
|
||||
return { ...acc, [key]: value }
|
||||
}
|
||||
}, {})
|
||||
}
|
||||
}
|
||||
|
||||
function confirm(message) {
|
||||
return {
|
||||
message,
|
||||
ok: {
|
||||
flat: true,
|
||||
color: 'primary'
|
||||
},
|
||||
cancel: {
|
||||
flat: true,
|
||||
color: 'grey'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
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 formatSat(value) {
|
||||
return new Intl.NumberFormat(window.LOCALE).format(value)
|
||||
}
|
||||
|
||||
function satOrBtc(val, showUnit = true, showSats = false) {
|
||||
const value = showSats
|
||||
? formatSat(val)
|
||||
: val == 0
|
||||
? 0.0
|
||||
: (val / 100000000).toFixed(8)
|
||||
if (!showUnit) return value
|
||||
return showSats ? value + ' sat' : value + ' BTC'
|
||||
}
|
||||
|
||||
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}`
|
||||
}
|
||||
|
||||
function isValidImageUrl(string) {
|
||||
let url
|
||||
try {
|
||||
url = new URL(string)
|
||||
} catch (_) {
|
||||
return false
|
||||
}
|
||||
return url.protocol === 'http:' || url.protocol === 'https:'
|
||||
}
|
||||
|
||||
function isValidKey(key, prefix = 'n') {
|
||||
try {
|
||||
if (key && key.startsWith(prefix)) {
|
||||
let { _, data } = NostrTools.nip19.decode(key)
|
||||
key = data
|
||||
}
|
||||
return isValidKeyHex(key)
|
||||
} catch (error) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
function isValidKeyHex(key) {
|
||||
return key?.toLowerCase()?.match(/^[0-9a-f]{64}$/)
|
||||
}
|
||||
|
||||
function formatCurrency(value, currency) {
|
||||
return new Intl.NumberFormat(window.LOCALE, {
|
||||
style: 'currency',
|
||||
currency: currency
|
||||
}).format(value)
|
||||
}
|
||||
|
|
@ -230,10 +230,7 @@
|
|||
}
|
||||
</style>
|
||||
|
||||
<!-- todo: serve locally -->
|
||||
<script src="https://unpkg.com/nostr-tools/lib/nostr.bundle.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/pica@6.1.1/dist/pica.min.js"></script>
|
||||
|
||||
<script src="{{ url_for('nostrmarket_static', path='js/nostr.bundle.js') }}"></script>
|
||||
<script src="{{ url_for('nostrmarket_static', path='js/utils.js') }}"></script>
|
||||
<script src="{{ url_for('nostrmarket_static', path='components/key-pair/key-pair.js') }}"></script>
|
||||
<script src="{{ url_for('nostrmarket_static', path='components/shipping-zones/shipping-zones.js') }}"></script>
|
||||
|
|
|
|||
|
|
@ -1,314 +1,36 @@
|
|||
{% extends "public.html" %} {% block page %}
|
||||
<q-layout view="lHh Lpr lff">
|
||||
<q-page-container class="q-mr-md">
|
||||
<div class="row q-mb-sm">
|
||||
<div class="col-lg-2 col-md-1 col-sm-0"></div>
|
||||
<div class="col-lg-8 col-md-10 col-sm-12 auto-width">
|
||||
<div class="row q-mb-md q-pa-none">
|
||||
<q-toolbar class="col-lg-1 col-md-1 col-sm-0 q-pl-none">
|
||||
<q-avatar rounded size="64px" class="q-ma-none q-pa-none gt-sm">
|
||||
<img v-if="logoImage" :src="logoImage">
|
||||
</q-avatar>
|
||||
</q-toolbar>
|
||||
<q-toolbar class="col-lg-6 col-md-5 col-sm-12 auto-width">
|
||||
<q-input class="rounded-pill" style="width:100%;" rounded outlined clearable v-model.trim="searchText"
|
||||
label="Filter products, load market profile...">
|
||||
<template v-slot:append>
|
||||
<q-icon v-if="!searchText" name="search" />
|
||||
</template>
|
||||
</q-input>
|
||||
</q-toolbar>
|
||||
<q-toolbar class="col-lg-5 col-md-6 col-sm-12 q-ma-none">
|
||||
<div class="float-right">
|
||||
<q-btn color="gray" icon="travel_explore" flat size="lg"
|
||||
@click="setActivePage('search-nostr')"><q-tooltip>Search
|
||||
for products on Nostr</q-tooltip></q-btn>
|
||||
<q-btn color="gray" icon="settings" flat size="lg" @click="setActivePage('market-config')"><q-tooltip>
|
||||
Settings</q-tooltip></q-btn>
|
||||
<q-btn v-if="account" @click="setActivePage('user-config')" color="gray" icon="perm_identity" flat
|
||||
size="lg"><q-tooltip>User
|
||||
User Config</q-tooltip></q-btn>
|
||||
<q-btn v-else @click="accountDialog.show = true" color="gray" icon="person_add" flat
|
||||
size="lg"><q-tooltip>User
|
||||
Login</q-tooltip></q-btn>
|
||||
<q-btn @click="setActivePage('user-chat')" color="gray" icon="chat" flat
|
||||
size="lg"><q-tooltip>Chat</q-tooltip></q-btn>
|
||||
<q-btn @click="setActivePage('customer-orders')" color="gray" icon="receipt_long" flat
|
||||
size="lg"><q-tooltip>Orders</q-tooltip></q-btn>
|
||||
<q-btn color="gray" icon="shopping_cart" dense round flat size="lg"
|
||||
@click="setActivePage('shopping-cart-list')">
|
||||
<q-tooltip>Shopping Cart</q-tooltip>
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
|
||||
<q-badge v-if="allCartsItemCount" color="secondary" floating>
|
||||
<span v-text="allCartsItemCount"></span>
|
||||
</q-badge>
|
||||
</q-btn>
|
||||
</div>
|
||||
|
||||
</q-toolbar>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-lg-2 col-md-1 col-sm-0"></div>
|
||||
</div>
|
||||
<div class="row q-mb-sm">
|
||||
<div class="col-lg-2 col-md-1 col-sm-0"></div>
|
||||
<div class="col-lg-8 col-md-10 col-sm-12">
|
||||
|
||||
<div v-if="products?.length" class="gt-sm">
|
||||
<q-virtual-scroll :items="allCategories" virtual-scroll-horizontal>
|
||||
<template v-slot="{ item, index }">
|
||||
<q-chip :key="index" :color="item.selected ? 'grey': ''" class="cursor-pointer q-mb-md">
|
||||
<span v-text="item.category" @click="toggleCategoryFilter(item.category)"></span>
|
||||
<q-badge @click="toggleCategoryFilter(item.category)" rounded class="q-ml-sm" color="secondary"> <span
|
||||
v-text="item.count"></span></q-badge>
|
||||
</q-chip>
|
||||
</template>
|
||||
</q-virtual-scroll>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-lg-2 col-md-1 col-sm-0"></div>
|
||||
</div>
|
||||
|
||||
<div v-if="isLoading" class="row q-mb-sm">
|
||||
<div class="col-12 text-center"> <q-spinner-dots color="primary" size="xl" /></div>
|
||||
</div>
|
||||
|
||||
<div class="row q-mb-sm">
|
||||
<div class="col-lg-2 col-md-1 col-sm-0"></div>
|
||||
<div class="col-lg-8 col-md-10 col-sm-12 auto-width">
|
||||
<q-banner class="row q-pa-none q-mb-lg gt-sm shadow-2">
|
||||
<q-img v-if="bannerImage" :src="bannerImage" class="rounded-borders" style="width: 100%; height: 250px" cover>
|
||||
<div v-if="config?.opts?.about" class="absolute-bottom text-subtitle1 text-center">
|
||||
<span v-text="config.opts.about"></span>
|
||||
</div>
|
||||
</q-img>
|
||||
</q-banner>
|
||||
|
||||
</div>
|
||||
<div class="col-lg-2 col-md-1 col-sm-0 auto-width"></div>
|
||||
</div>
|
||||
|
||||
<div class="row q-mb-sm">
|
||||
<div class="col-lg-2 col-md-1 col-sm-0"></div>
|
||||
<div class="col-lg-7 col-md-9 col-sm-12 auto-width">
|
||||
|
||||
<q-breadcrumbs class="cursor q-mt-sm q-mr-sm">
|
||||
<q-breadcrumbs-el :label="config?.opts?.name || 'Market'" icon="home" @click="navigateTo('market')"
|
||||
class="cursor-pointer auto-width">
|
||||
<q-checkbox v-model="groupByStall" v-if="activePage === 'market' && stalls?.length"
|
||||
class="q-pl-md float-right" size="xs" val="xs" label="Group by stalls"></q-checkbox>
|
||||
|
||||
</q-breadcrumbs-el>
|
||||
<q-breadcrumbs-el v-if="activePage === 'customer-stall'" :label="stallName"
|
||||
@click="navigateTo('stall', {stall: activeStall})" icon="storefront"
|
||||
class="cursor-pointer"></q-breadcrumbs-el>
|
||||
<q-breadcrumbs-el v-if="activePage === 'customer-stall' && activeProduct" :label="productName"
|
||||
class="cursor-pointer" icon="widgets"></q-breadcrumbs-el>
|
||||
<q-breadcrumbs-el v-if="activePage === 'shopping-cart-list'" label="Shoping Cart"
|
||||
icon="shopping_cart"></q-breadcrumbs-el>
|
||||
<q-breadcrumbs-el v-if="activePage === 'customer-orders'" label="Orders"
|
||||
icon="receipt_long"></q-breadcrumbs-el>
|
||||
<q-breadcrumbs-el v-if="activePage === 'market-config'" label="Settings" icon="settings"></q-breadcrumbs-el>
|
||||
<q-breadcrumbs-el v-if="activePage === 'user-config'" label="User Config"
|
||||
icon="perm_identity"></q-breadcrumbs-el>
|
||||
<q-breadcrumbs-el v-if="activePage === 'user-chat'" label="User Chat" icon="chat"></q-breadcrumbs-el>
|
||||
</q-breadcrumbs>
|
||||
<head>
|
||||
<title>Nostr Market App</title>
|
||||
<meta charset=utf-8>
|
||||
<meta name=description content="A Nostr marketplace">
|
||||
<meta name=format-detection content="telephone=no">
|
||||
<meta name=msapplication-tap-highlight content=no>
|
||||
<meta name=viewport content="user-scalable=no,initial-scale=1,maximum-scale=1,minimum-scale=1,width=device-width">
|
||||
|
||||
|
||||
</div>
|
||||
<div class="col-lg-1 col-md-1 col-sm-0 auto-width">
|
||||
<q-btn v-if="activePage === 'customer-stall'" flat color="grey" icon="content_copy" @click="copyUrl()"
|
||||
class="float-right"></q-btn>
|
||||
</div>
|
||||
<div class="col-lg-2 col-md-1 col-sm-0 auto-width"></div>
|
||||
</div>
|
||||
<script src="{{ url_for('nostrmarket_static', path='market/js/nostr.bundle.js') }}"></script>
|
||||
<script src="{{ url_for('nostrmarket_static', path='market/js/bolt11-decoder.js') }}"></script>
|
||||
<script src="{{ url_for('nostrmarket_static', path='market/js/utils.js') }}"></script>
|
||||
|
||||
<div class="row q-mb-md">
|
||||
<div class="col-lg-2 col-md-1 col-sm-0"></div>
|
||||
<div class="col-lg-8 col-md-10 col-sm-12 auto-width">
|
||||
<q-separator class="q-mt-sm q-mb-md"></q-separator>
|
||||
<link rel=icon type=image/png sizes=128x128
|
||||
href="{{ url_for('nostrmarket_static', path='market/icons/favicon-128x128.png')}}">
|
||||
|
||||
<market-config v-if="activePage === 'market-config'" :merchants="merchants" @add-merchant="addMerchant"
|
||||
@remove-merchant="removeMerchant" :relays="relays" :read-notes="readNotes" @add-relay="addRelay"
|
||||
@remove-relay="removeRelay" :config-ui="config?.opts" @ui-config-update="updateUiConfig"
|
||||
@publish-naddr="publishNaddr" @clear-all-data="clearAllData" @note-read="markNoteAsRead"></market-config>
|
||||
<link rel=icon type=image/png sizes=128x128
|
||||
href="{{ url_for('nostrmarket_static', path='market/icons/favicon-96x96.png')}}">
|
||||
<link rel=icon type=image/png sizes=128x128
|
||||
href="{{ url_for('nostrmarket_static', path='market/icons/favicon-32x32.png')}}">
|
||||
<link rel=icon type=image/png sizes=128x128 href="{{ url_for('nostrmarket_static', path='market/favicon.ico')}}">
|
||||
|
||||
<user-config v-else-if="activePage === 'user-config'" :account="account" @logout="logout"
|
||||
@copy-text="copyText"></user-config>
|
||||
<!-- Note: the .js and .css build IDs must be updated when a new version si released for 'static/market/index.html'-->
|
||||
<script type="module" crossorigin
|
||||
src="{{ url_for('nostrmarket_static', path='market/assets/index.725caa24.js')}}"></script>
|
||||
<link rel="stylesheet" href="{{ url_for('nostrmarket_static', path='market/assets/index.5f2eed21.css')}}">
|
||||
</head>
|
||||
|
||||
<user-chat v-else-if="activePage === 'user-chat'"></user-chat>
|
||||
<body>
|
||||
<div id=q-app></div>
|
||||
</body>
|
||||
|
||||
<shopping-cart-list v-else-if="activePage === 'shopping-cart-list'" :carts="shoppingCarts"
|
||||
@add-to-cart="addProductToCart" @remove-from-cart="removeProductFromCart" @remove-cart="removeCart"
|
||||
@checkout-cart="checkoutStallCart"></shopping-cart-list>
|
||||
|
||||
<shopping-cart-checkout v-else-if="activePage === 'shopping-cart-checkout'" :cart="checkoutCart"
|
||||
:stall="checkoutStall" :customer-pubkey="account?.pubkey" @login-required="openAccountDialog"
|
||||
@place-order="placeOrder" @change-page="navigateTo"></shopping-cart-checkout>
|
||||
|
||||
<customer-orders v-else-if="activePage === 'customer-orders'" :orders="orders" :products="products"
|
||||
:stalls="stalls" :merchants="merchants" @show-invoice="showInvoiceQr"></customer-orders>
|
||||
|
||||
<customer-stall v-else-if="activePage === 'customer-stall'"
|
||||
:stall="stalls.find(stall => stall.id == activeStall)" :products="filterProducts"
|
||||
:product-detail="activeProduct" @change-page="navigateTo" @add-to-cart="addProductToCart"></customer-stall>
|
||||
|
||||
<div v-else-if="!merchants?.length">
|
||||
<q-list class="q-mt-md" bordered>
|
||||
<q-item>
|
||||
<q-item-section avatar>
|
||||
<q-avatar>
|
||||
<q-icon color="primary" name="info" size="xl" />
|
||||
</q-avatar>
|
||||
|
||||
</q-item-section>
|
||||
|
||||
<q-item-section class="q-mt-sm q-ml-lg">
|
||||
<q-item-label><strong>Note</strong></q-item-label>
|
||||
|
||||
<q-item-label>
|
||||
<div class="text-caption">
|
||||
<span class="text-subtitle1"> You can start by adding a merchant public key </span>
|
||||
<q-btn @click="setActivePage('market-config')" flat color="secondary" class="q-mb-xs">Here</q-btn>
|
||||
<br>
|
||||
<span class="text-subtitle1 q-pt-md">Or enter a nostr market profile ( <code>naddr</code>) in the
|
||||
filter input.
|
||||
</span>
|
||||
</div>
|
||||
</q-item-label>
|
||||
</q-item-section>
|
||||
<q-item-section side>
|
||||
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
</q-list>
|
||||
</div>
|
||||
|
||||
<div v-else>
|
||||
<customer-stall-list v-if="groupByStall" :stalls="filterStalls"
|
||||
@change-page="navigateTo"></customer-stall-list>
|
||||
|
||||
<customer-market v-else :filtered-products="filterProducts" :search-text="searchText"
|
||||
:filter-categories="filterCategories" @change-page="navigateTo" @update-data="updateData"
|
||||
@add-to-cart="addProductToCart"></customer-market>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
<div class="col-lg-2 col-md-1 col-sm-0 auto-width"></div>
|
||||
</div>
|
||||
</q-page-container>
|
||||
|
||||
<!-- ACCOUNT DIALOG -->
|
||||
<q-dialog v-model="accountDialog.show" position="top">
|
||||
<q-card>
|
||||
<q-card-section class="row">
|
||||
<div class="text-h6">Account Setup</div>
|
||||
<q-space></q-space>
|
||||
</q-card-section>
|
||||
<q-card-section>
|
||||
<p>Enter your Nostr private key or generate a new one.</p>
|
||||
</q-card-section>
|
||||
|
||||
<q-card-section class="q-pt-none">
|
||||
<q-input dense label="Nsec/Hex" v-model="accountDialog.data.key" autofocus @keyup.enter="createAccount"
|
||||
:error="accountDialog.data.key && !isValidAccountKey" hint="Enter you private key"></q-input>
|
||||
</q-card-section>
|
||||
|
||||
<q-card-actions align="right" class="text-primary">
|
||||
<q-btn v-if="isValidAccountKey" label="Login" color="primary" @click="() => createAccount()"></q-btn>
|
||||
<q-btn v-else flat label="Generate" @click="generateKeyPair"></q-btn>
|
||||
<q-btn v-close-popup flat color="grey" class="q-ml-auto">Close</q-btn>
|
||||
</q-card-actions>
|
||||
</q-card>
|
||||
</q-dialog>
|
||||
|
||||
<!-- INVOICE DIALOG -->
|
||||
<q-dialog v-model="qrCodeDialog.show" position="top">
|
||||
<q-card class="q-pa-lg q-pt-xl lnbits__dialog-card">
|
||||
<div class="text-center q-mb-lg">
|
||||
<div v-if="qrCodeDialog.data.message" class="q-my-lg">
|
||||
<strong><span v-text="qrCodeDialog.data.message"></span> </strong>
|
||||
</div>
|
||||
<a v-else :href="'lightning:' + qrCodeDialog.data?.payment_request">
|
||||
<q-responsive v-if="qrCodeDialog.data.payment_request" :ratio="1" class="q-mx-xl">
|
||||
<qrcode :value="qrCodeDialog.data.payment_request" :options="{width: 340}" class="rounded-borders"></qrcode>
|
||||
</q-responsive>
|
||||
<div v-else>
|
||||
<q-spinner color="primary" size="2.55em"></q-spinner>
|
||||
</div>
|
||||
</a>
|
||||
|
||||
</div>
|
||||
<div class="row q-mt-lg">
|
||||
<q-btn v-if="qrCodeDialog.data.payment_request" outline color="grey"
|
||||
@click="copyText(qrCodeDialog.data.payment_request)">Copy invoice</q-btn>
|
||||
<q-btn flat v-close-popup color="grey" class="q-ml-auto">Close</q-btn>
|
||||
</div>
|
||||
</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/customer-stall-list/customer-stall-list.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/shopping-cart-list/shopping-cart-list.js') }}"></script>
|
||||
<script
|
||||
src="{{ url_for('nostrmarket_static', path='components/shopping-cart-checkout/shopping-cart-checkout.js') }}"></script>
|
||||
<script src="{{ url_for('nostrmarket_static', path='components/customer-orders/customer-orders.js') }}"></script>
|
||||
<script src="{{ url_for('nostrmarket_static', path='components/chat-dialog/chat-dialog.js') }}"></script>
|
||||
<script src="{{ url_for('nostrmarket_static', path='components/market-config/market-config.js') }}"></script>
|
||||
<script src="{{ url_for('nostrmarket_static', path='components/user-config/user-config.js') }}"></script>
|
||||
<script src="{{ url_for('nostrmarket_static', path='components/user-chat/user-chat.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;
|
||||
}
|
||||
|
||||
.q-item__label--caption {
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
</style>
|
||||
|
||||
{% endblock %}
|
||||
</html>
|
||||