diff --git a/apps/pay/components/ParsePOSPayment/nfc.tsx b/apps/pay/components/ParsePOSPayment/nfc.tsx
new file mode 100644
index 00000000000..d910e8819f2
--- /dev/null
+++ b/apps/pay/components/ParsePOSPayment/nfc.tsx
@@ -0,0 +1,213 @@
+import React, { useState, useEffect } from "react"
+
+import { getParams } from "js-lnurl"
+import Image from "next/image"
+import styles from "./parse-payment.module.css"
+
+type Props = {
+ paymentRequest?: string | undefined
+}
+
+function NFCComponent({ paymentRequest }: Props) {
+ const [hasNFCPermission, setHasNFCPermission] = useState(false)
+ const [nfcMessage, setNfcMessage] = useState("")
+
+ const decodeNDEFRecord = (record) => {
+ if (!record || !record.data) {
+ console.log("No data found")
+ return ""
+ }
+
+ // Ensure that the record's data is an instance of ArrayBuffer
+ if (record.data instanceof ArrayBuffer) {
+ console.log("ArrayBuffer")
+
+ const decoder = new TextDecoder(record.encoding || "utf-8")
+ return decoder.decode(record.data)
+ } else {
+ console.log("not ArrayBuffer")
+ // If it's not an ArrayBuffer, it might be a DataView or another typed array.
+ // In that case, we can create a new Uint8Array from the buffer of the DataView.
+ const decoder = new TextDecoder(record.encoding || "utf-8")
+ return decoder.decode(new Uint8Array(record.data.buffer))
+ }
+ }
+
+ const activateNfcScan = async () => {
+ await handleNFCScan()
+ alert(
+ "Boltcard is now active. There will be no need to active it again. Please tap your card to redeem the payment",
+ )
+ }
+
+ const handleNFCScan = async () => {
+ if (!("NDEFReader" in window)) {
+ console.error("NFC is not supported")
+ return
+ }
+
+ console.log("NFC is supported, start reading")
+
+ const ndef = new NDEFReader()
+
+ try {
+ await ndef.scan()
+
+ console.log("NFC scan started successfully.")
+
+ ndef.onreading = (event) => {
+ console.log("NFC tag read.")
+ console.log(event.message)
+
+ const record = event.message.records[0]
+ const text = decodeNDEFRecord(record)
+
+ setNfcMessage(text)
+ }
+
+ ndef.onreadingerror = () => {
+ console.error("Cannot read data from the NFC tag. Try another one?")
+ }
+ } catch (error) {
+ console.error(`Error! Scan failed to start: ${error}.`)
+ }
+ }
+
+ useEffect(() => {
+ ;(async () => {
+ if (!("permissions" in navigator)) {
+ console.error("Permissions API not supported")
+ return
+ }
+
+ let result: PermissionStatus
+ try {
+ result = await navigator.permissions.query({ name: "nfc" })
+ } catch (err) {
+ console.error("Error querying NFC permission", err)
+ return
+ }
+
+ console.log("result permission query", result)
+
+ if (result.state === "granted") {
+ setHasNFCPermission(true)
+ } else {
+ setHasNFCPermission(false)
+ }
+
+ result.onchange = () => {
+ if (result.state === "granted") {
+ setHasNFCPermission(true)
+ } else {
+ setHasNFCPermission(false)
+ }
+ }
+ })()
+ }, [setHasNFCPermission])
+
+ React.useEffect(() => {
+ console.log("hasNFCPermission", hasNFCPermission)
+
+ if (hasNFCPermission) {
+ handleNFCScan()
+ }
+ }, [hasNFCPermission])
+
+ React.useEffect(() => {
+ ;(async () => {
+ if (!nfcMessage) {
+ return
+ }
+
+ if (!nfcMessage.toLowerCase().includes("lnurl")) {
+ alert("Not a compatible boltcard")
+ return
+ }
+
+ if (!paymentRequest) {
+ alert("add an amount and create an invoice before scanning the card")
+ return
+ }
+
+ const sound = new Audio("/payment-sound.mp3")
+ sound
+ .play()
+ .then(() => {
+ console.log("Playback started successfully")
+ })
+ .catch((error) => {
+ console.error("Playback failed", error)
+ })
+
+ const lnurlParams = await getParams(nfcMessage)
+
+ if (!("tag" in lnurlParams && lnurlParams.tag === "withdrawRequest")) {
+ console.error("not a lnurl withdraw tag")
+ return
+ }
+
+ const { callback, k1 } = lnurlParams
+
+ const urlObject = new URL(callback)
+ const searchParams = urlObject.searchParams
+ searchParams.set("k1", k1)
+ searchParams.set("pr", paymentRequest)
+
+ const url = urlObject.toString()
+
+ const result = await fetch(url)
+ if (result.ok) {
+ const lnurlResponse = await result.json()
+ if (lnurlResponse?.status?.toLowerCase() !== "ok") {
+ console.error(lnurlResponse, "error with redeeming")
+ }
+
+ console.log("payment succesful")
+ } else {
+ let errorMessage = ""
+ try {
+ const decoded = await result.json()
+ if (decoded.reason) {
+ errorMessage += decoded.reason
+ }
+ if (decoded.message) {
+ errorMessage += decoded.message
+ }
+ } finally {
+ let message = `Error processing payment.\n\nHTTP error code: ${result.status}`
+ if (errorMessage) {
+ message += `\n\nError message: ${errorMessage}`
+ }
+ alert(message)
+ }
+ }
+ })()
+ }, [nfcMessage, paymentRequest])
+
+ return (
+
+ {!hasNFCPermission && (
+
+
+
+ )}
+ {/* {hasNFCPermission &&
Boltcard scanning is active
}
+ {nfcMessage &&
LNURL: {nfcMessage}
} */}
+
+ )
+}
+
+export default NFCComponent
diff --git a/apps/pay/package.json b/apps/pay/package.json
index b3db2a80131..28a327aa2cd 100644
--- a/apps/pay/package.json
+++ b/apps/pay/package.json
@@ -25,6 +25,7 @@
"graphql-ws": "^5.14.0",
"html2canvas": "^1.4.1",
"ioredis": "^5.3.1",
+ "js-lnurl": "^0.6.0",
"lnurl-pay": "^1.0.1",
"lodash.debounce": "^4.0.8",
"next": "^14.1.0",
diff --git a/apps/pay/public/payment-sound.mp3 b/apps/pay/public/payment-sound.mp3
new file mode 100644
index 00000000000..7e43ba287f4
Binary files /dev/null and b/apps/pay/public/payment-sound.mp3 differ
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 4fcf29ba616..f6dd89da91e 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -607,6 +607,9 @@ importers:
ioredis:
specifier: ^5.3.1
version: 5.3.2
+ js-lnurl:
+ specifier: ^0.6.0
+ version: 0.6.0
lnurl-pay:
specifier: ^1.0.1
version: 1.0.1
@@ -13773,6 +13776,10 @@ packages:
engines: {node: '>=10.13.0'}
dev: false
+ /@types/aes-js@3.1.4:
+ resolution: {integrity: sha512-v3D66IptpUqh+pHKVNRxY8yvp2ESSZXe0rTzsGdzUhEwag7ljVfgCllkWv2YgiYXDhWFBrEywll4A5JToyTNFA==}
+ dev: false
+
/@types/aria-query@5.0.4:
resolution: {integrity: sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==}
dev: true
@@ -13810,6 +13817,10 @@ packages:
'@babel/types': 7.23.6
dev: true
+ /@types/base64-js@1.3.2:
+ resolution: {integrity: sha512-Q2Xn2/vQHRGLRXhQ5+BSLwhHkR3JVflxVKywH0Q6fVoAiUE8fFYL2pE5/l2ZiOiBDfA8qUqRnSxln4G/NFz1Sg==}
+ dev: false
+
/@types/basic-auth@1.1.7:
resolution: {integrity: sha512-bFR3Ld3Fty5ayg45sqr3RI4e/GTXyp2W8jzMmw3WOC8RuQ19TrpsZE4y3jcw9iGSZj5f9mH6e+2biPeFUDovww==}
dependencies:
@@ -17797,7 +17808,6 @@ packages:
node-fetch: 2.7.0
transitivePeerDependencies:
- encoding
- dev: true
/cross-inspect@1.0.0:
resolution: {integrity: sha512-4PFfn4b5ZN6FMNGSZlyb7wUhuN8wvj8t/VQHZdM4JsDcruGJ8L2kf9zao98QIrBPFCpdk27qst/AGTl7pL3ypQ==}
@@ -18114,7 +18124,6 @@ packages:
/decode-uri-component@0.2.2:
resolution: {integrity: sha512-FqUYQ+8o158GyGTrMFJms9qh3CqTKvAqgqsTnkLI8sKu0028orqBhxNMFkFen0zGyg6epACD32pjVk58ngIErQ==}
engines: {node: '>=0.10'}
- dev: true
/decompress-response@3.3.0:
resolution: {integrity: sha512-BzRPQuY1ip+qDonAOz42gRm/pg9F768C+npV/4JOsxRC2sq+Rlk+Q4ZCAsOhnIaMrgarILY+RMUIvMmmX1qAEA==}
@@ -20754,6 +20763,11 @@ packages:
dependencies:
to-regex-range: 5.0.1
+ /filter-obj@1.1.0:
+ resolution: {integrity: sha512-8rXg1ZnX7xzy2NGDVkBVaAy+lSlPNwad13BtgSlLuxfIslyt5Vg64U7tFcCt4WS1R0hvtnQybT/IyCkGZ3DpXQ==}
+ engines: {node: '>=0.10.0'}
+ dev: false
+
/finalhandler@1.1.2:
resolution: {integrity: sha512-aAWcW57uxVNrQZqFXjITpW3sIUQmHGG3qSb9mUah9MgMC4NeWhNOlNjXEYq3HjRAvL6arUviZGGJsBg6z0zsWA==}
engines: {node: '>= 0.8'}
@@ -23913,6 +23927,22 @@ packages:
nopt: 6.0.0
dev: true
+ /js-lnurl@0.6.0:
+ resolution: {integrity: sha512-U4hnInqlHVM9DyYMnOLk0IqlD293A7GVen8JBNWbXrq7C1IigQpTfoal+Fgz/eTZOsYtIEFEOyW9mKgFD/Oc0w==}
+ dependencies:
+ '@types/aes-js': 3.1.4
+ '@types/base64-js': 1.3.2
+ aes-js: 3.1.2
+ base64-js: 1.5.1
+ bech32: 1.1.4
+ buffer: 5.7.1
+ cross-fetch: 3.1.8
+ query-string: 6.14.1
+ safe-buffer: 5.2.1
+ transitivePeerDependencies:
+ - encoding
+ dev: false
+
/js-md5@0.7.3:
resolution: {integrity: sha512-ZC41vPSTLKGwIRjqDh8DfXoCrdQIyBgspJVPXHBGu4nZlAEvG3nf+jO9avM9RmLiGakg7vz974ms99nEV0tmTQ==}
dev: false
@@ -27384,6 +27414,16 @@ packages:
strict-uri-encode: 1.1.0
dev: true
+ /query-string@6.14.1:
+ resolution: {integrity: sha512-XDxAeVmpfu1/6IjyT/gXHOl+S0vQ9owggJ30hhWKdHAsNPOcasn5o9BW0eejZqL2e4vMjhAxoW3jVHcD6mbcYw==}
+ engines: {node: '>=6'}
+ dependencies:
+ decode-uri-component: 0.2.2
+ filter-obj: 1.1.0
+ split-on-first: 1.1.0
+ strict-uri-encode: 2.0.0
+ dev: false
+
/querystringify@2.2.0:
resolution: {integrity: sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ==}
@@ -28852,6 +28892,11 @@ packages:
- utf-8-validate
dev: true
+ /split-on-first@1.1.0:
+ resolution: {integrity: sha512-43ZssAJaMusuKWL8sKUBQXHWOpq8d6CfN/u1p4gUzfJkM05C8rxTmYrkIPTXapZpORA6LkkzcUulJ8FqA7Uudw==}
+ engines: {node: '>=6'}
+ dev: false
+
/split2@4.2.0:
resolution: {integrity: sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==}
engines: {node: '>= 10.x'}
@@ -29017,6 +29062,11 @@ packages:
engines: {node: '>=0.10.0'}
dev: true
+ /strict-uri-encode@2.0.0:
+ resolution: {integrity: sha512-QwiXZgpRcKkhTj2Scnn++4PKtWsH0kpzZ62L2R6c/LUVYv7hVnZqcg2+sMuT6R7Jusu1vviK/MFsu6kNJfWlEQ==}
+ engines: {node: '>=4'}
+ dev: false
+
/string-env-interpolation@1.0.1:
resolution: {integrity: sha512-78lwMoCcn0nNu8LszbP1UA7g55OeE4v7rCeWnM5B453rnNr4aq+5it3FEYtZrSEiMvHZOZ9Jlqb0OD0M2VInqg==}
dev: true