diff --git a/src/app/request/[id]/page.tsx b/src/app/request/[id]/page.tsx index 57a446e..a7abf94 100644 --- a/src/app/request/[id]/page.tsx +++ b/src/app/request/[id]/page.tsx @@ -1,43 +1,10 @@ /** @format */ -"use client"; -import { SRFInfoSection } from "@/components/srf-info-section"; -import { TransactionsAndPaymentsTable } from "@/components/transactions-and-payments-table"; -import { Button } from "@/components/ui/button"; -import { - Card, - CardContent, - CardFooter, - CardHeader, - CardTitle, -} from "@/components/ui/card"; -import { Skeleton } from "@/components/ui/skeleton"; -import useExportPDF from "@/lib/hooks/use-export-pdf"; import { fetchRequest } from "@/lib/queries/channel"; -import { fetchRequestPayments } from "@/lib/queries/request-payments"; -import { fetchProxyDeploymentsByReference } from "@/lib/queries/srf-deployments"; -import type { Channel } from "@/lib/types"; -import { - calculateLongPaymentReference, - calculateShortPaymentReference, - capitalize, - commonQueryOptions, - formatTimestamp, - getAmountWithCurrencySymbol, - getBalance, - getContentDataFromCreateTransaction, - getPaymentDataFromCreateTransaction, - getTransactionCreateParameters, - renderAddress, -} from "@/lib/utils"; -import type { ActorInfo } from "@requestnetwork/data-format"; -import { useQuery } from "@tanstack/react-query"; -import { JsonEditor } from "json-edit-react"; -import { Copy, File, Loader2 } from "lucide-react"; -import Link from "next/link"; +import type { Metadata } from "next"; import { notFound } from "next/navigation"; -import { useState } from "react"; -import TimeAgo from "timeago-react"; +import { cache } from "react"; +import { RequestDetails } from "./request-details"; interface RequestPageProps { params: { @@ -45,402 +12,52 @@ interface RequestPageProps { }; } -const getGateway = (request: Channel | null) => { - if (request?.source === "storage_sepolia") { - return "sepolia.gateway.request.network"; - } - return "gnosis.gateway.request.network"; -}; - -const ActorInfoSection = ({ - actorInfo, - isEncrypted, -}: { actorInfo?: ActorInfo; isEncrypted?: boolean }) => { - if ( - !actorInfo || - Object.keys(actorInfo).every( - (k) => !Object.keys((actorInfo as any)[k]).length, - ) - ) { - return "N/A"; - } +// Memoized per render pass so generateMetadata and the page share one network +// call. The fetch-level cache cannot do this: graphql-request issues a POST, +// which Next's Data Cache does not dedupe, and the cache() wrapper inside +// graphQlClient is keyed on a fresh RequestInit object per call. Keying on the +// id string here is what actually collapses the two reads into one. +const getRequest = cache((id: string) => fetchRequest({ id })); + +// A missing request is marked noindex rather than returning a 404 status, and +// that is a deliberate concession to a framework limit, not an oversight. This +// route streams (Transfer-Encoding: chunked), so the HTTP status is committed +// when the shell flushes, before the lookup resolves. notFound() therefore +// swaps the rendered UI but cannot change the status — verified both from the +// page body and from generateMetadata; both return 200. Until that can return a +// real 404 (which needs the check to run before the response commits, i.e. in +// middleware), noindex is what actually prevents these from polluting the +// search index, which is the harm a soft 404 does. +export const generateMetadata = async ({ + params: { id }, +}: RequestPageProps): Promise => { + const request = await getRequest(id); - if (isEncrypted) { - return ( -
- -
- ); + if (!request) { + return { + title: "Request not found | Request Scan", + robots: { index: false, follow: false }, + }; } - return ( -
- -
- ); + return { + title: `Request ${id} | Request Scan`, + description: `Details of request ${id} on the Request Network: status, payee, payer, expected amount, balance, payment reference, transactions and payments.`, + }; }; -export default function RequestPage({ params: { id } }: RequestPageProps) { - const { exportPDF } = useExportPDF(); - const [isDownloading, setIsDownloading] = useState(false); - - const { data: request, status: requestStatus } = useQuery({ - queryKey: ["request", id], - queryFn: () => fetchRequest({ id }), - ...commonQueryOptions, - }); - - const shortPaymentReference = request - ? calculateShortPaymentReference( - id, - request?.transactions?.[0]?.dataObject?.data?.parameters - ?.extensionsData?.[0]?.parameters.salt || "", - request?.transactions?.[0]?.dataObject?.data?.parameters - ?.extensionsData?.[0]?.parameters.paymentAddress || "", - ) - : ""; - - const longPaymentReference = shortPaymentReference - ? calculateLongPaymentReference(shortPaymentReference) - : ""; - - const { data: requestPayments, isLoading: isLoadingRequestPayments } = - useQuery({ - queryKey: ["request-payments", longPaymentReference], - queryFn: () => fetchRequestPayments({ reference: longPaymentReference }), - ...commonQueryOptions, - }); - - const { data: srfDeployments, isLoading: isLoadingSRF } = useQuery({ - queryKey: ["srf-deployments", longPaymentReference], - queryFn: () => - fetchProxyDeploymentsByReference({ reference: longPaymentReference }), - enabled: !!longPaymentReference, - ...commonQueryOptions, - }); - - if (requestStatus === "pending" || isLoadingRequestPayments || isLoadingSRF) { - return ; - } - - if (requestStatus === "error") { - return
Error occurred while fetching data.
; - } +export default async function RequestPage({ + params: { id }, +}: RequestPageProps) { + // Fetched on the server so crawlers and link previews receive the request in + // the initial HTML. A thrown fetch is intentionally left to propagate to the + // error boundary: a backend outage must not be reported as "this request does + // not exist". + const request = await getRequest(id); if (!request) { notFound(); } - const firstTransaction = request?.transactions?.[0]; - const lastTransaction = - request?.transactions?.[request.transactions.length - 1]; - - const balance = getBalance(requestPayments); - - const createParametersFromFirstTransaction = - getTransactionCreateParameters(firstTransaction); - const createParametersFromLastTransaction = - getTransactionCreateParameters(lastTransaction); - - const contentData = getContentDataFromCreateTransaction( - createParametersFromLastTransaction, - ); - const paymentData = getPaymentDataFromCreateTransaction( - createParametersFromFirstTransaction, - ); - - const balanceCurrency = - paymentData?.acceptedTokens?.length > 0 - ? paymentData.acceptedTokens[0] - : ""; - - const buyerData = contentData?.buyerInfo; - const sellerData = contentData?.sellerInfo; - - const status = - balance >= BigInt(createParametersFromFirstTransaction?.expectedAmount || 0) - ? "Paid" - : lastTransaction?.dataObject?.data?.name; - - const modifiedTimestamp = - requestPayments && - requestPayments[requestPayments?.length - 1]?.timestamp > - lastTransaction?.blockTimestamp - ? requestPayments[requestPayments?.length - 1]?.timestamp - : lastTransaction?.blockTimestamp; - - const handleExportPDF = async () => { - setIsDownloading(true); - try { - await exportPDF({ - ...contentData, - currency: createParametersFromFirstTransaction?.currency, - currencyInfo: createParametersFromFirstTransaction?.currency, - payer: createParametersFromFirstTransaction?.payer, - payee: createParametersFromFirstTransaction?.payee, - expectedAmount: createParametersFromFirstTransaction?.expectedAmount, - paymentData, - }); - } catch (error) { - console.error("Error exporting PDF:", error); - } finally { - setIsDownloading(false); - } - }; - - return ( -
-
- - -
- -
- Request {id} - -
- -
-
-
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
Status:{status}
Gateway:{getGateway(request)}
Payee: -
- - {createParametersFromFirstTransaction?.payee?.value} - -
-
Payee Details: - -
Payer: -
- - {createParametersFromFirstTransaction?.payer?.value} - -
-
Payer Details: - -
Expected Amount: - {getAmountWithCurrencySymbol( - BigInt( - createParametersFromFirstTransaction?.expectedAmount || - 0, - ), - createParametersFromFirstTransaction?.currency?.value || - "", - )} -
Balance: - {getAmountWithCurrencySymbol( - BigInt(balance), - balanceCurrency || "", - )} -
Created: - {" "} - ({formatTimestamp(firstTransaction?.blockTimestamp || 0)}) -
Modified: - {" "} - ({formatTimestamp(modifiedTimestamp)}) -
Payment Reference:{shortPaymentReference}
Blockchain: - {paymentData?.network - ? capitalize(paymentData?.network) - : ""} -
Encryption Status: - {firstTransaction?.encryptedData - ? "Encrypted" - : "Not Encrypted"} -
Raw Content Data: - {firstTransaction?.encryptedData ? ( -
-
Encrypted
-
- ) : ( -
- -
- )} -
-
- -
-
-
Actions & Payments
-
- -
-
-
- {srfDeployments && srfDeployments.length > 0 && ( -
- -
- )} -
-
-
-
- ); + return ; } diff --git a/src/app/request/[id]/request-details.tsx b/src/app/request/[id]/request-details.tsx new file mode 100644 index 0000000..c412398 --- /dev/null +++ b/src/app/request/[id]/request-details.tsx @@ -0,0 +1,451 @@ +/** @format */ +"use client"; + +import { SRFInfoSection } from "@/components/srf-info-section"; +import { TransactionsAndPaymentsTable } from "@/components/transactions-and-payments-table"; +import { Button } from "@/components/ui/button"; +import { + Card, + CardContent, + CardFooter, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { Skeleton } from "@/components/ui/skeleton"; +import useExportPDF from "@/lib/hooks/use-export-pdf"; +import { fetchRequestPayments } from "@/lib/queries/request-payments"; +import { fetchProxyDeploymentsByReference } from "@/lib/queries/srf-deployments"; +import type { Channel } from "@/lib/types"; +import { + calculateLongPaymentReference, + calculateShortPaymentReference, + capitalize, + commonQueryOptions, + formatTimestamp, + getAmountWithCurrencySymbol, + getBalance, + getContentDataFromCreateTransaction, + getPaymentDataFromCreateTransaction, + getTransactionCreateParameters, + renderAddress, +} from "@/lib/utils"; +import type { ActorInfo } from "@requestnetwork/data-format"; +import { useQuery } from "@tanstack/react-query"; +import { Copy, File, Loader2 } from "lucide-react"; +import dynamic from "next/dynamic"; +import Link from "next/link"; +import { useState } from "react"; +import TimeAgo from "timeago-react"; + +// json-edit-react's theme provider writes to document.documentElement during +// render, so it throws under SSR. Loading it client-only keeps the rest of the +// page server-rendered; the raw JSON blob is not the indexable content anyway. +const JsonEditor = dynamic( + () => import("json-edit-react").then((m) => m.JsonEditor), + { ssr: false }, +); + +interface RequestDetailsProps { + id: string; + request: Channel; +} + +const getGateway = (request: Channel | null) => { + if (request?.source === "storage_sepolia") { + return "sepolia.gateway.request.network"; + } + return "gnosis.gateway.request.network"; +}; + +const ActorInfoSection = ({ + actorInfo, + isEncrypted, +}: { actorInfo?: ActorInfo; isEncrypted?: boolean }) => { + if ( + !actorInfo || + Object.keys(actorInfo).every( + (k) => !Object.keys((actorInfo as any)[k]).length, + ) + ) { + return "N/A"; + } + + if (isEncrypted) { + return ( +
+
    +
  • + Status: + Encrypted +
  • +
+
+ ); + } + + return ( +
+
    + {actorInfo?.businessName && ( +
  • + Business Name: + {actorInfo?.businessName} +
  • + )} + {actorInfo?.firstName && ( +
  • + First Name: + {actorInfo?.firstName} +
  • + )} + {actorInfo?.lastName && ( +
  • + Last Name: + {actorInfo?.lastName} +
  • + )} + {actorInfo?.email && ( +
  • + Email: + {actorInfo?.email} +
  • + )} + {actorInfo?.phone && ( +
  • + Phone: + {actorInfo?.phone} +
  • + )} + {actorInfo?.address && Object.keys(actorInfo?.address).length > 0 && ( +
  • + Address: + {renderAddress(actorInfo)} +
  • + )} + {actorInfo?.taxRegistration && ( +
  • + Tax Registration: + {actorInfo?.taxRegistration} +
  • + )} + {actorInfo?.miscellaneous! && ( +
  • + Miscellaneous: + {JSON.stringify(actorInfo?.miscellaneous!)} +
  • + )} +
+
+ ); +}; + +export const RequestDetails = ({ id, request }: RequestDetailsProps) => { + const { exportPDF } = useExportPDF(); + const [isDownloading, setIsDownloading] = useState(false); + + const shortPaymentReference = calculateShortPaymentReference( + id, + request?.transactions?.[0]?.dataObject?.data?.parameters + ?.extensionsData?.[0]?.parameters.salt || "", + request?.transactions?.[0]?.dataObject?.data?.parameters + ?.extensionsData?.[0]?.parameters.paymentAddress || "", + ); + + const longPaymentReference = shortPaymentReference + ? calculateLongPaymentReference(shortPaymentReference) + : ""; + + const { data: requestPayments, isLoading: isLoadingRequestPayments } = + useQuery({ + queryKey: ["request-payments", longPaymentReference], + queryFn: () => fetchRequestPayments({ reference: longPaymentReference }), + ...commonQueryOptions, + }); + + const { data: srfDeployments } = useQuery({ + queryKey: ["srf-deployments", longPaymentReference], + queryFn: () => + fetchProxyDeploymentsByReference({ reference: longPaymentReference }), + enabled: !!longPaymentReference, + ...commonQueryOptions, + }); + + const firstTransaction = request?.transactions?.[0]; + const lastTransaction = + request?.transactions?.[request.transactions.length - 1]; + + const balance = getBalance(requestPayments); + + const createParametersFromFirstTransaction = + getTransactionCreateParameters(firstTransaction); + const createParametersFromLastTransaction = + getTransactionCreateParameters(lastTransaction); + + const contentData = getContentDataFromCreateTransaction( + createParametersFromLastTransaction, + ); + const paymentData = getPaymentDataFromCreateTransaction( + createParametersFromFirstTransaction, + ); + + const balanceCurrency = + paymentData?.acceptedTokens?.length > 0 + ? paymentData.acceptedTokens[0] + : ""; + + const buyerData = contentData?.buyerInfo; + const sellerData = contentData?.sellerInfo; + + const status = + balance >= BigInt(createParametersFromFirstTransaction?.expectedAmount || 0) + ? "Paid" + : lastTransaction?.dataObject?.data?.name; + + const modifiedTimestamp = + requestPayments && + requestPayments[requestPayments?.length - 1]?.timestamp > + lastTransaction?.blockTimestamp + ? requestPayments[requestPayments?.length - 1]?.timestamp + : lastTransaction?.blockTimestamp; + + const handleExportPDF = async () => { + setIsDownloading(true); + try { + await exportPDF({ + ...contentData, + currency: createParametersFromFirstTransaction?.currency, + currencyInfo: createParametersFromFirstTransaction?.currency, + payer: createParametersFromFirstTransaction?.payer, + payee: createParametersFromFirstTransaction?.payee, + expectedAmount: createParametersFromFirstTransaction?.expectedAmount, + paymentData, + }); + } catch (error) { + console.error("Error exporting PDF:", error); + } finally { + setIsDownloading(false); + } + }; + + return ( +
+
+ + +
+ +
+ Request {id} + +
+ +
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Status: + {isLoadingRequestPayments ? ( + + ) : ( + status + )} +
Gateway:{getGateway(request)}
Payee: +
+ + {createParametersFromFirstTransaction?.payee?.value} + +
+
Payee Details: + +
Payer: +
+ + {createParametersFromFirstTransaction?.payer?.value} + +
+
Payer Details: + +
Expected Amount: + {getAmountWithCurrencySymbol( + BigInt( + createParametersFromFirstTransaction?.expectedAmount || + 0, + ), + createParametersFromFirstTransaction?.currency?.value || + "", + )} +
Balance: + {isLoadingRequestPayments ? ( + + ) : ( + getAmountWithCurrencySymbol( + BigInt(balance), + balanceCurrency || "", + ) + )} +
Created: + {" "} + ({formatTimestamp(firstTransaction?.blockTimestamp || 0)}) +
Modified: + {isLoadingRequestPayments ? ( + + ) : ( + <> + {" "} + ({formatTimestamp(modifiedTimestamp)}) + + )} +
Payment Reference:{shortPaymentReference}
Blockchain: + {paymentData?.network + ? capitalize(paymentData?.network) + : ""} +
Encryption Status: + {firstTransaction?.encryptedData + ? "Encrypted" + : "Not Encrypted"} +
Raw Content Data: + {firstTransaction?.encryptedData ? ( +
+
Encrypted
+
+ ) : ( +
+ +
+ )} +
+
+ +
+
+
Actions & Payments
+
+ {isLoadingRequestPayments ? ( + + ) : ( + + )} +
+
+
+ {srfDeployments && srfDeployments.length > 0 && ( +
+ +
+ )} +
+
+
+
+ ); +};