mirror of
https://github.com/lnbits/lnbits-legend.git
synced 2024-11-19 18:11:30 +01:00
Merge pull request #1502 from lnbits/remove-offlineshop
remove offlineshop
This commit is contained in:
commit
38d72408fb
@ -1,36 +0,0 @@
|
||||
# Offline Shop
|
||||
|
||||
## Create QR codes for each product and display them on your store for receiving payments Offline
|
||||
|
||||
[![video tutorial offline shop](http://img.youtube.com/vi/_XAvM_LNsoo/0.jpg)](https://youtu.be/_XAvM_LNsoo 'video tutorial offline shop')
|
||||
|
||||
LNbits Offline Shop allows for merchants to receive Bitcoin payments while offline and without any electronic device.
|
||||
|
||||
Merchant will create items and associate a QR code ([a LNURLp](https://github.com/lnbits/lnbits/blob/master/lnbits/extensions/lnurlp/README.md)) with a price. He can then print the QR codes and display them on their shop. When a customer chooses an item, scans the QR code, gets the description and price. After payment, the customer gets a confirmation code that the merchant can validate to be sure the payment was successful.
|
||||
|
||||
Customers must use an LNURL pay capable wallet.
|
||||
|
||||
[**Wallets supporting LNURL**](https://github.com/fiatjaf/awesome-lnurl#wallets)
|
||||
|
||||
## Usage
|
||||
|
||||
1. Entering the Offline shop extension you'll see an Items list, the Shop wallet and a Wordslist\
|
||||
![offline shop back office](https://i.imgur.com/Ei7cxj9.png)
|
||||
2. Begin by creating an item, click "ADD NEW ITEM"
|
||||
- set the item name and a small description
|
||||
- you can set an optional, preferably square image, that will show up on the customer wallet - _depending on wallet_
|
||||
- set the item price, if you choose a fiat currency the bitcoin conversion will happen at the time customer scans to pay\
|
||||
![add new item](https://i.imgur.com/pkZqRgj.png)
|
||||
3. After creating some products, click on "PRINT QR CODES"\
|
||||
![print qr codes](https://i.imgur.com/2GAiSTe.png)
|
||||
4. You'll see a QR code for each product in your LNbits Offline Shop with a title and price ready for printing\
|
||||
![qr codes sheet](https://i.imgur.com/faEqOcd.png)
|
||||
5. Place the printed QR codes on your shop, or at the fair stall, or have them as a menu style laminated sheet
|
||||
6. Choose what type of confirmation do you want customers to report to merchant after a successful payment\
|
||||
![wordlist](https://i.imgur.com/9aM6NUL.png)
|
||||
|
||||
- Wordlist is the default option: after a successful payment the customer will receive a word from this list, **sequentially**. Starting in _albatross_ as customers pay for the items they will get the next word in the list until _zebra_, then it starts at the top again. The list can be changed, for example if you think A-Z is a big list to track, you can use _apple_, _banana_, _coconut_\
|
||||
![totp authenticator](https://i.imgur.com/MrJXFxz.png)
|
||||
- TOTP (time-based one time password) can be used instead. If you use Google Authenticator just scan the presented QR with the app and after a successful payment the user will get the password that you can check with GA\
|
||||
![disable confirmations](https://i.imgur.com/2OFs4yi.png)
|
||||
- Nothing, disables the need for confirmation of payment, click the "DISABLE CONFIRMATION CODES"
|
@ -1,26 +0,0 @@
|
||||
from fastapi import APIRouter
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
|
||||
from lnbits.db import Database
|
||||
from lnbits.helpers import template_renderer
|
||||
|
||||
db = Database("ext_offlineshop")
|
||||
|
||||
offlineshop_static_files = [
|
||||
{
|
||||
"path": "/offlineshop/static",
|
||||
"app": StaticFiles(packages=[("lnbits", "extensions/offlineshop/static")]),
|
||||
"name": "offlineshop_static",
|
||||
}
|
||||
]
|
||||
|
||||
offlineshop_ext: APIRouter = APIRouter(prefix="/offlineshop", tags=["Offlineshop"])
|
||||
|
||||
|
||||
def offlineshop_renderer():
|
||||
return template_renderer(["lnbits/extensions/offlineshop/templates"])
|
||||
|
||||
|
||||
from .lnurl import * # noqa: F401,F403
|
||||
from .views import * # noqa: F401,F403
|
||||
from .views_api import * # noqa: F401,F403
|
@ -1,8 +0,0 @@
|
||||
{
|
||||
"name": "OfflineShop",
|
||||
"short_description": "Receive payments for products offline!",
|
||||
"tile": "/offlineshop/static/image/offlineshop.png",
|
||||
"contributors": [
|
||||
"fiatjaf"
|
||||
]
|
||||
}
|
@ -1,117 +0,0 @@
|
||||
from typing import List, Optional
|
||||
|
||||
from lnbits.db import SQLITE
|
||||
|
||||
from . import db
|
||||
from .models import Item, Shop
|
||||
from .wordlists import animals
|
||||
|
||||
|
||||
async def create_shop(*, wallet_id: str) -> int:
|
||||
returning = "" if db.type == SQLITE else "RETURNING ID"
|
||||
method = db.execute if db.type == SQLITE else db.fetchone
|
||||
|
||||
result = await (method)(
|
||||
f"""
|
||||
INSERT INTO offlineshop.shops (wallet, wordlist, method)
|
||||
VALUES (?, ?, 'wordlist')
|
||||
{returning}
|
||||
""",
|
||||
(wallet_id, "\n".join(animals)),
|
||||
)
|
||||
if db.type == SQLITE:
|
||||
return result._result_proxy.lastrowid
|
||||
else:
|
||||
return result[0] # type: ignore
|
||||
|
||||
|
||||
async def get_shop(id: int) -> Optional[Shop]:
|
||||
row = await db.fetchone("SELECT * FROM offlineshop.shops WHERE id = ?", (id,))
|
||||
return Shop(**row) if row else None
|
||||
|
||||
|
||||
async def get_or_create_shop_by_wallet(wallet: str) -> Optional[Shop]:
|
||||
row = await db.fetchone(
|
||||
"SELECT * FROM offlineshop.shops WHERE wallet = ?", (wallet,)
|
||||
)
|
||||
|
||||
if not row:
|
||||
# create on the fly
|
||||
ls_id = await create_shop(wallet_id=wallet)
|
||||
return await get_shop(ls_id)
|
||||
|
||||
return Shop(**row) if row else None
|
||||
|
||||
|
||||
async def set_method(shop: int, method: str, wordlist: str = "") -> Optional[Shop]:
|
||||
await db.execute(
|
||||
"UPDATE offlineshop.shops SET method = ?, wordlist = ? WHERE id = ?",
|
||||
(method, wordlist, shop),
|
||||
)
|
||||
return await get_shop(shop)
|
||||
|
||||
|
||||
async def add_item(
|
||||
shop: int,
|
||||
name: str,
|
||||
description: str,
|
||||
image: Optional[str],
|
||||
price: int,
|
||||
unit: str,
|
||||
fiat_base_multiplier: int,
|
||||
) -> int:
|
||||
result = await db.execute(
|
||||
"""
|
||||
INSERT INTO offlineshop.items (shop, name, description, image, price, unit, fiat_base_multiplier)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(shop, name, description, image, price, unit, fiat_base_multiplier),
|
||||
)
|
||||
return result._result_proxy.lastrowid
|
||||
|
||||
|
||||
async def update_item(
|
||||
shop: int,
|
||||
item_id: int,
|
||||
name: str,
|
||||
description: str,
|
||||
image: Optional[str],
|
||||
price: int,
|
||||
unit: str,
|
||||
fiat_base_multiplier: int,
|
||||
) -> int:
|
||||
await db.execute(
|
||||
"""
|
||||
UPDATE offlineshop.items SET
|
||||
name = ?,
|
||||
description = ?,
|
||||
image = ?,
|
||||
price = ?,
|
||||
unit = ?,
|
||||
fiat_base_multiplier = ?
|
||||
WHERE shop = ? AND id = ?
|
||||
""",
|
||||
(name, description, image, price, unit, fiat_base_multiplier, shop, item_id),
|
||||
)
|
||||
return item_id
|
||||
|
||||
|
||||
async def get_item(id: int) -> Optional[Item]:
|
||||
row = await db.fetchone(
|
||||
"SELECT * FROM offlineshop.items WHERE id = ? LIMIT 1", (id,)
|
||||
)
|
||||
return Item.from_row(row) if row else None
|
||||
|
||||
|
||||
async def get_items(shop: int) -> List[Item]:
|
||||
rows = await db.fetchall("SELECT * FROM offlineshop.items WHERE shop = ?", (shop,))
|
||||
return [Item.from_row(row) for row in rows]
|
||||
|
||||
|
||||
async def delete_item_from_shop(shop: int, item_id: int):
|
||||
await db.execute(
|
||||
"""
|
||||
DELETE FROM offlineshop.items WHERE shop = ? AND id = ?
|
||||
""",
|
||||
(shop, item_id),
|
||||
)
|
@ -1,17 +0,0 @@
|
||||
import base64
|
||||
import hmac
|
||||
import struct
|
||||
import time
|
||||
|
||||
|
||||
def hotp(key, counter, digits=6, digest="sha1"):
|
||||
key = base64.b32decode(key.upper() + "=" * ((8 - len(key)) % 8))
|
||||
counter = struct.pack(">Q", counter)
|
||||
mac = hmac.new(key, counter, digest).digest()
|
||||
offset = mac[-1] & 0x0F
|
||||
binary = struct.unpack(">L", mac[offset : offset + 4])[0] & 0x7FFFFFFF
|
||||
return str(binary)[-digits:].zfill(digits)
|
||||
|
||||
|
||||
def totp(key, time_step=30, digits=6, digest="sha1"):
|
||||
return hotp(key, int(time.time() / time_step), digits, digest)
|
@ -1,88 +0,0 @@
|
||||
from fastapi import Query
|
||||
from lnurl import LnurlErrorResponse, LnurlPayActionResponse, LnurlPayResponse
|
||||
from lnurl.models import ClearnetUrl, LightningInvoice, MilliSatoshi
|
||||
from starlette.requests import Request
|
||||
|
||||
from lnbits.core.services import create_invoice
|
||||
from lnbits.utils.exchange_rates import fiat_amount_as_satoshis
|
||||
|
||||
from . import offlineshop_ext
|
||||
from .crud import get_item, get_shop
|
||||
|
||||
|
||||
@offlineshop_ext.get("/lnurl/{item_id}", name="offlineshop.lnurl_response")
|
||||
async def lnurl_response(req: Request, item_id: int = Query(...)) -> dict:
|
||||
item = await get_item(item_id)
|
||||
if not item:
|
||||
return {"status": "ERROR", "reason": "Item not found."}
|
||||
|
||||
if not item.enabled:
|
||||
return {"status": "ERROR", "reason": "Item disabled."}
|
||||
|
||||
price_msat = (
|
||||
await fiat_amount_as_satoshis(item.price, item.unit)
|
||||
if item.unit != "sat"
|
||||
else item.price
|
||||
) * 1000
|
||||
|
||||
resp = LnurlPayResponse(
|
||||
callback=ClearnetUrl(
|
||||
req.url_for("offlineshop.lnurl_callback", item_id=item.id), scheme="https"
|
||||
),
|
||||
minSendable=MilliSatoshi(price_msat),
|
||||
maxSendable=MilliSatoshi(price_msat),
|
||||
metadata=await item.lnurlpay_metadata(),
|
||||
)
|
||||
|
||||
return resp.dict()
|
||||
|
||||
|
||||
@offlineshop_ext.get("/lnurl/cb/{item_id}", name="offlineshop.lnurl_callback")
|
||||
async def lnurl_callback(request: Request, item_id: int):
|
||||
item = await get_item(item_id)
|
||||
if not item:
|
||||
return {"status": "ERROR", "reason": "Couldn't find item."}
|
||||
|
||||
if item.unit == "sat":
|
||||
min = item.price * 1000
|
||||
max = item.price * 1000
|
||||
else:
|
||||
price = await fiat_amount_as_satoshis(item.price, item.unit)
|
||||
# allow some fluctuation (the fiat price may have changed between the calls)
|
||||
min = price * 995
|
||||
max = price * 1010
|
||||
|
||||
amount_received = int(request.query_params.get("amount") or 0)
|
||||
if amount_received < min:
|
||||
return LnurlErrorResponse(
|
||||
reason=f"Amount {amount_received} is smaller than minimum {min}."
|
||||
).dict()
|
||||
elif amount_received > max:
|
||||
return LnurlErrorResponse(
|
||||
reason=f"Amount {amount_received} is greater than maximum {max}."
|
||||
).dict()
|
||||
|
||||
shop = await get_shop(item.shop)
|
||||
assert shop
|
||||
|
||||
try:
|
||||
payment_hash, payment_request = await create_invoice(
|
||||
wallet_id=shop.wallet,
|
||||
amount=int(amount_received / 1000),
|
||||
memo=item.name,
|
||||
unhashed_description=(await item.lnurlpay_metadata()).encode(),
|
||||
extra={"tag": "offlineshop", "item": item.id},
|
||||
)
|
||||
except Exception as exc:
|
||||
return LnurlErrorResponse(reason=str(exc)).dict()
|
||||
|
||||
if shop.method:
|
||||
success_action = item.success_action(shop, payment_hash, request)
|
||||
assert success_action
|
||||
resp = LnurlPayActionResponse(
|
||||
pr=LightningInvoice(payment_request),
|
||||
successAction=success_action,
|
||||
routes=[],
|
||||
)
|
||||
|
||||
return resp.dict()
|
@ -1,39 +0,0 @@
|
||||
async def m001_initial(db):
|
||||
"""
|
||||
Initial offlineshop tables.
|
||||
"""
|
||||
await db.execute(
|
||||
f"""
|
||||
CREATE TABLE offlineshop.shops (
|
||||
id {db.serial_primary_key},
|
||||
wallet TEXT NOT NULL,
|
||||
method TEXT NOT NULL,
|
||||
wordlist TEXT
|
||||
);
|
||||
"""
|
||||
)
|
||||
|
||||
await db.execute(
|
||||
f"""
|
||||
CREATE TABLE offlineshop.items (
|
||||
shop INTEGER NOT NULL REFERENCES {db.references_schema}shops (id),
|
||||
id {db.serial_primary_key},
|
||||
name TEXT NOT NULL,
|
||||
description TEXT NOT NULL,
|
||||
image TEXT, -- image/png;base64,...
|
||||
enabled BOOLEAN NOT NULL DEFAULT true,
|
||||
price {db.big_int} NOT NULL,
|
||||
unit TEXT NOT NULL DEFAULT 'sat'
|
||||
);
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
async def m002_fiat_base_multiplier(db):
|
||||
"""
|
||||
Store the multiplier for fiat prices. We store the price in cents and
|
||||
remember to multiply by 100 when we use it to convert to Dollars.
|
||||
"""
|
||||
await db.execute(
|
||||
"ALTER TABLE offlineshop.items ADD COLUMN fiat_base_multiplier INTEGER DEFAULT 1;"
|
||||
)
|
@ -1,138 +0,0 @@
|
||||
import base64
|
||||
import hashlib
|
||||
import json
|
||||
from collections import OrderedDict
|
||||
from sqlite3 import Row
|
||||
from typing import Dict, List, Optional
|
||||
|
||||
from lnurl import encode as lnurl_encode
|
||||
from lnurl.models import ClearnetUrl, Max144Str, UrlAction
|
||||
from lnurl.types import LnurlPayMetadata
|
||||
from pydantic import BaseModel
|
||||
from starlette.requests import Request
|
||||
|
||||
from .helpers import totp
|
||||
|
||||
shop_counters: Dict = {}
|
||||
|
||||
|
||||
class ShopCounter:
|
||||
wordlist: List[str]
|
||||
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 _ in range(to_remove):
|
||||
self.fulfilled_payments.popitem(False)
|
||||
|
||||
return word
|
||||
|
||||
|
||||
class Shop(BaseModel):
|
||||
id: int
|
||||
wallet: str
|
||||
method: str
|
||||
wordlist: str
|
||||
|
||||
@classmethod
|
||||
def from_row(cls, row: Row):
|
||||
return cls(**dict(row))
|
||||
|
||||
@property
|
||||
def otp_key(self) -> str:
|
||||
return base64.b32encode(
|
||||
hashlib.sha256(
|
||||
("otpkey" + str(self.id) + self.wallet).encode("ascii")
|
||||
).digest()
|
||||
).decode("ascii")
|
||||
|
||||
def get_code(self, payment_hash: str) -> str:
|
||||
if self.method == "wordlist":
|
||||
sc = ShopCounter.invoke(self)
|
||||
return sc.get_word(payment_hash)
|
||||
elif self.method == "totp":
|
||||
return totp(self.otp_key)
|
||||
return ""
|
||||
|
||||
|
||||
class Item(BaseModel):
|
||||
shop: int
|
||||
id: int
|
||||
name: str
|
||||
description: str
|
||||
image: Optional[str]
|
||||
enabled: bool
|
||||
price: float
|
||||
unit: str
|
||||
fiat_base_multiplier: int
|
||||
|
||||
@classmethod
|
||||
def from_row(cls, row: Row) -> "Item":
|
||||
data = dict(row)
|
||||
if data["unit"] != "sat" and data["fiat_base_multiplier"]:
|
||||
data["price"] /= data["fiat_base_multiplier"]
|
||||
return cls(**data)
|
||||
|
||||
def lnurl(self, req: Request) -> str:
|
||||
return lnurl_encode(req.url_for("offlineshop.lnurl_response", item_id=self.id))
|
||||
|
||||
def values(self, req: Request):
|
||||
values = self.dict()
|
||||
values["lnurl"] = lnurl_encode(
|
||||
req.url_for("offlineshop.lnurl_response", item_id=self.id)
|
||||
)
|
||||
return values
|
||||
|
||||
async def lnurlpay_metadata(self) -> LnurlPayMetadata:
|
||||
metadata = [["text/plain", self.description]]
|
||||
|
||||
if self.image:
|
||||
metadata.append(self.image.split(":")[1].split(","))
|
||||
|
||||
return LnurlPayMetadata(json.dumps(metadata))
|
||||
|
||||
def success_action(
|
||||
self, shop: Shop, payment_hash: str, req: Request
|
||||
) -> Optional[UrlAction]:
|
||||
if not shop.wordlist:
|
||||
return None
|
||||
|
||||
return UrlAction(
|
||||
url=ClearnetUrl(
|
||||
req.url_for("offlineshop.confirmation_code", p=payment_hash),
|
||||
scheme="https",
|
||||
),
|
||||
description=Max144Str(
|
||||
"Open to get the confirmation code for your purchase."
|
||||
),
|
||||
)
|
Binary file not shown.
Before Width: | Height: | Size: 13 KiB |
@ -1,230 +0,0 @@
|
||||
/* globals Quasar, Vue, _, VueQrcode, windowMixin, LNbits, LOCALE */
|
||||
|
||||
Vue.component(VueQrcode.name, VueQrcode)
|
||||
|
||||
const pica = window.pica()
|
||||
|
||||
function imgSizeFit(img, maxWidth = 1024, maxHeight = 768) {
|
||||
let ratio = Math.min(
|
||||
1,
|
||||
maxWidth / img.naturalWidth,
|
||||
maxHeight / img.naturalHeight
|
||||
)
|
||||
return {width: img.naturalWidth * ratio, height: img.naturalHeight * ratio}
|
||||
}
|
||||
|
||||
const defaultItemData = {
|
||||
unit: 'sat'
|
||||
}
|
||||
|
||||
new Vue({
|
||||
el: '#vue',
|
||||
mixins: [windowMixin],
|
||||
data() {
|
||||
return {
|
||||
selectedWallet: null,
|
||||
confirmationMethod: 'wordlist',
|
||||
wordlistTainted: false,
|
||||
offlineshop: {
|
||||
method: null,
|
||||
wordlist: [],
|
||||
items: []
|
||||
},
|
||||
itemDialog: {
|
||||
show: false,
|
||||
urlImg: true,
|
||||
data: {...defaultItemData},
|
||||
units: ['sat']
|
||||
}
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
printItems() {
|
||||
return this.offlineshop.items.filter(({enabled}) => enabled)
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
openNewDialog() {
|
||||
this.itemDialog.show = true
|
||||
this.itemDialog.data = {...defaultItemData}
|
||||
},
|
||||
openUpdateDialog(itemId) {
|
||||
this.itemDialog.show = true
|
||||
let item = this.offlineshop.items.find(item => item.id === itemId)
|
||||
if (item.image.startsWith('data:')) {
|
||||
this.itemDialog.urlImg = false
|
||||
}
|
||||
this.itemDialog.data = item
|
||||
},
|
||||
imageAdded(file) {
|
||||
let blobURL = URL.createObjectURL(file)
|
||||
let image = new Image()
|
||||
image.src = blobURL
|
||||
image.onload = async () => {
|
||||
let fit = imgSizeFit(image, 100, 100)
|
||||
let canvas = document.createElement('canvas')
|
||||
canvas.setAttribute('width', fit.width)
|
||||
canvas.setAttribute('height', fit.height)
|
||||
output = await pica.resize(image, canvas)
|
||||
this.itemDialog.data.image = output.toDataURL('image/jpeg', 0.4)
|
||||
this.itemDialog = {...this.itemDialog}
|
||||
}
|
||||
},
|
||||
imageCleared() {
|
||||
this.itemDialog.data.image = null
|
||||
this.itemDialog = {...this.itemDialog}
|
||||
},
|
||||
disabledAddItemButton() {
|
||||
return (
|
||||
!this.itemDialog.data.name ||
|
||||
this.itemDialog.data.name.length === 0 ||
|
||||
!this.itemDialog.data.price ||
|
||||
!this.itemDialog.data.description ||
|
||||
!this.itemDialog.data.unit ||
|
||||
this.itemDialog.data.unit.length === 0
|
||||
)
|
||||
},
|
||||
changedWallet(wallet) {
|
||||
this.selectedWallet = wallet
|
||||
this.loadShop()
|
||||
},
|
||||
loadShop() {
|
||||
LNbits.api
|
||||
.request(
|
||||
'GET',
|
||||
'/offlineshop/api/v1/offlineshop',
|
||||
this.selectedWallet.inkey
|
||||
)
|
||||
.then(response => {
|
||||
this.offlineshop = response.data
|
||||
this.confirmationMethod = response.data.method
|
||||
this.wordlistTainted = false
|
||||
})
|
||||
.catch(err => {
|
||||
LNbits.utils.notifyApiError(err)
|
||||
})
|
||||
},
|
||||
async setMethod() {
|
||||
try {
|
||||
await LNbits.api.request(
|
||||
'PUT',
|
||||
'/offlineshop/api/v1/offlineshop/method',
|
||||
this.selectedWallet.inkey,
|
||||
{method: this.confirmationMethod, wordlist: this.offlineshop.wordlist}
|
||||
)
|
||||
} catch (err) {
|
||||
LNbits.utils.notifyApiError(err)
|
||||
return
|
||||
}
|
||||
|
||||
this.$q.notify({
|
||||
message:
|
||||
`Method set to ${this.confirmationMethod}.` +
|
||||
(this.confirmationMethod === 'wordlist' ? ' Counter reset.' : ''),
|
||||
timeout: 700
|
||||
})
|
||||
this.loadShop()
|
||||
},
|
||||
async sendItem() {
|
||||
let {id, name, image, description, price, unit} = this.itemDialog.data
|
||||
const data = {
|
||||
name,
|
||||
description,
|
||||
image,
|
||||
price,
|
||||
unit,
|
||||
fiat_base_multiplier: unit == 'sat' ? 1 : 100
|
||||
}
|
||||
|
||||
try {
|
||||
if (id) {
|
||||
await LNbits.api.request(
|
||||
'PUT',
|
||||
'/offlineshop/api/v1/offlineshop/items/' + id,
|
||||
this.selectedWallet.inkey,
|
||||
data
|
||||
)
|
||||
} else {
|
||||
await LNbits.api.request(
|
||||
'POST',
|
||||
'/offlineshop/api/v1/offlineshop/items',
|
||||
this.selectedWallet.inkey,
|
||||
data
|
||||
)
|
||||
this.$q.notify({
|
||||
message: `Item '${this.itemDialog.data.name}' added.`,
|
||||
timeout: 700
|
||||
})
|
||||
}
|
||||
} catch (err) {
|
||||
LNbits.utils.notifyApiError(err)
|
||||
return
|
||||
}
|
||||
|
||||
this.loadShop()
|
||||
this.itemDialog.show = false
|
||||
this.itemDialog.urlImg = true
|
||||
this.itemDialog.data = {...defaultItemData}
|
||||
},
|
||||
toggleItem(itemId) {
|
||||
let item = this.offlineshop.items.find(item => item.id === itemId)
|
||||
item.enabled = !item.enabled
|
||||
|
||||
LNbits.api
|
||||
.request(
|
||||
'PUT',
|
||||
'/offlineshop/api/v1/offlineshop/items/' + itemId,
|
||||
this.selectedWallet.inkey,
|
||||
item
|
||||
)
|
||||
.then(response => {
|
||||
this.$q.notify({
|
||||
message: `Item ${item.enabled ? 'enabled' : 'disabled'}.`,
|
||||
timeout: 700
|
||||
})
|
||||
this.offlineshop.items = this.offlineshop.items
|
||||
})
|
||||
.catch(err => {
|
||||
LNbits.utils.notifyApiError(err)
|
||||
})
|
||||
},
|
||||
deleteItem(itemId) {
|
||||
LNbits.utils
|
||||
.confirmDialog('Are you sure you want to delete this item?')
|
||||
.onOk(() => {
|
||||
LNbits.api
|
||||
.request(
|
||||
'DELETE',
|
||||
'/offlineshop/api/v1/offlineshop/items/' + itemId,
|
||||
this.selectedWallet.inkey
|
||||
)
|
||||
.then(response => {
|
||||
this.$q.notify({
|
||||
message: `Item deleted.`,
|
||||
timeout: 700
|
||||
})
|
||||
this.offlineshop.items.splice(
|
||||
this.offlineshop.items.findIndex(item => item.id === itemId),
|
||||
1
|
||||
)
|
||||
})
|
||||
.catch(err => {
|
||||
LNbits.utils.notifyApiError(err)
|
||||
})
|
||||
})
|
||||
}
|
||||
},
|
||||
created() {
|
||||
this.selectedWallet = this.g.user.wallets[0]
|
||||
this.loadShop()
|
||||
|
||||
LNbits.api
|
||||
.request('GET', '/offlineshop/api/v1/currencies')
|
||||
.then(response => {
|
||||
this.itemDialog = {...this.itemDialog, units: ['sat', ...response.data]}
|
||||
})
|
||||
.catch(err => {
|
||||
LNbits.utils.notifyApiError(err)
|
||||
})
|
||||
}
|
||||
})
|
@ -1,154 +0,0 @@
|
||||
<q-expansion-item
|
||||
group="extras"
|
||||
icon="swap_vertical_circle"
|
||||
label="How to use"
|
||||
:content-inset-level="0.5"
|
||||
>
|
||||
<q-card>
|
||||
<q-card-section>
|
||||
<ol>
|
||||
<li>Register items.</li>
|
||||
<li>
|
||||
Print QR codes and paste them on your store, your menu, somewhere,
|
||||
somehow.
|
||||
</li>
|
||||
<li>
|
||||
Clients scan the QR codes and get information about the items plus the
|
||||
price on their phones directly (they must have internet)
|
||||
</li>
|
||||
<li>
|
||||
Once they decide to pay, they'll get an invoice on their phones
|
||||
automatically
|
||||
</li>
|
||||
<li>
|
||||
When the payment is confirmed, a confirmation code will be issued for
|
||||
them.
|
||||
</li>
|
||||
</ol>
|
||||
<p>
|
||||
The confirmation codes are words from a predefined sequential word list.
|
||||
Each new payment bumps the words sequence by 1. So you can check the
|
||||
confirmation codes manually by just looking at them.
|
||||
</p>
|
||||
<p>
|
||||
For example, if your wordlist is
|
||||
<code>[apple, banana, coconut]</code> the first purchase will be
|
||||
<code>apple</code>, the second <code>banana</code> and so on. When it
|
||||
gets to the end it starts from the beginning again.
|
||||
</p>
|
||||
<p>Powered by LNURL-pay.</p>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</q-expansion-item>
|
||||
|
||||
<q-expansion-item
|
||||
group="extras"
|
||||
icon="swap_vertical_circle"
|
||||
label="API info"
|
||||
:content-inset-level="0.5"
|
||||
>
|
||||
<q-btn flat label="Swagger API" type="a" href="../docs#/offlineshop"></q-btn>
|
||||
<q-expansion-item
|
||||
group="api"
|
||||
dense
|
||||
expand-separator
|
||||
label="Create item (a shop will be created automatically based on the wallet you use)"
|
||||
>
|
||||
<q-card>
|
||||
<q-card-section>
|
||||
<code><span class="text-blue">POST</span></code>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">Headers</h5>
|
||||
<code>{"X-Api-Key": <invoice_key>}</code><br />
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">Body (application/json)</h5>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">Returns 201 OK</h5>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
|
||||
<code
|
||||
>curl -X GET {{ request.base_url
|
||||
}}offlineshop/api/v1/offlineshop/items -H "Content-Type:
|
||||
application/json" -H "X-Api-Key: {{ user.wallets[0].inkey }}" -d
|
||||
'{"name": <string>, "description": <string>, "image":
|
||||
<data-uri string>, "price": <integer>, "unit": <"sat"
|
||||
or "USD">}'
|
||||
</code>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</q-expansion-item>
|
||||
<q-expansion-item
|
||||
group="api"
|
||||
dense
|
||||
expand-separator
|
||||
label="Get the shop data along with items"
|
||||
>
|
||||
<q-card>
|
||||
<q-card-section>
|
||||
<code><span class="text-blue">GET</span></code>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">Headers</h5>
|
||||
<code>{"X-Api-Key": <invoice_key>}</code><br />
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">Body (application/json)</h5>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">
|
||||
Returns 200 OK (application/json)
|
||||
</h5>
|
||||
<code
|
||||
>{"id": <integer>, "wallet": <string>, "wordlist":
|
||||
<string>, "items": [{"id": <integer>, "name":
|
||||
<string>, "description": <string>, "image":
|
||||
<string>, "enabled": <boolean>, "price": <integer>,
|
||||
"unit": <string>, "lnurl": <string>}, ...]}<</code
|
||||
>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
|
||||
<code
|
||||
>curl -X GET {{ request.base_url }}offlineshop/api/v1/offlineshop -H
|
||||
"X-Api-Key: {{ user.wallets[0].inkey }}"
|
||||
</code>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</q-expansion-item>
|
||||
<q-expansion-item
|
||||
group="api"
|
||||
dense
|
||||
expand-separator
|
||||
label="Update item (all fields must be sent again)"
|
||||
>
|
||||
<q-card>
|
||||
<q-card-section>
|
||||
<code><span class="text-blue">PUT</span></code>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">Headers</h5>
|
||||
<code>{"X-Api-Key": <invoice_key>}</code><br />
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">Body (application/json)</h5>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">Returns 200 OK</h5>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
|
||||
<code
|
||||
>curl -X GET {{ request.base_url
|
||||
}}offlineshop/api/v1/offlineshop/items/<item_id> -H
|
||||
"Content-Type: application/json" -H "X-Api-Key: {{
|
||||
user.wallets[0].inkey }}" -d '{"name": <string>, "description":
|
||||
<string>, "image": <data-uri string>, "price":
|
||||
<integer>, "unit": <"sat" or "USD">}'
|
||||
</code>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</q-expansion-item>
|
||||
<q-expansion-item
|
||||
group="api"
|
||||
dense
|
||||
expand-separator
|
||||
label="Delete item"
|
||||
class="q-pb-md"
|
||||
>
|
||||
<q-card>
|
||||
<q-card-section>
|
||||
<code><span class="text-blue">DELETE</span></code>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">Headers</h5>
|
||||
<code>{"X-Api-Key": <invoice_key>}</code><br />
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">Body (application/json)</h5>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">Returns 200 OK</h5>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
|
||||
<code
|
||||
>curl -X GET {{ request.base_url
|
||||
}}offlineshop/api/v1/offlineshop/items/<item_id> -H "X-Api-Key:
|
||||
{{ user.wallets[0].inkey }}"
|
||||
</code>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</q-expansion-item>
|
||||
</q-expansion-item>
|
@ -1,348 +0,0 @@
|
||||
{% extends "base.html" %} {% from "macros.jinja" import window_vars with context
|
||||
%} {% block page %}
|
||||
<div class="row q-col-gutter-md">
|
||||
<div class="col-12 col-md-7 q-gutter-y-md">
|
||||
<q-card>
|
||||
<q-card-section>
|
||||
<div class="row items-center no-wrap q-mb-md">
|
||||
<div class="col">
|
||||
<h5 class="text-subtitle1 q-my-none">Items</h5>
|
||||
</div>
|
||||
<div class="col q-ml-lg">
|
||||
<q-btn unelevated color="primary" @click="openNewDialog()"
|
||||
>Add new item</q-btn
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
{% raw %}
|
||||
<q-table
|
||||
dense
|
||||
flat
|
||||
selection="multiple"
|
||||
:data="offlineshop.items"
|
||||
row-key="id"
|
||||
no-data-label="No items for sale yet"
|
||||
:pagination="{rowsPerPage: 0}"
|
||||
:binary-state-sort="true"
|
||||
>
|
||||
<template v-slot:header="props">
|
||||
<q-tr :props="props">
|
||||
<q-th auto-width></q-th>
|
||||
<q-th auto-width>Name</q-th>
|
||||
<q-th auto-width>Description</q-th>
|
||||
<q-th auto-width>Image</q-th>
|
||||
<q-th auto-width>Price</q-th>
|
||||
<q-th auto-width></q-th>
|
||||
</q-tr>
|
||||
</template>
|
||||
<template v-slot:body="props">
|
||||
<q-tr :props="props">
|
||||
<q-td auto-width>
|
||||
<q-btn
|
||||
unelevated
|
||||
dense
|
||||
size="xs"
|
||||
:icon="props.row.enabled ? 'done' : 'block'"
|
||||
:color="props.row.enabled ? 'green' : ($q.dark.isActive ? 'grey-7' : 'grey-5')"
|
||||
type="a"
|
||||
@click="toggleItem(props.row.id)"
|
||||
target="_blank"
|
||||
></q-btn>
|
||||
</q-td>
|
||||
<q-td auto-width class="text-center">{{ props.row.name }}</q-td>
|
||||
<q-td auto-width> {{ props.row.description }} </q-td>
|
||||
<q-td class="text-center" auto-width>
|
||||
<img
|
||||
v-if="props.row.image"
|
||||
:src="props.row.image"
|
||||
style="height: 1.5em"
|
||||
/>
|
||||
</q-td>
|
||||
<q-td class="text-center" auto-width>
|
||||
{{ props.row.price }} {{ props.row.unit }}
|
||||
</q-td>
|
||||
<q-td auto-width>
|
||||
<q-btn
|
||||
flat
|
||||
dense
|
||||
size="xs"
|
||||
@click="openUpdateDialog(props.row.id)"
|
||||
icon="edit"
|
||||
color="light-blue"
|
||||
></q-btn>
|
||||
<q-btn
|
||||
unelevated
|
||||
dense
|
||||
size="xs"
|
||||
icon="delete"
|
||||
color="negative"
|
||||
type="a"
|
||||
@click="deleteItem(props.row.id)"
|
||||
target="_blank"
|
||||
></q-btn>
|
||||
</q-td>
|
||||
</q-tr>
|
||||
</template>
|
||||
</q-table>
|
||||
{% endraw %}
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
|
||||
<q-card class="q-pa-sm col-5">
|
||||
<q-card-section class="q-pa-none text-center">
|
||||
<div class="row">
|
||||
<h5 class="text-subtitle1 q-my-none">Wallet Shop</h5>
|
||||
</div>
|
||||
|
||||
<q-form class="q-gutter-md">
|
||||
<q-select
|
||||
filled
|
||||
dense
|
||||
:options="g.user.wallets"
|
||||
:value="selectedWallet"
|
||||
label="Using wallet:"
|
||||
option-label="name"
|
||||
@input="changedWallet"
|
||||
>
|
||||
</q-select>
|
||||
</q-form>
|
||||
|
||||
<div v-if="printItems.length > 0" class="row q-gutter-sm q-my-md">
|
||||
<q-btn
|
||||
type="a"
|
||||
outline
|
||||
color="secondary"
|
||||
:href="'print?items=' + printItems.map(({id}) => id).join(',')"
|
||||
>Print QR Codes</q-btn
|
||||
>
|
||||
</div>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
|
||||
<q-card class="q-pa-sm col-5">
|
||||
<q-tabs
|
||||
v-model="confirmationMethod"
|
||||
no-caps
|
||||
class="bg-dark text-white shadow-2"
|
||||
>
|
||||
<q-tab name="wordlist" label="Wordlist"></q-tab>
|
||||
<q-tab name="totp" label="TOTP (Google Authenticator)"></q-tab>
|
||||
<q-tab name="none" label="Nothing"></q-tab>
|
||||
</q-tabs>
|
||||
|
||||
<q-card-section class="q-py-sm text-center">
|
||||
<q-form
|
||||
v-if="confirmationMethod === 'wordlist'"
|
||||
class="q-gutter-md q-y-md"
|
||||
@submit="setMethod"
|
||||
>
|
||||
<div class="row">
|
||||
<div class="col q-mx-lg">
|
||||
<q-input
|
||||
v-model="offlineshop.wordlist"
|
||||
@input="wordlistTainted = true"
|
||||
dense
|
||||
filled
|
||||
autogrow
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
class="col q-mx-lg items-align flex items-center justify-center"
|
||||
>
|
||||
<q-btn
|
||||
unelevated
|
||||
color="primary"
|
||||
type="submit"
|
||||
:disabled="!wordlistTainted"
|
||||
>
|
||||
Update Wordlist
|
||||
</q-btn>
|
||||
<q-btn @click="loadShop" flat color="grey" class="q-ml-auto"
|
||||
>Reset</q-btn
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</q-form>
|
||||
|
||||
<div v-else-if="confirmationMethod === 'totp'">
|
||||
<div class="row">
|
||||
<div class="col q-mx-lg">
|
||||
<q-responsive :ratio="1">
|
||||
<qrcode
|
||||
:value="`otpauth://totp/offlineshop:${selectedWallet.name}?secret=${offlineshop.otp_key}`"
|
||||
:options="{width: 800}"
|
||||
class="rounded-borders"
|
||||
></qrcode>
|
||||
</q-responsive>
|
||||
</div>
|
||||
<div
|
||||
class="col q-mx-lg items-align flex items-center justify-center"
|
||||
>
|
||||
<q-btn
|
||||
unelevated
|
||||
color="primary"
|
||||
:disabled="offlineshop.method === 'totp'"
|
||||
@click="setMethod"
|
||||
>
|
||||
Set TOTP
|
||||
</q-btn>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else-if="confirmationMethod === 'none'">
|
||||
<p>
|
||||
Setting this option disables the confirmation code message that
|
||||
appears in the consumer wallet after a purchase is paid for. It's ok
|
||||
if the consumer is to be trusted when they claim to have paid.
|
||||
</p>
|
||||
|
||||
<q-btn
|
||||
unelevated
|
||||
color="primary"
|
||||
:disabled="offlineshop.method === 'none'"
|
||||
@click="setMethod"
|
||||
>
|
||||
Disable Confirmation Codes
|
||||
</q-btn>
|
||||
</div>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</div>
|
||||
|
||||
<div class="col-12 col-md-5 q-gutter-y-md">
|
||||
<q-card>
|
||||
<q-card-section>
|
||||
<h6 class="text-subtitle1 q-my-none">
|
||||
{{SITE_TITLE}} OfflineShop extension
|
||||
</h6>
|
||||
</q-card-section>
|
||||
<q-card-section class="q-pa-none">
|
||||
<q-separator></q-separator>
|
||||
<q-list> {% include "offlineshop/_api_docs.html" %} </q-list>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</div>
|
||||
|
||||
<q-dialog v-model="itemDialog.show">
|
||||
<q-card class="q-pa-lg q-pt-xl lnbits__dialog-card">
|
||||
<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="'lightning:' + itemDialog.data.lnurl"
|
||||
:options="{width: 300}"
|
||||
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-input
|
||||
filled
|
||||
dense
|
||||
v-model.trim="itemDialog.data.name"
|
||||
type="text"
|
||||
label="Item name"
|
||||
></q-input>
|
||||
<q-input
|
||||
filled
|
||||
dense
|
||||
v-model.trim="itemDialog.data.description"
|
||||
type="text"
|
||||
label="Brief description"
|
||||
></q-input>
|
||||
<q-input
|
||||
v-if="itemDialog.urlImg"
|
||||
filled
|
||||
dense
|
||||
v-model.trim="itemDialog.data.image"
|
||||
type="url"
|
||||
label="Image URL"
|
||||
></q-input>
|
||||
<q-file
|
||||
v-else
|
||||
filled
|
||||
dense
|
||||
capture="environment"
|
||||
accept="image/jpeg, image/png"
|
||||
:max-file-size="3*1024**2"
|
||||
label="Small image (optional)"
|
||||
clearable
|
||||
@input="imageAdded"
|
||||
@clear="imageCleared"
|
||||
>
|
||||
<template v-if="itemDialog.data.image" v-slot:before>
|
||||
<img style="height: 1em" :src="itemDialog.data.image" />
|
||||
</template>
|
||||
<template v-if="itemDialog.data.image" v-slot:append>
|
||||
<q-icon
|
||||
name="cancel"
|
||||
@click.stop.prevent="imageCleared"
|
||||
class="cursor-pointer"
|
||||
/>
|
||||
</template>
|
||||
</q-file>
|
||||
<q-toggle
|
||||
:label="`${itemDialog.urlImg ? 'Insert image URL' : 'Upload image file'}`"
|
||||
v-model="itemDialog.urlImg"
|
||||
></q-toggle>
|
||||
<q-input
|
||||
filled
|
||||
dense
|
||||
v-model.number="itemDialog.data.price"
|
||||
type="number"
|
||||
step="0.001"
|
||||
min="0.001"
|
||||
:label="`Item price (${itemDialog.data.unit})`"
|
||||
></q-input>
|
||||
<q-select
|
||||
filled
|
||||
dense
|
||||
v-model="itemDialog.data.unit"
|
||||
type="text"
|
||||
label="Unit"
|
||||
:options="itemDialog.units"
|
||||
></q-select>
|
||||
|
||||
<div class="row q-mt-lg">
|
||||
<div class="col q-ml-lg">
|
||||
<q-btn
|
||||
unelevated
|
||||
color="primary"
|
||||
:disable="disabledAddItemButton()"
|
||||
type="submit"
|
||||
>
|
||||
{% raw %}{{ itemDialog.data.id ? 'Update' : 'Add' }}{% endraw %}
|
||||
Item
|
||||
</q-btn>
|
||||
</div>
|
||||
<div class="col q-ml-lg">
|
||||
<q-btn v-close-popup flat color="grey" class="q-ml-auto"
|
||||
>Cancel</q-btn
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</q-form>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</q-dialog>
|
||||
</div>
|
||||
{% endblock %} {% block scripts %} {{ window_vars(user) }}
|
||||
<script src="https://cdn.jsdelivr.net/npm/pica@6.1.1/dist/pica.min.js"></script>
|
||||
<script src="{{ url_for('offlineshop_static', path='js/index.js') }}"></script>
|
||||
{% endblock %}
|
@ -1,28 +0,0 @@
|
||||
{% 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="'lightning:' + 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 %}
|
@ -1,89 +0,0 @@
|
||||
import time
|
||||
from datetime import datetime
|
||||
from http import HTTPStatus
|
||||
|
||||
from fastapi import Depends, HTTPException, Query, Request
|
||||
from starlette.responses import HTMLResponse
|
||||
|
||||
from lnbits.core.crud import get_standalone_payment
|
||||
from lnbits.core.models import User
|
||||
from lnbits.core.views.api import api_payment
|
||||
from lnbits.decorators import check_user_exists
|
||||
|
||||
from . import offlineshop_ext, offlineshop_renderer
|
||||
from .crud import get_item, get_shop
|
||||
|
||||
|
||||
@offlineshop_ext.get("/", response_class=HTMLResponse)
|
||||
async def index(request: Request, user: User = Depends(check_user_exists)):
|
||||
return offlineshop_renderer().TemplateResponse(
|
||||
"offlineshop/index.html", {"request": request, "user": user.dict()}
|
||||
)
|
||||
|
||||
|
||||
@offlineshop_ext.get("/print", response_class=HTMLResponse)
|
||||
async def print_qr_codes(request: Request):
|
||||
items = []
|
||||
for item_id in request.query_params.get("items").split(","):
|
||||
item = await get_item(item_id)
|
||||
if item:
|
||||
items.append(
|
||||
{
|
||||
"lnurl": item.lnurl(request),
|
||||
"name": item.name,
|
||||
"price": f"{item.price} {item.unit}",
|
||||
}
|
||||
)
|
||||
|
||||
return offlineshop_renderer().TemplateResponse(
|
||||
"offlineshop/print.html", {"request": request, "items": items}
|
||||
)
|
||||
|
||||
|
||||
@offlineshop_ext.get(
|
||||
"/confirmation/{p}",
|
||||
name="offlineshop.confirmation_code",
|
||||
response_class=HTMLResponse,
|
||||
)
|
||||
async def confirmation_code(p: str = Query(...)):
|
||||
style = "<style>* { font-size: 100px}</style>"
|
||||
|
||||
payment_hash = p
|
||||
await api_payment(payment_hash)
|
||||
|
||||
payment = await get_standalone_payment(payment_hash)
|
||||
if not payment:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.NOT_FOUND,
|
||||
detail=f"Couldn't find the payment {payment_hash}." + style,
|
||||
)
|
||||
if payment.pending:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.PAYMENT_REQUIRED,
|
||||
detail=f"Payment {payment_hash} wasn't received yet. Please try again in a minute."
|
||||
+ style,
|
||||
)
|
||||
|
||||
if payment.time + 60 * 15 < time.time():
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.REQUEST_TIMEOUT,
|
||||
detail="Too much time has passed." + style,
|
||||
)
|
||||
|
||||
assert payment.extra
|
||||
item_id = payment.extra.get("item")
|
||||
assert item_id
|
||||
item = await get_item(item_id)
|
||||
assert item
|
||||
shop = await get_shop(item.shop)
|
||||
assert shop
|
||||
|
||||
return (
|
||||
f"""
|
||||
[{shop.get_code(payment_hash)}]<br>
|
||||
{item.name}<br>
|
||||
{item.price} {item.unit}<br>
|
||||
{datetime.utcfromtimestamp(payment.time).strftime('%Y-%m-%d %H:%M:%S')}
|
||||
"""
|
||||
+ style
|
||||
)
|
@ -1,136 +0,0 @@
|
||||
from http import HTTPStatus
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import Depends, HTTPException, Query, Request, Response
|
||||
from lnurl.exceptions import InvalidUrl as LnurlInvalidUrl
|
||||
from pydantic import BaseModel
|
||||
|
||||
from lnbits.decorators import WalletTypeInfo, get_key_type
|
||||
from lnbits.utils.exchange_rates import currencies
|
||||
|
||||
from . import offlineshop_ext
|
||||
from .crud import (
|
||||
add_item,
|
||||
delete_item_from_shop,
|
||||
get_items,
|
||||
get_or_create_shop_by_wallet,
|
||||
set_method,
|
||||
update_item,
|
||||
)
|
||||
from .models import ShopCounter
|
||||
|
||||
|
||||
@offlineshop_ext.get("/api/v1/currencies")
|
||||
async def api_list_currencies_available():
|
||||
return list(currencies.keys())
|
||||
|
||||
|
||||
@offlineshop_ext.get("/api/v1/offlineshop")
|
||||
async def api_shop_from_wallet(
|
||||
r: Request, wallet: WalletTypeInfo = Depends(get_key_type)
|
||||
):
|
||||
shop = await get_or_create_shop_by_wallet(wallet.wallet.id)
|
||||
assert shop
|
||||
items = await get_items(shop.id)
|
||||
try:
|
||||
return {
|
||||
**shop.dict(),
|
||||
**{"otp_key": shop.otp_key, "items": [item.values(r) for item in items]},
|
||||
}
|
||||
except LnurlInvalidUrl:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.UPGRADE_REQUIRED,
|
||||
detail="LNURLs need to be delivered over a publically accessible `https` domain or Tor.",
|
||||
)
|
||||
|
||||
|
||||
class CreateItemsData(BaseModel):
|
||||
name: str
|
||||
description: str
|
||||
image: Optional[str]
|
||||
price: float
|
||||
unit: str
|
||||
fiat_base_multiplier: int = Query(100, ge=1)
|
||||
|
||||
|
||||
@offlineshop_ext.post("/api/v1/offlineshop/items")
|
||||
@offlineshop_ext.put("/api/v1/offlineshop/items/{item_id}")
|
||||
async def api_add_or_update_item(
|
||||
data: CreateItemsData, item_id=None, wallet: WalletTypeInfo = Depends(get_key_type)
|
||||
):
|
||||
shop = await get_or_create_shop_by_wallet(wallet.wallet.id)
|
||||
assert shop
|
||||
if data.image:
|
||||
image_is_url = data.image.startswith("https://") or data.image.startswith(
|
||||
"http://"
|
||||
)
|
||||
|
||||
if not image_is_url:
|
||||
|
||||
def size(b64string):
|
||||
return int((len(b64string) * 3) / 4 - b64string.count("=", -2))
|
||||
|
||||
image_size = size(data.image) / 1024
|
||||
if image_size > 100:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.BAD_REQUEST,
|
||||
detail=f"Image size is too big, {int(image_size)}Kb. Max: 100kb, Compress the image at https://tinypng.com, or use an URL.",
|
||||
)
|
||||
if data.unit != "sat":
|
||||
data.price = data.price * 100
|
||||
if item_id is None:
|
||||
|
||||
await add_item(
|
||||
shop.id,
|
||||
data.name,
|
||||
data.description,
|
||||
data.image,
|
||||
int(data.price),
|
||||
data.unit,
|
||||
data.fiat_base_multiplier,
|
||||
)
|
||||
return Response(status_code=HTTPStatus.CREATED)
|
||||
else:
|
||||
await update_item(
|
||||
shop.id,
|
||||
item_id,
|
||||
data.name,
|
||||
data.description,
|
||||
data.image,
|
||||
int(data.price),
|
||||
data.unit,
|
||||
data.fiat_base_multiplier,
|
||||
)
|
||||
|
||||
|
||||
@offlineshop_ext.delete("/api/v1/offlineshop/items/{item_id}")
|
||||
async def api_delete_item(item_id, wallet: WalletTypeInfo = Depends(get_key_type)):
|
||||
shop = await get_or_create_shop_by_wallet(wallet.wallet.id)
|
||||
assert shop
|
||||
await delete_item_from_shop(shop.id, item_id)
|
||||
return "", HTTPStatus.NO_CONTENT
|
||||
|
||||
|
||||
class CreateMethodData(BaseModel):
|
||||
method: str
|
||||
wordlist: Optional[str]
|
||||
|
||||
|
||||
@offlineshop_ext.put("/api/v1/offlineshop/method")
|
||||
async def api_set_method(
|
||||
data: CreateMethodData, wallet: WalletTypeInfo = Depends(get_key_type)
|
||||
):
|
||||
method = data.method
|
||||
|
||||
wordlist = data.wordlist.split("\n") if data.wordlist else []
|
||||
wordlist = [word.strip() for word in wordlist if word.strip()]
|
||||
|
||||
shop = await get_or_create_shop_by_wallet(wallet.wallet.id)
|
||||
if not shop:
|
||||
raise HTTPException(status_code=HTTPStatus.NOT_FOUND)
|
||||
|
||||
updated_shop = await set_method(shop.id, method, "\n".join(wordlist))
|
||||
if not updated_shop:
|
||||
raise HTTPException(status_code=HTTPStatus.NOT_FOUND)
|
||||
|
||||
ShopCounter.reset(updated_shop)
|
@ -1,28 +0,0 @@
|
||||
animals = [
|
||||
"albatross",
|
||||
"bison",
|
||||
"chicken",
|
||||
"duck",
|
||||
"eagle",
|
||||
"flamingo",
|
||||
"gorilla",
|
||||
"hamster",
|
||||
"iguana",
|
||||
"jaguar",
|
||||
"koala",
|
||||
"llama",
|
||||
"macaroni penguin",
|
||||
"numbat",
|
||||
"octopus",
|
||||
"platypus",
|
||||
"quetzal",
|
||||
"rabbit",
|
||||
"salmon",
|
||||
"tuna",
|
||||
"unicorn",
|
||||
"vulture",
|
||||
"wolf",
|
||||
"xenops",
|
||||
"yak",
|
||||
"zebra",
|
||||
]
|
Loading…
Reference in New Issue
Block a user