Skip to content

Commit

Permalink
Merge pull request #1 from ocknamo/feature/pos
Browse files Browse the repository at this point in the history
Feature/pos
  • Loading branch information
Lokuyow authored Feb 26, 2024
2 parents eb73f39 + 07c5dd2 commit ac63c0f
Show file tree
Hide file tree
Showing 6 changed files with 395 additions and 7 deletions.
6 changes: 6 additions & 0 deletions images/settings-solid.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
55 changes: 49 additions & 6 deletions index.html
Original file line number Diff line number Diff line change
Expand Up @@ -113,7 +113,7 @@
</svg>
</button>
<input class="currency-input" type="text" id="sats" aria-label="サッツの金額"
oninput="calculateValues('sats')" pattern="[0-9]+([\.,][0-9]+)?" inputmode="decimal">
oninput="window.satsRate.calculateValues('sats')" pattern="[0-9]+([\.,][0-9]+)?" inputmode="decimal">
<button class="currency-units" id="paste-sats" data-currency="sats" aria-label="サッツの数値をペースト">
sats
</button>
Expand All @@ -124,7 +124,7 @@
</svg>
</button>
<input class="currency-input" type="text" id="btc" aria-label="ビットコインの金額"
oninput="calculateValues('btc')" pattern="[0-9]+([\.,][0-9]+)?" inputmode="decimal">
oninput="window.satsRate.calculateValues('btc')" pattern="[0-9]+([\.,][0-9]+)?" inputmode="decimal">
<button class="currency-units" id="paste-btc" data-currency="btc" aria-label="ビットコインの数値をペースト">
BTC
</button>
Expand All @@ -135,7 +135,7 @@
</svg>
</button>
<input class="currency-input" type="text" id="jpy" aria-label="日本円の金額"
oninput="calculateValues('jpy')" pattern="[0-9]+([\.,][0-9]+)?" inputmode="decimal">
oninput="window.satsRate.calculateValues('jpy')" pattern="[0-9]+([\.,][0-9]+)?" inputmode="decimal">
<button class="currency-units" id="paste-jpy" aria-label="円の数値をペースト">
JPY
</button>
Expand All @@ -146,7 +146,7 @@
</svg>
</button>
<input class="currency-input" type="text" id="usd" aria-label="アメリカドルの金額"
oninput="calculateValues('usd')" pattern="[0-9]+([\.,][0-9]+)?" inputmode="decimal">
oninput="window.satsRate.calculateValues('usd')" pattern="[0-9]+([\.,][0-9]+)?" inputmode="decimal">
<button class="currency-units" id="paste-usd" data-currency="usd" aria-label="USドルの数値をペースト">
USD
</button>
Expand All @@ -157,7 +157,7 @@
</svg>
</button>
<input class="currency-input" type="text" id="eur" aria-label="ユーロの金額"
oninput="calculateValues('eur')" pattern="[0-9]+([\.,][0-9]+)?" inputmode="decimal">
oninput="window.satsRate.calculateValues('eur')" pattern="[0-9]+([\.,][0-9]+)?" inputmode="decimal">
<button class="currency-units" id="paste-eur" data-currency="eur" aria-label="ユーロの数値をペースト">
EUR
</button>
Expand All @@ -174,6 +174,48 @@
</div>
</div>

<div class="bgcolor pos-wrapper">
<div class="subject">
POS
</div>
<div class="pos">
<button id="pos-pay-button">⚡請求書を表示</button>
<br />
<dialog id="update-lightning-address-dialog">
<form id="lightning-address-form">
<label class="lightning-address-label" for="lightning-address">受取用ライトニングアドレス
<input
type="text"
name="lightning-address"
id="lightning-address-input"
class="lightning-address-input"
pattern="^[a-z0-9._%+\-]+@[a-z0-9\.\-]+\.[a-z]{2,3}$"
maxlength="200"
minlength="4"
required
title="ライトニングアドレスの形式で入力してください"
/>
<div>
<button id="lightning-address-submit-button" type="submit" formmethod="dialog">設定する</button>
<button id="lightning-address-clear-button" value="clear" formmethod="dialog">削除</button>
<button id="lightning-address-close-button" value="cancel" formmethod="dialog">閉じる</button>
</div>
</label>
</form>
</dialog>
<p class="lightning-address">
<output id="lightning-address-output"></output>
<button class="setting-btn" id="show-lightning-address-dialog" aria-label="受取ライトニングアドレスの設定">
<svg width="24px" height="24px">
<use xlink:href="./images/settings-solid.svg#a"></use>
</svg>
</button>
</p>
<div id="lightning-pos-qr-box"></div>
<div id="pos-message"></div>
</div>
</div>

