From 12ff692d3c99f99076cbd237508d1a93ce781149 Mon Sep 17 00:00:00 2001 From: Puspendra Mahariya <95584952+silent-cipher@users.noreply.github.com> Date: Thu, 20 Aug 2026 22:51:20 +0530 Subject: [PATCH 01/10] refactor: improve rail settlement guards and preview (#309) * feat: improve epoch and date formatting across explorer * feat: add comment about rail rate change impact on settlement calculation * refactor: extract EpochTimeCell to reduce ternaries * refactor: replace epoch display with timestamp in funded until column * feat: add debt display and improve funded until calculation in console * fix: add explicit BigInt conversion * chore: address copilot review comments * refactor: replace epoch-based timing with timestamp-based calculations * feat: convert payment rates from per-epoch to per-day * refactor: display /day payment-rate in settleRailDialog * refactor: improve funded until calculation and rail state labels * refactor: improve settlement validation and add paused rail settlement logic * refactor: centralize settlement eligibility logic * refactor: rename settlementEpoch to currentEpoch and extract untilEpoch calculation * chore: address review comment --- .../RailsSection/components/RailActions.tsx | 18 +- .../context/SettleRailContext.tsx | 15 +- .../UserConsole/RailsSection/index.tsx | 14 +- .../UserConsole/RailsSection/types/index.ts | 1 - .../UserConsole/SettleRailDialog.tsx | 195 ------------------ .../SettleRailDialog/SettlementDetails.tsx | 106 ++++++++++ .../SettleRailDialog/SettlementNotices.tsx | 36 ++++ .../UserConsole/SettleRailDialog/index.tsx | 96 +++++++++ .../SettleRailDialog/useSettleRailDialog.ts | 118 +++++++++++ .../src/hooks/useRailSettlementAmounts.ts | 26 +++ .../hooks/useRailSettlementCalculations.ts | 34 --- .../explorer/src/hooks/useRailSettlements.tsx | 26 +-- apps/explorer/src/services/grapql/queries.ts | 12 +- apps/explorer/src/utils/formatter.ts | 20 ++ apps/explorer/src/utils/railSettlement.ts | 58 ++++++ 15 files changed, 505 insertions(+), 270 deletions(-) delete mode 100644 apps/explorer/src/components/UserConsole/SettleRailDialog.tsx create mode 100644 apps/explorer/src/components/UserConsole/SettleRailDialog/SettlementDetails.tsx create mode 100644 apps/explorer/src/components/UserConsole/SettleRailDialog/SettlementNotices.tsx create mode 100644 apps/explorer/src/components/UserConsole/SettleRailDialog/index.tsx create mode 100644 apps/explorer/src/components/UserConsole/SettleRailDialog/useSettleRailDialog.ts create mode 100644 apps/explorer/src/hooks/useRailSettlementAmounts.ts delete mode 100644 apps/explorer/src/hooks/useRailSettlementCalculations.ts create mode 100644 apps/explorer/src/utils/railSettlement.ts diff --git a/apps/explorer/src/components/UserConsole/RailsSection/components/RailActions.tsx b/apps/explorer/src/components/UserConsole/RailsSection/components/RailActions.tsx index d7507b4d..6627f447 100644 --- a/apps/explorer/src/components/UserConsole/RailsSection/components/RailActions.tsx +++ b/apps/explorer/src/components/UserConsole/RailsSection/components/RailActions.tsx @@ -1,6 +1,7 @@ import { Button } from "@filecoin-foundation/ui-filecoin/Button"; import { Tooltip, TooltipContent, TooltipTrigger } from "@filecoin-pay/ui/components/tooltip"; import { InlineTextLoader } from "@/components/shared"; +import { getRailSettlementEligibility, getRailSettlementUnavailableReason } from "@/utils/railSettlement"; import { useSettleRail } from "../context/SettleRailContext"; import type { RailTableRow } from "../types"; @@ -9,14 +10,12 @@ type RailActionsProps = { }; const RailActions = ({ rail }: RailActionsProps) => { - const { openSettleDialog } = useSettleRail(); - const isFinalized = rail.state === "FINALIZED"; - const isDisabled = isFinalized || rail.isSettling; + const { currentEpoch, openSettleDialog } = useSettleRail(); + const settlementEligibility = getRailSettlementEligibility(rail, currentEpoch); + const isDisabled = settlementEligibility.status !== "allowed" || rail.isSettling; - let tooltipContent = ""; - if (isFinalized) { - tooltipContent = "Rail is finalized and cannot be settled"; - } else if (rail.isSettling) { + let tooltipContent = getRailSettlementUnavailableReason(settlementEligibility); + if (settlementEligibility.status === "allowed" && rail.isSettling) { tooltipContent = "Settlement in progress..."; } @@ -30,7 +29,10 @@ const RailActions = ({ rail }: RailActionsProps) => { return (
- {button} + + {/* biome-ignore lint/a11y/noNoninteractiveTabindex: makes the disabled action explanation keyboard-accessible */} + {button} +

{tooltipContent}

diff --git a/apps/explorer/src/components/UserConsole/RailsSection/context/SettleRailContext.tsx b/apps/explorer/src/components/UserConsole/RailsSection/context/SettleRailContext.tsx index cb44a6b1..3f39c060 100644 --- a/apps/explorer/src/components/UserConsole/RailsSection/context/SettleRailContext.tsx +++ b/apps/explorer/src/components/UserConsole/RailsSection/context/SettleRailContext.tsx @@ -1,7 +1,10 @@ import type { Rail } from "@filecoin-pay/types"; -import { createContext, type ReactNode, useContext, useMemo } from "react"; +import { createContext, type ReactNode, useCallback, useContext, useMemo } from "react"; +import { useBlockNumber } from "wagmi"; +import type { supportedChains } from "@/services/wagmi/config"; interface SettleRailContextValue { + currentEpoch: bigint | undefined; openSettleDialog: (rail: Rail) => void; } @@ -17,10 +20,14 @@ export const useSettleRail = () => { interface SettleRailProviderProps { children: ReactNode; - onSettle: (rail: Rail) => void; + chainId: (typeof supportedChains)[number]["id"]; + onSettle: (rail: Rail, currentEpoch: bigint | undefined) => void; } -export const SettleRailProvider = ({ children, onSettle }: SettleRailProviderProps) => { - const value = useMemo(() => ({ openSettleDialog: onSettle }), [onSettle]); +export const SettleRailProvider = ({ children, chainId, onSettle }: SettleRailProviderProps) => { + const { data: currentEpoch } = useBlockNumber({ chainId, watch: true }); + const openSettleDialog = useCallback((rail: Rail) => onSettle(rail, currentEpoch), [currentEpoch, onSettle]); + const value = useMemo(() => ({ currentEpoch, openSettleDialog }), [currentEpoch, openSettleDialog]); + return {children}; }; diff --git a/apps/explorer/src/components/UserConsole/RailsSection/index.tsx b/apps/explorer/src/components/UserConsole/RailsSection/index.tsx index 444614cb..c6bb44be 100644 --- a/apps/explorer/src/components/UserConsole/RailsSection/index.tsx +++ b/apps/explorer/src/components/UserConsole/RailsSection/index.tsx @@ -7,7 +7,7 @@ import { PaginationNext, PaginationPrevious, } from "@filecoin-pay/ui/components/pagination"; -import { useMemo, useState } from "react"; +import { useCallback, useMemo, useState } from "react"; import { useChainId } from "wagmi"; import { getChain } from "@/constants/chains"; import { useAccountRails } from "@/hooks/useAccountDetails"; @@ -30,6 +30,7 @@ export const RailsSection: React.FC = ({ account, userAddress const [searchFilter, setSearchFilter] = useState("railId"); const [settleDialogOpen, setSettleDialogOpen] = useState(false); const [selectedRail, setSelectedRail] = useState(null); + const [currentEpoch, setCurrentEpoch] = useState(); const chainId = useChainId(); const { chain, walletNetwork } = useMemo(() => { @@ -48,10 +49,11 @@ export const RailsSection: React.FC = ({ account, userAddress explorerUrl: chain.blockExplorers?.default.url, }); - const handleSettle = (rail: Rail) => { + const handleSettle = useCallback((rail: Rail, epoch: bigint | undefined) => { setSelectedRail(rail); + setCurrentEpoch(epoch); setSettleDialogOpen(true); - }; + }, []); const handleSearch = (query: string, filterType: SearchFilterType) => { setSearchQuery(query.toLowerCase()); @@ -84,12 +86,11 @@ export const RailsSection: React.FC = ({ account, userAddress }); }, [data, searchQuery, searchFilter]); - // Prepare table data with userAddress, settlement state, and isPayer calculation + // Prepare table data with settlement state and the user's role. const tableData = useMemo( () => filteredRails.map((rail) => ({ ...rail, - userAddress, isPayer: rail.payer.address.toLowerCase() === userAddress.toLowerCase(), isSettling: settlements.has(rail.railId.toString()), })), @@ -125,7 +126,7 @@ export const RailsSection: React.FC = ({ account, userAddress ) : ( <> - + @@ -173,6 +174,7 @@ export const RailsSection: React.FC = ({ account, userAddress void; - isSettling?: boolean; - settleRail: (params: SettleRailParams) => Promise; -} - -export const SettleRailDialog: React.FC = ({ - rail, - userAddress, - open, - onOpenChange, - isSettling = false, - settleRail, -}) => { - const { - isPayer, - currentEpoch, - settledUptoEpoch, - epochsSinceLastSettlement, - expectedSettleAmount, - isLoadingBlockNumber, - } = useRailSettlementCalculations(rail, userAddress); - - const canSettle = !isSettling && !isLoadingBlockNumber && epochsSinceLastSettlement > 0n; - const readyCurrentEpoch = isLoadingBlockNumber || currentEpoch === 0n ? undefined : currentEpoch; - const epochsToSettleText = - readyCurrentEpoch === undefined ? "Loading..." : formatEpochDuration(epochsSinceLastSettlement); - - const handleSettle = async () => { - if (isLoadingBlockNumber || currentEpoch === 0n) { - return; - } - try { - await settleRail({ - railId: rail.railId, - paymentRate: BigInt(rail.paymentRate), - tokenSymbol: rail.token.symbol, - tokenDecimals: Number(rail.token.decimals), - settledUpto: settledUptoEpoch, - }); - // Close dialog immediately after initiating settlement - onOpenChange(false); - } catch { - // Error already handled by useRailSettlements hook with toast notifications - } - }; - - return ( - - - - Settle Rail #{rail.railId.toString()} - Confirm settlement for all pending payments up to the current epoch. - - -
- {/* Rail Overview */} -
-
- - {isPayer ? "Payer" : "Payee"} - - -
-
Operator: {formatAddress(rail.operator.address)}
-
- - {/* Settlement Information */} -
-
-
- Current Epoch: -
- - {readyCurrentEpoch !== undefined && ( -
Epoch {currentEpoch.toString()}
- )} -
-
-
- Settled Up To: -
- - {readyCurrentEpoch !== undefined && ( -
Epoch {settledUptoEpoch.toString()}
- )} -
-
-
- Epochs to Settle: -
-
{epochsToSettleText}
- {readyCurrentEpoch !== undefined && ( -
{epochsSinceLastSettlement.toString()} epochs
- )} -
-
-
-
- Payment Rate: - - {formatToken( - BigInt(rail.paymentRate) * TIME_CONSTANTS.EPOCHS_PER_DAY, - rail.token.decimals, - `${rail.token.symbol}/day`, - 12, - )} - -
-
- Historical Settled: - - {formatToken(rail.totalSettledAmount, rail.token.decimals, rail.token.symbol, 8)} - -
-
-
- Expected Amount: - - {isLoadingBlockNumber - ? "Loading..." - : formatToken(expectedSettleAmount, rail.token.decimals, rail.token.symbol, 8)} - -
-
-
- - {/* Estimate Disclaimer */} -
- -

- Amount is an estimate and may vary on-chain based on rate changes and network conditions. -

-
- - {/* Warning */} - {epochsSinceLastSettlement === 0n && ( -
- -

- The rail is already settled up to the current epoch. -

-
- )} -
- - - - - - -
- ); -}; diff --git a/apps/explorer/src/components/UserConsole/SettleRailDialog/SettlementDetails.tsx b/apps/explorer/src/components/UserConsole/SettleRailDialog/SettlementDetails.tsx new file mode 100644 index 00000000..269ee3fe --- /dev/null +++ b/apps/explorer/src/components/UserConsole/SettleRailDialog/SettlementDetails.tsx @@ -0,0 +1,106 @@ +import type { Rail } from "@filecoin-pay/types"; +import { TIME_CONSTANTS } from "@filoz/synapse-sdk"; +import { formatEpochDuration, formatToken, formatTokenTruncated } from "@/utils/formatter"; +import { EpochTimeCell } from "../../shared"; +import type { SettlementAmountState } from "./useSettleRailDialog"; + +interface EpochRowProps { + label: string; + epoch: bigint; + currentEpoch: bigint | undefined; +} + +const EpochRow = ({ label, epoch, currentEpoch }: EpochRowProps) => ( +
+ {label}: +
+ + {currentEpoch !== undefined &&
Epoch {epoch.toString()}
} +
+
+); + +function formatSettlementAmount(state: SettlementAmountState, rail: Rail): string { + switch (state.status) { + case "loading": + return "Loading..."; + case "error": + return "Unavailable"; + case "ready": + return formatTokenTruncated(state.amount, rail.token.decimals, rail.token.symbol, 8); + case "unavailable": + return "—"; + } +} + +interface SettlementDetailsProps { + rail: Rail; + currentEpoch: bigint | undefined; + untilEpoch: bigint | undefined; + settledUptoEpoch: bigint; + epochsSinceLastSettlement: bigint; + settlementAmountState: SettlementAmountState; +} + +export const SettlementDetails = ({ + rail, + currentEpoch, + untilEpoch, + settledUptoEpoch, + epochsSinceLastSettlement, + settlementAmountState, +}: SettlementDetailsProps) => { + const epochsToSettleText = currentEpoch === undefined ? "Loading..." : formatEpochDuration(epochsSinceLastSettlement); + + return ( +
+
+ + + +
+ Epochs to Settle: +
+
{epochsToSettleText}
+ {currentEpoch !== undefined && ( +
{epochsSinceLastSettlement.toString()} epochs
+ )} +
+
+ +
+ +
+ Payment Rate: + + {formatToken( + BigInt(rail.paymentRate) * TIME_CONSTANTS.EPOCHS_PER_DAY, + rail.token.decimals, + `${rail.token.symbol}/day`, + 12, + )} + +
+ +
+ Historical Settled: + + {formatToken(rail.totalSettledAmount, rail.token.decimals, rail.token.symbol, 8)} + +
+ +
+ +
+ Settlement Amount: + {formatSettlementAmount(settlementAmountState, rail)} +
+
+
+ ); +}; diff --git a/apps/explorer/src/components/UserConsole/SettleRailDialog/SettlementNotices.tsx b/apps/explorer/src/components/UserConsole/SettleRailDialog/SettlementNotices.tsx new file mode 100644 index 00000000..9e7dba94 --- /dev/null +++ b/apps/explorer/src/components/UserConsole/SettleRailDialog/SettlementNotices.tsx @@ -0,0 +1,36 @@ +import { AlertCircle, Info } from "lucide-react"; +import type { SettlementAmountState } from "./useSettleRailDialog"; + +interface SettlementNoticesProps { + settlementAmountStatus: SettlementAmountState["status"]; + showNoUnsettledWarning: boolean; +} + +export const SettlementNotices = ({ settlementAmountStatus, showNoUnsettledWarning }: SettlementNoticesProps) => ( + <> +
+ +

+ Calculated from the current on-chain state. The amount may change before confirmation. +

+
+ + {settlementAmountStatus === "error" && ( +
+ +

+ Unable to calculate the settlement amount. Close the dialog and try again. +

+
+ )} + + {showNoUnsettledWarning && ( +
+ +

+ The rail is already settled up to the selected epoch. +

+
+ )} + +); diff --git a/apps/explorer/src/components/UserConsole/SettleRailDialog/index.tsx b/apps/explorer/src/components/UserConsole/SettleRailDialog/index.tsx new file mode 100644 index 00000000..6e47fe3a --- /dev/null +++ b/apps/explorer/src/components/UserConsole/SettleRailDialog/index.tsx @@ -0,0 +1,96 @@ +import { Button } from "@filecoin-foundation/ui-filecoin/Button"; +import type { Rail } from "@filecoin-pay/types"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@filecoin-pay/ui/components/dialog"; +import { formatAddress } from "@/utils/formatter"; +import { InlineTextLoader, RailStateBadge, RoleIndicator } from "../../shared"; +import { SettlementDetails } from "./SettlementDetails"; +import { SettlementNotices } from "./SettlementNotices"; +import { type SettleRail, useSettleRailDialog } from "./useSettleRailDialog"; + +interface SettleRailDialogProps { + rail: Rail; + userAddress: string; + currentEpoch?: bigint; + open: boolean; + onOpenChange: (open: boolean) => void; + isSettling?: boolean; + settleRail: SettleRail; +} + +export const SettleRailDialog: React.FC = ({ + rail, + userAddress, + currentEpoch, + open, + onOpenChange, + isSettling = false, + settleRail, +}) => { + const settlement = useSettleRailDialog({ + rail, + userAddress, + currentEpoch, + open, + isSettling, + settleRail, + onSettled: () => onOpenChange(false), + }); + + const confirmButtonContent = isSettling ? : "Confirm"; + + return ( + + + + Settle Rail #{rail.railId.toString()} + Confirm settlement for all pending payments up to the selected epoch. + + +
+
+
+ + +
+
Operator: {formatAddress(rail.operator.address)}
+
+ + + + +
+ + + + + +
+
+ ); +}; diff --git a/apps/explorer/src/components/UserConsole/SettleRailDialog/useSettleRailDialog.ts b/apps/explorer/src/components/UserConsole/SettleRailDialog/useSettleRailDialog.ts new file mode 100644 index 00000000..359a9297 --- /dev/null +++ b/apps/explorer/src/components/UserConsole/SettleRailDialog/useSettleRailDialog.ts @@ -0,0 +1,118 @@ +import type { Rail } from "@filecoin-pay/types"; +import { toast } from "sonner"; +import type { Hex } from "viem"; +import { useRailSettlementAmounts } from "@/hooks/useRailSettlementAmounts"; +import type { SettleRailParams } from "@/hooks/useRailSettlements"; +import { + getRailSettlementEligibility, + getRailSettlementUnavailableReason, + getSettlementUntilEpoch, + getUnsettledEpochs, +} from "@/utils/railSettlement"; + +export type SettlementAmountState = + | { status: "unavailable"; reason: string } + | { status: "loading"; reason: string } + | { status: "error"; reason: string } + | { status: "ready"; amount: bigint; untilEpoch: bigint }; + +export type SettleRail = (params: SettleRailParams) => Promise; + +interface UseSettleRailDialogOptions { + rail: Rail; + userAddress: string; + currentEpoch: bigint | undefined; + open: boolean; + isSettling: boolean; + settleRail: SettleRail; + onSettled: () => void; +} + +export function useSettleRailDialog({ + rail, + userAddress, + currentEpoch, + open, + isSettling, + settleRail, + onSettled, +}: UseSettleRailDialogOptions) { + const railId = BigInt(rail.railId); + const settledUptoEpoch = BigInt(rail.settledUpto); + const untilEpoch = + currentEpoch !== undefined ? getSettlementUntilEpoch(BigInt(rail.endEpoch), currentEpoch) : undefined; + const epochsSinceLastSettlement = getUnsettledEpochs(rail, untilEpoch); + const settlementEligibility = getRailSettlementEligibility(rail, currentEpoch); + const isSettlementAllowed = settlementEligibility.status === "allowed"; + + const { + data: settlementAmounts, + isError, + isFetching, + isPending, + } = useRailSettlementAmounts({ + railId, + untilEpoch, + enabled: open && isSettlementAllowed, + }); + + let settlementAmountState: SettlementAmountState; + if (!isSettlementAllowed) { + settlementAmountState = { + status: "unavailable", + reason: getRailSettlementUnavailableReason(settlementEligibility), + }; + } else if (isSettling) { + settlementAmountState = { status: "unavailable", reason: "A settlement is already in progress." }; + } else if (open && (isPending || isFetching)) { + settlementAmountState = { status: "loading", reason: "The settlement amount is still being calculated." }; + } else if (isError) { + settlementAmountState = { + status: "error", + reason: "Unable to calculate the settlement amount. Close the dialog and try again.", + }; + } else if (settlementAmounts !== undefined) { + settlementAmountState = { + status: "ready", + amount: settlementAmounts.totalSettledAmount, + untilEpoch: settlementEligibility.untilEpoch, + }; + } else { + settlementAmountState = { status: "unavailable", reason: "The settlement amount is unavailable." }; + } + + const canSettle = isSettlementAllowed && !isSettling && settlementAmountState.status === "ready"; + const role: "payer" | "payee" = rail.payer.address.toLowerCase() === userAddress.toLowerCase() ? "payer" : "payee"; + + const handleSettle = async () => { + if (settlementAmountState.status !== "ready") { + toast.error("Unable to settle", { description: settlementAmountState.reason }); + return; + } + + try { + await settleRail({ + railId, + untilEpoch: settlementAmountState.untilEpoch, + settlementAmount: settlementAmountState.amount, + tokenSymbol: rail.token.symbol, + tokenDecimals: Number(rail.token.decimals), + }); + onSettled(); + } catch { + // useRailSettlements reports transaction errors to the user. + } + }; + + return { + role, + currentEpoch, + untilEpoch, + settledUptoEpoch, + epochsSinceLastSettlement, + settlementEligibility, + settlementAmountState, + canSettle, + handleSettle, + }; +} diff --git a/apps/explorer/src/hooks/useRailSettlementAmounts.ts b/apps/explorer/src/hooks/useRailSettlementAmounts.ts new file mode 100644 index 00000000..0171e100 --- /dev/null +++ b/apps/explorer/src/hooks/useRailSettlementAmounts.ts @@ -0,0 +1,26 @@ +import { useQuery } from "@tanstack/react-query"; +import useSynapse from "./useSynapse"; + +interface UseRailSettlementAmountsOptions { + railId: bigint; + untilEpoch: bigint | undefined; + enabled: boolean; +} + +export function useRailSettlementAmounts({ railId, untilEpoch, enabled }: UseRailSettlementAmountsOptions) { + const { synapse, constants } = useSynapse(); + + return useQuery({ + queryKey: ["railSettlementAmounts", constants.chain.id, railId.toString(), untilEpoch?.toString()], + queryFn: () => { + if (!synapse) throw new Error("Synapse is not initialized"); + if (untilEpoch === undefined) throw new Error("Settlement epoch is unavailable"); + + return synapse.payments.getSettlementAmounts({ railId, untilEpoch }); + }, + enabled: enabled && synapse !== null && untilEpoch !== undefined, + refetchOnReconnect: false, + refetchOnWindowFocus: false, + retry: false, + }); +} diff --git a/apps/explorer/src/hooks/useRailSettlementCalculations.ts b/apps/explorer/src/hooks/useRailSettlementCalculations.ts deleted file mode 100644 index c9b4310a..00000000 --- a/apps/explorer/src/hooks/useRailSettlementCalculations.ts +++ /dev/null @@ -1,34 +0,0 @@ -import type { Rail } from "@filecoin-pay/types"; -import { useMemo } from "react"; -import { useBlockNumber } from "wagmi"; - -interface RailSettlementCalculations { - isPayer: boolean; - currentEpoch: bigint; - settledUptoEpoch: bigint; - epochsSinceLastSettlement: bigint; - expectedSettleAmount: bigint; - isLoadingBlockNumber: boolean; -} - -export const useRailSettlementCalculations = (rail: Rail, userAddress: string): RailSettlementCalculations => { - const { data: blockNumber, isLoading: isLoadingBlockNumber } = useBlockNumber({ watch: true }); - - return useMemo(() => { - const isPayer = rail.payer.address.toLowerCase() === userAddress.toLowerCase(); - const currentEpoch = blockNumber ? BigInt(blockNumber) : 0n; - const settledUptoEpoch = BigInt(rail.settledUpto); - const epochsSinceLastSettlement = currentEpoch > settledUptoEpoch ? currentEpoch - settledUptoEpoch : 0n; - // expectedSettleAmount won't be accurate if the rail rate has changed since the last settlement - const expectedSettleAmount = blockNumber ? BigInt(rail.paymentRate) * epochsSinceLastSettlement : 0n; - - return { - isPayer, - currentEpoch, - settledUptoEpoch, - epochsSinceLastSettlement, - expectedSettleAmount, - isLoadingBlockNumber, - }; - }, [rail, userAddress, blockNumber, isLoadingBlockNumber]); -}; diff --git a/apps/explorer/src/hooks/useRailSettlements.tsx b/apps/explorer/src/hooks/useRailSettlements.tsx index acf45037..98b1f0e0 100644 --- a/apps/explorer/src/hooks/useRailSettlements.tsx +++ b/apps/explorer/src/hooks/useRailSettlements.tsx @@ -2,7 +2,7 @@ import { ExternalTextLink } from "@filecoin-foundation/ui-filecoin/TextLink/Exte import { useCallback, useEffect, useRef, useState } from "react"; import { toast } from "sonner"; import type { Abi, Hex, TransactionReceipt } from "viem"; -import { useBlockNumber, useWaitForTransactionReceipt, useWriteContract } from "wagmi"; +import { useWaitForTransactionReceipt, useWriteContract } from "wagmi"; import type { TransactionMetadata } from "@/types"; import { formatToken } from "@/utils/formatter"; import { getToastContent } from "@/utils/toast"; @@ -24,10 +24,10 @@ interface UseRailSettlementsOptions { export interface SettleRailParams { railId: bigint; - paymentRate: bigint; + untilEpoch: bigint; + settlementAmount: bigint; tokenSymbol: string; tokenDecimals: number; - settledUpto: bigint; } export const useRailSettlements = (options: UseRailSettlementsOptions) => { @@ -36,7 +36,6 @@ export const useRailSettlements = (options: UseRailSettlementsOptions) => { const [settlements, setSettlements] = useState>(new Map()); const [pendingTxHashes, setPendingTxHashes] = useState>(new Set()); - const { data: blockNumber } = useBlockNumber({ watch: true }); const { writeContractAsync } = useWriteContract(); // Watches for a pending transaction receipt. @@ -124,24 +123,13 @@ export const useRailSettlements = (options: UseRailSettlementsOptions) => { const settleRail = useCallback( async (params: SettleRailParams) => { - const { railId, paymentRate, tokenSymbol, tokenDecimals, settledUpto } = params; + const { railId, untilEpoch, settlementAmount, tokenSymbol, tokenDecimals } = params; const railIdStr = railId.toString(); - if (!blockNumber) { - toast.error("Unable to settle", { - description: "Failed to fetch current block number. Please try again.", - }); - return; - } - - const currentEpoch = BigInt(blockNumber); - const epochsSinceLastSettlement = currentEpoch > settledUpto ? currentEpoch - settledUpto : 0n; - const expectedAmount = paymentRate * epochsSinceLastSettlement; - const metadata: TransactionMetadata = { type: "settleRail", railId: railIdStr, - amount: formatToken(expectedAmount, tokenDecimals), + amount: formatToken(settlementAmount, tokenDecimals), token: tokenSymbol, }; @@ -163,7 +151,7 @@ export const useRailSettlements = (options: UseRailSettlementsOptions) => { address: contractAddress, abi, functionName: "settleRail", - args: [railId, blockNumber], + args: [railId, untilEpoch], }); setSettlements((prev) => { @@ -214,7 +202,7 @@ export const useRailSettlements = (options: UseRailSettlementsOptions) => { throw err; } }, - [blockNumber, contractAddress, abi, writeContractAsync], + [contractAddress, abi, writeContractAsync], ); const isSettling = useCallback( diff --git a/apps/explorer/src/services/grapql/queries.ts b/apps/explorer/src/services/grapql/queries.ts index 3fd465fa..0c375f01 100644 --- a/apps/explorer/src/services/grapql/queries.ts +++ b/apps/explorer/src/services/grapql/queries.ts @@ -34,7 +34,7 @@ export const GET_RECENT_ACCOUNTS = gql` export const GET_ACCOUNTS_LEADERBOARD = gql` query GetAccountsLeaderboard($first: Int = 10, $token: String!) { - topEarners: userTokens(orderBy: fundsCollected, orderDirection: desc, first: $first, where:{token: $token}) { + topEarners: userTokens(orderBy: fundsCollected, orderDirection: desc, first: $first, where: { token: $token }) { fundsCollected account { id @@ -46,7 +46,7 @@ export const GET_ACCOUNTS_LEADERBOARD = gql` decimals } } - topSpenders: userTokens(orderBy: payout, orderDirection: desc, first: $first, where:{token: $token}) { + topSpenders: userTokens(orderBy: payout, orderDirection: desc, first: $first, where: { token: $token }) { payout account { id @@ -148,7 +148,7 @@ export const GET_ACCOUNTS_PAGINATED = gql` totalRails totalTokens totalApprovals - userTokens(where: {token: $token}) { + userTokens(where: { token: $token }) { payout fundsCollected token { @@ -455,6 +455,12 @@ export const GET_ACCOUNT_RAILS = gql` totalOneTimePaymentAmount lockupPeriod settledUpto + endEpoch + # If the latest positive-rate segment is settled, every older segment is settled too. + rateChangeQueue(first: 1, where: { rate_gt: 0 }, orderBy: untilEpoch, orderDirection: desc) { + rate + untilEpoch + } createdAt payer { id diff --git a/apps/explorer/src/utils/formatter.ts b/apps/explorer/src/utils/formatter.ts index e820c467..9da93dbd 100644 --- a/apps/explorer/src/utils/formatter.ts +++ b/apps/explorer/src/utils/formatter.ts @@ -29,6 +29,26 @@ export function formatToken( return `${formatCompactNumber(unitValue, decimals)} ${symbol}`; } +/** + * Formats a token amount by truncating to `decimals` fractional digits, computed exactly via + * BigInt division. Unlike `formatToken`, this never rounds up through a float `Number()` + * conversion, so the display can't show more than the underlying amount actually is. + */ +export function formatTokenTruncated( + value: bigint, + tokenDecimals: number | bigint, + symbol: string = "", + decimals: number = 2, +): string { + const negative = value < 0n; + const absValue = negative ? -value : value; + const divisor = 10n ** BigInt(tokenDecimals); + const whole = absValue / divisor; + const fraction = (absValue % divisor).toString().padStart(Number(tokenDecimals), "0").slice(0, decimals); + const amount = `${whole}.${fraction.padEnd(decimals, "0")}`.replace(/(\.\d*?[1-9])0+$|\.0+$/, "$1"); + return `${negative ? "-" : ""}${amount} ${symbol}`.trim(); +} + export const formatFIL = (attoFil: string | bigint) => { if (!attoFil || attoFil === "0") return "0 FIL"; diff --git a/apps/explorer/src/utils/railSettlement.ts b/apps/explorer/src/utils/railSettlement.ts new file mode 100644 index 00000000..18ed6e49 --- /dev/null +++ b/apps/explorer/src/utils/railSettlement.ts @@ -0,0 +1,58 @@ +import type { Rail } from "@filecoin-pay/types"; + +type RailSettlementState = Pick; + +export type RailSettlementEligibility = + | { status: "allowed"; untilEpoch: bigint } + | { status: "finalized" } + | { status: "current-epoch-unavailable" } + | { status: "settled" } + | { status: "paused-without-payments" }; + +/** Returns the epoch settlement should target, clamped to `endEpoch` once the rail has terminated. */ +export function getSettlementUntilEpoch(endEpoch: bigint, currentEpoch: bigint): bigint { + return endEpoch > 0n && endEpoch < currentEpoch ? endEpoch : currentEpoch; +} + +/** Returns the number of epochs since the rail was last settled, up to `untilEpoch`. */ +export function getUnsettledEpochs(rail: Pick, untilEpoch: bigint | undefined): bigint { + if (untilEpoch === undefined) return 0n; + + const settledUpto = BigInt(rail.settledUpto); + return untilEpoch > settledUpto ? untilEpoch - settledUpto : 0n; +} + +/** Returns whether a rail can be settled and, if not, why. */ +export function getRailSettlementEligibility( + rail: RailSettlementState, + currentEpoch: bigint | undefined, +): RailSettlementEligibility { + if (rail.state === "FINALIZED") return { status: "finalized" }; + if (currentEpoch === undefined) return { status: "current-epoch-unavailable" }; + + const untilEpoch = getSettlementUntilEpoch(BigInt(rail.endEpoch), currentEpoch); + if (getUnsettledEpochs(rail, untilEpoch) === 0n) return { status: "settled" }; + if (rail.state !== "ZERORATE") return { status: "allowed", untilEpoch }; + + const settledUpto = BigInt(rail.settledUpto); + const hasUnsettledPayments = rail.rateChangeQueue.some( + (rateChange) => BigInt(rateChange.rate) > 0n && BigInt(rateChange.untilEpoch) > settledUpto, + ); + + return hasUnsettledPayments ? { status: "allowed", untilEpoch } : { status: "paused-without-payments" }; +} + +export function getRailSettlementUnavailableReason(eligibility: RailSettlementEligibility): string { + switch (eligibility.status) { + case "finalized": + return "Rail is finalized and cannot be settled."; + case "current-epoch-unavailable": + return "Failed to fetch the current epoch. Please try again."; + case "settled": + return "Rail has no unsettled payments."; + case "paused-without-payments": + return "Paused rail has no unsettled payments."; + case "allowed": + return ""; + } +} From 2217a2d9c1047e3d0aa08baf8322d56f3e9bc26d Mon Sep 17 00:00:00 2001 From: Puspendra Mahariya <95584952+silent-cipher@users.noreply.github.com> Date: Thu, 20 Aug 2026 22:54:38 +0530 Subject: [PATCH 02/10] fix: use NetworkLink with StyledLink (#324) --- apps/explorer/src/components/Home/TopAccounts/index.tsx | 6 +++--- apps/explorer/src/components/Home/TopOperators/index.tsx | 6 +++--- apps/explorer/src/components/Operator/OperatorRails.tsx | 6 ++++-- 3 files changed, 10 insertions(+), 8 deletions(-) diff --git a/apps/explorer/src/components/Home/TopAccounts/index.tsx b/apps/explorer/src/components/Home/TopAccounts/index.tsx index 7c4d466d..d6592551 100644 --- a/apps/explorer/src/components/Home/TopAccounts/index.tsx +++ b/apps/explorer/src/components/Home/TopAccounts/index.tsx @@ -5,7 +5,7 @@ import { LoadingStateCard } from "@filecoin-foundation/ui-filecoin/LoadingStateC import { PageSection } from "@filecoin-foundation/ui-filecoin/PageSection"; import { RefreshOverlay } from "@filecoin-foundation/ui-filecoin/RefreshOverlay"; import { AlertCircle, SearchIcon } from "lucide-react"; -import { StyledLink } from "@/components/shared"; +import { NetworkLink, StyledLink } from "@/components/shared"; import { calibration, mainnet } from "@/constants/chains"; import useAccountsLeaderboard from "@/hooks/useAccountsLeaderboard"; import useNetwork from "@/hooks/useNetwork"; @@ -24,8 +24,8 @@ const TopAccounts = () => {

Accounts Leaderboards

- - View All + + View All
diff --git a/apps/explorer/src/components/Home/TopOperators/index.tsx b/apps/explorer/src/components/Home/TopOperators/index.tsx index 4dc56707..95d99fe5 100644 --- a/apps/explorer/src/components/Home/TopOperators/index.tsx +++ b/apps/explorer/src/components/Home/TopOperators/index.tsx @@ -6,7 +6,7 @@ import { LoadingStateCard } from "@filecoin-foundation/ui-filecoin/LoadingStateC import { PageSection } from "@filecoin-foundation/ui-filecoin/PageSection"; import { RefreshOverlay } from "@filecoin-foundation/ui-filecoin/RefreshOverlay"; import { AlertCircle, SearchIcon } from "lucide-react"; -import { StyledLink } from "@/components/shared"; +import { NetworkLink, StyledLink } from "@/components/shared"; import { calibration, mainnet } from "@/constants/chains"; import useNetwork from "@/hooks/useNetwork"; import useOperatorsLeaderboard from "@/hooks/useOperatorsLeaderboard"; @@ -22,8 +22,8 @@ const TopOperators = () => {

Services Leaderboard

- - View All + + View All
diff --git a/apps/explorer/src/components/Operator/OperatorRails.tsx b/apps/explorer/src/components/Operator/OperatorRails.tsx index df651b3b..53ab0206 100644 --- a/apps/explorer/src/components/Operator/OperatorRails.tsx +++ b/apps/explorer/src/components/Operator/OperatorRails.tsx @@ -15,7 +15,7 @@ import { AlertCircle } from "lucide-react"; import { useState } from "react"; import { useOperatorRails } from "@/hooks/useOperatorDetails"; import { formatDate, formatToken } from "@/utils/formatter"; -import { CopyableText, RailStateBadge, StyledLink } from "../shared"; +import { CopyableText, NetworkLink, RailStateBadge, StyledLink } from "../shared"; interface OperatorRailsProps { operator: Operator; @@ -106,7 +106,9 @@ export const OperatorRails: React.FC = ({ operator }) => { {data.rails.map((rail) => ( - {rail.railId.toString()} + + {rail.railId.toString()} + Date: Fri, 21 Aug 2026 17:11:56 -0300 Subject: [PATCH 03/10] feat(console): add dedicated shell layout (#323) * feat(console): add dedicated shell layout Move the console pages into a (console) route group whose layout owns ConsoleProviders, the console header, and the wallet-connection gating, so pages no longer repeat that setup. URLs are unchanged. Suppress the global site navigation on /console* via ConditionalNavigation. The footer still renders on console pages. Keep notifications/verify outside the route group: it is an email link target and must render for a disconnected user. It shares ConsoleHeader, which stays free of wagmi so it can render without providers. * fix: verify page footer --- .../src/app/console/(console)/layout.tsx | 105 ++++++++++ .../{ => (console)}/notifications/page.tsx | 78 ++++---- .../src/app/console/(console)/page.tsx | 70 +++++++ .../app/console/notifications/verify/page.tsx | 189 +++++++++--------- apps/explorer/src/app/console/page.tsx | 112 ----------- .../components/UserConsole/ConsoleHeader.tsx | 31 +++ .../shared/ConditionalNavigation.tsx | 20 ++ .../src/components/shared/SiteLayout.tsx | 4 +- apps/explorer/src/components/shared/index.ts | 2 + 9 files changed, 361 insertions(+), 250 deletions(-) create mode 100644 apps/explorer/src/app/console/(console)/layout.tsx rename apps/explorer/src/app/console/{ => (console)}/notifications/page.tsx (90%) create mode 100644 apps/explorer/src/app/console/(console)/page.tsx delete mode 100644 apps/explorer/src/app/console/page.tsx create mode 100644 apps/explorer/src/components/UserConsole/ConsoleHeader.tsx create mode 100644 apps/explorer/src/components/shared/ConditionalNavigation.tsx diff --git a/apps/explorer/src/app/console/(console)/layout.tsx b/apps/explorer/src/app/console/(console)/layout.tsx new file mode 100644 index 00000000..4546fb02 --- /dev/null +++ b/apps/explorer/src/app/console/(console)/layout.tsx @@ -0,0 +1,105 @@ +"use client"; +import { Container } from "@filecoin-foundation/ui-filecoin/Container"; +import { AlertTriangle } from "lucide-react"; +import type { ReactNode } from "react"; +import { useConnection } from "wagmi"; +import Balance from "@/components/shared/Balance"; +import ChainSwitcher from "@/components/shared/ChainSwitcher"; +import { BetaWarning } from "@/components/UserConsole/BetaWarning"; +import { ConsoleHeader } from "@/components/UserConsole/ConsoleHeader"; +import ConsoleProviders from "@/components/UserConsole/ConsoleProviders"; +import { NotConnected, UnsupportedChain } from "@/components/UserConsole/States"; +import { isSupportedChainId } from "@/utils/network"; + +type ConsoleAccessState = "not-connected" | "unsupported-chain" | "ready"; + +const getConsoleAccessState = ({ + isConnected, + hasAddress, + chainId, +}: { + isConnected: boolean; + hasAddress: boolean; + chainId: number | undefined; +}): ConsoleAccessState => { + if (!isConnected || !hasAddress) { + return "not-connected"; + } + + if (chainId !== undefined && !isSupportedChainId(chainId)) { + return "unsupported-chain"; + } + + return "ready"; +}; + +const ConsoleWalletControls = ({ + accessState, + chainId, +}: { + accessState: ConsoleAccessState; + chainId: number | undefined; +}) => { + switch (accessState) { + case "not-connected": + return null; + case "unsupported-chain": + return ( + + + Unsupported Network + + ); + case "ready": + return ( + <> + + {chainId !== undefined ? : null} + + ); + } +}; + +const ConsoleAccessGate = ({ accessState, children }: { accessState: ConsoleAccessState; children: ReactNode }) => { + switch (accessState) { + case "not-connected": + return ; + case "unsupported-chain": + return ; + case "ready": + return children; + } +}; + +const ConsoleShell = ({ children }: { children: ReactNode }) => { + const { address, isConnected, chainId } = useConnection(); + const accessState = getConsoleAccessState({ + isConnected, + hasAddress: Boolean(address), + chainId, + }); + + return ( +
+ } /> + +
+ +
+ + {children} +
+
+
+
+ ); +}; + +// Kept separate from ConsoleShell: a component can't mount a provider and read from it. +const ConsoleLayout = ({ children }: { children: ReactNode }) => ( + + {children} + +); + +export default ConsoleLayout; diff --git a/apps/explorer/src/app/console/notifications/page.tsx b/apps/explorer/src/app/console/(console)/notifications/page.tsx similarity index 90% rename from apps/explorer/src/app/console/notifications/page.tsx rename to apps/explorer/src/app/console/(console)/notifications/page.tsx index 8197fe3d..6807dc48 100644 --- a/apps/explorer/src/app/console/notifications/page.tsx +++ b/apps/explorer/src/app/console/(console)/notifications/page.tsx @@ -1,7 +1,6 @@ "use client"; import { Button } from "@filecoin-foundation/ui-filecoin/Button"; import { EmptyStateCard } from "@filecoin-foundation/ui-filecoin/EmptyStateCard"; -import { PageSection } from "@filecoin-foundation/ui-filecoin/PageSection"; import { WarningCircleIcon } from "@phosphor-icons/react"; import { useMutation } from "@tanstack/react-query"; import { ArrowLeft, ChevronRight, WifiOff } from "lucide-react"; @@ -11,7 +10,6 @@ import { useCallback, useEffect, useRef, useState } from "react"; import { BaseError, UserRejectedRequestError } from "viem"; import { createSiweMessage, generateSiweNonce } from "viem/siwe"; import { useConnection, useSignMessage } from "wagmi"; -import ConsoleProviders from "@/components/UserConsole/ConsoleProviders"; import { AlertsActiveCard, AlertsOffCard, @@ -22,7 +20,6 @@ import { PendingVerificationCard, UnsubscribeDialog, } from "@/components/UserConsole/NotificationsSection/components"; -import { NotConnected } from "@/components/UserConsole/States"; import { useNotificationStatus } from "@/hooks/useNotificationStatus"; import { getNetworkFromChainId, @@ -415,7 +412,7 @@ const NotificationsContent = ({ } }; -const NotificationsMain = () => { +const NotificationsPage = () => { const { address, isConnected, chainId } = useConnection(); const walletNetwork = getNetworkFromChainId(chainId); const isEligibleNetwork = isSupportedChainId(chainId) && isNotificationsEligibleNetwork(walletNetwork); @@ -425,8 +422,8 @@ const NotificationsMain = () => { const showUpdateEmail = viewType === "subscribed" && isConnected && !!address && isEligibleNetwork; function renderContent() { - if (!isConnected || !address) return ; - if (chainId === undefined) return null; + // Connection gating lives in the console layout; by here the wallet is connected. + if (!address || chainId === undefined) return null; if (!isEligibleNetwork) return ; return ( { } return ( - -
-
- - - Back to console - -
-

- Email alerts -

- {showUpdateEmail && ( - - )} -
-

- Receive alerts when your account has less than 30 days of service runway remaining, so you can top up before - services are affected. -

+
+
+ + + Back to console + + +
+

+ Email alerts +

+ {showUpdateEmail ? ( + + ) : null}
- -
{renderContent()}
+

+ Receive alerts when your account has less than 30 days of service runway remaining, so you can top up before + services are affected. +

- + +
{renderContent()}
+
); }; -const NotificationsPage = () => ( - - - -); - export default NotificationsPage; diff --git a/apps/explorer/src/app/console/(console)/page.tsx b/apps/explorer/src/app/console/(console)/page.tsx new file mode 100644 index 00000000..e735e9d2 --- /dev/null +++ b/apps/explorer/src/app/console/(console)/page.tsx @@ -0,0 +1,70 @@ +"use client"; +import { LoadingStateCard } from "@filecoin-foundation/ui-filecoin/LoadingStateCard"; +import type { Account } from "@filecoin-pay/types"; +import { useConnection } from "wagmi"; +import { AlertsBanner, FundsSection, OperatorApprovalsSection, RailsSection } from "@/components/UserConsole"; +import { AccountNotFound, ErrorState } from "@/components/UserConsole/States"; +import { useAccountDetails } from "@/hooks/useAccountDetails"; +import { useNotificationStatus } from "@/hooks/useNotificationStatus"; +import { getNetworkFromChainId, isNotificationsEligibleNetwork } from "@/utils/network"; + +type AccountSectionsProps = { + account: Account | null | undefined; + // React Query guarantees error is non-null exactly when the query has failed, + // so it doubles as the error flag. + error: Error | null; + isLoading: boolean; + subscribed: boolean; + userAddress: string; +}; + +const AccountSections = ({ account, error, isLoading, subscribed, userAddress }: AccountSectionsProps) => { + if (isLoading) { + return ; + } + + if (!account) { + return error ? : ; + } + + return ( + <> + + + + + {/* A failed background refetch still leaves the last good account on screen. */} + {error ? : null} + + ); +}; + +const UserConsole = () => { + const { address, chainId } = useConnection(); + const walletNetwork = getNetworkFromChainId(chainId); + + const { data: notificationStatus, isError: isNotificationStatusError } = useNotificationStatus(address); + const isSubscribed = notificationStatus?.subscribed === true; + const showAlertsBanner = isNotificationsEligibleNetwork(walletNetwork) && !isSubscribed && !isNotificationStatusError; + + const accountQuery = useAccountDetails(address ?? "", { networkOverride: walletNetwork }); + + return ( +
+ {showAlertsBanner ? : null} + + {/* The (console) layout gates on a connected wallet, so address is set here. */} + {address ? ( + + ) : null} +
+ ); +}; + +export default UserConsole; diff --git a/apps/explorer/src/app/console/notifications/verify/page.tsx b/apps/explorer/src/app/console/notifications/verify/page.tsx index e110ea80..9687463e 100644 --- a/apps/explorer/src/app/console/notifications/verify/page.tsx +++ b/apps/explorer/src/app/console/notifications/verify/page.tsx @@ -1,5 +1,5 @@ "use client"; -import { PageSection } from "@filecoin-foundation/ui-filecoin/PageSection"; +import { Container } from "@filecoin-foundation/ui-filecoin/Container"; import { Card } from "@filecoin-pay/ui/components/card"; import { useQueryClient } from "@tanstack/react-query"; import { AlertCircle, CheckCircle2, Clock, Link2Off, Loader2 } from "lucide-react"; @@ -7,6 +7,7 @@ import Link from "next/link"; import { useSearchParams } from "next/navigation"; import type { ReactNode } from "react"; import { Suspense, useCallback, useEffect, useRef, useState } from "react"; +import { ConsoleHeader } from "@/components/UserConsole/ConsoleHeader"; const API_URL = process.env.NEXT_PUBLIC_NOTIFICATIONS_API_URL; @@ -158,106 +159,110 @@ const VerifyContent = () => { }, [verifyState.type]); return ( - -
- - {verifyState.type === "loading" && } +
+ + {verifyState.type === "loading" && } - {verifyState.type === "success" && ( - } - color='green' - title='Alerts are now on' - description="Your email has been verified. We'll notify you when this account has less than 30 days of service runway remaining." - actions={ -
- - Back to Console - - - Manage alerts - -
- } - /> - )} + {verifyState.type === "success" && ( + } + color='green' + title='Alerts are now on' + description="Your email has been verified. We'll notify you when this account has less than 30 days of service runway remaining." + actions={ +
+ + Back to Console + + + Manage alerts + +
+ } + /> + )} - {verifyState.type === "not-found" && ( - } - color='amber' - title='This verification link is no longer available' - description='It may have expired or already been used. Return to alert settings to request a new verification email.' - actions={} - /> - )} + {verifyState.type === "not-found" && ( + } + color='amber' + title='This verification link is no longer available' + description='It may have expired or already been used. Return to alert settings to request a new verification email.' + actions={} + /> + )} - {verifyState.type === "rate-limited" && ( - } - color='amber' - title='Too many attempts' - description='Please wait a few minutes before trying again, then return to alert settings to request a new verification email.' - actions={} - /> - )} + {verifyState.type === "rate-limited" && ( + } + color='amber' + title='Too many attempts' + description='Please wait a few minutes before trying again, then return to alert settings to request a new verification email.' + actions={} + /> + )} - {verifyState.type === "error" && ( - } - color='red' - title="We couldn't verify your email" - description={ - verifyState.kind === "network" - ? "Check your internet connection and try again." - : "Something went wrong on our end. Try again in a moment, or return to alert settings to request a new verification email." - } - detail={verifyState.detail} - actions={ -
- - -
- } - /> - )} + {verifyState.type === "error" && ( + } + color='red' + title="We couldn't verify your email" + description={ + verifyState.kind === "network" + ? "Check your internet connection and try again." + : "Something went wrong on our end. Try again in a moment, or return to alert settings to request a new verification email." + } + detail={verifyState.detail} + actions={ +
+ + +
+ } + /> + )} - {verifyState.type === "missing-params" && ( - } - color='amber' - title='This verification link is incomplete' - description="We couldn't find the information needed to verify your email. Return to alert settings to request a new verification email." - actions={} - /> - )} -
-
- + {verifyState.type === "missing-params" && ( + } + color='amber' + title='This verification link is incomplete' + description="We couldn't find the information needed to verify your email. Return to alert settings to request a new verification email." + actions={} + /> + )} +
+
); }; const VerifyPage = () => ( - - - - -
- } - > - - +
+ + +
+ + + + + } + > + + + +
+
); export default VerifyPage; diff --git a/apps/explorer/src/app/console/page.tsx b/apps/explorer/src/app/console/page.tsx deleted file mode 100644 index 9e0096db..00000000 --- a/apps/explorer/src/app/console/page.tsx +++ /dev/null @@ -1,112 +0,0 @@ -"use client"; -import { LoadingStateCard } from "@filecoin-foundation/ui-filecoin/LoadingStateCard"; -import { PageSection } from "@filecoin-foundation/ui-filecoin/PageSection"; -import { AlertTriangle } from "lucide-react"; -import { useMemo } from "react"; -import { useConnection } from "wagmi"; -import { Balance, ChainSwitcher } from "@/components/shared"; -import { - AlertsBanner, - BetaWarning, - FundsSection, - OperatorApprovalsSection, - RailsSection, -} from "@/components/UserConsole"; -import ConsoleProviders from "@/components/UserConsole/ConsoleProviders"; -import { AccountNotFound, ErrorState, NotConnected, UnsupportedChain } from "@/components/UserConsole/States"; -import { useAccountDetails } from "@/hooks/useAccountDetails"; -import { useNotificationStatus } from "@/hooks/useNotificationStatus"; -import { getNetworkFromChainId, isNotificationsEligibleNetwork, isSupportedChainId } from "@/utils/network"; - -const UserConsoleContent = () => { - const { address, isConnected, chainId } = useConnection(); - const walletNetwork = useMemo(() => getNetworkFromChainId(chainId), [chainId]); - const isUnsupportedChain = isConnected && chainId && !isSupportedChainId(chainId); - - const isNotificationsEligible = isNotificationsEligibleNetwork(walletNetwork); - - const { data: notificationStatus, isError: notificationStatusError } = useNotificationStatus(address); - const subscribed = notificationStatus?.subscribed ?? false; - - const { - data: account, - isLoading, - isError, - error, - } = useAccountDetails(address || "", { networkOverride: walletNetwork }); - - return ( - -
-
-

- Filecoin Pay Console -

- {isConnected && ( -
- {isUnsupportedChain ? ( - - - Unsupported Network - - ) : ( - <> - - {chainId && } - - )} -
- )} -
- - {/* Beta Warning */} - - - {/* Alerts Banner — hidden once subscribed */} - {isConnected && !isUnsupportedChain && isNotificationsEligible && !subscribed && !notificationStatusError && ( -
- -
- )} - - {/* Not Connected */} - {(!isConnected || !address) && } - - {/* Unsupported Chain */} - {isUnsupportedChain && } - - {/* Only show content if connected to supported chain */} - {isConnected && !isUnsupportedChain && ( - <> - {/* Loading */} - {isLoading && } - - {/* Account Not Found */} - {!isError && !isLoading && !account && } - - {!isLoading && address && account && ( - <> - - - - - )} - - {/* Error */} - {isError && } - - )} -
-
- ); -}; - -const UserConsole = () => { - return ( - - - - ); -}; - -export default UserConsole; diff --git a/apps/explorer/src/components/UserConsole/ConsoleHeader.tsx b/apps/explorer/src/components/UserConsole/ConsoleHeader.tsx new file mode 100644 index 00000000..85eb12a6 --- /dev/null +++ b/apps/explorer/src/components/UserConsole/ConsoleHeader.tsx @@ -0,0 +1,31 @@ +import { Container } from "@filecoin-foundation/ui-filecoin/Container"; +import Link from "next/link"; +import type { ReactNode } from "react"; +import Logo from "@/public/foc-logo-dark.svg"; + +type ConsoleHeaderProps = { + /** + * Wallet controls for the right-hand side. Only the gated console shell passes + * these — the header itself must render without wagmi/RainbowKit providers so + * ungated pages (e.g. the email verification landing page) can reuse it. + */ + walletControls?: ReactNode; +}; + +export const ConsoleHeader = ({ walletControls }: ConsoleHeaderProps) => ( +
+ +
+ + + + + {walletControls ? ( +
+ {walletControls} +
+ ) : null} +
+
+
+); diff --git a/apps/explorer/src/components/shared/ConditionalNavigation.tsx b/apps/explorer/src/components/shared/ConditionalNavigation.tsx new file mode 100644 index 00000000..73ade611 --- /dev/null +++ b/apps/explorer/src/components/shared/ConditionalNavigation.tsx @@ -0,0 +1,20 @@ +"use client"; + +import { usePathname } from "next/navigation"; +import Navigation from "./Navigation/Navigation"; + +// Console routes render their own header (see UserConsole/ConsoleHeader), so the +// global site navigation is suppressed there. Every other route is unaffected. +function isConsoleRoute(pathname: string | null): boolean { + return pathname === "/console" || Boolean(pathname?.startsWith("/console/")); +} + +function ConditionalNavigation() { + const pathname = usePathname(); + + if (isConsoleRoute(pathname)) return null; + + return ; +} + +export default ConditionalNavigation; diff --git a/apps/explorer/src/components/shared/SiteLayout.tsx b/apps/explorer/src/components/shared/SiteLayout.tsx index 4382b5de..75f9274f 100644 --- a/apps/explorer/src/components/shared/SiteLayout.tsx +++ b/apps/explorer/src/components/shared/SiteLayout.tsx @@ -3,7 +3,7 @@ import localFont from "next/font/local"; import Script from "next/script"; import type { ReactNode } from "react"; -import { Footer, Navigation, Providers } from "@/components/shared"; +import { ConditionalNavigation, Footer, Providers } from "@/components/shared"; const funnelSans = localFont({ src: "../../fonts/Funnel_Sans/FunnelSans[wght].woff2", @@ -42,7 +42,7 @@ function SiteLayout({ children }: SiteLayoutProps) { )} > - +
{children}