mirror of
https://github.com/lnbits/lnbits-legend.git
synced 2025-02-24 14:51:05 +01:00
QR codes, printing, success-action and other fixes.
This commit is contained in:
parent
cda0819f64
commit
63ae553565
9 changed files with 147 additions and 33 deletions
|
@ -136,7 +136,7 @@ async def get_standalone_payment(checking_id_or_hash: str) -> Optional[Payment]:
|
||||||
"""
|
"""
|
||||||
SELECT *
|
SELECT *
|
||||||
FROM apipayments
|
FROM apipayments
|
||||||
WHERE checking_id = ? OR payment_hash = ?
|
WHERE checking_id = ? OR hash = ?
|
||||||
LIMIT 1
|
LIMIT 1
|
||||||
""",
|
""",
|
||||||
(checking_id_or_hash, checking_id_or_hash),
|
(checking_id_or_hash, checking_id_or_hash),
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
{
|
{
|
||||||
"name": "Offline Shop",
|
"name": "OfflineShop",
|
||||||
"short_description": "Sell stuff with Lightning and lnurlpay on a shop without internet or any electronic device.",
|
"short_description": "Sell stuff with Lightning and lnurlpay on a shop without internet or any electronic device.",
|
||||||
"icon": "nature_people",
|
"icon": "nature_people",
|
||||||
"contributors": [
|
"contributors": [
|
||||||
|
|
|
@ -22,7 +22,7 @@ async def lnurl_response(item_id):
|
||||||
price_msat = item.price * 1000 * rate
|
price_msat = item.price * 1000 * rate
|
||||||
|
|
||||||
resp = LnurlPayResponse(
|
resp = LnurlPayResponse(
|
||||||
callback=url_for("shop.lnurl_callback", item_id=item.id, _external=True),
|
callback=url_for("offlineshop.lnurl_callback", item_id=item.id, _external=True),
|
||||||
min_sendable=price_msat,
|
min_sendable=price_msat,
|
||||||
max_sendable=price_msat,
|
max_sendable=price_msat,
|
||||||
metadata=await item.lnurlpay_metadata(),
|
metadata=await item.lnurlpay_metadata(),
|
||||||
|
@ -56,11 +56,11 @@ async def lnurl_callback(item_id):
|
||||||
payment_hash, payment_request = await create_invoice(
|
payment_hash, payment_request = await create_invoice(
|
||||||
wallet_id=shop.wallet,
|
wallet_id=shop.wallet,
|
||||||
amount=int(amount_received / 1000),
|
amount=int(amount_received / 1000),
|
||||||
memo=await item.name,
|
memo=item.name,
|
||||||
description_hash=hashlib.sha256((await item.lnurlpay_metadata()).encode("utf-8")).digest(),
|
description_hash=hashlib.sha256((await item.lnurlpay_metadata()).encode("utf-8")).digest(),
|
||||||
extra={"tag": "offlineshop", "item": item.id},
|
extra={"tag": "offlineshop", "item": item.id},
|
||||||
)
|
)
|
||||||
|
|
||||||
resp = LnurlPayActionResponse(pr=payment_request, success_action=item.success_action(payment_hash, shop), routes=[])
|
resp = LnurlPayActionResponse(pr=payment_request, success_action=item.success_action(shop, payment_hash), routes=[])
|
||||||
|
|
||||||
return jsonify(resp.dict())
|
return jsonify(resp.dict())
|
||||||
|
|
|
@ -1,11 +1,54 @@
|
||||||
import json
|
import json
|
||||||
from collections import OrderedDict
|
from collections import OrderedDict
|
||||||
from quart import url_for
|
from quart import url_for
|
||||||
from typing import NamedTuple, Optional
|
from typing import NamedTuple, Optional, List
|
||||||
from lnurl import encode as lnurl_encode # type: ignore
|
from lnurl import encode as lnurl_encode # type: ignore
|
||||||
from lnurl.types import LnurlPayMetadata # type: ignore
|
from lnurl.types import LnurlPayMetadata # type: ignore
|
||||||
from lnurl.models import LnurlPaySuccessAction, UrlAction # type: ignore
|
from lnurl.models import LnurlPaySuccessAction, UrlAction # type: ignore
|
||||||
|
|
||||||
|
shop_counters = {}
|
||||||
|
|
||||||
|
|
||||||
|
class ShopCounter(object):
|
||||||
|
fulfilled_payments: OrderedDict
|
||||||
|
counter: int
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def invoke(cls, shop: "Shop"):
|
||||||
|
shop_counter = shop_counters.get(shop.id)
|
||||||
|
if not shop_counter:
|
||||||
|
shop_counter = cls(wordlist=shop.wordlist.split("\n"))
|
||||||
|
shop_counters[shop.id] = shop_counter
|
||||||
|
return shop_counter
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def reset(cls, shop: "Shop"):
|
||||||
|
shop_counter = cls.invoke(shop)
|
||||||
|
shop_counter.counter = -1
|
||||||
|
shop_counter.wordlist = shop.wordlist.split("\n")
|
||||||
|
|
||||||
|
def __init__(self, wordlist: List[str]):
|
||||||
|
self.wordlist = wordlist
|
||||||
|
self.fulfilled_payments = OrderedDict()
|
||||||
|
self.counter = -1
|
||||||
|
|
||||||
|
def get_word(self, payment_hash):
|
||||||
|
if payment_hash in self.fulfilled_payments:
|
||||||
|
return self.fulfilled_payments[payment_hash]
|
||||||
|
|
||||||
|
# get a new word
|
||||||
|
self.counter += 1
|
||||||
|
word = self.wordlist[self.counter % len(self.wordlist)]
|
||||||
|
self.fulfilled_payments[payment_hash] = word
|
||||||
|
|
||||||
|
# cleanup confirmation words cache
|
||||||
|
to_remove = len(self.fulfilled_payments) - 23
|
||||||
|
if to_remove > 0:
|
||||||
|
for i in range(to_remove):
|
||||||
|
self.fulfilled_payments.popitem(False)
|
||||||
|
|
||||||
|
return word
|
||||||
|
|
||||||
|
|
||||||
class Shop(NamedTuple):
|
class Shop(NamedTuple):
|
||||||
id: int
|
id: int
|
||||||
|
@ -13,24 +56,8 @@ class Shop(NamedTuple):
|
||||||
wordlist: str
|
wordlist: str
|
||||||
|
|
||||||
def get_word(self, payment_hash):
|
def get_word(self, payment_hash):
|
||||||
# initialize confirmation words cache
|
sc = ShopCounter.invoke(self)
|
||||||
self.fulfilled_payments = self.words or OrderedDict()
|
return sc.get_word(payment_hash)
|
||||||
|
|
||||||
if payment_hash in self.fulfilled_payments:
|
|
||||||
return self.fulfilled_payments[payment_hash]
|
|
||||||
|
|
||||||
# get a new word
|
|
||||||
self.counter = (self.counter or -1) + 1
|
|
||||||
wordlist = self.wordlist.split("\n")
|
|
||||||
word = [self.counter % len(wordlist)]
|
|
||||||
|
|
||||||
# cleanup confirmation words cache
|
|
||||||
to_remove = self.fulfilled_payments - 23
|
|
||||||
if to_remove > 0:
|
|
||||||
for i in range(to_remove):
|
|
||||||
self.fulfilled_payments.popitem(False)
|
|
||||||
|
|
||||||
return word
|
|
||||||
|
|
||||||
|
|
||||||
class Item(NamedTuple):
|
class Item(NamedTuple):
|
||||||
|
@ -45,18 +72,23 @@ class Item(NamedTuple):
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def lnurl(self) -> str:
|
def lnurl(self) -> str:
|
||||||
return lnurl_encode(url_for("offlineshop.lnurl_response", item_id=self.id))
|
return lnurl_encode(url_for("offlineshop.lnurl_response", item_id=self.id, _external=True))
|
||||||
|
|
||||||
|
def values(self):
|
||||||
|
values = self._asdict()
|
||||||
|
values["lnurl"] = self.lnurl
|
||||||
|
return values
|
||||||
|
|
||||||
async def lnurlpay_metadata(self) -> LnurlPayMetadata:
|
async def lnurlpay_metadata(self) -> LnurlPayMetadata:
|
||||||
metadata = [["text/plain", self.description]]
|
metadata = [["text/plain", self.description]]
|
||||||
|
|
||||||
if self.image:
|
if self.image:
|
||||||
metadata.append(self.image.split(","))
|
metadata.append(self.image.split(":")[1].split(","))
|
||||||
|
|
||||||
return LnurlPayMetadata(json.dumps(metadata))
|
return LnurlPayMetadata(json.dumps(metadata))
|
||||||
|
|
||||||
def success_action(self, shop: Shop, payment_hash: str) -> Optional[LnurlPaySuccessAction]:
|
def success_action(self, shop: Shop, payment_hash: str) -> Optional[LnurlPaySuccessAction]:
|
||||||
if not self.wordlist:
|
if not shop.wordlist:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
return UrlAction(
|
return UrlAction(
|
||||||
|
|
|
@ -25,6 +25,11 @@ new Vue({
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
computed: {
|
||||||
|
printItems() {
|
||||||
|
return this.offlineshop.items.filter(({enabled}) => enabled)
|
||||||
|
}
|
||||||
|
},
|
||||||
methods: {
|
methods: {
|
||||||
openNewDialog() {
|
openNewDialog() {
|
||||||
this.itemDialog.show = true
|
this.itemDialog.show = true
|
||||||
|
|
|
@ -18,6 +18,7 @@
|
||||||
<q-table
|
<q-table
|
||||||
dense
|
dense
|
||||||
flat
|
flat
|
||||||
|
selection="multiple"
|
||||||
:data="offlineshop.items"
|
:data="offlineshop.items"
|
||||||
row-key="id"
|
row-key="id"
|
||||||
no-data-label="No items for sale yet"
|
no-data-label="No items for sale yet"
|
||||||
|
@ -101,6 +102,16 @@
|
||||||
>
|
>
|
||||||
</q-select>
|
</q-select>
|
||||||
</q-form>
|
</q-form>
|
||||||
|
|
||||||
|
<div v-if="printItems.length > 0" class="row q-gutter-sm q-my-md">
|
||||||
|
<q-btn
|
||||||
|
type="a"
|
||||||
|
outline
|
||||||
|
color="purple"
|
||||||
|
:href="'print?items=' + printItems.map(({id}) => id).join(',')"
|
||||||
|
>Print QR Codes</q-btn
|
||||||
|
>
|
||||||
|
</div>
|
||||||
</q-card-section>
|
</q-card-section>
|
||||||
</q-card>
|
</q-card>
|
||||||
</div>
|
</div>
|
||||||
|
@ -120,6 +131,30 @@
|
||||||
<q-dialog v-model="itemDialog.show">
|
<q-dialog v-model="itemDialog.show">
|
||||||
<q-card class="q-pa-lg q-pt-xl lnbits__dialog-card">
|
<q-card class="q-pa-lg q-pt-xl lnbits__dialog-card">
|
||||||
<q-card-section>
|
<q-card-section>
|
||||||
|
<h5
|
||||||
|
class="q-ma-none"
|
||||||
|
v-if="itemDialog.data.id"
|
||||||
|
v-text="itemDialog.data.name"
|
||||||
|
></h5>
|
||||||
|
<h5 class="q-ma-none q-mb-xl" v-else>Adding a new item</h5>
|
||||||
|
|
||||||
|
<q-responsive v-if="itemDialog.data.id" :ratio="1">
|
||||||
|
<qrcode
|
||||||
|
:value="itemDialog.data.lnurl"
|
||||||
|
:options="{width: 800}"
|
||||||
|
class="rounded-borders"
|
||||||
|
></qrcode>
|
||||||
|
</q-responsive>
|
||||||
|
|
||||||
|
<div v-if="itemDialog.data.id" class="row q-gutter-sm justify-center">
|
||||||
|
<q-btn
|
||||||
|
outline
|
||||||
|
color="grey"
|
||||||
|
@click="copyText(itemDialog.data.lnurl, 'LNURL copied to clipboard!')"
|
||||||
|
class="q-mb-lg"
|
||||||
|
>Copy LNURL</q-btn
|
||||||
|
>
|
||||||
|
</div>
|
||||||
<q-form @submit="sendItem" class="q-gutter-md">
|
<q-form @submit="sendItem" class="q-gutter-md">
|
||||||
<q-input
|
<q-input
|
||||||
filled
|
filled
|
||||||
|
|
|
@ -0,0 +1,25 @@
|
||||||
|
{% extends "print.html" %} {% block page %} {% raw %}
|
||||||
|
<div class="row justify-center">
|
||||||
|
<div v-for="item in items" class="q-my-sm q-mx-lg">
|
||||||
|
<div class="text-center q-ma-none q-mb-sm">{{ item.name }}</div>
|
||||||
|
<qrcode :value="item.lnurl" :options="{margin: 0, width: 250}"></qrcode>
|
||||||
|
<div class="text-center q-ma-none q-mt-sm">{{ item.price }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endraw %} {% endblock %} {% block scripts %}
|
||||||
|
<script>
|
||||||
|
Vue.component(VueQrcode.name, VueQrcode)
|
||||||
|
|
||||||
|
new Vue({
|
||||||
|
el: '#vue',
|
||||||
|
created: function () {
|
||||||
|
window.print()
|
||||||
|
},
|
||||||
|
data: function () {
|
||||||
|
return {
|
||||||
|
items: JSON.parse('{{items | tojson}}')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
|
@ -17,18 +17,35 @@ async def index():
|
||||||
return await render_template("offlineshop/index.html", user=g.user)
|
return await render_template("offlineshop/index.html", user=g.user)
|
||||||
|
|
||||||
|
|
||||||
|
@offlineshop_ext.route("/print")
|
||||||
|
async def print_qr_codes():
|
||||||
|
items = []
|
||||||
|
for item_id in request.args.get("items").split(","):
|
||||||
|
item = await get_item(item_id)
|
||||||
|
if item:
|
||||||
|
items.append({"lnurl": item.lnurl, "name": item.name, "price": f"{item.price} {item.unit}"})
|
||||||
|
|
||||||
|
return await render_template("offlineshop/print.html", items=items)
|
||||||
|
|
||||||
|
|
||||||
@offlineshop_ext.route("/confirmation")
|
@offlineshop_ext.route("/confirmation")
|
||||||
async def confirmation_code():
|
async def confirmation_code():
|
||||||
|
style = "<style>* { font-size: 100px}</style>"
|
||||||
|
|
||||||
payment_hash = request.args.get("p")
|
payment_hash = request.args.get("p")
|
||||||
payment: Payment = await get_standalone_payment(payment_hash)
|
payment: Payment = await get_standalone_payment(payment_hash)
|
||||||
if not payment:
|
if not payment:
|
||||||
return f"Couldn't find the payment {payment_hash}.", HTTPStatus.NOT_FOUND
|
return f"Couldn't find the payment {payment_hash}." + style, HTTPStatus.NOT_FOUND
|
||||||
if payment.pending:
|
if payment.pending:
|
||||||
return f"Payment {payment_hash} wasn't received yet. Please try again in a minute.", HTTPStatus.PAYMENT_REQUIRED
|
return (
|
||||||
|
f"Payment {payment_hash} wasn't received yet. Please try again in a minute." + style,
|
||||||
|
HTTPStatus.PAYMENT_REQUIRED,
|
||||||
|
)
|
||||||
|
|
||||||
if payment.time + 60 * 15 < time.time():
|
if payment.time + 60 * 15 < time.time():
|
||||||
return "too much time has passed."
|
return "too much time has passed." + style
|
||||||
|
|
||||||
item = await get_item(payment.extra.get("item"))
|
item = await get_item(payment.extra.get("item"))
|
||||||
shop = await get_shop(item.shop)
|
shop = await get_shop(item.shop)
|
||||||
return shop.next_word(payment_hash)
|
|
||||||
|
return shop.get_word(payment_hash) + style
|
||||||
|
|
|
@ -22,7 +22,7 @@ async def api_shop_from_wallet():
|
||||||
|
|
||||||
try:
|
try:
|
||||||
return (
|
return (
|
||||||
jsonify({**shop._asdict(), **{"items": [item._asdict() for item in items],},}),
|
jsonify({**shop._asdict(), **{"items": [item.values() for item in items],},}),
|
||||||
HTTPStatus.OK,
|
HTTPStatus.OK,
|
||||||
)
|
)
|
||||||
except LnurlInvalidUrl:
|
except LnurlInvalidUrl:
|
||||||
|
@ -39,7 +39,7 @@ async def api_shop_from_wallet():
|
||||||
schema={
|
schema={
|
||||||
"name": {"type": "string", "empty": False, "required": True},
|
"name": {"type": "string", "empty": False, "required": True},
|
||||||
"description": {"type": "string", "empty": False, "required": True},
|
"description": {"type": "string", "empty": False, "required": True},
|
||||||
"image": {"type": "string", "required": False},
|
"image": {"type": "string", "required": False, "nullable": True},
|
||||||
"price": {"type": "number", "required": True},
|
"price": {"type": "number", "required": True},
|
||||||
"unit": {"type": "string", "allowed": ["sat", "USD"], "required": True},
|
"unit": {"type": "string", "allowed": ["sat", "USD"], "required": True},
|
||||||
}
|
}
|
||||||
|
|
Loading…
Add table
Reference in a new issue