<div class="bgcolor">
<div class="subject">
SNSに計算結果を共有
Expand Down Expand Up @@ -337,7 +379,8 @@
</div>

<script src="https://cdn.jsdelivr.net/npm/[email protected]"></script>
<script src="./main.js"></script>
<script src="https://unpkg.com/[email protected]/lib/qr-code-styling.js"></script>
<script type="module" src="./main.js"></script>
</body>

</html>
81 changes: 81 additions & 0 deletions lib/lightning-address.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
// ライトニングアドレスの正規表現(メールの正規表現を流用)
const addressRegExp = /^[A-Za-z0-9]{1}[A-Za-z0-9_.-]*@{1}[A-Za-z0-9_.-]{1,}\.[A-Za-z0-9]{1,}$/

export class LightningAddress {
addressView = window.document.getElementById('lightning-address-output');
addressInput = window.document.getElementById('lightning-address-input');

#domain = '';
#userName = '';
#LightningAddressStr = '';

data = {};

constructor(lightningAddressString) {
this.#LightningAddressStr = addressRegExp.test(lightningAddressString) ? lightningAddressString : '';
const [userName, domain] = this.#LightningAddressStr.split('@');
this.#domain = domain;
this.#userName = userName;

this.#updateView()
}

async fetchAddressData() {
if(!this.#domain || !this.#userName) {
return;
}

// Document: https://github.com/lnurl/luds/blob/luds/16.md
this.data = await fetch(`https://${this.#domain}/.well-known/lnurlp/${this.#userName}`).then(async (res) => {
return res.json();
})

return this.data;
}

/**
* 入力値の数量でインボイスを生成する
* @param {number} amount ミリサトシ
* @returns void
*/
async getInvoice(amount) {
if(!this.data || this.data.tag !== 'payRequest' || !this.data.callback) {
throw new Error('支払い可能なライトニングアドレスではないようです')
}

if(this.data.status === 'ERROR') {
// 仕様上this.data.reasonが存在するはずなのでエラー理由をスローする
// https://github.com/lnurl/luds/blob/luds/06.md
if(this.data.reason) {
throw new Error(this.data.reason)
} else {
throw new Error(`Invalid lnurlp response: ${JSON.stringify(this.data)}`)
}
}

// 数量のバリデーション
if( amount > this.data.maxSendable && amount < (this.data.minSendable ?? 0)) {
return;
}

const callbackUrl = new URL(this.data.callback)
callbackUrl.searchParams.append('amount', amount);

return fetch(callbackUrl).then(async (res) => {
return res.json();
});
}

toString() {
return this.#LightningAddressStr;
}

hasValidAddress() {
return !!this.#LightningAddressStr;
}

#updateView() {
this.addressView.value = this.#LightningAddressStr || 'ライトニングアドレス未設定'
this.addressInput.value = this.#LightningAddressStr || '';
}
}
137 changes: 137 additions & 0 deletions lib/pos.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
import { LightningAddress } from './lightning-address.js';

/**
* POS機能
* ライトニングアドレスを保管して入力された金額のインボイスのQRコードの表示を行う。
* またインプットの値とインボイスの値が乖離することを避けるため入力値を監視し変更された場合は直ちに破棄する。
*/
export class Pos {
localStorageKey = 'POS:LnAddress';
#getQrCodeConfig(data) {
return {
width: 340,
height: 340,
type: 'svg',
data,
image: './images/icon_x192.png',
margin: 2,
dotsOptions: {
color: '#4D4D4D',
type: 'rounded'
},
backgroundOptions: {
color: '#e9ebee',
},
imageOptions: {
crossOrigin: 'anonymous',
margin: 2
}
}
};

// LightningAddressクラスのインスタンス
#lnAddress;

#qrWrapper = window.document.getElementById('lightning-pos-qr-box');
#satsInput = window.document.getElementById('sats');
#messageArea = window.document.getElementById('pos-message');
#otherUnits = ['btc', 'jpy', 'usd', 'eur']

// 連打による連続的なリクエストを制限するためのフラグ
#isRequesting = false;

