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
This commit is contained in:
Vlad Stan 2023-07-31 11:35:50 +03:00 committed by GitHub
parent 8ebe2fe458
commit a3299b63c4
60 changed files with 18008 additions and 3197 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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">
&nbsp
</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>

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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">
&nbsp
</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>

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,8 +0,0 @@
<q-card>
<div class="q-pa-md">
<div class="q-gutter-y-md">
User Chat
</div>
</div>
</q-card>

View file

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

View file

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

View file

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

View file

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

File diff suppressed because it is too large Load diff

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

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

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

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

BIN
static/market/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 245 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 967 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 784 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

27
static/market/index.html Normal file
View 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>

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

File diff suppressed because it is too large Load diff

167
static/market/js/utils.js Normal file
View 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)
}

View file

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

View file

@ -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>
<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">
</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">
<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 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>
<link rel=icon type=image/png sizes=128x128
href="{{ url_for('nostrmarket_static', path='market/icons/favicon-128x128.png')}}">
<div v-if="isLoading" class="row q-mb-sm">
<div class="col-12 text-center"> <q-spinner-dots color="primary" size="xl" /></div>
</div>
<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')}}">
<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>
<!-- 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>
</div>
<div class="col-lg-2 col-md-1 col-sm-0 auto-width"></div>
</div>
<body>
<div id=q-app></div>
</body>
<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>
</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>
<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>
<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>
<user-config v-else-if="activePage === 'user-config'" :account="account" @logout="logout"
@copy-text="copyText"></user-config>
<user-chat v-else-if="activePage === 'user-chat'"></user-chat>
<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>