Product delete (#64)

* feat: restore stalls from `nostr` as pending

* feat: stall and prod last update time

* feat: restore products and stalls as `pending`

* feat: show pending stalls

* feat: restore stall

* feat: restore a stall from nostr

* feat: add  blank `Restore Product` button

* fix: handle no talls to restore case

* feat: show restore dialog

* feat: allow query for pending products

* feat: restore products

* chore: code clean-up

* fix: last dm and last order query

* chore: code clean-up

* fix: subscribe for stalls and products on merchant create/restore

* feat: add message type to orders

* feat: simplify messages; code format

* feat: add type to DMs; restore DMs from nostr

* fix: parsing ints

* fix: hide copy button if invoice not present

* fix: do not generate invoice if product not found

* feat: order restore: first version

* refactor: move some logic into `services`

* feat: improve restore UX

* fix: too many calls to customer DMs

* fix: allow `All` customers filter

* fix: ws reconnect on server restart

* fix: query for customer profiles only one

* fix: unread messages per customer per merchant

* fix: disable `user-profile-events`

* fix: customer profile is optional

* fix: get customers after new message debounced

* chore: code clean-up

* feat: auto-create zone

* feat: fixed ID for default zone

* feat: notify order paid
This commit is contained in:
Vlad Stan 2023-06-30 12:12:56 +02:00 committed by GitHub
parent 1cb8fe86b1
commit 51c4147e65
17 changed files with 934 additions and 610 deletions

View file

@ -110,7 +110,8 @@
</q-inner-loading>
</div>
<div class="row q-mt-lg">
<q-btn outline color="grey" @click="copyText(qrCodeDialog.data.payment_request)">Copy invoice</q-btn>
<q-btn v-if="qrCodeDialog.data.payment_request" outline color="grey"
@click="copyText(qrCodeDialog.data.payment_request)">Copy invoice</q-btn>
<q-btn @click="closeQrCodeDialog" flat color="grey" class="q-ml-auto">Close</q-btn>
</div>
</q-card>

View file

@ -67,6 +67,7 @@ async function directMessages(path) {
LNbits.utils.notifyApiError(error)
}
},
sendDirectMesage: async function () {
try {
const {data} = await LNbits.api.request(
@ -106,12 +107,13 @@ async function directMessages(path) {
this.showAddPublicKey = false
}
},
handleNewMessage: async function (data) {
if (data.customerPubkey === this.activePublicKey) {
await this.getDirectMessages(this.activePublicKey)
} else {
await this.getCustomers()
handleNewMessage: async function (dm) {
if (dm.customerPubkey === this.activePublicKey) {
this.messages.push(dm.data)
this.focusOnChatBox(this.messages.length - 1)
// focus back on input box
}
this.getCustomersDebounced()
},
showClientOrders: function () {
this.$emit('customer-selected', this.activePublicKey)
@ -133,6 +135,7 @@ async function directMessages(path) {
},
created: async function () {
await this.getCustomers()
this.getCustomersDebounced = _.debounce(this.getCustomers, 2000, false)
}
})
}

View file

@ -1,72 +1,47 @@
<div>
<div class="row q-mb-md">
<div class="col-3 q-pr-lg">
<q-select
v-model="search.publicKey"
:options="customers.map(c => ({label: buildCustomerLabel(c), value: c.public_key}))"
label="Customer"
emit-value
class="text-wrap"
>
<div class="col-md-4 col-sm-6 q-pr-lg">
<q-select v-model="search.publicKey"
:options="customerOptions" label="Customer" emit-value
class="text-wrap">
</q-select>
</div>
<div class="col-3 q-pr-lg">
<q-select
v-model="search.isPaid"
:options="ternaryOptions"
label="Paid"
emit-value
>
<div class="col-md-2 col-sm-6 q-pr-lg">
<q-select v-model="search.isPaid" :options="ternaryOptions" label="Paid" emit-value>
</q-select>
</div>
<div class="col-3 q-pr-lg">
<q-select
v-model="search.isShipped"
:options="ternaryOptions"
label="Shipped"
emit-value
>
<div class="col-md-2 col-sm-6 q-pr-lg">
<q-select v-model="search.isShipped" :options="ternaryOptions" label="Shipped" emit-value>
</q-select>
</div>
<div class="col-3">
<q-btn
unelevated
outline
icon="search"
@click="getOrders()"
class="float-right"
>Search Orders</q-btn
>
<div class="col-md-4 col-sm-6">
<q-btn-dropdown @click="getOrders()" :disable="search.restoring" outline unelevated split class="q-pt-md float-right"
:label="search.restoring ? 'Restoring Orders...' : 'Search Orders'">
<q-spinner v-if="search.restoring" color="primary" size="2.55em" class="q-pt-md float-right"></q-spinner>
<q-item @click="restoreOrders" clickable v-close-popup>
<q-item-section>
<q-item-label>Restore Orders</q-item-label>
<q-item-label caption>Restore previous orders from Nostr</q-item-label>
</q-item-section>
</q-item>
</q-btn-dropdown>
</div>
</div>
<div class="row q-mt-md">
<div class="col">
<q-table
flat
dense
:data="orders"
row-key="id"
:columns="ordersTable.columns"
:pagination.sync="ordersTable.pagination"
:filter="filter"
>
<q-table flat dense :data="orders" row-key="id" :columns="ordersTable.columns"
:pagination.sync="ordersTable.pagination" :filter="filter">
<template v-slot:body="props">
<q-tr :props="props">
<q-td auto-width>
<q-btn
size="sm"
color="accent"
round
dense
@click="props.row.expanded= !props.row.expanded"
:icon="props.row.expanded? 'remove' : 'add'"
/>
<q-btn size="sm" color="accent" round dense @click="props.row.expanded= !props.row.expanded"
:icon="props.row.expanded? 'remove' : 'add'" />
</q-td>
<q-td key="id" :props="props">
{{toShortId(props.row.id)}}
<q-badge v-if="props.row.isNew" color="orange">new</q-badge></q-td
>
<q-badge v-if="props.row.isNew" color="orange">new</q-badge></q-td>
<q-td key="total" :props="props">
{{satBtc(props.row.total)}}
</q-td>
@ -77,33 +52,21 @@
</q-td>
<q-td key="paid" :props="props">
<q-checkbox
v-model="props.row.paid"
:label="props.row.paid ? 'Yes' : 'No'"
disable
readonly
size="sm"
></q-checkbox>
<q-checkbox v-model="props.row.paid" :label="props.row.paid ? 'Yes' : 'No'" disable readonly
size="sm"></q-checkbox>
</q-td>
<q-td key="shipped" :props="props">
<q-checkbox
v-model="props.row.shipped"
@input="showShipOrderDialog(props.row)"
:label="props.row.shipped ? 'Yes' : 'No'"
size="sm"
></q-checkbox>
<q-checkbox v-model="props.row.shipped" @input="showShipOrderDialog(props.row)"
:label="props.row.shipped ? 'Yes' : 'No'" size="sm"></q-checkbox>
</q-td>
<q-td key="public_key" :props="props">
<span
@click="customerSelected(props.row.public_key)"
class="cursor-pointer"
>
<span @click="customerSelected(props.row.public_key)" class="cursor-pointer">
{{toShortId(props.row.public_key)}}
</span>
</q-td>
<q-td key="time" :props="props">
{{formatDate(props.row.time)}}
<q-td key="event_created_at" :props="props">
{{formatDate(props.row.event_created_at)}}
</q-td>
</q-tr>
<q-tr v-if="props.row.expanded" :props="props">
@ -124,10 +87,7 @@
<div class="row items-center no-wrap q-mb-md">
<div class="col-3 q-pr-lg"></div>
<div class="col-8">
<div
v-for="item in props.row.items"
class="row items-center no-wrap q-mb-md"
>
<div v-for="item in props.row.items" class="row items-center no-wrap q-mb-md">
<div class="col-1">{{item.quantity}}</div>
<div class="col-1">x</div>
<div class="col-4">
@ -141,34 +101,18 @@
</div>
<div class="col-1"></div>
</div>
<div
v-if="props.row.extra.currency !== 'sat'"
class="row items-center no-wrap q-mb-md q-mt-md"
>
<div v-if="props.row.extra.currency !== 'sat'" class="row items-center no-wrap q-mb-md q-mt-md">
<div class="col-3 q-pr-lg">Exchange Rate (1 BTC):</div>
<div class="col-6 col-sm-8 q-pr-lg">
<q-input
filled
dense
readonly
disabled
:value="formatFiat(props.row.extra.btc_price, props.row.extra.currency)"
type="text"
></q-input>
<q-input filled dense readonly disabled
:value="formatFiat(props.row.extra.btc_price, props.row.extra.currency)" type="text"></q-input>
</div>
<div class="col-3 col-sm-1"></div>
</div>
<div class="row items-center no-wrap q-mb-md q-mt-md">
<div class="col-3 q-pr-lg">Order ID:</div>
<div class="col-6 col-sm-8 q-pr-lg">
<q-input
filled
dense
readonly
disabled
v-model.trim="props.row.id"
type="text"
></q-input>
<q-input filled dense readonly disabled v-model.trim="props.row.id" type="text"></q-input>
</div>
<div class="col-3 col-sm-1"></div>
</div>
@ -176,14 +120,7 @@
<div class="row items-center no-wrap q-mb-md">
<div class="col-3 q-pr-lg">Address:</div>
<div class="col-6 col-sm-8 q-pr-lg">
<q-input
filled
dense
readonly
disabled
v-model.trim="props.row.address"
type="text"
></q-input>
<q-input filled dense readonly disabled v-model.trim="props.row.address" type="text"></q-input>
</div>
<div class="col-3 col-sm-1"></div>
</div>
@ -191,63 +128,29 @@
<div class="row items-center no-wrap q-mb-md">
<div class="col-3 q-pr-lg">Customer Public Key:</div>
<div class="col-6 col-sm-8 q-pr-lg">
<q-input
filled
dense
readonly
disabled
v-model.trim="props.row.public_key"
type="text"
></q-input>
<q-input filled dense readonly disabled v-model.trim="props.row.public_key" type="text"></q-input>
</div>
<div class="col-3 col-sm-1"></div>
</div>
<div
v-if="props.row.contact.phone"
class="row items-center no-wrap q-mb-md"
>
<div v-if="props.row.contact.phone" class="row items-center no-wrap q-mb-md">
<div class="col-3 q-pr-lg">Phone:</div>
<div class="col-6 col-sm-8 q-pr-lg">
<q-input
filled
dense
readonly
disabled
v-model.trim="props.row.contact.phone"
type="text"
></q-input>
<q-input filled dense readonly disabled v-model.trim="props.row.contact.phone" type="text"></q-input>
</div>
<div class="col-3 col-sm-1"></div>
</div>
<div
v-if="props.row.contact.email"
class="row items-center no-wrap q-mb-md"
>
<div v-if="props.row.contact.email" class="row items-center no-wrap q-mb-md">
<div class="col-3 q-pr-lg">Email:</div>
<div class="col-6 col-sm-8 q-pr-lg">
<q-input
filled
dense
readonly
disabled
v-model.trim="props.row.contact.email"
type="text"
></q-input>
<q-input filled dense readonly disabled v-model.trim="props.row.contact.email" type="text"></q-input>
</div>
<div class="col-3 col-sm-1"></div>
</div>
<div class="row items-center no-wrap q-mb-md">
<div class="col-3 q-pr-lg">Invoice ID:</div>
<div class="col-6 col-sm-8 q-pr-lg">
<q-input
filled
dense
readonly
disabled
v-model.trim="props.row.invoice_id"
type="text"
></q-input>
<q-input filled dense readonly disabled v-model.trim="props.row.invoice_id" type="text"></q-input>
</div>
<div class="col-3 col-sm-1"></div>
</div>
@ -260,28 +163,16 @@
<q-dialog v-model="showShipDialog" position="top">
<q-card v-if="selectedOrder" class="q-pa-lg q-pt-xl" style="width: 500px">
<q-form @submit="updateOrderShipped" class="q-gutter-md">
<q-input
filled
dense
v-model.trim="shippingMessage"
label="Shipping Message"
type="textarea"
rows="4"
></q-input>
<q-input filled dense v-model.trim="shippingMessage" label="Shipping Message" type="textarea"
rows="4"></q-input>
<div class="row q-mt-lg">
<q-btn
unelevated
color="primary"
type="submit"
:label="selectedOrder.shipped? 'Unship Order' : 'Ship Order'"
></q-btn>
<q-btn unelevated color="primary" type="submit"
:label="selectedOrder.shipped? 'Unship Order' : 'Ship Order'"></q-btn>
<q-btn v-close-popup flat color="grey" class="q-ml-auto"
>Cancel</q-btn
>
<q-btn v-close-popup flat color="grey" class="q-ml-auto">Cancel</q-btn>
</div>
</q-form>
</q-card>
</q-dialog>
</div>
</div>

View file

@ -8,8 +8,8 @@ async function orderList(path) {
watch: {
customerPubkeyFilter: async function (n) {
this.search.publicKey = n
this.search.isPaid = {label: 'All', id: null}
this.search.isShipped = {label: 'All', id: null}
this.search.isPaid = { label: 'All', id: null }
this.search.isShipped = { label: 'All', id: null }
await this.getOrders()
}
},
@ -22,7 +22,7 @@ async function orderList(path) {
showShipDialog: false,
filter: '',
search: {
publicKey: '',
publicKey: null,
isPaid: {
label: 'All',
id: null
@ -30,7 +30,8 @@ async function orderList(path) {
isShipped: {
label: 'All',
id: null
}
},
restoring: false
},
customers: [],
ternaryOptions: [
@ -92,10 +93,10 @@ async function orderList(path) {
field: 'pubkey'
},
{
name: 'time',
name: 'event_created_at',
align: 'left',
label: 'Date',
field: 'time'
label: 'Created At',
field: 'event_created_at'
}
],
pagination: {
@ -104,6 +105,13 @@ async function orderList(path) {
}
}
},
computed: {
customerOptions: function () {
const options = this.customers.map(c => ({ label: this.buildCustomerLabel(c), value: c.public_key }))
options.unshift({ label: 'All', value: null, id: null })
return options
}
},
methods: {
toShortId: function (value) {
return value.substring(0, 5) + '...' + value.substring(value.length - 5)
@ -156,28 +164,48 @@ async function orderList(path) {
if (this.search.isShipped.id) {
query.push(`shipped=${this.search.isShipped.id}`)
}
const {data} = await LNbits.api.request(
const { data } = await LNbits.api.request(
'GET',
`/nostrmarket/api/v1/${ordersPath}?${query.join('&')}`,
this.inkey
)
this.orders = data.map(s => ({...s, expanded: false}))
this.orders = data.map(s => ({ ...s, expanded: false }))
console.log("### this.orders", this.orders)
} catch (error) {
LNbits.utils.notifyApiError(error)
}
},
getOrder: async function (orderId) {
try {
const {data} = await LNbits.api.request(
const { data } = await LNbits.api.request(
'GET',
`/nostrmarket/api/v1/order/${orderId}`,
this.inkey
)
return {...data, expanded: false, isNew: true}
return { ...data, expanded: false, isNew: true }
} catch (error) {
LNbits.utils.notifyApiError(error)
}
},
restoreOrders: async function () {
try {
this.search.restoring = true
await LNbits.api.request(
'PUT',
`/nostrmarket/api/v1/order/restore`,
this.adminkey
)
await this.getOrders()
this.$q.notify({
type: 'positive',
message: 'Orders restored!'
})
} catch (error) {
LNbits.utils.notifyApiError(error)
} finally {
this.search.restoring = false
}
},
updateOrderShipped: async function () {
this.selectedOrder.shipped = !this.selectedOrder.shipped
try {
@ -213,8 +241,8 @@ async function orderList(path) {
showShipOrderDialog: function (order) {
this.selectedOrder = order
this.shippingMessage = order.shipped
? `The order has been shipped! Order ID: '${order.id}' `
: `The order has NOT yet been shipped! Order ID: '${order.id}'`
? 'The order has been shipped!'
: 'The order has NOT yet been shipped!'
// do not change the status yet
this.selectedOrder.shipped = !order.shipped
@ -225,7 +253,7 @@ async function orderList(path) {
},
getCustomers: async function () {
try {
const {data} = await LNbits.api.request(
const { data } = await LNbits.api.request(
'GET',
'/nostrmarket/api/v1/customer',
this.inkey
@ -244,6 +272,12 @@ async function orderList(path) {
c.public_key.length - 16
)}`
return label
},
orderPaid: function(orderId) {
const order = this.orders.find(o => o.id === orderId)
if (order) {
order.paid = true
}
}
},
created: async function () {

View file

@ -10,54 +10,29 @@
<div class="row items-center no-wrap q-mb-md">
<div class="col-3 q-pr-lg">ID:</div>
<div class="col-6 col-sm-8 q-pr-lg">
<q-input
filled
dense
readonly
disabled
v-model.trim="stall.id"
type="text"
></q-input>
<q-input filled dense readonly disabled v-model.trim="stall.id" type="text"></q-input>
</div>
<div class="col-3 col-sm-1"></div>
</div>
<div class="row items-center no-wrap q-mb-md">
<div class="col-3 q-pr-lg">Name:</div>
<div class="col-6 col-sm-8 q-pr-lg">
<q-input
filled
dense
v-model.trim="stall.name"
type="text"
></q-input>
<q-input filled dense v-model.trim="stall.name" type="text"></q-input>
</div>
<div class="col-3 col-sm-1"></div>
</div>
<div class="row items-center no-wrap q-mb-md">
<div class="col-3 q-pr-lg">Description:</div>
<div class="col-6 col-sm-8 q-pr-lg">
<q-input
filled
dense
v-model.trim="stall.config.description"
type="textarea"
rows="3"
label="Description"
></q-input>
<q-input filled dense v-model.trim="stall.config.description" type="textarea" rows="3"
label="Description"></q-input>
</div>
<div class="col-3 col-sm-1"></div>
</div>
<div class="row items-center no-wrap q-mb-md">
<div class="col-3 q-pr-lg">Wallet:</div>
<div class="col-6 col-sm-8 q-pr-lg">
<q-select
filled
dense
emit-value
v-model="stall.wallet"
:options="walletOptions"
label="Wallet *"
>
<q-select filled dense emit-value v-model="stall.wallet" :options="walletOptions" label="Wallet *">
</q-select>
</div>
<div class="col-3 col-sm-1"></div>
@ -65,51 +40,25 @@
<div class="row items-center no-wrap q-mb-md">
<div class="col-3 q-pr-lg">Currency:</div>
<div class="col-6 col-sm-8 q-pr-lg">
<q-select
filled
dense
v-model="stall.currency"
type="text"
label="Unit"
:options="currencies"
></q-select>
<q-select filled dense v-model="stall.currency" type="text" label="Unit" :options="currencies"></q-select>
</div>
<div class="col-3 col-sm-1"></div>
</div>
<div class="row items-center no-wrap q-mb-md">
<div class="col-3 q-pr-lg">Shipping Zones:</div>
<div class="col-6 col-sm-8 q-pr-lg">
<q-select
:options="filteredZoneOptions"
filled
dense
multiple
v-model.trim="stall.shipping_zones"
label="Shipping Zones"
></q-select>
<q-select :options="filteredZoneOptions" filled dense multiple v-model.trim="stall.shipping_zones"
label="Shipping Zones"></q-select>
</div>
<div class="col-3 col-sm-1"></div>
</div>
</div>
<div class="row items-center q-mt-xl">
<div class="col-6 q-pr-lg">
<q-btn
unelevated
color="secondary"
class="float-left"
@click="updateStall()"
>Update Stall</q-btn
>
<q-btn unelevated color="secondary" class="float-left" @click="updateStall()">Update Stall</q-btn>
</div>
<div class="col-6">
<q-btn
unelevated
color="pink"
icon="cancel"
class="float-right"
@click="deleteStall()"
>Delete Stall</q-btn
>
<q-btn unelevated color="pink" icon="cancel" class="float-right" @click="deleteStall()">Delete Stall</q-btn>
</div>
</div>
</q-tab-panel>
@ -117,14 +66,23 @@
<div v-if="stall">
<div class="row items-center no-wrap q-mb-md">
<div class="col-3 q-pr-lg">
<q-btn
unelevated
color="green"
icon="plus"
class="float-left"
@click="showNewProductDialog()"
>New Product</q-btn
>
<q-btn-dropdown @click="showNewProductDialog()" unelevated split color="green" class="float-left"
label="New Product">
<q-item @click="showNewProductDialog()" clickable v-close-popup>
<q-item-section>
<q-item-label>New Product</q-item-label>
<q-item-label caption>Create a new product</q-item-label>
</q-item-section>
</q-item>
<q-item @click="openSelectPendingProductDialog" clickable v-close-popup>
<q-item-section>
<q-item-label>Restore Product</q-item-label>
<q-item-label caption>Restore existing product from Nostr</q-item-label>
</q-item-section>
</q-item>
</q-btn-dropdown>
</div>
<div class="col-6 col-sm-8 q-pr-lg"></div>
<div class="col-3 col-sm-1"></div>
@ -132,34 +90,15 @@
<div class="row items-center no-wrap q-mb-md">
<div class="col-12">
<q-table
flat
dense
:data="products"
row-key="id"
:columns="productsTable.columns"
:pagination.sync="productsTable.pagination"
:filter="productsFilter"
>
<q-table flat dense :data="products" row-key="id" :columns="productsTable.columns"
:pagination.sync="productsTable.pagination" :filter="productsFilter">
<template v-slot:body="props">
<q-tr :props="props">
<q-td auto-width>
<q-btn
size="sm"
color="pink"
dense
@click="deleteProduct(props.row.id)"
icon="delete"
/>
<q-btn size="sm" color="pink" dense @click="deleteProduct(props.row.id)" icon="delete" />
</q-td>
<q-td auto-width>
<q-btn
size="sm"
color="accent"
dense
@click="editProduct(props.row)"
icon="edit"
/>
<q-btn size="sm" color="accent" dense @click="editProduct(props.row)" icon="edit" />
</q-td>
<q-td key="id" :props="props"> {{props.row.id}} </q-td>
@ -186,112 +125,74 @@
</q-tab-panel>
<q-tab-panel name="orders">
<div v-if="stall">
<order-list
:adminkey="adminkey"
:inkey="inkey"
:stall-id="stallId"
@customer-selected="customerSelectedForOrder"
></order-list>
<order-list :adminkey="adminkey" :inkey="inkey" :stall-id="stallId"
@customer-selected="customerSelectedForOrder"></order-list>
</div>
</q-tab-panel>
</q-tab-panels>
<q-dialog v-model="productDialog.showDialog" position="top">
<q-card v-if="stall" class="q-pa-lg q-pt-xl" style="width: 500px">
<q-form @submit="sendProductFormData" class="q-gutter-md">
<q-input
filled
dense
v-model.trim="productDialog.data.name"
label="Name"
></q-input>
<q-input filled dense v-model.trim="productDialog.data.name" label="Name"></q-input>
<q-input
filled
dense
v-model.trim="productDialog.data.config.description"
label="Description"
></q-input>
<q-select
filled
multiple
dense
emit-value
v-model.trim="productDialog.data.categories"
use-input
use-chips
multiple
hide-dropdown-icon
input-debounce="0"
new-value-mode="add-unique"
label="Categories (Hit Enter to add)"
placeholder="crafts,robots,etc"
></q-select>
<q-input filled dense v-model.trim="productDialog.data.config.description" label="Description"></q-input>
<q-select filled multiple dense emit-value v-model.trim="productDialog.data.categories" use-input use-chips
multiple hide-dropdown-icon input-debounce="0" new-value-mode="add-unique"
label="Categories (Hit Enter to add)" placeholder="crafts,robots,etc"></q-select>
<q-input
filled
dense
v-model.trim="productDialog.data.image"
@keydown.enter="addProductImage"
type="url"
label="Image URL"
>
<q-btn @click="addProductImage" dense flat icon="add"></q-btn
></q-input>
<q-input filled dense v-model.trim="productDialog.data.image" @keydown.enter="addProductImage" type="url"
label="Image URL">
<q-btn @click="addProductImage" dense flat icon="add"></q-btn></q-input>
<q-chip
v-for="imageUrl in productDialog.data.images"
:key="imageUrl"
removable
@remove="removeProductImage(imageUrl)"
color="primary"
text-color="white"
>
<q-chip v-for="imageUrl in productDialog.data.images" :key="imageUrl" removable
@remove="removeProductImage(imageUrl)" color="primary" text-color="white">
<span v-text="imageUrl.split('/').pop()"></span>
</q-chip>
<q-input
filled
dense
v-model.number="productDialog.data.price"
type="number"
:label="'Price (' + stall.currency + ') *'"
:step="stall.currency != 'sat' ? '0.01' : '1'"
:mask="stall.currency != 'sat' ? '#.##' : '#'"
fill-mask="0"
reverse-fill-mask
></q-input>
<q-input
filled
dense
v-model.number="productDialog.data.quantity"
type="number"
label="Quantity"
></q-input>
<q-input filled dense v-model.number="productDialog.data.price" type="number"
:label="'Price (' + stall.currency + ') *'" :step="stall.currency != 'sat' ? '0.01' : '1'"
:mask="stall.currency != 'sat' ? '#.##' : '#'" fill-mask="0" reverse-fill-mask></q-input>
<q-input filled dense v-model.number="productDialog.data.quantity" type="number" label="Quantity"></q-input>
<div class="row q-mt-lg">
<q-btn
v-if="productDialog.data.id"
unelevated
color="primary"
type="submit"
>Update Product</q-btn
>
<q-btn v-if="productDialog.data.id" type="submit"
:label="productDialog.data.pending ? 'Restore Product' : 'Update Product'" unelevated
color="primary"></q-btn>
<q-btn
v-else
unelevated
color="primary"
:disable="!productDialog.data.price
<q-btn v-else unelevated color="primary" :disable="!productDialog.data.price
|| !productDialog.data.name
|| !productDialog.data.quantity"
type="submit"
>Create Product</q-btn
>
|| !productDialog.data.quantity" type="submit">Create Product</q-btn>
<q-btn v-close-popup flat color="grey" class="q-ml-auto"
>Cancel</q-btn
>
<q-btn v-close-popup flat color="grey" class="q-ml-auto">Cancel</q-btn>
</div>
</q-form>
</q-card>
</q-dialog>
</div>
<q-dialog v-model="productDialog.showRestore" position="top">
<q-card class="q-pa-lg q-pt-xl" style="width: 500px">
<div v-if="pendingProducts && pendingProducts.length" class="row q-mt-lg">
<q-item v-for="pendingProduct of pendingProducts" :key="pendingProduct.id" tag="label" class="full-width"
v-ripple>
<q-item-section>
<q-item-label><span v-text="pendingProduct.name"></span></q-item-label>
<q-item-label caption><span v-text="pendingProduct.config?.description"></span></q-item-label>
</q-item-section>
<q-item-section class="q-pl-xl float-right">
<q-btn @click="openRestoreProductDialog(pendingProduct)" v-close-popup flat color="green"
class="q-ml-auto float-right">Restore</q-btn>
</q-item-section>
<q-item-section class="float-right">
<q-btn @click="deleteProduct(pendingProduct.id)" v-close-popup color="red" class="q-ml-auto float-right"
icon="cancel"></q-btn>
</q-item-section>
</q-item>
</div>
<div v-else>
There are no products to be restored.
</div>
<div class="row q-mt-lg">
<q-btn v-close-popup flat color="grey" class="q-ml-auto">Close</q-btn>
</div>
</q-card>
</q-dialog>
</div>

View file

@ -20,8 +20,10 @@ async function stallDetails(path) {
tab: 'products',
stall: null,
products: [],
pendingProducts: [],
productDialog: {
showDialog: false,
showRestore: false,
url: true,
data: {
id: null,
@ -106,15 +108,15 @@ async function stallDetails(path) {
mapStall: function (stall) {
stall.shipping_zones.forEach(
z =>
(z.label = z.name
? `${z.name} (${z.countries.join(', ')})`
: z.countries.join(', '))
(z.label = z.name
? `${z.name} (${z.countries.join(', ')})`
: z.countries.join(', '))
)
return stall
},
getStall: async function () {
try {
const {data} = await LNbits.api.request(
const { data } = await LNbits.api.request(
'GET',
'/nostrmarket/api/v1/stall/' + this.stallId,
this.inkey
@ -126,7 +128,7 @@ async function stallDetails(path) {
},
updateStall: async function () {
try {
const {data} = await LNbits.api.request(
const { data } = await LNbits.api.request(
'PUT',
'/nostrmarket/api/v1/stall/' + this.stallId,
this.adminkey,
@ -189,14 +191,14 @@ async function stallDetails(path) {
this.productDialog.data.images.splice(index, 1)
}
},
getProducts: async function () {
getProducts: async function (pending = false) {
try {
const {data} = await LNbits.api.request(
const { data } = await LNbits.api.request(
'GET',
'/nostrmarket/api/v1/stall/product/' + this.stall.id,
`/nostrmarket/api/v1/stall/product/${this.stall.id}?pending=${pending}`,
this.inkey
)
this.products = data
return data
} catch (error) {
LNbits.utils.notifyApiError(error)
}
@ -215,6 +217,7 @@ async function stallDetails(path) {
}
this.productDialog.showDialog = false
if (this.productDialog.data.id) {
data.pending = false
this.updateProduct(data)
} else {
this.createProduct(data)
@ -222,7 +225,7 @@ async function stallDetails(path) {
},
updateProduct: async function (product) {
try {
const {data} = await LNbits.api.request(
const { data } = await LNbits.api.request(
'PATCH',
'/nostrmarket/api/v1/product/' + product.id,
this.adminkey,
@ -231,6 +234,8 @@ async function stallDetails(path) {
const index = this.products.findIndex(r => r.id === product.id)
if (index !== -1) {
this.products.splice(index, 1, data)
} else {
this.products.unshift(data)
}
this.$q.notify({
type: 'positive',
@ -244,7 +249,7 @@ async function stallDetails(path) {
},
createProduct: async function (payload) {
try {
const {data} = await LNbits.api.request(
const { data } = await LNbits.api.request(
'POST',
'/nostrmarket/api/v1/product',
this.adminkey,
@ -262,7 +267,7 @@ async function stallDetails(path) {
}
},
editProduct: async function (product) {
this.productDialog.data = {...product}
this.productDialog.data = { ...product }
this.productDialog.showDialog = true
},
deleteProduct: async function (productId) {
@ -289,8 +294,8 @@ async function stallDetails(path) {
}
})
},
showNewProductDialog: async function () {
this.productDialog.data = {
showNewProductDialog: async function (data) {
this.productDialog.data = data || {
id: null,
name: '',
description: '',
@ -305,13 +310,21 @@ async function stallDetails(path) {
}
this.productDialog.showDialog = true
},
openSelectPendingProductDialog: async function () {
this.productDialog.showRestore = true
this.pendingProducts = await this.getProducts(true)
},
openRestoreProductDialog: async function (pendingProduct) {
pendingProduct.pending = true
await this.showNewProductDialog(pendingProduct)
},
customerSelectedForOrder: function (customerPubkey) {
this.$emit('customer-selected-for-order', customerPubkey)
}
},
created: async function () {
await this.getStall()
await this.getProducts()
this.products = await this.getProducts()
}
})
}

View file

@ -1,21 +1,23 @@
<div>
<div class="row items-center no-wrap q-mb-md">
<div class="col q-pr-lg">
<q-btn
@click="openCreateStallDialog"
unelevated
color="green"
class="float-left"
>New Stall (Store)</q-btn
>
<q-input
borderless
dense
debounce="300"
v-model="filter"
placeholder="Search"
class="float-right"
>
<q-btn-dropdown @click="openCreateStallDialog()" unelevated split color="green" class="float-left"
label="New Stall (Store)">
<q-item @click="openCreateStallDialog()" clickable v-close-popup>
<q-item-section>
<q-item-label>New Stall</q-item-label>
<q-item-label caption>Create a new stall</q-item-label>
</q-item-section>
</q-item>
<q-item @click="openSelectPendingStallDialog" clickable v-close-popup>
<q-item-section>
<q-item-label>Restore Stall</q-item-label>
<q-item-label caption>Restore existing stall from Nostr</q-item-label>
</q-item-section>
</q-item>
</q-btn-dropdown>
<q-input borderless dense debounce="300" v-model="filter" placeholder="Search" class="float-right">
<template v-slot:append>
<q-icon name="search"></q-icon>
</template>
@ -23,26 +25,13 @@
</div>
</div>
<q-table
flat
dense
:data="stalls"
row-key="id"
:columns="stallsTable.columns"
:pagination.sync="stallsTable.pagination"
:filter="filter"
>
<q-table flat dense :data="stalls" row-key="id" :columns="stallsTable.columns"
:pagination.sync="stallsTable.pagination" :filter="filter">
<template v-slot:body="props">
<q-tr :props="props">
<q-td auto-width>
<q-btn
size="sm"
color="accent"
round
dense
@click="props.row.expanded= !props.row.expanded"
:icon="props.row.expanded? 'remove' : 'add'"
/>
<q-btn size="sm" color="accent" round dense @click="props.row.expanded= !props.row.expanded"
:icon="props.row.expanded? 'remove' : 'add'" />
</q-td>
<q-td key="id" :props="props"> {{props.row.name}} </q-td>
@ -61,17 +50,10 @@
<q-td colspan="100%">
<div class="row items-center q-mb-lg">
<div class="col-12">
<stall-details
:stall-id="props.row.id"
:adminkey="adminkey"
:inkey="inkey"
:wallet-options="walletOptions"
:zone-options="zoneOptions"
:currencies="currencies"
@stall-deleted="handleStallDeleted"
@stall-updated="handleStallUpdated"
@customer-selected-for-order="customerSelectedForOrder"
></stall-details>
<stall-details :stall-id="props.row.id" :adminkey="adminkey" :inkey="inkey"
:wallet-options="walletOptions" :zone-options="zoneOptions" :currencies="currencies"
@stall-deleted="handleStallDeleted" @stall-updated="handleStallUpdated"
@customer-selected-for-order="customerSelectedForOrder"></stall-details>
</div>
</div>
</q-td>
@ -83,64 +65,54 @@
<q-dialog v-model="stallDialog.show" position="top">
<q-card class="q-pa-lg q-pt-xl" style="width: 500px">
<q-form @submit="sendStallFormData" class="q-gutter-md">
<q-input
filled
dense
v-model.trim="stallDialog.data.name"
label="Name"
></q-input>
<q-input
filled
dense
v-model.trim="stallDialog.data.description"
type="textarea"
rows="3"
label="Description"
></q-input>
<q-select
filled
dense
emit-value
v-model="stallDialog.data.wallet"
:options="walletOptions"
label="Wallet *"
>
<q-input filled dense v-model.trim="stallDialog.data.name" label="Name"></q-input>
<q-input filled dense v-model.trim="stallDialog.data.description" type="textarea" rows="3"
label="Description"></q-input>
<q-select filled dense emit-value v-model="stallDialog.data.wallet" :options="walletOptions" label="Wallet *">
</q-select>
<q-select
filled
dense
v-model="stallDialog.data.currency"
type="text"
label="Unit"
:options="currencies"
></q-select>
<q-select
:options="filteredZoneOptions"
filled
dense
multiple
v-model.trim="stallDialog.data.shippingZones"
label="Shipping Zones"
></q-select>
<q-select filled dense v-model="stallDialog.data.currency" type="text" label="Unit"
:options="currencies"></q-select>
<q-select :options="filteredZoneOptions" filled dense multiple v-model.trim="stallDialog.data.shippingZones"
label="Shipping Zones"></q-select>
<div class="row q-mt-lg">
<q-btn
unelevated
color="primary"
:disable="!stallDialog.data.name
<q-btn unelevated color="primary" :disable="!stallDialog.data.name
|| !stallDialog.data.currency
|| !stallDialog.data.wallet
|| !stallDialog.data.shippingZones
|| !stallDialog.data.shippingZones.length"
type="submit"
>Create Stall</q-btn
>
<q-btn v-close-popup flat color="grey" class="q-ml-auto"
>Cancel</q-btn
>
|| !stallDialog.data.shippingZones.length" type="submit"
:label="stallDialog.data.id ? 'Restore Stall' : 'Create Stall'"></q-btn>
<q-btn v-close-popup flat color="grey" class="q-ml-auto">Cancel</q-btn>
</div>
</q-form>
</q-card>
</q-dialog>
<q-dialog v-model="stallDialog.showRestore" position="top">
<q-card class="q-pa-lg q-pt-xl" style="width: 500px">
<div v-if="pendingStalls && pendingStalls.length" class="row q-mt-lg">
<q-item v-for="pendingStall of pendingStalls" :key="pendingStall.id" tag="label" class="full-width" v-ripple>
<q-item-section>
<q-item-label><span v-text="pendingStall.name"></span></q-item-label>
<q-item-label caption><span v-text="pendingStall.config?.description"></span></q-item-label>
</q-item-section>
<q-item-section class="q-pl-xl float-right">
<q-btn @click="openRestoreStallDialog(pendingStall)" v-close-popup flat color="green"
class="q-ml-auto float-right">Restore</q-btn>
</q-item-section>
<q-item-section class="float-right">
<q-btn @click="deleteStall(pendingStall)" v-close-popup color="red" class="q-ml-auto float-right"
icon="cancel"></q-btn>
</q-item-section>
</q-item>
</div>
<div v-else>
There are no stalls to be restored.
</div>
<div class="row q-mt-lg">
<q-btn v-close-popup flat color="grey" class="q-ml-auto">Close</q-btn>
</div>
</q-card>
</q-dialog>
</div>
</div>
</div>

View file

@ -9,9 +9,11 @@ async function stallList(path) {
return {
filter: '',
stalls: [],
pendingStalls: [],
currencies: [],
stallDialog: {
show: false,
showRestore: false,
data: {
name: '',
description: '',
@ -69,7 +71,7 @@ async function stallList(path) {
},
methods: {
sendStallFormData: async function () {
await this.createStall({
const stallData = {
name: this.stallDialog.data.name,
wallet: this.stallDialog.data.wallet,
currency: this.stallDialog.data.currency,
@ -77,11 +79,18 @@ async function stallList(path) {
config: {
description: this.stallDialog.data.description
}
})
}
if (this.stallDialog.data.id) {
stallData.id = this.stallDialog.data.id
await this.restoreStall(stallData)
} else {
await this.createStall(stallData)
}
},
createStall: async function (stall) {
try {
const {data} = await LNbits.api.request(
const { data } = await LNbits.api.request(
'POST',
'/nostrmarket/api/v1/stall',
this.adminkey,
@ -98,39 +107,86 @@ async function stallList(path) {
LNbits.utils.notifyApiError(error)
}
},
restoreStall: async function (stallData) {
try {
stallData.pending = false
const { data } = await LNbits.api.request(
'PUT',
`/nostrmarket/api/v1/stall/${stallData.id}`,
this.adminkey,
stallData
)
this.stallDialog.show = false
data.expanded = false
this.stalls.unshift(data)
this.$q.notify({
type: 'positive',
message: 'Stall restored!'
})
} catch (error) {
LNbits.utils.notifyApiError(error)
}
},
deleteStall: async function (pendingStall) {
LNbits.utils
.confirmDialog(
`
Are you sure you want to delete this pending stall '${pendingStall.name}'?
`
)
.onOk(async () => {
try {
await LNbits.api.request(
'DELETE',
'/nostrmarket/api/v1/stall/' + pendingStall.id,
this.adminkey
)
this.$q.notify({
type: 'positive',
message: 'Pending Stall Deleted',
timeout: 5000
})
} catch (error) {
console.warn(error)
LNbits.utils.notifyApiError(error)
}
})
},
getCurrencies: async function () {
try {
const {data} = await LNbits.api.request(
const { data } = await LNbits.api.request(
'GET',
'/nostrmarket/api/v1/currencies',
this.inkey
)
this.currencies = ['sat', ...data]
return ['sat', ...data]
} catch (error) {
LNbits.utils.notifyApiError(error)
}
return []
},
getStalls: async function () {
getStalls: async function (pending = false) {
try {
const {data} = await LNbits.api.request(
const { data } = await LNbits.api.request(
'GET',
'/nostrmarket/api/v1/stall',
`/nostrmarket/api/v1/stall?pending=${pending}`,
this.inkey
)
this.stalls = data.map(s => ({...s, expanded: false}))
return data.map(s => ({ ...s, expanded: false }))
} catch (error) {
LNbits.utils.notifyApiError(error)
}
return []
},
getZones: async function () {
try {
const {data} = await LNbits.api.request(
const { data } = await LNbits.api.request(
'GET',
'/nostrmarket/api/v1/zone',
this.inkey
)
this.zoneOptions = data.map(z => ({
return data.map(z => ({
...z,
label: z.name
? `${z.name} (${z.countries.join(', ')})`
@ -139,6 +195,7 @@ async function stallList(path) {
} catch (error) {
LNbits.utils.notifyApiError(error)
}
return []
},
handleStallDeleted: function (stallId) {
this.stalls = _.reject(this.stalls, function (obj) {
@ -152,9 +209,9 @@ async function stallList(path) {
this.stalls.splice(index, 1, stall)
}
},
openCreateStallDialog: async function () {
await this.getCurrencies()
await this.getZones()
openCreateStallDialog: async function (stallData) {
this.currencies = await this.getCurrencies()
this.zoneOptions = await this.getZones()
if (!this.zoneOptions || !this.zoneOptions.length) {
this.$q.notify({
type: 'warning',
@ -162,7 +219,7 @@ async function stallList(path) {
})
return
}
this.stallDialog.data = {
this.stallDialog.data = stallData || {
name: '',
description: '',
wallet: null,
@ -171,14 +228,35 @@ async function stallList(path) {
}
this.stallDialog.show = true
},
openSelectPendingStallDialog: async function () {
this.stallDialog.showRestore = true
this.pendingStalls = await this.getStalls(true)
},
openRestoreStallDialog: async function (pendingStall) {
const shippingZonesIds = this.zoneOptions.map(z => z.id)
await this.openCreateStallDialog({
id: pendingStall.id,
name: pendingStall.name,
description: pendingStall.config?.description,
currency: pendingStall.currency,
shippingZones: (pendingStall.shipping_zones || [])
.filter(z => shippingZonesIds.indexOf(z.id) !== -1)
.map(z => ({
...z,
label: z.name
? `${z.name} (${z.countries.join(', ')})`
: z.countries.join(', ')
}))
})
},
customerSelectedForOrder: function (customerPubkey) {
this.$emit('customer-selected-for-order', customerPubkey)
}
},
created: async function () {
await this.getStalls()
await this.getCurrencies()
await this.getZones()
this.stalls = await this.getStalls()
this.currencies = await this.getCurrencies()
this.zoneOptions = await this.getZones()
}
})
}

View file

@ -28,7 +28,8 @@ const merchant = async () => {
data: {
privateKey: null
}
}
},
wsConnection: null
}
},
methods: {
@ -114,8 +115,9 @@ const merchant = async () => {
const scheme = location.protocol === 'http:' ? 'ws' : 'wss'
const port = location.port ? `:${location.port}` : ''
const wsUrl = `${scheme}://${document.domain}${port}/api/v1/ws/${this.merchant.id}`
const wsConnection = new WebSocket(wsUrl)
wsConnection.onmessage = async e => {
console.log('Reconnecting to websocket: ', wsUrl)
this.wsConnection = new WebSocket(wsUrl)
this.wsConnection.onmessage = async e => {
const data = JSON.parse(e.data)
if (data.type === 'new-order') {
this.$q.notify({
@ -124,10 +126,20 @@ const merchant = async () => {
message: 'New Order'
})
await this.$refs.orderListRef.addOrder(data)
} else if (data.type === 'order-paid') {
this.$q.notify({
timeout: 5000,
type: 'positive',
message: 'Order Paid'
})
await this.$refs.orderListRef.addOrder(data)
} else if (data.type === 'new-direct-message') {
await this.$refs.directMessagesRef.handleNewMessage(data)
}
// order paid
// order shipped
}
} catch (error) {
this.$q.notify({
timeout: 5000,
@ -157,7 +169,11 @@ const merchant = async () => {
},
created: async function () {
await this.getMerchant()
await this.waitForNotifications()
setInterval(async () => {
if (!this.wsConnection || this.wsConnection.readyState !== WebSocket.OPEN) {
await this.waitForNotifications()
}
}, 1000)
}
})
}