Skip to content

Commit

Permalink
feat: adding boltcard for the POS behind a beta flag
Browse files Browse the repository at this point in the history
  • Loading branch information
Nicolas Burtey committed Feb 21, 2024
1 parent a69940f commit 90b01f8
Show file tree
Hide file tree
Showing 9 changed files with 406 additions and 52 deletions.
4 changes: 2 additions & 2 deletions apps/pay/app/setuppwa/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,10 +22,10 @@ const SetupPwa = () => {
const [selectedDisplayCurrency, setSelectedDisplayCurrency] = useState("USD")

useEffect(() => {
if (usernameFromLocal && displayCurrencyFromLocal) {
if (router && usernameFromLocal && displayCurrencyFromLocal) {
router.push(`${usernameFromLocal}?display=${displayCurrencyFromLocal}`)
}
}, [displayCurrencyFromLocal, usernameFromLocal])
}, [displayCurrencyFromLocal, usernameFromLocal, router])

const handleSubmit = (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault()
Expand Down
11 changes: 0 additions & 11 deletions apps/pay/components/layouts/username-layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -171,17 +171,6 @@ const UsernameLayoutContainer = ({ children, username }: Props) => {
<div className={styles.divider}></div>
<main className={`${openSideBar && styles.main_bg} ${styles.main}`}>
{children}
<div className={styles.footer}>
<a href="https://galoy.io" target="_blank" rel="noreferrer">
<span>Powered by</span>
<Image
src="/icons/galoy-logo-text-icon.svg"
alt="Galoy logo"
width={50}
height={50}
/>
</a>
</div>
</main>
</div>
)
Expand Down
80 changes: 43 additions & 37 deletions apps/pay/components/parse-pos-payment/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import { Currency } from "../../lib/graphql/generated"
import DigitButton from "./digit-button"
import styles from "./parse-payment.module.css"
import ReceiveInvoice from "./receive-invoice"
import NFCComponent from "./nfc"

function isRunningStandalone() {
if (typeof window === "undefined") {
Expand Down Expand Up @@ -366,43 +367,48 @@ function ParsePayment({
walletId={walletId}
/>
) : (
<div className={styles.digits_grid}>
<DigitButton digit={"1"} dispatch={dispatch} />
<DigitButton digit={"2"} dispatch={dispatch} />
<DigitButton digit={"3"} dispatch={dispatch} />
<DigitButton digit={"4"} dispatch={dispatch} />
<DigitButton digit={"5"} dispatch={dispatch} />
<DigitButton digit={"6"} dispatch={dispatch} />
<DigitButton digit={"7"} dispatch={dispatch} />
<DigitButton digit={"8"} dispatch={dispatch} />
<DigitButton digit={"9"} dispatch={dispatch} />
{currencyMetadata.fractionDigits > 0 ? (
<DigitButton
digit={"."}
dispatch={dispatch}
disabled={unit === AmountUnit.Sat}
displayValue={
getLocaleConfig({ locale: language, currency: display }).decimalSeparator
}
/>
) : (
<DigitButton digit={""} dispatch={dispatch} disabled={true} />
)}

<DigitButton digit={"0"} dispatch={dispatch} />
<button
data-testid="backspace-btn"
className={styles.backspace_icon}
onClick={() => dispatch({ type: ACTIONS.DELETE_DIGIT })}
>
<Image
src="/icons/backspace-icon.svg"
alt="delete digit icon"
width="32"
height="32"
/>
</button>
</div>
<>
<NFCComponent />

<div className={styles.digits_grid}>
<DigitButton digit={"1"} dispatch={dispatch} />
<DigitButton digit={"2"} dispatch={dispatch} />
<DigitButton digit={"3"} dispatch={dispatch} />
<DigitButton digit={"4"} dispatch={dispatch} />
<DigitButton digit={"5"} dispatch={dispatch} />
<DigitButton digit={"6"} dispatch={dispatch} />
<DigitButton digit={"7"} dispatch={dispatch} />
<DigitButton digit={"8"} dispatch={dispatch} />
<DigitButton digit={"9"} dispatch={dispatch} />
{currencyMetadata.fractionDigits > 0 ? (
<DigitButton
digit={"."}
dispatch={dispatch}
disabled={unit === AmountUnit.Sat}
displayValue={
getLocaleConfig({ locale: language, currency: display })
.decimalSeparator
}
/>
) : (
<DigitButton digit={""} dispatch={dispatch} disabled={true} />
)}

<DigitButton digit={"0"} dispatch={dispatch} />
<button
data-testid="backspace-btn"
className={styles.backspace_icon}
onClick={() => dispatch({ type: ACTIONS.DELETE_DIGIT })}
>
<Image
src="/icons/backspace-icon.svg"
alt="delete digit icon"
width="32"
height="32"
/>
</button>
</div>
</>
)}

<div className={styles.pay_btn_container}>
Expand Down
222 changes: 222 additions & 0 deletions apps/pay/components/parse-pos-payment/nfc.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,222 @@
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
}

// TODO: refine the interface
interface NFCRecord {
data?: ArrayBuffer | DataView
encoding?: string
}

function NFCComponent({ paymentRequest }: Props) {
const [hasNFCPermission, setHasNFCPermission] = useState(false)
const [nfcMessage, setNfcMessage] = useState("")

const decodeNDEFRecord = (record: NFCRecord) => {
if (!record.data) {
console.log("No data found")
return ""
}

let buffer: ArrayBuffer
if (record.data instanceof ArrayBuffer) {
buffer = record.data
} else if (record.data instanceof DataView) {
buffer = record.data.buffer
} else {
console.log("Data type not supported")
return ""
}

const decoder = new TextDecoder(record.encoding || "utf-8")
return decoder.decode(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 {
/* eslint @typescript-eslint/ban-ts-comment: "off" */
// @ts-ignore-next-line
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()
}

// handleNFCScan leads to an infinite loop
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [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 successful")
} 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 (
<div>
{!hasNFCPermission && (
<div className="d-flex justify-content-center" style={{ marginTop: "20px" }}>
<button
data-testid="pay-btn"
className={styles.pay_new_btn}
onClick={activateNfcScan}
>
<Image
src={"/icons/lightning-icon.svg"}
alt="lightning icon"
width="20"
height="20"
/>
Activate boltcard
</button>
</div>
)}
</div>
)
}

export default NFCComponent
3 changes: 3 additions & 0 deletions apps/pay/components/parse-pos-payment/receive-invoice.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import { extractSearchParams, safeAmount } from "../../utils/utils"
import LoadingComponent from "../loading"

import styles from "./parse-payment.module.css"
import NFCComponent from "./nfc"

interface Props {
recipientWalletCurrency?: string
Expand Down Expand Up @@ -254,6 +255,8 @@ function ReceiveInvoice({ recipientWalletCurrency, walletId, state, dispatch }:
</div>
)}
<div>
<NFCComponent paymentRequest={invoice?.paymentRequest} />

{data ? (
<>
<div
Expand Down
Loading

0 comments on commit 90b01f8

Please sign in to comment.