initialize() {
// ローカルストレージからアドレスを取得
this.#lnAddress = new LightningAddress(window.localStorage.getItem(this.localStorageKey) ?? '');

// 値が変更されたら請求書QRコードは削除しなければいけないため入力値を監視する
this.#handleInputChange();
}

setLnAddress(form) {
const formData = new FormData(form);

this.#lnAddress = new LightningAddress(formData.get('lightning-address') ?? '');

if(!this.#lnAddress.hasValidAddress) {
console.warn(`Pos: invalid address: ${formData.get('lightning-address')}`);

return;
}

window.localStorage.setItem(this.localStorageKey, this.#lnAddress.toString());
}

getCurrntSatsAmount() {
const satsAmount = this.#satsInput.value.replaceAll(',', '');

return satsAmount || null;
}

// 支払いボタン押下時
async generateInvoice() {
if(this.#isRequesting) {
return;
}

this.#isRequesting = true;

if(!this.#lnAddress.hasValidAddress()) {
return;
}

const amount = this.getCurrntSatsAmount();
if(!amount) {
return;
}

this.#clearQrCode();

try {
await this.#lnAddress.fetchAddressData();
const invoice = await this.#lnAddress.getInvoice(amount * 1000);
this.#showQrCode(invoice.pr);
} catch (error) {
this.#setMessage(error.message ?? JSON.stringify(error));
} finally {
this.#isRequesting = false;
}
}

clearLnAddress() {
this.#lnAddress = new LightningAddress('');
window.localStorage.clear(this.localStorageKey);
this.#clearQrCode();
}

#clearQrCode() {
this.#qrWrapper.innerHTML = '';
}

#showQrCode(data) {
const qrCode = new QRCodeStyling(this.#getQrCodeConfig(data))
qrCode.append(this.#qrWrapper);
}

// どの通貨が変更された場合もQRコードをクリアする
#handleInputChange() {
let inputs = this.#otherUnits.map(u => window.document.getElementById(u))
inputs = [ ...inputs, this.#satsInput ];

inputs.forEach(input => {
input.addEventListener('input', () => {
this.#clearQrCode();
});
});
}

// エラーメッセージなどを表示する
#setMessage(message) {
this.#messageArea.innerHTML = '';
const p = window.document.createElement('p')
p.innerText = message;

this.#messageArea.appendChild(p)
}
}

57 changes: 56 additions & 1 deletion main.js
Original file line number Diff line number Diff line change
Expand Up @@ -791,4 +791,59 @@ async function displaySiteVersion() {
if (siteVersion) {
getDomElementById('siteVersion').textContent = siteVersion;
}
}
}

import { Pos } from './lib/pos.js';

const pos = new Pos();
pos.initialize();

// 支払いインボイスのQRコード表示の制御
const posPayButton = document.getElementById('pos-pay-button');
posPayButton.addEventListener('click', () => {
pos.generateInvoice();
});

/**
* ライトニングアドレスの変更ダイアログの制御
*/
const showButton = document.getElementById('show-lightning-address-dialog');
const lnDialog = document.getElementById('update-lightning-address-dialog');
const lnDialogSubmitButton = document.getElementById('lightning-address-submit-button');
const lnDialogCloseButton = document.getElementById('lightning-address-close-button');
const lnDialogClearButton = document.getElementById('lightning-address-clear-button');
const lnAddressForm = document.getElementById('lightning-address-form');

// ダイアログを開く
showButton.addEventListener('click', () => {
lnDialog.showModal();
});

// ダイアログを閉じる
lnDialogCloseButton.addEventListener('click', (event) => {
event.preventDefault(); // フォームを送信しない
lnDialog.close();
});

// フォームをクリア
lnDialogClearButton.addEventListener('click', (event) => {
event.preventDefault(); // フォームを送信しない
pos.clearLnAddress();
});

// アドレスを設定する
lnDialogSubmitButton.addEventListener('click', (event) => {
const isValid = lnAddressForm.checkValidity()
if(!isValid) {
return;
}

pos.setLnAddress(lnAddressForm)
event.preventDefault(); // フォームを送信しない
lnDialog.close();
});

// index.htmlで使用する関数をグローバルスコープで使用できるようにwindowに追加する
window.satsRate = {
calculateValues,
}
Loading

0 comments on commit ac63c0f

Please sign in to comment.