From 0a264aab18922c3c01bb860a7b53c7f32be872f9 Mon Sep 17 00:00:00 2001 From: feyyazcigim Date: Tue, 28 Oct 2025 18:59:08 +0300 Subject: [PATCH 01/50] Handle empty switch state --- src/pages/Market.tsx | 15 ++++++------ src/pages/market/MarketModeSelect.tsx | 34 ++++++++++++++++++--------- 2 files changed, 30 insertions(+), 19 deletions(-) diff --git a/src/pages/Market.tsx b/src/pages/Market.tsx index 3973f9f4d..eb9e223f7 100644 --- a/src/pages/Market.tsx +++ b/src/pages/Market.tsx @@ -265,7 +265,7 @@ export function Market() { const handleSecondaryTabClick = useCallback( (v: string) => { if (v === "fill") { - handleChangeTab(!mode || mode === "buy" ? TABLE_SLUGS[1] : TABLE_SLUGS[2]); + handleChangeTab(mode === "buy" ? TABLE_SLUGS[1] : TABLE_SLUGS[2]); } }, [mode], @@ -282,7 +282,7 @@ export function Market() { event_status: dataPoint?.status?.toLowerCase() ?? "unknown", price_per_pod: dataPoint?.y ?? 0, place_in_line_millions: Math.floor(dataPoint?.x ?? -1), - current_mode: !mode || mode === "buy" ? "buy" : "sell", + current_mode: mode ?? "unknown", }); if (dataPoint.eventType === "LISTING") { @@ -292,8 +292,7 @@ export function Market() { } }; - const viewMode = !mode || mode === "buy" ? "buy" : "sell"; - const fillView = !!id; + const viewMode = mode; return ( <> @@ -343,10 +342,10 @@ export function Market() {
- {viewMode === "buy" && !fillView && } - {viewMode === "buy" && fillView && } - {viewMode === "sell" && !fillView && } - {viewMode === "sell" && fillView && } + {viewMode === "buy" && id === "create" && } + {viewMode === "buy" && id === "fill" && } + {viewMode === "sell" && id === "create" && } + {viewMode === "sell" && id === "fill" && }
diff --git a/src/pages/market/MarketModeSelect.tsx b/src/pages/market/MarketModeSelect.tsx index 49c0d0304..f1eb330f7 100644 --- a/src/pages/market/MarketModeSelect.tsx +++ b/src/pages/market/MarketModeSelect.tsx @@ -14,8 +14,8 @@ export default function MarketModeSelect({ onMainSelectionChange, onSecondarySel const { mode, id } = useParams(); const navigate = useNavigate(); - const mainTab = !mode || mode === "buy" ? "buy" : "sell"; - const secondaryTab = !id || id === "create" ? "create" : "fill"; + const mainTab = mode === "buy" || mode === "sell" ? mode : undefined; + const secondaryTab = id === "fill" ? "fill" : id === "create" ? "create" : undefined; const handleMainChange = useCallback( (v: string) => { @@ -42,7 +42,7 @@ export default function MarketModeSelect({ onMainSelectionChange, onSecondarySel }); if (v === "create") { - navigate(`/market/pods/${mainTab}`); + navigate(`/market/pods/${mainTab}/create`); } else if (v === "fill") { navigate(`/market/pods/${mainTab}/fill`); } @@ -53,19 +53,31 @@ export default function MarketModeSelect({ onMainSelectionChange, onSecondarySel return (
- + Buy Pods Sell Pods - - - - {mainTab === "buy" ? "Order" : "List"} - Fill - - + {mainTab ? ( + <> + + + + {mainTab === "buy" ? "Order" : "List"} + Fill + + + + ) : ( +
+
+ Select Buy Pods +
+ or Sell Pods +
+
+ )}
); } From 3730250ab5a1c4eeb04bb93e8fa3cbb8efb89ea8 Mon Sep 17 00:00:00 2001 From: feyyazcigim Date: Wed, 29 Oct 2025 02:13:06 +0300 Subject: [PATCH 02/50] Add sell action empty state --- src/pages/market/MarketModeSelect.tsx | 31 +++++++++++++++++++++------ 1 file changed, 24 insertions(+), 7 deletions(-) diff --git a/src/pages/market/MarketModeSelect.tsx b/src/pages/market/MarketModeSelect.tsx index f1eb330f7..9824c08c9 100644 --- a/src/pages/market/MarketModeSelect.tsx +++ b/src/pages/market/MarketModeSelect.tsx @@ -1,6 +1,7 @@ import { Separator } from "@/components/ui/Separator"; import { Tabs, TabsList, TabsTrigger } from "@/components/ui/Tabs"; import { ANALYTICS_EVENTS } from "@/constants/analytics-events"; +import { useFarmerField } from "@/state/useFarmerField"; import { trackSimpleEvent } from "@/utils/analytics"; import { useCallback } from "react"; import { useNavigate, useParams, useSearchParams } from "react-router-dom"; @@ -13,9 +14,11 @@ interface MarketModeSelectProps { export default function MarketModeSelect({ onMainSelectionChange, onSecondarySelectionChange }: MarketModeSelectProps) { const { mode, id } = useParams(); const navigate = useNavigate(); + const farmerField = useFarmerField(); const mainTab = mode === "buy" || mode === "sell" ? mode : undefined; const secondaryTab = id === "fill" ? "fill" : id === "create" ? "create" : undefined; + const hasNoPods = farmerField.plots.length === 0; const handleMainChange = useCallback( (v: string) => { @@ -61,13 +64,27 @@ export default function MarketModeSelect({ onMainSelectionChange, onSecondarySel {mainTab ? ( <> - - - - {mainTab === "buy" ? "Order" : "List"} - Fill - - + {mainTab === "sell" && hasNoPods ? ( + <> + +
+
+ You have no Pods. You can get Pods by placing a bid on the Field or selecting{" "} + Buy Pods! +
+
+ + ) : ( + <> + + + + {mainTab === "buy" ? "Order" : "List"} + Fill + + + + )} ) : (
From fb570dcfdc3bf518d0f60271b81de55d536193ed Mon Sep 17 00:00:00 2001 From: feyyazcigim Date: Wed, 29 Oct 2025 18:00:23 +0300 Subject: [PATCH 03/50] Add PodLineGraph component --- src/components/PodLineGraph.tsx | 479 +++++++++++++++++++++ src/pages/market/actions/CreateListing.tsx | 32 +- 2 files changed, 510 insertions(+), 1 deletion(-) create mode 100644 src/components/PodLineGraph.tsx diff --git a/src/components/PodLineGraph.tsx b/src/components/PodLineGraph.tsx new file mode 100644 index 000000000..59fc247fc --- /dev/null +++ b/src/components/PodLineGraph.tsx @@ -0,0 +1,479 @@ +import { TokenValue } from "@/classes/TokenValue"; +import { PODS } from "@/constants/internalTokens"; +import { useFarmerField } from "@/state/useFarmerField"; +import { useHarvestableIndex, usePodIndex } from "@/state/useFieldData"; +import { formatter } from "@/utils/format"; +import { Plot } from "@/utils/types"; +import { cn } from "@/utils/utils"; +import { useMemo, useState } from "react"; + +// Layout constants +const HARVESTED_WIDTH_PERCENT = 20; +const PODLINE_WIDTH_PERCENT = 80; +const MIN_PLOT_WIDTH_PERCENT = 0.3; // Minimum plot width for clickability +const MAX_GAP_TO_COMBINE = TokenValue.fromHuman("1000000", PODS.decimals); // Combine plots within 1M gap for visual grouping + +interface CombinedPlot { + startIndex: TokenValue; + endIndex: TokenValue; + totalPods: TokenValue; + plots: Plot[]; + isHarvestable: boolean; + isSelected: boolean; +} + +interface PodLineGraphProps { + /** Optional: provide specific plots (if not provided, uses all farmer plots) */ + plots?: Plot[]; + /** Indices of selected plots */ + selectedPlotIndices?: string[]; + /** Callback when a plot group is clicked - receives all plot indices in the group */ + onPlotGroupSelect?: (plotIndices: string[]) => void; + /** Additional CSS classes */ + className?: string; +} + +/** + * Groups nearby plots for visual display while keeping each plot individually interactive + */ +function combinePlots( + plots: Plot[], + harvestableIndex: TokenValue, + selectedIndices: Set, +): CombinedPlot[] { + if (plots.length === 0) return []; + + // Sort plots by index + const sortedPlots = [...plots].sort((a, b) => a.index.sub(b.index).toNumber()); + + const combined: CombinedPlot[] = []; + let currentGroup: Plot[] = []; + + for (let i = 0; i < sortedPlots.length; i++) { + const plot = sortedPlots[i]; + const nextPlot = sortedPlots[i + 1]; + + currentGroup.push(plot); + + if (nextPlot) { + // Calculate gap between this plot's end and next plot's start + const gap = nextPlot.index.sub(plot.index.add(plot.pods)); + + // If gap is small enough, continue grouping + if (gap.lt(MAX_GAP_TO_COMBINE)) { + continue; + } + } + + // Finalize current group (gap is too large or it's the last plot) + if (currentGroup.length > 0) { + const startIndex = currentGroup[0].index; + const lastPlot = currentGroup[currentGroup.length - 1]; + const endIndex = lastPlot.index.add(lastPlot.pods); + const totalPods = currentGroup.reduce((sum, p) => sum.add(p.pods), TokenValue.ZERO); + + // Check if any plot in group is harvestable or selected + const isHarvestable = currentGroup.some( + (p) => p.harvestablePods?.gt(0) || endIndex.lte(harvestableIndex), + ); + const isSelected = currentGroup.some((p) => selectedIndices.has(p.index.toHuman())); + + combined.push({ + startIndex, + endIndex, + totalPods, + plots: currentGroup, + isHarvestable, + isSelected, + }); + + currentGroup = []; + } + } + + return combined; +} + +/** + * Generates nice axis labels at 10M intervals + */ +function generateAxisLabels(min: number, max: number): number[] { + const INTERVAL = 10_000_000; // 10M + const labels: number[] = []; + + // Start from 0 or the first 10M multiple + const start = Math.floor(min / INTERVAL) * INTERVAL; + + for (let value = start; value <= max; value += INTERVAL) { + if (value >= min) { + labels.push(value); + } + } + + return labels; +} + +/** + * Generates logarithmic grid points for harvested section + * Creates 2-3 evenly distributed points in log space + */ +function generateLogGridPoints(maxValue: number): number[] { + if (maxValue <= 0) return []; + + const gridPoints: number[] = []; + const million = 1_000_000; + const minValue = maxValue / 10; + + // For values less than 10M, use simple 1M, 2M, 5M pattern + if (maxValue <= 10 * million) { + if (maxValue > 1 * million && 1 * million > minValue) gridPoints.push(1 * million); + if (maxValue > 2 * million && 2 * million > minValue) gridPoints.push(2 * million); + if (maxValue > 5 * million && 5 * million > minValue) gridPoints.push(5 * million); + return gridPoints; + } + + // For larger values, use powers of 10 + let power = million; + while (power < maxValue) { + if (power > minValue) gridPoints.push(power); + const next2 = power * 2; + const next5 = power * 5; + if (next2 < maxValue && next2 > minValue) gridPoints.push(next2); + if (next5 < maxValue && next5 > minValue) gridPoints.push(next5); + power *= 10; + } + + return gridPoints.sort((a, b) => a - b); +} + +/** + * Formats large numbers for axis labels (e.g., 1000000 -> "1M") + */ +function formatAxisLabel(value: number): string { + if (value >= 1_000_000) { + return `${(value / 1_000_000).toFixed(0)}M`; + } + if (value >= 1_000) { + return `${(value / 1_000).toFixed(0)}K`; + } + return value.toFixed(0); +} + +export default function PodLineGraph({ + plots: providedPlots, + selectedPlotIndices = [], + onPlotGroupSelect, + className, +}: PodLineGraphProps) { + const farmerField = useFarmerField(); + const harvestableIndex = useHarvestableIndex(); + const podIndex = usePodIndex(); + + const [hoveredPlotIndex, setHoveredPlotIndex] = useState(null); + + // Use provided plots or default to farmer's plots + const plots = providedPlots ?? farmerField.plots; + + // Calculate pod line (total unharvested pods) + const podLine = podIndex.sub(harvestableIndex); + + // Selected indices set for quick lookup + const selectedSet = useMemo(() => new Set(selectedPlotIndices), [selectedPlotIndices]); + + // Combine plots for visualization + const combinedPlots = useMemo( + () => combinePlots(plots, harvestableIndex, selectedSet), + [plots, harvestableIndex, selectedSet], + ); + + // Separate harvested and unharvested plots + const { harvestedPlots, unharvestedPlots } = useMemo(() => { + const harvested: CombinedPlot[] = []; + const unharvested: CombinedPlot[] = []; + + combinedPlots.forEach((plot) => { + if (plot.endIndex.lte(harvestableIndex)) { + // Fully harvested + harvested.push(plot); + } else if (plot.startIndex.lt(harvestableIndex)) { + // Partially harvested - split it + const harvestedAmount = harvestableIndex.sub(plot.startIndex); + const unharvestedAmount = plot.endIndex.sub(harvestableIndex); + + harvested.push({ + ...plot, + endIndex: harvestableIndex, + totalPods: harvestedAmount, + isHarvestable: true, + }); + + unharvested.push({ + ...plot, + startIndex: harvestableIndex, + totalPods: unharvestedAmount, + }); + } else { + // Fully unharvested + unharvested.push(plot); + } + }); + + return { harvestedPlots: harvested, unharvestedPlots: unharvested }; + }, [combinedPlots, harvestableIndex]); + + // Calculate max harvested index for log scale + const maxHarvestedIndex = harvestableIndex.gt(0) ? harvestableIndex.toNumber() : 1; + + // Check if there are any harvested plots + const hasHarvestedPlots = harvestedPlots.length > 0; + + // Adjust width percentages based on whether we have harvested plots + const harvestedWidthPercent = hasHarvestedPlots ? HARVESTED_WIDTH_PERCENT : 0; + const podlineWidthPercent = hasHarvestedPlots ? PODLINE_WIDTH_PERCENT : 100; + + return ( +
+ {/* Plot container with border */} +
+
+ {/* Harvested Section (Log Scale) - Left 20% (only shown if there are harvested plots) */} + {hasHarvestedPlots && ( +
+ {/* Grid lines (exponential scale) */} +
+ {generateLogGridPoints(maxHarvestedIndex).map((value) => { + // Exponential scale: small values compressed to the left, large values spread to the right + const minValue = maxHarvestedIndex / 10; + const normalizedValue = (value - minValue) / (maxHarvestedIndex - minValue); + + // Apply exponential transformation: position = (e^(k*x) - 1) / (e^k - 1) + // Using k=1 for very gentle exponential curve (almost linear) + const k = 1; + const position = (Math.exp(k * normalizedValue) - 1) / (Math.exp(k) - 1) * 100; + + if (position > 100 || position < 0) return null; + + return ( +
+ ); + })} +
+ + {/* Plot rectangles */} +
+ {harvestedPlots.map((plot, idx) => { + // Exponential scale: small values compressed to the left, large values spread to the right + const minValue = maxHarvestedIndex / 10; + const plotStart = Math.max(plot.startIndex.toNumber(), minValue); + const plotEnd = Math.max(plot.endIndex.toNumber(), minValue); + + const normalizedStart = (plotStart - minValue) / (maxHarvestedIndex - minValue); + const normalizedEnd = (plotEnd - minValue) / (maxHarvestedIndex - minValue); + + // Apply exponential transformation + const k = 1; + const leftPercent = (Math.exp(k * normalizedStart) - 1) / (Math.exp(k) - 1) * 100; + const rightPercent = (Math.exp(k * normalizedEnd) - 1) / (Math.exp(k) - 1) * 100; + const widthPercent = rightPercent - leftPercent; + const displayWidth = Math.max(widthPercent, MIN_PLOT_WIDTH_PERCENT); + + // Check if this is the leftmost plot + const isLeftmost = idx === 0 && leftPercent < 1; + + return ( +
+ ); + })} +
+
+ )} + + {/* Podline Section (Linear Scale) - Right 80% or 100% if no harvested plots */} +
+ {/* Grid lines at 10M intervals */} +
+ {generateAxisLabels(0, podLine.toNumber()).map((value) => { + if (value === 0) return null; // Skip 0, it's the marker + const position = podLine.gt(0) ? (value / podLine.toNumber()) * 100 : 0; + if (position > 100) return null; + + return ( +
+ ); + })} +
+ + {/* Plot rectangles - grouped visually but individually interactive */} +
+ {unharvestedPlots.map((group, groupIdx) => { + const groupPlaceInLine = group.startIndex.sub(harvestableIndex); + const groupEnd = group.endIndex.sub(harvestableIndex); + + const groupLeftPercent = podLine.gt(0) ? (groupPlaceInLine.toNumber() / podLine.toNumber()) * 100 : 0; + const groupWidthPercent = + podLine.gt(0) ? ((groupEnd.toNumber() - groupPlaceInLine.toNumber()) / podLine.toNumber()) * 100 : 0; + const groupDisplayWidth = Math.max(groupWidthPercent, MIN_PLOT_WIDTH_PERCENT); + + // Check if this is the rightmost group + const isRightmost = groupIdx === unharvestedPlots.length - 1 && groupLeftPercent + groupDisplayWidth > 99; + + // Check if group is hovered or selected (based on first plot in group) + const groupFirstPlotIndex = group.plots[0].index.toHuman(); + const hasHoveredPlot = group.plots.some((p) => p.index.toHuman() === hoveredPlotIndex); + const hasSelectedPlot = group.plots.some((p) => selectedSet.has(p.index.toHuman())); + const hasHarvestablePlot = group.plots.some((p) => p.harvestablePods?.gt(0)); + + // Determine group color + const groupIsGreen = hasHarvestablePlot || hasSelectedPlot || hasHoveredPlot; + const groupIsActive = hasHoveredPlot || hasSelectedPlot; + + // Border radius for the group + let groupBorderRadius = "2px"; + if (isRightmost) { + groupBorderRadius = "0 2px 2px 0"; + } + + // Handle group click - select all plots in the group + const handleGroupClick = () => { + if (onPlotGroupSelect) { + // Send all plot indices in the group + const plotIndices = group.plots.map((p) => p.index.toHuman()); + onPlotGroupSelect(plotIndices); + } + }; + + // Render group as single solid unit + return ( +
setHoveredPlotIndex(groupFirstPlotIndex)} + onMouseLeave={() => setHoveredPlotIndex(null)} + > +
+ ); + })} +
+
+
+
+ + {/* "0" Marker - vertical line extending to labels (only shown if there are harvested plots) */} + {hasHarvestedPlots && ( +
+ )} + + {/* Bottom axis labels - outside border */} +
+ {/* "0" Label - positioned at the marker (only shown if there are harvested plots) */} + {hasHarvestedPlots && ( +
+ 0 +
+ )} + +
+ {/* Harvested section labels (only shown if there are harvested plots) */} + {hasHarvestedPlots && ( +
+ {generateLogGridPoints(maxHarvestedIndex).map((value) => { + // Exponential scale: small values compressed to the left, large values spread to the right + const minValue = maxHarvestedIndex / 10; + const normalizedValue = (value - minValue) / (maxHarvestedIndex - minValue); + + // Apply exponential transformation + const k = 1; + const position = (Math.exp(k * normalizedValue) - 1) / (Math.exp(k) - 1) * 100; + + return ( +
+ {formatAxisLabel(value)} +
+ ); + })} +
+ )} + + {/* Podline section labels - show place in line (10M, 20M, etc.) */} +
+ {generateAxisLabels(0, podLine.toNumber()).map((value) => { + if (value === 0 && hasHarvestedPlots) return null; // Skip 0 only if harvested section is shown + const position = podLine.gt(0) ? (value / podLine.toNumber()) * 100 : 0; + if (position > 100) return null; + + return ( +
+ {formatAxisLabel(value)} +
+ ); + })} +
+
+
+
+ ); +} + diff --git a/src/pages/market/actions/CreateListing.tsx b/src/pages/market/actions/CreateListing.tsx index 1a1eceaa4..6b94e5dba 100644 --- a/src/pages/market/actions/CreateListing.tsx +++ b/src/pages/market/actions/CreateListing.tsx @@ -2,6 +2,7 @@ import pintoIcon from "@/assets/tokens/PINTO.png"; import { TV, TokenValue } from "@/classes/TokenValue"; import ComboPlotInputField from "@/components/ComboPlotInputField"; import DestinationBalanceSelect from "@/components/DestinationBalanceSelect"; +import PodLineGraph from "@/components/PodLineGraph"; import SimpleInputField from "@/components/SimpleInputField"; import SmartSubmitButton from "@/components/SmartSubmitButton"; import { Separator } from "@/components/ui/Separator"; @@ -10,6 +11,7 @@ import { PODS } from "@/constants/internalTokens"; import { beanstalkAbi } from "@/generated/contractHooks"; import { useProtocolAddress } from "@/hooks/pinto/useProtocolAddress"; import useTransaction from "@/hooks/useTransaction"; +import { useFarmerField } from "@/state/useFarmerField"; import { useHarvestableIndex, usePodIndex } from "@/state/useFieldData"; import { useQueryKeys } from "@/state/useQueryKeys"; import useTokenData from "@/state/useTokenData"; @@ -17,7 +19,7 @@ import { trackSimpleEvent } from "@/utils/analytics"; import { formatter } from "@/utils/format"; import { FarmToMode, Plot } from "@/utils/types"; import { useQueryClient } from "@tanstack/react-query"; -import { useCallback, useEffect, useMemo, useState } from "react"; +import { useCallback, useMemo, useState } from "react"; import { useNavigate } from "react-router-dom"; import { toast } from "sonner"; import { useAccount } from "wagmi"; @@ -34,6 +36,7 @@ export default function CreateListing() { const mainToken = useTokenData().mainToken; const harvestableIndex = useHarvestableIndex(); const navigate = useNavigate(); + const farmerField = useFarmerField(); const queryClient = useQueryClient(); const { allPodListings, allMarket, farmerMarket } = useQueryKeys({ account, harvestableIndex }); @@ -156,6 +159,33 @@ export default function CreateListing() {

Select Plot

+ + {/* Pod Line Graph Visualization */} +
+ p.index.toHuman())} + onPlotGroupSelect={(plotIndices) => { + // Check if all plots in the group are already selected + const allSelected = plotIndices.every((index) => + plot.some((p) => p.index.toHuman() === index) + ); + + if (allSelected) { + // Deselect if already selected + setPlot([]); + } else { + // Find and select all plots in the group from farmer plots + const plotsToSelect = farmerField.plots.filter((p) => + plotIndices.includes(p.index.toHuman()) + ); + if (plotsToSelect.length > 0) { + handlePlotSelection(plotsToSelect); + } + } + }} + /> +
+ Date: Wed, 29 Oct 2025 18:36:41 +0300 Subject: [PATCH 04/50] Add graph title --- src/components/PodLineGraph.tsx | 5 +++++ src/pages/market/actions/CreateListing.tsx | 6 +++--- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/src/components/PodLineGraph.tsx b/src/components/PodLineGraph.tsx index 59fc247fc..d9fbba3d5 100644 --- a/src/components/PodLineGraph.tsx +++ b/src/components/PodLineGraph.tsx @@ -233,6 +233,11 @@ export default function PodLineGraph({ return (
+ {/* Label */} +
+

My Pods In Line

+
+ {/* Plot container with border */}
diff --git a/src/pages/market/actions/CreateListing.tsx b/src/pages/market/actions/CreateListing.tsx index 6b94e5dba..1e97fc630 100644 --- a/src/pages/market/actions/CreateListing.tsx +++ b/src/pages/market/actions/CreateListing.tsx @@ -158,7 +158,7 @@ export default function CreateListing() { return (
-

Select Plot

+

Select the Plot(s) you want to List (i):

{/* Pod Line Graph Visualization */}
@@ -186,14 +186,14 @@ export default function CreateListing() { />
- + /> */}

Amount I want for each Pod

From 57c4498a7dc45e0394b453bc3e75ba05bfece067 Mon Sep 17 00:00:00 2001 From: feyyazcigim Date: Wed, 29 Oct 2025 22:02:49 +0300 Subject: [PATCH 05/50] Implement sell list UI --- src/components/PodLineGraph.tsx | 436 ++++++++++----------- src/pages/market/actions/CreateListing.tsx | 289 ++++++++++---- tailwind.config.js | 5 + 3 files changed, 432 insertions(+), 298 deletions(-) diff --git a/src/components/PodLineGraph.tsx b/src/components/PodLineGraph.tsx index d9fbba3d5..5a082426e 100644 --- a/src/components/PodLineGraph.tsx +++ b/src/components/PodLineGraph.tsx @@ -36,11 +36,7 @@ interface PodLineGraphProps { /** * Groups nearby plots for visual display while keeping each plot individually interactive */ -function combinePlots( - plots: Plot[], - harvestableIndex: TokenValue, - selectedIndices: Set, -): CombinedPlot[] { +function combinePlots(plots: Plot[], harvestableIndex: TokenValue, selectedIndices: Set): CombinedPlot[] { if (plots.length === 0) return []; // Sort plots by index @@ -73,9 +69,7 @@ function combinePlots( const totalPods = currentGroup.reduce((sum, p) => sum.add(p.pods), TokenValue.ZERO); // Check if any plot in group is harvestable or selected - const isHarvestable = currentGroup.some( - (p) => p.harvestablePods?.gt(0) || endIndex.lte(harvestableIndex), - ); + const isHarvestable = currentGroup.some((p) => p.harvestablePods?.gt(0) || endIndex.lte(harvestableIndex)); const isSelected = currentGroup.some((p) => selectedIndices.has(p.index.toHuman())); combined.push({ @@ -100,16 +94,16 @@ function combinePlots( function generateAxisLabels(min: number, max: number): number[] { const INTERVAL = 10_000_000; // 10M const labels: number[] = []; - + // Start from 0 or the first 10M multiple const start = Math.floor(min / INTERVAL) * INTERVAL; - + for (let value = start; value <= max; value += INTERVAL) { if (value >= min) { labels.push(value); } } - + return labels; } @@ -119,11 +113,11 @@ function generateAxisLabels(min: number, max: number): number[] { */ function generateLogGridPoints(maxValue: number): number[] { if (maxValue <= 0) return []; - + const gridPoints: number[] = []; const million = 1_000_000; const minValue = maxValue / 10; - + // For values less than 10M, use simple 1M, 2M, 5M pattern if (maxValue <= 10 * million) { if (maxValue > 1 * million && 1 * million > minValue) gridPoints.push(1 * million); @@ -131,7 +125,7 @@ function generateLogGridPoints(maxValue: number): number[] { if (maxValue > 5 * million && 5 * million > minValue) gridPoints.push(5 * million); return gridPoints; } - + // For larger values, use powers of 10 let power = million; while (power < maxValue) { @@ -142,7 +136,7 @@ function generateLogGridPoints(maxValue: number): number[] { if (next5 < maxValue && next5 > minValue) gridPoints.push(next5); power *= 10; } - + return gridPoints.sort((a, b) => a - b); } @@ -223,10 +217,10 @@ export default function PodLineGraph({ // Calculate max harvested index for log scale const maxHarvestedIndex = harvestableIndex.gt(0) ? harvestableIndex.toNumber() : 1; - + // Check if there are any harvested plots const hasHarvestedPlots = harvestedPlots.length > 0; - + // Adjust width percentages based on whether we have harvested plots const harvestedWidthPercent = hasHarvestedPlots ? HARVESTED_WIDTH_PERCENT : 0; const podlineWidthPercent = hasHarvestedPlots ? PODLINE_WIDTH_PERCENT : 100; @@ -237,30 +231,90 @@ export default function PodLineGraph({

My Pods In Line

- + {/* Plot container with border */}
{/* Harvested Section (Log Scale) - Left 20% (only shown if there are harvested plots) */} {hasHarvestedPlots && ( -
- {/* Grid lines (exponential scale) */} +
+ {/* Grid lines (exponential scale) */} +
+ {generateLogGridPoints(maxHarvestedIndex).map((value) => { + // Exponential scale: small values compressed to the left, large values spread to the right + const minValue = maxHarvestedIndex / 10; + const normalizedValue = (value - minValue) / (maxHarvestedIndex - minValue); + + // Apply exponential transformation: position = (e^(k*x) - 1) / (e^k - 1) + // Using k=1 for very gentle exponential curve (almost linear) + const k = 1; + const position = ((Math.exp(k * normalizedValue) - 1) / (Math.exp(k) - 1)) * 100; + + if (position > 100 || position < 0) return null; + + return ( +
+ ); + })} +
+ + {/* Plot rectangles */} +
+ {harvestedPlots.map((plot, idx) => { + // Exponential scale: small values compressed to the left, large values spread to the right + const minValue = maxHarvestedIndex / 10; + const plotStart = Math.max(plot.startIndex.toNumber(), minValue); + const plotEnd = Math.max(plot.endIndex.toNumber(), minValue); + + const normalizedStart = (plotStart - minValue) / (maxHarvestedIndex - minValue); + const normalizedEnd = (plotEnd - minValue) / (maxHarvestedIndex - minValue); + + // Apply exponential transformation + const k = 1; + const leftPercent = ((Math.exp(k * normalizedStart) - 1) / (Math.exp(k) - 1)) * 100; + const rightPercent = ((Math.exp(k * normalizedEnd) - 1) / (Math.exp(k) - 1)) * 100; + const widthPercent = rightPercent - leftPercent; + const displayWidth = Math.max(widthPercent, MIN_PLOT_WIDTH_PERCENT); + + // Check if this is the leftmost plot + const isLeftmost = idx === 0 && leftPercent < 1; + + return ( +
+ ); + })} +
+
+ )} + + {/* Podline Section (Linear Scale) - Right 80% or 100% if no harvested plots */} +
+ {/* Grid lines at 10M intervals */}
- {generateLogGridPoints(maxHarvestedIndex).map((value) => { - // Exponential scale: small values compressed to the left, large values spread to the right - const minValue = maxHarvestedIndex / 10; - const normalizedValue = (value - minValue) / (maxHarvestedIndex - minValue); - - // Apply exponential transformation: position = (e^(k*x) - 1) / (e^k - 1) - // Using k=1 for very gentle exponential curve (almost linear) - const k = 1; - const position = (Math.exp(k * normalizedValue) - 1) / (Math.exp(k) - 1) * 100; - - if (position > 100 || position < 0) return null; + {generateAxisLabels(0, podLine.toNumber()).map((value) => { + if (value === 0) return null; // Skip 0, it's the marker + const position = podLine.gt(0) ? (value / podLine.toNumber()) * 100 : 0; + if (position > 100) return null; return (
@@ -268,217 +322,157 @@ export default function PodLineGraph({ })}
- {/* Plot rectangles */} + {/* Plot rectangles - grouped visually but individually interactive */}
- {harvestedPlots.map((plot, idx) => { - // Exponential scale: small values compressed to the left, large values spread to the right - const minValue = maxHarvestedIndex / 10; - const plotStart = Math.max(plot.startIndex.toNumber(), minValue); - const plotEnd = Math.max(plot.endIndex.toNumber(), minValue); - - const normalizedStart = (plotStart - minValue) / (maxHarvestedIndex - minValue); - const normalizedEnd = (plotEnd - minValue) / (maxHarvestedIndex - minValue); - - // Apply exponential transformation - const k = 1; - const leftPercent = (Math.exp(k * normalizedStart) - 1) / (Math.exp(k) - 1) * 100; - const rightPercent = (Math.exp(k * normalizedEnd) - 1) / (Math.exp(k) - 1) * 100; - const widthPercent = rightPercent - leftPercent; - const displayWidth = Math.max(widthPercent, MIN_PLOT_WIDTH_PERCENT); - - // Check if this is the leftmost plot - const isLeftmost = idx === 0 && leftPercent < 1; + {unharvestedPlots.map((group, groupIdx) => { + const groupPlaceInLine = group.startIndex.sub(harvestableIndex); + const groupEnd = group.endIndex.sub(harvestableIndex); + + const groupLeftPercent = podLine.gt(0) ? (groupPlaceInLine.toNumber() / podLine.toNumber()) * 100 : 0; + const groupWidthPercent = podLine.gt(0) + ? ((groupEnd.toNumber() - groupPlaceInLine.toNumber()) / podLine.toNumber()) * 100 + : 0; + const groupDisplayWidth = Math.max(groupWidthPercent, MIN_PLOT_WIDTH_PERCENT); + + // Check if this is the rightmost group + const isRightmost = + groupIdx === unharvestedPlots.length - 1 && groupLeftPercent + groupDisplayWidth > 99; + + // Check if group is hovered or selected (based on first plot in group) + const groupFirstPlotIndex = group.plots[0].index.toHuman(); + const hasHoveredPlot = group.plots.some((p) => p.index.toHuman() === hoveredPlotIndex); + const hasSelectedPlot = group.plots.some((p) => selectedSet.has(p.index.toHuman())); + const hasHarvestablePlot = group.plots.some((p) => p.harvestablePods?.gt(0)); + + // Determine group color + const groupIsGreen = hasHarvestablePlot || hasSelectedPlot || hasHoveredPlot; + const groupIsActive = hasHoveredPlot || hasSelectedPlot; + + // Border radius for the group + let groupBorderRadius = "2px"; + if (isRightmost) { + groupBorderRadius = "0 2px 2px 0"; + } - return ( -
- ); - })} + // Handle group click - select all plots in the group + const handleGroupClick = () => { + if (onPlotGroupSelect) { + // Send all plot indices in the group + const plotIndices = group.plots.map((p) => p.index.toHuman()); + onPlotGroupSelect(plotIndices); + } + }; + + // Render group as single solid unit + return ( +
setHoveredPlotIndex(groupFirstPlotIndex)} + onMouseLeave={() => setHoveredPlotIndex(null)} + /> + ); + })}
+
+
+ + {/* "0" Marker - vertical line extending to labels (only shown if there are harvested plots) */} + {hasHarvestedPlots && ( +
+ )} + + {/* Bottom axis labels - outside border */} +
+ {/* "0" Label - positioned at the marker (only shown if there are harvested plots) */} + {hasHarvestedPlots && ( +
+ 0 +
+ )} + +
+ {/* Harvested section labels (only shown if there are harvested plots) */} + {hasHarvestedPlots && ( +
+ {generateLogGridPoints(maxHarvestedIndex).map((value) => { + // Exponential scale: small values compressed to the left, large values spread to the right + const minValue = maxHarvestedIndex / 10; + const normalizedValue = (value - minValue) / (maxHarvestedIndex - minValue); + + // Apply exponential transformation + const k = 1; + const position = ((Math.exp(k * normalizedValue) - 1) / (Math.exp(k) - 1)) * 100; + + return ( +
+ {formatAxisLabel(value)} +
+ ); + })} +
)} - {/* Podline Section (Linear Scale) - Right 80% or 100% if no harvested plots */} + {/* Podline section labels - show place in line (10M, 20M, etc.) */}
- {/* Grid lines at 10M intervals */} -
{generateAxisLabels(0, podLine.toNumber()).map((value) => { - if (value === 0) return null; // Skip 0, it's the marker + if (value === 0 && hasHarvestedPlots) return null; // Skip 0 only if harvested section is shown const position = podLine.gt(0) ? (value / podLine.toNumber()) * 100 : 0; if (position > 100) return null; return (
- ); - })} -
- - {/* Plot rectangles - grouped visually but individually interactive */} -
- {unharvestedPlots.map((group, groupIdx) => { - const groupPlaceInLine = group.startIndex.sub(harvestableIndex); - const groupEnd = group.endIndex.sub(harvestableIndex); - - const groupLeftPercent = podLine.gt(0) ? (groupPlaceInLine.toNumber() / podLine.toNumber()) * 100 : 0; - const groupWidthPercent = - podLine.gt(0) ? ((groupEnd.toNumber() - groupPlaceInLine.toNumber()) / podLine.toNumber()) * 100 : 0; - const groupDisplayWidth = Math.max(groupWidthPercent, MIN_PLOT_WIDTH_PERCENT); - - // Check if this is the rightmost group - const isRightmost = groupIdx === unharvestedPlots.length - 1 && groupLeftPercent + groupDisplayWidth > 99; - - // Check if group is hovered or selected (based on first plot in group) - const groupFirstPlotIndex = group.plots[0].index.toHuman(); - const hasHoveredPlot = group.plots.some((p) => p.index.toHuman() === hoveredPlotIndex); - const hasSelectedPlot = group.plots.some((p) => selectedSet.has(p.index.toHuman())); - const hasHarvestablePlot = group.plots.some((p) => p.harvestablePods?.gt(0)); - - // Determine group color - const groupIsGreen = hasHarvestablePlot || hasSelectedPlot || hasHoveredPlot; - const groupIsActive = hasHoveredPlot || hasSelectedPlot; - - // Border radius for the group - let groupBorderRadius = "2px"; - if (isRightmost) { - groupBorderRadius = "0 2px 2px 0"; - } - - // Handle group click - select all plots in the group - const handleGroupClick = () => { - if (onPlotGroupSelect) { - // Send all plot indices in the group - const plotIndices = group.plots.map((p) => p.index.toHuman()); - onPlotGroupSelect(plotIndices); - } - }; - - // Render group as single solid unit - return ( -
setHoveredPlotIndex(groupFirstPlotIndex)} - onMouseLeave={() => setHoveredPlotIndex(null)} > + {formatAxisLabel(value)}
); })}
-
-
- - {/* "0" Marker - vertical line extending to labels (only shown if there are harvested plots) */} - {hasHarvestedPlots && ( -
- )} - - {/* Bottom axis labels - outside border */} -
- {/* "0" Label - positioned at the marker (only shown if there are harvested plots) */} - {hasHarvestedPlots && ( -
- 0 -
- )} - -
- {/* Harvested section labels (only shown if there are harvested plots) */} - {hasHarvestedPlots && ( -
- {generateLogGridPoints(maxHarvestedIndex).map((value) => { - // Exponential scale: small values compressed to the left, large values spread to the right - const minValue = maxHarvestedIndex / 10; - const normalizedValue = (value - minValue) / (maxHarvestedIndex - minValue); - - // Apply exponential transformation - const k = 1; - const position = (Math.exp(k * normalizedValue) - 1) / (Math.exp(k) - 1) * 100; - - return ( -
- {formatAxisLabel(value)} -
- ); - })} -
- )} - - {/* Podline section labels - show place in line (10M, 20M, etc.) */} -
- {generateAxisLabels(0, podLine.toNumber()).map((value) => { - if (value === 0 && hasHarvestedPlots) return null; // Skip 0 only if harvested section is shown - const position = podLine.gt(0) ? (value / podLine.toNumber()) * 100 : 0; - if (position > 100) return null; - - return ( -
- {formatAxisLabel(value)} -
- ); - })} -
-
); } - diff --git a/src/pages/market/actions/CreateListing.tsx b/src/pages/market/actions/CreateListing.tsx index 1e97fc630..535e2195d 100644 --- a/src/pages/market/actions/CreateListing.tsx +++ b/src/pages/market/actions/CreateListing.tsx @@ -1,11 +1,13 @@ import pintoIcon from "@/assets/tokens/PINTO.png"; import { TV, TokenValue } from "@/classes/TokenValue"; import ComboPlotInputField from "@/components/ComboPlotInputField"; -import DestinationBalanceSelect from "@/components/DestinationBalanceSelect"; import PodLineGraph from "@/components/PodLineGraph"; import SimpleInputField from "@/components/SimpleInputField"; import SmartSubmitButton from "@/components/SmartSubmitButton"; +import { Input } from "@/components/ui/Input"; import { Separator } from "@/components/ui/Separator"; +import { MultiSlider, Slider } from "@/components/ui/Slider"; +import { Switch } from "@/components/ui/Switch"; import { ANALYTICS_EVENTS } from "@/constants/analytics-events"; import { PODS } from "@/constants/internalTokens"; import { beanstalkAbi } from "@/generated/contractHooks"; @@ -18,16 +20,33 @@ import useTokenData from "@/state/useTokenData"; import { trackSimpleEvent } from "@/utils/analytics"; import { formatter } from "@/utils/format"; import { FarmToMode, Plot } from "@/utils/types"; +import { cn } from "@/utils/utils"; import { useQueryClient } from "@tanstack/react-query"; import { useCallback, useMemo, useState } from "react"; import { useNavigate } from "react-router-dom"; import { toast } from "sonner"; import { useAccount } from "wagmi"; -const pricePerPodValidation = { - maxValue: 1, - minValue: 0.000001, - maxDecimals: 6, +const PRICE_PER_POD_CONFIG = { + MAX: 1, + MIN: 0.000001, + DECIMALS: 6, + DECIMAL_MULTIPLIER: 1_000_000, // 10^6 for 6 decimals +} as const; + +const TextAdornment = ({ text, className }: { text: string; className?: string }) => { + return
{text}
; +}; + +// Utility function to format and truncate price per pod values +const formatPricePerPod = (value: number): number => { + return Math.floor(value * PRICE_PER_POD_CONFIG.DECIMAL_MULTIPLIER) / PRICE_PER_POD_CONFIG.DECIMAL_MULTIPLIER; +}; + +// Utility function to clamp and format price per pod input +const clampAndFormatPrice = (value: number): number => { + const clamped = Math.max(PRICE_PER_POD_CONFIG.MIN, Math.min(PRICE_PER_POD_CONFIG.MAX, value)); + return formatPricePerPod(clamped); }; export default function CreateListing() { @@ -44,23 +63,40 @@ export default function CreateListing() { const [plot, setPlot] = useState([]); const [amount, setAmount] = useState(0); - const [expiresIn, setExpiresIn] = useState(undefined); + const [podRange, setPodRange] = useState<[number, number]>([0, 0]); const [pricePerPod, setPricePerPod] = useState(undefined); - const [balanceTo, setBalanceTo] = useState(FarmToMode.INTERNAL); + const [pricePerPodInput, setPricePerPodInput] = useState(""); + const [balanceTo, setBalanceTo] = useState(FarmToMode.EXTERNAL); // Default: Wallet Balance (toggle off) const podIndex = usePodIndex(); const maxExpiration = Number.parseInt(podIndex.toHuman()) - Number.parseInt(harvestableIndex.toHuman()) || 0; + const expiresIn = maxExpiration; // Auto-set to max expiration const minFill = TokenValue.fromHuman(1, PODS.decimals); const plotPosition = plot.length > 0 ? plot[0].index.sub(harvestableIndex) : TV.ZERO; - const maxExpirationValidation = useMemo( - () => ({ - minValue: 1, - maxValue: maxExpiration, - maxDecimals: 0, - }), - [maxExpiration], - ); + // Calculate max pods based on selected plots OR all farmer plots + const maxPodAmount = useMemo(() => { + const plotsToUse = plot.length > 0 ? plot : farmerField.plots; + if (plotsToUse.length === 0) return 0; + return plotsToUse.reduce((sum, p) => sum + p.pods.toNumber(), 0); + }, [plot, farmerField.plots]); + + // Calculate position range in line + const positionInfo = useMemo(() => { + const plotsToUse = plot.length > 0 ? plot : farmerField.plots; + if (plotsToUse.length === 0) return null; + + const minIndex = plotsToUse.reduce((min, p) => (p.index.lt(min) ? p.index : min), plotsToUse[0].index); + const maxIndex = plotsToUse.reduce((max, p) => { + const endIndex = p.index.add(p.pods); + return endIndex.gt(max) ? endIndex : max; + }, plotsToUse[0].index); + + return { + start: minIndex.sub(harvestableIndex), + end: maxIndex.sub(harvestableIndex), + }; + }, [plot, farmerField.plots, harvestableIndex]); // Plot selection handler with tracking const handlePlotSelection = useCallback( @@ -70,17 +106,60 @@ export default function CreateListing() { previous_count: plot.length, }); setPlot(plots); + + // Reset range when plots change + if (plots.length > 0) { + const totalPods = plots.reduce((sum, p) => sum + p.pods.toNumber(), 0); + setPodRange([0, totalPods]); + setAmount(totalPods); + } else { + setPodRange([0, 0]); + setAmount(0); + } }, [plot.length], ); + // Pod range slider handler (two thumbs) + const handlePodRangeChange = useCallback((value: number[]) => { + const [min, max] = value; + setPodRange([min, max]); + setAmount(max - min); + }, []); + + // Price per pod slider handler + const handlePriceSliderChange = useCallback((value: number[]) => { + const formatted = formatPricePerPod(value[0]); + setPricePerPod(formatted); + setPricePerPodInput(formatted.toFixed(PRICE_PER_POD_CONFIG.DECIMALS)); + }, []); + + // Price per pod input handlers + const handlePriceInputChange = useCallback((e: React.ChangeEvent) => { + const value = e.target.value; + setPricePerPodInput(value); + }, []); + + const handlePriceInputBlur = useCallback(() => { + const numValue = Number.parseFloat(pricePerPodInput); + if (!Number.isNaN(numValue)) { + const formatted = clampAndFormatPrice(numValue); + setPricePerPod(formatted); + setPricePerPodInput(formatted.toString()); + } else { + setPricePerPodInput(""); + setPricePerPod(undefined); + } + }, [pricePerPodInput]); + // reset form and invalidate pod listing query const onSuccess = useCallback(() => { navigate(`/market/pods/buy/${plot[0].index.toBigInt()}`); setPlot([]); setAmount(0); - setExpiresIn(undefined); + setPodRange([0, 0]); setPricePerPod(undefined); + setPricePerPodInput(""); allQK.forEach((key) => queryClient.invalidateQueries({ queryKey: key })); }, [navigate, plot, queryClient, allQK]); @@ -146,6 +225,7 @@ export default function CreateListing() { harvestableIndex, minFill, plot, + plotPosition, setSubmitting, mainToken.decimals, diamondAddress, @@ -153,76 +233,131 @@ export default function CreateListing() { ]); // ui state - const disabled = !pricePerPod || !expiresIn || !amount || !account || plot.length !== 1; + const disabled = !pricePerPod || !amount || !account || plot.length !== 1; return (
-
+ {/* Plot Selection Section */} +

Select the Plot(s) you want to List (i):

- + {/* Pod Line Graph Visualization */} -
- p.index.toHuman())} - onPlotGroupSelect={(plotIndices) => { - // Check if all plots in the group are already selected - const allSelected = plotIndices.every((index) => - plot.some((p) => p.index.toHuman() === index) - ); - - if (allSelected) { - // Deselect if already selected - setPlot([]); - } else { - // Find and select all plots in the group from farmer plots - const plotsToSelect = farmerField.plots.filter((p) => - plotIndices.includes(p.index.toHuman()) - ); - if (plotsToSelect.length > 0) { - handlePlotSelection(plotsToSelect); - } + p.index.toHuman())} + onPlotGroupSelect={(plotIndices) => { + // Check if all plots in the group are already selected + const allSelected = plotIndices.every((index) => plot.some((p) => p.index.toHuman() === index)); + + if (allSelected) { + // Deselect if already selected + setPlot([]); + } else { + // Find and select all plots in the group from farmer plots + const plotsToSelect = farmerField.plots.filter((p) => plotIndices.includes(p.index.toHuman())); + if (plotsToSelect.length > 0) { + handlePlotSelection(plotsToSelect); } - }} - /> -
- -{/* */} -
-
-

Amount I want for each Pod

- -
-
-

Expires In

- - {!!expiresIn && ( -

- This listing will automatically expire after {formatter.noDec(expiresIn)} more Pods become Harvestable. -

+ + {/* Position in Line Display (below graph) */} + {positionInfo && ( +
+

+ {positionInfo.start.toHuman("short")} - {positionInfo.end.toHuman("short")} +

+
)}
-
-

Send proceeds to

- -
+ + {/* Total Pods to List Summary */} + {maxPodAmount > 0 && ( +
+

Total Pods to List:

+

{formatter.noDec(plot.length > 0 ? amount : maxPodAmount)} Pods

+
+ )} + + {/* Show these sections only when plots are selected */} + {plot.length > 0 && ( +
+ {/* Pod Range Selection */} +
+
+

Select Pods

+
+

{formatter.noDec(podRange[0])}

+
+ {maxPodAmount > 0 && ( + + )} +
+

{formatter.noDec(podRange[1])}

+
+
+
+ + {/* Price Per Pod */} +
+

Amount I am willing to sell for each Pod for:

+
+
+

0

+ +

1

+
+ } + /> +
+
+ {/* Expires In - Auto-set to max expiration */} + {/*
+

Expires In

+ + {!!expiresIn && ( +

+ This listing will automatically expire after {formatter.noDec(expiresIn)} more Pods become Harvestable. +

+ )} +
*/} + {/*
+

Send balances to Farm Balance

+ setBalanceTo(checked ? FarmToMode.INTERNAL : FarmToMode.EXTERNAL)} + /> +
*/} +
+ )}
{!disabled && } diff --git a/tailwind.config.js b/tailwind.config.js index 0d5f4ebad..fa500884c 100644 --- a/tailwind.config.js +++ b/tailwind.config.js @@ -248,6 +248,10 @@ module.exports = { boxShadow: "0 0 30px var(--glow-color, rgba(36, 102, 69, 0.7))" }, }, + "fade-in": { + "0%": { opacity: "0", transform: "translateY(-10px)" }, + "100%": { opacity: "1", transform: "translateY(0)" }, + }, }, animation: { "accordion-down": "accordion-down 0.2s ease-out", @@ -261,6 +265,7 @@ module.exports = { "vertical-marquee-small": "vertical-marquee-small 80s linear infinite", "text-background-scroll": "text-background-scroll 5s linear infinite", "pulse-glow": "pulse-glow 8s ease-in-out infinite", + "fade-in": "fade-in 0.1s ease-in-out", }, aspectRatio: { "3/1": "3 / 1", From b87b0c7564e0e7314d87355b3d78d4a870e9d97f Mon Sep 17 00:00:00 2001 From: feyyazcigim Date: Thu, 30 Oct 2025 00:16:29 +0300 Subject: [PATCH 06/50] Add partially select with slider to PodLineGraph --- src/components/PodLineGraph.tsx | 77 ++++++++++++++++++---- src/pages/market/actions/CreateListing.tsx | 48 +++++++++++++- 2 files changed, 111 insertions(+), 14 deletions(-) diff --git a/src/components/PodLineGraph.tsx b/src/components/PodLineGraph.tsx index 5a082426e..bd523ea24 100644 --- a/src/components/PodLineGraph.tsx +++ b/src/components/PodLineGraph.tsx @@ -27,6 +27,8 @@ interface PodLineGraphProps { plots?: Plot[]; /** Indices of selected plots */ selectedPlotIndices?: string[]; + /** Optional: specify a partial range selection (absolute pod line positions) */ + selectedPodRange?: { start: TokenValue; end: TokenValue }; /** Callback when a plot group is clicked - receives all plot indices in the group */ onPlotGroupSelect?: (plotIndices: string[]) => void; /** Additional CSS classes */ @@ -156,6 +158,7 @@ function formatAxisLabel(value: number): string { export default function PodLineGraph({ plots: providedPlots, selectedPlotIndices = [], + selectedPodRange, onPlotGroupSelect, className, }: PodLineGraphProps) { @@ -344,8 +347,35 @@ export default function PodLineGraph({ const hasSelectedPlot = group.plots.some((p) => selectedSet.has(p.index.toHuman())); const hasHarvestablePlot = group.plots.some((p) => p.harvestablePods?.gt(0)); + // Calculate partial selection if selectedPodRange is provided + let partialSelectionPercent: { start: number; end: number } | null = null; + if (selectedPodRange && hasSelectedPlot) { + // Calculate intersection of group and selected range + const groupStart = group.startIndex; + const groupEnd = group.endIndex; + const rangeStart = selectedPodRange.start; + const rangeEnd = selectedPodRange.end; + + // Check if there's an overlap + if (rangeStart.lt(groupEnd) && rangeEnd.gt(groupStart)) { + // Calculate the overlapping range within the group + const overlapStart = rangeStart.gt(groupStart) ? rangeStart : groupStart; + const overlapEnd = rangeEnd.lt(groupEnd) ? rangeEnd : groupEnd; + + // Convert to percentages within the group + const groupTotal = groupEnd.sub(groupStart).toNumber(); + const overlapStartOffset = overlapStart.sub(groupStart).toNumber(); + const overlapEndOffset = overlapEnd.sub(groupStart).toNumber(); + + partialSelectionPercent = { + start: (overlapStartOffset / groupTotal) * 100, + end: (overlapEndOffset / groupTotal) * 100, + }; + } + } + // Determine group color - const groupIsGreen = hasHarvestablePlot || hasSelectedPlot || hasHoveredPlot; + const groupIsGreen = hasHarvestablePlot || (hasSelectedPlot && !partialSelectionPercent) || hasHoveredPlot; const groupIsActive = hasHoveredPlot || hasSelectedPlot; // Border radius for the group @@ -363,27 +393,52 @@ export default function PodLineGraph({ } }; - // Render group as single solid unit + // Render group with partial selection if applicable return (
setHoveredPlotIndex(groupFirstPlotIndex)} - onMouseLeave={() => setHoveredPlotIndex(null)} - /> + > + {/* Base rectangle (background color) */} +
setHoveredPlotIndex(groupFirstPlotIndex)} + onMouseLeave={() => setHoveredPlotIndex(null)} + /> + + {/* Partial selection overlay (green) */} + {partialSelectionPercent && ( +
= 99.9 ? groupBorderRadius : "0", + borderBottomRightRadius: partialSelectionPercent.end >= 99.9 ? groupBorderRadius : "0", + }} + /> + )} +
); })}
diff --git a/src/pages/market/actions/CreateListing.tsx b/src/pages/market/actions/CreateListing.tsx index 535e2195d..38f6b35b5 100644 --- a/src/pages/market/actions/CreateListing.tsx +++ b/src/pages/market/actions/CreateListing.tsx @@ -98,6 +98,40 @@ export default function CreateListing() { }; }, [plot, farmerField.plots, harvestableIndex]); + // Calculate selected pod range for PodLineGraph partial selection + const selectedPodRange = useMemo(() => { + if (plot.length === 0) return undefined; + + // Sort plots by index + const sortedPlots = [...plot].sort((a, b) => a.index.sub(b.index).toNumber()); + + // Helper function to convert pod offset to absolute index + const offsetToAbsoluteIndex = (offset: number): TokenValue => { + let remainingOffset = offset; + + for (const p of sortedPlots) { + const plotPods = p.pods.toNumber(); + + if (remainingOffset <= plotPods) { + // The offset falls within this plot + return p.index.add(TokenValue.fromHuman(remainingOffset, PODS.decimals)); + } + + // Move to next plot + remainingOffset -= plotPods; + } + + // If we've exhausted all plots, return the end of the last plot + const lastPlot = sortedPlots[sortedPlots.length - 1]; + return lastPlot.index.add(lastPlot.pods); + }; + + return { + start: offsetToAbsoluteIndex(podRange[0]), + end: offsetToAbsoluteIndex(podRange[1]), + }; + }, [plot, podRange]); + // Plot selection handler with tracking const handlePlotSelection = useCallback( (plots: Plot[]) => { @@ -123,8 +157,15 @@ export default function CreateListing() { // Pod range slider handler (two thumbs) const handlePodRangeChange = useCallback((value: number[]) => { const [min, max] = value; + const newAmount = max - min; + setPodRange([min, max]); - setAmount(max - min); + setAmount(newAmount); + + // If amount becomes 0, clear the plot selection + if (newAmount === 0) { + setPlot([]); + } }, []); // Price per pod slider handler @@ -244,6 +285,7 @@ export default function CreateListing() { {/* Pod Line Graph Visualization */} p.index.toHuman())} + selectedPodRange={selectedPodRange} onPlotGroupSelect={(plotIndices) => { // Check if all plots in the group are already selected const allSelected = plotIndices.every((index) => plot.some((p) => p.index.toHuman() === index)); @@ -349,13 +391,13 @@ export default function CreateListing() {

)}
*/} - {/*
+

Send balances to Farm Balance

setBalanceTo(checked ? FarmToMode.INTERNAL : FarmToMode.EXTERNAL)} /> -
*/} +
)}
From 4ce96ca22b6372ff9181a0ebab904085cf6a4881 Mon Sep 17 00:00:00 2001 From: feyyazcigim Date: Thu, 30 Oct 2025 03:29:08 +0300 Subject: [PATCH 07/50] Implement sell/fill --- src/components/PodLineGraph.tsx | 24 +- src/pages/market/MarketModeSelect.tsx | 3 +- src/pages/market/actions/CreateListing.tsx | 18 +- src/pages/market/actions/FillOrder.tsx | 446 +++++++++++++++------ 4 files changed, 344 insertions(+), 147 deletions(-) diff --git a/src/components/PodLineGraph.tsx b/src/components/PodLineGraph.tsx index bd523ea24..8285e9e12 100644 --- a/src/components/PodLineGraph.tsx +++ b/src/components/PodLineGraph.tsx @@ -72,7 +72,8 @@ function combinePlots(plots: Plot[], harvestableIndex: TokenValue, selectedIndic // Check if any plot in group is harvestable or selected const isHarvestable = currentGroup.some((p) => p.harvestablePods?.gt(0) || endIndex.lte(harvestableIndex)); - const isSelected = currentGroup.some((p) => selectedIndices.has(p.index.toHuman())); + // Check selection by id (for order markers) or index (for regular plots) + const isSelected = currentGroup.some((p) => selectedIndices.has(p.id || p.index.toHuman())); combined.push({ startIndex, @@ -342,9 +343,10 @@ export default function PodLineGraph({ groupIdx === unharvestedPlots.length - 1 && groupLeftPercent + groupDisplayWidth > 99; // Check if group is hovered or selected (based on first plot in group) - const groupFirstPlotIndex = group.plots[0].index.toHuman(); - const hasHoveredPlot = group.plots.some((p) => p.index.toHuman() === hoveredPlotIndex); - const hasSelectedPlot = group.plots.some((p) => selectedSet.has(p.index.toHuman())); + // Use id (for order markers) or index (for regular plots) + const groupFirstPlotIndex = group.plots[0].id || group.plots[0].index.toHuman(); + const hasHoveredPlot = group.plots.some((p) => (p.id || p.index.toHuman()) === hoveredPlotIndex); + const hasSelectedPlot = group.plots.some((p) => selectedSet.has(p.id || p.index.toHuman())); const hasHarvestablePlot = group.plots.some((p) => p.harvestablePods?.gt(0)); // Calculate partial selection if selectedPodRange is provided @@ -361,12 +363,12 @@ export default function PodLineGraph({ // Calculate the overlapping range within the group const overlapStart = rangeStart.gt(groupStart) ? rangeStart : groupStart; const overlapEnd = rangeEnd.lt(groupEnd) ? rangeEnd : groupEnd; - + // Convert to percentages within the group const groupTotal = groupEnd.sub(groupStart).toNumber(); const overlapStartOffset = overlapStart.sub(groupStart).toNumber(); const overlapEndOffset = overlapEnd.sub(groupStart).toNumber(); - + partialSelectionPercent = { start: (overlapStartOffset / groupTotal) * 100, end: (overlapEndOffset / groupTotal) * 100, @@ -375,7 +377,8 @@ export default function PodLineGraph({ } // Determine group color - const groupIsGreen = hasHarvestablePlot || (hasSelectedPlot && !partialSelectionPercent) || hasHoveredPlot; + const groupIsGreen = + hasHarvestablePlot || (hasSelectedPlot && !partialSelectionPercent) || hasHoveredPlot; const groupIsActive = hasHoveredPlot || hasSelectedPlot; // Border radius for the group @@ -387,8 +390,9 @@ export default function PodLineGraph({ // Handle group click - select all plots in the group const handleGroupClick = () => { if (onPlotGroupSelect) { - // Send all plot indices in the group - const plotIndices = group.plots.map((p) => p.index.toHuman()); + // Send all plot IDs or indices in the group + // Prefer 'id' if available (for order markers), otherwise use index + const plotIndices = group.plots.map((p) => p.id || p.index.toHuman()); onPlotGroupSelect(plotIndices); } }; @@ -420,7 +424,7 @@ export default function PodLineGraph({ onMouseEnter={() => setHoveredPlotIndex(groupFirstPlotIndex)} onMouseLeave={() => setHoveredPlotIndex(null)} /> - + {/* Partial selection overlay (green) */} {partialSelectionPercent && (
{ if (plot.length === 0) return undefined; - + // Sort plots by index const sortedPlots = [...plot].sort((a, b) => a.index.sub(b.index).toNumber()); - + // Helper function to convert pod offset to absolute index const offsetToAbsoluteIndex = (offset: number): TokenValue => { let remainingOffset = offset; - + for (const p of sortedPlots) { const plotPods = p.pods.toNumber(); - + if (remainingOffset <= plotPods) { // The offset falls within this plot return p.index.add(TokenValue.fromHuman(remainingOffset, PODS.decimals)); } - + // Move to next plot remainingOffset -= plotPods; } - + // If we've exhausted all plots, return the end of the last plot const lastPlot = sortedPlots[sortedPlots.length - 1]; return lastPlot.index.add(lastPlot.pods); }; - + return { start: offsetToAbsoluteIndex(podRange[0]), end: offsetToAbsoluteIndex(podRange[1]), @@ -158,10 +158,10 @@ export default function CreateListing() { const handlePodRangeChange = useCallback((value: number[]) => { const [min, max] = value; const newAmount = max - min; - + setPodRange([min, max]); setAmount(newAmount); - + // If amount becomes 0, clear the plot selection if (newAmount === 0) { setPlot([]); diff --git a/src/pages/market/actions/FillOrder.tsx b/src/pages/market/actions/FillOrder.tsx index 5bb0dd54a..6f0910a23 100644 --- a/src/pages/market/actions/FillOrder.tsx +++ b/src/pages/market/actions/FillOrder.tsx @@ -1,10 +1,9 @@ -import podIcon from "@/assets/protocol/Pod.png"; import pintoIcon from "@/assets/tokens/PINTO.png"; -import { TV, TokenValue } from "@/classes/TokenValue"; -import ComboPlotInputField from "@/components/ComboPlotInputField"; -import DestinationBalanceSelect from "@/components/DestinationBalanceSelect"; +import { TokenValue } from "@/classes/TokenValue"; +import PodLineGraph from "@/components/PodLineGraph"; import SmartSubmitButton from "@/components/SmartSubmitButton"; import { Separator } from "@/components/ui/Separator"; +import { MultiSlider } from "@/components/ui/Slider"; import { ANALYTICS_EVENTS } from "@/constants/analytics-events"; import { PODS } from "@/constants/internalTokens"; import { beanstalkAbi } from "@/generated/contractHooks"; @@ -13,81 +12,216 @@ import useTransaction from "@/hooks/useTransaction"; import usePodOrders from "@/state/market/usePodOrders"; import { useFarmerBalances } from "@/state/useFarmerBalances"; import { useFarmerPlotsQuery } from "@/state/useFarmerField"; -import { useHarvestableIndex } from "@/state/useFieldData"; +import { useHarvestableIndex, usePodIndex } from "@/state/useFieldData"; import { useQueryKeys } from "@/state/useQueryKeys"; import useTokenData from "@/state/useTokenData"; import { trackSimpleEvent } from "@/utils/analytics"; import { formatter } from "@/utils/format"; import { FarmToMode, Plot } from "@/utils/types"; import { useQueryClient } from "@tanstack/react-query"; -import { useCallback, useMemo, useState } from "react"; -import { useParams } from "react-router-dom"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { toast } from "sonner"; import { Address } from "viem"; import { useAccount } from "wagmi"; import CancelOrder from "./CancelOrder"; +//! TODO: ADD INPUT FIELD FOR POD AMOUNTS +//! TODO: SOLVE CHART REDIRECT ISSUE +//! TODO: ADD SETTINGS SECTION + +// Constants +const FIELD_ID = 0n; +const START_INDEX_WITHIN_PLOT = 0n; +const MIN_PODS_THRESHOLD = 1; // Minimum pods required for order eligibility + +// Helper Functions +const calculateRemainingPods = ( + order: { + beanAmount: string | bigint | number; + beanAmountFilled: string | bigint | number; + pricePerPod: string | bigint | number; + }, + tokenDecimals: number, +): number => { + const amount = TokenValue.fromBlockchain(order.beanAmount, tokenDecimals); + const amountFilled = TokenValue.fromBlockchain(order.beanAmountFilled, tokenDecimals); + const pricePerPod = TokenValue.fromBlockchain(order.pricePerPod, tokenDecimals); + + return pricePerPod.gt(0) ? amount.sub(amountFilled).div(pricePerPod).toNumber() : 0; +}; + +const isOrderEligible = ( + order: { + beanAmount: string | bigint | number; + beanAmountFilled: string | bigint | number; + pricePerPod: string | bigint | number; + maxPlaceInLine: string | bigint | number; + }, + tokenDecimals: number, + podLine: TokenValue, +): boolean => { + const amount = TokenValue.fromBlockchain(order.beanAmount, tokenDecimals); + const amountFilled = TokenValue.fromBlockchain(order.beanAmountFilled, tokenDecimals); + const pricePerPod = TokenValue.fromBlockchain(order.pricePerPod, tokenDecimals); + const remainingPods = pricePerPod.gt(0) ? amount.sub(amountFilled).div(pricePerPod) : TokenValue.ZERO; + const orderMaxPlace = TokenValue.fromBlockchain(order.maxPlaceInLine, PODS.decimals); + + return remainingPods.gt(MIN_PODS_THRESHOLD) && orderMaxPlace.lte(podLine); +}; + export default function FillOrder() { const mainToken = useTokenData().mainToken; const diamondAddress = useProtocolAddress(); const { queryKeys: balanceQKs } = useFarmerBalances(); const account = useAccount(); + const harvestableIndex = useHarvestableIndex(); + const podIndex = usePodIndex(); + const podLine = podIndex.sub(harvestableIndex); const queryClient = useQueryClient(); - const { allPodOrders, allMarket, farmerMarket, farmerField } = useQueryKeys({ + const { + allPodOrders, + allMarket, + farmerMarket, + farmerField: farmerFieldQK, + } = useQueryKeys({ account: account.address, }); const { queryKey: farmerPlotsQK } = useFarmerPlotsQuery(); const allQK = useMemo( - () => [allPodOrders, allMarket, farmerMarket, farmerField, farmerPlotsQK, ...balanceQKs], - [allPodOrders, allMarket, farmerMarket, farmerField, farmerPlotsQK, balanceQKs], + () => [allPodOrders, allMarket, farmerMarket, farmerFieldQK, farmerPlotsQK, ...balanceQKs], + [allPodOrders, allMarket, farmerMarket, farmerFieldQK, farmerPlotsQK, balanceQKs], ); const [plot, setPlot] = useState([]); - // TODO: need to handle an edge case with amount where the first half of the plot is sellable, and the second half is not. - // Currently this is handled my making such a plot not fillable via ComboPlotInputField. - const [amount, setAmount] = useState(0); - const [balanceTo, setBalanceTo] = useState(FarmToMode.INTERNAL); + const [podRange, setPodRange] = useState<[number, number]>([0, 0]); + const [selectedOrderIds, setSelectedOrderIds] = useState([]); + + const prevTotalCapacityRef = useRef(0); + const selectedOrderIdsRef = useRef([]); + + // Keep ref in sync and reset capacity ref when selection changes + useEffect(() => { + selectedOrderIdsRef.current = selectedOrderIds; + prevTotalCapacityRef.current = -1; // Reset to allow re-triggering range update + }, [selectedOrderIds]); - const { id } = useParams(); const podOrders = usePodOrders(); const allOrders = podOrders.data; - const order = allOrders?.podOrders.find((order) => order.id === id); - - const amountOrder = TokenValue.fromBlockchain(order?.beanAmount || 0, mainToken.decimals); - const amountFilled = TokenValue.fromBlockchain(order?.beanAmountFilled || 0, mainToken.decimals); - const pricePerPod = TokenValue.fromBlockchain(order?.pricePerPod || 0, mainToken.decimals); - const minFillAmount = TokenValue.fromBlockchain(order?.minFillAmount || 0, PODS.decimals); - const remainingBeans = amountOrder.sub(amountFilled); - // biome-ignore lint/correctness/useExhaustiveDependencies: All are derived from `order` - const { remainingPods, maxPlaceInLine } = useMemo(() => { + + const { selectedOrders, orderPositions, totalCapacity } = useMemo(() => { + if (!allOrders?.podOrders) return { selectedOrders: [], orderPositions: [], totalCapacity: 0 }; + + const orders = allOrders.podOrders.filter((order) => selectedOrderIds.includes(order.id)); + + let cumulative = 0; + const positions = orders.map((order) => { + const fillableAmount = calculateRemainingPods(order, mainToken.decimals); + const startPos = cumulative; + cumulative += fillableAmount; + + return { + orderId: order.id, + startPos, + endPos: cumulative, + capacity: fillableAmount, + order, + }; + }); + return { - remainingPods: pricePerPod.gt(0) ? remainingBeans.div(pricePerPod) : TokenValue.ZERO, - maxPlaceInLine: TokenValue.fromBlockchain(order?.maxPlaceInLine || 0, PODS.decimals), + selectedOrders: orders, + orderPositions: positions, + totalCapacity: cumulative, }; - }, [order]); + }, [allOrders?.podOrders, selectedOrderIds, mainToken.decimals]); - const harvestableIndex = useHarvestableIndex(); + const order = selectedOrders[0]; + const amount = podRange[1] - podRange[0]; const amountToSell = TokenValue.fromHuman(amount || 0, PODS.decimals); - const plotPosition = plot.length > 0 ? plot[0].index.sub(harvestableIndex) : TV.ZERO; - - // Plot selection handler with tracking - const handlePlotSelection = useCallback( - (plots: Plot[]) => { - trackSimpleEvent(ANALYTICS_EVENTS.MARKET.LISTING_PLOT_SELECTED, { - plot_count: plots.length, - previous_count: plot.length, - fill_action: "order", + + const ordersToFill = useMemo(() => { + const [rangeStart, rangeEnd] = podRange; + + return orderPositions + .filter((pos) => { + return pos.endPos > rangeStart && pos.startPos < rangeEnd; + }) + .map((pos) => { + const overlapStart = Math.max(pos.startPos, rangeStart); + const overlapEnd = Math.min(pos.endPos, rangeEnd); + const fillAmount = overlapEnd - overlapStart; + + return { + order: pos.order, + amount: fillAmount, + }; }); - setPlot(plots); - }, - [plot.length], - ); + }, [orderPositions, podRange]); + + // Calculate weighted average price per pod once for reuse + const weightedAvgPricePerPod = useMemo(() => { + if (ordersToFill.length === 0 || amount === 0) return 0; + + let totalValue = 0; + let totalPods = 0; + + ordersToFill.forEach(({ order, amount: fillAmount }) => { + const orderPricePerPod = TokenValue.fromBlockchain(order.pricePerPod, mainToken.decimals).toNumber(); + totalValue += orderPricePerPod * fillAmount; + totalPods += fillAmount; + }); + + return totalPods > 0 ? totalValue / totalPods : 0; + }, [ordersToFill, amount, mainToken.decimals]); + + const eligibleOrders = useMemo(() => { + if (!allOrders?.podOrders) return []; + + return allOrders.podOrders.filter((order) => isOrderEligible(order, mainToken.decimals, podLine)); + }, [allOrders?.podOrders, mainToken.decimals, podLine]); + + useEffect(() => { + if (totalCapacity !== prevTotalCapacityRef.current) { + setPodRange([0, totalCapacity]); + prevTotalCapacityRef.current = totalCapacity; + } + }, [totalCapacity]); + + const orderMarkers = useMemo(() => { + if (eligibleOrders.length === 0) return []; + + return eligibleOrders.map((order) => { + const orderMaxPlace = TokenValue.fromBlockchain(order.maxPlaceInLine, PODS.decimals); + const markerIndex = harvestableIndex.add(orderMaxPlace); + + return { + index: markerIndex, + pods: TokenValue.fromHuman(1, PODS.decimals), + harvestablePods: TokenValue.ZERO, + id: order.id, + } as Plot; + }); + }, [eligibleOrders, harvestableIndex]); + + const plotsForGraph = useMemo(() => { + return orderMarkers; + }, [orderMarkers]); + + const handlePodRangeChange = useCallback((values: number[]) => { + const newRange = values as [number, number]; + setPodRange(newRange); + + const rangeAmount = newRange[1] - newRange[0]; + if (rangeAmount === 0 && selectedOrderIdsRef.current.length > 0) { + setSelectedOrderIds([]); + } + }, []); - // reset form and invalidate pod orders/farmer plot queries const onSuccess = useCallback(() => { setPlot([]); - setAmount(0); + setPodRange([0, 0]); + setSelectedOrderIds([]); allQK.forEach((key) => queryClient.invalidateQueries({ queryKey: key })); }, [queryClient, allQK]); @@ -102,7 +236,6 @@ export default function FillOrder() { return; } - // Track pod order fill trackSimpleEvent(ANALYTICS_EVENTS.MARKET.POD_ORDER_FILL, { order_price_per_pod: Number(order.pricePerPod), order_max_place: Number(order.maxPlaceInLine), @@ -117,16 +250,16 @@ export default function FillOrder() { functionName: "fillPodOrder", args: [ { - orderer: order.farmer.id as Address, // order - account - fieldId: 0n, // plot - fieldId - maxPlaceInLine: BigInt(order.maxPlaceInLine), // order - maxPlaceInLine - pricePerPod: Number(order.pricePerPod), // order - pricePerPod - minFillAmount: BigInt(order.minFillAmount), // order - minFillAmount + orderer: order.farmer.id as Address, + fieldId: FIELD_ID, + maxPlaceInLine: BigInt(order.maxPlaceInLine), + pricePerPod: Number(order.pricePerPod), + minFillAmount: BigInt(order.minFillAmount), }, - plot[0].index.toBigInt(), // index of plot to sell - 0n, // start index within plot - amountToSell.toBigInt(), // amount of pods to sell - Number(balanceTo), //destination balance + plot[0].index.toBigInt(), + START_INDEX_WITHIN_PLOT, + amountToSell.toBigInt(), + Number(FarmToMode.INTERNAL), ], }); } catch (e) { @@ -137,109 +270,168 @@ export default function FillOrder() { } finally { setSubmitting(false); } - }, [order, plot, amountToSell, balanceTo, writeWithEstimateGas, setSubmitting, diamondAddress]); + }, [order, plot, amountToSell, writeWithEstimateGas, setSubmitting, diamondAddress]); + + const isOwnOrder = order && order.farmer.id === account.address?.toLowerCase(); - const isOwnOrder = order && order?.farmer.id === account.address?.toLowerCase(); - const disabled = !order || !plot[0] || !amount; + if (eligibleOrders.length === 0) { + return ( +
+
+

Select the order you want to fill (i):

+ +
+
+

There are no open orders that can be filled with your Pods.

+
+
+ ); + } return ( -
- {!order ? ( -
- Select an Order on the panel to the left +
+ {/* Order Markers Visualization - Click to select orders (multi-select) */} +
+

Select the orders you want to fill:

+ + {/* Pod Line Graph - Shows order markers (orange thin lines at maxPlaceInLine) */} + item.order.id)} + onPlotGroupSelect={(plotIndices) => { + // Multi-select toggle: add or remove clicked order + if (plotIndices.length > 0) { + const clickedOrderId = plotIndices[0]; + + setSelectedOrderIds((prev) => { + // If already selected, remove it + if (prev.includes(clickedOrderId)) { + return prev.filter((id) => id !== clickedOrderId); + } + // Otherwise, add it to selection + return [...prev, clickedOrderId]; + }); + } + }} + /> + + {/* Total Pods Available - Simple text below graph */} +
+

+ Total Pods that can be filled:{" "} + {formatter.noDec( + eligibleOrders.reduce((sum, order) => sum + calculateRemainingPods(order, mainToken.decimals), 0), + )}{" "} + Pods +

- ) : ( -
-
-
-

Buyer

-

{order.farmer.id.substring(0, 6)}

-
-
-

Place in Line

-

0 - {maxPlaceInLine.toHuman("short")}

-
-
-

Pods Requested

-
- {"pod -

{remainingPods.toHuman("short")}

-
-
-
-

Price per Pod

-
- {"pinto -

{pricePerPod.toHuman("short")}

-
-
-
-

Pinto Remaining

-
- {"pinto -

{remainingBeans.toHuman("short")}

-
-
-
+
+ + {/* Show cancel option if user owns an order */} + {isOwnOrder && order && ( + <> - {isOwnOrder ? ( - - ) : ( + + + )} + + {/* Show form only if orders are selected and not own order (even if amount is 0) */} + {!isOwnOrder && + selectedOrderIds.length > 0 && + (() => { + const maxAmount = totalCapacity; + + return ( <> -
-

Select Plot

- + {/* Amount Selection */} +
+ {/* Pods selected from slider */} +
+

+ Pods Selected in {ordersToFill.length} Order{ordersToFill.length !== 1 ? "s" : ""}: +

+

+ {formatter.number(amount, { minDecimals: 0, maxDecimals: 2 })} Pods +

+
+ + {/* Pod Range Selection - Multi-slider for selecting from which orders */} +
+
+

Select Range

+
+

+ {formatter.number(podRange[0], { minDecimals: 0, maxDecimals: 2 })} +

+
+ {maxAmount > 0 && ( + + )} +
+

+ {formatter.number(podRange[1], { minDecimals: 0, maxDecimals: 2 })} +

+
+
+
+ + {/* Order Info Display - Based on selected range */} +
+
+

+ {ordersToFill.length > 1 ? "Weighted Avg Price/Pod" : "Price/Pod"} +

+
+

+ {formatter.number(weightedAvgPricePerPod, { minDecimals: 2, maxDecimals: 6 })} Pinto +

+
+
+
-
+ + {/* OLD DESTINATION SECTION - COMMENTED OUT FOR POTENTIAL FUTURE USE */} + {/*

Destination

-
+
*/} +
- {!disabled && ( - + {ordersToFill.length > 0 && amount > 0 && ( + )}
- )} -
- )} + ); + })()}
); } -const ActionSummary = ({ - podAmount, - plotPosition, - pricePerPod, -}: { podAmount: TV; plotPosition: TV; pricePerPod: TV }) => { - const beansOut = podAmount.mul(pricePerPod); +const ActionSummary = ({ podAmount, pricePerPod }: { podAmount: number; pricePerPod: number }) => { + const beansOut = podAmount * pricePerPod; return (
-

- In exchange for {formatter.noDec(podAmount)} Pods @ {plotPosition.toHuman("short")} in Line, I will receive -

+

You will Receive:

{"order - {formatter.number(beansOut, { minDecimals: 0, maxDecimals: 2 })} Pinto + {formatter.number(beansOut, { minDecimals: 0, maxDecimals: 2 })} PINTO

From 04e566d2d923c00e29737156b8c45e913b45b5c8 Mon Sep 17 00:00:00 2001 From: feyyazcigim Date: Thu, 30 Oct 2025 05:22:13 +0300 Subject: [PATCH 08/50] Implement buy/order flow --- src/components/PodLineGraph.tsx | 18 ++ src/pages/market/actions/CreateOrder.tsx | 294 +++++++++++++++++------ 2 files changed, 233 insertions(+), 79 deletions(-) diff --git a/src/components/PodLineGraph.tsx b/src/components/PodLineGraph.tsx index 8285e9e12..d785b812c 100644 --- a/src/components/PodLineGraph.tsx +++ b/src/components/PodLineGraph.tsx @@ -29,6 +29,8 @@ interface PodLineGraphProps { selectedPlotIndices?: string[]; /** Optional: specify a partial range selection (absolute pod line positions) */ selectedPodRange?: { start: TokenValue; end: TokenValue }; + /** Optional: show order range overlay from 0 to this position (absolute index) */ + orderRangeEnd?: TokenValue; /** Callback when a plot group is clicked - receives all plot indices in the group */ onPlotGroupSelect?: (plotIndices: string[]) => void; /** Additional CSS classes */ @@ -160,6 +162,7 @@ export default function PodLineGraph({ plots: providedPlots, selectedPlotIndices = [], selectedPodRange, + orderRangeEnd, onPlotGroupSelect, className, }: PodLineGraphProps) { @@ -326,6 +329,21 @@ export default function PodLineGraph({ })}
+ {/* Order range overlay (from 0 to orderRangeEnd) */} + {orderRangeEnd?.gt(harvestableIndex) && ( +
+ )} + {/* Plot rectangles - grouped visually but individually interactive */}
{unharvestedPlots.map((group, groupIdx) => { diff --git a/src/pages/market/actions/CreateOrder.tsx b/src/pages/market/actions/CreateOrder.tsx index 26386ed30..e38c970b0 100644 --- a/src/pages/market/actions/CreateOrder.tsx +++ b/src/pages/market/actions/CreateOrder.tsx @@ -2,11 +2,14 @@ import podIcon from "@/assets/protocol/Pod.png"; import { TV, TokenValue } from "@/classes/TokenValue"; import { ComboInputField } from "@/components/ComboInputField"; import FrameAnimator from "@/components/LoadingSpinner"; +import PodLineGraph from "@/components/PodLineGraph"; import RoutingAndSlippageInfo, { useRoutingAndSlippageWarning } from "@/components/RoutingAndSlippageInfo"; import SimpleInputField from "@/components/SimpleInputField"; import SlippageButton from "@/components/SlippageButton"; import SmartSubmitButton from "@/components/SmartSubmitButton"; +import { Input } from "@/components/ui/Input"; import { Separator } from "@/components/ui/Separator"; +import { Slider } from "@/components/ui/Slider"; import { ANALYTICS_EVENTS } from "@/constants/analytics-events"; import { PODS } from "@/constants/internalTokens"; import createPodOrder from "@/encoders/createPodOrder"; @@ -27,21 +30,32 @@ import { trackSimpleEvent } from "@/utils/analytics"; import { formatter } from "@/utils/format"; import { tokensEqual } from "@/utils/token"; import { FarmFromMode, FarmToMode, Token } from "@/utils/types"; +import { cn } from "@/utils/utils"; import { useQueryClient } from "@tanstack/react-query"; import { useCallback, useEffect, useMemo, useState } from "react"; import { toast } from "sonner"; import { useAccount } from "wagmi"; -const pricePerPodValidation = { - maxValue: 1, - minValue: 0.000001, - maxDecimals: 6, +const PRICE_PER_POD_CONFIG = { + MAX: 1, + MIN: 0.000001, + DECIMALS: 6, + DECIMAL_MULTIPLIER: 1_000_000, // 10^6 for 6 decimals +} as const; + +const TextAdornment = ({ text, className }: { text: string; className?: string }) => { + return
{text}
; +}; + +// Utility function to format and truncate price per pod values +const formatPricePerPod = (value: number): number => { + return Math.floor(value * PRICE_PER_POD_CONFIG.DECIMAL_MULTIPLIER) / PRICE_PER_POD_CONFIG.DECIMAL_MULTIPLIER; }; -const maxPlaceInLineValidation = { - minValue: 1, - maxValue: 999999999999, - maxDecimals: 0, +// Utility function to clamp and format price per pod input +const clampAndFormatPrice = (value: number): number => { + const clamped = Math.max(PRICE_PER_POD_CONFIG.MIN, Math.min(PRICE_PER_POD_CONFIG.MAX, value)); + return formatPricePerPod(clamped); }; const useFilterTokens = () => { @@ -124,6 +138,7 @@ export default function CreateOrder() { const maxPlace = Number.parseInt(podIndex.toHuman()) - Number.parseInt(harvestableIndex.toHuman()) || 0; const [maxPlaceInLine, setMaxPlaceInLine] = useState(undefined); const [pricePerPod, setPricePerPod] = useState(undefined); + const [pricePerPodInput, setPricePerPodInput] = useState(""); // set preferred token useEffect(() => { @@ -144,7 +159,52 @@ export default function CreateOrder() { }); setTokenIn(newToken); }, - [tokenIn, mainToken], + [tokenIn], + ); + + // Price per pod slider handler + const handlePriceSliderChange = useCallback((value: number[]) => { + const formatted = formatPricePerPod(value[0]); + setPricePerPod(formatted); + setPricePerPodInput(formatted.toFixed(PRICE_PER_POD_CONFIG.DECIMALS)); + }, []); + + // Price per pod input handlers + const handlePriceInputChange = useCallback((e: React.ChangeEvent) => { + const value = e.target.value; + setPricePerPodInput(value); + }, []); + + const handlePriceInputBlur = useCallback(() => { + const numValue = Number.parseFloat(pricePerPodInput); + if (!Number.isNaN(numValue)) { + const formatted = clampAndFormatPrice(numValue); + setPricePerPod(formatted); + setPricePerPodInput(formatted.toString()); + } else { + setPricePerPodInput(""); + setPricePerPod(undefined); + } + }, [pricePerPodInput]); + + // Max place in line slider handler + const handleMaxPlaceSliderChange = useCallback((value: number[]) => { + const newValue = Math.floor(value[0]); + setMaxPlaceInLine(newValue > 0 ? newValue : undefined); + }, []); + + // Max place in line input handler + const handleMaxPlaceInputChange = useCallback( + (e: React.ChangeEvent) => { + const cleanValue = e.target.value.replace(/,/g, ""); + const value = Number.parseInt(cleanValue); + if (!Number.isNaN(value) && value > 0 && value <= maxPlace) { + setMaxPlaceInLine(value); + } else if (cleanValue === "") { + setMaxPlaceInLine(undefined); + } + }, + [maxPlace], ); // invalidate pod orders query @@ -152,6 +212,7 @@ export default function CreateOrder() { setAmountIn(""); setMaxPlaceInLine(undefined); setPricePerPod(undefined); + setPricePerPodInput(""); allQK.forEach((key) => queryClient.invalidateQueries({ queryKey: key })); }, [queryClient, allQK]); @@ -242,6 +303,7 @@ export default function CreateOrder() { diamondAddress, mainToken, swapBuild, + tokenIn.symbol, ]); const swapDataNotReady = (shouldSwap && (!swapData || !swapBuild)) || !!swapQuery.error; @@ -250,83 +312,157 @@ export default function CreateOrder() { const formIsFilled = !!pricePerPod && !!maxPlaceInLine && !!account && amountInTV.gt(0); const disabled = !formIsFilled || swapDataNotReady; + // Calculate orderRangeEnd for PodLineGraph overlay (memoized) + const orderRangeEnd = useMemo(() => { + if (!maxPlaceInLine) return undefined; + return harvestableIndex.add(TokenValue.fromHuman(maxPlaceInLine, PODS.decimals)); + }, [maxPlaceInLine, harvestableIndex]); + return (
-
+ {/* PodLineGraph Visualization */} + + + {/* Place in Line Slider */} +

I want to order Pods with a Place in Line up to:

- -
-
-

Amount I am willing to pay for each Pod

- -
-
-
-

Order Using

- -
- - {shouldSwap && amountInTV.gt(0) && ( - + {maxPlace === 0 ? ( +

No Pods in Line currently available to order.

+ ) : ( +
+
+

0

+ {maxPlace > 0 && ( + + )} +

{formatter.noDec(maxPlace)}

+
+ e.target.select()} + placeholder={formatter.noDec(maxPlace)} + outlined + containerClassName="w-[108px]" + className="" + disabled={maxPlace === 0} + /> +
)} - {slippageWarning}
-
- - {disabled && formIsFilled && ( -
- + + {/* Show these sections only when maxPlaceInLine is greater than 0 */} + {maxPlaceInLine !== undefined && maxPlaceInLine > 0 && ( +
+ {/* Price Per Pod */} +
+

Amount I am willing to pay for each Pod for:

+
+
+

0

+ +

1

+
+ e.target.select()} + placeholder="0.00" + outlined + containerClassName="" + className="" + endIcon={} + /> +
+
+ + {/* Order Using Section */} +
+
+

Order Using

+ +
+ + {shouldSwap && amountInTV.gt(0) && ( + + )} + {slippageWarning} +
+
+ + {disabled && formIsFilled && ( +
+ +
+ )} + {!disabled && ( + + )} +
+ {}} + className="flex-1" + /> + +
- )} - {!disabled && ( - - )} -
-
-
+ )}
); } From 58f68caa67a5b04868a6ae8df5645ad86f1e5daa Mon Sep 17 00:00:00 2001 From: feyyazcigim Date: Thu, 30 Oct 2025 06:55:14 +0300 Subject: [PATCH 09/50] Implement buy/fill UI --- src/components/PodLineGraph.tsx | 18 + src/pages/market/actions/FillListing.tsx | 537 ++++++++++++++++++----- 2 files changed, 436 insertions(+), 119 deletions(-) diff --git a/src/components/PodLineGraph.tsx b/src/components/PodLineGraph.tsx index d785b812c..2d7eaffa3 100644 --- a/src/components/PodLineGraph.tsx +++ b/src/components/PodLineGraph.tsx @@ -31,6 +31,8 @@ interface PodLineGraphProps { selectedPodRange?: { start: TokenValue; end: TokenValue }; /** Optional: show order range overlay from 0 to this position (absolute index) */ orderRangeEnd?: TokenValue; + /** Optional: show range overlay from start to end position (absolute indices) */ + rangeOverlay?: { start: TokenValue; end: TokenValue }; /** Callback when a plot group is clicked - receives all plot indices in the group */ onPlotGroupSelect?: (plotIndices: string[]) => void; /** Additional CSS classes */ @@ -163,6 +165,7 @@ export default function PodLineGraph({ selectedPlotIndices = [], selectedPodRange, orderRangeEnd, + rangeOverlay, onPlotGroupSelect, className, }: PodLineGraphProps) { @@ -344,6 +347,21 @@ export default function PodLineGraph({ /> )} + {/* Range overlay (from start to end) */} + {rangeOverlay?.start.gte(harvestableIndex) && rangeOverlay?.end.gt(rangeOverlay.start) && ( +
+ )} + {/* Plot rectangles - grouped visually but individually interactive */}
{unharvestedPlots.map((group, groupIdx) => { diff --git a/src/pages/market/actions/FillListing.tsx b/src/pages/market/actions/FillListing.tsx index 062a2934c..a5334d995 100644 --- a/src/pages/market/actions/FillListing.tsx +++ b/src/pages/market/actions/FillListing.tsx @@ -1,12 +1,14 @@ import podIcon from "@/assets/protocol/Pod.png"; -import pintoIcon from "@/assets/tokens/PINTO.png"; import { TV, TokenValue } from "@/classes/TokenValue"; import { ComboInputField } from "@/components/ComboInputField"; import FrameAnimator from "@/components/LoadingSpinner"; +import PodLineGraph from "@/components/PodLineGraph"; import RoutingAndSlippageInfo, { useRoutingAndSlippageWarning } from "@/components/RoutingAndSlippageInfo"; import SlippageButton from "@/components/SlippageButton"; import SmartSubmitButton from "@/components/SmartSubmitButton"; +import { Input } from "@/components/ui/Input"; import { Separator } from "@/components/ui/Separator"; +import { MultiSlider, Slider } from "@/components/ui/Slider"; import { ANALYTICS_EVENTS } from "@/constants/analytics-events"; import { PODS } from "@/constants/internalTokens"; import fillPodListing from "@/encoders/fillPodListing"; @@ -24,22 +26,40 @@ import usePriceImpactSummary from "@/hooks/wells/usePriceImpactSummary"; import usePodListings from "@/state/market/usePodListings"; import { useFarmerBalances } from "@/state/useFarmerBalances"; import { useFarmerPlotsQuery } from "@/state/useFarmerField"; -import { useHarvestableIndex } from "@/state/useFieldData"; +import { useHarvestableIndex, usePodIndex } from "@/state/useFieldData"; import { useQueryKeys } from "@/state/useQueryKeys"; import useTokenData from "@/state/useTokenData"; import { trackSimpleEvent } from "@/utils/analytics"; import { formatter } from "@/utils/format"; import { toSafeTVFromHuman } from "@/utils/number"; import { tokensEqual } from "@/utils/token"; -import { FarmFromMode, FarmToMode, Token } from "@/utils/types"; -import { getBalanceFromMode } from "@/utils/utils"; +import { FarmFromMode, FarmToMode, Plot, Token } from "@/utils/types"; +import { cn, getBalanceFromMode } from "@/utils/utils"; import { useQueryClient } from "@tanstack/react-query"; import { useCallback, useEffect, useMemo, useState } from "react"; import { useNavigate, useParams } from "react-router-dom"; import { toast } from "sonner"; import { Address } from "viem"; import { useAccount } from "wagmi"; -import CancelListing from "./CancelListing"; + +// Configuration constants +const PRICE_PER_POD_CONFIG = { + MAX: 1, + MIN: 0, + DECIMALS: 6, + DECIMAL_MULTIPLIER: 1_000_000, // 10^6 for 6 decimals +} as const; + +const PRICE_SLIDER_STEP = 0.001; + +const TextAdornment = ({ text, className }: { text: string; className?: string }) => { + return
{text}
; +}; + +// Utility function to format and truncate price per pod values +const formatPricePerPod = (value: number): number => { + return Math.floor(value * PRICE_PER_POD_CONFIG.DECIMAL_MULTIPLIER) / PRICE_PER_POD_CONFIG.DECIMAL_MULTIPLIER; +}; const useFilterTokens = () => { const tokens = useTokenMap(); @@ -93,6 +113,20 @@ export default function FillListing() { const [balanceFrom, setBalanceFrom] = useState(FarmFromMode.INTERNAL_EXTERNAL); const [slippage, setSlippage] = useState(0.1); + // Price per pod filter state + const [maxPricePerPod, setMaxPricePerPod] = useState(0); + const [maxPricePerPodInput, setMaxPricePerPodInput] = useState("0"); + + // Place in line range state + const podIndex = usePodIndex(); + const maxPlace = Number.parseInt(podIndex.toHuman()) - Number.parseInt(harvestableIndex.toHuman()) || 0; + const [placeInLineRange, setPlaceInLineRange] = useState<[number, number]>([0, maxPlace]); + + // Update place in line range when maxPlace changes + useEffect(() => { + setPlaceInLineRange((prev) => [prev[0], maxPlace]); + }, [maxPlace]); + const isUsingMain = tokensEqual(tokenIn, mainToken); const amountInTV = useSafeTokenValue(amountIn, tokenIn); @@ -137,7 +171,146 @@ export default function FillListing() { }); setTokenIn(newToken); }, - [tokenIn, mainToken], + [tokenIn], + ); + + // Price per pod slider handler + const handlePriceSliderChange = useCallback((value: number[]) => { + const formatted = formatPricePerPod(value[0]); + setMaxPricePerPod(formatted); + setMaxPricePerPodInput(formatted.toFixed(PRICE_PER_POD_CONFIG.DECIMALS)); + }, []); + + // Price per pod input handlers + const handlePriceInputChange = useCallback((e: React.ChangeEvent) => { + const value = e.target.value; + setMaxPricePerPodInput(value); + }, []); + + const handlePriceInputBlur = useCallback(() => { + const numValue = Number.parseFloat(maxPricePerPodInput); + if (!Number.isNaN(numValue)) { + const clamped = Math.max(0, Math.min(PRICE_PER_POD_CONFIG.MAX, numValue)); + const formatted = formatPricePerPod(clamped); + setMaxPricePerPod(formatted); + setMaxPricePerPodInput(formatted.toString()); + } else { + setMaxPricePerPodInput("0"); + setMaxPricePerPod(0); + } + }, [maxPricePerPodInput]); + + // Place in line range handler + const handlePlaceInLineRangeChange = useCallback((value: number[]) => { + const [min, max] = value; + setPlaceInLineRange([Math.floor(min), Math.floor(max)]); + }, []); + + // Place in line input handlers + const handleMinPlaceInputChange = useCallback( + (e: React.ChangeEvent) => { + const cleanValue = e.target.value.replace(/,/g, ""); + const value = Number.parseInt(cleanValue); + if (!Number.isNaN(value) && value >= 0 && value <= maxPlace) { + setPlaceInLineRange([value, placeInLineRange[1]]); + } else if (cleanValue === "") { + setPlaceInLineRange([0, placeInLineRange[1]]); + } + }, + [maxPlace, placeInLineRange], + ); + + const handleMaxPlaceInputChange = useCallback( + (e: React.ChangeEvent) => { + const cleanValue = e.target.value.replace(/,/g, ""); + const value = Number.parseInt(cleanValue); + if (!Number.isNaN(value) && value >= 0 && value <= maxPlace) { + setPlaceInLineRange([placeInLineRange[0], value]); + } else if (cleanValue === "") { + setPlaceInLineRange([placeInLineRange[0], maxPlace]); + } + }, + [maxPlace, placeInLineRange], + ); + + /** + * Convert all listings to Plot objects and determine eligible ones + * Eligible = matching both price criteria AND place in line range + */ + const { listingPlots, eligibleListingIds, rangeOverlay } = useMemo(() => { + if (!allListings?.podListings) { + return { listingPlots: [], eligibleListingIds: [], rangeOverlay: undefined }; + } + + // Convert all listings to Plot objects for graph visualization + const plots: Plot[] = allListings.podListings.map((listing) => ({ + index: TokenValue.fromBlockchain(listing.index, PODS.decimals), + pods: TokenValue.fromBlockchain(listing.remainingAmount, PODS.decimals), + harvestedPods: TokenValue.ZERO, + harvestablePods: TokenValue.ZERO, + id: listing.id, + idHex: listing.id, + })); + + // Calculate place in line boundaries for filtering + const minPlaceIndex = harvestableIndex.add(TokenValue.fromHuman(placeInLineRange[0], PODS.decimals)); + const maxPlaceIndex = harvestableIndex.add(TokenValue.fromHuman(placeInLineRange[1], PODS.decimals)); + + // Determine eligible listings (shown as green on graph) + // When maxPricePerPod is 0, no listings are eligible (all show as orange) + // When maxPricePerPod > 0, filter by both price and place in line + const eligible: string[] = + maxPricePerPod > 0 + ? allListings.podListings + .filter((listing) => { + const listingPrice = TokenValue.fromBlockchain(listing.pricePerPod, mainToken.decimals).toNumber(); + const listingIndex = TokenValue.fromBlockchain(listing.index, PODS.decimals); + + // Listing must match both criteria to be eligible + return ( + listingPrice <= maxPricePerPod && listingIndex.gte(minPlaceIndex) && listingIndex.lte(maxPlaceIndex) + ); + }) + .map((listing) => listing.id) + : []; + + // Calculate range overlay for visual feedback on graph + const overlay = { + start: harvestableIndex.add(TokenValue.fromHuman(placeInLineRange[0], PODS.decimals)), + end: harvestableIndex.add(TokenValue.fromHuman(placeInLineRange[1], PODS.decimals)), + }; + + return { listingPlots: plots, eligibleListingIds: eligible, rangeOverlay: overlay }; + }, [allListings, maxPricePerPod, placeInLineRange, mainToken.decimals, harvestableIndex]); + + // Calculate open available pods count (eligible listings only - already filtered by price AND place) + const openAvailablePods = useMemo(() => { + if (!allListings?.podListings.length) return 0; + + const eligibleSet = new Set(eligibleListingIds); + + return allListings.podListings.reduce((sum, listing) => { + // eligibleListingIds already contains listings that match both price and place criteria + if (!eligibleSet.has(listing.id)) return sum; + + const remainingAmount = TokenValue.fromBlockchain(listing.remainingAmount, PODS.decimals); + return sum + remainingAmount.toNumber(); + }, 0); + }, [allListings, eligibleListingIds]); + + // Plot selection handler - navigate to selected listing + const handlePlotGroupSelect = useCallback( + (plotIndices: string[]) => { + if (plotIndices.length > 0) { + const listingId = plotIndices[0]; + // Extract the index from the listing ID (format: "0-{index}") + const indexPart = listingId.split("-")[1]; + if (indexPart) { + navigate(`/market/pods/buy/${indexPart}`); + } + } + }, + [navigate], ); // reset form and invalidate pod listings/farmer plot queries @@ -154,29 +327,72 @@ export default function FillListing() { successCallback: onSuccess, }); - const placeInLine = TokenValue.fromBlockchain(listing?.index || 0n, PODS.decimals).sub(harvestableIndex); - const podsAvailable = TokenValue.fromBlockchain(listing?.amount || 0n, PODS.decimals); - const pricePerPod = TokenValue.fromBlockchain(listing?.pricePerPod || 0n, mainToken.decimals); - const mainTokensToFill = podsAvailable.mul(pricePerPod); const mainTokensIn = isUsingMain ? toSafeTVFromHuman(amountIn, mainToken.decimals) : swapData?.buyAmount; + // Calculate weighted average for eligible listings + const eligibleSummary = useMemo(() => { + if (!allListings?.podListings.length || eligibleListingIds.length === 0) { + return null; + } + + const eligibleSet = new Set(eligibleListingIds); + const eligibleListings = allListings.podListings.filter((l) => eligibleSet.has(l.id)); + + let totalValue = 0; + let totalPods = 0; + let totalPlaceInLine = 0; + + eligibleListings.forEach((listing) => { + const listingPrice = TokenValue.fromBlockchain(listing.pricePerPod, mainToken.decimals).toNumber(); + const listingPods = TokenValue.fromBlockchain(listing.remainingAmount, PODS.decimals).toNumber(); + const listingPlace = TokenValue.fromBlockchain(listing.index, PODS.decimals).sub(harvestableIndex).toNumber(); + + totalValue += listingPrice * listingPods; + totalPods += listingPods; + totalPlaceInLine += listingPlace * listingPods; + }); + + const avgPricePerPod = totalPods > 0 ? totalValue / totalPods : 0; + const avgPlaceInLine = totalPods > 0 ? totalPlaceInLine / totalPods : 0; + + return { + avgPricePerPod: TokenValue.fromHuman(avgPricePerPod, mainToken.decimals), + avgPlaceInLine: TokenValue.fromHuman(avgPlaceInLine, PODS.decimals), + totalPods, + }; + }, [allListings, eligibleListingIds, mainToken.decimals, harvestableIndex]); + + // Calculate total tokens needed to fill eligible listings + const totalMainTokensToFill = useMemo(() => { + if (!eligibleSummary) return TokenValue.ZERO; + return eligibleSummary.avgPricePerPod.mul(TokenValue.fromHuman(eligibleSummary.totalPods, PODS.decimals)); + }, [eligibleSummary]); + const tokenInBalance = farmerBalances.balances.get(tokenIn); - const { data: maxFillAmount } = useMaxBuy(tokenIn, slippage, mainTokensToFill); + const { data: maxFillAmount } = useMaxBuy(tokenIn, slippage, totalMainTokensToFill); const balanceFromMode = getBalanceFromMode(tokenInBalance, balanceFrom); const balanceExceedsMax = balanceFromMode.gt(0) && maxFillAmount && balanceFromMode.gte(maxFillAmount); const onSubmit = useCallback(async () => { + // Validate requirements if (!listing) { + toast.error("No listing selected"); throw new Error("Listing not found"); } if (!account.address) { + toast.error("Please connect your wallet"); throw new Error("Signer required"); } + if (!eligibleListingIds.length) { + toast.error("No eligible listings available"); + throw new Error("No eligible listings"); + } // Track pod listing fill trackSimpleEvent(ANALYTICS_EVENTS.MARKET.POD_LIST_FILL, { payment_token: tokenIn.symbol, balance_source: balanceFrom, + eligible_listings_count: eligibleListingIds.length, }); try { @@ -245,7 +461,7 @@ export default function FillListing() { } }, [ listing, - account, + account.address, amountIn, balanceFrom, swapBuild, @@ -255,134 +471,217 @@ export default function FillListing() { value, diamondAddress, mainToken, + eligibleListingIds.length, + tokenIn.symbol, ]); - const isOwnListing = listing && listing?.farmer.id === account.address?.toLowerCase(); - const disabled = !mainTokensIn || mainTokensIn.eq(0); + // Disable submit if no tokens entered, no eligible listings, or no listing selected + const disabled = !mainTokensIn || mainTokensIn.eq(0) || !eligibleListingIds.length || !listing; return ( -
- {!listing ? ( -
- Select a Listing on the panel to the left +
+ {/* PodLineGraph Visualization */} +
+ +
+ + {/* Max Price Per Pod Filter Section */} +
+

I am willing to buy Pods up to:

+
+
+

0

+ +

1

+
+ e.target.select()} + placeholder="0" + outlined + containerClassName="" + className="" + endIcon={} + />
- ) : ( - <> -
-
-
-

Seller

-

{listing.farmer.id.substring(0, 6)}

-
-
-

Place in Line

-

{placeInLine.toHuman("short")}

-
-
-

Pods Available

-
- {"pod -

{podsAvailable.toHuman("short")}

-
-
-
-

Price per Pod

-
- {"pinto -

{pricePerPod.toHuman()}

-
-
-
-

Pinto to Fill

-
- {"pinto -

{mainTokensToFill.toHuman()}

-
-
+
+ + {/* Place in Line Range Selector */} + {maxPlace > 0 && ( +
+

At a Place in Line between:

+ {/* Slider row */} +
+

0

+ +

{formatter.noDec(maxPlace)}

+
+ {/* Input row */} +
+ e.target.select()} + placeholder="0" + outlined + containerClassName="flex-1" + className="" + /> + — + e.target.select()} + placeholder={formatter.noDec(maxPlace)} + outlined + containerClassName="flex-1" + className="" + /> +
+
+ )} + + {/* Open Available Pods Display */} +
+

+ Open available pods: {formatter.noDec(openAvailablePods)} Pods +

+
+ + {/* Fill Using Section - Only show if there are eligible listings */} + {eligibleListingIds.length > 0 && ( +
+
+
+

Fill Using

+ +
+ + {!isUsingMain && amountInTV.gt(0) && ( + + )} + {slippageWarning} +
+
+ + {disabled && Number(amountIn) > 0 && ( +
+
- - {isOwnListing ? ( - - ) : ( - <> -
-
-

Fill Using

- -
- - {!isUsingMain && amountInTV.gt(0) && ( - - )} - {slippageWarning} -
-
- - {disabled && Number(amountIn) > 0 && ( -
- -
- )} - {!disabled && ( - - )} - -
- - )} + )} + {!disabled && eligibleSummary && mainTokensIn && ( + + )} +
+ {}} + className="flex-1" + /> +
- +
+
)}
); } +/** + * Displays summary of the fill transaction + * Shows estimated pods to receive, average position, and pricing details + */ const ActionSummary = ({ pricePerPod, plotPosition, beanAmount, -}: { pricePerPod: TV; plotPosition: TV; beanAmount: TV }) => { - const podAmount = beanAmount.div(pricePerPod); +}: { + pricePerPod: TV; + plotPosition: TV; + beanAmount: TV; +}) => { + // Calculate estimated pods to receive + const estimatedPods = beanAmount.div(pricePerPod); + return (
-

In exchange for {formatter.noDec(beanAmount)} Pinto, I will receive

+

You will receive approximately

- {"order - {formatter.number(podAmount, { minDecimals: 0, maxDecimals: 2 })} Pods + Pod icon + {formatter.number(estimatedPods, { minDecimals: 0, maxDecimals: 2 })} Pods +

+

@ average {plotPosition.toHuman("short")} in Line

+

+ for {formatter.number(beanAmount, { minDecimals: 0, maxDecimals: 2 })} Pinto at an average price of{" "} + {formatter.number(pricePerPod, { minDecimals: 2, maxDecimals: 6 })} per Pod

-

@ {plotPosition.toHuman("short")} in Line

); From b15c5ea84ada707f3b8abdfa8eb4ddf7496dcfc3 Mon Sep 17 00:00:00 2001 From: feyyazcigim Date: Thu, 30 Oct 2025 23:00:51 +0300 Subject: [PATCH 10/50] Fix sell/list calculations --- src/pages/market/actions/CreateListing.tsx | 85 +++++++++++++- src/pages/market/actions/FillListing.tsx | 130 ++++++++++----------- 2 files changed, 145 insertions(+), 70 deletions(-) diff --git a/src/pages/market/actions/CreateListing.tsx b/src/pages/market/actions/CreateListing.tsx index 42be8d6c1..59d5390e9 100644 --- a/src/pages/market/actions/CreateListing.tsx +++ b/src/pages/market/actions/CreateListing.tsx @@ -27,6 +27,14 @@ import { useNavigate } from "react-router-dom"; import { toast } from "sonner"; import { useAccount } from "wagmi"; +interface PodListingData { + plot: Plot; + index: TokenValue; + start: TokenValue; // plot içindeki relative start + end: TokenValue; // plot içindeki relative end + amount: TokenValue; // list edilecek pod miktarı +} + const PRICE_PER_POD_CONFIG = { MAX: 1, MIN: 0.000001, @@ -132,6 +140,46 @@ export default function CreateListing() { }; }, [plot, podRange]); + // Convert pod range (offset based) to individual plot listing data + const listingData = useMemo((): PodListingData[] => { + if (plot.length === 0 || amount === 0) return []; + + const sortedPlots = [...plot].sort((a, b) => a.index.sub(b.index).toNumber()); + const result: PodListingData[] = []; + + // Calculate cumulative pod amounts to find which plots are affected + let cumulativeStart = 0; + const rangeStart = podRange[0]; + const rangeEnd = podRange[1]; + + for (const p of sortedPlots) { + const plotPods = p.pods.toNumber(); + const cumulativeEnd = cumulativeStart + plotPods; + + // Check if this plot is within the selected range + if (rangeEnd > cumulativeStart && rangeStart < cumulativeEnd) { + // Calculate the intersection + const startInPlot = Math.max(0, rangeStart - cumulativeStart); + const endInPlot = Math.min(plotPods, rangeEnd - cumulativeStart); + const amountInPlot = endInPlot - startInPlot; + + if (amountInPlot > 0) { + result.push({ + plot: p, + index: p.index, + start: TokenValue.fromHuman(startInPlot, PODS.decimals), + end: TokenValue.fromHuman(endInPlot, PODS.decimals), + amount: TokenValue.fromHuman(amountInPlot, PODS.decimals), + }); + } + } + + cumulativeStart = cumulativeEnd; + } + + return result; + }, [plot, podRange, amount]); + // Plot selection handler with tracking const handlePlotSelection = useCallback( (plots: Plot[]) => { @@ -274,7 +322,7 @@ export default function CreateListing() { ]); // ui state - const disabled = !pricePerPod || !amount || !account || plot.length !== 1; + const disabled = !pricePerPod || !amount || !account || plot.length === 0; return (
@@ -402,7 +450,14 @@ export default function CreateListing() { )}
- {!disabled && } + {!disabled && ( + + )} { + harvestableIndex, +}: { podAmount: number; listingData: PodListingData[]; pricePerPod: number; harvestableIndex: TokenValue }) => { const beansOut = podAmount * pricePerPod; + // Format line positions + const formatLinePositions = (): string => { + if (listingData.length === 0) return ""; + if (listingData.length === 1) { + const placeInLine = listingData[0].index.sub(harvestableIndex); + return `@ ${placeInLine.toHuman("short")} in Line`; + } + + // Multiple plots: show range + const sortedData = [...listingData].sort((a, b) => a.index.sub(b.index).toNumber()); + const firstPlace = sortedData[0].index.sub(harvestableIndex); + const lastPlace = sortedData[sortedData.length - 1].index.sub(harvestableIndex); + + if (firstPlace.eq(lastPlace)) { + return `@ ${firstPlace.toHuman("short")} in Line`; + } + return `@ ${firstPlace.toHuman("short")} - ${lastPlace.toHuman("short")} in Lines`; + }; + return (

If my listing is filled, I will receive

@@ -431,7 +506,7 @@ const ActionSummary = ({ {formatter.number(beansOut, { minDecimals: 0, maxDecimals: 2 })} Pinto

- in exchange for {formatter.noDec(podAmount)} Pods @ {plotPosition.toHuman("short")} in Line. + in exchange for {formatter.noDec(podAmount)} Pods {formatLinePositions()}.

diff --git a/src/pages/market/actions/FillListing.tsx b/src/pages/market/actions/FillListing.tsx index a5334d995..563c3e9b5 100644 --- a/src/pages/market/actions/FillListing.tsx +++ b/src/pages/market/actions/FillListing.tsx @@ -537,7 +537,9 @@ export default function FillListing() { max={maxPlace} className="flex-1 min-w-0" /> -

{formatter.noDec(maxPlace)}

+

+ {formatter.noDec(maxPlace)} +

{/* Input row */}
@@ -578,75 +580,73 @@ export default function FillListing() { {/* Fill Using Section - Only show if there are eligible listings */} {eligibleListingIds.length > 0 && (
-
-
-

Fill Using

- -
- - {!isUsingMain && amountInTV.gt(0) && ( - - )} - {slippageWarning} -
-
- - {disabled && Number(amountIn) > 0 && ( -
- +
+
+

Fill Using

+
- )} - {!disabled && eligibleSummary && mainTokensIn && ( - - )} -
- {}} - className="flex-1" - /> - + {!isUsingMain && amountInTV.gt(0) && ( + + )} + {slippageWarning} +
+
+ + {disabled && Number(amountIn) > 0 && ( +
+ +
+ )} + {!disabled && eligibleSummary && mainTokensIn && ( + + )} +
+ {}} + className="flex-1" + /> + +
-
)}
From 3693f7cd4762bf4a3c1aaa88cb4c240838c68d27 Mon Sep 17 00:00:00 2001 From: feyyazcigim Date: Thu, 30 Oct 2025 23:28:32 +0300 Subject: [PATCH 11/50] Fix sell/list willing amount decimal --- src/pages/market/actions/CreateListing.tsx | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/src/pages/market/actions/CreateListing.tsx b/src/pages/market/actions/CreateListing.tsx index 59d5390e9..b99ebd67f 100644 --- a/src/pages/market/actions/CreateListing.tsx +++ b/src/pages/market/actions/CreateListing.tsx @@ -57,6 +57,11 @@ const clampAndFormatPrice = (value: number): number => { return formatPricePerPod(clamped); }; +// Utility function to remove trailing zeros from formatted price +const removeTrailingZeros = (value: string): string => { + return value.includes(".") ? value.replace(/\.?0+$/, "") : value; +}; + export default function CreateListing() { const { address: account } = useAccount(); const diamondAddress = useProtocolAddress(); @@ -220,7 +225,7 @@ export default function CreateListing() { const handlePriceSliderChange = useCallback((value: number[]) => { const formatted = formatPricePerPod(value[0]); setPricePerPod(formatted); - setPricePerPodInput(formatted.toFixed(PRICE_PER_POD_CONFIG.DECIMALS)); + setPricePerPodInput(removeTrailingZeros(formatted.toFixed(PRICE_PER_POD_CONFIG.DECIMALS))); }, []); // Price per pod input handlers @@ -234,7 +239,7 @@ export default function CreateListing() { if (!Number.isNaN(numValue)) { const formatted = clampAndFormatPrice(numValue); setPricePerPod(formatted); - setPricePerPodInput(formatted.toString()); + setPricePerPodInput(removeTrailingZeros(formatted.toFixed(PRICE_PER_POD_CONFIG.DECIMALS))); } else { setPricePerPodInput(""); setPricePerPod(undefined); @@ -404,7 +409,7 @@ export default function CreateListing() { Date: Fri, 31 Oct 2025 02:43:10 +0300 Subject: [PATCH 12/50] Batch listing and success component for sell/list --- src/pages/market/actions/CreateListing.tsx | 126 +++++++++++++++++---- 1 file changed, 103 insertions(+), 23 deletions(-) diff --git a/src/pages/market/actions/CreateListing.tsx b/src/pages/market/actions/CreateListing.tsx index b99ebd67f..6786ecee7 100644 --- a/src/pages/market/actions/CreateListing.tsx +++ b/src/pages/market/actions/CreateListing.tsx @@ -4,6 +4,7 @@ import ComboPlotInputField from "@/components/ComboPlotInputField"; import PodLineGraph from "@/components/PodLineGraph"; import SimpleInputField from "@/components/SimpleInputField"; import SmartSubmitButton from "@/components/SmartSubmitButton"; +import { Button } from "@/components/ui/Button"; import { Input } from "@/components/ui/Input"; import { Separator } from "@/components/ui/Separator"; import { MultiSlider, Slider } from "@/components/ui/Slider"; @@ -25,6 +26,7 @@ import { useQueryClient } from "@tanstack/react-query"; import { useCallback, useMemo, useState } from "react"; import { useNavigate } from "react-router-dom"; import { toast } from "sonner"; +import { encodeFunctionData } from "viem"; import { useAccount } from "wagmi"; interface PodListingData { @@ -80,6 +82,9 @@ export default function CreateListing() { const [pricePerPod, setPricePerPod] = useState(undefined); const [pricePerPodInput, setPricePerPodInput] = useState(""); const [balanceTo, setBalanceTo] = useState(FarmToMode.EXTERNAL); // Default: Wallet Balance (toggle off) + const [isSuccessful, setIsSuccessful] = useState(false); + const [successAmount, setSuccessAmount] = useState(null); + const [successPrice, setSuccessPrice] = useState(null); const podIndex = usePodIndex(); const maxExpiration = Number.parseInt(podIndex.toHuman()) - Number.parseInt(harvestableIndex.toHuman()) || 0; const expiresIn = maxExpiration; // Auto-set to max expiration @@ -248,14 +253,16 @@ export default function CreateListing() { // reset form and invalidate pod listing query const onSuccess = useCallback(() => { - navigate(`/market/pods/buy/${plot[0].index.toBigInt()}`); + setSuccessAmount(amount); + setSuccessPrice(pricePerPod || 0); + setIsSuccessful(true); setPlot([]); setAmount(0); setPodRange([0, 0]); setPricePerPod(undefined); setPricePerPodInput(""); allQK.forEach((key) => queryClient.invalidateQueries({ queryKey: key })); - }, [navigate, plot, queryClient, allQK]); + }, [amount, pricePerPod, queryClient, allQK]); // state for toast txns const { isConfirming, writeWithEstimateGas, submitting, setSubmitting } = useTransaction({ @@ -265,42 +272,89 @@ export default function CreateListing() { }); const onSubmit = useCallback(async () => { - if (!pricePerPod || pricePerPod <= 0 || !expiresIn || !amount || amount <= 0 || !account || plot.length !== 1) { + if ( + !pricePerPod || + pricePerPod <= 0 || + !expiresIn || + !amount || + amount <= 0 || + !account || + listingData.length === 0 + ) { return; } // Track pod listing creation trackSimpleEvent(ANALYTICS_EVENTS.MARKET.POD_LIST_CREATE, { has_price_per_pod: !!pricePerPod, + listing_count: listingData.length, plot_position_millions: plot.length > 0 ? Math.round(plotPosition.div(1_000_000).toNumber()) : 0, }); const _pricePerPod = TokenValue.fromHuman(pricePerPod, mainToken.decimals); const _expiresIn = TokenValue.fromHuman(expiresIn, PODS.decimals); - const index = plot[0].index; - const start = TokenValue.fromHuman(0, PODS.decimals); - const _amount = TokenValue.fromHuman(amount, PODS.decimals); const maxHarvestableIndex = _expiresIn.add(harvestableIndex); try { setSubmitting(true); - toast.loading("Creating Listing..."); + toast.loading(`Creating ${listingData.length} Listing${listingData.length > 1 ? "s" : ""}...`); + + // Log listing data for debugging + // console.log("=== CREATE LISTING DATA ==="); + // console.log(`Total listings to create: ${listingData.length}`); + // console.log(`Price per pod: ${pricePerPod} ${mainToken.symbol}`); + + const farmData: `0x${string}`[] = []; + + // Create a listing call for each plot + for (const data of listingData) { + // console.log(`\n--- Listing ${index + 1} ---`); + // console.log(`Plot Index: ${data.index.toHuman()}`); + // console.log(`Start (relative): ${data.start.toHuman()}`); + // console.log(`End (relative): ${data.end.toHuman()}`); + // console.log(`Amount: ${data.amount.toHuman()} pods`); + // console.log(`Place in line: ${data.index.sub(harvestableIndex).toHuman()}`); + + const listingArgs = { + lister: account, + fieldId: 0n, + index: data.index.toBigInt(), + start: data.start.toBigInt(), + podAmount: data.amount.toBigInt(), + pricePerPod: Number(_pricePerPod), + maxHarvestableIndex: maxHarvestableIndex.toBigInt(), + minFillAmount: minFill.toBigInt(), + mode: Number(balanceTo), + }; + + // console.log("Encoded args:", { + // lister: listingArgs.lister, + // fieldId: listingArgs.fieldId.toString(), + // index: listingArgs.index.toString(), + // start: listingArgs.start.toString(), + // podAmount: listingArgs.podAmount.toString(), + // pricePerPod: listingArgs.pricePerPod, + // maxHarvestableIndex: listingArgs.maxHarvestableIndex.toString(), + // minFillAmount: listingArgs.minFillAmount.toString(), + // mode: listingArgs.mode, + // }); + + const listingCall = encodeFunctionData({ + abi: beanstalkAbi, + functionName: "createPodListing", + args: [listingArgs], + }); + farmData.push(listingCall); + } + + // console.log(`\nTotal farm calls: ${farmData.length}`); + // console.log("=========================\n"); + + // Use farm to batch all listings in one transaction writeWithEstimateGas({ address: diamondAddress, abi: beanstalkAbi, - functionName: "createPodListing", - args: [ - { - lister: account, - fieldId: 0n, - index: index.toBigInt(), - start: start.toBigInt(), - podAmount: _amount.toBigInt(), - pricePerPod: Number(_pricePerPod), - maxHarvestableIndex: maxHarvestableIndex.toBigInt(), - minFillAmount: minFill.toBigInt(), - mode: Number(balanceTo), - }, - ], + functionName: "farm", + args: [farmData], }); } catch (e: unknown) { console.error(e); @@ -319,6 +373,7 @@ export default function CreateListing() { harvestableIndex, minFill, plot, + listingData, plotPosition, setSubmitting, mainToken.decimals, @@ -466,11 +521,36 @@ export default function CreateListing() {
+ + {/* Success Screen */} + {isSuccessful && successAmount !== null && successPrice !== null && ( +
+ + +
+

+ You have successfully created a Pod Listing with {formatter.noDec(successAmount)} Pods at a price of{" "} + {formatter.number(successPrice, { minDecimals: 0, maxDecimals: 6 })} Pintos! +

+
+ +
+ +
+
+ )}
); } From d26980a7aa05ac289105ba12a9cf88c7d7bd6e3b Mon Sep 17 00:00:00 2001 From: feyyazcigim Date: Fri, 31 Oct 2025 02:47:14 +0300 Subject: [PATCH 13/50] Add effective temperature to sell/list --- src/pages/market/actions/CreateListing.tsx | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/pages/market/actions/CreateListing.tsx b/src/pages/market/actions/CreateListing.tsx index 6786ecee7..05aeff77d 100644 --- a/src/pages/market/actions/CreateListing.tsx +++ b/src/pages/market/actions/CreateListing.tsx @@ -483,6 +483,15 @@ export default function CreateListing() { endIcon={} />
+ {/* Effective Temperature Display */} + {pricePerPod && pricePerPod > 0 && ( +
+

+ effective Temperature (i):{" "} + {formatter.noDec((1 / pricePerPod) * 100)}% +

+
+ )}
{/* Expires In - Auto-set to max expiration */} {/*
From bfa06015f1c383b321d1fbddb5690a265ae317a0 Mon Sep 17 00:00:00 2001 From: feyyazcigim Date: Fri, 31 Oct 2025 02:55:17 +0300 Subject: [PATCH 14/50] Add effective temperature to sell/fill Fix effective temperature decimal to 2 --- src/pages/market/actions/CreateListing.tsx | 4 +++- src/pages/market/actions/FillOrder.tsx | 15 ++++++++++++--- 2 files changed, 15 insertions(+), 4 deletions(-) diff --git a/src/pages/market/actions/CreateListing.tsx b/src/pages/market/actions/CreateListing.tsx index 05aeff77d..b08549b00 100644 --- a/src/pages/market/actions/CreateListing.tsx +++ b/src/pages/market/actions/CreateListing.tsx @@ -488,7 +488,9 @@ export default function CreateListing() {

effective Temperature (i):{" "} - {formatter.noDec((1 / pricePerPod) * 100)}% + + {formatter.number((1 / pricePerPod) * 100, { minDecimals: 2, maxDecimals: 2 })}% +

)} diff --git a/src/pages/market/actions/FillOrder.tsx b/src/pages/market/actions/FillOrder.tsx index 6f0910a23..140fce206 100644 --- a/src/pages/market/actions/FillOrder.tsx +++ b/src/pages/market/actions/FillOrder.tsx @@ -384,15 +384,24 @@ export default function FillOrder() { {/* Order Info Display - Based on selected range */}
-

- {ordersToFill.length > 1 ? "Weighted Avg Price/Pod" : "Price/Pod"} -

+

Average Price Per Pod

{formatter.number(weightedAvgPricePerPod, { minDecimals: 2, maxDecimals: 6 })} Pinto

+
+

Effective Temperature

+
+

+ {weightedAvgPricePerPod > 0 + ? formatter.number((1 / weightedAvgPricePerPod) * 100, { minDecimals: 2, maxDecimals: 2 }) + : "0.00"} + % +

+
+
From 06ef58405dc1a452caf5d841c13dd6842f474e95 Mon Sep 17 00:00:00 2001 From: feyyazcigim Date: Fri, 31 Oct 2025 03:36:07 +0300 Subject: [PATCH 15/50] Implement batch sell to sell/fill Add success state after transaction --- src/pages/market/actions/FillOrder.tsx | 261 +++++++++++++++++++++---- 1 file changed, 220 insertions(+), 41 deletions(-) diff --git a/src/pages/market/actions/FillOrder.tsx b/src/pages/market/actions/FillOrder.tsx index 140fce206..0a28348de 100644 --- a/src/pages/market/actions/FillOrder.tsx +++ b/src/pages/market/actions/FillOrder.tsx @@ -2,6 +2,7 @@ import pintoIcon from "@/assets/tokens/PINTO.png"; import { TokenValue } from "@/classes/TokenValue"; import PodLineGraph from "@/components/PodLineGraph"; import SmartSubmitButton from "@/components/SmartSubmitButton"; +import { Button } from "@/components/ui/Button"; import { Separator } from "@/components/ui/Separator"; import { MultiSlider } from "@/components/ui/Slider"; import { ANALYTICS_EVENTS } from "@/constants/analytics-events"; @@ -11,7 +12,7 @@ import { useProtocolAddress } from "@/hooks/pinto/useProtocolAddress"; import useTransaction from "@/hooks/useTransaction"; import usePodOrders from "@/state/market/usePodOrders"; import { useFarmerBalances } from "@/state/useFarmerBalances"; -import { useFarmerPlotsQuery } from "@/state/useFarmerField"; +import { useFarmerField, useFarmerPlotsQuery } from "@/state/useFarmerField"; import { useHarvestableIndex, usePodIndex } from "@/state/useFieldData"; import { useQueryKeys } from "@/state/useQueryKeys"; import useTokenData from "@/state/useTokenData"; @@ -20,8 +21,9 @@ import { formatter } from "@/utils/format"; import { FarmToMode, Plot } from "@/utils/types"; import { useQueryClient } from "@tanstack/react-query"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useNavigate } from "react-router-dom"; import { toast } from "sonner"; -import { Address } from "viem"; +import { Address, encodeFunctionData } from "viem"; import { useAccount } from "wagmi"; import CancelOrder from "./CancelOrder"; @@ -31,7 +33,6 @@ import CancelOrder from "./CancelOrder"; // Constants const FIELD_ID = 0n; -const START_INDEX_WITHIN_PLOT = 0n; const MIN_PODS_THRESHOLD = 1; // Minimum pods required for order eligibility // Helper Functions @@ -77,6 +78,7 @@ export default function FillOrder() { const harvestableIndex = useHarvestableIndex(); const podIndex = usePodIndex(); const podLine = podIndex.sub(harvestableIndex); + const navigate = useNavigate(); const queryClient = useQueryClient(); const { @@ -93,12 +95,16 @@ export default function FillOrder() { [allPodOrders, allMarket, farmerMarket, farmerFieldQK, farmerPlotsQK, balanceQKs], ); - const [plot, setPlot] = useState([]); const [podRange, setPodRange] = useState<[number, number]>([0, 0]); const [selectedOrderIds, setSelectedOrderIds] = useState([]); + const [isSuccessful, setIsSuccessful] = useState(false); + const [successAmount, setSuccessAmount] = useState(null); + const [successAvgPrice, setSuccessAvgPrice] = useState(null); + const [successTotal, setSuccessTotal] = useState(null); const prevTotalCapacityRef = useRef(0); const selectedOrderIdsRef = useRef([]); + const successDataRef = useRef<{ amount: number; avgPrice: number; total: number } | null>(null); // Keep ref in sync and reset capacity ref when selection changes useEffect(() => { @@ -108,6 +114,7 @@ export default function FillOrder() { const podOrders = usePodOrders(); const allOrders = podOrders.data; + const farmerField = useFarmerField(); const { selectedOrders, orderPositions, totalCapacity } = useMemo(() => { if (!allOrders?.podOrders) return { selectedOrders: [], orderPositions: [], totalCapacity: 0 }; @@ -136,9 +143,7 @@ export default function FillOrder() { }; }, [allOrders?.podOrders, selectedOrderIds, mainToken.decimals]); - const order = selectedOrders[0]; const amount = podRange[1] - podRange[0]; - const amountToSell = TokenValue.fromHuman(amount || 0, PODS.decimals); const ordersToFill = useMemo(() => { const [rangeStart, rangeEnd] = podRange; @@ -156,7 +161,8 @@ export default function FillOrder() { order: pos.order, amount: fillAmount, }; - }); + }) + .filter((item) => item.amount > 0); }, [orderPositions, podRange]); // Calculate weighted average price per pod once for reuse @@ -178,8 +184,31 @@ export default function FillOrder() { const eligibleOrders = useMemo(() => { if (!allOrders?.podOrders) return []; - return allOrders.podOrders.filter((order) => isOrderEligible(order, mainToken.decimals, podLine)); - }, [allOrders?.podOrders, mainToken.decimals, podLine]); + // Get farmer's frontmost pod position (lowest index) + const farmerPlots = farmerField.plots; + const farmerFrontmostPodIndex = + farmerPlots.length > 0 + ? farmerPlots.reduce((min, plot) => (plot.index.lt(min) ? plot.index : min), farmerPlots[0].index) + : null; + + return allOrders.podOrders.filter((order) => { + // Check basic eligibility + if (!isOrderEligible(order, mainToken.decimals, podLine)) { + return false; + } + + // Check if farmer has pods that can fill this order + // Order's maxPlaceInLine + harvestableIndex must be >= farmer's frontmost pod index + if (!farmerFrontmostPodIndex) { + return false; // No pods available + } + + const orderMaxPlaceIndex = harvestableIndex.add(TokenValue.fromBlockchain(order.maxPlaceInLine, PODS.decimals)); + + // Farmer's pod must be at or before the order's maxPlaceInLine position + return farmerFrontmostPodIndex.lte(orderMaxPlaceIndex); + }); + }, [allOrders?.podOrders, mainToken.decimals, podLine, farmerField.plots, harvestableIndex]); useEffect(() => { if (totalCapacity !== prevTotalCapacityRef.current) { @@ -219,7 +248,16 @@ export default function FillOrder() { }, []); const onSuccess = useCallback(() => { - setPlot([]); + // Set success state from ref (to avoid stale closure) + if (successDataRef.current) { + const { amount, avgPrice, total } = successDataRef.current; + setSuccessAmount(amount); + setSuccessAvgPrice(avgPrice); + setSuccessTotal(total); + setIsSuccessful(true); + successDataRef.current = null; // Clear ref after use + } + setPodRange([0, 0]); setSelectedOrderIds([]); allQK.forEach((key) => queryClient.invalidateQueries({ queryKey: key })); @@ -231,48 +269,155 @@ export default function FillOrder() { successCallback: onSuccess, }); - const onSubmit = useCallback(() => { - if (!order || !plot[0]) { + const onSubmit = useCallback(async () => { + if (ordersToFill.length === 0 || !account || farmerField.plots.length === 0) { return; } - trackSimpleEvent(ANALYTICS_EVENTS.MARKET.POD_ORDER_FILL, { - order_price_per_pod: Number(order.pricePerPod), - order_max_place: Number(order.maxPlaceInLine), + // Reset success state when starting new transaction + setIsSuccessful(false); + setSuccessAmount(null); + setSuccessAvgPrice(null); + setSuccessTotal(null); + + // Save success data to ref (to avoid stale closure in onSuccess callback) + successDataRef.current = { + amount, + avgPrice: weightedAvgPricePerPod, + total: amount * weightedAvgPricePerPod, + }; + + // Track analytics for each order being filled + ordersToFill.forEach(({ order: orderToFill }) => { + trackSimpleEvent(ANALYTICS_EVENTS.MARKET.POD_ORDER_FILL, { + order_price_per_pod: Number(orderToFill.pricePerPod), + order_max_place: Number(orderToFill.maxPlaceInLine), + }); }); try { setSubmitting(true); - toast.loading("Filling Order..."); + toast.loading(`Filling ${ordersToFill.length} Order${ordersToFill.length !== 1 ? "s" : ""}...`); + + // Sort farmer plots by index to use them in order (only sort once) + const sortedPlots = [...farmerField.plots].sort((a, b) => a.index.sub(b.index).toNumber()); + + if (sortedPlots.length === 0) { + throw new Error("No pods available to fill orders"); + } + + // Allocate pods from plots to orders + let plotIndex = 0; + let remainingPodsInCurrentPlot = sortedPlots[0].pods.toNumber(); + let currentPlot = sortedPlots[0]; + let currentPlotStartOffset = 0; + + const farmData: `0x${string}`[] = []; + + for (const { order: orderToFill, amount: fillAmount } of ordersToFill) { + let remainingAmount = fillAmount; + const orderMaxPlaceIndex = harvestableIndex.add( + TokenValue.fromBlockchain(orderToFill.maxPlaceInLine, PODS.decimals), + ); + + // Continue using plots until we have enough pods to fill this order + while (remainingAmount > 0 && plotIndex < sortedPlots.length) { + // Move to next plot if current one is exhausted + if (remainingPodsInCurrentPlot === 0) { + plotIndex++; + if (plotIndex >= sortedPlots.length) { + throw new Error( + `Insufficient pods in your plots to fill order. Need ${remainingAmount.toFixed(2)} more pods.`, + ); + } + currentPlot = sortedPlots[plotIndex]; + remainingPodsInCurrentPlot = currentPlot.pods.toNumber(); + currentPlotStartOffset = 0; + } + + // Validate that plot position is valid for this order + if (currentPlot.index.gt(orderMaxPlaceIndex)) { + throw new Error( + `Your pod at position ${currentPlot.index.toHuman()} is too far in line for order (max: ${orderMaxPlaceIndex.toHuman()})`, + ); + } + + const podsToUse = Math.min(remainingAmount, remainingPodsInCurrentPlot); + const podAmount = TokenValue.fromHuman(podsToUse, PODS.decimals); + const startOffset = TokenValue.fromHuman(currentPlotStartOffset, PODS.decimals); + + // Create fillPodOrder call for this order with pod allocation from current plot + const fillOrderArgs = { + orderer: orderToFill.farmer.id as Address, + fieldId: FIELD_ID, + maxPlaceInLine: BigInt(orderToFill.maxPlaceInLine), + pricePerPod: Number(orderToFill.pricePerPod), + minFillAmount: BigInt(orderToFill.minFillAmount), + }; + + const fillCall = encodeFunctionData({ + abi: beanstalkAbi, + functionName: "fillPodOrder", + args: [ + fillOrderArgs, + currentPlot.index.toBigInt(), + startOffset.toBigInt(), + podAmount.toBigInt(), + Number(FarmToMode.INTERNAL), + ], + }); + + farmData.push(fillCall); + + // Update tracking variables + remainingAmount -= podsToUse; + remainingPodsInCurrentPlot -= podsToUse; + currentPlotStartOffset += podsToUse; + } + + // Validate all pods were allocated + if (remainingAmount > 0) { + throw new Error( + `Insufficient pods in your plots to fill order. Need ${remainingAmount.toFixed(2)} more pods.`, + ); + } + } + + if (farmData.length === 0) { + throw new Error("No valid fill operations to execute"); + } + + // Use farm to batch all order fills in one transaction + // Success state will be set in onSuccess callback via ref writeWithEstimateGas({ address: diamondAddress, abi: beanstalkAbi, - functionName: "fillPodOrder", - args: [ - { - orderer: order.farmer.id as Address, - fieldId: FIELD_ID, - maxPlaceInLine: BigInt(order.maxPlaceInLine), - pricePerPod: Number(order.pricePerPod), - minFillAmount: BigInt(order.minFillAmount), - }, - plot[0].index.toBigInt(), - START_INDEX_WITHIN_PLOT, - amountToSell.toBigInt(), - Number(FarmToMode.INTERNAL), - ], + functionName: "farm", + args: [farmData], }); } catch (e) { - console.error(e); + console.error("Fill order error:", e); toast.dismiss(); - toast.error("Order Fill Failed"); - throw e; - } finally { + const errorMessage = + e instanceof Error ? e.message : "Order Fill Failed. Please check your pod balance and try again."; + toast.error(errorMessage); setSubmitting(false); } - }, [order, plot, amountToSell, writeWithEstimateGas, setSubmitting, diamondAddress]); - - const isOwnOrder = order && order.farmer.id === account.address?.toLowerCase(); + }, [ + ordersToFill, + account, + farmerField.plots, + writeWithEstimateGas, + setSubmitting, + diamondAddress, + amount, + weightedAvgPricePerPod, + harvestableIndex, + ]); + + const isOwnOrder = useMemo(() => { + return selectedOrders.some((order) => order.farmer.id === account.address?.toLowerCase()); + }, [selectedOrders, account.address]); if (eligibleOrders.length === 0) { return ( @@ -328,10 +473,14 @@ export default function FillOrder() {
{/* Show cancel option if user owns an order */} - {isOwnOrder && order && ( + {isOwnOrder && selectedOrders.length > 0 && ( <> - + {selectedOrders + .filter((order) => order.farmer.id === account.address?.toLowerCase()) + .map((order) => ( + + ))} )} @@ -419,14 +568,44 @@ export default function FillOrder() {
); })()} + + {/* Success Screen */} + {isSuccessful && successAmount !== null && successAvgPrice !== null && successTotal !== null && ( +
+ + +
+

+ You have successfully filled {formatter.noDec(successAmount)} Pods at an average price of{" "} + {formatter.number(successAvgPrice, { minDecimals: 2, maxDecimals: 6 })} Pintos per Pod, for a total of{" "} + {formatter.number(successTotal, { minDecimals: 0, maxDecimals: 2 })} Pintos! +

+
+ +
+ +
+
+ )}
); } From 8d52d648fcdd59da0610d32bbfc62d354c27295f Mon Sep 17 00:00:00 2001 From: feyyazcigim Date: Fri, 31 Oct 2025 04:04:41 +0300 Subject: [PATCH 16/50] Add SmartApprovalButton --- src/components/SmartApprovalButton.tsx | 254 +++++++++++++++++++++++ src/pages/market/actions/CreateOrder.tsx | 8 +- 2 files changed, 259 insertions(+), 3 deletions(-) create mode 100644 src/components/SmartApprovalButton.tsx diff --git a/src/components/SmartApprovalButton.tsx b/src/components/SmartApprovalButton.tsx new file mode 100644 index 000000000..f1384e644 --- /dev/null +++ b/src/components/SmartApprovalButton.tsx @@ -0,0 +1,254 @@ +import { TokenValue } from "@/classes/TokenValue"; +import { diamondABI } from "@/constants/abi/diamondABI"; +import { ZERO_ADDRESS } from "@/constants/address"; +import { useProtocolAddress } from "@/hooks/pinto/useProtocolAddress"; +import useTransaction from "@/hooks/useTransaction"; +import { useFarmerBalances } from "@/state/useFarmerBalances"; +import { toSafeTVFromHuman } from "@/utils/number"; +import { FarmFromMode, Token } from "@/utils/types"; +import { exists } from "@/utils/utils"; +import { useQueryClient } from "@tanstack/react-query"; +import { useCallback, useEffect, useMemo, useState } from "react"; +import { toast } from "sonner"; +import { Address, erc20Abi } from "viem"; +import { useAccount, useReadContract } from "wagmi"; +import { Button, ButtonProps } from "./ui/Button"; + +type ApprovalState = "idle" | "approving" | "approved"; + +interface SmartApprovalButton extends Omit { + token?: Token; + amount?: string; + callback?: () => void; + className?: string; + disabled?: boolean; + balanceFrom?: FarmFromMode; + spender?: Address; + requiresDiamondAllowance?: boolean; + forceApproval?: boolean; +} + +export default function SmartApprovalButton({ + token, + amount, + callback, + className, + disabled, + balanceFrom, + spender, + requiresDiamondAllowance, + forceApproval, + ...props +}: SmartApprovalButton) { + const account = useAccount(); + const queryClient = useQueryClient(); + const farmerBalances = useFarmerBalances().balances; + const diamond = useProtocolAddress(); + const baseAllowanceQueryEnabled = !!account.address && !!token && !token.isNative; + const [approvalState, setApprovalState] = useState("idle"); + + const { + data: tokenAllowance, + isFetching: tokenAllowanceFetching, + queryKey: tokenAllowanceQueryKey, + } = useReadContract({ + abi: erc20Abi, + address: token?.address, + functionName: "allowance", + scopeKey: "allowance", + args: [account.address ?? ZERO_ADDRESS, spender ?? diamond], + query: { + enabled: baseAllowanceQueryEnabled && !requiresDiamondAllowance, + }, + }); + + const { + data: diamondAllowance, + isFetching: diamondAllowanceFetching, + queryKey: diamondAllowanceQueryKey, + } = useReadContract({ + abi: diamondABI, + address: diamond, + functionName: "tokenAllowance", + args: [account.address ?? ZERO_ADDRESS, spender ?? ZERO_ADDRESS, token?.address ?? ZERO_ADDRESS], + query: { + enabled: baseAllowanceQueryEnabled && requiresDiamondAllowance && !!spender, + }, + }); + + const allowance = requiresDiamondAllowance ? diamondAllowance : tokenAllowance; + const allowanceFetching = requiresDiamondAllowance ? diamondAllowanceFetching : tokenAllowanceFetching; + const allowanceQueryKey = requiresDiamondAllowance ? diamondAllowanceQueryKey : tokenAllowanceQueryKey; + + const onSuccess = useCallback(() => { + queryClient.invalidateQueries({ queryKey: allowanceQueryKey }); + setApprovalState("approved"); + callback?.(); + }, [queryClient, allowanceQueryKey, callback]); + + const onError = useCallback(() => { + setApprovalState("idle"); + return false; // Let the hook handle the error toast + }, []); + + const { + submitting: submittingApproval, + isConfirming: isConfirmingApproval, + setSubmitting: setSubmittingApproval, + writeWithEstimateGas, + error: approvalError, + } = useTransaction({ + successCallback: onSuccess, + successMessage: "Approval success", + errorMessage: "Approval failed", + onError, + }); + + // Reset approval state on error + useEffect(() => { + if (approvalError && approvalState === "approving") { + setApprovalState("idle"); + } + }, [approvalError, approvalState]); + + const needsApproval = useMemo(() => { + if (!token || !exists(balanceFrom) || token.isNative) { + return false; + } + + // Convert amount to TokenValue for comparison + const inputAmount = toSafeTVFromHuman(amount ?? "", token); + + // Get internal balance + const tokenBalances = farmerBalances.get(token); + const internalBalance = tokenBalances?.internal ?? TokenValue.ZERO; + + // Get allowance + const allowanceAmount = TokenValue.fromBlockchain(allowance || 0, token.decimals); + + // If allowance covers the full amount, no approval needed + if (allowanceAmount.gte(inputAmount)) { + return false; + } else if (requiresDiamondAllowance) { + return allowanceAmount.lt(inputAmount); + } else { + // Balance doesn't cover full amount + switch (balanceFrom) { + case FarmFromMode.EXTERNAL: + return true; + case FarmFromMode.INTERNAL: + return false; + case FarmFromMode.INTERNAL_EXTERNAL: + // Need approval if amount exceeds internal balance + return inputAmount.gt(internalBalance); + default: + return false; + } + } + }, [allowance, farmerBalances, amount, token, balanceFrom, requiresDiamondAllowance]); + + // Update approval state when submitting/confirming + useEffect(() => { + if (submittingApproval || isConfirmingApproval) { + setApprovalState("approving"); + } + }, [submittingApproval, isConfirmingApproval]); + + // Check if already approved based on allowance + const isApproved = useMemo(() => { + if (!token || token.isNative || !allowance) return false; + if (!amount) return false; + + const inputAmount = toSafeTVFromHuman(amount, token); + const allowanceAmount = TokenValue.fromBlockchain(allowance, token.decimals); + return allowanceAmount.gte(inputAmount); + }, [token, allowance, amount]); + + // Update approval state when allowance changes + useEffect(() => { + if (!allowanceFetching) { + if (isApproved) { + setApprovalState("approved"); + } else if (approvalState === "approved" && !isApproved) { + // If allowance was revoked or changed, reset to idle + setApprovalState("idle"); + } + } + }, [isApproved, allowanceFetching, approvalState]); + + async function handleApprove() { + if ((!forceApproval && !needsApproval) || !token || !exists(amount)) return; + + try { + setSubmittingApproval(true); + setApprovalState("approving"); + toast.loading("Approving..."); + + const inputAmount = toSafeTVFromHuman(amount, token); + + if (requiresDiamondAllowance) { + if (!spender) throw new Error("Spender required"); + + await writeWithEstimateGas({ + abi: diamondABI, + address: diamond, + functionName: "approveToken", + args: [spender, token.address, inputAmount.toBigInt()], + }); + } else { + await writeWithEstimateGas({ + abi: erc20Abi, + address: token.address ?? ZERO_ADDRESS, + functionName: "approve", + args: [spender ?? diamond, inputAmount.toBigInt()], + }); + } + } catch (e) { + console.error(e); + setApprovalState("idle"); + toast.dismiss(); + toast.error("Approval failed"); + throw e; + } finally { + setSubmittingApproval(false); + } + } + + const isApproving = approvalState === "approving" || submittingApproval || isConfirmingApproval; + const isDisabled = + disabled || + allowanceFetching || + isApproving || + (!forceApproval && approvalState === "approved") || + (!forceApproval && !needsApproval); + + const getButtonText = () => { + if (isApproving) { + return "Approving"; + } + if (approvalState === "approved") { + return "Approved"; + } + return "Approve"; + }; + + return ( + + ); +} diff --git a/src/pages/market/actions/CreateOrder.tsx b/src/pages/market/actions/CreateOrder.tsx index e38c970b0..003fc7545 100644 --- a/src/pages/market/actions/CreateOrder.tsx +++ b/src/pages/market/actions/CreateOrder.tsx @@ -6,6 +6,7 @@ import PodLineGraph from "@/components/PodLineGraph"; import RoutingAndSlippageInfo, { useRoutingAndSlippageWarning } from "@/components/RoutingAndSlippageInfo"; import SimpleInputField from "@/components/SimpleInputField"; import SlippageButton from "@/components/SlippageButton"; +import SmartApprovalButton from "@/components/SmartApprovalButton"; import SmartSubmitButton from "@/components/SmartSubmitButton"; import { Input } from "@/components/ui/Input"; import { Separator } from "@/components/ui/Separator"; @@ -440,12 +441,13 @@ export default function CreateOrder() { )}
- {}} className="flex-1" /> Date: Fri, 31 Oct 2025 04:19:44 +0300 Subject: [PATCH 17/50] Improve graph interactions on buy/order --- src/components/PodLineGraph.tsx | 13 ++++++--- src/pages/market/actions/CreateListing.tsx | 5 ++-- src/pages/market/actions/CreateOrder.tsx | 34 +++++++++++++++++----- 3 files changed, 39 insertions(+), 13 deletions(-) diff --git a/src/components/PodLineGraph.tsx b/src/components/PodLineGraph.tsx index 2d7eaffa3..42595475a 100644 --- a/src/components/PodLineGraph.tsx +++ b/src/components/PodLineGraph.tsx @@ -35,6 +35,8 @@ interface PodLineGraphProps { rangeOverlay?: { start: TokenValue; end: TokenValue }; /** Callback when a plot group is clicked - receives all plot indices in the group */ onPlotGroupSelect?: (plotIndices: string[]) => void; + /** Disable hover and click interactions */ + disableInteractions?: boolean; /** Additional CSS classes */ className?: string; } @@ -167,6 +169,7 @@ export default function PodLineGraph({ orderRangeEnd, rangeOverlay, onPlotGroupSelect, + disableInteractions = false, className, }: PodLineGraphProps) { const farmerField = useFarmerField(); @@ -425,6 +428,7 @@ export default function PodLineGraph({ // Handle group click - select all plots in the group const handleGroupClick = () => { + if (disableInteractions) return; if (onPlotGroupSelect) { // Send all plot IDs or indices in the group // Prefer 'id' if available (for order markers), otherwise use index @@ -450,15 +454,16 @@ export default function PodLineGraph({ {/* Base rectangle (background color) */}
setHoveredPlotIndex(groupFirstPlotIndex)} - onMouseLeave={() => setHoveredPlotIndex(null)} + onClick={disableInteractions ? undefined : handleGroupClick} + onMouseEnter={disableInteractions ? undefined : () => setHoveredPlotIndex(groupFirstPlotIndex)} + onMouseLeave={disableInteractions ? undefined : () => setHoveredPlotIndex(null)} /> {/* Partial selection overlay (green) */} diff --git a/src/pages/market/actions/CreateListing.tsx b/src/pages/market/actions/CreateListing.tsx index b08549b00..3d7bee6b2 100644 --- a/src/pages/market/actions/CreateListing.tsx +++ b/src/pages/market/actions/CreateListing.tsx @@ -291,7 +291,8 @@ export default function CreateListing() { plot_position_millions: plot.length > 0 ? Math.round(plotPosition.div(1_000_000).toNumber()) : 0, }); - const _pricePerPod = TokenValue.fromHuman(pricePerPod, mainToken.decimals); + // pricePerPod should be encoded as uint24 with 6 decimals (0.5 * 1_000_000 = 500000) + const encodedPricePerPod = pricePerPod ? Math.floor(pricePerPod * PRICE_PER_POD_CONFIG.DECIMAL_MULTIPLIER) : 0; const _expiresIn = TokenValue.fromHuman(expiresIn, PODS.decimals); const maxHarvestableIndex = _expiresIn.add(harvestableIndex); try { @@ -320,7 +321,7 @@ export default function CreateListing() { index: data.index.toBigInt(), start: data.start.toBigInt(), podAmount: data.amount.toBigInt(), - pricePerPod: Number(_pricePerPod), + pricePerPod: encodedPricePerPod, maxHarvestableIndex: maxHarvestableIndex.toBigInt(), minFillAmount: minFill.toBigInt(), mode: Number(balanceTo), diff --git a/src/pages/market/actions/CreateOrder.tsx b/src/pages/market/actions/CreateOrder.tsx index 003fc7545..14c4ecbaf 100644 --- a/src/pages/market/actions/CreateOrder.tsx +++ b/src/pages/market/actions/CreateOrder.tsx @@ -59,6 +59,11 @@ const clampAndFormatPrice = (value: number): number => { return formatPricePerPod(clamped); }; +// Utility function to remove trailing zeros from formatted price +const removeTrailingZeros = (value: string): string => { + return value.includes(".") ? value.replace(/\.?0+$/, "") : value; +}; + const useFilterTokens = () => { const tokens = useTokenMap(); const isWSOL = useIsWSOL(); @@ -167,7 +172,7 @@ export default function CreateOrder() { const handlePriceSliderChange = useCallback((value: number[]) => { const formatted = formatPricePerPod(value[0]); setPricePerPod(formatted); - setPricePerPodInput(formatted.toFixed(PRICE_PER_POD_CONFIG.DECIMALS)); + setPricePerPodInput(removeTrailingZeros(formatted.toFixed(PRICE_PER_POD_CONFIG.DECIMALS))); }, []); // Price per pod input handlers @@ -181,7 +186,7 @@ export default function CreateOrder() { if (!Number.isNaN(numValue)) { const formatted = clampAndFormatPrice(numValue); setPricePerPod(formatted); - setPricePerPodInput(formatted.toString()); + setPricePerPodInput(removeTrailingZeros(formatted.toFixed(PRICE_PER_POD_CONFIG.DECIMALS))); } else { setPricePerPodInput(""); setPricePerPod(undefined); @@ -257,13 +262,14 @@ export default function CreateOrder() { : undefined; const _maxPlaceInLine = TokenValue.fromHuman(maxPlaceInLine?.toString() || "0", PODS.decimals); - const _pricePerPod = TokenValue.fromHuman(pricePerPod?.toString() || "0", mainToken.decimals); + // pricePerPod should be encoded as uint24 with 6 decimals (0.5 * 1_000_000 = 500000) + const encodedPricePerPod = pricePerPod ? Math.floor(pricePerPod * PRICE_PER_POD_CONFIG.DECIMAL_MULTIPLIER) : 0; const minFill = TokenValue.fromHuman("1", PODS.decimals); const orderCallStruct = createPodOrder( account, _amount, - Number(_pricePerPod), + encodedPricePerPod, _maxPlaceInLine, minFill, fromMode, @@ -322,7 +328,7 @@ export default function CreateOrder() { return (
{/* PodLineGraph Visualization */} - + {/* Place in Line Slider */}
@@ -373,7 +379,7 @@ export default function CreateOrder() { } />
+ {/* Effective Temperature Display */} + {pricePerPod && pricePerPod > 0 && ( +
+

+ effective Temperature (i):{" "} + + {formatter.number((1 / pricePerPod) * 100, { minDecimals: 2, maxDecimals: 2 })}% + +

+
+ )}
{/* Order Using Section */} @@ -474,7 +491,10 @@ const ActionSummary = ({ pricePerPod, maxPlaceInLine, }: { beansIn: TV; pricePerPod: number; maxPlaceInLine: number }) => { - const podsOut = beansIn.div(TokenValue.fromHuman(pricePerPod, 6)); + // pricePerPod is Pinto per Pod (0-1), convert to TokenValue with same decimals as beansIn (mainToken decimals) + // Then divide to get pods and convert to Pods decimals + const pricePerPodTV = TokenValue.fromHuman(pricePerPod.toString(), beansIn.decimals); + const podsOut = beansIn.div(pricePerPodTV).reDecimal(PODS.decimals); return (
From a7e7c60d08dec30a95614cb9ae9f4dd68866a45e Mon Sep 17 00:00:00 2001 From: feyyazcigim Date: Fri, 31 Oct 2025 04:23:10 +0300 Subject: [PATCH 18/50] Add success component to buy/order --- src/pages/market/actions/CreateOrder.tsx | 75 +++++++++++++++++++++++- 1 file changed, 72 insertions(+), 3 deletions(-) diff --git a/src/pages/market/actions/CreateOrder.tsx b/src/pages/market/actions/CreateOrder.tsx index 14c4ecbaf..6e9758fa9 100644 --- a/src/pages/market/actions/CreateOrder.tsx +++ b/src/pages/market/actions/CreateOrder.tsx @@ -8,6 +8,7 @@ import SimpleInputField from "@/components/SimpleInputField"; import SlippageButton from "@/components/SlippageButton"; import SmartApprovalButton from "@/components/SmartApprovalButton"; import SmartSubmitButton from "@/components/SmartSubmitButton"; +import { Button } from "@/components/ui/Button"; import { Input } from "@/components/ui/Input"; import { Separator } from "@/components/ui/Separator"; import { Slider } from "@/components/ui/Slider"; @@ -33,7 +34,8 @@ import { tokensEqual } from "@/utils/token"; import { FarmFromMode, FarmToMode, Token } from "@/utils/types"; import { cn } from "@/utils/utils"; import { useQueryClient } from "@tanstack/react-query"; -import { useCallback, useEffect, useMemo, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useNavigate } from "react-router-dom"; import { toast } from "sonner"; import { useAccount } from "wagmi"; @@ -104,6 +106,13 @@ export default function CreateOrder() { const [tokenIn, setTokenIn] = useState(preferredToken); const [balanceFrom, setBalanceFrom] = useState(FarmFromMode.INTERNAL_EXTERNAL); const [slippage, setSlippage] = useState(0.1); + const [isSuccessful, setIsSuccessful] = useState(false); + const [successPods, setSuccessPods] = useState(null); + const [successPricePerPod, setSuccessPricePerPod] = useState(null); + const [successAmountIn, setSuccessAmountIn] = useState(null); + const navigate = useNavigate(); + + const successDataRef = useRef<{ pods: number; pricePerPod: number; amountIn: string } | null>(null); const shouldSwap = !tokensEqual(tokenIn, mainToken); @@ -213,8 +222,25 @@ export default function CreateOrder() { [maxPlace], ); + // Calculate pods out for success message + const podsOut = useMemo(() => { + if (!pricePerPod || pricePerPod <= 0 || beansInOrder.isZero) return 0; + const pricePerPodTV = TokenValue.fromHuman(pricePerPod.toString(), beansInOrder.decimals); + return beansInOrder.div(pricePerPodTV).reDecimal(PODS.decimals).toNumber(); + }, [beansInOrder, pricePerPod]); + // invalidate pod orders query const onSuccess = useCallback(() => { + // Set success state from ref (to avoid stale closure) + if (successDataRef.current) { + const { pods, pricePerPod, amountIn } = successDataRef.current; + setSuccessPods(pods); + setSuccessPricePerPod(pricePerPod); + setSuccessAmountIn(amountIn); + setIsSuccessful(true); + successDataRef.current = null; // Clear ref after use + } + setAmountIn(""); setMaxPlaceInLine(undefined); setPricePerPod(undefined); @@ -231,6 +257,19 @@ export default function CreateOrder() { // submit txn const onSubmit = useCallback(async () => { + // Reset success state when starting new transaction + setIsSuccessful(false); + setSuccessPods(null); + setSuccessPricePerPod(null); + setSuccessAmountIn(null); + + // Save success data to ref (to avoid stale closure in onSuccess callback) + successDataRef.current = { + pods: podsOut, + pricePerPod: pricePerPod || 0, + amountIn: amountIn, + }; + // Track pod order creation trackSimpleEvent(ANALYTICS_EVENTS.MARKET.POD_ORDER_CREATE, { payment_token: tokenIn.symbol, @@ -311,6 +350,9 @@ export default function CreateOrder() { mainToken, swapBuild, tokenIn.symbol, + podsOut, + pricePerPod, + amountIn, ]); const swapDataNotReady = (shouldSwap && (!swapData || !swapBuild)) || !!swapQuery.error; @@ -470,9 +512,9 @@ export default function CreateOrder() {
)} + + {/* Success Screen */} + {isSuccessful && successPods !== null && successPricePerPod !== null && successAmountIn !== null && ( +
+ + +
+

+ You have successfully placed an order for {formatter.noDec(successPods)} Pods at{" "} + {formatter.number(successPricePerPod, { minDecimals: 2, maxDecimals: 6 })} Pintos per Pod, with{" "} + {formatter.number(Number.parseFloat(successAmountIn) || 0, { minDecimals: 0, maxDecimals: 2 })}{" "} + {tokenIn.symbol}! +

+
+ +
+ +
+
+ )}
); } From 9d1de8c395569a97f7fc72d136b9f53799256608 Mon Sep 17 00:00:00 2001 From: feyyazcigim Date: Fri, 31 Oct 2025 04:38:47 +0300 Subject: [PATCH 19/50] Improve UI on buy/fill --- src/pages/market/actions/FillListing.tsx | 120 +++++++++++++++++------ 1 file changed, 90 insertions(+), 30 deletions(-) diff --git a/src/pages/market/actions/FillListing.tsx b/src/pages/market/actions/FillListing.tsx index 563c3e9b5..ebc532148 100644 --- a/src/pages/market/actions/FillListing.tsx +++ b/src/pages/market/actions/FillListing.tsx @@ -5,7 +5,9 @@ import FrameAnimator from "@/components/LoadingSpinner"; import PodLineGraph from "@/components/PodLineGraph"; import RoutingAndSlippageInfo, { useRoutingAndSlippageWarning } from "@/components/RoutingAndSlippageInfo"; import SlippageButton from "@/components/SlippageButton"; +import SmartApprovalButton from "@/components/SmartApprovalButton"; import SmartSubmitButton from "@/components/SmartSubmitButton"; +import { Button } from "@/components/ui/Button"; import { Input } from "@/components/ui/Input"; import { Separator } from "@/components/ui/Separator"; import { MultiSlider, Slider } from "@/components/ui/Slider"; @@ -36,7 +38,7 @@ import { tokensEqual } from "@/utils/token"; import { FarmFromMode, FarmToMode, Plot, Token } from "@/utils/types"; import { cn, getBalanceFromMode } from "@/utils/utils"; import { useQueryClient } from "@tanstack/react-query"; -import { useCallback, useEffect, useMemo, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useNavigate, useParams } from "react-router-dom"; import { toast } from "sonner"; import { Address } from "viem"; @@ -112,10 +114,15 @@ export default function FillListing() { const [tokenIn, setTokenIn] = useState(mainToken); const [balanceFrom, setBalanceFrom] = useState(FarmFromMode.INTERNAL_EXTERNAL); const [slippage, setSlippage] = useState(0.1); + const [isSuccessful, setIsSuccessful] = useState(false); + const [successPods, setSuccessPods] = useState(null); + const [successAvgPrice, setSuccessAvgPrice] = useState(null); + const [successTotal, setSuccessTotal] = useState(null); + const successDataRef = useRef<{ pods: number; avgPrice: number; total: number } | null>(null); // Price per pod filter state const [maxPricePerPod, setMaxPricePerPod] = useState(0); - const [maxPricePerPodInput, setMaxPricePerPodInput] = useState("0"); + const [maxPricePerPodInput, setMaxPricePerPodInput] = useState("0.000000"); // Place in line range state const podIndex = usePodIndex(); @@ -178,7 +185,7 @@ export default function FillListing() { const handlePriceSliderChange = useCallback((value: number[]) => { const formatted = formatPricePerPod(value[0]); setMaxPricePerPod(formatted); - setMaxPricePerPodInput(formatted.toFixed(PRICE_PER_POD_CONFIG.DECIMALS)); + setMaxPricePerPodInput(formatted.toFixed(6)); }, []); // Price per pod input handlers @@ -193,9 +200,9 @@ export default function FillListing() { const clamped = Math.max(0, Math.min(PRICE_PER_POD_CONFIG.MAX, numValue)); const formatted = formatPricePerPod(clamped); setMaxPricePerPod(formatted); - setMaxPricePerPodInput(formatted.toString()); + setMaxPricePerPodInput(formatted.toFixed(PRICE_PER_POD_CONFIG.DECIMALS)); } else { - setMaxPricePerPodInput("0"); + setMaxPricePerPodInput("0.000000"); setMaxPricePerPod(0); } }, [maxPricePerPodInput]); @@ -298,28 +305,22 @@ export default function FillListing() { }, 0); }, [allListings, eligibleListingIds]); - // Plot selection handler - navigate to selected listing - const handlePlotGroupSelect = useCallback( - (plotIndices: string[]) => { - if (plotIndices.length > 0) { - const listingId = plotIndices[0]; - // Extract the index from the listing ID (format: "0-{index}") - const indexPart = listingId.split("-")[1]; - if (indexPart) { - navigate(`/market/pods/buy/${indexPart}`); - } - } - }, - [navigate], - ); - // reset form and invalidate pod listings/farmer plot queries const onSuccess = useCallback(() => { - navigate(`/market/pods/buy/fill`); + // Set success state from ref (to avoid stale closure) + if (successDataRef.current) { + const { pods, avgPrice, total } = successDataRef.current; + setSuccessPods(pods); + setSuccessAvgPrice(avgPrice); + setSuccessTotal(total); + setIsSuccessful(true); + successDataRef.current = null; // Clear ref after use + } + setAmountIn(""); resetSwap(); allQK.forEach((key) => queryClient.invalidateQueries({ queryKey: key })); - }, [navigate, resetSwap, queryClient, allQK]); + }, [resetSwap, queryClient, allQK]); const { writeWithEstimateGas, submitting, isConfirming, setSubmitting } = useTransaction({ successMessage: "Listing Fill successful", @@ -388,6 +389,25 @@ export default function FillListing() { throw new Error("No eligible listings"); } + // Reset success state when starting new transaction + setIsSuccessful(false); + setSuccessPods(null); + setSuccessAvgPrice(null); + setSuccessTotal(null); + + // Calculate and save success data to ref (to avoid stale closure in onSuccess callback) + if (eligibleSummary && mainTokensIn) { + const estimatedPods = mainTokensIn.div(eligibleSummary.avgPricePerPod).toNumber(); + const avgPrice = eligibleSummary.avgPricePerPod.toNumber(); + const total = mainTokensIn.toNumber(); + + successDataRef.current = { + pods: estimatedPods, + avgPrice, + total, + }; + } + // Track pod listing fill trackSimpleEvent(ANALYTICS_EVENTS.MARKET.POD_LIST_FILL, { payment_token: tokenIn.symbol, @@ -473,6 +493,8 @@ export default function FillListing() { mainToken, eligibleListingIds.length, tokenIn.symbol, + eligibleSummary, + mainTokensIn, ]); // Disable submit if no tokens entered, no eligible listings, or no listing selected @@ -486,7 +508,7 @@ export default function FillListing() { plots={listingPlots} selectedPlotIndices={listing ? [listing.id] : eligibleListingIds} rangeOverlay={rangeOverlay} - onPlotGroupSelect={handlePlotGroupSelect} + disableInteractions={true} />
@@ -513,13 +535,24 @@ export default function FillListing() { onChange={handlePriceInputChange} onBlur={handlePriceInputBlur} onFocus={(e) => e.target.select()} - placeholder="0" + placeholder="0.000000" outlined containerClassName="" className="" endIcon={} />
+ {/* Effective Temperature Display */} + {maxPricePerPod > 0 && ( +
+

+ effective Temperature (i):{" "} + + {formatter.number((1 / maxPricePerPod) * 100, { minDecimals: 2, maxDecimals: 2 })}% + +

+
+ )}
{/* Place in Line Range Selector */} @@ -626,29 +659,56 @@ export default function FillListing() { /> )}
- {}} + token={tokenIn} + amount={amountIn} + balanceFrom={balanceFrom} + disabled={disabled || !ackSlippage || isConfirming || submitting || isSuccessful} className="flex-1" />
)} + + {/* Success Screen */} + {isSuccessful && successPods !== null && successAvgPrice !== null && successTotal !== null && ( +
+ + +
+

+ You've successfully purchased {formatter.noDec(successPods)} Pods for{" "} + {formatter.number(successTotal, { minDecimals: 0, maxDecimals: 2 })} Pinto, for an average price of{" "} + {formatter.number(successAvgPrice, { minDecimals: 2, maxDecimals: 6 })} Pintos per Pod! +

+
+ +
+ +
+
+ )}
); } From 2b5caaa31bb644457ab6a6e00f298076caa45197 Mon Sep 17 00:00:00 2001 From: feyyazcigim Date: Fri, 31 Oct 2025 04:46:25 +0300 Subject: [PATCH 20/50] Implement contrat interactions for buy/fill --- src/pages/market/actions/FillListing.tsx | 195 +++++++++++++++-------- 1 file changed, 132 insertions(+), 63 deletions(-) diff --git a/src/pages/market/actions/FillListing.tsx b/src/pages/market/actions/FillListing.tsx index ebc532148..058cf690a 100644 --- a/src/pages/market/actions/FillListing.tsx +++ b/src/pages/market/actions/FillListing.tsx @@ -39,9 +39,9 @@ import { FarmFromMode, FarmToMode, Plot, Token } from "@/utils/types"; import { cn, getBalanceFromMode } from "@/utils/utils"; import { useQueryClient } from "@tanstack/react-query"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; -import { useNavigate, useParams } from "react-router-dom"; +import { useNavigate } from "react-router-dom"; import { toast } from "sonner"; -import { Address } from "viem"; +import { Address, encodeFunctionData } from "viem"; import { useAccount } from "wagmi"; // Configuration constants @@ -105,9 +105,7 @@ export default function FillListing() { }); const podListings = usePodListings(); - const { id } = useParams(); const allListings = podListings.data; - const listing = allListings?.podListings.find((listing) => listing.index === id); const [didSetPreferred, setDidSetPreferred] = useState(false); const [amountIn, setAmountIn] = useState(""); @@ -330,27 +328,67 @@ export default function FillListing() { const mainTokensIn = isUsingMain ? toSafeTVFromHuman(amountIn, mainToken.decimals) : swapData?.buyAmount; - // Calculate weighted average for eligible listings - const eligibleSummary = useMemo(() => { + // Get eligible listings sorted by price (cheapest first) + const eligibleListings = useMemo(() => { if (!allListings?.podListings.length || eligibleListingIds.length === 0) { - return null; + return []; } const eligibleSet = new Set(eligibleListingIds); - const eligibleListings = allListings.podListings.filter((l) => eligibleSet.has(l.id)); + return allListings.podListings + .filter((l) => eligibleSet.has(l.id)) + .sort((a, b) => { + const priceA = TokenValue.fromBlockchain(a.pricePerPod, mainToken.decimals).toNumber(); + const priceB = TokenValue.fromBlockchain(b.pricePerPod, mainToken.decimals).toNumber(); + return priceA - priceB; // Sort by price ascending (cheapest first) + }); + }, [allListings, eligibleListingIds, mainToken.decimals]); + + // Calculate which listings to fill and how much from each (based on mainTokensIn) + const listingsToFill = useMemo(() => { + if (!mainTokensIn || mainTokensIn.eq(0) || eligibleListings.length === 0) { + return []; + } + + const result: Array<{ listing: (typeof eligibleListings)[0]; beanAmount: TokenValue }> = []; + let remainingBeans = mainTokensIn; + + for (const listing of eligibleListings) { + if (remainingBeans.lte(0)) break; + + const listingPrice = TokenValue.fromBlockchain(listing.pricePerPod, mainToken.decimals); + const listingRemainingPods = TokenValue.fromBlockchain(listing.remainingAmount, PODS.decimals); + const maxBeansForListing = listingRemainingPods.mul(listingPrice); + + // Take the minimum of: remaining beans and max beans we can spend on this listing + const beansToSpend = TokenValue.min(remainingBeans, maxBeansForListing); + if (beansToSpend.gt(0)) { + result.push({ listing, beanAmount: beansToSpend }); + remainingBeans = remainingBeans.sub(beansToSpend); + } + } + + return result; + }, [mainTokensIn, eligibleListings, mainToken.decimals]); + + // Calculate weighted average for eligible listings + const eligibleSummary = useMemo(() => { + if (listingsToFill.length === 0) { + return null; + } let totalValue = 0; let totalPods = 0; let totalPlaceInLine = 0; - eligibleListings.forEach((listing) => { - const listingPrice = TokenValue.fromBlockchain(listing.pricePerPod, mainToken.decimals).toNumber(); - const listingPods = TokenValue.fromBlockchain(listing.remainingAmount, PODS.decimals).toNumber(); - const listingPlace = TokenValue.fromBlockchain(listing.index, PODS.decimals).sub(harvestableIndex).toNumber(); + listingsToFill.forEach(({ listing, beanAmount }) => { + const listingPrice = TokenValue.fromBlockchain(listing.pricePerPod, mainToken.decimals); + const podsFromListing = beanAmount.div(listingPrice); + const listingPlace = TokenValue.fromBlockchain(listing.index, PODS.decimals).sub(harvestableIndex); - totalValue += listingPrice * listingPods; - totalPods += listingPods; - totalPlaceInLine += listingPlace * listingPods; + totalValue += listingPrice.toNumber() * podsFromListing.toNumber(); + totalPods += podsFromListing.toNumber(); + totalPlaceInLine += listingPlace.toNumber() * podsFromListing.toNumber(); }); const avgPricePerPod = totalPods > 0 ? totalValue / totalPods : 0; @@ -361,7 +399,7 @@ export default function FillListing() { avgPlaceInLine: TokenValue.fromHuman(avgPlaceInLine, PODS.decimals), totalPods, }; - }, [allListings, eligibleListingIds, mainToken.decimals, harvestableIndex]); + }, [listingsToFill, mainToken.decimals, harvestableIndex]); // Calculate total tokens needed to fill eligible listings const totalMainTokensToFill = useMemo(() => { @@ -376,17 +414,17 @@ export default function FillListing() { const onSubmit = useCallback(async () => { // Validate requirements - if (!listing) { - toast.error("No listing selected"); - throw new Error("Listing not found"); - } if (!account.address) { toast.error("Please connect your wallet"); throw new Error("Signer required"); } - if (!eligibleListingIds.length) { - toast.error("No eligible listings available"); - throw new Error("No eligible listings"); + if (listingsToFill.length === 0) { + toast.error("No eligible listings to fill"); + throw new Error("No listings to fill"); + } + if (!mainTokensIn || mainTokensIn.eq(0)) { + toast.error("No amount specified"); + throw new Error("Amount required"); } // Reset success state when starting new transaction @@ -412,54 +450,86 @@ export default function FillListing() { trackSimpleEvent(ANALYTICS_EVENTS.MARKET.POD_LIST_FILL, { payment_token: tokenIn.symbol, balance_source: balanceFrom, - eligible_listings_count: eligibleListingIds.length, + eligible_listings_count: listingsToFill.length, }); try { setSubmitting(true); - toast.loading("Filling Listing..."); + toast.loading(`Filling ${listingsToFill.length} Listing${listingsToFill.length !== 1 ? "s" : ""}...`); + if (isUsingMain) { + // Direct fill - create farm calls for each listing + const farmData: `0x${string}`[] = []; + + for (const { listing, beanAmount } of listingsToFill) { + // Encode pricePerPod with 6 decimals (like CreateOrder.tsx) + const pricePerPodNumber = TokenValue.fromBlockchain(listing.pricePerPod, mainToken.decimals).toNumber(); + const encodedPricePerPod = Math.floor(pricePerPodNumber * PRICE_PER_POD_CONFIG.DECIMAL_MULTIPLIER); + + const fillCall = encodeFunctionData({ + abi: beanstalkAbi, + functionName: "fillPodListing", + args: [ + { + lister: listing.farmer.id as Address, + fieldId: 0n, + index: TokenValue.fromBlockchain(listing.index, PODS.decimals).toBigInt(), + start: TokenValue.fromBlockchain(listing.start, PODS.decimals).toBigInt(), + podAmount: TokenValue.fromBlockchain(listing.amount, PODS.decimals).toBigInt(), + pricePerPod: encodedPricePerPod, + maxHarvestableIndex: TokenValue.fromBlockchain(listing.maxHarvestableIndex, PODS.decimals).toBigInt(), + minFillAmount: TokenValue.fromBlockchain(listing.minFillAmount, mainToken.decimals).toBigInt(), + mode: Number(listing.mode), + }, + beanAmount.toBigInt(), + Number(balanceFrom), + ], + }); + + farmData.push(fillCall); + } + + if (farmData.length === 0) { + throw new Error("No valid fill operations to execute"); + } + + // Use farm to batch all listing fills in one transaction return writeWithEstimateGas({ address: diamondAddress, abi: beanstalkAbi, - functionName: "fillPodListing", - args: [ - { - lister: listing.farmer.id as Address, // account - fieldId: 0n, // fieldId - index: TokenValue.fromBlockchain(listing.index, PODS.decimals).toBigInt(), // index - start: TokenValue.fromBlockchain(listing.start, PODS.decimals).toBigInt(), // start - podAmount: TokenValue.fromBlockchain(listing.amount, PODS.decimals).toBigInt(), // amount - pricePerPod: Number(TokenValue.fromBlockchain(listing.pricePerPod, mainToken.decimals)), // pricePerPod - maxHarvestableIndex: TokenValue.fromBlockchain(listing.maxHarvestableIndex, PODS.decimals).toBigInt(), // maxHarvestableIndex - minFillAmount: TokenValue.fromBlockchain(listing.minFillAmount, mainToken.decimals).toBigInt(), // minFillAmount, measured in Beans - mode: Number(listing.mode), // mode - }, - toSafeTVFromHuman(amountIn, mainToken.decimals).toBigInt(), // amountIn - Number(balanceFrom), // fromMode - ], + functionName: "farm", + args: [farmData], }); } else if (swapBuild?.advancedFarm.length) { + // Swap + fill - use advancedFarm const { clipboard } = await swapBuild.deriveClipboardWithOutputToken(mainToken, 9, account.address, { value: value ?? TV.ZERO, }); const advFarm = [...swapBuild.advancedFarm]; - advFarm.push( - fillPodListing( - listing.farmer.id as Address, // account - TokenValue.fromBlockchain(listing.index, PODS.decimals), // index - TokenValue.fromBlockchain(listing.start, PODS.decimals), // start - TokenValue.fromBlockchain(listing.amount, PODS.decimals), // amount - Number(TokenValue.fromBlockchain(listing.pricePerPod, mainToken.decimals)), // pricePerPod - TokenValue.fromBlockchain(listing.maxHarvestableIndex, PODS.decimals), // maxHarvestableIndex - TokenValue.fromBlockchain(listing.minFillAmount, mainToken.decimals), // minFillAmount, measured in Beans - Number(listing.mode), // mode - TV.ZERO, // amountIn (from clipboard) - FarmFromMode.INTERNAL, // fromMode + + // Add fillPodListing calls for each listing + for (const { listing, beanAmount } of listingsToFill) { + // Encode pricePerPod with 6 decimals (like CreateOrder.tsx) + const pricePerPodNumber = TokenValue.fromBlockchain(listing.pricePerPod, mainToken.decimals).toNumber(); + const encodedPricePerPod = Math.floor(pricePerPodNumber * PRICE_PER_POD_CONFIG.DECIMAL_MULTIPLIER); + + const fillCall = fillPodListing( + listing.farmer.id as Address, + TokenValue.fromBlockchain(listing.index, PODS.decimals), + TokenValue.fromBlockchain(listing.start, PODS.decimals), + TokenValue.fromBlockchain(listing.amount, PODS.decimals), + encodedPricePerPod, + TokenValue.fromBlockchain(listing.maxHarvestableIndex, PODS.decimals), + TokenValue.fromBlockchain(listing.minFillAmount, mainToken.decimals), + Number(listing.mode), + beanAmount, + FarmFromMode.INTERNAL, clipboard, - ), - ); + ); + + advFarm.push(fillCall); + } return writeWithEstimateGas({ address: diamondAddress, @@ -474,15 +544,16 @@ export default function FillListing() { } catch (e) { console.error(e); toast.dismiss(); - toast.error("Listing Fill Failed"); + const errorMessage = e instanceof Error ? e.message : "Listing Fill Failed"; + toast.error(errorMessage); throw e; } finally { setSubmitting(false); } }, [ - listing, account.address, - amountIn, + listingsToFill, + mainTokensIn, balanceFrom, swapBuild, writeWithEstimateGas, @@ -491,14 +562,12 @@ export default function FillListing() { value, diamondAddress, mainToken, - eligibleListingIds.length, tokenIn.symbol, eligibleSummary, - mainTokensIn, ]); - // Disable submit if no tokens entered, no eligible listings, or no listing selected - const disabled = !mainTokensIn || mainTokensIn.eq(0) || !eligibleListingIds.length || !listing; + // Disable submit if no tokens entered, no eligible listings, or no listings to fill + const disabled = !mainTokensIn || mainTokensIn.eq(0) || listingsToFill.length === 0; return (
@@ -506,7 +575,7 @@ export default function FillListing() {
From efd2bdaac946a22f82b5c7fb7dc7a1c8f7269255 Mon Sep 17 00:00:00 2001 From: feyyazcigim Date: Fri, 31 Oct 2025 05:00:51 +0300 Subject: [PATCH 21/50] Change tab trigger - buy/sell pods --- src/pages/market/MarketModeSelect.tsx | 32 +++++++++++++++++----- src/pages/market/actions/CreateListing.tsx | 2 +- src/pages/market/actions/CreateOrder.tsx | 2 +- src/pages/market/actions/FillListing.tsx | 2 +- 4 files changed, 28 insertions(+), 10 deletions(-) diff --git a/src/pages/market/MarketModeSelect.tsx b/src/pages/market/MarketModeSelect.tsx index a351b44e6..2e5c5e118 100644 --- a/src/pages/market/MarketModeSelect.tsx +++ b/src/pages/market/MarketModeSelect.tsx @@ -4,7 +4,7 @@ import { ANALYTICS_EVENTS } from "@/constants/analytics-events"; import { useFarmerField } from "@/state/useFarmerField"; import { trackSimpleEvent } from "@/utils/analytics"; import { useCallback } from "react"; -import { useNavigate, useParams, useSearchParams } from "react-router-dom"; +import { useNavigate, useParams } from "react-router-dom"; interface MarketModeSelectProps { onMainSelectionChange?: (v: string) => void; @@ -57,12 +57,30 @@ export default function MarketModeSelect({ onMainSelectionChange, onSecondarySel return (
- - - Buy Pods - Sell Pods - - +
+ + +
{mainTab ? ( <> {mainTab === "sell" && hasNoPods ? ( diff --git a/src/pages/market/actions/CreateListing.tsx b/src/pages/market/actions/CreateListing.tsx index 3d7bee6b2..ec622a3dc 100644 --- a/src/pages/market/actions/CreateListing.tsx +++ b/src/pages/market/actions/CreateListing.tsx @@ -488,7 +488,7 @@ export default function CreateListing() { {pricePerPod && pricePerPod > 0 && (

- effective Temperature (i):{" "} + Effective Temperature (i):{" "} {formatter.number((1 / pricePerPod) * 100, { minDecimals: 2, maxDecimals: 2 })}% diff --git a/src/pages/market/actions/CreateOrder.tsx b/src/pages/market/actions/CreateOrder.tsx index 6e9758fa9..17d1da441 100644 --- a/src/pages/market/actions/CreateOrder.tsx +++ b/src/pages/market/actions/CreateOrder.tsx @@ -446,7 +446,7 @@ export default function CreateOrder() { {pricePerPod && pricePerPod > 0 && (

- effective Temperature (i):{" "} + Effective Temperature (i):{" "} {formatter.number((1 / pricePerPod) * 100, { minDecimals: 2, maxDecimals: 2 })}% diff --git a/src/pages/market/actions/FillListing.tsx b/src/pages/market/actions/FillListing.tsx index 058cf690a..32f368729 100644 --- a/src/pages/market/actions/FillListing.tsx +++ b/src/pages/market/actions/FillListing.tsx @@ -615,7 +615,7 @@ export default function FillListing() { {maxPricePerPod > 0 && (

- effective Temperature (i):{" "} + Effective Temperature (i):{" "} {formatter.number((1 / maxPricePerPod) * 100, { minDecimals: 2, maxDecimals: 2 })}% From e69caee50a8be64e37cd17c6e415b556801e8e2d Mon Sep 17 00:00:00 2001 From: feyyazcigim Date: Fri, 31 Oct 2025 05:19:25 +0300 Subject: [PATCH 22/50] Add buy/sell redirect from chart --- src/pages/Market.tsx | 4 ++-- src/pages/market/MarketActivityTable.tsx | 4 ++-- src/pages/market/PodListingsTable.tsx | 2 +- src/pages/market/PodOrdersTable.tsx | 2 +- src/pages/market/actions/FillListing.tsx | 30 +++++++++++++++++++++++- src/pages/market/actions/FillOrder.tsx | 18 +++++++++++++- 6 files changed, 52 insertions(+), 8 deletions(-) diff --git a/src/pages/Market.tsx b/src/pages/Market.tsx index eb9e223f7..a9834ddf9 100644 --- a/src/pages/Market.tsx +++ b/src/pages/Market.tsx @@ -286,9 +286,9 @@ export function Market() { }); if (dataPoint.eventType === "LISTING") { - navigate(`/market/pods/buy/${dataPoint.eventIndex.toString().replace(".", "")}`); + navigate(`/market/pods/buy/fill?listingId=${dataPoint.eventId}`); } else { - navigate(`/market/pods/sell/${dataPoint.eventId.replace(".", "")}`); + navigate(`/market/pods/sell/fill?orderId=${dataPoint.eventId}`); } }; diff --git a/src/pages/market/MarketActivityTable.tsx b/src/pages/market/MarketActivityTable.tsx index a5bc0b2e6..fc191f05e 100644 --- a/src/pages/market/MarketActivityTable.tsx +++ b/src/pages/market/MarketActivityTable.tsx @@ -38,9 +38,9 @@ export function MarketActivityTable({ marketData, titleText, farmer }: MarketAct if (event.status === "ACTIVE") { if (event.type === "LISTING") { const listingEvent = event as Listing; - navigate(`/market/pods/buy/${listingEvent.index}`); + navigate(`/market/pods/buy/fill?listingId=${listingEvent.id}`); } else { - navigate(`/market/pods/sell/${event.id}`); + navigate(`/market/pods/sell/fill?orderId=${event.id}`); } } }, diff --git a/src/pages/market/PodListingsTable.tsx b/src/pages/market/PodListingsTable.tsx index 8a8375550..f7e47d15f 100644 --- a/src/pages/market/PodListingsTable.tsx +++ b/src/pages/market/PodListingsTable.tsx @@ -32,7 +32,7 @@ export function PodListingsTable() { const navigate = useNavigate(); const navigateTo = useCallback( (id: string) => { - navigate(`/market/pods/buy/${id}`); + navigate(`/market/pods/buy/fill?listingId=${id}`); }, [navigate], ); diff --git a/src/pages/market/PodOrdersTable.tsx b/src/pages/market/PodOrdersTable.tsx index 21e34480b..fb60e64b3 100644 --- a/src/pages/market/PodOrdersTable.tsx +++ b/src/pages/market/PodOrdersTable.tsx @@ -42,7 +42,7 @@ export function PodOrdersTable() { const navigate = useNavigate(); const navigateTo = useCallback( (id: string) => { - navigate(`/market/pods/sell/${id}`); + navigate(`/market/pods/sell/fill?orderId=${id}`); }, [navigate], ); diff --git a/src/pages/market/actions/FillListing.tsx b/src/pages/market/actions/FillListing.tsx index 32f368729..e14f3bbbd 100644 --- a/src/pages/market/actions/FillListing.tsx +++ b/src/pages/market/actions/FillListing.tsx @@ -39,7 +39,7 @@ import { FarmFromMode, FarmToMode, Plot, Token } from "@/utils/types"; import { cn, getBalanceFromMode } from "@/utils/utils"; import { useQueryClient } from "@tanstack/react-query"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; -import { useNavigate } from "react-router-dom"; +import { useNavigate, useSearchParams } from "react-router-dom"; import { toast } from "sonner"; import { Address, encodeFunctionData } from "viem"; import { useAccount } from "wagmi"; @@ -86,6 +86,8 @@ export default function FillListing() { const farmerBalances = useFarmerBalances(); const harvestableIndex = useHarvestableIndex(); const navigate = useNavigate(); + const [searchParams] = useSearchParams(); + const listingId = searchParams.get("listingId"); const filterTokens = useFilterTokens(); @@ -166,6 +168,32 @@ export default function FillListing() { } }, [preferredToken, preferredLoading, didSetPreferred]); + // Pre-fill form when listingId parameter is present (clicked from chart) + useEffect(() => { + if (!listingId || !allListings?.podListings || maxPlace === 0) return; + + // Find the listing with matching ID + const listing = allListings.podListings.find((l) => l.id === listingId); + if (!listing) return; + + // Pre-fill price per pod + const listingPrice = TokenValue.fromBlockchain(listing.pricePerPod, mainToken.decimals).toNumber(); + const formattedPrice = formatPricePerPod(listingPrice); + setMaxPricePerPod(formattedPrice); + setMaxPricePerPodInput(formattedPrice.toFixed(6)); + + // Calculate listing's place in line + const listingIndex = TokenValue.fromBlockchain(listing.index, PODS.decimals); + const placeInLine = listingIndex.sub(harvestableIndex).toNumber(); + + // Set place in line range to include this listing with a small margin + // Clamp to valid range [0, maxPlace] + const margin = Math.max(1, Math.floor(maxPlace * 0.01)); // 1% margin or at least 1 + const minPlace = Math.max(0, Math.floor(placeInLine - margin)); + const maxPlaceValue = Math.min(maxPlace, Math.ceil(placeInLine + margin)); + setPlaceInLineRange([minPlace, maxPlaceValue]); + }, [listingId, allListings, maxPlace, mainToken.decimals, harvestableIndex]); + // Token selection handler with tracking const handleTokenSelection = useCallback( (newToken: Token) => { diff --git a/src/pages/market/actions/FillOrder.tsx b/src/pages/market/actions/FillOrder.tsx index 0a28348de..6f856a23a 100644 --- a/src/pages/market/actions/FillOrder.tsx +++ b/src/pages/market/actions/FillOrder.tsx @@ -21,7 +21,7 @@ import { formatter } from "@/utils/format"; import { FarmToMode, Plot } from "@/utils/types"; import { useQueryClient } from "@tanstack/react-query"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; -import { useNavigate } from "react-router-dom"; +import { useNavigate, useSearchParams } from "react-router-dom"; import { toast } from "sonner"; import { Address, encodeFunctionData } from "viem"; import { useAccount } from "wagmi"; @@ -79,6 +79,8 @@ export default function FillOrder() { const podIndex = usePodIndex(); const podLine = podIndex.sub(harvestableIndex); const navigate = useNavigate(); + const [searchParams] = useSearchParams(); + const orderId = searchParams.get("orderId"); const queryClient = useQueryClient(); const { @@ -217,6 +219,20 @@ export default function FillOrder() { } }, [totalCapacity]); + // Pre-select order when orderId parameter is present (clicked from chart) + useEffect(() => { + if (!orderId || !allOrders?.podOrders) return; + + // Find the order with matching ID + const order = allOrders.podOrders.find((o) => o.id === orderId); + if (!order) return; + + // Add order to selection if not already selected + if (!selectedOrderIds.includes(orderId)) { + setSelectedOrderIds((prev) => [...prev, orderId]); + } + }, [orderId, allOrders, selectedOrderIds]); + const orderMarkers = useMemo(() => { if (eligibleOrders.length === 0) return []; From cc4fbf210f713a42ab67a5a84ede7165da01d1c8 Mon Sep 17 00:00:00 2001 From: feyyazcigim Date: Fri, 31 Oct 2025 05:28:07 +0300 Subject: [PATCH 23/50] Add content header to Market page --- src/components/ReadMoreAccordion.tsx | 32 +++++++++++++++++++++++++++- src/pages/Market.tsx | 17 +++++++++++++++ 2 files changed, 48 insertions(+), 1 deletion(-) diff --git a/src/components/ReadMoreAccordion.tsx b/src/components/ReadMoreAccordion.tsx index 44a066310..4a8046057 100644 --- a/src/components/ReadMoreAccordion.tsx +++ b/src/components/ReadMoreAccordion.tsx @@ -7,8 +7,14 @@ interface IReadMoreAccordion { children: React.ReactNode; defaultOpen?: boolean; onChange?: (open: boolean) => void; + inline?: boolean; } -export default function ReadMoreAccordion({ children, defaultOpen = false, onChange }: IReadMoreAccordion) { +export default function ReadMoreAccordion({ + children, + defaultOpen = false, + onChange, + inline = false, +}: IReadMoreAccordion) { const [open, setOpen] = useState(defaultOpen); const handleToggle = () => { @@ -17,6 +23,30 @@ export default function ReadMoreAccordion({ children, defaultOpen = false, onCha onChange?.(newValue); }; + if (inline) { + return ( + + + {open && {children}} + + + {open ? " Read less" : " Read more"} + + + ); + } + return ( diff --git a/src/pages/Market.tsx b/src/pages/Market.tsx index a9834ddf9..b09e8167d 100644 --- a/src/pages/Market.tsx +++ b/src/pages/Market.tsx @@ -2,6 +2,7 @@ import PodIcon from "@/assets/protocol/Pod.png"; import PintoIcon from "@/assets/tokens/PINTO.png"; import { TokenValue } from "@/classes/TokenValue"; import FrameAnimator from "@/components/LoadingSpinner"; +import ReadMoreAccordion from "@/components/ReadMoreAccordion"; import ScatterChart from "@/components/charts/ScatterChart"; import { Separator } from "@/components/ui/Separator"; import { ANALYTICS_EVENTS } from "@/constants/analytics-events"; @@ -304,6 +305,22 @@ export function Market() {

+
+
Market
+
+ Buy and sell Pods on the open market. + + The Pod Market is a decentralized marketplace where users can trade Pods, which are protocol-native debt + instruments that represent future Pinto tokens. When you buy Pods, you're essentially purchasing the + right to redeem them for Pinto tokens at a fixed rate when they become harvestable. The market operates + on a first-in-first-out (FIFO) basis, meaning the oldest Pods become harvestable first. You can place + buy orders to acquire Pods at a specific price, or create listings to sell your existing Pods to other + users. The scatter chart above visualizes all active orders and listings, showing their place in line + and price per Pod. This allows you to see market depth and make informed trading decisions based on + current market conditions and your investment strategy. + +
+
From 136bf4b5598297797c49c8c5b44567bf40ae8cec Mon Sep 17 00:00:00 2001 From: feyyazcigim Date: Fri, 31 Oct 2025 05:32:59 +0300 Subject: [PATCH 24/50] Add graph label parameter --- src/components/PodLineGraph.tsx | 5 ++++- src/pages/market/actions/CreateListing.tsx | 1 + src/pages/market/actions/CreateOrder.tsx | 2 +- src/pages/market/actions/FillListing.tsx | 1 + src/pages/market/actions/FillOrder.tsx | 3 ++- 5 files changed, 9 insertions(+), 3 deletions(-) diff --git a/src/components/PodLineGraph.tsx b/src/components/PodLineGraph.tsx index 42595475a..8b49634f4 100644 --- a/src/components/PodLineGraph.tsx +++ b/src/components/PodLineGraph.tsx @@ -39,6 +39,8 @@ interface PodLineGraphProps { disableInteractions?: boolean; /** Additional CSS classes */ className?: string; + /** Optional: custom label text (default: "My Pods In Line") */ + label?: string; } /** @@ -171,6 +173,7 @@ export default function PodLineGraph({ onPlotGroupSelect, disableInteractions = false, className, + label = "My Pods In Line", }: PodLineGraphProps) { const farmerField = useFarmerField(); const harvestableIndex = useHarvestableIndex(); @@ -242,7 +245,7 @@ export default function PodLineGraph({
{/* Label */}
-

My Pods In Line

+

{label}

{/* Plot container with border */} diff --git a/src/pages/market/actions/CreateListing.tsx b/src/pages/market/actions/CreateListing.tsx index ec622a3dc..6330ed9e4 100644 --- a/src/pages/market/actions/CreateListing.tsx +++ b/src/pages/market/actions/CreateListing.tsx @@ -395,6 +395,7 @@ export default function CreateListing() { p.index.toHuman())} selectedPodRange={selectedPodRange} + label="My Pods In Line" onPlotGroupSelect={(plotIndices) => { // Check if all plots in the group are already selected const allSelected = plotIndices.every((index) => plot.some((p) => p.index.toHuman() === index)); diff --git a/src/pages/market/actions/CreateOrder.tsx b/src/pages/market/actions/CreateOrder.tsx index 17d1da441..2485dd87d 100644 --- a/src/pages/market/actions/CreateOrder.tsx +++ b/src/pages/market/actions/CreateOrder.tsx @@ -370,7 +370,7 @@ export default function CreateOrder() { return (
{/* PodLineGraph Visualization */} - + {/* Place in Line Slider */}
diff --git a/src/pages/market/actions/FillListing.tsx b/src/pages/market/actions/FillListing.tsx index e14f3bbbd..2fd87591a 100644 --- a/src/pages/market/actions/FillListing.tsx +++ b/src/pages/market/actions/FillListing.tsx @@ -606,6 +606,7 @@ export default function FillListing() { selectedPlotIndices={eligibleListingIds} rangeOverlay={rangeOverlay} disableInteractions={true} + label="Available Listings" />
diff --git a/src/pages/market/actions/FillOrder.tsx b/src/pages/market/actions/FillOrder.tsx index 6f856a23a..539e2e295 100644 --- a/src/pages/market/actions/FillOrder.tsx +++ b/src/pages/market/actions/FillOrder.tsx @@ -440,7 +440,7 @@ export default function FillOrder() {

Select the order you want to fill (i):

- +

There are no open orders that can be filled with your Pods.

@@ -459,6 +459,7 @@ export default function FillOrder() { item.order.id)} + label="Open Orders" onPlotGroupSelect={(plotIndices) => { // Multi-select toggle: add or remove clicked order if (plotIndices.length > 0) { From f399da3011b53bd58f75cf702cc2b948612ec03a Mon Sep 17 00:00:00 2001 From: feyyazcigim Date: Fri, 31 Oct 2025 05:46:28 +0300 Subject: [PATCH 25/50] Add multiple select on graph --- src/pages/market/actions/CreateListing.tsx | 40 ++++++++++++++++------ src/pages/market/actions/FillOrder.tsx | 28 +++++++++------ 2 files changed, 47 insertions(+), 21 deletions(-) diff --git a/src/pages/market/actions/CreateListing.tsx b/src/pages/market/actions/CreateListing.tsx index 6330ed9e4..73ef2d19c 100644 --- a/src/pages/market/actions/CreateListing.tsx +++ b/src/pages/market/actions/CreateListing.tsx @@ -197,11 +197,14 @@ export default function CreateListing() { plot_count: plots.length, previous_count: plot.length, }); - setPlot(plots); - - // Reset range when plots change - if (plots.length > 0) { - const totalPods = plots.reduce((sum, p) => sum + p.pods.toNumber(), 0); + + // Sort plots by index to ensure consistent ordering + const sortedPlots = [...plots].sort((a, b) => a.index.sub(b.index).toNumber()); + setPlot(sortedPlots); + + // Reset range when plots change - slider always starts from first plot and ends at last plot + if (sortedPlots.length > 0) { + const totalPods = sortedPlots.reduce((sum, p) => sum + p.pods.toNumber(), 0); setPodRange([0, totalPods]); setAmount(totalPods); } else { @@ -397,17 +400,32 @@ export default function CreateListing() { selectedPodRange={selectedPodRange} label="My Pods In Line" onPlotGroupSelect={(plotIndices) => { + // Find all plots in the clicked group from farmer plots + const plotsInGroup = farmerField.plots.filter((p) => plotIndices.includes(p.index.toHuman())); + + if (plotsInGroup.length === 0) return; + // Check if all plots in the group are already selected const allSelected = plotIndices.every((index) => plot.some((p) => p.index.toHuman() === index)); if (allSelected) { - // Deselect if already selected - setPlot([]); + // Deselect only this group - remove plots from this group + const updatedPlots = plot.filter((p) => !plotIndices.includes(p.index.toHuman())); + handlePlotSelection(updatedPlots); } else { - // Find and select all plots in the group from farmer plots - const plotsToSelect = farmerField.plots.filter((p) => plotIndices.includes(p.index.toHuman())); - if (plotsToSelect.length > 0) { - handlePlotSelection(plotsToSelect); + // Add this group to existing selection - merge with current selection (avoid duplicates) + const plotIndexSet = new Set(plot.map((p) => p.index.toHuman())); + const newPlots = [...plot]; + + plotsInGroup.forEach((plotToAdd) => { + if (!plotIndexSet.has(plotToAdd.index.toHuman())) { + newPlots.push(plotToAdd); + plotIndexSet.add(plotToAdd.index.toHuman()); + } + }); + + if (newPlots.length > plot.length) { + handlePlotSelection(newPlots); } } }} diff --git a/src/pages/market/actions/FillOrder.tsx b/src/pages/market/actions/FillOrder.tsx index 539e2e295..d06c197c9 100644 --- a/src/pages/market/actions/FillOrder.tsx +++ b/src/pages/market/actions/FillOrder.tsx @@ -458,20 +458,28 @@ export default function FillOrder() { {/* Pod Line Graph - Shows order markers (orange thin lines at maxPlaceInLine) */} item.order.id)} + selectedPlotIndices={selectedOrderIds} label="Open Orders" onPlotGroupSelect={(plotIndices) => { - // Multi-select toggle: add or remove clicked order - if (plotIndices.length > 0) { - const clickedOrderId = plotIndices[0]; + // Multi-select toggle: add or remove clicked order group + if (plotIndices.length === 0) return; + // Check if all orders in the group are already selected + const allSelected = plotIndices.every((orderId) => selectedOrderIds.includes(orderId)); + + if (allSelected) { + // Deselect only this group - remove orders from this group + setSelectedOrderIds((prev) => prev.filter((id) => !plotIndices.includes(id))); + } else { + // Add this group to existing selection - merge with current selection (avoid duplicates) setSelectedOrderIds((prev) => { - // If already selected, remove it - if (prev.includes(clickedOrderId)) { - return prev.filter((id) => id !== clickedOrderId); - } - // Otherwise, add it to selection - return [...prev, clickedOrderId]; + const newOrderIds = [...prev]; + plotIndices.forEach((orderId) => { + if (!newOrderIds.includes(orderId)) { + newOrderIds.push(orderId); + } + }); + return newOrderIds; }); } }} From 1affa181f9ef62ac8b0774bcf4cb7e72bbaf9b94 Mon Sep 17 00:00:00 2001 From: feyyazcigim Date: Fri, 31 Oct 2025 05:57:58 +0300 Subject: [PATCH 26/50] Add advanced settings to create listing --- src/pages/market/actions/CreateListing.tsx | 89 ++++++++++++++++------ 1 file changed, 65 insertions(+), 24 deletions(-) diff --git a/src/pages/market/actions/CreateListing.tsx b/src/pages/market/actions/CreateListing.tsx index 73ef2d19c..366970644 100644 --- a/src/pages/market/actions/CreateListing.tsx +++ b/src/pages/market/actions/CreateListing.tsx @@ -1,3 +1,4 @@ +import settingsIcon from "@/assets/misc/Settings.svg"; import pintoIcon from "@/assets/tokens/PINTO.png"; import { TV, TokenValue } from "@/classes/TokenValue"; import ComboPlotInputField from "@/components/ComboPlotInputField"; @@ -23,6 +24,7 @@ import { formatter } from "@/utils/format"; import { FarmToMode, Plot } from "@/utils/types"; import { cn } from "@/utils/utils"; import { useQueryClient } from "@tanstack/react-query"; +import { motion } from "framer-motion"; import { useCallback, useMemo, useState } from "react"; import { useNavigate } from "react-router-dom"; import { toast } from "sonner"; @@ -87,11 +89,21 @@ export default function CreateListing() { const [successPrice, setSuccessPrice] = useState(null); const podIndex = usePodIndex(); const maxExpiration = Number.parseInt(podIndex.toHuman()) - Number.parseInt(harvestableIndex.toHuman()) || 0; - const expiresIn = maxExpiration; // Auto-set to max expiration + const [expiresIn, setExpiresIn] = useState(maxExpiration); // Auto-set to max expiration const minFill = TokenValue.fromHuman(1, PODS.decimals); + const [showAdvancedSettings, setShowAdvancedSettings] = useState(false); const plotPosition = plot.length > 0 ? plot[0].index.sub(harvestableIndex) : TV.ZERO; + const maxExpirationValidation = useMemo( + () => ({ + minValue: 1, + maxValue: maxExpiration, + maxDecimals: 0, + }), + [maxExpiration], + ); + // Calculate max pods based on selected plots OR all farmer plots const maxPodAmount = useMemo(() => { const plotsToUse = plot.length > 0 ? plot : farmerField.plots; @@ -197,7 +209,7 @@ export default function CreateListing() { plot_count: plots.length, previous_count: plot.length, }); - + // Sort plots by index to ensure consistent ordering const sortedPlots = [...plots].sort((a, b) => a.index.sub(b.index).toNumber()); setPlot(sortedPlots); @@ -416,7 +428,7 @@ export default function CreateListing() { // Add this group to existing selection - merge with current selection (avoid duplicates) const plotIndexSet = new Set(plot.map((p) => p.index.toHuman())); const newPlots = [...plot]; - + plotsInGroup.forEach((plotToAdd) => { if (!plotIndexSet.has(plotToAdd.index.toHuman())) { newPlots.push(plotToAdd); @@ -515,28 +527,57 @@ export default function CreateListing() {
)}
- {/* Expires In - Auto-set to max expiration */} - {/*
-

Expires In

- - {!!expiresIn && ( -

- This listing will automatically expire after {formatter.noDec(expiresIn)} more Pods become Harvestable. -

- )} -
*/} -
-

Send balances to Farm Balance

- setBalanceTo(checked ? FarmToMode.INTERNAL : FarmToMode.EXTERNAL)} - /> + {/* Advanced Settings Toggle */} +
+

Settings

+
+ {/* Advanced Settings - Collapsible */} + +
+ {/* Expires In - Auto-set to max expiration */} +
+

Expires In

+ + {!!expiresIn && ( +

+ This listing will automatically expire after {formatter.noDec(expiresIn)} more Pods become + Harvestable. +

+ )} +
+
+

Send balances to Farm Balance

+ setBalanceTo(checked ? FarmToMode.INTERNAL : FarmToMode.EXTERNAL)} + /> +
+
+
)}
From 38174d682e2b4aad52121a24684c7c99577cacc4 Mon Sep 17 00:00:00 2001 From: feyyazcigim Date: Fri, 31 Oct 2025 06:19:40 +0300 Subject: [PATCH 27/50] Fix ComboInputFiels --- src/components/ComboInputField.tsx | 47 ++++++++++++++++-------- src/pages/market/actions/FillListing.tsx | 16 ++++++++ src/pages/market/actions/FillOrder.tsx | 6 +++ 3 files changed, 53 insertions(+), 16 deletions(-) diff --git a/src/components/ComboInputField.tsx b/src/components/ComboInputField.tsx index 44dc1cb1e..8859d2df6 100644 --- a/src/components/ComboInputField.tsx +++ b/src/components/ComboInputField.tsx @@ -171,24 +171,30 @@ function ComboInputField({ return selectedPlots.reduce((total, plot) => total.add(plot.pods), TokenValue.ZERO); } - if (customMaxAmount) { - return customMaxAmount; - } - + // Get base balance first + let baseBalance = TokenValue.ZERO; if (tokenAndBalanceMap && selectedToken) { - return tokenAndBalanceMap.get(selectedToken) ?? TokenValue.ZERO; + baseBalance = tokenAndBalanceMap.get(selectedToken) ?? TokenValue.ZERO; + } else if (farmerTokenBalance) { + switch (balanceFrom) { + case FarmFromMode.EXTERNAL: + baseBalance = farmerTokenBalance.external || TokenValue.ZERO; + break; + case FarmFromMode.INTERNAL: + baseBalance = farmerTokenBalance.internal || TokenValue.ZERO; + break; + default: + baseBalance = farmerTokenBalance.total || TokenValue.ZERO; + } } - if (!farmerTokenBalance) return TokenValue.ZERO; - - switch (balanceFrom) { - case FarmFromMode.EXTERNAL: - return farmerTokenBalance.external || TokenValue.ZERO; - case FarmFromMode.INTERNAL: - return farmerTokenBalance.internal || TokenValue.ZERO; - default: - return farmerTokenBalance.total || TokenValue.ZERO; + // If customMaxAmount is provided and greater than 0, use the minimum of base balance and customMaxAmount + if (customMaxAmount?.gt(0)) { + return TokenValue.min(baseBalance, customMaxAmount); } + + // Otherwise use base balance + return baseBalance; }, [mode, selectedPlots, customMaxAmount, tokenAndBalanceMap, selectedToken, balanceFrom, farmerTokenBalance]); const balance = useMemo(() => { @@ -200,8 +206,17 @@ function ComboInputField({ return tokenAndBalanceMap.get(selectedToken) ?? TokenValue.ZERO; } } - return maxAmount; - }, [mode, selectedPlots, tokenAndBalanceMap, selectedToken, maxAmount]); + // Always use farmerTokenBalance for display, not maxAmount (which may be limited by customMaxAmount) + if (!farmerTokenBalance) return TokenValue.ZERO; + switch (balanceFrom) { + case FarmFromMode.EXTERNAL: + return farmerTokenBalance.external || TokenValue.ZERO; + case FarmFromMode.INTERNAL: + return farmerTokenBalance.internal || TokenValue.ZERO; + default: + return farmerTokenBalance.total || TokenValue.ZERO; + } + }, [mode, selectedPlots, tokenAndBalanceMap, selectedToken, farmerTokenBalance, balanceFrom]); /** * Clamp the input amount to the max amount ONLY IF clamping is enabled diff --git a/src/pages/market/actions/FillListing.tsx b/src/pages/market/actions/FillListing.tsx index 2fd87591a..cd444213d 100644 --- a/src/pages/market/actions/FillListing.tsx +++ b/src/pages/market/actions/FillListing.tsx @@ -405,6 +405,21 @@ export default function FillListing() { return null; } + // If only one listing, use its price directly (no need for average) + if (listingsToFill.length === 1) { + const { listing, beanAmount } = listingsToFill[0]; + const listingPrice = TokenValue.fromBlockchain(listing.pricePerPod, mainToken.decimals); + const podsFromListing = beanAmount.div(listingPrice); + const listingPlace = TokenValue.fromBlockchain(listing.index, PODS.decimals).sub(harvestableIndex); + + return { + avgPricePerPod: listingPrice, + avgPlaceInLine: listingPlace, + totalPods: podsFromListing.toNumber(), + }; + } + + // Multiple listings - calculate weighted average let totalValue = 0; let totalPods = 0; let totalPlaceInLine = 0; @@ -718,6 +733,7 @@ export default function FillListing() {
{ if (ordersToFill.length === 0 || amount === 0) return 0; + // If only one order, use its price directly (no need for average) + if (ordersToFill.length === 1) { + return TokenValue.fromBlockchain(ordersToFill[0].order.pricePerPod, mainToken.decimals).toNumber(); + } + + // Multiple orders - calculate weighted average let totalValue = 0; let totalPods = 0; From a2704a982cfdab5bea965935e2951d28406597fb Mon Sep 17 00:00:00 2001 From: feyyazcigim Date: Fri, 31 Oct 2025 06:23:21 +0300 Subject: [PATCH 28/50] Edit Market header --- src/pages/Market.tsx | 36 ++++++++++++++++++++---------------- 1 file changed, 20 insertions(+), 16 deletions(-) diff --git a/src/pages/Market.tsx b/src/pages/Market.tsx index b09e8167d..a579b4f54 100644 --- a/src/pages/Market.tsx +++ b/src/pages/Market.tsx @@ -1,6 +1,7 @@ import PodIcon from "@/assets/protocol/Pod.png"; import PintoIcon from "@/assets/tokens/PINTO.png"; import { TokenValue } from "@/classes/TokenValue"; +import { Col } from "@/components/Container"; import FrameAnimator from "@/components/LoadingSpinner"; import ReadMoreAccordion from "@/components/ReadMoreAccordion"; import ScatterChart from "@/components/charts/ScatterChart"; @@ -305,23 +306,26 @@ export function Market() {
-
-
Market
-
- Buy and sell Pods on the open market. - - The Pod Market is a decentralized marketplace where users can trade Pods, which are protocol-native debt - instruments that represent future Pinto tokens. When you buy Pods, you're essentially purchasing the - right to redeem them for Pinto tokens at a fixed rate when they become harvestable. The market operates - on a first-in-first-out (FIFO) basis, meaning the oldest Pods become harvestable first. You can place - buy orders to acquire Pods at a specific price, or create listings to sell your existing Pods to other - users. The scatter chart above visualizes all active orders and listings, showing their place in line - and price per Pod. This allows you to see market depth and make informed trading decisions based on - current market conditions and your investment strategy. - + +
+
Market
+
+ Buy and sell Pods on the open market. +
-
-
+ + The Pod Market is a decentralized marketplace where users can trade Pods, which are protocol-native debt + instruments that represent future Pinto tokens. When you buy Pods, you're essentially purchasing the right + to redeem them for Pinto tokens at a fixed rate when they become harvestable. The market operates on a + first-in-first-out (FIFO) basis, meaning the oldest Pods become harvestable first. You can place buy + orders to acquire Pods at a specific price, or create listings to sell your existing Pods to other users. + The scatter chart above visualizes all active orders and listings, showing their place in line and price + per Pod. This allows you to see market depth and make informed trading decisions based on current market + conditions and your investment strategy. + + + +
{!isLoaded && ( From 37071c848922dd25e7d43e1bf3144e1340d2609d Mon Sep 17 00:00:00 2001 From: feyyazcigim Date: Mon, 3 Nov 2025 15:53:42 +0300 Subject: [PATCH 29/50] Clean up code by removing unnecessary comment lines --- src/pages/market/actions/CreateListing.tsx | 26 ---------------------- 1 file changed, 26 deletions(-) diff --git a/src/pages/market/actions/CreateListing.tsx b/src/pages/market/actions/CreateListing.tsx index 366970644..0ab71ac64 100644 --- a/src/pages/market/actions/CreateListing.tsx +++ b/src/pages/market/actions/CreateListing.tsx @@ -314,21 +314,10 @@ export default function CreateListing() { setSubmitting(true); toast.loading(`Creating ${listingData.length} Listing${listingData.length > 1 ? "s" : ""}...`); - // Log listing data for debugging - // console.log("=== CREATE LISTING DATA ==="); - // console.log(`Total listings to create: ${listingData.length}`); - // console.log(`Price per pod: ${pricePerPod} ${mainToken.symbol}`); - const farmData: `0x${string}`[] = []; // Create a listing call for each plot for (const data of listingData) { - // console.log(`\n--- Listing ${index + 1} ---`); - // console.log(`Plot Index: ${data.index.toHuman()}`); - // console.log(`Start (relative): ${data.start.toHuman()}`); - // console.log(`End (relative): ${data.end.toHuman()}`); - // console.log(`Amount: ${data.amount.toHuman()} pods`); - // console.log(`Place in line: ${data.index.sub(harvestableIndex).toHuman()}`); const listingArgs = { lister: account, @@ -342,18 +331,6 @@ export default function CreateListing() { mode: Number(balanceTo), }; - // console.log("Encoded args:", { - // lister: listingArgs.lister, - // fieldId: listingArgs.fieldId.toString(), - // index: listingArgs.index.toString(), - // start: listingArgs.start.toString(), - // podAmount: listingArgs.podAmount.toString(), - // pricePerPod: listingArgs.pricePerPod, - // maxHarvestableIndex: listingArgs.maxHarvestableIndex.toString(), - // minFillAmount: listingArgs.minFillAmount.toString(), - // mode: listingArgs.mode, - // }); - const listingCall = encodeFunctionData({ abi: beanstalkAbi, functionName: "createPodListing", @@ -362,9 +339,6 @@ export default function CreateListing() { farmData.push(listingCall); } - // console.log(`\nTotal farm calls: ${farmData.length}`); - // console.log("=========================\n"); - // Use farm to batch all listings in one transaction writeWithEstimateGas({ address: diamondAddress, From ff7bfba4e49114e8578236e70a5dec67d62b3dad Mon Sep 17 00:00:00 2001 From: feyyazcigim Date: Mon, 3 Nov 2025 16:03:27 +0300 Subject: [PATCH 30/50] Replace duplicated Buy/Sell buttons with Tabs and preserve scroll within /market/pods --- src/components/ScrollToTop.tsx | 17 ++++++++++-- src/pages/market/MarketModeSelect.tsx | 30 +++++----------------- src/pages/market/actions/CreateListing.tsx | 1 - 3 files changed, 21 insertions(+), 27 deletions(-) diff --git a/src/components/ScrollToTop.tsx b/src/components/ScrollToTop.tsx index b35dadaf6..2f54728d2 100644 --- a/src/components/ScrollToTop.tsx +++ b/src/components/ScrollToTop.tsx @@ -1,17 +1,30 @@ // components/ScrollToTop.tsx -import { useEffect } from "react"; +import { useEffect, useRef } from "react"; import { useLocation } from "react-router-dom"; export default function ScrollToTop() { const { pathname } = useLocation(); + const prevPathnameRef = useRef(null); useEffect(() => { + const prev = prevPathnameRef.current; + const isMarketPodsPrev = prev?.startsWith("/market/pods"); + const isMarketPodsCurrent = pathname.startsWith("/market/pods"); + + // Preserve scroll position when navigating within the Pod Market + if (isMarketPodsPrev && isMarketPodsCurrent) { + prevPathnameRef.current = pathname; + return; + } + document.body.scrollTo({ top: 0, left: 0, behavior: "instant", }); - }, [pathname]); // Trigger on pathname or search param changes + + prevPathnameRef.current = pathname; + }, [pathname]); return null; } diff --git a/src/pages/market/MarketModeSelect.tsx b/src/pages/market/MarketModeSelect.tsx index 2e5c5e118..130865a87 100644 --- a/src/pages/market/MarketModeSelect.tsx +++ b/src/pages/market/MarketModeSelect.tsx @@ -57,30 +57,12 @@ export default function MarketModeSelect({ onMainSelectionChange, onSecondarySel return (
-
- - -
+ + + Buy Pods + Sell Pods + + {mainTab ? ( <> {mainTab === "sell" && hasNoPods ? ( diff --git a/src/pages/market/actions/CreateListing.tsx b/src/pages/market/actions/CreateListing.tsx index 0ab71ac64..8034ed58a 100644 --- a/src/pages/market/actions/CreateListing.tsx +++ b/src/pages/market/actions/CreateListing.tsx @@ -318,7 +318,6 @@ export default function CreateListing() { // Create a listing call for each plot for (const data of listingData) { - const listingArgs = { lister: account, fieldId: 0n, From d0b553f706ace59fdba266b39bf5285b2bbc74cc Mon Sep 17 00:00:00 2001 From: feyyazcigim Date: Mon, 3 Nov 2025 16:12:32 +0300 Subject: [PATCH 31/50] Make nextPlot access bounds-aware for readability --- src/components/PodLineGraph.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/PodLineGraph.tsx b/src/components/PodLineGraph.tsx index 8b49634f4..779292dc4 100644 --- a/src/components/PodLineGraph.tsx +++ b/src/components/PodLineGraph.tsx @@ -57,7 +57,7 @@ function combinePlots(plots: Plot[], harvestableIndex: TokenValue, selectedIndic for (let i = 0; i < sortedPlots.length; i++) { const plot = sortedPlots[i]; - const nextPlot = sortedPlots[i + 1]; + const nextPlot = i + 1 < sortedPlots.length ? sortedPlots[i + 1] : undefined; currentGroup.push(plot); From dc9716cd878ce7b78a08edede609d9cd44fdbe8c Mon Sep 17 00:00:00 2001 From: feyyazcigim Date: Mon, 3 Nov 2025 16:37:02 +0300 Subject: [PATCH 32/50] Extract PlotGroup/Overlay and fix context-aware coloring --- src/components/PodLineGraph.tsx | 172 +++++++----------- .../PodLineGraph/PartialSelectionOverlay.tsx | 30 +++ src/components/PodLineGraph/PlotGroup.tsx | 52 ++++++ src/components/PodLineGraph/geometry.ts | 50 +++++ src/components/PodLineGraph/selection.ts | 13 ++ 5 files changed, 207 insertions(+), 110 deletions(-) create mode 100644 src/components/PodLineGraph/PartialSelectionOverlay.tsx create mode 100644 src/components/PodLineGraph/PlotGroup.tsx create mode 100644 src/components/PodLineGraph/geometry.ts create mode 100644 src/components/PodLineGraph/selection.ts diff --git a/src/components/PodLineGraph.tsx b/src/components/PodLineGraph.tsx index 779292dc4..31e572e14 100644 --- a/src/components/PodLineGraph.tsx +++ b/src/components/PodLineGraph.tsx @@ -6,6 +6,10 @@ import { formatter } from "@/utils/format"; import { Plot } from "@/utils/types"; import { cn } from "@/utils/utils"; import { useMemo, useState } from "react"; +import { PlotGroup } from "./PodLineGraph/PlotGroup"; +import { PartialSelectionOverlay } from "./PodLineGraph/PartialSelectionOverlay"; +import { computeGroupLayout, computePartialSelectionPercent } from "./PodLineGraph/geometry"; +import { deriveGroupState } from "./PodLineGraph/selection"; // Layout constants const HARVESTED_WIDTH_PERCENT = 20; @@ -281,7 +285,6 @@ export default function PodLineGraph({ {/* Plot rectangles */}
{harvestedPlots.map((plot, idx) => { - // Exponential scale: small values compressed to the left, large values spread to the right const minValue = maxHarvestedIndex / 10; const plotStart = Math.max(plot.startIndex.toNumber(), minValue); const plotEnd = Math.max(plot.endIndex.toNumber(), minValue); @@ -289,29 +292,24 @@ export default function PodLineGraph({ const normalizedStart = (plotStart - minValue) / (maxHarvestedIndex - minValue); const normalizedEnd = (plotEnd - minValue) / (maxHarvestedIndex - minValue); - // Apply exponential transformation const k = 1; const leftPercent = ((Math.exp(k * normalizedStart) - 1) / (Math.exp(k) - 1)) * 100; const rightPercent = ((Math.exp(k * normalizedEnd) - 1) / (Math.exp(k) - 1)) * 100; const widthPercent = rightPercent - leftPercent; const displayWidth = Math.max(widthPercent, MIN_PLOT_WIDTH_PERCENT); - // Check if this is the leftmost plot const isLeftmost = idx === 0 && leftPercent < 1; + const borderRadius = isLeftmost ? "2px 0 0 2px" : "2px"; return ( -
); })} @@ -371,122 +369,76 @@ export default function PodLineGraph({ {/* Plot rectangles - grouped visually but individually interactive */}
{unharvestedPlots.map((group, groupIdx) => { - const groupPlaceInLine = group.startIndex.sub(harvestableIndex); - const groupEnd = group.endIndex.sub(harvestableIndex); - - const groupLeftPercent = podLine.gt(0) ? (groupPlaceInLine.toNumber() / podLine.toNumber()) * 100 : 0; - const groupWidthPercent = podLine.gt(0) - ? ((groupEnd.toNumber() - groupPlaceInLine.toNumber()) / podLine.toNumber()) * 100 - : 0; - const groupDisplayWidth = Math.max(groupWidthPercent, MIN_PLOT_WIDTH_PERCENT); - - // Check if this is the rightmost group - const isRightmost = - groupIdx === unharvestedPlots.length - 1 && groupLeftPercent + groupDisplayWidth > 99; + const groupStartMinusHarvestable = group.startIndex.sub(harvestableIndex); + const groupEndMinusHarvestable = group.endIndex.sub(harvestableIndex); + + const isLastGroup = groupIdx === unharvestedPlots.length - 1; + const { leftPercent, displayWidthPercent, borderRadius } = computeGroupLayout( + groupStartMinusHarvestable, + groupEndMinusHarvestable, + podLine, + isLastGroup, + ); - // Check if group is hovered or selected (based on first plot in group) - // Use id (for order markers) or index (for regular plots) const groupFirstPlotIndex = group.plots[0].id || group.plots[0].index.toHuman(); const hasHoveredPlot = group.plots.some((p) => (p.id || p.index.toHuman()) === hoveredPlotIndex); const hasSelectedPlot = group.plots.some((p) => selectedSet.has(p.id || p.index.toHuman())); const hasHarvestablePlot = group.plots.some((p) => p.harvestablePods?.gt(0)); - // Calculate partial selection if selectedPodRange is provided - let partialSelectionPercent: { start: number; end: number } | null = null; - if (selectedPodRange && hasSelectedPlot) { - // Calculate intersection of group and selected range - const groupStart = group.startIndex; - const groupEnd = group.endIndex; - const rangeStart = selectedPodRange.start; - const rangeEnd = selectedPodRange.end; - - // Check if there's an overlap - if (rangeStart.lt(groupEnd) && rangeEnd.gt(groupStart)) { - // Calculate the overlapping range within the group - const overlapStart = rangeStart.gt(groupStart) ? rangeStart : groupStart; - const overlapEnd = rangeEnd.lt(groupEnd) ? rangeEnd : groupEnd; - - // Convert to percentages within the group - const groupTotal = groupEnd.sub(groupStart).toNumber(); - const overlapStartOffset = overlapStart.sub(groupStart).toNumber(); - const overlapEndOffset = overlapEnd.sub(groupStart).toNumber(); - - partialSelectionPercent = { - start: (overlapStartOffset / groupTotal) * 100, - end: (overlapEndOffset / groupTotal) * 100, - }; - } - } - - // Determine group color - const groupIsGreen = - hasHarvestablePlot || (hasSelectedPlot && !partialSelectionPercent) || hasHoveredPlot; - const groupIsActive = hasHoveredPlot || hasSelectedPlot; - - // Border radius for the group - let groupBorderRadius = "2px"; - if (isRightmost) { - groupBorderRadius = "0 2px 2px 0"; - } + // Determine if the current range overlaps this group (used for coloring when range is present) + const overlapsSelection = selectedPodRange + ? selectedPodRange.start.lt(group.endIndex) && selectedPodRange.end.gt(group.startIndex) + : false; + + // Compute partial selection overlay only when selection overlaps + const partialSelectionPercent = selectedPodRange && overlapsSelection + ? computePartialSelectionPercent( + group.startIndex, + group.endIndex, + selectedPodRange.start, + selectedPodRange.end, + ) + : null; + + // In Create (range present), green follows overlap; in Fill (no range), green follows selection + const selectionGreen = selectedPodRange ? overlapsSelection : hasSelectedPlot; + + const { isGreen: groupIsGreen, isActive: groupIsActive } = deriveGroupState( + hasHarvestablePlot, + hasSelectedPlot, + hasHoveredPlot, + selectionGreen, + ); - // Handle group click - select all plots in the group const handleGroupClick = () => { if (disableInteractions) return; if (onPlotGroupSelect) { - // Send all plot IDs or indices in the group - // Prefer 'id' if available (for order markers), otherwise use index const plotIndices = group.plots.map((p) => p.id || p.index.toHuman()); onPlotGroupSelect(plotIndices); } }; - // Render group with partial selection if applicable return ( -
setHoveredPlotIndex(groupFirstPlotIndex)} + onMouseLeave={() => setHoveredPlotIndex(null)} > - {/* Base rectangle (background color) */} -
setHoveredPlotIndex(groupFirstPlotIndex)} - onMouseLeave={disableInteractions ? undefined : () => setHoveredPlotIndex(null)} - /> - - {/* Partial selection overlay (green) */} - {partialSelectionPercent && ( -
= 99.9 ? groupBorderRadius : "0", - borderBottomRightRadius: partialSelectionPercent.end >= 99.9 ? groupBorderRadius : "0", - }} + {partialSelectionPercent && hasSelectedPlot && ( + )} -
+ ); })}
diff --git a/src/components/PodLineGraph/PartialSelectionOverlay.tsx b/src/components/PodLineGraph/PartialSelectionOverlay.tsx new file mode 100644 index 000000000..64aa13c11 --- /dev/null +++ b/src/components/PodLineGraph/PartialSelectionOverlay.tsx @@ -0,0 +1,30 @@ +import React from "react"; + +interface PartialSelectionOverlayProps { + startPercent: number; + endPercent: number; + borderRadius: string; +} + +function PartialSelectionOverlayComponent({ startPercent, endPercent, borderRadius }: PartialSelectionOverlayProps) { + const width = Math.max(endPercent - startPercent, 0); + return ( +
= 99.9 ? borderRadius : "0", + borderBottomRightRadius: endPercent >= 99.9 ? borderRadius : "0", + }} + /> + ); +} + +export const PartialSelectionOverlay = React.memo(PartialSelectionOverlayComponent); + + diff --git a/src/components/PodLineGraph/PlotGroup.tsx b/src/components/PodLineGraph/PlotGroup.tsx new file mode 100644 index 000000000..d3f4d87ec --- /dev/null +++ b/src/components/PodLineGraph/PlotGroup.tsx @@ -0,0 +1,52 @@ +import { cn } from "@/utils/utils"; +import React from "react"; + +interface PlotGroupProps { + leftPercent: number; + widthPercent: number; + borderRadius: string; + isGreen: boolean; + isActive: boolean; + disableInteractions?: boolean; + onClick?: () => void; + onMouseEnter?: () => void; + onMouseLeave?: () => void; + children?: React.ReactNode; +} + +function PlotGroupComponent({ + leftPercent, + widthPercent, + borderRadius, + isGreen, + isActive, + disableInteractions, + onClick, + onMouseEnter, + onMouseLeave, + children, +}: PlotGroupProps) { + return ( +
+
+ {children} +
+ ); +} + +export const PlotGroup = React.memo(PlotGroupComponent); + + diff --git a/src/components/PodLineGraph/geometry.ts b/src/components/PodLineGraph/geometry.ts new file mode 100644 index 000000000..36ccfe90e --- /dev/null +++ b/src/components/PodLineGraph/geometry.ts @@ -0,0 +1,50 @@ +import { TokenValue } from "@/classes/TokenValue"; + +export const MIN_PLOT_WIDTH_PERCENT = 0.3; + +export function computeGroupLayout( + groupStartMinusHarvestable: TokenValue, + groupEndMinusHarvestable: TokenValue, + podLine: TokenValue, + isLastGroup: boolean, +): { leftPercent: number; displayWidthPercent: number; borderRadius: string } { + const leftPercent = podLine.gt(0) + ? (groupStartMinusHarvestable.toNumber() / podLine.toNumber()) * 100 + : 0; + const widthPercent = podLine.gt(0) + ? ((groupEndMinusHarvestable.toNumber() - groupStartMinusHarvestable.toNumber()) / podLine.toNumber()) * 100 + : 0; + const displayWidthPercent = Math.max(widthPercent, MIN_PLOT_WIDTH_PERCENT); + + let borderRadius = "2px"; + if (isLastGroup && leftPercent + displayWidthPercent > 99) { + borderRadius = "0 2px 2px 0"; + } + + return { leftPercent, displayWidthPercent, borderRadius }; +} + +export function computePartialSelectionPercent( + groupStart: TokenValue, + groupEnd: TokenValue, + rangeStart: TokenValue, + rangeEnd: TokenValue, +): { start: number; end: number } | null { + if (!(rangeStart.lt(groupEnd) && rangeEnd.gt(groupStart))) return null; + + const overlapStart = rangeStart.gt(groupStart) ? rangeStart : groupStart; + const overlapEnd = rangeEnd.lt(groupEnd) ? rangeEnd : groupEnd; + + const groupTotal = groupEnd.sub(groupStart).toNumber(); + if (groupTotal <= 0) return null; + + const overlapStartOffset = overlapStart.sub(groupStart).toNumber(); + const overlapEndOffset = overlapEnd.sub(groupStart).toNumber(); + + return { + start: (overlapStartOffset / groupTotal) * 100, + end: (overlapEndOffset / groupTotal) * 100, + }; +} + + diff --git a/src/components/PodLineGraph/selection.ts b/src/components/PodLineGraph/selection.ts new file mode 100644 index 000000000..6cf7db2f2 --- /dev/null +++ b/src/components/PodLineGraph/selection.ts @@ -0,0 +1,13 @@ +export function deriveGroupState( + hasHarvestablePlot: boolean, + hasSelectedPlot: boolean, + hasHoveredPlot: boolean, + selectionGreen: boolean, +): { isGreen: boolean; isActive: boolean } { + // Green if harvestable, hovered, or selection indicates green for this context + const isGreen = hasHarvestablePlot || hasHoveredPlot || selectionGreen; + const isActive = hasHoveredPlot || hasSelectedPlot; + return { isGreen, isActive }; +} + + From 7710006eaf254cf06a8922d100f0f6c29df2e819 Mon Sep 17 00:00:00 2001 From: feyyazcigim Date: Mon, 3 Nov 2025 16:44:04 +0300 Subject: [PATCH 33/50] Replace inline width styles with Tailwind --- src/components/PodLineGraph.tsx | 23 ++++++++++--------- .../PodLineGraph/PartialSelectionOverlay.tsx | 2 -- src/components/PodLineGraph/PlotGroup.tsx | 11 ++++++--- src/components/PodLineGraph/geometry.ts | 6 +---- src/components/PodLineGraph/selection.ts | 2 -- 5 files changed, 21 insertions(+), 23 deletions(-) diff --git a/src/components/PodLineGraph.tsx b/src/components/PodLineGraph.tsx index 31e572e14..7f053dc0a 100644 --- a/src/components/PodLineGraph.tsx +++ b/src/components/PodLineGraph.tsx @@ -6,8 +6,8 @@ import { formatter } from "@/utils/format"; import { Plot } from "@/utils/types"; import { cn } from "@/utils/utils"; import { useMemo, useState } from "react"; -import { PlotGroup } from "./PodLineGraph/PlotGroup"; import { PartialSelectionOverlay } from "./PodLineGraph/PartialSelectionOverlay"; +import { PlotGroup } from "./PodLineGraph/PlotGroup"; import { computeGroupLayout, computePartialSelectionPercent } from "./PodLineGraph/geometry"; import { deriveGroupState } from "./PodLineGraph/selection"; @@ -257,7 +257,7 @@ export default function PodLineGraph({
{/* Harvested Section (Log Scale) - Left 20% (only shown if there are harvested plots) */} {hasHarvestedPlots && ( -
+
{/* Grid lines (exponential scale) */}
{generateLogGridPoints(maxHarvestedIndex).map((value) => { @@ -318,7 +318,7 @@ export default function PodLineGraph({ )} {/* Podline Section (Linear Scale) - Right 80% or 100% if no harvested plots */} -
+
{/* Grid lines at 10M intervals */}
{generateAxisLabels(0, podLine.toNumber()).map((value) => { @@ -391,14 +391,15 @@ export default function PodLineGraph({ : false; // Compute partial selection overlay only when selection overlaps - const partialSelectionPercent = selectedPodRange && overlapsSelection - ? computePartialSelectionPercent( - group.startIndex, - group.endIndex, - selectedPodRange.start, - selectedPodRange.end, - ) - : null; + const partialSelectionPercent = + selectedPodRange && overlapsSelection + ? computePartialSelectionPercent( + group.startIndex, + group.endIndex, + selectedPodRange.start, + selectedPodRange.end, + ) + : null; // In Create (range present), green follows overlap; in Fill (no range), green follows selection const selectionGreen = selectedPodRange ? overlapsSelection : hasSelectedPlot; diff --git a/src/components/PodLineGraph/PartialSelectionOverlay.tsx b/src/components/PodLineGraph/PartialSelectionOverlay.tsx index 64aa13c11..10d5aa27c 100644 --- a/src/components/PodLineGraph/PartialSelectionOverlay.tsx +++ b/src/components/PodLineGraph/PartialSelectionOverlay.tsx @@ -26,5 +26,3 @@ function PartialSelectionOverlayComponent({ startPercent, endPercent, borderRadi } export const PartialSelectionOverlay = React.memo(PartialSelectionOverlayComponent); - - diff --git a/src/components/PodLineGraph/PlotGroup.tsx b/src/components/PodLineGraph/PlotGroup.tsx index d3f4d87ec..4da88fec3 100644 --- a/src/components/PodLineGraph/PlotGroup.tsx +++ b/src/components/PodLineGraph/PlotGroup.tsx @@ -29,7 +29,14 @@ function PlotGroupComponent({ return (
Date: Mon, 3 Nov 2025 16:47:51 +0300 Subject: [PATCH 34/50] Memoize grid points and axis labels to avoid redundant recalculations --- src/components/PodLineGraph.tsx | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/src/components/PodLineGraph.tsx b/src/components/PodLineGraph.tsx index 7f053dc0a..10133d4c2 100644 --- a/src/components/PodLineGraph.tsx +++ b/src/components/PodLineGraph.tsx @@ -245,6 +245,11 @@ export default function PodLineGraph({ const harvestedWidthPercent = hasHarvestedPlots ? HARVESTED_WIDTH_PERCENT : 0; const podlineWidthPercent = hasHarvestedPlots ? PODLINE_WIDTH_PERCENT : 100; + // Memoized grid points and axis labels + const logGridPoints = useMemo(() => generateLogGridPoints(maxHarvestedIndex), [maxHarvestedIndex]); + const topAxisLabels = useMemo(() => generateAxisLabels(0, podLine.toNumber()), [podLine]); + const bottomAxisLabels = topAxisLabels; + return (
{/* Label */} @@ -260,7 +265,7 @@ export default function PodLineGraph({
{/* Grid lines (exponential scale) */}
- {generateLogGridPoints(maxHarvestedIndex).map((value) => { + {logGridPoints.map((value) => { // Exponential scale: small values compressed to the left, large values spread to the right const minValue = maxHarvestedIndex / 10; const normalizedValue = (value - minValue) / (maxHarvestedIndex - minValue); @@ -321,7 +326,7 @@ export default function PodLineGraph({
{/* Grid lines at 10M intervals */}
- {generateAxisLabels(0, podLine.toNumber()).map((value) => { + {bottomAxisLabels.map((value) => { if (value === 0) return null; // Skip 0, it's the marker const position = podLine.gt(0) ? (value / podLine.toNumber()) * 100 : 0; if (position > 100) return null; From d95b2a2215860449aed48de2354667dfe5b499e5 Mon Sep 17 00:00:00 2001 From: feyyazcigim Date: Mon, 3 Nov 2025 16:52:22 +0300 Subject: [PATCH 35/50] Extract onPlotGroupSelect into memoized handler for readability --- src/pages/market/actions/CreateListing.tsx | 59 +++++++++++----------- 1 file changed, 29 insertions(+), 30 deletions(-) diff --git a/src/pages/market/actions/CreateListing.tsx b/src/pages/market/actions/CreateListing.tsx index 8034ed58a..ea9258f87 100644 --- a/src/pages/market/actions/CreateListing.tsx +++ b/src/pages/market/actions/CreateListing.tsx @@ -266,6 +266,34 @@ export default function CreateListing() { } }, [pricePerPodInput]); + const handlePlotGroupSelect = useCallback( + (plotIndices: string[]) => { + const plotsInGroup = farmerField.plots.filter((p) => plotIndices.includes(p.index.toHuman())); + if (plotsInGroup.length === 0) return; + + const allSelected = plotIndices.every((index) => plot.some((p) => p.index.toHuman() === index)); + if (allSelected) { + const updatedPlots = plot.filter((p) => !plotIndices.includes(p.index.toHuman())); + handlePlotSelection(updatedPlots); + return; + } + + const plotIndexSet = new Set(plot.map((p) => p.index.toHuman())); + const newPlots = [...plot]; + plotsInGroup.forEach((plotToAdd) => { + const key = plotToAdd.index.toHuman(); + if (!plotIndexSet.has(key)) { + newPlots.push(plotToAdd); + plotIndexSet.add(key); + } + }); + if (newPlots.length > plot.length) { + handlePlotSelection(newPlots); + } + }, + [farmerField.plots, plot, handlePlotSelection], + ); + // reset form and invalidate pod listing query const onSuccess = useCallback(() => { setSuccessAmount(amount); @@ -384,36 +412,7 @@ export default function CreateListing() { selectedPlotIndices={plot.map((p) => p.index.toHuman())} selectedPodRange={selectedPodRange} label="My Pods In Line" - onPlotGroupSelect={(plotIndices) => { - // Find all plots in the clicked group from farmer plots - const plotsInGroup = farmerField.plots.filter((p) => plotIndices.includes(p.index.toHuman())); - - if (plotsInGroup.length === 0) return; - - // Check if all plots in the group are already selected - const allSelected = plotIndices.every((index) => plot.some((p) => p.index.toHuman() === index)); - - if (allSelected) { - // Deselect only this group - remove plots from this group - const updatedPlots = plot.filter((p) => !plotIndices.includes(p.index.toHuman())); - handlePlotSelection(updatedPlots); - } else { - // Add this group to existing selection - merge with current selection (avoid duplicates) - const plotIndexSet = new Set(plot.map((p) => p.index.toHuman())); - const newPlots = [...plot]; - - plotsInGroup.forEach((plotToAdd) => { - if (!plotIndexSet.has(plotToAdd.index.toHuman())) { - newPlots.push(plotToAdd); - plotIndexSet.add(plotToAdd.index.toHuman()); - } - }); - - if (newPlots.length > plot.length) { - handlePlotSelection(newPlots); - } - } - }} + onPlotGroupSelect={handlePlotGroupSelect} /> {/* Position in Line Display (below graph) */} From 33dc443115a2c122e34a224e8a5f9a74c23825db Mon Sep 17 00:00:00 2001 From: feyyazcigim Date: Mon, 3 Nov 2025 17:25:07 +0300 Subject: [PATCH 36/50] Extract duplicate balance mode switch-case into reusable helper function --- src/components/ComboInputField.tsx | 50 +++++++++++++++++------------- 1 file changed, 29 insertions(+), 21 deletions(-) diff --git a/src/components/ComboInputField.tsx b/src/components/ComboInputField.tsx index 8859d2df6..075c55d88 100644 --- a/src/components/ComboInputField.tsx +++ b/src/components/ComboInputField.tsx @@ -166,6 +166,22 @@ function ComboInputField({ : selectedTokenPrice.mul(disableInput ? amountAsTokenValue : internalAmount) : undefined; + // Helper to get balance from farmerTokenBalance based on balanceFrom mode + const getFarmerBalanceByMode = useCallback( + (farmerBalance: typeof farmerTokenBalance, mode: FarmFromMode | undefined): TokenValue => { + if (!farmerBalance) return TokenValue.ZERO; + switch (mode) { + case FarmFromMode.EXTERNAL: + return farmerBalance.external || TokenValue.ZERO; + case FarmFromMode.INTERNAL: + return farmerBalance.internal || TokenValue.ZERO; + default: + return farmerBalance.total || TokenValue.ZERO; + } + }, + [], + ); + const maxAmount = useMemo(() => { if (mode === "plots" && selectedPlots) { return selectedPlots.reduce((total, plot) => total.add(plot.pods), TokenValue.ZERO); @@ -176,16 +192,7 @@ function ComboInputField({ if (tokenAndBalanceMap && selectedToken) { baseBalance = tokenAndBalanceMap.get(selectedToken) ?? TokenValue.ZERO; } else if (farmerTokenBalance) { - switch (balanceFrom) { - case FarmFromMode.EXTERNAL: - baseBalance = farmerTokenBalance.external || TokenValue.ZERO; - break; - case FarmFromMode.INTERNAL: - baseBalance = farmerTokenBalance.internal || TokenValue.ZERO; - break; - default: - baseBalance = farmerTokenBalance.total || TokenValue.ZERO; - } + baseBalance = getFarmerBalanceByMode(farmerTokenBalance, balanceFrom); } // If customMaxAmount is provided and greater than 0, use the minimum of base balance and customMaxAmount @@ -195,7 +202,16 @@ function ComboInputField({ // Otherwise use base balance return baseBalance; - }, [mode, selectedPlots, customMaxAmount, tokenAndBalanceMap, selectedToken, balanceFrom, farmerTokenBalance]); + }, [ + mode, + selectedPlots, + customMaxAmount, + tokenAndBalanceMap, + selectedToken, + balanceFrom, + farmerTokenBalance, + getFarmerBalanceByMode, + ]); const balance = useMemo(() => { if (mode === "plots" && selectedPlots) { @@ -207,16 +223,8 @@ function ComboInputField({ } } // Always use farmerTokenBalance for display, not maxAmount (which may be limited by customMaxAmount) - if (!farmerTokenBalance) return TokenValue.ZERO; - switch (balanceFrom) { - case FarmFromMode.EXTERNAL: - return farmerTokenBalance.external || TokenValue.ZERO; - case FarmFromMode.INTERNAL: - return farmerTokenBalance.internal || TokenValue.ZERO; - default: - return farmerTokenBalance.total || TokenValue.ZERO; - } - }, [mode, selectedPlots, tokenAndBalanceMap, selectedToken, farmerTokenBalance, balanceFrom]); + return getFarmerBalanceByMode(farmerTokenBalance, balanceFrom); + }, [mode, selectedPlots, tokenAndBalanceMap, selectedToken, farmerTokenBalance, balanceFrom, getFarmerBalanceByMode]); /** * Clamp the input amount to the max amount ONLY IF clamping is enabled From 56e88eb2a1dccd11d5af04658bf2b37a7ef4226f Mon Sep 17 00:00:00 2001 From: feyyazcigim Date: Mon, 3 Nov 2025 23:04:52 +0300 Subject: [PATCH 37/50] Fix listing table redirect --- src/pages/market/PodListingsTable.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/pages/market/PodListingsTable.tsx b/src/pages/market/PodListingsTable.tsx index f7e47d15f..d36fb1d22 100644 --- a/src/pages/market/PodListingsTable.tsx +++ b/src/pages/market/PodListingsTable.tsx @@ -97,7 +97,7 @@ export function PodListingsTable() { key={listing.id} className={`hover:cursor-pointer ${selectedListing === id ? "bg-pinto-green-1 hover:bg-pinto-green-1" : ""}`} noHoverMute - onClick={() => navigateTo(listing.index.valueOf())} + onClick={() => navigateTo(listing.id)} > {createdAt.toLocaleString(undefined, dateOptions)} From a9baa53bc733dde7877050735eaaf820a21e056e Mon Sep 17 00:00:00 2001 From: feyyazcigim Date: Sun, 9 Nov 2025 23:26:21 +0300 Subject: [PATCH 38/50] Refactor and resolve QA --- src/components/PodLineGraph.tsx | 195 ++++++++++---- src/components/PodLineGraph/HoverTooltip.tsx | 61 +++++ .../PodLineGraph/PartialSelectionOverlay.tsx | 22 +- src/components/PodLineGraph/PlotGroup.tsx | 67 +++-- src/components/PodLineGraph/geometry.ts | 56 ++-- src/components/PodLineGraph/selection.ts | 32 ++- src/components/ui/Tabs.tsx | 47 +++- src/pages/Market.tsx | 254 ++++++++++-------- src/pages/market/MarketModeSelect.tsx | 93 ++++--- src/pages/market/actions/CreateListing.tsx | 165 ++++++------ src/pages/market/actions/CreateOrder.tsx | 72 +++-- src/pages/market/actions/FillListing.tsx | 63 +++-- src/pages/market/actions/FillOrder.tsx | 35 +-- tailwind.config.js | 1 + 14 files changed, 742 insertions(+), 421 deletions(-) create mode 100644 src/components/PodLineGraph/HoverTooltip.tsx diff --git a/src/components/PodLineGraph.tsx b/src/components/PodLineGraph.tsx index 10133d4c2..9acf17eb7 100644 --- a/src/components/PodLineGraph.tsx +++ b/src/components/PodLineGraph.tsx @@ -2,10 +2,10 @@ import { TokenValue } from "@/classes/TokenValue"; import { PODS } from "@/constants/internalTokens"; import { useFarmerField } from "@/state/useFarmerField"; import { useHarvestableIndex, usePodIndex } from "@/state/useFieldData"; -import { formatter } from "@/utils/format"; import { Plot } from "@/utils/types"; import { cn } from "@/utils/utils"; -import { useMemo, useState } from "react"; +import React, { useMemo, useState, useRef, useEffect } from "react"; +import { HoverTooltip } from "./PodLineGraph/HoverTooltip"; import { PartialSelectionOverlay } from "./PodLineGraph/PartialSelectionOverlay"; import { PlotGroup } from "./PodLineGraph/PlotGroup"; import { computeGroupLayout, computePartialSelectionPercent } from "./PodLineGraph/geometry"; @@ -17,6 +17,12 @@ const PODLINE_WIDTH_PERCENT = 80; const MIN_PLOT_WIDTH_PERCENT = 0.3; // Minimum plot width for clickability const MAX_GAP_TO_COMBINE = TokenValue.fromHuman("1000000", PODS.decimals); // Combine plots within 1M gap for visual grouping +// Grid and scale constants +const AXIS_INTERVAL = 10_000_000; // 10M intervals for axis labels +const LOG_SCALE_K = 1; // Exponential transformation factor for log scale +const MILLION = 1_000_000; +const TOOLTIP_OFFSET = 12; // px offset for tooltip positioning + interface CombinedPlot { startIndex: TokenValue; endIndex: TokenValue; @@ -107,13 +113,10 @@ function combinePlots(plots: Plot[], harvestableIndex: TokenValue, selectedIndic * Generates nice axis labels at 10M intervals */ function generateAxisLabels(min: number, max: number): number[] { - const INTERVAL = 10_000_000; // 10M const labels: number[] = []; + const start = Math.floor(min / AXIS_INTERVAL) * AXIS_INTERVAL; - // Start from 0 or the first 10M multiple - const start = Math.floor(min / INTERVAL) * INTERVAL; - - for (let value = start; value <= max; value += INTERVAL) { + for (let value = start; value <= max; value += AXIS_INTERVAL) { if (value >= min) { labels.push(value); } @@ -130,19 +133,18 @@ function generateLogGridPoints(maxValue: number): number[] { if (maxValue <= 0) return []; const gridPoints: number[] = []; - const million = 1_000_000; const minValue = maxValue / 10; // For values less than 10M, use simple 1M, 2M, 5M pattern - if (maxValue <= 10 * million) { - if (maxValue > 1 * million && 1 * million > minValue) gridPoints.push(1 * million); - if (maxValue > 2 * million && 2 * million > minValue) gridPoints.push(2 * million); - if (maxValue > 5 * million && 5 * million > minValue) gridPoints.push(5 * million); + if (maxValue <= 10 * MILLION) { + if (maxValue > MILLION && MILLION > minValue) gridPoints.push(MILLION); + if (maxValue > 2 * MILLION && 2 * MILLION > minValue) gridPoints.push(2 * MILLION); + if (maxValue > 5 * MILLION && 5 * MILLION > minValue) gridPoints.push(5 * MILLION); return gridPoints; } // For larger values, use powers of 10 - let power = million; + let power = MILLION; while (power < maxValue) { if (power > minValue) gridPoints.push(power); const next2 = power * 2; @@ -159,8 +161,8 @@ function generateLogGridPoints(maxValue: number): number[] { * Formats large numbers for axis labels (e.g., 1000000 -> "1M") */ function formatAxisLabel(value: number): string { - if (value >= 1_000_000) { - return `${(value / 1_000_000).toFixed(0)}M`; + if (value >= MILLION) { + return `${(value / MILLION).toFixed(0)}M`; } if (value >= 1_000) { return `${(value / 1_000).toFixed(0)}K`; @@ -168,6 +170,14 @@ function formatAxisLabel(value: number): string { return value.toFixed(0); } +/** + * Calculates exponential position for log scale visualization + */ +function calculateLogPosition(value: number, minValue: number, maxValue: number): number { + const normalizedValue = (value - minValue) / (maxValue - minValue); + return ((Math.exp(LOG_SCALE_K * normalizedValue) - 1) / (Math.exp(LOG_SCALE_K) - 1)) * 100; +} + export default function PodLineGraph({ plots: providedPlots, selectedPlotIndices = [], @@ -184,6 +194,24 @@ export default function PodLineGraph({ const podIndex = usePodIndex(); const [hoveredPlotIndex, setHoveredPlotIndex] = useState(null); + const [tooltipData, setTooltipData] = useState<{ + podAmount: TokenValue; + placeStart: TokenValue; + placeEnd: TokenValue; + mouseX: number; + mouseY: number; + } | null>(null); + const rafRef = useRef(); + const containerRef = useRef(null); + + // Cleanup RAF on unmount + useEffect(() => { + return () => { + if (rafRef.current) { + cancelAnimationFrame(rafRef.current); + } + }; + }, []); // Use provided plots or default to farmer's plots const plots = providedPlots ?? farmerField.plots; @@ -251,7 +279,7 @@ export default function PodLineGraph({ const bottomAxisLabels = topAxisLabels; return ( -
+
{/* Label */}

{label}

@@ -266,14 +294,8 @@ export default function PodLineGraph({ {/* Grid lines (exponential scale) */}
{logGridPoints.map((value) => { - // Exponential scale: small values compressed to the left, large values spread to the right const minValue = maxHarvestedIndex / 10; - const normalizedValue = (value - minValue) / (maxHarvestedIndex - minValue); - - // Apply exponential transformation: position = (e^(k*x) - 1) / (e^k - 1) - // Using k=1 for very gentle exponential curve (almost linear) - const k = 1; - const position = ((Math.exp(k * normalizedValue) - 1) / (Math.exp(k) - 1)) * 100; + const position = calculateLogPosition(value, minValue, maxHarvestedIndex); if (position > 100 || position < 0) return null; @@ -289,30 +311,22 @@ export default function PodLineGraph({ {/* Plot rectangles */}
- {harvestedPlots.map((plot, idx) => { + {harvestedPlots.map((plot) => { const minValue = maxHarvestedIndex / 10; const plotStart = Math.max(plot.startIndex.toNumber(), minValue); const plotEnd = Math.max(plot.endIndex.toNumber(), minValue); - const normalizedStart = (plotStart - minValue) / (maxHarvestedIndex - minValue); - const normalizedEnd = (plotEnd - minValue) / (maxHarvestedIndex - minValue); - - const k = 1; - const leftPercent = ((Math.exp(k * normalizedStart) - 1) / (Math.exp(k) - 1)) * 100; - const rightPercent = ((Math.exp(k * normalizedEnd) - 1) / (Math.exp(k) - 1)) * 100; + const leftPercent = calculateLogPosition(plotStart, minValue, maxHarvestedIndex); + const rightPercent = calculateLogPosition(plotEnd, minValue, maxHarvestedIndex); const widthPercent = rightPercent - leftPercent; const displayWidth = Math.max(widthPercent, MIN_PLOT_WIDTH_PERCENT); - const isLeftmost = idx === 0 && leftPercent < 1; - const borderRadius = isLeftmost ? "2px 0 0 2px" : "2px"; - return ( @@ -350,7 +364,6 @@ export default function PodLineGraph({ width: `${podLine.gt(0) ? (orderRangeEnd.sub(harvestableIndex).toNumber() / podLine.toNumber()) * 100 : 0}%`, height: "100%", top: "0%", - borderRadius: "2px", zIndex: 5, }} /> @@ -365,7 +378,6 @@ export default function PodLineGraph({ width: `${podLine.gt(0) ? (rangeOverlay.end.sub(rangeOverlay.start).toNumber() / podLine.toNumber()) * 100 : 0}%`, height: "100%", top: "0%", - borderRadius: "2px", zIndex: 5, }} /> @@ -377,12 +389,10 @@ export default function PodLineGraph({ const groupStartMinusHarvestable = group.startIndex.sub(harvestableIndex); const groupEndMinusHarvestable = group.endIndex.sub(harvestableIndex); - const isLastGroup = groupIdx === unharvestedPlots.length - 1; - const { leftPercent, displayWidthPercent, borderRadius } = computeGroupLayout( + const { leftPercent, displayWidthPercent } = computeGroupLayout( groupStartMinusHarvestable, groupEndMinusHarvestable, podLine, - isLastGroup, ); const groupFirstPlotIndex = group.plots[0].id || group.plots[0].index.toHuman(); @@ -406,16 +416,27 @@ export default function PodLineGraph({ ) : null; - // In Create (range present), green follows overlap; in Fill (no range), green follows selection - const selectionGreen = selectedPodRange ? overlapsSelection : hasSelectedPlot; + // In Create (range present), highlighted follows overlap; in Fill (no range), highlighted follows selection + // However, if there's a partial selection AND not hovered, don't highlight the whole group - let the overlay show the selection + const hasPartialSelection = Boolean(partialSelectionPercent) && !hasHoveredPlot; + const selectionHighlighted = hasPartialSelection + ? false + : selectedPodRange + ? overlapsSelection + : hasSelectedPlot; - const { isGreen: groupIsGreen, isActive: groupIsActive } = deriveGroupState( + const { isHighlighted: groupIsHighlighted, isActive: groupIsActive } = deriveGroupState( hasHarvestablePlot, hasSelectedPlot, hasHoveredPlot, - selectionGreen, + selectionHighlighted, ); + // If there's a partial selection AND not hovered, don't make the whole group active (yellow) + // The partial overlay will show the yellow color for the selected portion + // When hovered, the whole group should be yellow + const finalIsActive = hasPartialSelection ? false : groupIsActive; + const handleGroupClick = () => { if (disableInteractions) return; if (onPlotGroupSelect) { @@ -424,24 +445,60 @@ export default function PodLineGraph({ } }; + const handleMouseEnter = (e: React.MouseEvent) => { + setHoveredPlotIndex(groupFirstPlotIndex); + if (!disableInteractions) { + setTooltipData({ + podAmount: group.totalPods, + placeStart: groupStartMinusHarvestable, + placeEnd: groupEndMinusHarvestable, + mouseX: e.clientX, + mouseY: e.clientY, + }); + } + }; + + const handleMouseMove = (e: React.MouseEvent) => { + if (!disableInteractions && tooltipData) { + // Use RAF for smooth 60fps updates + if (rafRef.current) { + cancelAnimationFrame(rafRef.current); + } + rafRef.current = requestAnimationFrame(() => { + setTooltipData({ + ...tooltipData, + mouseX: e.clientX, + mouseY: e.clientY, + }); + }); + } + }; + + const handleMouseLeave = () => { + setHoveredPlotIndex(null); + setTooltipData(null); + if (rafRef.current) { + cancelAnimationFrame(rafRef.current); + } + }; + return ( setHoveredPlotIndex(groupFirstPlotIndex)} - onMouseLeave={() => setHoveredPlotIndex(null)} + onMouseEnter={handleMouseEnter} + onMouseMove={handleMouseMove} + onMouseLeave={handleMouseLeave} > {partialSelectionPercent && hasSelectedPlot && ( )} @@ -486,14 +543,9 @@ export default function PodLineGraph({ {/* Harvested section labels (only shown if there are harvested plots) */} {hasHarvestedPlots && (
- {generateLogGridPoints(maxHarvestedIndex).map((value) => { - // Exponential scale: small values compressed to the left, large values spread to the right + {logGridPoints.map((value) => { const minValue = maxHarvestedIndex / 10; - const normalizedValue = (value - minValue) / (maxHarvestedIndex - minValue); - - // Apply exponential transformation - const k = 1; - const position = ((Math.exp(k * normalizedValue) - 1) / (Math.exp(k) - 1)) * 100; + const position = calculateLogPosition(value, minValue, maxHarvestedIndex); return (
+ + {/* Tooltip - follows mouse cursor */} + {tooltipData && + containerRef.current && + (() => { + const containerRect = containerRef.current.getBoundingClientRect(); + const relativeX = tooltipData.mouseX - containerRect.left; + const alignRight = relativeX > containerRect.width / 2; + + return ( +
+ +
+ ); + })()}
); } diff --git a/src/components/PodLineGraph/HoverTooltip.tsx b/src/components/PodLineGraph/HoverTooltip.tsx new file mode 100644 index 000000000..7c83b23d6 --- /dev/null +++ b/src/components/PodLineGraph/HoverTooltip.tsx @@ -0,0 +1,61 @@ +import { TokenValue } from "@/classes/TokenValue"; +import { cn } from "@/utils/utils"; +import React from "react"; + +interface HoverTooltipProps { + podAmount: TokenValue; + placeInLineStart: TokenValue; + placeInLineEnd: TokenValue; + visible: boolean; + alignRight?: boolean; +} + +const MILLION = 1_000_000; +const THOUSAND = 1_000; + +/** + * Formats large numbers for display (e.g., 1000000 -> "1M") + */ +export function formatNumber(value: number): string { + if (value >= MILLION) { + return `${(value / MILLION).toFixed(1)}M`; + } + if (value >= THOUSAND) { + return `${(value / THOUSAND).toFixed(0)}K`; + } + return value.toFixed(0); +} + +function HoverTooltipComponent({ + podAmount, + placeInLineStart, + placeInLineEnd, + visible, + alignRight = false, +}: HoverTooltipProps) { + if (!visible) return null; + + const formattedPods = formatNumber(podAmount.toNumber()); + const formattedStart = formatNumber(placeInLineStart.toNumber()); + const formattedEnd = formatNumber(placeInLineEnd.toNumber()); + + const textClassName = cn("text-[0.875rem] font-[340] text-pinto-gray-4", alignRight ? "text-right" : "text-left"); + + const valueClassName = "text-pinto-gray-5 font-[400]"; + + return ( +
+
+ {formattedPods} Pods +
+
+ Place{" "} + + {formattedStart} - {formattedEnd} + +
+
+ ); +} + +export const HoverTooltip = React.memo(HoverTooltipComponent); diff --git a/src/components/PodLineGraph/PartialSelectionOverlay.tsx b/src/components/PodLineGraph/PartialSelectionOverlay.tsx index 10d5aa27c..c0e7f1877 100644 --- a/src/components/PodLineGraph/PartialSelectionOverlay.tsx +++ b/src/components/PodLineGraph/PartialSelectionOverlay.tsx @@ -3,26 +3,26 @@ import React from "react"; interface PartialSelectionOverlayProps { startPercent: number; endPercent: number; - borderRadius: string; } -function PartialSelectionOverlayComponent({ startPercent, endPercent, borderRadius }: PartialSelectionOverlayProps) { - const width = Math.max(endPercent - startPercent, 0); +const OVERLAY_Z_INDEX = 10; + +function PartialSelectionOverlayComponent({ startPercent, endPercent }: PartialSelectionOverlayProps) { + // Early return if invalid range + if (startPercent >= endPercent) return null; + + const width = endPercent - startPercent; + return (
= 99.9 ? borderRadius : "0", - borderBottomRightRadius: endPercent >= 99.9 ? borderRadius : "0", + zIndex: OVERLAY_Z_INDEX, }} /> ); } -export const PartialSelectionOverlay = React.memo(PartialSelectionOverlayComponent); +export const PartialSelectionOverlay = React.memo(PartialSelectionOverlayComponent); diff --git a/src/components/PodLineGraph/PlotGroup.tsx b/src/components/PodLineGraph/PlotGroup.tsx index 4da88fec3..652522411 100644 --- a/src/components/PodLineGraph/PlotGroup.tsx +++ b/src/components/PodLineGraph/PlotGroup.tsx @@ -1,57 +1,80 @@ import { cn } from "@/utils/utils"; -import React from "react"; +import React, { useMemo } from "react"; interface PlotGroupProps { leftPercent: number; widthPercent: number; - borderRadius: string; - isGreen: boolean; + isHighlighted: boolean; isActive: boolean; disableInteractions?: boolean; onClick?: () => void; - onMouseEnter?: () => void; + onMouseEnter?: (e: React.MouseEvent) => void; + onMouseMove?: (e: React.MouseEvent) => void; onMouseLeave?: () => void; children?: React.ReactNode; } +const Z_INDEX_ACTIVE = 20; +const Z_INDEX_DEFAULT = 1; +const MIN_PLOT_WIDTH = "4px"; + +/** + * Determines background color based on plot state + */ +function getBackgroundColor(isHighlighted: boolean, isActive: boolean): string { + if (isHighlighted && isActive) { + return "bg-pinto-yellow-active"; // Yellow for hover/selected + } + if (isHighlighted && !isActive) { + return "bg-pinto-green-1"; // Green for harvestable plots + } + return "bg-pinto-morning-orange"; // Default orange +} + function PlotGroupComponent({ leftPercent, widthPercent, - borderRadius, - isGreen, + isHighlighted, isActive, - disableInteractions, + disableInteractions = false, onClick, onMouseEnter, + onMouseMove, onMouseLeave, children, }: PlotGroupProps) { + const backgroundColor = useMemo(() => getBackgroundColor(isHighlighted, isActive), [isHighlighted, isActive]); + + const eventHandlers = useMemo( + () => + disableInteractions + ? {} + : { + onClick, + onMouseEnter, + onMouseMove, + onMouseLeave, + }, + [disableInteractions, onClick, onMouseEnter, onMouseMove, onMouseLeave], + ); + return (
{children}
); } -export const PlotGroup = React.memo(PlotGroupComponent); +export const PlotGroup = React.memo(PlotGroupComponent); diff --git a/src/components/PodLineGraph/geometry.ts b/src/components/PodLineGraph/geometry.ts index e4483623f..fa01f0b92 100644 --- a/src/components/PodLineGraph/geometry.ts +++ b/src/components/PodLineGraph/geometry.ts @@ -1,25 +1,39 @@ import { TokenValue } from "@/classes/TokenValue"; export const MIN_PLOT_WIDTH_PERCENT = 0.3; +const PERCENT_MULTIPLIER = 100; + +/** + * Converts a value to percentage of total + */ +function toPercent(value: number, total: number): number { + return total > 0 ? (value / total) * PERCENT_MULTIPLIER : 0; +} + +export interface GroupLayout { + leftPercent: number; + displayWidthPercent: number; +} + +export interface PartialSelection { + start: number; + end: number; +} export function computeGroupLayout( groupStartMinusHarvestable: TokenValue, groupEndMinusHarvestable: TokenValue, podLine: TokenValue, - isLastGroup: boolean, -): { leftPercent: number; displayWidthPercent: number; borderRadius: string } { - const leftPercent = podLine.gt(0) ? (groupStartMinusHarvestable.toNumber() / podLine.toNumber()) * 100 : 0; - const widthPercent = podLine.gt(0) - ? ((groupEndMinusHarvestable.toNumber() - groupStartMinusHarvestable.toNumber()) / podLine.toNumber()) * 100 - : 0; - const displayWidthPercent = Math.max(widthPercent, MIN_PLOT_WIDTH_PERCENT); +): GroupLayout { + const podLineNum = podLine.toNumber(); + const startNum = groupStartMinusHarvestable.toNumber(); + const endNum = groupEndMinusHarvestable.toNumber(); - let borderRadius = "2px"; - if (isLastGroup && leftPercent + displayWidthPercent > 99) { - borderRadius = "0 2px 2px 0"; - } + const leftPercent = toPercent(startNum, podLineNum); + const widthPercent = toPercent(endNum - startNum, podLineNum); + const displayWidthPercent = Math.max(widthPercent, MIN_PLOT_WIDTH_PERCENT); - return { leftPercent, displayWidthPercent, borderRadius }; + return { leftPercent, displayWidthPercent }; } export function computePartialSelectionPercent( @@ -27,20 +41,24 @@ export function computePartialSelectionPercent( groupEnd: TokenValue, rangeStart: TokenValue, rangeEnd: TokenValue, -): { start: number; end: number } | null { - if (!(rangeStart.lt(groupEnd) && rangeEnd.gt(groupStart))) return null; - - const overlapStart = rangeStart.gt(groupStart) ? rangeStart : groupStart; - const overlapEnd = rangeEnd.lt(groupEnd) ? rangeEnd : groupEnd; +): PartialSelection | null { + // Check if range overlaps with group + if (!rangeStart.lt(groupEnd) || !rangeEnd.gt(groupStart)) { + return null; + } const groupTotal = groupEnd.sub(groupStart).toNumber(); if (groupTotal <= 0) return null; + // Calculate overlap boundaries + const overlapStart = rangeStart.gt(groupStart) ? rangeStart : groupStart; + const overlapEnd = rangeEnd.lt(groupEnd) ? rangeEnd : groupEnd; + const overlapStartOffset = overlapStart.sub(groupStart).toNumber(); const overlapEndOffset = overlapEnd.sub(groupStart).toNumber(); return { - start: (overlapStartOffset / groupTotal) * 100, - end: (overlapEndOffset / groupTotal) * 100, + start: toPercent(overlapStartOffset, groupTotal), + end: toPercent(overlapEndOffset, groupTotal), }; } diff --git a/src/components/PodLineGraph/selection.ts b/src/components/PodLineGraph/selection.ts index cf9523730..24307e1d3 100644 --- a/src/components/PodLineGraph/selection.ts +++ b/src/components/PodLineGraph/selection.ts @@ -1,11 +1,33 @@ +/** + * Represents the visual state of a plot group + */ +export interface GroupState { + /** Whether the group should be visually highlighted (green/yellow) */ + isHighlighted: boolean; + /** Whether the group is in active state (yellow for hover/selection) */ + isActive: boolean; +} + +/** + * Derives the visual state of a plot group based on its properties + * + * @param hasHarvestablePlot - Whether the group contains harvestable plots + * @param hasSelectedPlot - Whether the group contains selected plots + * @param hasHoveredPlot - Whether the group is currently being hovered + * @param selectionHighlighted - Whether the group overlaps with a selection range + * @returns The derived visual state for the group + */ export function deriveGroupState( hasHarvestablePlot: boolean, hasSelectedPlot: boolean, hasHoveredPlot: boolean, - selectionGreen: boolean, -): { isGreen: boolean; isActive: boolean } { - // Green if harvestable, hovered, or selection indicates green for this context - const isGreen = hasHarvestablePlot || hasHoveredPlot || selectionGreen; + selectionHighlighted: boolean, +): GroupState { + // Group is highlighted if it's harvestable, hovered, or within selection range + const isHighlighted = hasHarvestablePlot || hasHoveredPlot || selectionHighlighted; + + // Group is active (yellow) if it's hovered or selected const isActive = hasHoveredPlot || hasSelectedPlot; - return { isGreen, isActive }; + + return { isHighlighted, isActive }; } diff --git a/src/components/ui/Tabs.tsx b/src/components/ui/Tabs.tsx index 6192db1b0..8e68118b7 100644 --- a/src/components/ui/Tabs.tsx +++ b/src/components/ui/Tabs.tsx @@ -3,7 +3,6 @@ import { type VariantProps, cva } from "class-variance-authority"; import * as React from "react"; import { cn } from "@/utils/utils"; -import clsx from "clsx"; const Tabs = TabsPrimitive.Root; @@ -14,6 +13,7 @@ const tabsListVariants = cva("inline-flex items-center", { "h-[3.25rem] justify-center rounded-[0.75rem] bg-white border border-pinto-gray-2 p-0.5 sm:p-1 text-muted-foreground", text: "flex-row justify-between overflow-x-auto", textSecondary: "flex flex-row gap-4 w-full overflow-x-auto", + textSecondaryLarge: "flex flex-row gap-6 w-full overflow-x-auto", }, borderBottom: { true: "border-b", @@ -34,11 +34,16 @@ const tabsTriggerVariants = cva( primary: "rounded-[0.75rem] text-pinto-gray-4 px-3 text-[1rem] sm:text-[1.25rem] py-2.5 sm:py-1.5 font-medium ring-offset-background focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 data-[state=active]:bg-pinto-green-1 data-[state=active]:text-pinto-green data-[state=active]:shadow-sm", text: "pinto-h3 py-2 pr-4 pl-0 text-left data-[state=active]:text-pinto-secondary data-[state=inactive]:text-pinto-gray-4", - textSecondary: clsx( + textSecondary: cn( "pb-2 border-b-2 pinto-sm", "data-[state=inactive]:text-pinto-gray-4 data-[state=inactive]:border-transparent", "data-[state=active]:border-pinto-green-4 data-[state=active]:font-medium", ), + textSecondaryLarge: cn( + "pb-2 border-b-2 pinto-body", + "data-[state=inactive]:text-pinto-gray-4 data-[state=inactive]:border-transparent", + "data-[state=active]:border-pinto-green-4 data-[state=active]:font-semibold", + ), }, }, defaultVariants: { @@ -49,18 +54,39 @@ const tabsTriggerVariants = cva( // Context for sharing variant between TabsList and TabsTrigger type TabsVariant = VariantProps["variant"]; -const TabsVariantContext = React.createContext(undefined); + +interface TabsVariantContextValue { + variant: TabsVariant; +} + +const TabsVariantContext = React.createContext(null); + +/** + * Hook to access the tabs variant from context + */ +function useTabsVariant(): TabsVariant { + const context = React.useContext(TabsVariantContext); + return context?.variant ?? "primary"; +} export interface TabsListProps extends React.ComponentPropsWithoutRef, VariantProps {} const TabsList = React.forwardRef, TabsListProps>( - ({ className, variant, borderBottom, ...props }, ref) => ( - - - - ), + ({ className, variant, borderBottom, ...props }, ref) => { + const contextValue = React.useMemo(() => ({ variant }), [variant]); + + return ( + + + + ); + }, ); TabsList.displayName = TabsPrimitive.List.displayName; @@ -70,9 +96,8 @@ export interface TabsTriggerProps const TabsTrigger = React.forwardRef, TabsTriggerProps>( ({ className, variant, ...props }, ref) => { - // Use provided variant, fallback to context, then default - const contextVariant = React.useContext(TabsVariantContext); - const finalVariant = variant ?? contextVariant ?? "primary"; + const contextVariant = useTabsVariant(); + const finalVariant = variant ?? contextVariant; return ( { - if (window.innerWidth > 1600) { - return 90; - } else if (window.innerWidth > 1100) { - return 80; - } else { - return 40; - } +const MILLION = 1_000_000; +const TOOLTIP_Z_INDEX = 1; +const CHART_MAX_PRICE = 100; + +// Responsive breakpoints for tooltip positioning +const BREAKPOINT_XL = 1600; +const BREAKPOINT_LG = 1100; + +const TOOLTIP_OFFSET = { + TOP: { XL: 90, LG: 80, DEFAULT: 40 }, + BOTTOM: { XL: 175, LG: 130, DEFAULT: 90 }, +}; + +const getPointTopOffset = (): number => { + const width = window.innerWidth; + if (width > BREAKPOINT_XL) return TOOLTIP_OFFSET.TOP.XL; + if (width > BREAKPOINT_LG) return TOOLTIP_OFFSET.TOP.LG; + return TOOLTIP_OFFSET.TOP.DEFAULT; }; -const getPointBottomOffset = () => { - if (window.innerWidth > 1600) { - return 175; - } else if (window.innerWidth > 1100) { - return 130; - } else { - return 90; - } +const getPointBottomOffset = (): number => { + const width = window.innerWidth; + if (width > BREAKPOINT_XL) return TOOLTIP_OFFSET.BOTTOM.XL; + if (width > BREAKPOINT_LG) return TOOLTIP_OFFSET.BOTTOM.LG; + return TOOLTIP_OFFSET.BOTTOM.DEFAULT; }; type MarketScatterChartDataPoint = { @@ -66,82 +75,81 @@ type MarketScatterChartData = { pointRadius: number; }; +/** + * Transforms raw market data into scatter chart format + */ const shapeScatterChartData = (data: any[], harvestableIndex: TokenValue): MarketScatterChartData[] => { - return ( - data?.reduce( - (acc, event) => { - // Skip Fill Orders - if ("toFarmer" in event) { - return acc; + if (!data) return []; + + return data.reduce( + (acc, event) => { + // Skip Fill Orders + if ("toFarmer" in event) { + return acc; + } + + const price = event.pricePerPod.toNumber(); + const eventId = event.id; + const eventType: "ORDER" | "LISTING" = event.type as "ORDER" | "LISTING"; + + if ("beanAmount" in event) { + // Handle Orders + const amount = event.beanAmount.div(event.pricePerPod).toNumber(); + const fillPct = event.beanAmountFilled.div(event.beanAmount).mul(100).toNumber(); + const status = fillPct > 99 ? "FILLED" : event.status === "CANCELLED_PARTIAL" ? "CANCELLED" : event.status; + const placeInLine = event.maxPlaceInLine.toNumber(); + + if (status === "ACTIVE" && placeInLine !== null && price !== null) { + acc[0].data.push({ + x: placeInLine / MILLION, + y: price, + eventId, + eventType, + status, + amount, + placeInLine, + }); } + } else if ("originalAmount" in event) { + // Handle Listings + const amount = event.originalAmount.toNumber(); + const fillPct = event.filled.div(event.originalAmount).mul(100).toNumber(); + const status = fillPct > 99 ? "FILLED" : event.status === "CANCELLED_PARTIAL" ? "CANCELLED" : event.status; + const placeInLine = status === "ACTIVE" ? event.index.sub(harvestableIndex).toNumber() : null; + const eventIndex = event.index.toNumber(); - let amount: number | null = null; - let status = ""; - let placeInLine: number | null = null; - let eventIndex: number | null = null; - const price = event.pricePerPod.toNumber(); - const eventId = event.id; - const eventType: "ORDER" | "LISTING" = event.type as "ORDER" | "LISTING"; - - if ("beanAmount" in event) { - // Handle Orders - amount = event.beanAmount.div(event.pricePerPod).toNumber(); - const fillPct = event.beanAmountFilled.div(event.beanAmount).mul(100).toNumber(); - status = fillPct > 99 ? "FILLED" : event.status === "CANCELLED_PARTIAL" ? "CANCELLED" : event.status; - placeInLine = event.maxPlaceInLine.toNumber(); - - if (status === "ACTIVE" && placeInLine !== null && price !== null) { - acc[0].data.push({ - x: placeInLine / 1_000_000, - y: price, - eventId, - eventType, - status, - amount, - placeInLine, - }); - } - } else if ("originalAmount" in event) { - // Handle Listings - amount = event.originalAmount.toNumber(); - const fillPct = event.filled.div(event.originalAmount).mul(100).toNumber(); - status = fillPct > 99 ? "FILLED" : event.status === "CANCELLED_PARTIAL" ? "CANCELLED" : event.status; - placeInLine = status === "ACTIVE" ? event.index.sub(harvestableIndex).toNumber() : null; - eventIndex = event.index.toNumber(); - - if (placeInLine !== null && price !== null) { - acc[1].data.push({ - x: placeInLine / 1_000_000, - y: price, - eventId, - eventIndex, - eventType, - status, - amount, - placeInLine, - }); - } + if (placeInLine !== null && price !== null) { + acc[1].data.push({ + x: placeInLine / MILLION, + y: price, + eventId, + eventIndex, + eventType, + status, + amount, + placeInLine, + }); } + } - return acc; + return acc; + }, + [ + { + label: "Orders", + data: [] as MarketScatterChartDataPoint[], + color: "#40b0a6", // teal + pointStyle: "circle" as PointStyle, + pointRadius: 6, + }, + { + label: "Listings", + data: [] as MarketScatterChartDataPoint[], + color: "#e0b57d", // tan + pointStyle: "rect" as PointStyle, + pointRadius: 6, }, - [ - { - label: "Orders", - data: [] as MarketScatterChartDataPoint[], - color: "#40b0a6", // teal - pointStyle: "circle" as PointStyle, - pointRadius: 6, - }, - { - label: "Listings", - data: [] as MarketScatterChartDataPoint[], - color: "#e0b57d", // tan - pointStyle: "rect" as PointStyle, - pointRadius: 6, - }, - ], - ) || [] + ], ); }; @@ -151,7 +159,7 @@ export function Market() { const navigate = useNavigate(); const { data, isLoaded } = useAllMarket(); const podLine = usePodLine(); - const podLineAsNumber = podLine.toNumber() / 1000000; + const podLineAsNumber = podLine.toNumber() / MILLION; const harvestableIndex = useHarvestableIndex(); const scatterChartData: MarketScatterChartData[] = useMemo( @@ -194,7 +202,7 @@ export function Market() { tooltipEl.style.color = "black"; tooltipEl.style.borderRadius = "10px"; tooltipEl.style.border = "1px solid #D9D9D9"; - tooltipEl.style.zIndex = "1"; + tooltipEl.style.zIndex = String(TOOLTIP_Z_INDEX); // Basically all of this is custom logic for 3 different breakpoints to either display the tooltip to the top right or bottom right of the point. const topOfPoint = position.y + getPointTopOffset(); const bottomOfPoint = position.y + getPointBottomOffset(); @@ -246,6 +254,14 @@ export function Market() { } }, []); + useEffect(() => { + if (mode === "buy" && !id) { + navigate("/market/pods/buy/fill", { replace: true }); + } else if (mode === "sell" && !id) { + navigate("/market/pods/sell/create", { replace: true }); + } + }, [id, mode, navigate]); + const handleChangeTabFactory = useCallback( (selection: string) => () => { // Track activity tab changes @@ -273,26 +289,32 @@ export function Market() { [mode], ); - const onPointClick = (event: ChartEvent, activeElements: ActiveElement[], chart: Chart) => { - const dataPoint = scatterChartData[activeElements[0].datasetIndex].data[activeElements[0].index] as any; + const onPointClick = useCallback( + (_event: ChartEvent, activeElements: ActiveElement[], _chart: Chart) => { + if (!activeElements.length) return; - if (!dataPoint) return; + const { datasetIndex, index } = activeElements[0]; + const dataPoint = scatterChartData[datasetIndex]?.data[index]; - // Track chart point click event - trackSimpleEvent(ANALYTICS_EVENTS.MARKET.CHART_POINT_CLICK, { - event_type: dataPoint?.eventType?.toLowerCase() ?? "unknown", - event_status: dataPoint?.status?.toLowerCase() ?? "unknown", - price_per_pod: dataPoint?.y ?? 0, - place_in_line_millions: Math.floor(dataPoint?.x ?? -1), - current_mode: mode ?? "unknown", - }); + if (!dataPoint) return; - if (dataPoint.eventType === "LISTING") { - navigate(`/market/pods/buy/fill?listingId=${dataPoint.eventId}`); - } else { - navigate(`/market/pods/sell/fill?orderId=${dataPoint.eventId}`); - } - }; + // Track chart point click event + trackSimpleEvent(ANALYTICS_EVENTS.MARKET.CHART_POINT_CLICK, { + event_type: dataPoint.eventType.toLowerCase(), + event_status: dataPoint.status.toLowerCase(), + price_per_pod: dataPoint.y, + place_in_line_millions: Math.floor(dataPoint.x), + current_mode: mode ?? "unknown", + }); + + if (dataPoint.eventType === "LISTING") { + navigate(`/market/pods/buy/fill?listingId=${dataPoint.eventId}`); + } else { + navigate(`/market/pods/sell/fill?orderId=${dataPoint.eventId}`); + } + }, + [scatterChartData, mode, navigate], + ); const viewMode = mode; @@ -336,7 +358,7 @@ export function Market() { @@ -360,14 +382,18 @@ export function Market() { {tab === TABLE_SLUGS[3] && }
-
-
- - {viewMode === "buy" && id === "create" && } - {viewMode === "buy" && id === "fill" && } - {viewMode === "sell" && id === "create" && } - {viewMode === "sell" && id === "fill" && } -
+
+ +
+ +
+ {viewMode === "buy" && id === "create" && } + {viewMode === "buy" && id === "fill" && } + {viewMode === "sell" && id === "create" && } + {viewMode === "sell" && id === "fill" && } +
+
+
diff --git a/src/pages/market/MarketModeSelect.tsx b/src/pages/market/MarketModeSelect.tsx index 130865a87..30ecd6e0b 100644 --- a/src/pages/market/MarketModeSelect.tsx +++ b/src/pages/market/MarketModeSelect.tsx @@ -1,36 +1,71 @@ import { Separator } from "@/components/ui/Separator"; import { Tabs, TabsList, TabsTrigger } from "@/components/ui/Tabs"; import { ANALYTICS_EVENTS } from "@/constants/analytics-events"; -import { useFarmerField } from "@/state/useFarmerField"; import { trackSimpleEvent } from "@/utils/analytics"; -import { useCallback } from "react"; +import { useCallback, useMemo } from "react"; import { useNavigate, useParams } from "react-router-dom"; +type MarketMode = "buy" | "sell"; +type MarketAction = "create" | "fill"; + interface MarketModeSelectProps { onMainSelectionChange?: (v: string) => void; onSecondarySelectionChange?: (v: string) => void; } +// Constants +const DEFAULT_MODE: MarketMode = "buy"; +const DEFAULT_ACTION_BY_MODE: Record = { + buy: "fill", + sell: "create", +}; + +const ACTION_LABELS: Record> = { + buy: { + create: "Order", + fill: "Fill", + }, + sell: { + create: "List", + fill: "Fill", + }, +}; + export default function MarketModeSelect({ onMainSelectionChange, onSecondarySelectionChange }: MarketModeSelectProps) { const { mode, id } = useParams(); const navigate = useNavigate(); - const farmerField = useFarmerField(); - const mainTab = mode === "buy" || mode === "sell" ? mode : undefined; - // Only set secondaryTab if id is explicitly "create" or "fill" - const secondaryTab = id === "create" ? "create" : id === "fill" ? "fill" : undefined; - const hasNoPods = farmerField.plots.length === 0; + // Derive current state from URL params + const { mainTab, secondaryTab, mainTabValue, secondaryTabValue } = useMemo(() => { + const validMode = mode === "buy" || mode === "sell" ? (mode as MarketMode) : undefined; + const validAction = id === "create" || id === "fill" ? (id as MarketAction) : undefined; + + // Only use default mode if a valid mode exists, otherwise leave undefined + const currentMode = validMode; + const defaultAction = validMode ? DEFAULT_ACTION_BY_MODE[validMode] : undefined; + const currentAction = validAction ?? defaultAction; + + return { + mainTab: validMode, + secondaryTab: validAction ?? (validMode ? DEFAULT_ACTION_BY_MODE[validMode] : undefined), + mainTabValue: currentMode ?? DEFAULT_MODE, // Fallback for Tabs component + secondaryTabValue: currentAction ?? DEFAULT_ACTION_BY_MODE[DEFAULT_MODE], // Fallback for Tabs component + }; + }, [mode, id]); const handleMainChange = useCallback( (v: string) => { + const newMode = v as MarketMode; + const defaultAction = DEFAULT_ACTION_BY_MODE[newMode]; + // Track buy/sell tab changes trackSimpleEvent(ANALYTICS_EVENTS.MARKET.BUY_SELL_TAB_CLICK, { previous_mode: mainTab, - new_mode: v, + new_mode: newMode, secondary_tab: secondaryTab, }); - navigate(`/market/pods/${v}`); + navigate(`/market/pods/${newMode}/${defaultAction}`); onMainSelectionChange?.(v); }, [navigate, onMainSelectionChange, mainTab, secondaryTab], @@ -38,6 +73,9 @@ export default function MarketModeSelect({ onMainSelectionChange, onSecondarySel const handleSecondaryChange = useCallback( (v: string) => { + if (!mainTab) { + return; + } // Track create/fill tab changes trackSimpleEvent(ANALYTICS_EVENTS.MARKET.CREATE_FILL_TAB_CLICK, { previous_action: secondaryTab, @@ -57,35 +95,26 @@ export default function MarketModeSelect({ onMainSelectionChange, onSecondarySel return (
- - + + Buy Pods Sell Pods {mainTab ? ( <> - {mainTab === "sell" && hasNoPods ? ( - <> - -
-
- You have no Pods. You can get Pods by placing a bid on the Field or selecting{" "} - Buy Pods! -
-
- - ) : ( - <> - - - - {mainTab === "buy" ? "Order" : "List"} - Fill - - - - )} + + + + {ACTION_LABELS[mainTab].create} + {ACTION_LABELS[mainTab].fill} + + ) : (
diff --git a/src/pages/market/actions/CreateListing.tsx b/src/pages/market/actions/CreateListing.tsx index ea9258f87..17da1d309 100644 --- a/src/pages/market/actions/CreateListing.tsx +++ b/src/pages/market/actions/CreateListing.tsx @@ -1,9 +1,7 @@ import settingsIcon from "@/assets/misc/Settings.svg"; import pintoIcon from "@/assets/tokens/PINTO.png"; import { TV, TokenValue } from "@/classes/TokenValue"; -import ComboPlotInputField from "@/components/ComboPlotInputField"; import PodLineGraph from "@/components/PodLineGraph"; -import SimpleInputField from "@/components/SimpleInputField"; import SmartSubmitButton from "@/components/SmartSubmitButton"; import { Button } from "@/components/ui/Button"; import { Input } from "@/components/ui/Input"; @@ -39,13 +37,16 @@ interface PodListingData { amount: TokenValue; // list edilecek pod miktarı } +// Constants const PRICE_PER_POD_CONFIG = { MAX: 1, - MIN: 0.000001, + MIN: 0.001, DECIMALS: 6, DECIMAL_MULTIPLIER: 1_000_000, // 10^6 for 6 decimals } as const; +const MILLION = 1_000_000; + const TextAdornment = ({ text, className }: { text: string; className?: string }) => { return
{text}
; }; @@ -81,29 +82,22 @@ export default function CreateListing() { const [plot, setPlot] = useState([]); const [amount, setAmount] = useState(0); const [podRange, setPodRange] = useState<[number, number]>([0, 0]); - const [pricePerPod, setPricePerPod] = useState(undefined); - const [pricePerPodInput, setPricePerPodInput] = useState(""); + const initialPrice = removeTrailingZeros(PRICE_PER_POD_CONFIG.MIN.toFixed(PRICE_PER_POD_CONFIG.DECIMALS)); + const [pricePerPod, setPricePerPod] = useState(PRICE_PER_POD_CONFIG.MIN); + const [pricePerPodInput, setPricePerPodInput] = useState(initialPrice); const [balanceTo, setBalanceTo] = useState(FarmToMode.EXTERNAL); // Default: Wallet Balance (toggle off) const [isSuccessful, setIsSuccessful] = useState(false); const [successAmount, setSuccessAmount] = useState(null); const [successPrice, setSuccessPrice] = useState(null); const podIndex = usePodIndex(); const maxExpiration = Number.parseInt(podIndex.toHuman()) - Number.parseInt(harvestableIndex.toHuman()) || 0; - const [expiresIn, setExpiresIn] = useState(maxExpiration); // Auto-set to max expiration + const [expiresIn, setExpiresIn] = useState(null); + const selectedExpiresIn = expiresIn ?? maxExpiration; const minFill = TokenValue.fromHuman(1, PODS.decimals); const [showAdvancedSettings, setShowAdvancedSettings] = useState(false); const plotPosition = plot.length > 0 ? plot[0].index.sub(harvestableIndex) : TV.ZERO; - const maxExpirationValidation = useMemo( - () => ({ - minValue: 1, - maxValue: maxExpiration, - maxDecimals: 0, - }), - [maxExpiration], - ); - // Calculate max pods based on selected plots OR all farmer plots const maxPodAmount = useMemo(() => { const plotsToUse = plot.length > 0 ? plot : farmerField.plots; @@ -132,8 +126,7 @@ export default function CreateListing() { const selectedPodRange = useMemo(() => { if (plot.length === 0) return undefined; - // Sort plots by index - const sortedPlots = [...plot].sort((a, b) => a.index.sub(b.index).toNumber()); + const sortedPlots = plot; // Already sorted in handlePlotSelection // Helper function to convert pod offset to absolute index const offsetToAbsoluteIndex = (offset: number): TokenValue => { @@ -166,7 +159,7 @@ export default function CreateListing() { const listingData = useMemo((): PodListingData[] => { if (plot.length === 0 || amount === 0) return []; - const sortedPlots = [...plot].sort((a, b) => a.index.sub(b.index).toNumber()); + const sortedPlots = plot; // Already sorted in handlePlotSelection const result: PodListingData[] = []; // Calculate cumulative pod amounts to find which plots are affected @@ -202,6 +195,11 @@ export default function CreateListing() { return result; }, [plot, podRange, amount]); + // Helper function to sort plots by index + const sortPlotsByIndex = useCallback((plots: Plot[]): Plot[] => { + return [...plots].sort((a, b) => a.index.sub(b.index).toNumber()); + }, []); + // Plot selection handler with tracking const handlePlotSelection = useCallback( (plots: Plot[]) => { @@ -210,8 +208,7 @@ export default function CreateListing() { previous_count: plot.length, }); - // Sort plots by index to ensure consistent ordering - const sortedPlots = [...plots].sort((a, b) => a.index.sub(b.index).toNumber()); + const sortedPlots = sortPlotsByIndex(plots); setPlot(sortedPlots); // Reset range when plots change - slider always starts from first plot and ends at last plot @@ -224,7 +221,7 @@ export default function CreateListing() { setAmount(0); } }, - [plot.length], + [plot.length, sortPlotsByIndex], ); // Pod range slider handler (two thumbs) @@ -252,6 +249,17 @@ export default function CreateListing() { const handlePriceInputChange = useCallback((e: React.ChangeEvent) => { const value = e.target.value; setPricePerPodInput(value); + + if (value === "" || value === ".") { + setPricePerPod(PRICE_PER_POD_CONFIG.MIN); + return; + } + + const numValue = Number.parseFloat(value); + if (!Number.isNaN(numValue)) { + const formatted = clampAndFormatPrice(numValue); + setPricePerPod(formatted); + } }, []); const handlePriceInputBlur = useCallback(() => { @@ -261,8 +269,9 @@ export default function CreateListing() { setPricePerPod(formatted); setPricePerPodInput(removeTrailingZeros(formatted.toFixed(PRICE_PER_POD_CONFIG.DECIMALS))); } else { - setPricePerPodInput(""); - setPricePerPod(undefined); + const formatted = clampAndFormatPrice(PRICE_PER_POD_CONFIG.MIN); + setPricePerPod(formatted); + setPricePerPodInput(removeTrailingZeros(formatted.toFixed(PRICE_PER_POD_CONFIG.DECIMALS))); } }, [pricePerPodInput]); @@ -302,10 +311,11 @@ export default function CreateListing() { setPlot([]); setAmount(0); setPodRange([0, 0]); - setPricePerPod(undefined); - setPricePerPodInput(""); + setPricePerPod(PRICE_PER_POD_CONFIG.MIN); + setPricePerPodInput(initialPrice); + setExpiresIn(null); allQK.forEach((key) => queryClient.invalidateQueries({ queryKey: key })); - }, [amount, pricePerPod, queryClient, allQK]); + }, [amount, pricePerPod, queryClient, allQK, initialPrice]); // state for toast txns const { isConfirming, writeWithEstimateGas, submitting, setSubmitting } = useTransaction({ @@ -318,7 +328,7 @@ export default function CreateListing() { if ( !pricePerPod || pricePerPod <= 0 || - !expiresIn || + selectedExpiresIn <= 0 || !amount || amount <= 0 || !account || @@ -331,12 +341,12 @@ export default function CreateListing() { trackSimpleEvent(ANALYTICS_EVENTS.MARKET.POD_LIST_CREATE, { has_price_per_pod: !!pricePerPod, listing_count: listingData.length, - plot_position_millions: plot.length > 0 ? Math.round(plotPosition.div(1_000_000).toNumber()) : 0, + plot_position_millions: plot.length > 0 ? Math.round(plotPosition.div(MILLION).toNumber()) : 0, }); // pricePerPod should be encoded as uint24 with 6 decimals (0.5 * 1_000_000 = 500000) const encodedPricePerPod = pricePerPod ? Math.floor(pricePerPod * PRICE_PER_POD_CONFIG.DECIMAL_MULTIPLIER) : 0; - const _expiresIn = TokenValue.fromHuman(expiresIn, PODS.decimals); + const _expiresIn = TokenValue.fromHuman(selectedExpiresIn, PODS.decimals); const maxHarvestableIndex = _expiresIn.add(harvestableIndex); try { setSubmitting(true); @@ -385,7 +395,7 @@ export default function CreateListing() { account, amount, pricePerPod, - expiresIn, + selectedExpiresIn, balanceTo, harvestableIndex, minFill, @@ -393,19 +403,31 @@ export default function CreateListing() { listingData, plotPosition, setSubmitting, - mainToken.decimals, diamondAddress, writeWithEstimateGas, ]); // ui state - const disabled = !pricePerPod || !amount || !account || plot.length === 0; + const disabled = !pricePerPod || !amount || !account || plot.length === 0 || selectedExpiresIn <= 0; return (
{/* Plot Selection Section */}
-

Select the Plot(s) you want to List (i):

+
+

Select the Plot(s) you want to List (i):

+ +
{/* Pod Line Graph Visualization */}

1

@@ -480,10 +502,8 @@ export default function CreateListing() { value={pricePerPodInput} onChange={handlePriceInputChange} onBlur={handlePriceInputBlur} - placeholder="0.00" + placeholder="0.001" outlined - containerClassName="" - className="" endIcon={} />
@@ -499,21 +519,6 @@ export default function CreateListing() {
)}
- {/* Advanced Settings Toggle */} -
-

Settings

- -
{/* Advanced Settings - Collapsible */}

Expires In

- - {!!expiresIn && ( +
+

{formatter.noDec(0)}

+ setExpiresIn(value[0])} + className="flex-1" + /> +
+ {formatter.noDec(maxExpiration)} +
+
+ {selectedExpiresIn > 0 && (

- This listing will automatically expire after {formatter.noDec(expiresIn)} more Pods become + This listing will automatically expire after{" "} + {formatter.noDec(selectedExpiresIn)} more Pods become Harvestable.

)} @@ -599,32 +613,33 @@ export default function CreateListing() { ); } -const ActionSummary = ({ - podAmount, - listingData, - pricePerPod, - harvestableIndex, -}: { podAmount: number; listingData: PodListingData[]; pricePerPod: number; harvestableIndex: TokenValue }) => { +interface ActionSummaryProps { + podAmount: number; + listingData: PodListingData[]; + pricePerPod: number; + harvestableIndex: TokenValue; +} + +const ActionSummary = ({ podAmount, listingData, pricePerPod, harvestableIndex }: ActionSummaryProps) => { const beansOut = podAmount * pricePerPod; - // Format line positions - const formatLinePositions = (): string => { + // Format line positions - memoized to avoid recalculation + const linePositions = useMemo((): string => { if (listingData.length === 0) return ""; if (listingData.length === 1) { const placeInLine = listingData[0].index.sub(harvestableIndex); return `@ ${placeInLine.toHuman("short")} in Line`; } - // Multiple plots: show range - const sortedData = [...listingData].sort((a, b) => a.index.sub(b.index).toNumber()); - const firstPlace = sortedData[0].index.sub(harvestableIndex); - const lastPlace = sortedData[sortedData.length - 1].index.sub(harvestableIndex); + // Multiple plots: show range (already sorted) + const firstPlace = listingData[0].index.sub(harvestableIndex); + const lastPlace = listingData[listingData.length - 1].index.sub(harvestableIndex); if (firstPlace.eq(lastPlace)) { return `@ ${firstPlace.toHuman("short")} in Line`; } - return `@ ${firstPlace.toHuman("short")} - ${lastPlace.toHuman("short")} in Lines`; - }; + return `@ ${firstPlace.toHuman("short")} - ${lastPlace.toHuman("short")} in Line`; + }, [listingData, harvestableIndex]); return (
@@ -635,7 +650,7 @@ const ActionSummary = ({ {formatter.number(beansOut, { minDecimals: 0, maxDecimals: 2 })} Pinto

- in exchange for {formatter.noDec(podAmount)} Pods {formatLinePositions()}. + in exchange for {formatter.noDec(podAmount)} Pods {linePositions}.

diff --git a/src/pages/market/actions/CreateOrder.tsx b/src/pages/market/actions/CreateOrder.tsx index 2485dd87d..95568eb12 100644 --- a/src/pages/market/actions/CreateOrder.tsx +++ b/src/pages/market/actions/CreateOrder.tsx @@ -4,7 +4,6 @@ import { ComboInputField } from "@/components/ComboInputField"; import FrameAnimator from "@/components/LoadingSpinner"; import PodLineGraph from "@/components/PodLineGraph"; import RoutingAndSlippageInfo, { useRoutingAndSlippageWarning } from "@/components/RoutingAndSlippageInfo"; -import SimpleInputField from "@/components/SimpleInputField"; import SlippageButton from "@/components/SlippageButton"; import SmartApprovalButton from "@/components/SmartApprovalButton"; import SmartSubmitButton from "@/components/SmartSubmitButton"; @@ -39,13 +38,17 @@ import { useNavigate } from "react-router-dom"; import { toast } from "sonner"; import { useAccount } from "wagmi"; +// Constants const PRICE_PER_POD_CONFIG = { MAX: 1, - MIN: 0.000001, + MIN: 0.001, DECIMALS: 6, DECIMAL_MULTIPLIER: 1_000_000, // 10^6 for 6 decimals } as const; +const MILLION = 1_000_000; +const MIN_FILL_AMOUNT = "1"; + const TextAdornment = ({ text, className }: { text: string; className?: string }) => { return
{text}
; }; @@ -151,9 +154,10 @@ export default function CreateOrder() { const podIndex = usePodIndex(); const harvestableIndex = useHarvestableIndex(); const maxPlace = Number.parseInt(podIndex.toHuman()) - Number.parseInt(harvestableIndex.toHuman()) || 0; + const initialPrice = removeTrailingZeros(PRICE_PER_POD_CONFIG.MIN.toFixed(PRICE_PER_POD_CONFIG.DECIMALS)); const [maxPlaceInLine, setMaxPlaceInLine] = useState(undefined); - const [pricePerPod, setPricePerPod] = useState(undefined); - const [pricePerPodInput, setPricePerPodInput] = useState(""); + const [pricePerPod, setPricePerPod] = useState(PRICE_PER_POD_CONFIG.MIN); + const [pricePerPodInput, setPricePerPodInput] = useState(initialPrice); // set preferred token useEffect(() => { @@ -188,6 +192,17 @@ export default function CreateOrder() { const handlePriceInputChange = useCallback((e: React.ChangeEvent) => { const value = e.target.value; setPricePerPodInput(value); + + if (value === "" || value === ".") { + setPricePerPod(PRICE_PER_POD_CONFIG.MIN); + return; + } + + const numValue = Number.parseFloat(value); + if (!Number.isNaN(numValue)) { + const formatted = clampAndFormatPrice(numValue); + setPricePerPod(formatted); + } }, []); const handlePriceInputBlur = useCallback(() => { @@ -197,8 +212,9 @@ export default function CreateOrder() { setPricePerPod(formatted); setPricePerPodInput(removeTrailingZeros(formatted.toFixed(PRICE_PER_POD_CONFIG.DECIMALS))); } else { - setPricePerPodInput(""); - setPricePerPod(undefined); + const formatted = clampAndFormatPrice(PRICE_PER_POD_CONFIG.MIN); + setPricePerPod(formatted); + setPricePerPodInput(removeTrailingZeros(formatted.toFixed(PRICE_PER_POD_CONFIG.DECIMALS))); } }, [pricePerPodInput]); @@ -243,10 +259,10 @@ export default function CreateOrder() { setAmountIn(""); setMaxPlaceInLine(undefined); - setPricePerPod(undefined); - setPricePerPodInput(""); + setPricePerPod(PRICE_PER_POD_CONFIG.MIN); + setPricePerPodInput(initialPrice); allQK.forEach((key) => queryClient.invalidateQueries({ queryKey: key })); - }, [queryClient, allQK]); + }, [queryClient, allQK, initialPrice]); // state for toast txns const { isConfirming, writeWithEstimateGas, submitting, setSubmitting } = useTransaction({ @@ -303,7 +319,7 @@ export default function CreateOrder() { const _maxPlaceInLine = TokenValue.fromHuman(maxPlaceInLine?.toString() || "0", PODS.decimals); // pricePerPod should be encoded as uint24 with 6 decimals (0.5 * 1_000_000 = 500000) const encodedPricePerPod = pricePerPod ? Math.floor(pricePerPod * PRICE_PER_POD_CONFIG.DECIMAL_MULTIPLIER) : 0; - const minFill = TokenValue.fromHuman("1", PODS.decimals); + const minFill = TokenValue.fromHuman(MIN_FILL_AMOUNT, PODS.decimals); const orderCallStruct = createPodOrder( account, @@ -351,7 +367,6 @@ export default function CreateOrder() { swapBuild, tokenIn.symbol, podsOut, - pricePerPod, amountIn, ]); @@ -361,10 +376,10 @@ export default function CreateOrder() { const formIsFilled = !!pricePerPod && !!maxPlaceInLine && !!account && amountInTV.gt(0); const disabled = !formIsFilled || swapDataNotReady; - // Calculate orderRangeEnd for PodLineGraph overlay (memoized) + // Calculate orderRangeEnd for PodLineGraph overlay const orderRangeEnd = useMemo(() => { if (!maxPlaceInLine) return undefined; - return harvestableIndex.add(TokenValue.fromHuman(maxPlaceInLine, PODS.decimals)); + return harvestableIndex.add(TokenValue.fromHuman(maxPlaceInLine.toString(), PODS.decimals)); }, [maxPlaceInLine, harvestableIndex]); return ( @@ -414,7 +429,7 @@ export default function CreateOrder() {
{/* Price Per Pod */}
-

Amount I am willing to pay for each Pod for:

+

I am willing to buy Pods up to:

0

@@ -424,7 +439,7 @@ export default function CreateOrder() { step={0.000001} value={[pricePerPod || PRICE_PER_POD_CONFIG.MIN]} onValueChange={handlePriceSliderChange} - className="w-[300px]" + className="w-[18rem]" />

1

@@ -435,10 +450,8 @@ export default function CreateOrder() { onChange={handlePriceInputChange} onBlur={handlePriceInputBlur} onFocus={(e) => e.target.select()} - placeholder="0.00" + placeholder="0.001" outlined - containerClassName="" - className="" endIcon={} />
@@ -555,15 +568,20 @@ export default function CreateOrder() { ); } -const ActionSummary = ({ - beansIn, - pricePerPod, - maxPlaceInLine, -}: { beansIn: TV; pricePerPod: number; maxPlaceInLine: number }) => { - // pricePerPod is Pinto per Pod (0-1), convert to TokenValue with same decimals as beansIn (mainToken decimals) - // Then divide to get pods and convert to Pods decimals - const pricePerPodTV = TokenValue.fromHuman(pricePerPod.toString(), beansIn.decimals); - const podsOut = beansIn.div(pricePerPodTV).reDecimal(PODS.decimals); +interface ActionSummaryProps { + beansIn: TV; + pricePerPod: number; + maxPlaceInLine: number; +} + +const ActionSummary = ({ beansIn, pricePerPod, maxPlaceInLine }: ActionSummaryProps) => { + // Calculate pods out - memoized to avoid recalculation + const podsOut = useMemo(() => { + // pricePerPod is Pinto per Pod (0-1), convert to TokenValue with same decimals as beansIn (mainToken decimals) + // Then divide to get pods and convert to Pods decimals + const pricePerPodTV = TokenValue.fromHuman(pricePerPod.toString(), beansIn.decimals); + return beansIn.div(pricePerPodTV).reDecimal(PODS.decimals); + }, [beansIn, pricePerPod]); return (
diff --git a/src/pages/market/actions/FillListing.tsx b/src/pages/market/actions/FillListing.tsx index cd444213d..d12eafa2e 100644 --- a/src/pages/market/actions/FillListing.tsx +++ b/src/pages/market/actions/FillListing.tsx @@ -53,6 +53,9 @@ const PRICE_PER_POD_CONFIG = { } as const; const PRICE_SLIDER_STEP = 0.001; +const MILLION = 1_000_000; +const DEFAULT_PRICE_INPUT = "0.001"; +const PLACE_MARGIN_PERCENT = 0.01; // 1% margin for place in line range const TextAdornment = ({ text, className }: { text: string; className?: string }) => { return
{text}
; @@ -122,7 +125,7 @@ export default function FillListing() { // Price per pod filter state const [maxPricePerPod, setMaxPricePerPod] = useState(0); - const [maxPricePerPodInput, setMaxPricePerPodInput] = useState("0.000000"); + const [maxPricePerPodInput, setMaxPricePerPodInput] = useState(DEFAULT_PRICE_INPUT); // Place in line range state const podIndex = usePodIndex(); @@ -188,7 +191,7 @@ export default function FillListing() { // Set place in line range to include this listing with a small margin // Clamp to valid range [0, maxPlace] - const margin = Math.max(1, Math.floor(maxPlace * 0.01)); // 1% margin or at least 1 + const margin = Math.max(1, Math.floor(maxPlace * PLACE_MARGIN_PERCENT)); const minPlace = Math.max(0, Math.floor(placeInLine - margin)); const maxPlaceValue = Math.min(maxPlace, Math.ceil(placeInLine + margin)); setPlaceInLineRange([minPlace, maxPlaceValue]); @@ -218,6 +221,18 @@ export default function FillListing() { const handlePriceInputChange = useCallback((e: React.ChangeEvent) => { const value = e.target.value; setMaxPricePerPodInput(value); + + if (value === "" || value === ".") { + setMaxPricePerPod(0); + return; + } + + const numValue = Number.parseFloat(value); + if (!Number.isNaN(numValue)) { + const clamped = Math.max(PRICE_PER_POD_CONFIG.MIN, Math.min(PRICE_PER_POD_CONFIG.MAX, numValue)); + const formatted = formatPricePerPod(clamped); + setMaxPricePerPod(formatted); + } }, []); const handlePriceInputBlur = useCallback(() => { @@ -388,7 +403,6 @@ export default function FillListing() { const listingRemainingPods = TokenValue.fromBlockchain(listing.remainingAmount, PODS.decimals); const maxBeansForListing = listingRemainingPods.mul(listingPrice); - // Take the minimum of: remaining beans and max beans we can spend on this listing const beansToSpend = TokenValue.min(remainingBeans, maxBeansForListing); if (beansToSpend.gt(0)) { result.push({ listing, beanAmount: beansToSpend }); @@ -401,11 +415,9 @@ export default function FillListing() { // Calculate weighted average for eligible listings const eligibleSummary = useMemo(() => { - if (listingsToFill.length === 0) { - return null; - } + if (listingsToFill.length === 0) return null; - // If only one listing, use its price directly (no need for average) + // Single listing - use its price directly if (listingsToFill.length === 1) { const { listing, beanAmount } = listingsToFill[0]; const listingPrice = TokenValue.fromBlockchain(listing.pricePerPod, mainToken.decimals); @@ -424,15 +436,16 @@ export default function FillListing() { let totalPods = 0; let totalPlaceInLine = 0; - listingsToFill.forEach(({ listing, beanAmount }) => { + for (const { listing, beanAmount } of listingsToFill) { const listingPrice = TokenValue.fromBlockchain(listing.pricePerPod, mainToken.decimals); const podsFromListing = beanAmount.div(listingPrice); const listingPlace = TokenValue.fromBlockchain(listing.index, PODS.decimals).sub(harvestableIndex); - totalValue += listingPrice.toNumber() * podsFromListing.toNumber(); - totalPods += podsFromListing.toNumber(); - totalPlaceInLine += listingPlace.toNumber() * podsFromListing.toNumber(); - }); + const pods = podsFromListing.toNumber(); + totalValue += listingPrice.toNumber() * pods; + totalPods += pods; + totalPlaceInLine += listingPlace.toNumber() * pods; + } const avgPricePerPod = totalPods > 0 ? totalValue / totalPods : 0; const avgPlaceInLine = totalPods > 0 ? totalPlaceInLine / totalPods : 0; @@ -637,7 +650,7 @@ export default function FillListing() { step={PRICE_SLIDER_STEP} value={[maxPricePerPod]} onValueChange={handlePriceSliderChange} - className="w-[300px]" + className="w-[18rem]" />

1

@@ -648,10 +661,8 @@ export default function FillListing() { onChange={handlePriceInputChange} onBlur={handlePriceInputBlur} onFocus={(e) => e.target.select()} - placeholder="0.000000" + placeholder="0.001" outlined - containerClassName="" - className="" endIcon={} />
@@ -827,21 +838,19 @@ export default function FillListing() { ); } +interface ActionSummaryProps { + pricePerPod: TV; + plotPosition: TV; + beanAmount: TV; +} + /** * Displays summary of the fill transaction * Shows estimated pods to receive, average position, and pricing details */ -const ActionSummary = ({ - pricePerPod, - plotPosition, - beanAmount, -}: { - pricePerPod: TV; - plotPosition: TV; - beanAmount: TV; -}) => { - // Calculate estimated pods to receive - const estimatedPods = beanAmount.div(pricePerPod); +const ActionSummary = ({ pricePerPod, plotPosition, beanAmount }: ActionSummaryProps) => { + // Calculate estimated pods to receive - memoized to avoid recalculation + const estimatedPods = useMemo(() => beanAmount.div(pricePerPod), [beanAmount, pricePerPod]); return (
diff --git a/src/pages/market/actions/FillOrder.tsx b/src/pages/market/actions/FillOrder.tsx index f32c4e327..a2f0b6a58 100644 --- a/src/pages/market/actions/FillOrder.tsx +++ b/src/pages/market/actions/FillOrder.tsx @@ -27,10 +27,6 @@ import { Address, encodeFunctionData } from "viem"; import { useAccount } from "wagmi"; import CancelOrder from "./CancelOrder"; -//! TODO: ADD INPUT FIELD FOR POD AMOUNTS -//! TODO: SOLVE CHART REDIRECT ISSUE -//! TODO: ADD SETTINGS SECTION - // Constants const FIELD_ID = 0n; const MIN_PODS_THRESHOLD = 1; // Minimum pods required for order eligibility @@ -143,7 +139,7 @@ export default function FillOrder() { orderPositions: positions, totalCapacity: cumulative, }; - }, [allOrders?.podOrders, selectedOrderIds, mainToken.decimals]); + }, [allOrders, selectedOrderIds, mainToken.decimals]); const amount = podRange[1] - podRange[0]; @@ -151,9 +147,7 @@ export default function FillOrder() { const [rangeStart, rangeEnd] = podRange; return orderPositions - .filter((pos) => { - return pos.endPos > rangeStart && pos.startPos < rangeEnd; - }) + .filter((pos) => pos.endPos > rangeStart && pos.startPos < rangeEnd) .map((pos) => { const overlapStart = Math.max(pos.startPos, rangeStart); const overlapEnd = Math.min(pos.endPos, rangeEnd); @@ -171,7 +165,7 @@ export default function FillOrder() { const weightedAvgPricePerPod = useMemo(() => { if (ordersToFill.length === 0 || amount === 0) return 0; - // If only one order, use its price directly (no need for average) + // Single order - use its price directly if (ordersToFill.length === 1) { return TokenValue.fromBlockchain(ordersToFill[0].order.pricePerPod, mainToken.decimals).toNumber(); } @@ -180,11 +174,11 @@ export default function FillOrder() { let totalValue = 0; let totalPods = 0; - ordersToFill.forEach(({ order, amount: fillAmount }) => { + for (const { order, amount: fillAmount } of ordersToFill) { const orderPricePerPod = TokenValue.fromBlockchain(order.pricePerPod, mainToken.decimals).toNumber(); totalValue += orderPricePerPod * fillAmount; totalPods += fillAmount; - }); + } return totalPods > 0 ? totalValue / totalPods : 0; }, [ordersToFill, amount, mainToken.decimals]); @@ -310,12 +304,12 @@ export default function FillOrder() { }; // Track analytics for each order being filled - ordersToFill.forEach(({ order: orderToFill }) => { + for (const { order: orderToFill } of ordersToFill) { trackSimpleEvent(ANALYTICS_EVENTS.MARKET.POD_ORDER_FILL, { order_price_per_pod: Number(orderToFill.pricePerPod), order_max_place: Number(orderToFill.maxPlaceInLine), }); - }); + } try { setSubmitting(true); @@ -585,12 +579,6 @@ export default function FillOrder() {
- {/* OLD DESTINATION SECTION - COMMENTED OUT FOR POTENTIAL FUTURE USE */} - {/*
-

Destination

- -
*/} -
{ordersToFill.length > 0 && amount > 0 && ( @@ -641,8 +629,13 @@ export default function FillOrder() { ); } -const ActionSummary = ({ podAmount, pricePerPod }: { podAmount: number; pricePerPod: number }) => { - const beansOut = podAmount * pricePerPod; +interface ActionSummaryProps { + podAmount: number; + pricePerPod: number; +} + +const ActionSummary = ({ podAmount, pricePerPod }: ActionSummaryProps) => { + const beansOut = useMemo(() => podAmount * pricePerPod, [podAmount, pricePerPod]); return (
diff --git a/tailwind.config.js b/tailwind.config.js index fa500884c..9c1ce21d8 100644 --- a/tailwind.config.js +++ b/tailwind.config.js @@ -127,6 +127,7 @@ module.exports = { "morning-yellow-2": "#F1F88C", "warning-yellow": "#DCB505", "warning-orange": "#ED7A00", + "yellow-active": "#CCA702", "stalk-gold": "#D3B567", "seed-silver": "#7B9387", "pod-bronze": "#9F7F54", From 48a0338ac1bbe5c42c79e2ec7dae80595b254c6c Mon Sep 17 00:00:00 2001 From: feyyazcigim Date: Mon, 10 Nov 2025 02:14:58 +0300 Subject: [PATCH 39/50] Make Market Action Menu sticky, and add users pod line to Market Page --- src/components/PodLineGraph.tsx | 4 ++-- src/pages/Market.tsx | 11 ++++++++++- 2 files changed, 12 insertions(+), 3 deletions(-) diff --git a/src/components/PodLineGraph.tsx b/src/components/PodLineGraph.tsx index 9acf17eb7..55a8da69c 100644 --- a/src/components/PodLineGraph.tsx +++ b/src/components/PodLineGraph.tsx @@ -279,14 +279,14 @@ export default function PodLineGraph({ const bottomAxisLabels = topAxisLabels; return ( -
+
{/* Label */}

{label}

{/* Plot container with border */} -
+
{/* Harvested Section (Log Scale) - Left 20% (only shown if there are harvested plots) */} {hasHarvestedPlots && ( diff --git a/src/pages/Market.tsx b/src/pages/Market.tsx index 26a63eec2..7d02866d7 100644 --- a/src/pages/Market.tsx +++ b/src/pages/Market.tsx @@ -3,11 +3,13 @@ import PintoIcon from "@/assets/tokens/PINTO.png"; import { TokenValue } from "@/classes/TokenValue"; import { Col } from "@/components/Container"; import FrameAnimator from "@/components/LoadingSpinner"; +import PodLineGraph from "@/components/PodLineGraph"; import ReadMoreAccordion from "@/components/ReadMoreAccordion"; import ScatterChart from "@/components/charts/ScatterChart"; import { Card } from "@/components/ui/Card"; import { Separator } from "@/components/ui/Separator"; import { ANALYTICS_EVENTS } from "@/constants/analytics-events"; +import useNavHeight from "@/hooks/display/useNavHeight"; import { useAllMarket } from "@/state/market/useAllMarket"; import { useHarvestableIndex, usePodLine } from "@/state/useFieldData"; import { trackSimpleEvent } from "@/utils/analytics"; @@ -161,6 +163,7 @@ export function Market() { const podLine = usePodLine(); const podLineAsNumber = podLine.toNumber() / MILLION; const harvestableIndex = useHarvestableIndex(); + const navHeight = useNavHeight(); const scatterChartData: MarketScatterChartData[] = useMemo( () => shapeScatterChartData(data || [], harvestableIndex), @@ -363,6 +366,9 @@ export function Market() { toolTipOptions={toolTipOptions as TooltipOptions} />
+
+ +
{TABLE_SLUGS.map((s, idx) => (

}

-
+
From f26c340a74b4e911afba3b7a99b78c204f1ee1e0 Mon Sep 17 00:00:00 2001 From: feyyazcigim Date: Mon, 10 Nov 2025 02:49:29 +0300 Subject: [PATCH 40/50] Add pagination to Market Table --- src/components/MarketPaginationControls.tsx | 79 +++++++++++++++++++++ src/pages/market/MarketActivityTable.tsx | 34 +++------ src/pages/market/PodListingsTable.tsx | 32 +++------ src/pages/market/PodOrdersTable.tsx | 32 +++------ 4 files changed, 107 insertions(+), 70 deletions(-) create mode 100644 src/components/MarketPaginationControls.tsx diff --git a/src/components/MarketPaginationControls.tsx b/src/components/MarketPaginationControls.tsx new file mode 100644 index 000000000..5bec080f8 --- /dev/null +++ b/src/components/MarketPaginationControls.tsx @@ -0,0 +1,79 @@ +import { Button } from "@/components/ui/Button"; +import { ChevronLeftIcon, ChevronRightIcon } from "@radix-ui/react-icons"; + +interface MarketPaginationControlsProps { + currentPage: number; + totalPages: number; + onPageChange: (page: number) => void; + totalItems: number; + itemsPerPage: number; +} + +export function MarketPaginationControls({ + currentPage, + totalPages, + onPageChange, + totalItems, + itemsPerPage, +}: MarketPaginationControlsProps) { + if (totalPages <= 1) return null; + + const startItem = (currentPage - 1) * itemsPerPage + 1; + const endItem = Math.min(currentPage * itemsPerPage, totalItems); + + return ( +
+
+ Showing {startItem}-{endItem} of {totalItems} items +
+
+ +
+ {Array.from({ length: Math.min(totalPages, 5) }, (_, i) => { + let pageNum: number; + if (totalPages <= 5) { + pageNum = i + 1; + } else if (currentPage <= 3) { + pageNum = i + 1; + } else if (currentPage >= totalPages - 2) { + pageNum = totalPages - 4 + i; + } else { + pageNum = currentPage - 2 + i; + } + + return ( + + ); + })} +
+ +
+
+ ); +} diff --git a/src/pages/market/MarketActivityTable.tsx b/src/pages/market/MarketActivityTable.tsx index fc191f05e..57f16fdda 100644 --- a/src/pages/market/MarketActivityTable.tsx +++ b/src/pages/market/MarketActivityTable.tsx @@ -2,8 +2,8 @@ import podIcon from "@/assets/protocol/Pod.png"; import pintoIcon from "@/assets/tokens/PINTO.png"; import { TokenValue } from "@/classes/TokenValue"; import FrameAnimator from "@/components/LoadingSpinner"; -import { Button } from "@/components/ui/Button"; -import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/Card"; +import { MarketPaginationControls } from "@/components/MarketPaginationControls"; +import { Card, CardContent, CardHeader } from "@/components/ui/Card"; import IconImage from "@/components/ui/IconImage"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/Table"; import { PODS } from "@/constants/internalTokens"; @@ -25,7 +25,7 @@ export function MarketActivityTable({ marketData, titleText, farmer }: MarketAct const harvestableIndex = useHarvestableIndex(); const { data, isLoaded, isFetching } = marketData; - const rowsPerPage = 5000; + const rowsPerPage = 12; const totalRows = data?.length || 0; const totalPages = Math.ceil(totalRows / rowsPerPage); const [currentPage, setCurrentPage] = useState(1); @@ -237,27 +237,13 @@ export function MarketActivityTable({ marketData, titleText, farmer }: MarketAct - {totalPages > 1 && ( -
- -
{`${currentPage} of ${totalPages}`}
- -
- )} + )} diff --git a/src/pages/market/PodListingsTable.tsx b/src/pages/market/PodListingsTable.tsx index d36fb1d22..35d750e2f 100644 --- a/src/pages/market/PodListingsTable.tsx +++ b/src/pages/market/PodListingsTable.tsx @@ -2,7 +2,7 @@ import podIcon from "@/assets/protocol/Pod.png"; import pintoIcon from "@/assets/tokens/PINTO.png"; import { TokenValue } from "@/classes/TokenValue"; import FrameAnimator from "@/components/LoadingSpinner"; -import { Button } from "@/components/ui/Button"; +import { MarketPaginationControls } from "@/components/MarketPaginationControls"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/Card"; import IconImage from "@/components/ui/IconImage"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/Table"; @@ -22,7 +22,7 @@ export function PodListingsTable() { const podListings = podListingsQuery.data?.podListings; const harvestableIndex = useHarvestableIndex(); - const rowsPerPage = 5000; + const rowsPerPage = 12; const totalRows = podListings?.length || 0; const totalPages = Math.ceil(totalRows / rowsPerPage); const [currentPage, setCurrentPage] = useState(1); @@ -137,27 +137,13 @@ export function PodListingsTable() { - {totalPages > 1 && ( -
- -
{`${currentPage} of ${totalPages}`}
- -
- )} + )} diff --git a/src/pages/market/PodOrdersTable.tsx b/src/pages/market/PodOrdersTable.tsx index fb60e64b3..8022c7370 100644 --- a/src/pages/market/PodOrdersTable.tsx +++ b/src/pages/market/PodOrdersTable.tsx @@ -2,7 +2,7 @@ import podIcon from "@/assets/protocol/Pod.png"; import pintoIcon from "@/assets/tokens/PINTO.png"; import { TokenValue } from "@/classes/TokenValue"; import FrameAnimator from "@/components/LoadingSpinner"; -import { Button } from "@/components/ui/Button"; +import { MarketPaginationControls } from "@/components/MarketPaginationControls"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/Card"; import IconImage from "@/components/ui/IconImage"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/Table"; @@ -32,7 +32,7 @@ export function PodOrdersTable() { } } - const rowsPerPage = 5000; + const rowsPerPage = 12; const totalRows = filteredOrders?.length || 0; const totalPages = Math.ceil(totalRows / rowsPerPage); const [currentPage, setCurrentPage] = useState(1); @@ -141,27 +141,13 @@ export function PodOrdersTable() { - {totalPages > 1 && ( -
- -
{`${currentPage} of ${totalPages}`}
- -
- )} + )} From d87a4f0f616b6b1e3d3091b72574777653c4167c Mon Sep 17 00:00:00 2001 From: feyyazcigim Date: Mon, 10 Nov 2025 19:54:17 +0300 Subject: [PATCH 41/50] Add slider to ComboInputField and implement optional marks --- src/components/ComboInputField.tsx | 123 +++++++++++++++++++++++ src/components/ui/Slider.tsx | 52 +++++++++- src/pages/market/actions/CreateOrder.tsx | 2 + src/pages/market/actions/FillListing.tsx | 4 +- 4 files changed, 177 insertions(+), 4 deletions(-) diff --git a/src/components/ComboInputField.tsx b/src/components/ComboInputField.tsx index 075c55d88..9a245ea0a 100644 --- a/src/components/ComboInputField.tsx +++ b/src/components/ComboInputField.tsx @@ -28,10 +28,32 @@ import PlotSelect from "./PlotSelect"; import TextSkeleton from "./TextSkeleton"; import TokenSelectWithBalances, { TransformTokenLabelsFunction } from "./TokenSelectWithBalances"; import { Button } from "./ui/Button"; +import { Input } from "./ui/Input"; import { Skeleton } from "./ui/Skeleton"; +import { Slider } from "./ui/Slider"; const ETH_GAS_RESERVE = TokenValue.fromHuman("0.0003333333333", 18); // Reserve $1 of gas if eth is $3k +/** + * Convert slider value (0-100) to TokenValue + */ +const sliderToTokenValue = (sliderValue: number, maxAmount: TokenValue): TokenValue => { + const percentage = sliderValue / 100; + return maxAmount.mul(percentage); +}; + +/** + * Convert TokenValue to slider value (0-100) + */ +const tokenValueToSlider = (tokenValue: TokenValue, maxAmount: TokenValue): number => { + if (maxAmount.eq(0)) return 0; + return tokenValue.div(maxAmount).mul(100).toNumber(); +}; + +const TextAdornment = ({ text, className }: { text: string; className?: string }) => { + return
{text}
; +}; + export interface ComboInputProps extends InputHTMLAttributes { // Token mode props setToken?: Dispatch> | ((token: Token) => void); @@ -75,6 +97,10 @@ export interface ComboInputProps extends InputHTMLAttributes { // Token select props transformTokenLabels?: TransformTokenLabelsFunction; + + // Slider props + enableSlider?: boolean; + sliderMarkers?: number[]; } function ComboInputField({ @@ -112,6 +138,8 @@ function ComboInputField({ selectKey, transformTokenLabels, placeholder, + enableSlider, + sliderMarkers, }: ComboInputProps) { const tokenData = useTokenData(); const { balances } = useFarmerBalances(); @@ -270,6 +298,18 @@ function ComboInputField({ [connectedAccount, setError], ); + /** + * Reset amount when token changes + */ + useEffect(() => { + setInternalAmount(TokenValue.ZERO); + setDisplayValue("0"); + if (setAmount) { + setAmount("0"); + lastInternalAmountRef.current = "0"; + } + }, [selectedToken, setAmount]); + /** * Clamp the internal amount to the max amount * - ONLY when the selected token changes @@ -436,6 +476,58 @@ function ComboInputField({ return sortedPlots.map((plot) => truncateHex(plot.idHex)).join(", "); }, [selectedPlots]); + // Calculate slider value from internal amount + const sliderValue = useMemo(() => { + if (!enableSlider || maxAmount.eq(0)) return 0; + return tokenValueToSlider(internalAmount, maxAmount); + }, [enableSlider, internalAmount, maxAmount]); + + // Handle slider value changes + const handleSliderChange = useCallback( + (values: number[]) => { + if (disableInput || !enableSlider) return; + + const sliderVal = values[0] ?? 0; + const tokenVal = sliderToTokenValue(sliderVal, maxAmount); + + setIsUserInput(true); + setInternalAmount(tokenVal); + setDisplayValue(tokenVal.toHuman()); + handleSetError(tokenVal.gt(maxAmount)); + }, + [disableInput, enableSlider, maxAmount, handleSetError], + ); + + // Handle percentage input changes + const handlePercentageChange = useCallback( + (value: string) => { + if (disableInput || !enableSlider) return; + + // Allow empty string or valid number input + if (value === "") { + const tokenVal = TokenValue.ZERO; + setIsUserInput(true); + setInternalAmount(tokenVal); + setDisplayValue(tokenVal.toHuman()); + return; + } + + // Sanitize and validate percentage input (0-100) + const numValue = parseFloat(value); + if (Number.isNaN(numValue)) return; + + // Clamp between 0 and 100 + const clampedPct = Math.max(0, Math.min(100, numValue)); + const tokenVal = sliderToTokenValue(clampedPct, maxAmount); + + setIsUserInput(true); + setInternalAmount(tokenVal); + setDisplayValue(tokenVal.toHuman()); + handleSetError(tokenVal.gt(maxAmount)); + }, + [disableInput, enableSlider, maxAmount, handleSetError], + ); + return ( <>
)} + {enableSlider && ( +
+
+
+ +
+
+ handlePercentageChange(e.target.value)} + onFocus={(e) => e.target.select()} + placeholder={formatter.noDec(sliderValue)} + outlined + containerClassName="w-[6rem]" + disabled={disableInput || maxAmount.eq(0)} + endIcon={} + /> +
+
+
+ )}
diff --git a/src/components/ui/Slider.tsx b/src/components/ui/Slider.tsx index 8b3a54f07..55a9c6757 100644 --- a/src/components/ui/Slider.tsx +++ b/src/components/ui/Slider.tsx @@ -5,20 +5,66 @@ import { cn } from "@/utils/utils"; export interface ISliderProps extends React.ComponentPropsWithoutRef { numThumbs?: number; + markers?: number[]; } const Slider = React.forwardRef, ISliderProps>( - ({ className, numThumbs = 1, ...props }, ref) => { + ({ className, numThumbs = 1, markers = [], ...props }, ref) => { const thumbs = React.useMemo(() => Array.from({ length: numThumbs }, (_, i) => i), [numThumbs]); + // Calculate marker position as percentage + const calculateMarkerPosition = React.useCallback( + (markerValue: number): number => { + const min = props.min ?? 0; + const max = props.max ?? 100; + if (max === min) return 0; + return ((markerValue - min) / (max - min)) * 100; + }, + [props.min, props.max], + ); + + // Filter markers to only include values within min-max range + const validMarkers = React.useMemo(() => { + const min = props.min ?? 0; + const max = props.max ?? 100; + return markers.filter((marker) => marker >= min && marker <= max); + }, [markers, props.min, props.max]); + + // Get current slider value + const currentValue = React.useMemo(() => { + const values = props.value ?? props.defaultValue; + if (Array.isArray(values)) { + return values[0] ?? props.min ?? 0; + } + return values ?? props.min ?? 0; + }, [props.value, props.defaultValue, props.min]); + return ( - - + + + {validMarkers.map((marker, index) => { + const position = calculateMarkerPosition(marker); + const isAboveValue = marker > currentValue; + return ( +
+ ); + })} {thumbs.map((idx) => ( {shouldSwap && amountInTV.gt(0) && ( {!isUsingMain && amountInTV.gt(0) && ( Date: Mon, 10 Nov 2025 20:01:09 +0300 Subject: [PATCH 42/50] Fix slider styling --- src/components/ui/Slider.tsx | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/src/components/ui/Slider.tsx b/src/components/ui/Slider.tsx index 55a9c6757..c78f5f961 100644 --- a/src/components/ui/Slider.tsx +++ b/src/components/ui/Slider.tsx @@ -53,14 +53,12 @@ const Slider = React.forwardRef, I return (
); From c6f3f902903697b1beb18f75c1c2226b7c51a4ea Mon Sep 17 00:00:00 2001 From: feyyazcigim Date: Wed, 12 Nov 2025 20:28:03 +0300 Subject: [PATCH 43/50] Select plot group from Market --- src/constants/analytics-events.ts | 1 + src/pages/Market.tsx | 20 ++++++++- src/pages/market/actions/CreateListing.tsx | 52 +++++++++++++++++++++- 3 files changed, 70 insertions(+), 3 deletions(-) diff --git a/src/constants/analytics-events.ts b/src/constants/analytics-events.ts index 9f04453c8..6bb05dce8 100644 --- a/src/constants/analytics-events.ts +++ b/src/constants/analytics-events.ts @@ -216,6 +216,7 @@ const MARKET_EVENTS = { // Pod Listing Events POD_LIST_CREATE: "market_pod_list_create", LISTING_PLOT_SELECTED: "market_listing_plot_selected", + LISTING_AUTO_SELECTED: "market_listing_auto_selected", LISTING_PRICE_INPUT: "market_listing_price_input", LISTING_AMOUNT_INPUT: "market_listing_amount_input", POD_LIST_FILL: "market_pod_list_fill", diff --git a/src/pages/Market.tsx b/src/pages/Market.tsx index 7d02866d7..b1edb3669 100644 --- a/src/pages/Market.tsx +++ b/src/pages/Market.tsx @@ -319,6 +319,24 @@ export function Market() { [scatterChartData, mode, navigate], ); + const handleMarketPodLineGraphSelect = useCallback( + (plotIndices: string[]) => { + if (plotIndices.length === 0) return; + + // Track analytics + trackSimpleEvent(ANALYTICS_EVENTS.MARKET.LISTING_PLOT_SELECTED, { + plot_count: plotIndices.length, + source: "market_page", + }); + + // Navigate to CreateListing with plot indices (not full Plot objects to avoid serialization issues) + navigate("/market/pods/sell/create", { + state: { selectedPlotIndices: plotIndices }, + }); + }, + [navigate], + ); + const viewMode = mode; return ( @@ -367,7 +385,7 @@ export function Market() { />
- +
{TABLE_SLUGS.map((s, idx) => ( diff --git a/src/pages/market/actions/CreateListing.tsx b/src/pages/market/actions/CreateListing.tsx index 17da1d309..560fee83a 100644 --- a/src/pages/market/actions/CreateListing.tsx +++ b/src/pages/market/actions/CreateListing.tsx @@ -23,8 +23,8 @@ import { FarmToMode, Plot } from "@/utils/types"; import { cn } from "@/utils/utils"; import { useQueryClient } from "@tanstack/react-query"; import { motion } from "framer-motion"; -import { useCallback, useMemo, useState } from "react"; -import { useNavigate } from "react-router-dom"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useLocation, useNavigate } from "react-router-dom"; import { toast } from "sonner"; import { encodeFunctionData } from "viem"; import { useAccount } from "wagmi"; @@ -224,6 +224,54 @@ export default function CreateListing() { [plot.length, sortPlotsByIndex], ); + // Auto-select plots from location state (from Market page PodLineGraph) + const location = useLocation(); + const lastProcessedIndices = useRef(null); + + useEffect(() => { + const selectedPlotIndices = location.state?.selectedPlotIndices; + + // Type guard and validation + if (!selectedPlotIndices || !Array.isArray(selectedPlotIndices) || selectedPlotIndices.length === 0) { + return; + } + + // Create a unique key from the indices to detect if this is a new selection + // Use slice() to avoid mutating the original array + const indicesKey = [...selectedPlotIndices].sort().join(","); + + // Skip if we've already processed this exact selection + if (lastProcessedIndices.current === indicesKey) { + return; + } + + // Find matching plots from farmer's field using string comparison + const validPlots = farmerField.plots.filter((p) => selectedPlotIndices.includes(p.index.toHuman())); + + if (validPlots.length > 0) { + // Mark this selection as processed + lastProcessedIndices.current = indicesKey; + + // Track auto-selection + trackSimpleEvent(ANALYTICS_EVENTS.MARKET.LISTING_AUTO_SELECTED, { + plot_count: validPlots.length, + source: "market_podline_graph", + }); + + // Sort and set plots directly to avoid re-triggering handlePlotSelection + const sortedPlots = sortPlotsByIndex(validPlots); + setPlot(sortedPlots); + + // Set range and amount + const totalPods = sortedPlots.reduce((sum, p) => sum + p.pods.toNumber(), 0); + setPodRange([0, totalPods]); + setAmount(totalPods); + + // Clean up location state to prevent re-selection on re-mount + window.history.replaceState({}, document.title); + } + }, [location.state, farmerField.plots, sortPlotsByIndex]); + // Pod range slider handler (two thumbs) const handlePodRangeChange = useCallback((value: number[]) => { const [min, max] = value; From 412fd0eff858fb100ddd095949ba3b6b8398bd6b Mon Sep 17 00:00:00 2001 From: feyyazcigim Date: Thu, 13 Nov 2025 02:31:30 +0300 Subject: [PATCH 44/50] Change place in line multi slider to slider on FillListing --- src/pages/market/actions/FillListing.tsx | 313 +++++++++++------------ 1 file changed, 151 insertions(+), 162 deletions(-) diff --git a/src/pages/market/actions/FillListing.tsx b/src/pages/market/actions/FillListing.tsx index d7d6586c0..ec83eb1db 100644 --- a/src/pages/market/actions/FillListing.tsx +++ b/src/pages/market/actions/FillListing.tsx @@ -10,7 +10,7 @@ import SmartSubmitButton from "@/components/SmartSubmitButton"; import { Button } from "@/components/ui/Button"; import { Input } from "@/components/ui/Input"; import { Separator } from "@/components/ui/Separator"; -import { MultiSlider, Slider } from "@/components/ui/Slider"; +import { Slider } from "@/components/ui/Slider"; import { ANALYTICS_EVENTS } from "@/constants/analytics-events"; import { PODS } from "@/constants/internalTokens"; import fillPodListing from "@/encoders/fillPodListing"; @@ -53,7 +53,6 @@ const PRICE_PER_POD_CONFIG = { } as const; const PRICE_SLIDER_STEP = 0.001; -const MILLION = 1_000_000; const DEFAULT_PRICE_INPUT = "0.001"; const PLACE_MARGIN_PERCENT = 0.01; // 1% margin for place in line range @@ -127,15 +126,19 @@ export default function FillListing() { const [maxPricePerPod, setMaxPricePerPod] = useState(0); const [maxPricePerPodInput, setMaxPricePerPodInput] = useState(DEFAULT_PRICE_INPUT); - // Place in line range state + // Place in line state const podIndex = usePodIndex(); const maxPlace = Number.parseInt(podIndex.toHuman()) - Number.parseInt(harvestableIndex.toHuman()) || 0; - const [placeInLineRange, setPlaceInLineRange] = useState<[number, number]>([0, maxPlace]); + const [maxPlaceInLine, setMaxPlaceInLine] = useState(undefined); + const [hasInitializedPlace, setHasInitializedPlace] = useState(false); - // Update place in line range when maxPlace changes + // Set maxPlaceInLine to maxPlace by default when maxPlace is available (only once on initial load) useEffect(() => { - setPlaceInLineRange((prev) => [prev[0], maxPlace]); - }, [maxPlace]); + if (maxPlace > 0 && !hasInitializedPlace && maxPlaceInLine === undefined) { + setMaxPlaceInLine(maxPlace); + setHasInitializedPlace(true); + } + }, [maxPlace, hasInitializedPlace, maxPlaceInLine]); const isUsingMain = tokensEqual(tokenIn, mainToken); @@ -183,18 +186,18 @@ export default function FillListing() { const listingPrice = TokenValue.fromBlockchain(listing.pricePerPod, mainToken.decimals).toNumber(); const formattedPrice = formatPricePerPod(listingPrice); setMaxPricePerPod(formattedPrice); - setMaxPricePerPodInput(formattedPrice.toFixed(6)); + setMaxPricePerPodInput(formattedPrice.toFixed(PRICE_PER_POD_CONFIG.DECIMALS)); // Calculate listing's place in line const listingIndex = TokenValue.fromBlockchain(listing.index, PODS.decimals); const placeInLine = listingIndex.sub(harvestableIndex).toNumber(); - // Set place in line range to include this listing with a small margin + // Set max place in line to include this listing with a small margin // Clamp to valid range [0, maxPlace] const margin = Math.max(1, Math.floor(maxPlace * PLACE_MARGIN_PERCENT)); - const minPlace = Math.max(0, Math.floor(placeInLine - margin)); const maxPlaceValue = Math.min(maxPlace, Math.ceil(placeInLine + margin)); - setPlaceInLineRange([minPlace, maxPlaceValue]); + setMaxPlaceInLine(maxPlaceValue); + setHasInitializedPlace(true); // Mark as initialized to prevent default value override }, [listingId, allListings, maxPlace, mainToken.decimals, harvestableIndex]); // Token selection handler with tracking @@ -214,7 +217,7 @@ export default function FillListing() { const handlePriceSliderChange = useCallback((value: number[]) => { const formatted = formatPricePerPod(value[0]); setMaxPricePerPod(formatted); - setMaxPricePerPodInput(formatted.toFixed(6)); + setMaxPricePerPodInput(formatted.toFixed(PRICE_PER_POD_CONFIG.DECIMALS)); }, []); // Price per pod input handlers @@ -248,37 +251,24 @@ export default function FillListing() { } }, [maxPricePerPodInput]); - // Place in line range handler - const handlePlaceInLineRangeChange = useCallback((value: number[]) => { - const [min, max] = value; - setPlaceInLineRange([Math.floor(min), Math.floor(max)]); + // Max place in line slider handler + const handleMaxPlaceSliderChange = useCallback((value: number[]) => { + const newValue = Math.floor(value[0]); + setMaxPlaceInLine(newValue > 0 ? newValue : undefined); }, []); - // Place in line input handlers - const handleMinPlaceInputChange = useCallback( - (e: React.ChangeEvent) => { - const cleanValue = e.target.value.replace(/,/g, ""); - const value = Number.parseInt(cleanValue); - if (!Number.isNaN(value) && value >= 0 && value <= maxPlace) { - setPlaceInLineRange([value, placeInLineRange[1]]); - } else if (cleanValue === "") { - setPlaceInLineRange([0, placeInLineRange[1]]); - } - }, - [maxPlace, placeInLineRange], - ); - + // Max place in line input handler const handleMaxPlaceInputChange = useCallback( (e: React.ChangeEvent) => { const cleanValue = e.target.value.replace(/,/g, ""); const value = Number.parseInt(cleanValue); - if (!Number.isNaN(value) && value >= 0 && value <= maxPlace) { - setPlaceInLineRange([placeInLineRange[0], value]); + if (!Number.isNaN(value) && value > 0 && value <= maxPlace) { + setMaxPlaceInLine(value); } else if (cleanValue === "") { - setPlaceInLineRange([placeInLineRange[0], maxPlace]); + setMaxPlaceInLine(undefined); } }, - [maxPlace, placeInLineRange], + [maxPlace], ); /** @@ -300,36 +290,39 @@ export default function FillListing() { idHex: listing.id, })); - // Calculate place in line boundaries for filtering - const minPlaceIndex = harvestableIndex.add(TokenValue.fromHuman(placeInLineRange[0], PODS.decimals)); - const maxPlaceIndex = harvestableIndex.add(TokenValue.fromHuman(placeInLineRange[1], PODS.decimals)); + // Calculate place in line boundary for filtering + const maxPlaceIndex = maxPlaceInLine + ? harvestableIndex.add(TokenValue.fromHuman(maxPlaceInLine.toString(), PODS.decimals)) + : undefined; // Determine eligible listings (shown as green on graph) - // When maxPricePerPod is 0, no listings are eligible (all show as orange) - // When maxPricePerPod > 0, filter by both price and place in line + // When maxPricePerPod is 0 OR maxPlaceInLine is not set, no listings are eligible (all show as orange) + // When both maxPricePerPod > 0 AND maxPlaceInLine is set, filter by both price and place in line const eligible: string[] = - maxPricePerPod > 0 + maxPricePerPod > 0 && maxPlaceIndex ? allListings.podListings .filter((listing) => { const listingPrice = TokenValue.fromBlockchain(listing.pricePerPod, mainToken.decimals).toNumber(); const listingIndex = TokenValue.fromBlockchain(listing.index, PODS.decimals); // Listing must match both criteria to be eligible - return ( - listingPrice <= maxPricePerPod && listingIndex.gte(minPlaceIndex) && listingIndex.lte(maxPlaceIndex) - ); + const matchesPrice = listingPrice <= maxPricePerPod; + const matchesPlace = listingIndex.lte(maxPlaceIndex); + return matchesPrice && matchesPlace; }) .map((listing) => listing.id) : []; // Calculate range overlay for visual feedback on graph - const overlay = { - start: harvestableIndex.add(TokenValue.fromHuman(placeInLineRange[0], PODS.decimals)), - end: harvestableIndex.add(TokenValue.fromHuman(placeInLineRange[1], PODS.decimals)), - }; + const overlay = maxPlaceInLine + ? { + start: harvestableIndex, + end: harvestableIndex.add(TokenValue.fromHuman(maxPlaceInLine.toString(), PODS.decimals)), + } + : undefined; return { listingPlots: plots, eligibleListingIds: eligible, rangeOverlay: overlay }; - }, [allListings, maxPricePerPod, placeInLineRange, mainToken.decimals, harvestableIndex]); + }, [allListings, maxPricePerPod, maxPlaceInLine, mainToken.decimals, harvestableIndex]); // Calculate open available pods count (eligible listings only - already filtered by price AND place) const openAvailablePods = useMemo(() => { @@ -679,135 +672,131 @@ export default function FillListing() { )}
- {/* Place in Line Range Selector */} - {maxPlace > 0 && ( -
-

At a Place in Line between:

- {/* Slider row */} -
-

0

- -

- {formatter.noDec(maxPlace)} -

-
- {/* Input row */} -
- e.target.select()} - placeholder="0" - outlined - containerClassName="flex-1" - className="" - /> - — + {/* Place in Line Slider */} +
+

I want to fill listings with a Place in Line up to:

+ {maxPlace === 0 ? ( +

No Pods in Line currently available to fill.

+ ) : ( +
+
+

0

+ {maxPlace > 0 && ( + + )} +

{formatter.noDec(maxPlace)}

+
e.target.select()} placeholder={formatter.noDec(maxPlace)} outlined - containerClassName="flex-1" + containerClassName="w-[108px]" className="" + disabled={maxPlace === 0} />
-
- )} - - {/* Open Available Pods Display */} -
-

- Open available pods: {formatter.noDec(openAvailablePods)} Pods -

+ )}
- {/* Fill Using Section - Only show if there are eligible listings */} - {eligibleListingIds.length > 0 && ( + {/* Show these sections only when maxPlaceInLine is greater than 0 */} + {maxPlaceInLine !== undefined && maxPlaceInLine > 0 && (
-
-
-

Fill Using

- -
- - {!isUsingMain && amountInTV.gt(0) && ( - - )} - {slippageWarning} + {/* Open Available Pods Display */} +
+

+ Open available pods: {formatter.noDec(openAvailablePods)} Pods +

-
- - {disabled && Number(amountIn) > 0 && ( -
- + + {/* Fill Using Section - Only show if there are eligible listings */} + {eligibleListingIds.length > 0 && ( + <> +
+
+

Fill Using

+ +
+ + {!isUsingMain && amountInTV.gt(0) && ( + + )} + {slippageWarning}
- )} - {!disabled && eligibleSummary && mainTokensIn && ( - - )} -
- - -
-
+
+ + {disabled && Number(amountIn) > 0 && ( +
+ +
+ )} + {!disabled && eligibleSummary && mainTokensIn && ( + + )} +
+ + +
+
+ + )}
)} From aeacec2200a90c203f8a74d22fd8d34704672b31 Mon Sep 17 00:00:00 2001 From: feyyazcigim Date: Thu, 13 Nov 2025 21:17:22 +0300 Subject: [PATCH 45/50] Add overlay to MarketChart --- src/components/MarketChartOverlay.tsx | 278 ++++++++++++++++++++++++++ 1 file changed, 278 insertions(+) create mode 100644 src/components/MarketChartOverlay.tsx diff --git a/src/components/MarketChartOverlay.tsx b/src/components/MarketChartOverlay.tsx new file mode 100644 index 000000000..1f6b2a0c9 --- /dev/null +++ b/src/components/MarketChartOverlay.tsx @@ -0,0 +1,278 @@ +import { TokenValue } from "@/classes/TokenValue"; +import { Chart } from "chart.js"; +import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"; + +const MILLION = 1_000_000; + +const OVERLAY_COLORS = { + shadedRegion: "rgba(64, 176, 166, 0.15)", // Teal with 15% opacity + border: "rgba(64, 176, 166, 0.8)", // Teal with 80% opacity +}; + +interface MarketChartOverlayProps { + pricePerPod: number | null; + maxPlaceInLine: number | null; + chartRef: React.RefObject; + visible: boolean; + harvestableIndex: TokenValue; +} + +type ChartDimensions = { + left: number; + top: number; + width: number; + height: number; + bottom: number; + right: number; +}; + +const MarketChartOverlay = React.memo( + ({ pricePerPod, maxPlaceInLine, chartRef, visible, harvestableIndex }) => { + const [dimensions, setDimensions] = useState(null); + const containerRef = useRef(null); + + // Calculate pixel position from data value using chart scales + const calculatePixelPosition = useCallback((dataValue: number, axis: "x" | "y"): number | null => { + // Handle null chart ref gracefully + if (!chartRef.current) return null; + + // Handle uninitialized chart scales + const scale = chartRef.current.scales?.[axis]; + if (!scale) return null; + + // Verify scale has required methods and properties + if (typeof scale.getPixelForValue !== 'function') return null; + if (scale.min === undefined || scale.max === undefined) return null; + + // Clamp data value to scale bounds to prevent out-of-range rendering + const clampedValue = Math.max(scale.min, Math.min(dataValue, scale.max)); + + try { + return scale.getPixelForValue(clampedValue); + } catch (error) { + // Handle any errors during pixel calculation + console.warn(`Error calculating pixel position for ${axis}-axis:`, error); + return null; + } + }, [chartRef]); + + // Get chart dimensions from chart instance + const getChartDimensions = useCallback((): ChartDimensions | null => { + // Handle null chart ref gracefully + if (!chartRef.current) return null; + + // Handle uninitialized chart area + const chartArea = chartRef.current.chartArea; + if (!chartArea) return null; + + // Validate all required properties exist + if ( + typeof chartArea.left !== 'number' || + typeof chartArea.top !== 'number' || + typeof chartArea.right !== 'number' || + typeof chartArea.bottom !== 'number' + ) { + return null; + } + + // Validate dimensions are positive and sensible + const width = chartArea.right - chartArea.left; + const height = chartArea.bottom - chartArea.top; + + if (width <= 0 || height <= 0) { + return null; + } + + return { + left: chartArea.left, + top: chartArea.top, + width, + height, + bottom: chartArea.bottom, + right: chartArea.right, + }; + }, [chartRef]); + + // Update dimensions when chart changes or resizes + useEffect(() => { + const updateDimensions = () => { + try { + const newDimensions = getChartDimensions(); + if (newDimensions) { + setDimensions(newDimensions); + } + } catch (error) { + // Handle errors during dimension calculation + console.warn('Error updating chart dimensions:', error); + } + }; + + // Initial dimensions with slight delay to ensure chart is ready + const initialTimeout = setTimeout(updateDimensions, 0); + + // Set up ResizeObserver with debouncing for parent element + let resizeTimeoutId: NodeJS.Timeout; + let resizeObserver: ResizeObserver | null = null; + + try { + resizeObserver = new ResizeObserver(() => { + clearTimeout(resizeTimeoutId); + resizeTimeoutId = setTimeout(updateDimensions, 100); + }); + + if (containerRef.current?.parentElement) { + resizeObserver.observe(containerRef.current.parentElement); + } + } catch (error) { + console.warn('Error setting up ResizeObserver:', error); + } + + // Also listen to window resize events + let windowResizeTimeoutId: NodeJS.Timeout; + const handleWindowResize = () => { + clearTimeout(windowResizeTimeoutId); + windowResizeTimeoutId = setTimeout(updateDimensions, 100); + }; + + window.addEventListener("resize", handleWindowResize); + + return () => { + clearTimeout(initialTimeout); + clearTimeout(resizeTimeoutId); + clearTimeout(windowResizeTimeoutId); + if (resizeObserver) { + resizeObserver.disconnect(); + } + window.removeEventListener("resize", handleWindowResize); + }; + }, [getChartDimensions]); + + // Calculate pixel coordinates for overlay elements + const overlayCoordinates = useMemo(() => { + // Early return for missing required data + if (!visible || !pricePerPod || !maxPlaceInLine || !dimensions) { + return null; + } + + // Validate input values are finite numbers + if (!Number.isFinite(pricePerPod) || !Number.isFinite(maxPlaceInLine)) { + return null; + } + + // Handle null chart ref gracefully + if (!chartRef.current) { + return null; + } + + // Check if chart has valid scales + const xScale = chartRef.current.scales?.x; + const yScale = chartRef.current.scales?.y; + + if (!xScale || !yScale) { + return null; + } + + // Handle uninitialized chart scales + if (xScale.max === undefined || xScale.max === 0 || yScale.max === undefined) { + return null; + } + + // Convert maxPlaceInLine to chart x-axis value (millions) + const placeInLineChartX = maxPlaceInLine / MILLION; + + // Validate converted value is reasonable + if (!Number.isFinite(placeInLineChartX) || placeInLineChartX < 0) { + return null; + } + + // Get pixel positions (already clamped in calculatePixelPosition) + const priceY = calculatePixelPosition(pricePerPod, "y"); + const placeX = calculatePixelPosition(placeInLineChartX, "x"); + + if (priceY === null || placeX === null) { + return null; + } + + // Additional clamping to chart boundaries to prevent overflow + const clampedPlaceX = Math.max(dimensions.left, Math.min(placeX, dimensions.right)); + const clampedPriceY = Math.max(dimensions.top, Math.min(priceY, dimensions.bottom)); + + // Validate final coordinates are within bounds + if ( + !Number.isFinite(clampedPlaceX) || + !Number.isFinite(clampedPriceY) || + clampedPlaceX < dimensions.left || + clampedPlaceX > dimensions.right || + clampedPriceY < dimensions.top || + clampedPriceY > dimensions.bottom + ) { + return null; + } + + return { + priceY: clampedPriceY, + placeX: clampedPlaceX, + }; + }, [visible, pricePerPod, maxPlaceInLine, dimensions, calculatePixelPosition, chartRef]); + + // Don't render if not visible or no valid coordinates + if (!visible || !overlayCoordinates || !dimensions) { + return null; + } + + const { priceY, placeX } = overlayCoordinates; + + // Additional validation: ensure coordinates are within chart bounds + // This is a safety check since coordinates should already be clamped + if ( + !Number.isFinite(placeX) || + !Number.isFinite(priceY) || + placeX < dimensions.left || + placeX > dimensions.right || + priceY < dimensions.top || + priceY > dimensions.bottom + ) { + return null; + } + + // Calculate dimensions for the shaded region + const rectWidth = placeX - dimensions.left; + const rectHeight = dimensions.bottom - priceY; + + // Don't render if dimensions are invalid or too small + if (!Number.isFinite(rectWidth) || !Number.isFinite(rectHeight) || rectWidth <= 0 || rectHeight <= 0) { + return null; + } + + return ( +
+ + {/* Shaded region - from top-left to the intersection point */} + + +
+ ); + }, +); + +MarketChartOverlay.displayName = "MarketChartOverlay"; + +export default MarketChartOverlay; From 8ff7eda7c639aa4792603f956d6c8284c898f312 Mon Sep 17 00:00:00 2001 From: feyyazcigim Date: Thu, 13 Nov 2025 22:43:52 +0300 Subject: [PATCH 46/50] Listing overlay --- src/components/MarketChartOverlay.tsx | 509 +++++++++++++-------- src/components/charts/ScatterChart.tsx | 68 +-- src/pages/Market.tsx | 147 +++--- src/pages/market/actions/CreateListing.tsx | 48 +- src/pages/market/actions/CreateOrder.tsx | 43 +- src/pages/market/actions/FillListing.tsx | 43 +- 6 files changed, 586 insertions(+), 272 deletions(-) diff --git a/src/components/MarketChartOverlay.tsx b/src/components/MarketChartOverlay.tsx index 1f6b2a0c9..96edb8480 100644 --- a/src/components/MarketChartOverlay.tsx +++ b/src/components/MarketChartOverlay.tsx @@ -2,16 +2,53 @@ import { TokenValue } from "@/classes/TokenValue"; import { Chart } from "chart.js"; import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"; +// Performance constants - hoisted outside component const MILLION = 1_000_000; - -const OVERLAY_COLORS = { +const RESIZE_DEBOUNCE_MS = 100; +const DIMENSION_UPDATE_DELAY_MS = 0; +const BOX_SIZE = 12; +const HALF_BOX_SIZE = 6; // Pre-calculated for performance +const BORDER_WIDTH = 1; + +// Frozen color constants for immutability and optimization +const BUY_OVERLAY_COLORS = Object.freeze({ shadedRegion: "rgba(64, 176, 166, 0.15)", // Teal with 15% opacity border: "rgba(64, 176, 166, 0.8)", // Teal with 80% opacity -}; +}); + +const SELL_OVERLAY_COLORS = Object.freeze({ + plotBorder: "#ED7A00", + plotFill: "#e0b57d", + lineColor: "black", +}); + +// Tailwind class strings for reuse +const BASE_OVERLAY_CLASSES = "absolute pointer-events-none"; +const TRANSITION_CLASSES = "transition-all duration-150 ease-out"; +const LINE_TRANSITION_CLASSES = "transition-[top,opacity] duration-150 ease-out"; + +// Overlay parameter types +export interface PlotOverlayData { + startIndex: TokenValue; // Absolute pod index + amount: TokenValue; // Number of pods in this plot +} + +interface BuyOverlayParams { + mode: "buy"; + pricePerPod: number; + maxPlaceInLine: number; +} + +interface SellOverlayParams { + mode: "sell"; + pricePerPod: number; + plots: PlotOverlayData[]; +} + +export type OverlayParams = BuyOverlayParams | SellOverlayParams | null; interface MarketChartOverlayProps { - pricePerPod: number | null; - maxPlaceInLine: number | null; + overlayParams: OverlayParams; chartRef: React.RefObject; visible: boolean; harvestableIndex: TokenValue; @@ -26,248 +63,350 @@ type ChartDimensions = { right: number; }; +interface PlotRectangle { + x: number; // Left edge pixel position + y: number; // Top edge pixel position (price line) + width: number; // Width in pixels (based on plot amount) + height: number; // Height in pixels (from price to bottom) +} + const MarketChartOverlay = React.memo( - ({ pricePerPod, maxPlaceInLine, chartRef, visible, harvestableIndex }) => { + ({ overlayParams, chartRef, visible, harvestableIndex }) => { const [dimensions, setDimensions] = useState(null); const containerRef = useRef(null); - // Calculate pixel position from data value using chart scales + // Optimized pixel position calculator with minimal validation overhead const calculatePixelPosition = useCallback((dataValue: number, axis: "x" | "y"): number | null => { - // Handle null chart ref gracefully - if (!chartRef.current) return null; + const chart = chartRef.current; + if (!chart?.scales) return null; - // Handle uninitialized chart scales - const scale = chartRef.current.scales?.[axis]; - if (!scale) return null; - - // Verify scale has required methods and properties - if (typeof scale.getPixelForValue !== 'function') return null; - if (scale.min === undefined || scale.max === undefined) return null; + const scale = chart.scales[axis]; + if (!scale?.getPixelForValue || scale.min === undefined || scale.max === undefined) { + return null; + } - // Clamp data value to scale bounds to prevent out-of-range rendering - const clampedValue = Math.max(scale.min, Math.min(dataValue, scale.max)); + // Fast clamp without Math.max/min for better performance + const clampedValue = dataValue < scale.min ? scale.min : dataValue > scale.max ? scale.max : dataValue; try { return scale.getPixelForValue(clampedValue); - } catch (error) { - // Handle any errors during pixel calculation - console.warn(`Error calculating pixel position for ${axis}-axis:`, error); + } catch { return null; } }, [chartRef]); - // Get chart dimensions from chart instance + // Optimized dimension calculator with minimal object creation const getChartDimensions = useCallback((): ChartDimensions | null => { - // Handle null chart ref gracefully - if (!chartRef.current) return null; - - // Handle uninitialized chart area - const chartArea = chartRef.current.chartArea; - if (!chartArea) return null; - - // Validate all required properties exist - if ( - typeof chartArea.left !== 'number' || - typeof chartArea.top !== 'number' || - typeof chartArea.right !== 'number' || - typeof chartArea.bottom !== 'number' - ) { - return null; - } + const chart = chartRef.current; + if (!chart?.chartArea) return null; - // Validate dimensions are positive and sensible - const width = chartArea.right - chartArea.left; - const height = chartArea.bottom - chartArea.top; + const { left, top, right, bottom } = chart.chartArea; - if (width <= 0 || height <= 0) { + // Fast type validation + if (typeof left !== 'number' || typeof top !== 'number' || + typeof right !== 'number' || typeof bottom !== 'number') { return null; } - return { - left: chartArea.left, - top: chartArea.top, - width, - height, - bottom: chartArea.bottom, - right: chartArea.right, - }; + const width = right - left; + const height = bottom - top; + + if (width <= 0 || height <= 0) return null; + + return { left, top, width, height, bottom, right }; }, [chartRef]); - // Update dimensions when chart changes or resizes + // Optimized resize handling with single debounced handler useEffect(() => { - const updateDimensions = () => { - try { + let timeoutId: NodeJS.Timeout; + let resizeObserver: ResizeObserver | null = null; + + const debouncedUpdate = () => { + clearTimeout(timeoutId); + timeoutId = setTimeout(() => { const newDimensions = getChartDimensions(); if (newDimensions) { setDimensions(newDimensions); } - } catch (error) { - // Handle errors during dimension calculation - console.warn('Error updating chart dimensions:', error); - } + }, RESIZE_DEBOUNCE_MS); }; - // Initial dimensions with slight delay to ensure chart is ready - const initialTimeout = setTimeout(updateDimensions, 0); - - // Set up ResizeObserver with debouncing for parent element - let resizeTimeoutId: NodeJS.Timeout; - let resizeObserver: ResizeObserver | null = null; - - try { - resizeObserver = new ResizeObserver(() => { - clearTimeout(resizeTimeoutId); - resizeTimeoutId = setTimeout(updateDimensions, 100); - }); - - if (containerRef.current?.parentElement) { - resizeObserver.observe(containerRef.current.parentElement); + // Initial update + const initialTimeout = setTimeout(() => { + const dimensions = getChartDimensions(); + if (dimensions) setDimensions(dimensions); + }, DIMENSION_UPDATE_DELAY_MS); + + // Single ResizeObserver for all resize events + if (typeof ResizeObserver !== 'undefined') { + resizeObserver = new ResizeObserver(debouncedUpdate); + const parent = containerRef.current?.parentElement; + if (parent) { + resizeObserver.observe(parent); } - } catch (error) { - console.warn('Error setting up ResizeObserver:', error); } - // Also listen to window resize events - let windowResizeTimeoutId: NodeJS.Timeout; - const handleWindowResize = () => { - clearTimeout(windowResizeTimeoutId); - windowResizeTimeoutId = setTimeout(updateDimensions, 100); - }; - - window.addEventListener("resize", handleWindowResize); + // Fallback window resize listener with passive flag for better performance + window.addEventListener("resize", debouncedUpdate, { passive: true }); return () => { clearTimeout(initialTimeout); - clearTimeout(resizeTimeoutId); - clearTimeout(windowResizeTimeoutId); - if (resizeObserver) { - resizeObserver.disconnect(); - } - window.removeEventListener("resize", handleWindowResize); + clearTimeout(timeoutId); + resizeObserver?.disconnect(); + window.removeEventListener("resize", debouncedUpdate); }; }, [getChartDimensions]); - // Calculate pixel coordinates for overlay elements - const overlayCoordinates = useMemo(() => { - // Early return for missing required data - if (!visible || !pricePerPod || !maxPlaceInLine || !dimensions) { - return null; - } + // Optimized buy overlay renderer with minimal validation + const renderBuyOverlay = useCallback( + (params: BuyOverlayParams) => { + const { pricePerPod, maxPlaceInLine } = params; + + // Fast early returns + if (!dimensions || !chartRef.current?.scales) return null; + + const { scales } = chartRef.current; + const { x: xScale, y: yScale } = scales; + + if (!xScale?.max || xScale.max === 0 || !yScale?.max) return null; + + // Optimized conversion and validation + const placeInLineChartX = maxPlaceInLine / MILLION; + if (placeInLineChartX < 0) return null; + + // Batch pixel position calculations + const priceY = calculatePixelPosition(pricePerPod, "y"); + const placeX = calculatePixelPosition(placeInLineChartX, "x"); + + if (priceY === null || placeX === null) return null; + + // Fast clamping with ternary operators + const { left, right, top, bottom } = dimensions; + const clampedPlaceX = placeX < left ? left : placeX > right ? right : placeX; + const clampedPriceY = priceY < top ? top : priceY > bottom ? bottom : priceY; + + // Calculate dimensions + const rectWidth = clampedPlaceX - left; + const rectHeight = bottom - clampedPriceY; + + // Single validation check + if (rectWidth <= 0 || rectHeight <= 0) return null; + + return ( + + + + ); + }, + [dimensions, calculatePixelPosition], + ); - // Validate input values are finite numbers - if (!Number.isFinite(pricePerPod) || !Number.isFinite(maxPlaceInLine)) { - return null; - } + // Highly optimized plot rectangle calculator + const calculatePlotRectangle = useCallback( + (plot: PlotOverlayData, pricePerPod: number): PlotRectangle | null => { + // Early returns for performance + if (!dimensions || !chartRef.current?.scales || !plot.startIndex || !plot.amount) { + return null; + } - // Handle null chart ref gracefully - if (!chartRef.current) { - return null; - } + const { scales } = chartRef.current; + const { x: xScale, y: yScale } = scales; + + if (!xScale?.max || !yScale?.max) return null; + + // Optimized place in line calculation - avoid intermediate TokenValue object + const placeInLineNum = plot.startIndex.toNumber() - harvestableIndex.toNumber(); + if (placeInLineNum < 0) return null; + + // Batch calculations for better performance + const startX = placeInLineNum / MILLION; + const endX = (placeInLineNum + plot.amount.toNumber()) / MILLION; + + // Fast validation + if (startX < 0 || endX <= startX) return null; + + // Batch pixel position calculations + const startPixelX = calculatePixelPosition(startX, "x"); + const endPixelX = calculatePixelPosition(endX, "x"); + const pricePixelY = calculatePixelPosition(pricePerPod, "y"); + + if (startPixelX === null || endPixelX === null || pricePixelY === null) { + return null; + } - // Check if chart has valid scales - const xScale = chartRef.current.scales?.x; - const yScale = chartRef.current.scales?.y; - - if (!xScale || !yScale) { - return null; - } + // Optimized clamping with ternary operators + const { left, right, top, bottom } = dimensions; + const clampedStartX = startPixelX < left ? left : startPixelX > right ? right : startPixelX; + const clampedEndX = endPixelX < left ? left : endPixelX > right ? right : endPixelX; + const clampedPriceY = pricePixelY < top ? top : pricePixelY > bottom ? bottom : pricePixelY; - // Handle uninitialized chart scales - if (xScale.max === undefined || xScale.max === 0 || yScale.max === undefined) { - return null; - } + const width = clampedEndX - clampedStartX; + const height = bottom - clampedPriceY; - // Convert maxPlaceInLine to chart x-axis value (millions) - const placeInLineChartX = maxPlaceInLine / MILLION; + // Single validation check + if (width <= 0 || height <= 0 || clampedStartX >= right || clampedEndX <= left) { + return null; + } - // Validate converted value is reasonable - if (!Number.isFinite(placeInLineChartX) || placeInLineChartX < 0) { - return null; - } + return { x: clampedStartX, y: clampedPriceY, width, height }; + }, + [dimensions, calculatePixelPosition, harvestableIndex], + ); - // Get pixel positions (already clamped in calculatePixelPosition) - const priceY = calculatePixelPosition(pricePerPod, "y"); - const placeX = calculatePixelPosition(placeInLineChartX, "x"); + // Highly optimized rectangle memoization with minimal object creation + const memoizedRectangles = useMemo(() => { + if (!overlayParams || overlayParams.mode !== "sell" || !dimensions) return null; - if (priceY === null || placeX === null) { - return null; - } + const { pricePerPod, plots } = overlayParams; - // Additional clamping to chart boundaries to prevent overflow - const clampedPlaceX = Math.max(dimensions.left, Math.min(placeX, dimensions.right)); - const clampedPriceY = Math.max(dimensions.top, Math.min(priceY, dimensions.bottom)); - - // Validate final coordinates are within bounds - if ( - !Number.isFinite(clampedPlaceX) || - !Number.isFinite(clampedPriceY) || - clampedPlaceX < dimensions.left || - clampedPlaceX > dimensions.right || - clampedPriceY < dimensions.top || - clampedPriceY > dimensions.bottom - ) { - return null; + // Fast validation + if (pricePerPod <= 0 || !plots?.length) return null; + + // Pre-allocate array for better performance + const rectangles: Array = []; + + // Use for loop for better performance than map/filter chain + for (let i = 0; i < plots.length; i++) { + const plot = plots[i]; + const rect = calculatePlotRectangle(plot, pricePerPod); + + if (rect) { + rectangles.push({ + x: rect.x, + y: rect.y, + width: rect.width, + height: rect.height, + plotKey: plot.startIndex.toHuman(), + plotIndex: i, + }); + } } - return { - priceY: clampedPriceY, - placeX: clampedPlaceX, - }; - }, [visible, pricePerPod, maxPlaceInLine, dimensions, calculatePixelPosition, chartRef]); + return rectangles.length > 0 ? rectangles : null; + }, [overlayParams, dimensions, calculatePlotRectangle]); + + // Highly optimized sell overlay renderer with pre-calculated values + const renderSellOverlay = useCallback( + (params: SellOverlayParams) => { + const { pricePerPod } = params; + + if (!dimensions || !memoizedRectangles) return null; + + // Calculate price line Y position + const pricePixelY = calculatePixelPosition(pricePerPod, "y"); + if (pricePixelY === null) return null; + + // Fast clamping + const { top, bottom, left } = dimensions; + const clampedPriceY = pricePixelY < top ? top : pricePixelY > bottom ? bottom : pricePixelY; + + // Pre-calculate common values to avoid repeated calculations + const lineWidth = dimensions.right - left; + const lineHeight = bottom - top; + const lastRectIndex = memoizedRectangles.length - 1; + + return ( + <> + {/* Horizontal price line - Tailwind + minimal inline styles */} +
+ + {/* Selection boxes - Tailwind + minimal inline styles */} + {memoizedRectangles.map((rect) => { + const centerX = rect.x + (rect.width >> 1); // Bit shift for division by 2 + const centerY = clampedPriceY; + + return ( +
+ ); + })} + + {/* Vertical lines - Tailwind + minimal inline styles */} + {memoizedRectangles.length > 0 && ( + <> +
+
+ + )} + + ); + }, + [dimensions, memoizedRectangles, calculatePixelPosition], + ); - // Don't render if not visible or no valid coordinates - if (!visible || !overlayCoordinates || !dimensions) { + // Don't render if not visible or no overlay params + if (!visible || !overlayParams || !dimensions) { return null; } - const { priceY, placeX } = overlayCoordinates; - - // Additional validation: ensure coordinates are within chart bounds - // This is a safety check since coordinates should already be clamped - if ( - !Number.isFinite(placeX) || - !Number.isFinite(priceY) || - placeX < dimensions.left || - placeX > dimensions.right || - priceY < dimensions.top || - priceY > dimensions.bottom - ) { - return null; + // Determine which overlay to render based on mode + let overlayContent: JSX.Element | null = null; + if (overlayParams.mode === "buy") { + overlayContent = renderBuyOverlay(overlayParams); + } else if (overlayParams.mode === "sell") { + overlayContent = renderSellOverlay(overlayParams); } - // Calculate dimensions for the shaded region - const rectWidth = placeX - dimensions.left; - const rectHeight = dimensions.bottom - priceY; - - // Don't render if dimensions are invalid or too small - if (!Number.isFinite(rectWidth) || !Number.isFinite(rectHeight) || rectWidth <= 0 || rectHeight <= 0) { + if (!overlayContent) { return null; } return (
- - {/* Shaded region - from top-left to the intersection point */} - - + {overlayContent}
); }, diff --git a/src/components/charts/ScatterChart.tsx b/src/components/charts/ScatterChart.tsx index 59c8f340a..5a6c03f87 100644 --- a/src/components/charts/ScatterChart.tsx +++ b/src/components/charts/ScatterChart.tsx @@ -74,23 +74,30 @@ export interface ScatterChartProps { } const ScatterChart = React.memo( - ({ - data, - size, - valueFormatter, - onMouseOver, - activeIndex, - useLogarithmicScale = false, - horizontalReferenceLines = [], - xOptions, - yOptions, - customValueTransform, - onPointClick, - toolTipOptions, - }: ScatterChartProps) => { - const chartRef = useRef(null); - const activeIndexRef = useRef(activeIndex); - const selectedPointRef = useRef<[number, number] | null>(null); + React.forwardRef( + ( + { + data, + size, + valueFormatter, + onMouseOver, + activeIndex, + useLogarithmicScale = false, + horizontalReferenceLines = [], + xOptions, + yOptions, + customValueTransform, + onPointClick, + toolTipOptions, + }, + ref, + ) => { + const chartRef = useRef(null); + const activeIndexRef = useRef(activeIndex); + const selectedPointRef = useRef<[number, number] | null>(null); + + // Expose chart instance through ref + React.useImperativeHandle(ref, () => chartRef.current as Chart, []); useEffect(() => { activeIndexRef.current = activeIndex; @@ -480,18 +487,19 @@ const ScatterChart = React.memo( }; } }, [size]); - return ( - - ); - }, + return ( + + ); + }, + ), areScatterChartPropsEqual, ); @@ -644,4 +652,6 @@ function areScatterChartPropsEqual(prevProps: ScatterChartProps, nextProps: Scat return true; } +ScatterChart.displayName = "ScatterChart"; + export default ScatterChart; diff --git a/src/pages/Market.tsx b/src/pages/Market.tsx index b1edb3669..8d36744ac 100644 --- a/src/pages/Market.tsx +++ b/src/pages/Market.tsx @@ -15,8 +15,9 @@ import { useHarvestableIndex, usePodLine } from "@/state/useFieldData"; import { trackSimpleEvent } from "@/utils/analytics"; import { ActiveElement, ChartEvent, PointStyle, TooltipOptions } from "chart.js"; import { Chart } from "chart.js"; -import { useCallback, useEffect, useMemo, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useNavigate, useParams } from "react-router-dom"; +import MarketChartOverlay, { type OverlayParams } from "@/components/MarketChartOverlay"; import { AllActivityTable } from "./market/AllActivityTable"; import { FarmerActivityTable } from "./market/FarmerActivityTable"; import MarketModeSelect from "./market/MarketModeSelect"; @@ -161,71 +162,91 @@ export function Market() { const navigate = useNavigate(); const { data, isLoaded } = useAllMarket(); const podLine = usePodLine(); - const podLineAsNumber = podLine.toNumber() / MILLION; const harvestableIndex = useHarvestableIndex(); + const podLineAsNumber = podLine.toNumber() / MILLION; const navHeight = useNavHeight(); + + // Overlay state and chart ref + const chartRef = useRef(null); + const [overlayParams, setOverlayParams] = useState(null); + + // Memoized callback to update overlay params + const handleOverlayParamsChange = useCallback((params: OverlayParams) => { + setOverlayParams(params); + }, []); const scatterChartData: MarketScatterChartData[] = useMemo( () => shapeScatterChartData(data || [], harvestableIndex), [data, harvestableIndex], ); - const toolTipOptions: Partial = { - enabled: false, - external: (context) => { - const tooltipEl = document.getElementById("chartjs-tooltip"); - - // Create element on first render - if (!tooltipEl) { - const div = document.createElement("div"); - div.id = "chartjs-tooltip"; - div.style.background = "rgba(0, 0, 0, 0.7)"; - div.style.borderRadius = "3px"; - div.style.color = "white"; - div.style.opacity = "1"; - div.style.pointerEvents = "none"; - div.style.position = "absolute"; - div.style.transform = "translate(25px)"; // Position to right of point - div.style.transition = "all .1s ease"; - document.body.appendChild(div); - } else { - // Hide if no tooltip - if (context.tooltip.opacity === 0) { - tooltipEl.style.opacity = "0"; - return; - } - - // Set Text - if (context.tooltip.body) { - const position = context.tooltip.dataPoints[0].element.getProps(["x", "y"], true); - const dataPoint = context.tooltip.dataPoints[0].raw as MarketScatterChartDataPoint; - tooltipEl.style.opacity = "1"; - tooltipEl.style.width = "250px"; - tooltipEl.style.backgroundColor = "white"; - tooltipEl.style.color = "black"; - tooltipEl.style.borderRadius = "10px"; - tooltipEl.style.border = "1px solid #D9D9D9"; - tooltipEl.style.zIndex = String(TOOLTIP_Z_INDEX); - // Basically all of this is custom logic for 3 different breakpoints to either display the tooltip to the top right or bottom right of the point. - const topOfPoint = position.y + getPointTopOffset(); - const bottomOfPoint = position.y + getPointBottomOffset(); - tooltipEl.style.top = dataPoint.y > 0.8 ? bottomOfPoint : topOfPoint + "px"; // Position relative to point y - // end custom logic - tooltipEl.style.left = position.x + "px"; // Position relative to point x - tooltipEl.style.padding = context.tooltip.options.padding + "px " + context.tooltip.options.padding + "px"; - const listingHeader = ` + // Calculate chart x-axis max value - use podLineAsNumber with a minimum value + // Don't depend on overlayParams to avoid re-rendering chart on every slider change + const chartXMax = useMemo(() => { + // Use podLineAsNumber if available, otherwise use a reasonable default + const maxValue = podLineAsNumber > 0 ? podLineAsNumber : 50; // Default to 50 million + + // Ensure a minimum value for the chart to render properly + return Math.max(maxValue, 1); // At least 1 million + }, [podLineAsNumber]); + + const toolTipOptions: Partial = useMemo( + () => ({ + enabled: false, + external: (context) => { + const tooltipEl = document.getElementById("chartjs-tooltip"); + + // Create element on first render + if (!tooltipEl) { + const div = document.createElement("div"); + div.id = "chartjs-tooltip"; + div.style.background = "rgba(0, 0, 0, 0.7)"; + div.style.borderRadius = "3px"; + div.style.color = "white"; + div.style.opacity = "1"; + div.style.pointerEvents = "none"; + div.style.position = "absolute"; + div.style.transform = "translate(25px)"; // Position to right of point + div.style.transition = "all .1s ease"; + document.body.appendChild(div); + } else { + // Hide if no tooltip + if (context.tooltip.opacity === 0) { + tooltipEl.style.opacity = "0"; + return; + } + + // Set Text + if (context.tooltip.body) { + const position = context.tooltip.dataPoints[0].element.getProps(["x", "y"], true); + const dataPoint = context.tooltip.dataPoints[0].raw as MarketScatterChartDataPoint; + tooltipEl.style.opacity = "1"; + tooltipEl.style.width = "250px"; + tooltipEl.style.backgroundColor = "white"; + tooltipEl.style.color = "black"; + tooltipEl.style.borderRadius = "10px"; + tooltipEl.style.border = "1px solid #D9D9D9"; + tooltipEl.style.zIndex = String(TOOLTIP_Z_INDEX); + // Basically all of this is custom logic for 3 different breakpoints to either display the tooltip to the top right or bottom right of the point. + const topOfPoint = position.y + getPointTopOffset(); + const bottomOfPoint = position.y + getPointBottomOffset(); + tooltipEl.style.top = dataPoint.y > 0.8 ? bottomOfPoint : topOfPoint + "px"; // Position relative to point y + // end custom logic + tooltipEl.style.left = position.x + "px"; // Position relative to point x + tooltipEl.style.padding = context.tooltip.options.padding + "px " + context.tooltip.options.padding + "px"; + const listingHeader = `
pod icon ${TokenValue.fromHuman(dataPoint.amount, 0).toHuman("short")} Pods Listed
`; - const orderHeader = ` + const orderHeader = `
pod icon Order for ${TokenValue.fromHuman(dataPoint.amount, 0).toHuman("short")} Pods
`; - tooltipEl.innerHTML = ` + tooltipEl.innerHTML = `
${dataPoint.eventType === "LISTING" ? listingHeader : orderHeader}
@@ -241,10 +262,12 @@ export function Market() {
`; + } } - } - }, - }; + }, + }), + [], + ); // Upon initial page load only, navigate to a page other than Activity if the url is granular. // In general it is allowed to be on Activity tab with these granular urls, hence the empty dependency array. @@ -377,12 +400,22 @@ export function Market() {
)} +
@@ -414,9 +447,13 @@ export function Market() {
- {viewMode === "buy" && id === "create" && } - {viewMode === "buy" && id === "fill" && } - {viewMode === "sell" && id === "create" && } + {viewMode === "buy" && id === "create" && ( + + )} + {viewMode === "buy" && id === "fill" && } + {viewMode === "sell" && id === "create" && ( + + )} {viewMode === "sell" && id === "fill" && }
diff --git a/src/pages/market/actions/CreateListing.tsx b/src/pages/market/actions/CreateListing.tsx index 560fee83a..3dda78f59 100644 --- a/src/pages/market/actions/CreateListing.tsx +++ b/src/pages/market/actions/CreateListing.tsx @@ -28,6 +28,7 @@ import { useLocation, useNavigate } from "react-router-dom"; import { toast } from "sonner"; import { encodeFunctionData } from "viem"; import { useAccount } from "wagmi"; +import type { OverlayParams, PlotOverlayData } from "@/components/MarketChartOverlay"; interface PodListingData { plot: Plot; @@ -67,7 +68,11 @@ const removeTrailingZeros = (value: string): string => { return value.includes(".") ? value.replace(/\.?0+$/, "") : value; }; -export default function CreateListing() { +interface CreateListingProps { + onOverlayParamsChange?: (params: OverlayParams) => void; +} + +export default function CreateListing({ onOverlayParamsChange }: CreateListingProps) { const { address: account } = useAccount(); const diamondAddress = useProtocolAddress(); const mainToken = useTokenData().mainToken; @@ -455,6 +460,47 @@ export default function CreateListing() { writeWithEstimateGas, ]); + // Throttle overlay parameter updates for better performance + const overlayUpdateTimerRef = useRef(null); + + useEffect(() => { + // Clear any pending update + if (overlayUpdateTimerRef.current) { + clearTimeout(overlayUpdateTimerRef.current); + } + + // Throttle overlay updates to avoid performance issues during slider drag + overlayUpdateTimerRef.current = setTimeout(() => { + if (listingData.length > 0 && pricePerPod > 0) { + const plotOverlayData: PlotOverlayData[] = listingData.map((data) => ({ + startIndex: data.index, + amount: data.amount, + })); + + onOverlayParamsChange?.({ + mode: "sell", + pricePerPod, + plots: plotOverlayData, + }); + } else { + onOverlayParamsChange?.(null); + } + }, 16); // ~60fps (16ms) + + return () => { + if (overlayUpdateTimerRef.current) { + clearTimeout(overlayUpdateTimerRef.current); + } + }; + }, [listingData, pricePerPod, onOverlayParamsChange]); + + // Cleanup on unmount + useEffect(() => { + return () => { + onOverlayParamsChange?.(null); + }; + }, [onOverlayParamsChange]); + // ui state const disabled = !pricePerPod || !amount || !account || plot.length === 0 || selectedExpiresIn <= 0; diff --git a/src/pages/market/actions/CreateOrder.tsx b/src/pages/market/actions/CreateOrder.tsx index 0dda896d3..1ae79943b 100644 --- a/src/pages/market/actions/CreateOrder.tsx +++ b/src/pages/market/actions/CreateOrder.tsx @@ -37,6 +37,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useNavigate } from "react-router-dom"; import { toast } from "sonner"; import { useAccount } from "wagmi"; +import type { OverlayParams } from "@/components/MarketChartOverlay"; // Constants const PRICE_PER_POD_CONFIG = { @@ -85,7 +86,11 @@ const useFilterTokens = () => { }, [tokens, isWSOL]); }; -export default function CreateOrder() { +interface CreateOrderProps { + onOverlayParamsChange?: (params: OverlayParams) => void; +} + +export default function CreateOrder({ onOverlayParamsChange }: CreateOrderProps = {}) { const diamondAddress = useProtocolAddress(); const mainToken = useTokenData().mainToken; const { queryKeys: balanceQKs } = useFarmerBalances(); @@ -169,6 +174,42 @@ export default function CreateOrder() { } }, [preferredToken, preferredLoading, didSetPreferred]); + // Throttle overlay parameter updates for better performance + const overlayUpdateTimerRef = useRef(null); + + useEffect(() => { + // Clear any pending update + if (overlayUpdateTimerRef.current) { + clearTimeout(overlayUpdateTimerRef.current); + } + + // Throttle overlay updates to avoid performance issues during slider drag + overlayUpdateTimerRef.current = setTimeout(() => { + if (maxPlaceInLine && maxPlaceInLine > 0 && pricePerPod > 0) { + onOverlayParamsChange?.({ + mode: "buy", + pricePerPod, + maxPlaceInLine, + }); + } else { + onOverlayParamsChange?.(null); + } + }, 16); // ~60fps (16ms) + + return () => { + if (overlayUpdateTimerRef.current) { + clearTimeout(overlayUpdateTimerRef.current); + } + }; + }, [pricePerPod, maxPlaceInLine, onOverlayParamsChange]); + + // Cleanup overlay on unmount + useEffect(() => { + return () => { + onOverlayParamsChange?.(null); + }; + }, [onOverlayParamsChange]); + // Token selection handler with tracking const handleTokenSelection = useCallback( (newToken: Token) => { diff --git a/src/pages/market/actions/FillListing.tsx b/src/pages/market/actions/FillListing.tsx index ec83eb1db..df47868f1 100644 --- a/src/pages/market/actions/FillListing.tsx +++ b/src/pages/market/actions/FillListing.tsx @@ -43,6 +43,7 @@ import { useNavigate, useSearchParams } from "react-router-dom"; import { toast } from "sonner"; import { Address, encodeFunctionData } from "viem"; import { useAccount } from "wagmi"; +import type { OverlayParams } from "@/components/MarketChartOverlay"; // Configuration constants const PRICE_PER_POD_CONFIG = { @@ -81,7 +82,11 @@ const useFilterTokens = () => { }, [tokens, isWSOL]); }; -export default function FillListing() { +interface FillListingProps { + onOverlayParamsChange?: (params: OverlayParams) => void; +} + +export default function FillListing({ onOverlayParamsChange }: FillListingProps = {}) { const mainToken = useTokenData().mainToken; const diamondAddress = useProtocolAddress(); const account = useAccount(); @@ -200,6 +205,42 @@ export default function FillListing() { setHasInitializedPlace(true); // Mark as initialized to prevent default value override }, [listingId, allListings, maxPlace, mainToken.decimals, harvestableIndex]); + // Update overlay parameters when maxPricePerPod or maxPlaceInLine changes + const overlayUpdateTimerRef = useRef(null); + + useEffect(() => { + // Clear any pending update + if (overlayUpdateTimerRef.current) { + clearTimeout(overlayUpdateTimerRef.current); + } + + // Throttle overlay updates to avoid performance issues during slider drag + overlayUpdateTimerRef.current = setTimeout(() => { + if (maxPlaceInLine && maxPlaceInLine > 0 && maxPricePerPod) { + onOverlayParamsChange?.({ + pricePerPod: maxPricePerPod, + maxPlaceInLine, + mode: "buy", + }); + } else { + onOverlayParamsChange?.(null); + } + }, 16); // ~60fps (16ms) + + return () => { + if (overlayUpdateTimerRef.current) { + clearTimeout(overlayUpdateTimerRef.current); + } + }; + }, [maxPricePerPod, maxPlaceInLine, onOverlayParamsChange]); + + // Cleanup overlay on unmount + useEffect(() => { + return () => { + onOverlayParamsChange?.(null); + }; + }, [onOverlayParamsChange]); + // Token selection handler with tracking const handleTokenSelection = useCallback( (newToken: Token) => { From 0e9d9e2d06793e8c0161101174b8003431d837eb Mon Sep 17 00:00:00 2001 From: feyyazcigim Date: Fri, 14 Nov 2025 20:25:47 +0300 Subject: [PATCH 47/50] Implement pod score and color feature --- src/components/MarketChartOverlay.tsx | 113 ++++++++--- src/components/PodScoreGradientLegend.tsx | 65 +++++++ src/components/charts/ScatterChart.tsx | 9 +- src/pages/Market.tsx | 78 +++++++- src/pages/market/actions/CreateListing.tsx | 34 ++++ src/pages/market/actions/FillListing.tsx | 60 ++++++ src/utils/podScore.ts | 39 ++++ src/utils/podScoreColorScaler.ts | 208 +++++++++++++++++++++ 8 files changed, 574 insertions(+), 32 deletions(-) create mode 100644 src/components/PodScoreGradientLegend.tsx create mode 100644 src/utils/podScore.ts create mode 100644 src/utils/podScoreColorScaler.ts diff --git a/src/components/MarketChartOverlay.tsx b/src/components/MarketChartOverlay.tsx index 96edb8480..2d6f49a2b 100644 --- a/src/components/MarketChartOverlay.tsx +++ b/src/components/MarketChartOverlay.tsx @@ -1,4 +1,6 @@ import { TokenValue } from "@/classes/TokenValue"; +import { buildPodScoreColorScaler } from "@/utils/podScoreColorScaler"; +import { calculatePodScore } from "@/utils/podScore"; import { Chart } from "chart.js"; import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"; @@ -12,8 +14,8 @@ const BORDER_WIDTH = 1; // Frozen color constants for immutability and optimization const BUY_OVERLAY_COLORS = Object.freeze({ - shadedRegion: "rgba(64, 176, 166, 0.15)", // Teal with 15% opacity - border: "rgba(64, 176, 166, 0.8)", // Teal with 80% opacity + shadedRegion: "rgba(92, 184, 169, 0.15)", // Teal with 15% opacity + border: "rgba(92, 184, 169, 0.8)", // Teal with 80% opacity }); const SELL_OVERLAY_COLORS = Object.freeze({ @@ -52,6 +54,7 @@ interface MarketChartOverlayProps { chartRef: React.RefObject; visible: boolean; harvestableIndex: TokenValue; + marketListingScores?: number[]; // Pod Scores from existing market listings for color scaling } type ChartDimensions = { @@ -71,9 +74,13 @@ interface PlotRectangle { } const MarketChartOverlay = React.memo( - ({ overlayParams, chartRef, visible, harvestableIndex }) => { + ({ overlayParams, chartRef, visible, harvestableIndex, marketListingScores = [] }) => { const [dimensions, setDimensions] = useState(null); const containerRef = useRef(null); + + // Note: Throttling removed to ensure immediate updates during price changes + // Performance is acceptable without throttling due to optimized calculations + const throttledOverlayParams = overlayParams; // Optimized pixel position calculator with minimal validation overhead const calculatePixelPosition = useCallback((dataValue: number, axis: "x" | "y"): number | null => { @@ -119,16 +126,27 @@ const MarketChartOverlay = React.memo( // Optimized resize handling with single debounced handler useEffect(() => { let timeoutId: NodeJS.Timeout; + let animationFrameId: number | null = null; let resizeObserver: ResizeObserver | null = null; - const debouncedUpdate = () => { - clearTimeout(timeoutId); - timeoutId = setTimeout(() => { + const updateDimensions = () => { + // Use requestAnimationFrame to sync with browser's repaint cycle + if (animationFrameId !== null) { + cancelAnimationFrame(animationFrameId); + } + + animationFrameId = requestAnimationFrame(() => { const newDimensions = getChartDimensions(); if (newDimensions) { setDimensions(newDimensions); } - }, RESIZE_DEBOUNCE_MS); + animationFrameId = null; + }); + }; + + const debouncedUpdate = () => { + clearTimeout(timeoutId); + timeoutId = setTimeout(updateDimensions, RESIZE_DEBOUNCE_MS); }; // Initial update @@ -144,18 +162,45 @@ const MarketChartOverlay = React.memo( if (parent) { resizeObserver.observe(parent); } + // Also observe the chart canvas itself for more accurate updates + const chart = chartRef.current; + if (chart?.canvas) { + resizeObserver.observe(chart.canvas); + } } // Fallback window resize listener with passive flag for better performance window.addEventListener("resize", debouncedUpdate, { passive: true }); + + // Listen to Chart.js resize events for immediate sync + const chart = chartRef.current; + if (chart) { + // Chart.js emits 'resize' event when chart dimensions change + chart.resize(); + // Force update after chart is ready + updateDimensions(); + } return () => { clearTimeout(initialTimeout); clearTimeout(timeoutId); + if (animationFrameId !== null) { + cancelAnimationFrame(animationFrameId); + } resizeObserver?.disconnect(); window.removeEventListener("resize", debouncedUpdate); }; - }, [getChartDimensions]); + }, [getChartDimensions, chartRef]); + + // Update dimensions when overlay params or visibility changes + useEffect(() => { + if (visible && throttledOverlayParams) { + const newDimensions = getChartDimensions(); + if (newDimensions) { + setDimensions(newDimensions); + } + } + }, [throttledOverlayParams, visible, getChartDimensions]); // Optimized buy overlay renderer with minimal validation const renderBuyOverlay = useCallback( @@ -269,15 +314,15 @@ const MarketChartOverlay = React.memo( // Highly optimized rectangle memoization with minimal object creation const memoizedRectangles = useMemo(() => { - if (!overlayParams || overlayParams.mode !== "sell" || !dimensions) return null; + if (!throttledOverlayParams || throttledOverlayParams.mode !== "sell" || !dimensions) return null; - const { pricePerPod, plots } = overlayParams; + const { pricePerPod, plots } = throttledOverlayParams; // Fast validation if (pricePerPod <= 0 || !plots?.length) return null; // Pre-allocate array for better performance - const rectangles: Array = []; + const rectangles: Array = []; // Use for loop for better performance than map/filter chain for (let i = 0; i < plots.length; i++) { @@ -285,6 +330,12 @@ const MarketChartOverlay = React.memo( const rect = calculatePlotRectangle(plot, pricePerPod); if (rect) { + // Calculate place in line for Pod Score + const placeInLineNum = plot.startIndex.toNumber() - harvestableIndex.toNumber(); + // Use placeInLine in millions for consistent scaling with market listings + const podScore = calculatePodScore(pricePerPod, placeInLineNum / MILLION); + + rectangles.push({ x: rect.x, y: rect.y, @@ -292,12 +343,13 @@ const MarketChartOverlay = React.memo( height: rect.height, plotKey: plot.startIndex.toHuman(), plotIndex: i, + podScore, }); } } return rectangles.length > 0 ? rectangles : null; - }, [overlayParams, dimensions, calculatePlotRectangle]); + }, [throttledOverlayParams, dimensions, calculatePlotRectangle, harvestableIndex]); // Highly optimized sell overlay renderer with pre-calculated values const renderSellOverlay = useCallback( @@ -319,6 +371,17 @@ const MarketChartOverlay = React.memo( const lineHeight = bottom - top; const lastRectIndex = memoizedRectangles.length - 1; + // Build color scaler from both market listings and overlay plot scores + // This ensures overlay colors are relative to existing market conditions + const plotScores = memoizedRectangles + .map(rect => rect.podScore) + .filter((score): score is number => score !== undefined); + + // Combine market listing scores with overlay plot scores for consistent scaling + const allScores = [...marketListingScores, ...plotScores]; + + const colorScaler = buildPodScoreColorScaler(allScores); + return ( <> {/* Horizontal price line - Tailwind + minimal inline styles */} @@ -336,18 +399,19 @@ const MarketChartOverlay = React.memo( const centerX = rect.x + (rect.width >> 1); // Bit shift for division by 2 const centerY = clampedPriceY; + // Get dynamic color based on Pod Score, fallback to default if undefined + const fillColor = rect.podScore !== undefined + ? colorScaler.toColor(rect.podScore) + : SELL_OVERLAY_COLORS.plotFill; + return (
@@ -384,16 +448,16 @@ const MarketChartOverlay = React.memo( ); // Don't render if not visible or no overlay params - if (!visible || !overlayParams || !dimensions) { + if (!visible || !throttledOverlayParams || !dimensions) { return null; } // Determine which overlay to render based on mode let overlayContent: JSX.Element | null = null; - if (overlayParams.mode === "buy") { - overlayContent = renderBuyOverlay(overlayParams); - } else if (overlayParams.mode === "sell") { - overlayContent = renderSellOverlay(overlayParams); + if (throttledOverlayParams.mode === "buy") { + overlayContent = renderBuyOverlay(throttledOverlayParams); + } else if (throttledOverlayParams.mode === "sell") { + overlayContent = renderSellOverlay(throttledOverlayParams); } if (!overlayContent) { @@ -403,8 +467,7 @@ const MarketChartOverlay = React.memo( return (
{overlayContent}
diff --git a/src/components/PodScoreGradientLegend.tsx b/src/components/PodScoreGradientLegend.tsx new file mode 100644 index 000000000..e609e1255 --- /dev/null +++ b/src/components/PodScoreGradientLegend.tsx @@ -0,0 +1,65 @@ +import TooltipSimple from "./TooltipSimple"; +import { cn } from "@/utils/utils"; + +interface PodScoreGradientLegendProps { + learnMoreUrl?: string; + className?: string; +} + +/** + * Displays a gradient legend showing the Pod Score color scale. + * Shows a horizontal gradient bar from brown (poor) to gold (average) to green (good), + * with an info icon tooltip explaining the Pod Score metric. + */ +export default function PodScoreGradientLegend({ + learnMoreUrl = "https://docs.pinto.money/", + className, +}: PodScoreGradientLegendProps) { + return ( +
+ {/* Row 1: Title and info icon */} +
+ Pod Score + +

+ Pod Score measures listing quality based on Return/Place in Line ratio. Higher scores (green) indicate + better value opportunities. +

+ + Learn more + +
+ } + /> +
+ + {/* Row 2: Gradient bar */} +
+ + {/* Row 3: Labels (Low, Avg, High) */} +
+ Low + Avg + High +
+
+ ); +} diff --git a/src/components/charts/ScatterChart.tsx b/src/components/charts/ScatterChart.tsx index 5a6c03f87..1ba30d3fe 100644 --- a/src/components/charts/ScatterChart.tsx +++ b/src/components/charts/ScatterChart.tsx @@ -188,7 +188,8 @@ const ScatterChart = React.memo( datasets: data.map(({ label, data, color, pointStyle, pointRadius }) => ({ label, data, - backgroundColor: color, + // Use per-point colors if available, otherwise use dataset color + backgroundColor: data.map((point: any) => point.color || color), pointStyle, pointRadius: pointRadius, hoverRadius: pointRadius + 1, @@ -626,7 +627,8 @@ function areScatterChartPropsEqual(prevProps: ScatterChartProps, nextProps: Scat if ( prevPoint.x !== nextPoint.x || prevPoint.y !== nextPoint.y || - (prevPoint as any).eventId !== (nextPoint as any).eventId + (prevPoint as any).eventId !== (nextPoint as any).eventId || + (prevPoint as any).color !== (nextPoint as any).color ) { return false; } @@ -641,7 +643,8 @@ function areScatterChartPropsEqual(prevProps: ScatterChartProps, nextProps: Scat if ( prevPoint.x !== nextPoint.x || prevPoint.y !== nextPoint.y || - (prevPoint as any).eventId !== (nextPoint as any).eventId + (prevPoint as any).eventId !== (nextPoint as any).eventId || + (prevPoint as any).color !== (nextPoint as any).color ) { return false; } diff --git a/src/pages/Market.tsx b/src/pages/Market.tsx index 8d36744ac..84b8ecd45 100644 --- a/src/pages/Market.tsx +++ b/src/pages/Market.tsx @@ -13,11 +13,14 @@ import useNavHeight from "@/hooks/display/useNavHeight"; import { useAllMarket } from "@/state/market/useAllMarket"; import { useHarvestableIndex, usePodLine } from "@/state/useFieldData"; import { trackSimpleEvent } from "@/utils/analytics"; +import { buildPodScoreColorScaler } from "@/utils/podScoreColorScaler"; +import { calculatePodScore } from "@/utils/podScore"; import { ActiveElement, ChartEvent, PointStyle, TooltipOptions } from "chart.js"; import { Chart } from "chart.js"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useNavigate, useParams } from "react-router-dom"; import MarketChartOverlay, { type OverlayParams } from "@/components/MarketChartOverlay"; +import PodScoreGradientLegend from "@/components/PodScoreGradientLegend"; import { AllActivityTable } from "./market/AllActivityTable"; import { FarmerActivityTable } from "./market/FarmerActivityTable"; import MarketModeSelect from "./market/MarketModeSelect"; @@ -68,6 +71,8 @@ type MarketScatterChartDataPoint = { amount: number; placeInLine: number; eventIndex?: number; + podScore?: number; + color?: string; }; type MarketScatterChartData = { @@ -79,12 +84,12 @@ type MarketScatterChartData = { }; /** - * Transforms raw market data into scatter chart format + * Transforms raw market data into scatter chart format with Pod Score coloring */ const shapeScatterChartData = (data: any[], harvestableIndex: TokenValue): MarketScatterChartData[] => { if (!data) return []; - return data.reduce( + const result = data.reduce( (acc, event) => { // Skip Fill Orders if ("toFarmer" in event) { @@ -122,6 +127,10 @@ const shapeScatterChartData = (data: any[], harvestableIndex: TokenValue): Marke const eventIndex = event.index.toNumber(); if (placeInLine !== null && price !== null) { + // Calculate Pod Score for the listing + // Use placeInLine in millions for consistent scaling with chart x-axis + const podScore = calculatePodScore(price, placeInLine / MILLION); + acc[1].data.push({ x: placeInLine / MILLION, y: price, @@ -131,6 +140,7 @@ const shapeScatterChartData = (data: any[], harvestableIndex: TokenValue): Marke status, amount, placeInLine, + podScore, }); } } @@ -141,19 +151,38 @@ const shapeScatterChartData = (data: any[], harvestableIndex: TokenValue): Marke { label: "Orders", data: [] as MarketScatterChartDataPoint[], - color: "#40b0a6", // teal + color: "#5CB8A9", // teal pointStyle: "circle" as PointStyle, pointRadius: 6, }, { label: "Listings", data: [] as MarketScatterChartDataPoint[], - color: "#e0b57d", // tan + color: "#e0b57d", // tan (fallback) pointStyle: "rect" as PointStyle, pointRadius: 6, }, ], ); + + // Apply Pod Score coloring to listings + // Extract all listing Pod Scores (filter out undefined values) + const listingScores = result[1].data + .map(point => point.podScore) + .filter((score): score is number => score !== undefined); + + // Build color scaler from listing scores + const colorScaler = buildPodScoreColorScaler(listingScores); + + // Map through listings and apply colors + result[1].data = result[1].data.map(point => ({ + ...point, + color: point.podScore !== undefined + ? colorScaler.toColor(point.podScore) + : "#e0b57d", // Fallback color for invalid Pod Scores + })); + + return result; }; export function Market() { @@ -180,6 +209,21 @@ export function Market() { [data, harvestableIndex], ); + // Extract Pod Scores from market listings for overlay color scaling + const marketListingScores = useMemo(() => { + if (!scatterChartData || scatterChartData.length < 2) return []; + + // Listings are at index 1 in scatterChartData + const listingsData = scatterChartData[1]; + if (!listingsData?.data) return []; + + const scores = listingsData.data + .map(point => point.podScore) + .filter((score): score is number => score !== undefined); + + return scores; + }, [scatterChartData]); + // Calculate chart x-axis max value - use podLineAsNumber with a minimum value // Don't depend on overlayParams to avoid re-rendering chart on every slider change const chartXMax = useMemo(() => { @@ -246,6 +290,24 @@ export function Market() { Order for ${TokenValue.fromHuman(dataPoint.amount, 0).toHuman("short")} Pods
`; + // Format Pod Score for display (only for listings) + const formatPodScore = (score: number): string => { + if (score >= 1000000) { + return `${(score / 1000000).toFixed(2)}M`; + } else if (score >= 1000) { + return `${(score / 1000).toFixed(1)}K`; + } else { + return score.toFixed(2); + } + }; + + const podScoreRow = dataPoint.eventType === "LISTING" && dataPoint.podScore !== undefined + ? `
+ Pod Score: + ${formatPodScore(dataPoint.podScore)} +
` + : ''; + tooltipEl.innerHTML = `
${dataPoint.eventType === "LISTING" ? listingHeader : orderHeader} @@ -260,6 +322,7 @@ export function Market() { Place in Line: ${TokenValue.fromHuman(dataPoint.placeInLine, 0).toHuman("long")}
+ ${podScoreRow}
`; } @@ -407,6 +470,12 @@ export function Market() { onPointClick={onPointClick} toolTipOptions={toolTipOptions as TooltipOptions} /> + + {/* Gradient Legend - positioned in top-right corner */} +
+ +
+
diff --git a/src/pages/market/actions/CreateListing.tsx b/src/pages/market/actions/CreateListing.tsx index 3dda78f59..3603bb956 100644 --- a/src/pages/market/actions/CreateListing.tsx +++ b/src/pages/market/actions/CreateListing.tsx @@ -19,6 +19,7 @@ import { useQueryKeys } from "@/state/useQueryKeys"; import useTokenData from "@/state/useTokenData"; import { trackSimpleEvent } from "@/utils/analytics"; import { formatter } from "@/utils/format"; +import { calculatePodScore } from "@/utils/podScore"; import { FarmToMode, Plot } from "@/utils/types"; import { cn } from "@/utils/utils"; import { useQueryClient } from "@tanstack/react-query"; @@ -200,6 +201,26 @@ export default function CreateListing({ onOverlayParamsChange }: CreateListingPr return result; }, [plot, podRange, amount]); + // Calculate Pod Score range for selected plots + const podScoreRange = useMemo(() => { + if (listingData.length === 0 || !pricePerPod || pricePerPod <= 0) return null; + + const scores = listingData + .map((data) => { + const placeInLine = data.index.sub(harvestableIndex).toNumber(); + // Use placeInLine in millions for consistent scaling + return calculatePodScore(pricePerPod, placeInLine / MILLION); + }) + .filter((score): score is number => score !== undefined); + + if (scores.length === 0) return null; + + const min = Math.min(...scores); + const max = Math.max(...scores); + + return { min, max, isSingle: scores.length === 1 || min === max }; + }, [listingData, pricePerPod, harvestableIndex]); + // Helper function to sort plots by index const sortPlotsByIndex = useCallback((plots: Plot[]): Plot[] => { return [...plots].sort((a, b) => a.index.sub(b.index).toNumber()); @@ -612,6 +633,19 @@ export default function CreateListing({ onOverlayParamsChange }: CreateListingPr

)} + {/* Pod Score Display */} + {podScoreRange && ( +
+

+ Pod Score:{" "} + + {podScoreRange.isSingle + ? formatter.number(podScoreRange.min, { minDecimals: 2, maxDecimals: 2 }) + : `${formatter.number(podScoreRange.min, { minDecimals: 2, maxDecimals: 2 })} - ${formatter.number(podScoreRange.max, { minDecimals: 2, maxDecimals: 2 })}`} + +

+
+ )}
{/* Advanced Settings - Collapsible */} {text}
; }; +/** + * Calculates Pod Score for a listing based on price per pod and place in line. + * Formula: (1/pricePerPod - 1) / placeInLine * 1e6 + * + * @param pricePerPod - Price per pod (must be > 0) + * @param placeInLine - Position in harvest queue in millions (must be > 0) + * @returns Pod Score value, or undefined for invalid inputs + */ +const calculatePodScore = (pricePerPod: number, placeInLine: number): number | undefined => { + // Handle edge cases: invalid price or place in line + if (pricePerPod <= 0 || placeInLine <= 0) { + return undefined; + } + + // Calculate return: (1/pricePerPod - 1) + const returnValue = 1 / pricePerPod - 1; + + // Calculate Pod Score: return / placeInLine * 1e6 + const podScore = (returnValue / placeInLine) * 1e6; + + // Filter out invalid results (NaN, Infinity) + if (!Number.isFinite(podScore)) { + return undefined; + } + + return podScore; +}; + // Utility function to format and truncate price per pod values const formatPricePerPod = (value: number): number => { return Math.floor(value * PRICE_PER_POD_CONFIG.DECIMAL_MULTIPLIER) / PRICE_PER_POD_CONFIG.DECIMAL_MULTIPLIER; @@ -179,6 +207,25 @@ export default function FillListing({ onOverlayParamsChange }: FillListingProps } }, [preferredToken, preferredLoading, didSetPreferred]); + // Find selected listing based on listingId parameter + const selectedListing = useMemo(() => { + if (!listingId || !allListings?.podListings) return null; + return allListings.podListings.find((l) => l.id === listingId) || null; + }, [listingId, allListings]); + + // Calculate Pod Score for the selected listing + const listingPodScore = useMemo(() => { + if (!selectedListing) return undefined; + + const price = TokenValue.fromBlockchain(selectedListing.pricePerPod, mainToken.decimals).toNumber(); + const placeInLine = TokenValue.fromBlockchain(selectedListing.index, PODS.decimals) + .sub(harvestableIndex) + .toNumber(); + + // Use placeInLine in millions for consistent scaling + return calculatePodScore(price, placeInLine / 1_000_000); + }, [selectedListing, mainToken.decimals, harvestableIndex]); + // Pre-fill form when listingId parameter is present (clicked from chart) useEffect(() => { if (!listingId || !allListings?.podListings || maxPlace === 0) return; @@ -711,6 +758,19 @@ export default function FillListing({ onOverlayParamsChange }: FillListingProps

)} + {/* Pod Score Display */} + {selectedListing && ( +
+

+ Pod Score:{" "} + + {listingPodScore !== undefined + ? formatter.number(listingPodScore, { minDecimals: 2, maxDecimals: 2 }) + : 'N/A'} + +

+
+ )}
{/* Place in Line Slider */} diff --git a/src/utils/podScore.ts b/src/utils/podScore.ts new file mode 100644 index 000000000..8d3e3584f --- /dev/null +++ b/src/utils/podScore.ts @@ -0,0 +1,39 @@ +/** + * Pod Score Calculation Utility + * + * Provides the Pod Score calculation function for evaluating pod listings. + * Pod Score = (Return / Place in Line) * 1e6 + * where Return = (1/pricePerPod - 1) + */ + +/** + * Calculate Pod Score for a pod listing + * + * Formula: (1/pricePerPod - 1) / placeInLine * 1e6 + * + * @param pricePerPod - Price per pod (must be > 0) + * @param placeInLine - Position in harvest queue in millions (must be > 0) + * @returns Pod Score value, or undefined for invalid inputs + */ +export const calculatePodScore = (pricePerPod: number, placeInLine: number): number | undefined => { + // Handle edge cases: invalid price or place in line + if (pricePerPod <= 0 || placeInLine <= 0) { + return undefined; + } + + // Calculate return: (1/pricePerPod - 1) + // When pricePerPod < 1.0: positive return (good deal) + // When pricePerPod = 1.0: zero return (break even) + // When pricePerPod > 1.0: negative return (bad deal) + const returnValue = 1 / pricePerPod - 1; + + // Calculate Pod Score: return / placeInLine * 1e6 + const podScore = (returnValue / placeInLine) * 1e6; + + // Filter out invalid results (NaN, Infinity) + if (!Number.isFinite(podScore)) { + return undefined; + } + + return podScore; +}; diff --git a/src/utils/podScoreColorScaler.ts b/src/utils/podScoreColorScaler.ts new file mode 100644 index 000000000..594649cc8 --- /dev/null +++ b/src/utils/podScoreColorScaler.ts @@ -0,0 +1,208 @@ +/** + * Pod Score Color Scaler Utility + * + * Provides percentile-based color scaling for Pod Score visualization. + * Maps scores to a three-stop gradient: brown (poor) → gold (average) → green (good) + */ + +type Hex = `#${string}`; + +interface RGB { + r: number; + g: number; + b: number; +} + +interface ScalerOptions { + lowerPct?: number; // Default: 5 + upperPct?: number; // Default: 95 + smoothFactor?: number; // Default: 0 (no smoothing) + bad?: Hex; // Default: '#91580D' + mid?: Hex; // Default: '#E8C15F' + good?: Hex; // Default: '#A8E868' +} + +export interface ColorScaler { + toColor: (score: number) => Hex; + toUnit: (score: number) => number; + bounds: { low: number; high: number }; +} + +/** + * Calculate percentile value from sorted array + */ +function percentile(values: number[], p: number): number { + if (values.length === 0) return 0; + if (values.length === 1) return values[0]; + + // Sort values in ascending order + const sorted = [...values].sort((a, b) => a - b); + + // Calculate index (using linear interpolation) + const index = (p / 100) * (sorted.length - 1); + const lower = Math.floor(index); + const upper = Math.ceil(index); + const weight = index - lower; + + if (lower === upper) { + return sorted[lower]; + } + + return sorted[lower] * (1 - weight) + sorted[upper] * weight; +} + +/** + * Clamp value between min and max + */ +function clamp(x: number, min: number, max: number): number { + return Math.min(Math.max(x, min), max); +} + +/** + * Exponentially smooth value with previous value + */ +function smooth(prev: number, next: number, alpha: number): number { + return prev + alpha * (next - prev); +} + +/** + * Convert hex color to RGB + */ +function hexToRgb(hex: Hex): RGB { + const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex); + if (!result) { + throw new Error(`Invalid hex color: ${hex}`); + } + return { + r: parseInt(result[1], 16), + g: parseInt(result[2], 16), + b: parseInt(result[3], 16), + }; +} + +/** + * Convert RGB to hex color + */ +function rgbToHex(c: RGB): Hex { + const toHex = (n: number) => { + const hex = Math.round(clamp(n, 0, 255)).toString(16); + return hex.length === 1 ? '0' + hex : hex; + }; + return `#${toHex(c.r)}${toHex(c.g)}${toHex(c.b)}` as Hex; +} + +/** + * Mix two RGB colors with interpolation factor t (0-1) + */ +function mix(a: RGB, b: RGB, t: number): RGB { + const clampedT = clamp(t, 0, 1); + return { + r: a.r + (b.r - a.r) * clampedT, + g: a.g + (b.g - a.g) * clampedT, + b: a.b + (b.b - a.b) * clampedT, + }; +} + +/** + * Build a color scaler for Pod Scores + * + * @param scores - Array of Pod Score values + * @param prevBounds - Optional previous bounds for smoothing + * @param opts - Optional configuration + * @returns ColorScaler object with toColor, toUnit, and bounds + */ +export function buildPodScoreColorScaler( + scores: number[], + prevBounds?: { low: number; high: number } | null, + opts?: ScalerOptions +): ColorScaler { + // Default options + const options: Required = { + lowerPct: opts?.lowerPct ?? 5, + upperPct: opts?.upperPct ?? 95, + smoothFactor: opts?.smoothFactor ?? 0, + bad: opts?.bad ?? '#91580D', + mid: opts?.mid ?? '#E8C15F', + good: opts?.good ?? '#A8E868', + }; + + // Filter out invalid values (NaN, Infinity) + const validScores = scores.filter(s => Number.isFinite(s)); + + // Calculate percentile bounds + let low: number; + let high: number; + + if (validScores.length === 0) { + // Fallback bounds for empty array + low = 0; + high = 1; + } else if (validScores.length === 1) { + // Single value - use it as both bounds with small range + low = validScores[0] - 0.5; + high = validScores[0] + 0.5; + } else { + // Calculate percentiles + low = percentile(validScores, options.lowerPct); + high = percentile(validScores, options.upperPct); + + // Ensure high > low + if (high <= low) { + high = low + 1; + } + } + + // Apply smoothing if previous bounds provided + if (prevBounds && options.smoothFactor > 0) { + low = smooth(prevBounds.low, low, options.smoothFactor); + high = smooth(prevBounds.high, high, options.smoothFactor); + + // Ensure smoothed high > smoothed low + if (high <= low) { + high = low + 1; + } + } + + // Pre-calculate RGB values for color stops + const badRgb = hexToRgb(options.bad); + const midRgb = hexToRgb(options.mid); + const goodRgb = hexToRgb(options.good); + + /** + * Convert score to normalized 0-1 value + */ + const toUnit = (score: number): number => { + if (!Number.isFinite(score)) return 0; + const clamped = clamp(score, low, high); + return (clamped - low) / (high - low); + }; + + /** + * Convert score to hex color + */ + const toColor = (score: number): Hex => { + const unit = toUnit(score); + + // Three-stop gradient: bad → mid → good + // 0.0 - 0.5: bad to mid + // 0.5 - 1.0: mid to good + let rgb: RGB; + if (unit <= 0.5) { + // Interpolate from bad to mid + const t = unit / 0.5; + rgb = mix(badRgb, midRgb, t); + } else { + // Interpolate from mid to good + const t = (unit - 0.5) / 0.5; + rgb = mix(midRgb, goodRgb, t); + } + + return rgbToHex(rgb); + }; + + return { + toColor, + toUnit, + bounds: { low, high }, + }; +} From 4b7759662fe0b4340efad975aab13f9a30fa43b0 Mon Sep 17 00:00:00 2001 From: feyyazcigim Date: Sat, 22 Nov 2025 17:30:23 +0300 Subject: [PATCH 48/50] Fix lint errors --- src/components/MarketChartOverlay.tsx | 82 +-- src/components/PodScoreGradientLegend.tsx | 7 +- src/components/charts/ScatterChart.tsx | 684 ++++++++++----------- src/pages/Market.tsx | 50 +- src/pages/market/actions/CreateListing.tsx | 2 +- src/pages/market/actions/CreateOrder.tsx | 4 +- src/pages/market/actions/FillListing.tsx | 14 +- src/utils/podScore.ts | 14 +- src/utils/podScoreColorScaler.ts | 44 +- 9 files changed, 453 insertions(+), 448 deletions(-) diff --git a/src/components/MarketChartOverlay.tsx b/src/components/MarketChartOverlay.tsx index 2d6f49a2b..7cff02962 100644 --- a/src/components/MarketChartOverlay.tsx +++ b/src/components/MarketChartOverlay.tsx @@ -1,6 +1,6 @@ import { TokenValue } from "@/classes/TokenValue"; -import { buildPodScoreColorScaler } from "@/utils/podScoreColorScaler"; import { calculatePodScore } from "@/utils/podScore"; +import { buildPodScoreColorScaler } from "@/utils/podScoreColorScaler"; import { Chart } from "chart.js"; import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"; @@ -77,30 +77,33 @@ const MarketChartOverlay = React.memo( ({ overlayParams, chartRef, visible, harvestableIndex, marketListingScores = [] }) => { const [dimensions, setDimensions] = useState(null); const containerRef = useRef(null); - + // Note: Throttling removed to ensure immediate updates during price changes // Performance is acceptable without throttling due to optimized calculations const throttledOverlayParams = overlayParams; // Optimized pixel position calculator with minimal validation overhead - const calculatePixelPosition = useCallback((dataValue: number, axis: "x" | "y"): number | null => { - const chart = chartRef.current; - if (!chart?.scales) return null; + const calculatePixelPosition = useCallback( + (dataValue: number, axis: "x" | "y"): number | null => { + const chart = chartRef.current; + if (!chart?.scales) return null; - const scale = chart.scales[axis]; - if (!scale?.getPixelForValue || scale.min === undefined || scale.max === undefined) { - return null; - } + const scale = chart.scales[axis]; + if (!scale?.getPixelForValue || scale.min === undefined || scale.max === undefined) { + return null; + } - // Fast clamp without Math.max/min for better performance - const clampedValue = dataValue < scale.min ? scale.min : dataValue > scale.max ? scale.max : dataValue; + // Fast clamp without Math.max/min for better performance + const clampedValue = dataValue < scale.min ? scale.min : dataValue > scale.max ? scale.max : dataValue; - try { - return scale.getPixelForValue(clampedValue); - } catch { - return null; - } - }, [chartRef]); + try { + return scale.getPixelForValue(clampedValue); + } catch { + return null; + } + }, + [chartRef], + ); // Optimized dimension calculator with minimal object creation const getChartDimensions = useCallback((): ChartDimensions | null => { @@ -108,16 +111,20 @@ const MarketChartOverlay = React.memo( if (!chart?.chartArea) return null; const { left, top, right, bottom } = chart.chartArea; - + // Fast type validation - if (typeof left !== 'number' || typeof top !== 'number' || - typeof right !== 'number' || typeof bottom !== 'number') { + if ( + typeof left !== "number" || + typeof top !== "number" || + typeof right !== "number" || + typeof bottom !== "number" + ) { return null; } const width = right - left; const height = bottom - top; - + if (width <= 0 || height <= 0) return null; return { left, top, width, height, bottom, right }; @@ -128,13 +135,13 @@ const MarketChartOverlay = React.memo( let timeoutId: NodeJS.Timeout; let animationFrameId: number | null = null; let resizeObserver: ResizeObserver | null = null; - + const updateDimensions = () => { // Use requestAnimationFrame to sync with browser's repaint cycle if (animationFrameId !== null) { cancelAnimationFrame(animationFrameId); } - + animationFrameId = requestAnimationFrame(() => { const newDimensions = getChartDimensions(); if (newDimensions) { @@ -156,7 +163,7 @@ const MarketChartOverlay = React.memo( }, DIMENSION_UPDATE_DELAY_MS); // Single ResizeObserver for all resize events - if (typeof ResizeObserver !== 'undefined') { + if (typeof ResizeObserver !== "undefined") { resizeObserver = new ResizeObserver(debouncedUpdate); const parent = containerRef.current?.parentElement; if (parent) { @@ -171,7 +178,7 @@ const MarketChartOverlay = React.memo( // Fallback window resize listener with passive flag for better performance window.addEventListener("resize", debouncedUpdate, { passive: true }); - + // Listen to Chart.js resize events for immediate sync const chart = chartRef.current; if (chart) { @@ -270,7 +277,7 @@ const MarketChartOverlay = React.memo( const { scales } = chartRef.current; const { x: xScale, y: yScale } = scales; - + if (!xScale?.max || !yScale?.max) return null; // Optimized place in line calculation - avoid intermediate TokenValue object @@ -280,7 +287,7 @@ const MarketChartOverlay = React.memo( // Batch calculations for better performance const startX = placeInLineNum / MILLION; const endX = (placeInLineNum + plot.amount.toNumber()) / MILLION; - + // Fast validation if (startX < 0 || endX <= startX) return null; @@ -323,18 +330,17 @@ const MarketChartOverlay = React.memo( // Pre-allocate array for better performance const rectangles: Array = []; - + // Use for loop for better performance than map/filter chain for (let i = 0; i < plots.length; i++) { const plot = plots[i]; const rect = calculatePlotRectangle(plot, pricePerPod); - + if (rect) { // Calculate place in line for Pod Score const placeInLineNum = plot.startIndex.toNumber() - harvestableIndex.toNumber(); // Use placeInLine in millions for consistent scaling with market listings const podScore = calculatePodScore(pricePerPod, placeInLineNum / MILLION); - rectangles.push({ x: rect.x, @@ -374,12 +380,12 @@ const MarketChartOverlay = React.memo( // Build color scaler from both market listings and overlay plot scores // This ensures overlay colors are relative to existing market conditions const plotScores = memoizedRectangles - .map(rect => rect.podScore) + .map((rect) => rect.podScore) .filter((score): score is number => score !== undefined); - + // Combine market listing scores with overlay plot scores for consistent scaling const allScores = [...marketListingScores, ...plotScores]; - + const colorScaler = buildPodScoreColorScaler(allScores); return ( @@ -400,9 +406,8 @@ const MarketChartOverlay = React.memo( const centerY = clampedPriceY; // Get dynamic color based on Pod Score, fallback to default if undefined - const fillColor = rect.podScore !== undefined - ? colorScaler.toColor(rect.podScore) - : SELL_OVERLAY_COLORS.plotFill; + const fillColor = + rect.podScore !== undefined ? colorScaler.toColor(rect.podScore) : SELL_OVERLAY_COLORS.plotFill; return (
( } return ( -
+
{overlayContent}
); diff --git a/src/components/PodScoreGradientLegend.tsx b/src/components/PodScoreGradientLegend.tsx index e609e1255..aabea14ec 100644 --- a/src/components/PodScoreGradientLegend.tsx +++ b/src/components/PodScoreGradientLegend.tsx @@ -1,5 +1,5 @@ -import TooltipSimple from "./TooltipSimple"; import { cn } from "@/utils/utils"; +import TooltipSimple from "./TooltipSimple"; interface PodScoreGradientLegendProps { learnMoreUrl?: string; @@ -17,7 +17,10 @@ export default function PodScoreGradientLegend({ }: PodScoreGradientLegendProps) { return (
{/* Row 1: Title and info icon */}
diff --git a/src/components/charts/ScatterChart.tsx b/src/components/charts/ScatterChart.tsx index 1ba30d3fe..2d619e090 100644 --- a/src/components/charts/ScatterChart.tsx +++ b/src/components/charts/ScatterChart.tsx @@ -99,395 +99,395 @@ const ScatterChart = React.memo( // Expose chart instance through ref React.useImperativeHandle(ref, () => chartRef.current as Chart, []); - useEffect(() => { - activeIndexRef.current = activeIndex; - if (chartRef.current) { - chartRef.current.update("none"); // Disable animations during update - } - }, [activeIndex]); + useEffect(() => { + activeIndexRef.current = activeIndex; + if (chartRef.current) { + chartRef.current.update("none"); // Disable animations during update + } + }, [activeIndex]); + + const [yTickMin, yTickMax] = useMemo(() => { + // If custom min/max are provided, use those + if (yOptions.min !== undefined && yOptions.max !== undefined) { + // Even with custom ranges, ensure 1.0 is visible if showReferenceLineAtOne is true + if (horizontalReferenceLines.some((line) => line.value === 1)) { + const hasOne = yOptions.min <= 1 && yOptions.max >= 1; + if (!hasOne) { + // If 1.0 is not in range, adjust the range to include it + if (useLogarithmicScale) { + // For logarithmic scale, we need to ensure we maintain the ratio + // but include 1.0 in the range + if (yOptions.min > 1) { + return [0.7, Math.max(yOptions.max, 1.5)]; // Include 1.0 with padding below + } else if (yOptions.max < 1) { + return [Math.min(yOptions.min, 0.7), 1.5]; // Include 1.0 with padding above + } + } else { + // For linear scale, just expand the range to include 1.0 + if (yOptions.min > 1) { + return [0.9, Math.max(yOptions.max, 1.1)]; // Include 1.0 with padding + } else if (yOptions.max < 1) { + return [Math.min(yOptions.min, 0.9), 1.1]; // Include 1.0 with padding + } + } + } + } + return [yOptions.min, yOptions.max]; + } + + // Otherwise calculate based on data + const maxData = Number.MIN_SAFE_INTEGER; //data.reduce((acc, next) => Math.max(acc, next.y), Number.MIN_SAFE_INTEGER); + const minData = Number.MAX_SAFE_INTEGER; //data.reduce((acc, next) => Math.min(acc, next.y), Number.MAX_SAFE_INTEGER); + + const maxTick = maxData === minData && maxData === 0 ? 1 : maxData; + let minTick = Math.max(0, minData - (maxData - minData) * 0.1); + if (minTick === maxData) { + minTick = maxData * 0.99; + } + + // For logarithmic scale, ensure minTick is positive + if (useLogarithmicScale && minTick <= 0) { + minTick = 0.000001; // Small positive value + } - const [yTickMin, yTickMax] = useMemo(() => { - // If custom min/max are provided, use those - if (yOptions.min !== undefined && yOptions.max !== undefined) { - // Even with custom ranges, ensure 1.0 is visible if showReferenceLineAtOne is true + // Use custom min/max if provided + let finalMin = yOptions.min !== undefined ? yOptions.min : minTick; + let finalMax = yOptions.max !== undefined ? yOptions.max : maxTick; + + // Ensure 1.0 is visible if there's a reference line at 1.0 if (horizontalReferenceLines.some((line) => line.value === 1)) { - const hasOne = yOptions.min <= 1 && yOptions.max >= 1; - if (!hasOne) { - // If 1.0 is not in range, adjust the range to include it + if (finalMin > 1 || finalMax < 1) { if (useLogarithmicScale) { // For logarithmic scale, we need to ensure we maintain the ratio - // but include 1.0 in the range - if (yOptions.min > 1) { - return [0.7, Math.max(yOptions.max, 1.5)]; // Include 1.0 with padding below - } else if (yOptions.max < 1) { - return [Math.min(yOptions.min, 0.7), 1.5]; // Include 1.0 with padding above + if (finalMin > 1) { + finalMin = 0.7; // Include 1.0 with padding below + finalMax = Math.max(finalMax, 1.5); + } else if (finalMax < 1) { + finalMin = Math.min(finalMin, 0.7); + finalMax = 1.5; // Include 1.0 with padding above } } else { // For linear scale, just expand the range to include 1.0 - if (yOptions.min > 1) { - return [0.9, Math.max(yOptions.max, 1.1)]; // Include 1.0 with padding - } else if (yOptions.max < 1) { - return [Math.min(yOptions.min, 0.9), 1.1]; // Include 1.0 with padding + if (finalMin > 1) { + finalMin = 0.9; // Include 1.0 with padding + finalMax = Math.max(finalMax, 1.1); + } else if (finalMax < 1) { + finalMin = Math.min(finalMin, 0.9); + finalMax = 1.1; // Include 1.0 with padding } } } } - return [yOptions.min, yOptions.max]; - } - // Otherwise calculate based on data - const maxData = Number.MIN_SAFE_INTEGER; //data.reduce((acc, next) => Math.max(acc, next.y), Number.MIN_SAFE_INTEGER); - const minData = Number.MAX_SAFE_INTEGER; //data.reduce((acc, next) => Math.min(acc, next.y), Number.MAX_SAFE_INTEGER); - - const maxTick = maxData === minData && maxData === 0 ? 1 : maxData; - let minTick = Math.max(0, minData - (maxData - minData) * 0.1); - if (minTick === maxData) { - minTick = maxData * 0.99; - } + return [finalMin, finalMax]; + }, [data, useLogarithmicScale, yOptions.min, yOptions.max, horizontalReferenceLines]); + + const chartData = useCallback( + (ctx: CanvasRenderingContext2D | null): ChartData => { + return { + datasets: data.map(({ label, data, color, pointStyle, pointRadius }) => ({ + label, + data, + // Use per-point colors if available, otherwise use dataset color + backgroundColor: data.map((point: any) => point.color || color), + pointStyle, + pointRadius: pointRadius, + hoverRadius: pointRadius + 1, + })), + }; + }, + [data], + ); - // For logarithmic scale, ensure minTick is positive - if (useLogarithmicScale && minTick <= 0) { - minTick = 0.000001; // Small positive value - } + const verticalLinePlugin: Plugin = useMemo( + () => ({ + id: "customVerticalLine", + afterDraw: (chart: Chart) => { + const ctx = chart.ctx; + const activeIndex = activeIndexRef.current; + if (ctx) { + ctx.save(); + ctx.setLineDash([4, 4]); + + // Draw the vertical line for the active element (hovered point) + const activeElements = chart.getActiveElements(); + if (activeElements.length > 0) { + const activeElement = activeElements[0]; + const datasetIndex = activeElement.datasetIndex; + const index = activeElement.index; + const dataPoint = chart.getDatasetMeta(datasetIndex).data[index]; + + if (dataPoint) { + const { x } = dataPoint.getProps(["x"], true); + ctx.beginPath(); + ctx.moveTo(x, chart.chartArea.top); + ctx.lineTo(x, chart.chartArea.bottom); + ctx.strokeStyle = "black"; + ctx.lineWidth = 1.5; + ctx.stroke(); + } + } - // Use custom min/max if provided - let finalMin = yOptions.min !== undefined ? yOptions.min : minTick; - let finalMax = yOptions.max !== undefined ? yOptions.max : maxTick; - - // Ensure 1.0 is visible if there's a reference line at 1.0 - if (horizontalReferenceLines.some((line) => line.value === 1)) { - if (finalMin > 1 || finalMax < 1) { - if (useLogarithmicScale) { - // For logarithmic scale, we need to ensure we maintain the ratio - if (finalMin > 1) { - finalMin = 0.7; // Include 1.0 with padding below - finalMax = Math.max(finalMax, 1.5); - } else if (finalMax < 1) { - finalMin = Math.min(finalMin, 0.7); - finalMax = 1.5; // Include 1.0 with padding above + ctx.restore(); } - } else { - // For linear scale, just expand the range to include 1.0 - if (finalMin > 1) { - finalMin = 0.9; // Include 1.0 with padding - finalMax = Math.max(finalMax, 1.1); - } else if (finalMax < 1) { - finalMin = Math.min(finalMin, 0.9); - finalMax = 1.1; // Include 1.0 with padding - } - } - } - } + }, + }), + [], + ); - return [finalMin, finalMax]; - }, [data, useLogarithmicScale, yOptions.min, yOptions.max, horizontalReferenceLines]); + const horizontalReferenceLinePlugin: Plugin = useMemo( + () => ({ + id: "horizontalReferenceLine", + afterDraw: (chart: Chart) => { + const ctx = chart.ctx; + if (!ctx || horizontalReferenceLines.length === 0) return; - const chartData = useCallback( - (ctx: CanvasRenderingContext2D | null): ChartData => { - return { - datasets: data.map(({ label, data, color, pointStyle, pointRadius }) => ({ - label, - data, - // Use per-point colors if available, otherwise use dataset color - backgroundColor: data.map((point: any) => point.color || color), - pointStyle, - pointRadius: pointRadius, - hoverRadius: pointRadius + 1, - })), - }; - }, - [data], - ); - - const verticalLinePlugin: Plugin = useMemo( - () => ({ - id: "customVerticalLine", - afterDraw: (chart: Chart) => { - const ctx = chart.ctx; - const activeIndex = activeIndexRef.current; - if (ctx) { ctx.save(); - ctx.setLineDash([4, 4]); - // Draw the vertical line for the active element (hovered point) - const activeElements = chart.getActiveElements(); - if (activeElements.length > 0) { - const activeElement = activeElements[0]; - const datasetIndex = activeElement.datasetIndex; - const index = activeElement.index; - const dataPoint = chart.getDatasetMeta(datasetIndex).data[index]; + // Draw each horizontal reference line + horizontalReferenceLines.forEach((line) => { + const yScale = chart.scales.y; + const y = yScale.getPixelForValue(line.value); - if (dataPoint) { - const { x } = dataPoint.getProps(["x"], true); + // Only draw if within chart area + if (y >= chart.chartArea.top && y <= chart.chartArea.bottom) { ctx.beginPath(); - ctx.moveTo(x, chart.chartArea.top); - ctx.lineTo(x, chart.chartArea.bottom); - ctx.strokeStyle = "black"; - ctx.lineWidth = 1.5; + if (line.dash) { + ctx.setLineDash(line.dash); + } else { + ctx.setLineDash([4, 4]); // Default dash pattern + } + ctx.moveTo(chart.chartArea.left, y); + ctx.lineTo(chart.chartArea.right, y); + ctx.strokeStyle = line.color; + ctx.lineWidth = 1; ctx.stroke(); - } - } - ctx.restore(); - } - }, - }), - [], - ); + // Reset dash pattern + ctx.setLineDash([]); - const horizontalReferenceLinePlugin: Plugin = useMemo( - () => ({ - id: "horizontalReferenceLine", - afterDraw: (chart: Chart) => { - const ctx = chart.ctx; - if (!ctx || horizontalReferenceLines.length === 0) return; + // Add label if provided + if (line.label) { + ctx.font = "12px Arial"; + ctx.fillStyle = line.color; - ctx.save(); + // Measure text width to ensure it doesn't get cut off + const textWidth = ctx.measureText(line.label).width; + const rightPadding = 10; // Padding from right edge - // Draw each horizontal reference line - horizontalReferenceLines.forEach((line) => { - const yScale = chart.scales.y; - const y = yScale.getPixelForValue(line.value); + // Position the label at the right side of the chart with padding + const labelX = chart.chartArea.right - textWidth - rightPadding; + const labelPadding = 5; // Padding between line and text + const textHeight = 12; // Approximate height of the text - // Only draw if within chart area - if (y >= chart.chartArea.top && y <= chart.chartArea.bottom) { - ctx.beginPath(); - if (line.dash) { - ctx.setLineDash(line.dash); - } else { - ctx.setLineDash([4, 4]); // Default dash pattern - } - ctx.moveTo(chart.chartArea.left, y); - ctx.lineTo(chart.chartArea.right, y); - ctx.strokeStyle = line.color; - ctx.lineWidth = 1; - ctx.stroke(); + // Check if the line is too close to the top of the chart + const isNearTop = y - textHeight - labelPadding < chart.chartArea.top; + + // Check if the line is too close to the bottom of the chart + const isNearBottom = y + textHeight + labelPadding > chart.chartArea.bottom; + + // Set text alignment + ctx.textAlign = "left"; - // Reset dash pattern - ctx.setLineDash([]); - - // Add label if provided - if (line.label) { - ctx.font = "12px Arial"; - ctx.fillStyle = line.color; - - // Measure text width to ensure it doesn't get cut off - const textWidth = ctx.measureText(line.label).width; - const rightPadding = 10; // Padding from right edge - - // Position the label at the right side of the chart with padding - const labelX = chart.chartArea.right - textWidth - rightPadding; - const labelPadding = 5; // Padding between line and text - const textHeight = 12; // Approximate height of the text - - // Check if the line is too close to the top of the chart - const isNearTop = y - textHeight - labelPadding < chart.chartArea.top; - - // Check if the line is too close to the bottom of the chart - const isNearBottom = y + textHeight + labelPadding > chart.chartArea.bottom; - - // Set text alignment - ctx.textAlign = "left"; - - // Position the label based on proximity to chart edges - // biome-ignore lint/suspicious/noExplicitAny: - let labelY: any; - ctx.textBaseline = "bottom"; - labelY = y - labelPadding; - if (isNearTop) { - ctx.textBaseline = "top"; - labelY = y + labelPadding; - } else if (isNearBottom) { + // Position the label based on proximity to chart edges + // biome-ignore lint/suspicious/noExplicitAny: + let labelY: any; + ctx.textBaseline = "bottom"; labelY = y - labelPadding; + if (isNearTop) { + ctx.textBaseline = "top"; + labelY = y + labelPadding; + } else if (isNearBottom) { + labelY = y - labelPadding; + } + ctx.fillText(line.label, labelX, labelY); } - ctx.fillText(line.label, labelX, labelY); } - } - }); - - ctx.restore(); - }, - }), - [horizontalReferenceLines], - ); + }); - const selectionPointPlugin: Plugin = useMemo( - () => ({ - id: "customSelectPoint", - afterDraw: (chart: Chart) => { - const ctx = chart.ctx; - if (!ctx) return; - - // Define the function to draw the selection point - const drawSelectionPoint = ( - x: number, - y: number, - pointRadius: number, - pointStyle: PointStyle, - color?: string, - ) => { - // console.info("🚀 ~ drawSelectionPoint ~ pointRadius:", pointRadius); - ctx.save(); - ctx.fillStyle = "transparent"; - ctx.strokeStyle = color || "black"; - ctx.lineWidth = !!color ? 2 : 1; - - const rectWidth = pointRadius * 2.5 || 10; - const rectHeight = pointRadius * 2.5 || 10; - const cornerRadius = pointStyle === "rect" ? 0 : pointRadius * 1.5; - - ctx.beginPath(); - ctx.moveTo(x - rectWidth / 2 + cornerRadius, y - rectHeight / 2); - ctx.lineTo(x + rectWidth / 2 - cornerRadius, y - rectHeight / 2); - ctx.quadraticCurveTo( - x + rectWidth / 2, - y - rectHeight / 2, - x + rectWidth / 2, - y - rectHeight / 2 + cornerRadius, - ); - ctx.lineTo(x + rectWidth / 2, y + rectHeight / 2 - cornerRadius); - ctx.quadraticCurveTo( - x + rectWidth / 2, - y + rectHeight / 2, - x + rectWidth / 2 - cornerRadius, - y + rectHeight / 2, - ); - ctx.lineTo(x - rectWidth / 2 + cornerRadius, y + rectHeight / 2); - ctx.quadraticCurveTo( - x - rectWidth / 2, - y + rectHeight / 2, - x - rectWidth / 2, - y + rectHeight / 2 - cornerRadius, - ); - ctx.lineTo(x - rectWidth / 2, y - rectHeight / 2 + cornerRadius); - ctx.quadraticCurveTo( - x - rectWidth / 2, - y - rectHeight / 2, - x - rectWidth / 2 + cornerRadius, - y - rectHeight / 2, - ); - ctx.closePath(); - - ctx.fill(); - ctx.stroke(); ctx.restore(); - }; + }, + }), + [horizontalReferenceLines], + ); - // Draw selection point for the hovered data point - const activeElements = chart.getActiveElements(); - for (const activeElement of activeElements) { - const datasetIndex = activeElement.datasetIndex; - const index = activeElement.index; - const dataPoint = chart.getDatasetMeta(datasetIndex).data[index]; - - if (dataPoint) { - const { x, y } = dataPoint.getProps(["x", "y"], true); - const pointRadius = dataPoint.options.radius; - const pointStyle = dataPoint.options.pointStyle; - drawSelectionPoint(x, y, pointRadius, pointStyle); - } - } + const selectionPointPlugin: Plugin = useMemo( + () => ({ + id: "customSelectPoint", + afterDraw: (chart: Chart) => { + const ctx = chart.ctx; + if (!ctx) return; + + // Define the function to draw the selection point + const drawSelectionPoint = ( + x: number, + y: number, + pointRadius: number, + pointStyle: PointStyle, + color?: string, + ) => { + // console.info("🚀 ~ drawSelectionPoint ~ pointRadius:", pointRadius); + ctx.save(); + ctx.fillStyle = "transparent"; + ctx.strokeStyle = color || "black"; + ctx.lineWidth = !!color ? 2 : 1; + + const rectWidth = pointRadius * 2.5 || 10; + const rectHeight = pointRadius * 2.5 || 10; + const cornerRadius = pointStyle === "rect" ? 0 : pointRadius * 1.5; - // Draw the circle around currently selected element (i.e. clicked) - const [selectedPointDatasetIndex, selectedPointIndex] = selectedPointRef.current || []; - if (selectedPointDatasetIndex !== undefined && selectedPointIndex !== undefined) { - const dataPoint = chart.getDatasetMeta(selectedPointDatasetIndex).data[selectedPointIndex]; - if (dataPoint) { - const { x, y } = dataPoint.getProps(["x", "y"], true); - const pointRadius = dataPoint.options.radius; - const pointStyle = dataPoint.options.pointStyle; - drawSelectionPoint(x, y, pointRadius, pointStyle, "#387F5C"); - } - } - }, - }), - [selectedPointRef.current], - ); + ctx.beginPath(); + ctx.moveTo(x - rectWidth / 2 + cornerRadius, y - rectHeight / 2); + ctx.lineTo(x + rectWidth / 2 - cornerRadius, y - rectHeight / 2); + ctx.quadraticCurveTo( + x + rectWidth / 2, + y - rectHeight / 2, + x + rectWidth / 2, + y - rectHeight / 2 + cornerRadius, + ); + ctx.lineTo(x + rectWidth / 2, y + rectHeight / 2 - cornerRadius); + ctx.quadraticCurveTo( + x + rectWidth / 2, + y + rectHeight / 2, + x + rectWidth / 2 - cornerRadius, + y + rectHeight / 2, + ); + ctx.lineTo(x - rectWidth / 2 + cornerRadius, y + rectHeight / 2); + ctx.quadraticCurveTo( + x - rectWidth / 2, + y + rectHeight / 2, + x - rectWidth / 2, + y + rectHeight / 2 - cornerRadius, + ); + ctx.lineTo(x - rectWidth / 2, y - rectHeight / 2 + cornerRadius); + ctx.quadraticCurveTo( + x - rectWidth / 2, + y - rectHeight / 2, + x - rectWidth / 2 + cornerRadius, + y - rectHeight / 2, + ); + ctx.closePath(); + + ctx.fill(); + ctx.stroke(); + ctx.restore(); + }; - const selectionCallbackPlugin: Plugin = useMemo( - () => ({ - id: "selectionCallback", - afterDraw: (chart: Chart) => { - onMouseOver?.(chart.getActiveElements()[0]?.index); - }, - }), - [], - ); + // Draw selection point for the hovered data point + const activeElements = chart.getActiveElements(); + for (const activeElement of activeElements) { + const datasetIndex = activeElement.datasetIndex; + const index = activeElement.index; + const dataPoint = chart.getDatasetMeta(datasetIndex).data[index]; + + if (dataPoint) { + const { x, y } = dataPoint.getProps(["x", "y"], true); + const pointRadius = dataPoint.options.radius; + const pointStyle = dataPoint.options.pointStyle; + drawSelectionPoint(x, y, pointRadius, pointStyle); + } + } - const chartOptions: ChartOptions = useMemo(() => { - return { - maintainAspectRatio: false, - responsive: true, - plugins: { - tooltip: toolTipOptions || {}, - legend: { - display: false, + // Draw the circle around currently selected element (i.e. clicked) + const [selectedPointDatasetIndex, selectedPointIndex] = selectedPointRef.current || []; + if (selectedPointDatasetIndex !== undefined && selectedPointIndex !== undefined) { + const dataPoint = chart.getDatasetMeta(selectedPointDatasetIndex).data[selectedPointIndex]; + if (dataPoint) { + const { x, y } = dataPoint.getProps(["x", "y"], true); + const pointRadius = dataPoint.options.radius; + const pointStyle = dataPoint.options.pointStyle; + drawSelectionPoint(x, y, pointRadius, pointStyle, "#387F5C"); + } + } }, - }, - layout: { - // Tick padding must be uniform, undo it here - padding: { - left: 0, - right: 0, - top: 0, - bottom: 0, + }), + [selectedPointRef.current], + ); + + const selectionCallbackPlugin: Plugin = useMemo( + () => ({ + id: "selectionCallback", + afterDraw: (chart: Chart) => { + onMouseOver?.(chart.getActiveElements()[0]?.index); }, - }, - interaction: { - mode: "nearest", - intersect: false, - }, - scales: { - x: { - title: { - display: true, - text: xOptions.label || "", + }), + [], + ); + + const chartOptions: ChartOptions = useMemo(() => { + return { + maintainAspectRatio: false, + responsive: true, + plugins: { + tooltip: toolTipOptions || {}, + legend: { + display: false, }, - type: "linear", - position: "bottom", - min: xOptions.min, - max: Math.round((xOptions.max / 10) * 10), // round to nearest 10 so auto tick generation works - ticks: { - padding: 0, - callback: (val) => `${Number(val)}M`, + }, + layout: { + // Tick padding must be uniform, undo it here + padding: { + left: 0, + right: 0, + top: 0, + bottom: 0, }, }, - y: { - title: { - display: true, - text: yOptions.label || "", + interaction: { + mode: "nearest", + intersect: false, + }, + scales: { + x: { + title: { + display: true, + text: xOptions.label || "", + }, + type: "linear", + position: "bottom", + min: xOptions.min, + max: Math.round((xOptions.max / 10) * 10), // round to nearest 10 so auto tick generation works + ticks: { + padding: 0, + callback: (val) => `${Number(val)}M`, + }, }, - ticks: { - padding: 0, + y: { + title: { + display: true, + text: yOptions.label || "", + }, + ticks: { + padding: 0, + }, }, }, - }, - onClick: (event, activeElements, chart) => { - const activeElement = activeElements[0]; - selectedPointRef.current = [activeElement.datasetIndex, activeElement.index]; - onPointClick?.(event, activeElements, chart); - }, - }; - }, [data, yTickMin, yTickMax, valueFormatter, useLogarithmicScale, customValueTransform]); + onClick: (event, activeElements, chart) => { + const activeElement = activeElements[0]; + selectedPointRef.current = [activeElement.datasetIndex, activeElement.index]; + onPointClick?.(event, activeElements, chart); + }, + }; + }, [data, yTickMin, yTickMax, valueFormatter, useLogarithmicScale, customValueTransform]); - const allPlugins = useMemo( - () => [verticalLinePlugin, horizontalReferenceLinePlugin, selectionPointPlugin, selectionCallbackPlugin], - [verticalLinePlugin, horizontalReferenceLinePlugin, selectionPointPlugin, selectionCallbackPlugin], - ); + const allPlugins = useMemo( + () => [verticalLinePlugin, horizontalReferenceLinePlugin, selectionPointPlugin, selectionCallbackPlugin], + [verticalLinePlugin, horizontalReferenceLinePlugin, selectionPointPlugin, selectionCallbackPlugin], + ); - const chartDimensions = useMemo(() => { - if (size === "small") { - return { - w: 3, - h: 1, - }; - } else { - return { - w: 6, - h: 2, - }; - } - }, [size]); + const chartDimensions = useMemo(() => { + if (size === "small") { + return { + w: 3, + h: 1, + }; + } else { + return { + w: 6, + h: 2, + }; + } + }, [size]); return ( point.podScore) + .map((point) => point.podScore) .filter((score): score is number => score !== undefined); // Build color scaler from listing scores const colorScaler = buildPodScoreColorScaler(listingScores); // Map through listings and apply colors - result[1].data = result[1].data.map(point => ({ + result[1].data = result[1].data.map((point) => ({ ...point, - color: point.podScore !== undefined - ? colorScaler.toColor(point.podScore) - : "#e0b57d", // Fallback color for invalid Pod Scores + color: point.podScore !== undefined ? colorScaler.toColor(point.podScore) : "#e0b57d", // Fallback color for invalid Pod Scores })); return result; @@ -194,7 +192,7 @@ export function Market() { const harvestableIndex = useHarvestableIndex(); const podLineAsNumber = podLine.toNumber() / MILLION; const navHeight = useNavHeight(); - + // Overlay state and chart ref const chartRef = useRef(null); const [overlayParams, setOverlayParams] = useState(null); @@ -212,15 +210,15 @@ export function Market() { // Extract Pod Scores from market listings for overlay color scaling const marketListingScores = useMemo(() => { if (!scatterChartData || scatterChartData.length < 2) return []; - + // Listings are at index 1 in scatterChartData const listingsData = scatterChartData[1]; if (!listingsData?.data) return []; - + const scores = listingsData.data - .map(point => point.podScore) + .map((point) => point.podScore) .filter((score): score is number => score !== undefined); - + return scores; }, [scatterChartData]); @@ -229,7 +227,7 @@ export function Market() { const chartXMax = useMemo(() => { // Use podLineAsNumber if available, otherwise use a reasonable default const maxValue = podLineAsNumber > 0 ? podLineAsNumber : 50; // Default to 50 million - + // Ensure a minimum value for the chart to render properly return Math.max(maxValue, 1); // At least 1 million }, [podLineAsNumber]); @@ -300,13 +298,14 @@ export function Market() { return score.toFixed(2); } }; - - const podScoreRow = dataPoint.eventType === "LISTING" && dataPoint.podScore !== undefined - ? `
+ + const podScoreRow = + dataPoint.eventType === "LISTING" && dataPoint.podScore !== undefined + ? `
Pod Score: ${formatPodScore(dataPoint.podScore)}
` - : ''; + : ""; tooltipEl.innerHTML = `
@@ -470,18 +469,17 @@ export function Market() { onPointClick={onPointClick} toolTipOptions={toolTipOptions as TooltipOptions} /> - + {/* Gradient Legend - positioned in top-right corner */}
- +
- + )} - {viewMode === "buy" && id === "fill" && } + {viewMode === "buy" && id === "fill" && ( + + )} {viewMode === "sell" && id === "create" && ( )} diff --git a/src/pages/market/actions/CreateListing.tsx b/src/pages/market/actions/CreateListing.tsx index 3603bb956..3f377e226 100644 --- a/src/pages/market/actions/CreateListing.tsx +++ b/src/pages/market/actions/CreateListing.tsx @@ -1,6 +1,7 @@ import settingsIcon from "@/assets/misc/Settings.svg"; import pintoIcon from "@/assets/tokens/PINTO.png"; import { TV, TokenValue } from "@/classes/TokenValue"; +import type { OverlayParams, PlotOverlayData } from "@/components/MarketChartOverlay"; import PodLineGraph from "@/components/PodLineGraph"; import SmartSubmitButton from "@/components/SmartSubmitButton"; import { Button } from "@/components/ui/Button"; @@ -29,7 +30,6 @@ import { useLocation, useNavigate } from "react-router-dom"; import { toast } from "sonner"; import { encodeFunctionData } from "viem"; import { useAccount } from "wagmi"; -import type { OverlayParams, PlotOverlayData } from "@/components/MarketChartOverlay"; interface PodListingData { plot: Plot; diff --git a/src/pages/market/actions/CreateOrder.tsx b/src/pages/market/actions/CreateOrder.tsx index 1ae79943b..383d38819 100644 --- a/src/pages/market/actions/CreateOrder.tsx +++ b/src/pages/market/actions/CreateOrder.tsx @@ -2,6 +2,7 @@ import podIcon from "@/assets/protocol/Pod.png"; import { TV, TokenValue } from "@/classes/TokenValue"; import { ComboInputField } from "@/components/ComboInputField"; import FrameAnimator from "@/components/LoadingSpinner"; +import type { OverlayParams } from "@/components/MarketChartOverlay"; import PodLineGraph from "@/components/PodLineGraph"; import RoutingAndSlippageInfo, { useRoutingAndSlippageWarning } from "@/components/RoutingAndSlippageInfo"; import SlippageButton from "@/components/SlippageButton"; @@ -37,7 +38,6 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useNavigate } from "react-router-dom"; import { toast } from "sonner"; import { useAccount } from "wagmi"; -import type { OverlayParams } from "@/components/MarketChartOverlay"; // Constants const PRICE_PER_POD_CONFIG = { @@ -176,7 +176,7 @@ export default function CreateOrder({ onOverlayParamsChange }: CreateOrderProps // Throttle overlay parameter updates for better performance const overlayUpdateTimerRef = useRef(null); - + useEffect(() => { // Clear any pending update if (overlayUpdateTimerRef.current) { diff --git a/src/pages/market/actions/FillListing.tsx b/src/pages/market/actions/FillListing.tsx index 5fc3299e6..d3fb1c49f 100644 --- a/src/pages/market/actions/FillListing.tsx +++ b/src/pages/market/actions/FillListing.tsx @@ -2,6 +2,7 @@ import podIcon from "@/assets/protocol/Pod.png"; import { TV, TokenValue } from "@/classes/TokenValue"; import { ComboInputField } from "@/components/ComboInputField"; import FrameAnimator from "@/components/LoadingSpinner"; +import type { OverlayParams } from "@/components/MarketChartOverlay"; import PodLineGraph from "@/components/PodLineGraph"; import RoutingAndSlippageInfo, { useRoutingAndSlippageWarning } from "@/components/RoutingAndSlippageInfo"; import SlippageButton from "@/components/SlippageButton"; @@ -43,7 +44,6 @@ import { useNavigate, useSearchParams } from "react-router-dom"; import { toast } from "sonner"; import { Address, encodeFunctionData } from "viem"; import { useAccount } from "wagmi"; -import type { OverlayParams } from "@/components/MarketChartOverlay"; // Configuration constants const PRICE_PER_POD_CONFIG = { @@ -64,7 +64,7 @@ const TextAdornment = ({ text, className }: { text: string; className?: string } /** * Calculates Pod Score for a listing based on price per pod and place in line. * Formula: (1/pricePerPod - 1) / placeInLine * 1e6 - * + * * @param pricePerPod - Price per pod (must be > 0) * @param placeInLine - Position in harvest queue in millions (must be > 0) * @returns Pod Score value, or undefined for invalid inputs @@ -77,7 +77,7 @@ const calculatePodScore = (pricePerPod: number, placeInLine: number): number | u // Calculate return: (1/pricePerPod - 1) const returnValue = 1 / pricePerPod - 1; - + // Calculate Pod Score: return / placeInLine * 1e6 const podScore = (returnValue / placeInLine) * 1e6; @@ -216,12 +216,12 @@ export default function FillListing({ onOverlayParamsChange }: FillListingProps // Calculate Pod Score for the selected listing const listingPodScore = useMemo(() => { if (!selectedListing) return undefined; - + const price = TokenValue.fromBlockchain(selectedListing.pricePerPod, mainToken.decimals).toNumber(); const placeInLine = TokenValue.fromBlockchain(selectedListing.index, PODS.decimals) .sub(harvestableIndex) .toNumber(); - + // Use placeInLine in millions for consistent scaling return calculatePodScore(price, placeInLine / 1_000_000); }, [selectedListing, mainToken.decimals, harvestableIndex]); @@ -764,9 +764,9 @@ export default function FillListing({ onOverlayParamsChange }: FillListingProps

Pod Score:{" "} - {listingPodScore !== undefined + {listingPodScore !== undefined ? formatter.number(listingPodScore, { minDecimals: 2, maxDecimals: 2 }) - : 'N/A'} + : "N/A"}

diff --git a/src/utils/podScore.ts b/src/utils/podScore.ts index 8d3e3584f..45cdc472a 100644 --- a/src/utils/podScore.ts +++ b/src/utils/podScore.ts @@ -1,6 +1,6 @@ /** * Pod Score Calculation Utility - * + * * Provides the Pod Score calculation function for evaluating pod listings. * Pod Score = (Return / Place in Line) * 1e6 * where Return = (1/pricePerPod - 1) @@ -8,9 +8,9 @@ /** * Calculate Pod Score for a pod listing - * + * * Formula: (1/pricePerPod - 1) / placeInLine * 1e6 - * + * * @param pricePerPod - Price per pod (must be > 0) * @param placeInLine - Position in harvest queue in millions (must be > 0) * @returns Pod Score value, or undefined for invalid inputs @@ -20,20 +20,20 @@ export const calculatePodScore = (pricePerPod: number, placeInLine: number): num if (pricePerPod <= 0 || placeInLine <= 0) { return undefined; } - + // Calculate return: (1/pricePerPod - 1) // When pricePerPod < 1.0: positive return (good deal) // When pricePerPod = 1.0: zero return (break even) // When pricePerPod > 1.0: negative return (bad deal) const returnValue = 1 / pricePerPod - 1; - + // Calculate Pod Score: return / placeInLine * 1e6 const podScore = (returnValue / placeInLine) * 1e6; - + // Filter out invalid results (NaN, Infinity) if (!Number.isFinite(podScore)) { return undefined; } - + return podScore; }; diff --git a/src/utils/podScoreColorScaler.ts b/src/utils/podScoreColorScaler.ts index 594649cc8..232ba73af 100644 --- a/src/utils/podScoreColorScaler.ts +++ b/src/utils/podScoreColorScaler.ts @@ -1,6 +1,6 @@ /** * Pod Score Color Scaler Utility - * + * * Provides percentile-based color scaling for Pod Score visualization. * Maps scores to a three-stop gradient: brown (poor) → gold (average) → green (good) */ @@ -14,12 +14,12 @@ interface RGB { } interface ScalerOptions { - lowerPct?: number; // Default: 5 - upperPct?: number; // Default: 95 - smoothFactor?: number; // Default: 0 (no smoothing) - bad?: Hex; // Default: '#91580D' - mid?: Hex; // Default: '#E8C15F' - good?: Hex; // Default: '#A8E868' + lowerPct?: number; // Default: 5 + upperPct?: number; // Default: 95 + smoothFactor?: number; // Default: 0 (no smoothing) + bad?: Hex; // Default: '#91580D' + mid?: Hex; // Default: '#E8C15F' + good?: Hex; // Default: '#A8E868' } export interface ColorScaler { @@ -34,20 +34,20 @@ export interface ColorScaler { function percentile(values: number[], p: number): number { if (values.length === 0) return 0; if (values.length === 1) return values[0]; - + // Sort values in ascending order const sorted = [...values].sort((a, b) => a - b); - + // Calculate index (using linear interpolation) const index = (p / 100) * (sorted.length - 1); const lower = Math.floor(index); const upper = Math.ceil(index); const weight = index - lower; - + if (lower === upper) { return sorted[lower]; } - + return sorted[lower] * (1 - weight) + sorted[upper] * weight; } @@ -86,7 +86,7 @@ function hexToRgb(hex: Hex): RGB { function rgbToHex(c: RGB): Hex { const toHex = (n: number) => { const hex = Math.round(clamp(n, 0, 255)).toString(16); - return hex.length === 1 ? '0' + hex : hex; + return hex.length === 1 ? "0" + hex : hex; }; return `#${toHex(c.r)}${toHex(c.g)}${toHex(c.b)}` as Hex; } @@ -105,7 +105,7 @@ function mix(a: RGB, b: RGB, t: number): RGB { /** * Build a color scaler for Pod Scores - * + * * @param scores - Array of Pod Score values * @param prevBounds - Optional previous bounds for smoothing * @param opts - Optional configuration @@ -114,20 +114,20 @@ function mix(a: RGB, b: RGB, t: number): RGB { export function buildPodScoreColorScaler( scores: number[], prevBounds?: { low: number; high: number } | null, - opts?: ScalerOptions + opts?: ScalerOptions, ): ColorScaler { // Default options const options: Required = { lowerPct: opts?.lowerPct ?? 5, upperPct: opts?.upperPct ?? 95, smoothFactor: opts?.smoothFactor ?? 0, - bad: opts?.bad ?? '#91580D', - mid: opts?.mid ?? '#E8C15F', - good: opts?.good ?? '#A8E868', + bad: opts?.bad ?? "#91580D", + mid: opts?.mid ?? "#E8C15F", + good: opts?.good ?? "#A8E868", }; // Filter out invalid values (NaN, Infinity) - const validScores = scores.filter(s => Number.isFinite(s)); + const validScores = scores.filter((s) => Number.isFinite(s)); // Calculate percentile bounds let low: number; @@ -145,7 +145,7 @@ export function buildPodScoreColorScaler( // Calculate percentiles low = percentile(validScores, options.lowerPct); high = percentile(validScores, options.upperPct); - + // Ensure high > low if (high <= low) { high = low + 1; @@ -156,7 +156,7 @@ export function buildPodScoreColorScaler( if (prevBounds && options.smoothFactor > 0) { low = smooth(prevBounds.low, low, options.smoothFactor); high = smooth(prevBounds.high, high, options.smoothFactor); - + // Ensure smoothed high > smoothed low if (high <= low) { high = low + 1; @@ -182,7 +182,7 @@ export function buildPodScoreColorScaler( */ const toColor = (score: number): Hex => { const unit = toUnit(score); - + // Three-stop gradient: bad → mid → good // 0.0 - 0.5: bad to mid // 0.5 - 1.0: mid to good @@ -196,7 +196,7 @@ export function buildPodScoreColorScaler( const t = (unit - 0.5) / 0.5; rgb = mix(midRgb, goodRgb, t); } - + return rgbToHex(rgb); }; From fd685e905fbf606bdd2c9df95de17aa2622a9f6e Mon Sep 17 00:00:00 2001 From: feyyazcigim Date: Sun, 23 Nov 2025 19:16:52 +0300 Subject: [PATCH 49/50] Fix overlay calculations --- src/components/MarketChartOverlay.tsx | 202 ++++++++++++++++++++--- src/pages/Market.tsx | 5 +- src/pages/market/actions/FillListing.tsx | 26 ++- 3 files changed, 202 insertions(+), 31 deletions(-) diff --git a/src/components/MarketChartOverlay.tsx b/src/components/MarketChartOverlay.tsx index 7cff02962..6966fcac0 100644 --- a/src/components/MarketChartOverlay.tsx +++ b/src/components/MarketChartOverlay.tsx @@ -133,21 +133,88 @@ const MarketChartOverlay = React.memo( // Optimized resize handling with single debounced handler useEffect(() => { let timeoutId: NodeJS.Timeout; + let retryTimeouts: NodeJS.Timeout[] = []; let animationFrameId: number | null = null; let resizeObserver: ResizeObserver | null = null; + let isMounted = true; + + // Check if chart is fully ready with all required properties + const isChartReady = (): boolean => { + const chart = chartRef.current; + if (!chart) return false; + + // Check canvas is in DOM (critical check to prevent ownerDocument errors) + if (!chart.canvas || !chart.canvas.ownerDocument) return false; + + // Check chartArea exists and has valid dimensions + if (!chart.chartArea) return false; + const { left, top, right, bottom } = chart.chartArea; + if ( + typeof left !== "number" || + typeof top !== "number" || + typeof right !== "number" || + typeof bottom !== "number" || + right - left <= 0 || + bottom - top <= 0 + ) { + return false; + } + + // Check scales exist and are ready + if (!chart.scales?.x || !chart.scales?.y) return false; + const xScale = chart.scales.x; + const yScale = chart.scales.y; + + // Check scales have required methods and valid ranges + if ( + typeof xScale.getPixelForValue !== "function" || + typeof yScale.getPixelForValue !== "function" || + xScale.min === undefined || + xScale.max === undefined || + yScale.min === undefined || + yScale.max === undefined + ) { + return false; + } + + return true; + }; const updateDimensions = () => { + if (!isMounted) return; + + const chart = chartRef.current; + + // Ensure Chart.js resizes first before getting dimensions + // Only resize if canvas is in DOM + if (chart?.canvas?.ownerDocument && isChartReady()) { + try { + chart.resize(); + } catch (error) { + // If resize fails, chart might be detached, skip resize + console.warn("Chart resize failed, canvas may be detached:", error); + } + } + // Use requestAnimationFrame to sync with browser's repaint cycle + // Double RAF ensures Chart.js has finished resizing and updated chartArea if (animationFrameId !== null) { cancelAnimationFrame(animationFrameId); } animationFrameId = requestAnimationFrame(() => { - const newDimensions = getChartDimensions(); - if (newDimensions) { - setDimensions(newDimensions); - } - animationFrameId = null; + // Second RAF to ensure Chart.js has updated chartArea after resize + requestAnimationFrame(() => { + if (!isMounted) return; + + // Try to get dimensions even if chart is not fully ready + // This ensures overlay can render when chartArea is available + const newDimensions = getChartDimensions(); + if (newDimensions) { + setDimensions(newDimensions); + } + animationFrameId = null; + }); }); }; @@ -156,11 +223,50 @@ const MarketChartOverlay = React.memo( timeoutId = setTimeout(updateDimensions, RESIZE_DEBOUNCE_MS); }; - // Initial update - const initialTimeout = setTimeout(() => { - const dimensions = getChartDimensions(); - if (dimensions) setDimensions(dimensions); - }, DIMENSION_UPDATE_DELAY_MS); + // Aggressive retry mechanism for direct link navigation + const tryUpdateDimensions = (attempt = 0, maxAttempts = 10) => { + if (!isMounted) return; + + if (isChartReady()) { + const chart = chartRef.current; + if (chart?.canvas?.ownerDocument) { + // Only update if canvas is in DOM + try { + chart.update("none"); + } catch (error) { + // If update fails, chart might be detached, skip update + console.warn("Chart update failed, canvas may be detached:", error); + } + } + + // Use triple RAF to ensure chart is fully rendered + requestAnimationFrame(() => { + requestAnimationFrame(() => { + requestAnimationFrame(() => { + if (!isMounted) return; + // Try to get dimensions even if chart is not fully ready + // This ensures overlay can render when chartArea is available + const newDimensions = getChartDimensions(); + if (newDimensions) { + setDimensions(newDimensions); + } else if (attempt < maxAttempts) { + // Retry if dimensions still not available + const timeout = setTimeout(() => tryUpdateDimensions(attempt + 1, maxAttempts), 50); + retryTimeouts.push(timeout); + } + }); + }); + }); + } else if (attempt < maxAttempts) { + // Chart not ready, retry with exponential backoff + const delay = Math.min(50 * 1.5 ** attempt, 500); + const timeout = setTimeout(() => tryUpdateDimensions(attempt + 1, maxAttempts), delay); + retryTimeouts.push(timeout); + } + }; + + // Start initial update attempts + tryUpdateDimensions(); // Single ResizeObserver for all resize events if (typeof ResizeObserver !== "undefined") { @@ -179,18 +285,19 @@ const MarketChartOverlay = React.memo( // Fallback window resize listener with passive flag for better performance window.addEventListener("resize", debouncedUpdate, { passive: true }); - // Listen to Chart.js resize events for immediate sync + // Initial resize to ensure chart is properly sized const chart = chartRef.current; if (chart) { - // Chart.js emits 'resize' event when chart dimensions change chart.resize(); // Force update after chart is ready updateDimensions(); } return () => { - clearTimeout(initialTimeout); + isMounted = false; clearTimeout(timeoutId); + retryTimeouts.forEach(clearTimeout); + retryTimeouts = []; if (animationFrameId !== null) { cancelAnimationFrame(animationFrameId); } @@ -202,10 +309,57 @@ const MarketChartOverlay = React.memo( // Update dimensions when overlay params or visibility changes useEffect(() => { if (visible && throttledOverlayParams) { - const newDimensions = getChartDimensions(); - if (newDimensions) { - setDimensions(newDimensions); - } + const tryUpdate = (attempt = 0, maxAttempts = 15) => { + const chart = chartRef.current; + + // Comprehensive chart readiness check + if ( + chart?.canvas?.ownerDocument && // Critical: canvas must be in DOM + chart?.chartArea && + chart.scales?.x && + chart.scales?.y && + typeof chart.chartArea.left === "number" && + typeof chart.chartArea.right === "number" && + typeof chart.chartArea.top === "number" && + typeof chart.chartArea.bottom === "number" && + chart.chartArea.right - chart.chartArea.left > 0 && + chart.chartArea.bottom - chart.chartArea.top > 0 && + typeof chart.scales.x.getPixelForValue === "function" && + typeof chart.scales.y.getPixelForValue === "function" && + chart.scales.x.min !== undefined && + chart.scales.x.max !== undefined && + chart.scales.y.min !== undefined && + chart.scales.y.max !== undefined + ) { + // Force chart update to ensure everything is synced + // Only update if canvas is in DOM + try { + chart.update("none"); + } catch (error) { + // If update fails, chart might be detached, but still try to get dimensions + console.warn("Chart update failed, canvas may be detached:", error); + } + + // Use triple RAF to ensure chart is fully rendered + // Continue with dimension update even if chart.update() failed + requestAnimationFrame(() => { + requestAnimationFrame(() => { + requestAnimationFrame(() => { + const newDimensions = getChartDimensions(); + if (newDimensions) { + setDimensions(newDimensions); + } + }); + }); + }); + } else if (attempt < maxAttempts) { + // Chart not ready, retry with exponential backoff + const delay = Math.min(50 * 1.3 ** attempt, 300); + setTimeout(() => tryUpdate(attempt + 1, maxAttempts), delay); + } + }; + + tryUpdate(); } }, [throttledOverlayParams, visible, getChartDimensions]); @@ -256,6 +410,7 @@ const MarketChartOverlay = React.memo( fill={BUY_OVERLAY_COLORS.shadedRegion} stroke={BUY_OVERLAY_COLORS.border} strokeWidth={1} + strokeDasharray="4 4" style={{ willChange: "auto", transition: "x 0.15s ease-out, y 0.15s ease-out, width 0.15s ease-out, height 0.15s ease-out", @@ -285,8 +440,9 @@ const MarketChartOverlay = React.memo( if (placeInLineNum < 0) return null; // Batch calculations for better performance + // Overlay should extend to the middle of the pod, not the end const startX = placeInLineNum / MILLION; - const endX = (placeInLineNum + plot.amount.toNumber()) / MILLION; + const endX = (placeInLineNum + plot.amount.toNumber() / 2) / MILLION; // Fast validation if (startX < 0 || endX <= startX) return null; @@ -390,7 +546,7 @@ const MarketChartOverlay = React.memo( return ( <> - {/* Horizontal price line - Tailwind + minimal inline styles */} + {/* Horizontal price line */}
( }} /> - {/* Selection boxes - Tailwind + minimal inline styles */} + {/* Selection boxes */} {memoizedRectangles.map((rect) => { const centerX = rect.x + (rect.width >> 1); // Bit shift for division by 2 const centerY = clampedPriceY; @@ -423,14 +579,14 @@ const MarketChartOverlay = React.memo( ); })} - {/* Vertical lines - Tailwind + minimal inline styles */} + {/* Vertical lines */} {memoizedRectangles.length > 0 && ( <>
> 1), // Pod'un ortası top, height: lineHeight, }} @@ -439,7 +595,7 @@ const MarketChartOverlay = React.memo( key="vertical-line-right" className={`${BASE_OVERLAY_CLASSES} ${TRANSITION_CLASSES} w-0 border-l border-dashed border-black`} style={{ - left: memoizedRectangles[lastRectIndex].x + memoizedRectangles[lastRectIndex].width, + left: memoizedRectangles[lastRectIndex].x + (memoizedRectangles[lastRectIndex].width >> 1), // Pod'un ortası top, height: lineHeight, }} diff --git a/src/pages/Market.tsx b/src/pages/Market.tsx index 9dea8b359..2196bab0d 100644 --- a/src/pages/Market.tsx +++ b/src/pages/Market.tsx @@ -396,7 +396,10 @@ export function Market() { }); if (dataPoint.eventType === "LISTING") { - navigate(`/market/pods/buy/fill?listingId=${dataPoint.eventId}`); + // Include placeInLine in URL so FillListing can set it correctly + const placeInLine = dataPoint.placeInLine; + const placeInLineParam = placeInLine ? `&placeInLine=${placeInLine}` : ""; + navigate(`/market/pods/buy/fill?listingId=${dataPoint.eventId}${placeInLineParam}`); } else { navigate(`/market/pods/sell/fill?orderId=${dataPoint.eventId}`); } diff --git a/src/pages/market/actions/FillListing.tsx b/src/pages/market/actions/FillListing.tsx index d3fb1c49f..d3fa61534 100644 --- a/src/pages/market/actions/FillListing.tsx +++ b/src/pages/market/actions/FillListing.tsx @@ -123,6 +123,7 @@ export default function FillListing({ onOverlayParamsChange }: FillListingProps const navigate = useNavigate(); const [searchParams] = useSearchParams(); const listingId = searchParams.get("listingId"); + const placeInLineFromUrl = searchParams.get("placeInLine"); const filterTokens = useFilterTokens(); @@ -240,17 +241,28 @@ export default function FillListing({ onOverlayParamsChange }: FillListingProps setMaxPricePerPod(formattedPrice); setMaxPricePerPodInput(formattedPrice.toFixed(PRICE_PER_POD_CONFIG.DECIMALS)); - // Calculate listing's place in line - const listingIndex = TokenValue.fromBlockchain(listing.index, PODS.decimals); - const placeInLine = listingIndex.sub(harvestableIndex).toNumber(); + // Use placeInLine from URL if available (from chart click), otherwise calculate from listing index + let placeInLine: number; + if (placeInLineFromUrl) { + // Use the exact place in line value from the chart (pod's actual place in line) + placeInLine = Number.parseInt(placeInLineFromUrl, 10); + if (Number.isNaN(placeInLine) || placeInLine <= 0) { + // Fallback to calculating from listing index if URL value is invalid + const listingIndex = TokenValue.fromBlockchain(listing.index, PODS.decimals); + placeInLine = listingIndex.sub(harvestableIndex).toNumber(); + } + } else { + // Calculate listing's place in line from index (fallback for direct URL access) + const listingIndex = TokenValue.fromBlockchain(listing.index, PODS.decimals); + placeInLine = listingIndex.sub(harvestableIndex).toNumber(); + } - // Set max place in line to include this listing with a small margin + // Set max place in line to the exact pod's place in line (no margin needed since it's the exact value) // Clamp to valid range [0, maxPlace] - const margin = Math.max(1, Math.floor(maxPlace * PLACE_MARGIN_PERCENT)); - const maxPlaceValue = Math.min(maxPlace, Math.ceil(placeInLine + margin)); + const maxPlaceValue = Math.min(maxPlace, Math.max(0, placeInLine)); setMaxPlaceInLine(maxPlaceValue); setHasInitializedPlace(true); // Mark as initialized to prevent default value override - }, [listingId, allListings, maxPlace, mainToken.decimals, harvestableIndex]); + }, [listingId, allListings, maxPlace, mainToken.decimals, harvestableIndex, placeInLineFromUrl]); // Update overlay parameters when maxPricePerPod or maxPlaceInLine changes const overlayUpdateTimerRef = useRef(null); From 7ed7eeaa1febe24dec288b53f153521960a3b889 Mon Sep 17 00:00:00 2001 From: feyyazcigim Date: Mon, 24 Nov 2025 14:58:16 +0300 Subject: [PATCH 50/50] Fix chart overlay resize issue --- src/components/MarketChartOverlay.tsx | 109 +++++++++++++++++++++++--- 1 file changed, 96 insertions(+), 13 deletions(-) diff --git a/src/components/MarketChartOverlay.tsx b/src/components/MarketChartOverlay.tsx index 6966fcac0..d92ca079b 100644 --- a/src/components/MarketChartOverlay.tsx +++ b/src/components/MarketChartOverlay.tsx @@ -285,30 +285,110 @@ const MarketChartOverlay = React.memo( // Fallback window resize listener with passive flag for better performance window.addEventListener("resize", debouncedUpdate, { passive: true }); - // Initial resize to ensure chart is properly sized + // Listen to chart update events (zoom/pan/scale changes) const chart = chartRef.current; if (chart) { + // Listen to chart's update event to catch zoom/pan/scale changes + const handleChartUpdate = () => { + // Use RAF to ensure chart has finished updating + requestAnimationFrame(() => { + requestAnimationFrame(() => { + updateDimensions(); + }); + }); + }; + + // Chart.js doesn't have built-in event system, so we'll use a polling approach + // or listen to chart's internal update cycle + // For now, we'll add a MutationObserver on the canvas to detect changes + let lastScaleMin: { x: number | undefined; y: number | undefined } = { + x: chart.scales?.x?.min, + y: chart.scales?.y?.min, + }; + let lastScaleMax: { x: number | undefined; y: number | undefined } = { + x: chart.scales?.x?.max, + y: chart.scales?.y?.max, + }; + + // Poll for scale changes (zoom/pan) - Chart.js doesn't have built-in scale change events + const scaleCheckInterval = setInterval(() => { + if (!isMounted || !chartRef.current) { + clearInterval(scaleCheckInterval); + return; + } + + const currentChart = chartRef.current; + if (!currentChart?.scales?.x || !currentChart?.scales?.y) return; + + const currentXMin = currentChart.scales.x.min; + const currentXMax = currentChart.scales.x.max; + const currentYMin = currentChart.scales.y.min; + const currentYMax = currentChart.scales.y.max; + + // Check if scales have changed (zoom/pan) + if ( + currentXMin !== lastScaleMin.x || + currentXMax !== lastScaleMax.x || + currentYMin !== lastScaleMin.y || + currentYMax !== lastScaleMax.y + ) { + lastScaleMin = { x: currentXMin, y: currentYMin }; + lastScaleMax = { x: currentXMax, y: currentYMax }; + handleChartUpdate(); + } + }, 200); // Check every 200ms for better performance + chart.resize(); // Force update after chart is ready updateDimensions(); - } - return () => { - isMounted = false; - clearTimeout(timeoutId); - retryTimeouts.forEach(clearTimeout); - retryTimeouts = []; - if (animationFrameId !== null) { - cancelAnimationFrame(animationFrameId); - } - resizeObserver?.disconnect(); - window.removeEventListener("resize", debouncedUpdate); - }; + return () => { + isMounted = false; + clearInterval(scaleCheckInterval); + clearTimeout(timeoutId); + retryTimeouts.forEach(clearTimeout); + retryTimeouts = []; + if (animationFrameId !== null) { + cancelAnimationFrame(animationFrameId); + } + resizeObserver?.disconnect(); + window.removeEventListener("resize", debouncedUpdate); + }; + } else { + return () => { + isMounted = false; + clearTimeout(timeoutId); + retryTimeouts.forEach(clearTimeout); + retryTimeouts = []; + if (animationFrameId !== null) { + cancelAnimationFrame(animationFrameId); + } + resizeObserver?.disconnect(); + window.removeEventListener("resize", debouncedUpdate); + }; + } }, [getChartDimensions, chartRef]); // Update dimensions when overlay params or visibility changes useEffect(() => { if (visible && throttledOverlayParams) { + // Force immediate dimension update when overlay params change + // This ensures overlay position updates correctly when params change + const updateOnParamsChange = () => { + const chart = chartRef.current; + if (!chart?.chartArea) return; + + // Use RAF to ensure chart is ready + requestAnimationFrame(() => { + requestAnimationFrame(() => { + const newDimensions = getChartDimensions(); + if (newDimensions) { + setDimensions(newDimensions); + } + }); + }); + }; + const tryUpdate = (attempt = 0, maxAttempts = 15) => { const chart = chartRef.current; @@ -359,6 +439,9 @@ const MarketChartOverlay = React.memo( } }; + // Try immediate update first + updateOnParamsChange(); + // Also try comprehensive update tryUpdate(); } }, [throttledOverlayParams, visible, getChartDimensions]);