diff --git a/images/settings-solid.svg b/images/settings-solid.svg new file mode 100644 index 0000000..ad00141 --- /dev/null +++ b/images/settings-solid.svg @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/index.html b/index.html index 29fd5df..8cf589b 100644 --- a/index.html +++ b/index.html @@ -113,7 +113,7 @@ + oninput="window.satsRate.calculateValues('sats')" pattern="[0-9]+([\.,][0-9]+)?" inputmode="decimal"> @@ -124,7 +124,7 @@ + oninput="window.satsRate.calculateValues('btc')" pattern="[0-9]+([\.,][0-9]+)?" inputmode="decimal"> @@ -135,7 +135,7 @@ + oninput="window.satsRate.calculateValues('jpy')" pattern="[0-9]+([\.,][0-9]+)?" inputmode="decimal"> @@ -146,7 +146,7 @@ + oninput="window.satsRate.calculateValues('usd')" pattern="[0-9]+([\.,][0-9]+)?" inputmode="decimal"> @@ -157,7 +157,7 @@ + oninput="window.satsRate.calculateValues('eur')" pattern="[0-9]+([\.,][0-9]+)?" inputmode="decimal"> @@ -174,6 +174,48 @@ +
+
+ POS +
+
+ +
+ +
+ +
+
+

+ + +

+
+
+
+
+
SNSに計算結果を共有 @@ -337,7 +379,8 @@
- + + \ No newline at end of file diff --git a/lib/lightning-address.js b/lib/lightning-address.js new file mode 100644 index 0000000..91b834e --- /dev/null +++ b/lib/lightning-address.js @@ -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 || ''; + } +} \ No newline at end of file diff --git a/lib/pos.js b/lib/pos.js new file mode 100644 index 0000000..88193e2 --- /dev/null +++ b/lib/pos.js @@ -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) + } +} + diff --git a/main.js b/main.js index ad814f3..9b31906 100644 --- a/main.js +++ b/main.js @@ -791,4 +791,59 @@ async function displaySiteVersion() { if (siteVersion) { getDomElementById('siteVersion').textContent = siteVersion; } -} \ No newline at end of file +} + +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, +} diff --git a/styles.css b/styles.css index 5ee2deb..56c82b2 100644 --- a/styles.css +++ b/styles.css @@ -636,4 +636,70 @@ input:checked+.slider:before { --unit-bg-color: #E0E0E0; --invalid-color: #D6D6D6; } +} + + +/* POS機能設定 */ +.pos { + display: flex; + align-items: center; + flex-direction: column; +} + +.pos button { + color: var(--main-text-color); + background-color: var(--input-bg-color); + font-size: 1rem; + font-weight: bold; + border: 0; + cursor: pointer; + border-radius: 6px; + height: 42px; + line-height: 42px; + padding: 0 1em; +} + +.pos .setting-btn { + border: none; + background: none; + display: flex; + align-items: center; +} + +.pos svg { + color: var(--svg-color); +} + +.pos .lightning-address { + display: flex; + align-items: center; + margin-bottom: 0; +} + +.pos label { + color: var(--subject-text-color); + display: flex; + flex-flow: column; + font-size: 1.1rem; + font-weight: bold; + text-align: center; + padding: 16px; + margin: 0.5em 0 0.2em; +} + +.pos input{ + margin: 1.5em 0; + height: 2em; +} + +.pos input:invalid { + background-color: ivory; + border: none; + outline: 2px solid red; + border-radius: 5px; +} + +.pos dialog { + border-radius: 1em; + border: 1px solid #333333; } \ No newline at end of file