Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(pay): add checkout page #4363

Merged
merged 12 commits into from
May 23, 2024
63 changes: 63 additions & 0 deletions apps/pay/app/checkout/[hash]/hash.module.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
.paymentContainer {
box-shadow: rgba(0, 0, 0, 0.05) 0px 0px 0px 1px,
rgb(209, 213, 219) 0px 0px 0px 1px inset;
border-radius: 1em;
width: 95%;
max-width: 40em;
margin: 1em auto;
align-self: center;
padding: 1.5em;
}

.headerContainer {
display: flex;
justify-content: center;
align-items: center;
position: relative;
}

.headerContainer > button {
background-color: transparent;
display: flex;
align-items: center;
justify-content: center;
border: none;
outline: none;
position: absolute;
left: -50%;
transition: all 0.5s ease;
}

.headerContainer > button:hover,
.headerContainer > button:focus {
outline: 1px solid rgba(239, 241, 245, 1);
border-radius: 50%;
padding: 0.75rem 0.85rem;
}

.title {
color: rgba(17, 25, 40, 1);
font-weight: 600;
line-height: 24px;
margin-top: 12px;
margin-bottom: 6px;
text-transform: capitalize;
}

@media (max-width: 768px) {
.paymentContainer {
box-shadow: none;
margin-top: 0;
width: 100%;
}

.title {
display: none;
}
}

@media print {
.title {
display: none;
}
}
49 changes: 49 additions & 0 deletions apps/pay/app/checkout/[hash]/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import { NextPage } from "next"
import { headers } from "next/headers"

import styles from "./hash.module.css"

import { fetchInvoiceByHash } from "@/app/graphql/queries/invoice-by-hash"

import Invoice from "@/components/invoice"
import { decodeInvoice } from "@/components/utils"
import StatusActions from "@/components/invoice/status-actions"
import CheckoutLayoutContainer from "@/components/layouts/checkout-layout"

import { InvoiceStatusProvider } from "@/context/invoice-status-context"

import { baseLogger } from "@/lib/logger"

const CheckoutPage: NextPage<{ params: { hash: string } }> = async (context) => {
const headersList = headers()
const returnUrl = headersList.get("x-return-url")

const { hash } = context.params
const invoice = await fetchInvoiceByHash({ hash })
if (invoice instanceof Error || !invoice.paymentRequest || !invoice.status) {
baseLogger.error({ hash }, "Error getting invoice for hash")
return <div>Error getting invoice for hash: {hash}</div>
}

const decodedInvoice = decodeInvoice(invoice.paymentRequest)
if (!decodedInvoice) {
baseLogger.error({ invoice }, "Error decoding invoice for hash")
return <div>Error decoding invoice for hash: {hash}</div>
}

return (
<InvoiceStatusProvider invoice={decodedInvoice} initialStatus={invoice.status}>
<CheckoutLayoutContainer>
<div className={styles.paymentContainer}>
<div className={styles.headerContainer}>
<p className={styles.title}>Pay Invoice</p>
</div>
<Invoice title="Pay Invoice" returnUrl={returnUrl} />
<StatusActions returnUrl={returnUrl} />
</div>
</CheckoutLayoutContainer>
</InvoiceStatusProvider>
)
}

export default CheckoutPage
28 changes: 28 additions & 0 deletions apps/pay/app/checkout/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { NextResponse } from "next/server"

export async function POST(request: Request) {
let returnUrl, hash
try {
const formData = await request.formData()
hash = formData.get("hash")?.toString()
returnUrl = formData.get("returnUrl")?.toString()
} catch (error) {}

try {
const data = await request.json()
hash = data.hash
returnUrl = data.returnUrl
} catch (error) {}

if (!hash) {
return Response.json({ error: "Invalid hash" }, { status: 404 })
}

returnUrl = returnUrl || request.referrer

const response = NextResponse.redirect(`${request.url}/${hash}`)
if (returnUrl !== "about:client") {
response.headers.set("x-return-url", returnUrl)
}
return response
}
41 changes: 41 additions & 0 deletions apps/pay/app/graphql/queries/invoice-by-hash.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import { gql } from "@apollo/client"

import { apollo } from "@/app/ssr-client"
import {
LnInvoicePaymentStatusByHashDocument,
LnInvoicePaymentStatusByHashQuery,
} from "@/lib/graphql/generated"

gql`
query LnInvoicePaymentStatusByHash($input: LnInvoicePaymentStatusByHashInput!) {
lnInvoicePaymentStatusByHash(input: $input) {
paymentHash
paymentRequest
status
}
}
`

export type InvoiceStatus =
LnInvoicePaymentStatusByHashQuery["lnInvoicePaymentStatusByHash"]

export async function fetchInvoiceByHash({
hash,
}: {
hash: string
}): Promise<InvoiceStatus | Error> {
try {
const response = await apollo
.unauthenticated()
.getClient()
.query<LnInvoicePaymentStatusByHashQuery>({
query: LnInvoicePaymentStatusByHashDocument,
variables: { input: { paymentHash: hash } },
})

return response?.data?.lnInvoicePaymentStatusByHash
} catch (err) {
if (err instanceof Error) return err
return new Error("FetchInvoice unknown error")
}
}
50 changes: 50 additions & 0 deletions apps/pay/components/invoice/expiration-label.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
"use client"

import { useEffect, useState } from "react"

import styles from "./index.module.css"
import { type ExpirationLabelProps } from "./index.types"

function ExpirationLabel({ expirationDate }: ExpirationLabelProps) {
const [seconds, setSeconds] = useState(0)

const setRemainingSeconds = () => {
const currentTime = new Date()
const expirationTime = new Date(expirationDate * 1000)
const elapsedTime = expirationTime.getTime() - currentTime.getTime()
let remainingSeconds = Math.ceil(elapsedTime / 1000)
if (remainingSeconds <= 0) {
remainingSeconds = 0
}
setSeconds(remainingSeconds)
}

useEffect(() => {
const interval = setInterval(() => setRemainingSeconds(), 1000)

return () => clearInterval(interval)
})

return (
<span className={styles.expirationLabel}>{formatInvoiceExpirationTime(seconds)}</span>
)
}
export default ExpirationLabel

const formatInvoiceExpirationTime = (seconds: number): string => {
if (seconds <= 0) {
return "Expired"
}

if (seconds >= 3600) {
const hours = Math.floor(seconds / 3600)
return `Expires in ~${hours} Hour${hours > 1 ? "s" : ""}`
}

if (seconds >= 60) {
const minutes = Math.floor(seconds / 60)
return `Expires in ~${minutes} Minute${minutes > 1 ? "s" : ""}`
}

return `Expires in ${seconds} Second${seconds > 1 ? "s" : ""}`
}
Loading
Loading