From 8f0d5dd82d58faa2fea277c1f4a8b5997d65105e Mon Sep 17 00:00:00 2001 From: rodrigopavezi Date: Tue, 28 Jul 2026 12:10:20 -0300 Subject: [PATCH] feat: server-render the request page so crawlers get the request MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous commits stopped /request/ returning 500, but the page still served nothing useful: it fetched on the client, so the HTML was a skeleton with zero request data. An SEO audit flagged these URLs, and a 200 with an empty shell is barely better than the 500 it replaced. page.tsx is now an async server component that awaits fetchRequest and passes the Channel to a new client component, request-details.tsx, which keeps everything genuinely browser-bound: PDF export, the JSON viewer, the clipboard buttons, TimeAgo, useState. The relocation is verbatim — same markup, classNames, copy and table structure. Verified against a local stand-in backend: the served HTML now contains the gateway, payee, payer, expected amount, payment reference, encryption status and the transactions table. Before, none of it was there. Two things this uncovered, both worth reading: **json-edit-react cannot be server-rendered.** Its theme provider calls `document.documentElement.style.setProperty("--jer-highlight-color", …)` during render. Once the details actually server-rendered, that threw `ReferenceError: document is not defined`, React bailed out to client rendering, and the HTML went back to being an empty shell — the original bug's twin, hidden until now because SSR had never got as far as rendering this component. It is loaded via next/dynamic with ssr:false. The raw JSON blob is not the indexable content, so nothing is lost. Note this is a *render-time* touch of document, not an import-time one, which is why an import-level audit of the same file came back clean. **A missing request still returns 200, not 404.** It renders the not-found UI, but the status is wrong, and this is a framework limit rather than something left undone. The route streams (Transfer-Encoding: chunked), so the status commits when the shell flushes, before the lookup resolves; notFound() can then only swap the UI. Tried and measured: notFound() from the page body → 200, and notFound() from generateMetadata → also 200. A real 404 needs the existence check to run before the response commits, which means middleware and a Hasura call on every request — a bigger trade than this commit should make unilaterally. So the actual harm is addressed instead: a missing request is served ``, which is what stops soft 404s accumulating in the search index. Valid requests are not noindexed — checked both ways. Also: generateMetadata gives these pages a real title and description for search results and link previews, and the request lookup is wrapped in React's cache() keyed on the id so generateMetadata and the page share one network call. The fetch-level cache cannot do that here — graphql- request issues a POST, which Next's Data Cache does not dedupe, and the cache() inside graphQlClient is keyed on a fresh RequestInit per call. The two dependent queries (payments, SRF deployments) stay client-side: they hang off a payment reference derived from the request, they are the polling surface via refetchInterval, and keeping them there guarantees neither can affect the HTTP status or block the request's own content. The four cells they feed — Status, Balance, Modified, and the payments table — render their own inline loading state, so they no longer briefly display wrong values (Balance 0, Status from the last transaction) before the query lands. --- src/app/request/[id]/page.tsx | 465 ++--------------------- src/app/request/[id]/request-details.tsx | 451 ++++++++++++++++++++++ 2 files changed, 492 insertions(+), 424 deletions(-) create mode 100644 src/app/request/[id]/request-details.tsx 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 ( -
-
    -
  • - Status: - Encrypted -
  • -
-
- ); + if (!request) { + return { + title: "Request not found | Request Scan", + robots: { index: false, follow: false }, + }; } - 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!)} -
  • - )} -
-
- ); + 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 && ( +
+ +
+ )} +
+
+
+
+ ); +};