diff --git a/src/components/ComboInputField.tsx b/src/components/ComboInputField.tsx index 44dc1cb1e..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(); @@ -166,30 +194,52 @@ 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); } - 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) { + baseBalance = getFarmerBalanceByMode(farmerTokenBalance, balanceFrom); } - 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); } - }, [mode, selectedPlots, customMaxAmount, tokenAndBalanceMap, selectedToken, balanceFrom, farmerTokenBalance]); + + // Otherwise use base balance + return baseBalance; + }, [ + mode, + selectedPlots, + customMaxAmount, + tokenAndBalanceMap, + selectedToken, + balanceFrom, + farmerTokenBalance, + getFarmerBalanceByMode, + ]); const balance = useMemo(() => { if (mode === "plots" && selectedPlots) { @@ -200,8 +250,9 @@ 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) + return getFarmerBalanceByMode(farmerTokenBalance, balanceFrom); + }, [mode, selectedPlots, tokenAndBalanceMap, selectedToken, farmerTokenBalance, balanceFrom, getFarmerBalanceByMode]); /** * Clamp the input amount to the max amount ONLY IF clamping is enabled @@ -247,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 @@ -413,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/MarketChartOverlay.tsx b/src/components/MarketChartOverlay.tsx new file mode 100644 index 000000000..d92ca079b --- /dev/null +++ b/src/components/MarketChartOverlay.tsx @@ -0,0 +1,721 @@ +import { TokenValue } from "@/classes/TokenValue"; +import { calculatePodScore } from "@/utils/podScore"; +import { buildPodScoreColorScaler } from "@/utils/podScoreColorScaler"; +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 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(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({ + 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 { + overlayParams: OverlayParams; + chartRef: React.RefObject; + visible: boolean; + harvestableIndex: TokenValue; + marketListingScores?: number[]; // Pod Scores from existing market listings for color scaling +} + +type ChartDimensions = { + left: number; + top: number; + width: number; + height: number; + bottom: number; + 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( + ({ 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 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; + + try { + return scale.getPixelForValue(clampedValue); + } catch { + return null; + } + }, + [chartRef], + ); + + // Optimized dimension calculator with minimal object creation + const getChartDimensions = useCallback((): ChartDimensions | null => { + const chart = chartRef.current; + 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" + ) { + return null; + } + + const width = right - left; + const height = bottom - top; + + if (width <= 0 || height <= 0) return null; + + return { left, top, width, height, bottom, right }; + }, [chartRef]); + + // 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(() => { + // 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; + }); + }); + }; + + const debouncedUpdate = () => { + clearTimeout(timeoutId); + timeoutId = setTimeout(updateDimensions, RESIZE_DEBOUNCE_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") { + resizeObserver = new ResizeObserver(debouncedUpdate); + const parent = containerRef.current?.parentElement; + 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 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; + 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; + + // 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); + } + }; + + // Try immediate update first + updateOnParamsChange(); + // Also try comprehensive update + tryUpdate(); + } + }, [throttledOverlayParams, visible, getChartDimensions]); + + // 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], + ); + + // 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; + } + + 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 + // Overlay should extend to the middle of the pod, not the end + const startX = placeInLineNum / MILLION; + const endX = (placeInLineNum + plot.amount.toNumber() / 2) / 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; + } + + // 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; + + const width = clampedEndX - clampedStartX; + const height = bottom - clampedPriceY; + + // Single validation check + if (width <= 0 || height <= 0 || clampedStartX >= right || clampedEndX <= left) { + return null; + } + + return { x: clampedStartX, y: clampedPriceY, width, height }; + }, + [dimensions, calculatePixelPosition, harvestableIndex], + ); + + // Highly optimized rectangle memoization with minimal object creation + const memoizedRectangles = useMemo(() => { + if (!throttledOverlayParams || throttledOverlayParams.mode !== "sell" || !dimensions) return null; + + const { pricePerPod, plots } = throttledOverlayParams; + + // 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) { + // 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, + width: rect.width, + height: rect.height, + plotKey: plot.startIndex.toHuman(), + plotIndex: i, + podScore, + }); + } + } + + return rectangles.length > 0 ? rectangles : null; + }, [throttledOverlayParams, dimensions, calculatePlotRectangle, harvestableIndex]); + + // 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; + + // 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 */} +
+ + {/* Selection boxes */} + {memoizedRectangles.map((rect) => { + 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 ( +
+ ); + })} + + {/* Vertical lines */} + {memoizedRectangles.length > 0 && ( + <> +
> 1), // Pod'un ortası + top, + height: lineHeight, + }} + /> +
> 1), // Pod'un ortası + top, + height: lineHeight, + }} + /> + + )} + + ); + }, + [dimensions, memoizedRectangles, calculatePixelPosition], + ); + + // Don't render if not visible or no overlay params + if (!visible || !throttledOverlayParams || !dimensions) { + return null; + } + + // Determine which overlay to render based on mode + let overlayContent: JSX.Element | null = null; + if (throttledOverlayParams.mode === "buy") { + overlayContent = renderBuyOverlay(throttledOverlayParams); + } else if (throttledOverlayParams.mode === "sell") { + overlayContent = renderSellOverlay(throttledOverlayParams); + } + + if (!overlayContent) { + return null; + } + + return ( +
+ {overlayContent} +
+ ); + }, +); + +MarketChartOverlay.displayName = "MarketChartOverlay"; + +export default MarketChartOverlay; 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/components/PodLineGraph.tsx b/src/components/PodLineGraph.tsx new file mode 100644 index 000000000..55a8da69c --- /dev/null +++ b/src/components/PodLineGraph.tsx @@ -0,0 +1,620 @@ +import { TokenValue } from "@/classes/TokenValue"; +import { PODS } from "@/constants/internalTokens"; +import { useFarmerField } from "@/state/useFarmerField"; +import { useHarvestableIndex, usePodIndex } from "@/state/useFieldData"; +import { Plot } from "@/utils/types"; +import { cn } from "@/utils/utils"; +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"; +import { deriveGroupState } from "./PodLineGraph/selection"; + +// 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 + +// 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; + 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[]; + /** 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; + /** 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; + /** Disable hover and click interactions */ + disableInteractions?: boolean; + /** Additional CSS classes */ + className?: string; + /** Optional: custom label text (default: "My Pods In Line") */ + label?: 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 = i + 1 < sortedPlots.length ? sortedPlots[i + 1] : undefined; + + 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)); + // 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, + endIndex, + totalPods, + plots: currentGroup, + isHarvestable, + isSelected, + }); + + currentGroup = []; + } + } + + return combined; +} + +/** + * Generates nice axis labels at 10M intervals + */ +function generateAxisLabels(min: number, max: number): number[] { + const labels: number[] = []; + const start = Math.floor(min / AXIS_INTERVAL) * AXIS_INTERVAL; + + for (let value = start; value <= max; value += AXIS_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 minValue = maxValue / 10; + + // For values less than 10M, use simple 1M, 2M, 5M pattern + 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; + 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 >= MILLION) { + return `${(value / MILLION).toFixed(0)}M`; + } + if (value >= 1_000) { + return `${(value / 1_000).toFixed(0)}K`; + } + 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 = [], + selectedPodRange, + orderRangeEnd, + rangeOverlay, + onPlotGroupSelect, + disableInteractions = false, + className, + label = "My Pods In Line", +}: PodLineGraphProps) { + const farmerField = useFarmerField(); + const harvestableIndex = useHarvestableIndex(); + 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; + + // 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; + + // 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 */} +
+

{label}

+
+ + {/* Plot container with border */} +
+
+ {/* Harvested Section (Log Scale) - Left 20% (only shown if there are harvested plots) */} + {hasHarvestedPlots && ( +
+ {/* Grid lines (exponential scale) */} +
+ {logGridPoints.map((value) => { + const minValue = maxHarvestedIndex / 10; + const position = calculateLogPosition(value, minValue, maxHarvestedIndex); + + if (position > 100 || position < 0) return null; + + return ( +
+ ); + })} +
+ + {/* Plot rectangles */} +
+ {harvestedPlots.map((plot) => { + const minValue = maxHarvestedIndex / 10; + const plotStart = Math.max(plot.startIndex.toNumber(), minValue); + const plotEnd = Math.max(plot.endIndex.toNumber(), minValue); + + 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); + + return ( + + ); + })} +
+
+ )} + + {/* Podline Section (Linear Scale) - Right 80% or 100% if no harvested plots */} +
+ {/* Grid lines at 10M intervals */} +
+ {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; + + return ( +
+ ); + })} +
+ + {/* Order range overlay (from 0 to orderRangeEnd) */} + {orderRangeEnd?.gt(harvestableIndex) && ( +
+ )} + + {/* 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) => { + const groupStartMinusHarvestable = group.startIndex.sub(harvestableIndex); + const groupEndMinusHarvestable = group.endIndex.sub(harvestableIndex); + + const { leftPercent, displayWidthPercent } = computeGroupLayout( + groupStartMinusHarvestable, + groupEndMinusHarvestable, + podLine, + ); + + 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)); + + // 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), 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 { isHighlighted: groupIsHighlighted, isActive: groupIsActive } = deriveGroupState( + hasHarvestablePlot, + hasSelectedPlot, + hasHoveredPlot, + 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) { + const plotIndices = group.plots.map((p) => p.id || p.index.toHuman()); + onPlotGroupSelect(plotIndices); + } + }; + + 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 ( + + {partialSelectionPercent && hasSelectedPlot && ( + + )} + + ); + })} +
+
+
+
+ + {/* "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 && ( +
+ {logGridPoints.map((value) => { + const minValue = maxHarvestedIndex / 10; + const position = calculateLogPosition(value, minValue, maxHarvestedIndex); + + 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)} +
+ ); + })} +
+
+
+ + {/* 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 new file mode 100644 index 000000000..c0e7f1877 --- /dev/null +++ b/src/components/PodLineGraph/PartialSelectionOverlay.tsx @@ -0,0 +1,28 @@ +import React from "react"; + +interface PartialSelectionOverlayProps { + startPercent: number; + endPercent: number; +} + +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 ( +
+ ); +} + +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..652522411 --- /dev/null +++ b/src/components/PodLineGraph/PlotGroup.tsx @@ -0,0 +1,80 @@ +import { cn } from "@/utils/utils"; +import React, { useMemo } from "react"; + +interface PlotGroupProps { + leftPercent: number; + widthPercent: number; + isHighlighted: boolean; + isActive: boolean; + disableInteractions?: boolean; + onClick?: () => 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, + isHighlighted, + isActive, + 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); diff --git a/src/components/PodLineGraph/geometry.ts b/src/components/PodLineGraph/geometry.ts new file mode 100644 index 000000000..fa01f0b92 --- /dev/null +++ b/src/components/PodLineGraph/geometry.ts @@ -0,0 +1,64 @@ +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, +): GroupLayout { + const podLineNum = podLine.toNumber(); + const startNum = groupStartMinusHarvestable.toNumber(); + const endNum = groupEndMinusHarvestable.toNumber(); + + const leftPercent = toPercent(startNum, podLineNum); + const widthPercent = toPercent(endNum - startNum, podLineNum); + const displayWidthPercent = Math.max(widthPercent, MIN_PLOT_WIDTH_PERCENT); + + return { leftPercent, displayWidthPercent }; +} + +export function computePartialSelectionPercent( + groupStart: TokenValue, + groupEnd: TokenValue, + rangeStart: TokenValue, + rangeEnd: TokenValue, +): 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: toPercent(overlapStartOffset, groupTotal), + end: toPercent(overlapEndOffset, groupTotal), + }; +} diff --git a/src/components/PodLineGraph/selection.ts b/src/components/PodLineGraph/selection.ts new file mode 100644 index 000000000..24307e1d3 --- /dev/null +++ b/src/components/PodLineGraph/selection.ts @@ -0,0 +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, + 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 { isHighlighted, isActive }; +} diff --git a/src/components/PodScoreGradientLegend.tsx b/src/components/PodScoreGradientLegend.tsx new file mode 100644 index 000000000..aabea14ec --- /dev/null +++ b/src/components/PodScoreGradientLegend.tsx @@ -0,0 +1,68 @@ +import { cn } from "@/utils/utils"; +import TooltipSimple from "./TooltipSimple"; + +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/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/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/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/components/charts/ScatterChart.tsx b/src/components/charts/ScatterChart.tsx index 59c8f340a..2d619e090 100644 --- a/src/components/charts/ScatterChart.tsx +++ b/src/components/charts/ScatterChart.tsx @@ -74,424 +74,433 @@ 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); - - useEffect(() => { - activeIndexRef.current = activeIndex; - if (chartRef.current) { - chartRef.current.update("none"); // Disable animations during update - } - }, [activeIndex]); + 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; + 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; + } - 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 + // For logarithmic scale, ensure minTick is positive + if (useLogarithmicScale && minTick <= 0) { + minTick = 0.000001; // Small positive value + } + + // 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; - } - // For logarithmic scale, ensure minTick is positive - if (useLogarithmicScale && minTick <= 0) { - minTick = 0.000001; // Small positive value - } + 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], + ); + + 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 - } - } 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 + ctx.restore(); } - } - } - } + }, + }), + [], + ); - 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, - backgroundColor: 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; - // 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) { + // 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) { + 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], + ); + + 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 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); - } - } + 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(); + }; - // 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], - ); + // 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]; - const selectionCallbackPlugin: Plugin = useMemo( - () => ({ - id: "selectionCallback", - afterDraw: (chart: Chart) => { - onMouseOver?.(chart.getActiveElements()[0]?.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]); - - 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, + onClick: (event, activeElements, chart) => { + const activeElement = activeElements[0]; + selectedPointRef.current = [activeElement.datasetIndex, activeElement.index]; + onPointClick?.(event, activeElements, chart); + }, }; - } - }, [size]); - return ( - - ); - }, + }, [data, yTickMin, yTickMax, valueFormatter, useLogarithmicScale, customValueTransform]); + + 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]); + return ( + + ); + }, + ), areScatterChartPropsEqual, ); @@ -618,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; } @@ -633,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; } @@ -644,4 +655,6 @@ function areScatterChartPropsEqual(prevProps: ScatterChartProps, nextProps: Scat return true; } +ScatterChart.displayName = "ScatterChart"; + export default ScatterChart; diff --git a/src/components/ui/Slider.tsx b/src/components/ui/Slider.tsx index 8b3a54f07..c78f5f961 100644 --- a/src/components/ui/Slider.tsx +++ b/src/components/ui/Slider.tsx @@ -5,20 +5,64 @@ 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) => ( ["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 getPointBottomOffset = () => { - if (window.innerWidth > 1600) { - return 175; - } else if (window.innerWidth > 1100) { - return 130; - } else { - return 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 = (): 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 = { @@ -54,6 +71,8 @@ type MarketScatterChartDataPoint = { amount: number; placeInLine: number; eventIndex?: number; + podScore?: number; + color?: string; }; type MarketScatterChartData = { @@ -64,83 +83,104 @@ type MarketScatterChartData = { pointRadius: number; }; +/** + * Transforms raw market data into scatter chart format with Pod Score coloring + */ const shapeScatterChartData = (data: any[], harvestableIndex: TokenValue): MarketScatterChartData[] => { - return ( - data?.reduce( - (acc, event) => { - // Skip Fill Orders - if ("toFarmer" in event) { - return acc; + if (!data) return []; + + const result = 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) { + // 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, + eventId, + eventIndex, + eventType, + status, + amount, + placeInLine, + podScore, + }); } + } - return acc; + return acc; + }, + [ + { + label: "Orders", + data: [] as MarketScatterChartDataPoint[], + color: "#5CB8A9", // teal + pointStyle: "circle" as PointStyle, + pointRadius: 6, + }, + { + label: "Listings", + data: [] as MarketScatterChartDataPoint[], + color: "#e0b57d", // tan (fallback) + 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, - }, - ], - ) || [] + ], ); + + // 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() { @@ -149,70 +189,125 @@ export function Market() { const navigate = useNavigate(); const { data, isLoaded } = useAllMarket(); const podLine = usePodLine(); - const podLineAsNumber = podLine.toNumber() / 1000000; 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; - } + // 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(() => { + // 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 = "1"; - // 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 = ` + // 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 = ` + // 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}
@@ -226,12 +321,15 @@ export function Market() { Place in Line: ${TokenValue.fromHuman(dataPoint.placeInLine, 0).toHuman("long")}
+ ${podScoreRow}
`; + } } - } - }, - }; + }, + }), + [], + ); // 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. @@ -244,6 +342,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 @@ -265,35 +371,61 @@ 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], ); - 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 || mode === "buy" ? "buy" : "sell", - }); + if (!dataPoint) return; - if (dataPoint.eventType === "LISTING") { - navigate(`/market/pods/buy/${dataPoint.eventIndex.toString().replace(".", "")}`); - } else { - navigate(`/market/pods/sell/${dataPoint.eventId.replace(".", "")}`); - } - }; + // 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") { + // 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}`); + } + }, + [scatterChartData, mode, navigate], + ); - const viewMode = !mode || mode === "buy" ? "buy" : "sell"; - const fillView = !!id; + 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 ( <> @@ -305,7 +437,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. + + + +
{!isLoaded && ( @@ -314,12 +465,31 @@ export function Market() {
)} + + {/* Gradient Legend - positioned in top-right corner */} +
+ +
+ + +
+
+
{TABLE_SLUGS.map((s, idx) => ( @@ -340,14 +510,27 @@ export function Market() { {tab === TABLE_SLUGS[3] && }
-
-
- - {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/MarketActivityTable.tsx b/src/pages/market/MarketActivityTable.tsx index a5bc0b2e6..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); @@ -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}`); } } }, @@ -237,27 +237,13 @@ export function MarketActivityTable({ marketData, titleText, farmer }: MarketAct - {totalPages > 1 && ( -
- -
{`${currentPage} of ${totalPages}`}
- -
- )} + )} diff --git a/src/pages/market/MarketModeSelect.tsx b/src/pages/market/MarketModeSelect.tsx index 49c0d0304..30ecd6e0b 100644 --- a/src/pages/market/MarketModeSelect.tsx +++ b/src/pages/market/MarketModeSelect.tsx @@ -2,31 +2,70 @@ import { Separator } from "@/components/ui/Separator"; import { Tabs, TabsList, TabsTrigger } from "@/components/ui/Tabs"; import { ANALYTICS_EVENTS } from "@/constants/analytics-events"; import { trackSimpleEvent } from "@/utils/analytics"; -import { useCallback } from "react"; -import { useNavigate, useParams, useSearchParams } from "react-router-dom"; +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 mainTab = !mode || mode === "buy" ? "buy" : "sell"; - const secondaryTab = !id || id === "create" ? "create" : "fill"; + // 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], @@ -34,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, @@ -42,7 +84,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 +95,36 @@ export default function MarketModeSelect({ onMainSelectionChange, onSecondarySel return (
- - + + Buy Pods Sell Pods - - - - {mainTab === "buy" ? "Order" : "List"} - Fill - - + {mainTab ? ( + <> + + + + {ACTION_LABELS[mainTab].create} + {ACTION_LABELS[mainTab].fill} + + + + ) : ( +
+
+ Select Buy Pods +
+ or Sell Pods +
+
+ )}
); } diff --git a/src/pages/market/PodListingsTable.tsx b/src/pages/market/PodListingsTable.tsx index 8a8375550..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); @@ -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], ); @@ -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)} @@ -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 21e34480b..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); @@ -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], ); @@ -141,27 +141,13 @@ export function PodOrdersTable() { - {totalPages > 1 && ( -
- -
{`${currentPage} of ${totalPages}`}
- -
- )} + )} diff --git a/src/pages/market/actions/CreateListing.tsx b/src/pages/market/actions/CreateListing.tsx index 1a1eceaa4..3f377e226 100644 --- a/src/pages/market/actions/CreateListing.tsx +++ b/src/pages/market/actions/CreateListing.tsx @@ -1,39 +1,85 @@ +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 DestinationBalanceSelect from "@/components/DestinationBalanceSelect"; -import SimpleInputField from "@/components/SimpleInputField"; +import type { OverlayParams, PlotOverlayData } from "@/components/MarketChartOverlay"; +import PodLineGraph from "@/components/PodLineGraph"; 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 { Switch } from "@/components/ui/Switch"; import { ANALYTICS_EVENTS } from "@/constants/analytics-events"; 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"; 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"; -import { useCallback, useEffect, useMemo, useState } from "react"; -import { useNavigate } from "react-router-dom"; +import { motion } from "framer-motion"; +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"; -const pricePerPodValidation = { - maxValue: 1, - minValue: 0.000001, - maxDecimals: 6, +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ı +} + +// Constants +const PRICE_PER_POD_CONFIG = { + MAX: 1, + 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}
; +}; + +// 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() { +// Utility function to remove trailing zeros from formatted price +const removeTrailingZeros = (value: string): string => { + return value.includes(".") ? value.replace(/\.?0+$/, "") : value; +}; + +interface CreateListingProps { + onOverlayParamsChange?: (params: OverlayParams) => void; +} + +export default function CreateListing({ onOverlayParamsChange }: CreateListingProps) { const { address: account } = useAccount(); const diamondAddress = useProtocolAddress(); const mainToken = useTokenData().mainToken; const harvestableIndex = useHarvestableIndex(); const navigate = useNavigate(); + const farmerField = useFarmerField(); const queryClient = useQueryClient(); const { allPodListings, allMarket, farmerMarket } = useQueryKeys({ account, harvestableIndex }); @@ -41,23 +87,144 @@ export default function CreateListing() { const [plot, setPlot] = useState([]); const [amount, setAmount] = useState(0); - const [expiresIn, setExpiresIn] = useState(undefined); - const [pricePerPod, setPricePerPod] = useState(undefined); - const [balanceTo, setBalanceTo] = useState(FarmToMode.INTERNAL); + const [podRange, setPodRange] = useState<[number, number]>([0, 0]); + 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(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; + 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]); + + // Calculate selected pod range for PodLineGraph partial selection + const selectedPodRange = useMemo(() => { + if (plot.length === 0) return undefined; + + const sortedPlots = plot; // Already sorted in handlePlotSelection + + // 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]); + + // Convert pod range (offset based) to individual plot listing data + const listingData = useMemo((): PodListingData[] => { + if (plot.length === 0 || amount === 0) return []; + + const sortedPlots = plot; // Already sorted in handlePlotSelection + 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]); + + // 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()); + }, []); // Plot selection handler with tracking const handlePlotSelection = useCallback( @@ -66,20 +233,163 @@ export default function CreateListing() { plot_count: plots.length, previous_count: plot.length, }); - setPlot(plots); + + const sortedPlots = sortPlotsByIndex(plots); + 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 { + setPodRange([0, 0]); + setAmount(0); + } }, - [plot.length], + [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; + const newAmount = max - min; + + setPodRange([min, max]); + setAmount(newAmount); + + // If amount becomes 0, clear the plot selection + if (newAmount === 0) { + setPlot([]); + } + }, []); + + // Price per pod slider handler + const handlePriceSliderChange = useCallback((value: number[]) => { + const formatted = formatPricePerPod(value[0]); + setPricePerPod(formatted); + setPricePerPodInput(removeTrailingZeros(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); + + 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(() => { + const numValue = Number.parseFloat(pricePerPodInput); + if (!Number.isNaN(numValue)) { + const formatted = clampAndFormatPrice(numValue); + setPricePerPod(formatted); + setPricePerPodInput(removeTrailingZeros(formatted.toFixed(PRICE_PER_POD_CONFIG.DECIMALS))); + } else { + const formatted = clampAndFormatPrice(PRICE_PER_POD_CONFIG.MIN); + setPricePerPod(formatted); + setPricePerPodInput(removeTrailingZeros(formatted.toFixed(PRICE_PER_POD_CONFIG.DECIMALS))); + } + }, [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(() => { - navigate(`/market/pods/buy/${plot[0].index.toBigInt()}`); + setSuccessAmount(amount); + setSuccessPrice(pricePerPod || 0); + setIsSuccessful(true); setPlot([]); setAmount(0); - setExpiresIn(undefined); - setPricePerPod(undefined); + setPodRange([0, 0]); + setPricePerPod(PRICE_PER_POD_CONFIG.MIN); + setPricePerPodInput(initialPrice); + setExpiresIn(null); allQK.forEach((key) => queryClient.invalidateQueries({ queryKey: key })); - }, [navigate, plot, queryClient, allQK]); + }, [amount, pricePerPod, queryClient, allQK, initialPrice]); // state for toast txns const { isConfirming, writeWithEstimateGas, submitting, setSubmitting } = useTransaction({ @@ -89,42 +399,63 @@ export default function CreateListing() { }); const onSubmit = useCallback(async () => { - if (!pricePerPod || pricePerPod <= 0 || !expiresIn || !amount || amount <= 0 || !account || plot.length !== 1) { + if ( + !pricePerPod || + pricePerPod <= 0 || + selectedExpiresIn <= 0 || + !amount || + amount <= 0 || + !account || + listingData.length === 0 + ) { return; } // Track pod listing creation trackSimpleEvent(ANALYTICS_EVENTS.MARKET.POD_LIST_CREATE, { has_price_per_pod: !!pricePerPod, - plot_position_millions: plot.length > 0 ? Math.round(plotPosition.div(1_000_000).toNumber()) : 0, + listing_count: listingData.length, + plot_position_millions: plot.length > 0 ? Math.round(plotPosition.div(MILLION).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); + // 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(selectedExpiresIn, PODS.decimals); const maxHarvestableIndex = _expiresIn.add(harvestableIndex); try { setSubmitting(true); - toast.loading("Creating Listing..."); + toast.loading(`Creating ${listingData.length} Listing${listingData.length > 1 ? "s" : ""}...`); + + const farmData: `0x${string}`[] = []; + + // Create a listing call for each plot + for (const data of listingData) { + const listingArgs = { + lister: account, + fieldId: 0n, + index: data.index.toBigInt(), + start: data.start.toBigInt(), + podAmount: data.amount.toBigInt(), + pricePerPod: encodedPricePerPod, + maxHarvestableIndex: maxHarvestableIndex.toBigInt(), + minFillAmount: minFill.toBigInt(), + mode: Number(balanceTo), + }; + + const listingCall = encodeFunctionData({ + abi: beanstalkAbi, + functionName: "createPodListing", + args: [listingArgs], + }); + farmData.push(listingCall); + } + + // 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); @@ -138,83 +469,306 @@ export default function CreateListing() { account, amount, pricePerPod, - expiresIn, + selectedExpiresIn, balanceTo, harvestableIndex, minFill, plot, + listingData, + plotPosition, setSubmitting, - mainToken.decimals, diamondAddress, 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 || !expiresIn || !amount || !account || plot.length !== 1; + const disabled = !pricePerPod || !amount || !account || plot.length === 0 || selectedExpiresIn <= 0; return (
-
-

Select Plot

- -
-
-

Amount I want for each Pod

- -
-
-

Expires In

- +
+

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

+ +
+ + {/* Pod Line Graph Visualization */} + p.index.toHuman())} + selectedPodRange={selectedPodRange} + label="My Pods In Line" + onPlotGroupSelect={handlePlotGroupSelect} /> - {!!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

+
+ } + /> +
+ {/* Effective Temperature Display */} + {pricePerPod && pricePerPod > 0 && ( +
+

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

+
+ )} + {/* 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 */} + +
+ {/* Expires In - Auto-set to max expiration */} +
+

Expires In

+
+

{formatter.noDec(0)}

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

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

+ )} +
+
+

Send balances to Farm Balance

+ setBalanceTo(checked ? FarmToMode.INTERNAL : FarmToMode.EXTERNAL)} + /> +
+
+
+
+ )}
- {!disabled && } + {!disabled && ( + + )}
+ + {/* 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! +

+
+ +
+ +
+
+ )}
); } -const ActionSummary = ({ - podAmount, - plotPosition, - pricePerPod, -}: { podAmount: number; plotPosition: TV; pricePerPod: number }) => { +interface ActionSummaryProps { + podAmount: number; + listingData: PodListingData[]; + pricePerPod: number; + harvestableIndex: TokenValue; +} + +const ActionSummary = ({ podAmount, listingData, pricePerPod, harvestableIndex }: ActionSummaryProps) => { const beansOut = podAmount * pricePerPod; + // 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 (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 Line`; + }, [listingData, harvestableIndex]); + return (

If my listing is filled, I will receive

@@ -224,7 +778,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 {linePositions}.

diff --git a/src/pages/market/actions/CreateOrder.tsx b/src/pages/market/actions/CreateOrder.tsx index 26386ed30..383d38819 100644 --- a/src/pages/market/actions/CreateOrder.tsx +++ b/src/pages/market/actions/CreateOrder.tsx @@ -2,11 +2,16 @@ 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 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"; import { ANALYTICS_EVENTS } from "@/constants/analytics-events"; import { PODS } from "@/constants/internalTokens"; import createPodOrder from "@/encoders/createPodOrder"; @@ -27,21 +32,42 @@ 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 { useCallback, useEffect, useMemo, useRef, 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, +// Constants +const PRICE_PER_POD_CONFIG = { + MAX: 1, + 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}
; +}; + +// 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); +}; + +// Utility function to remove trailing zeros from formatted price +const removeTrailingZeros = (value: string): string => { + return value.includes(".") ? value.replace(/\.?0+$/, "") : value; }; const useFilterTokens = () => { @@ -60,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(); @@ -84,6 +114,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); @@ -122,8 +159,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 [pricePerPod, setPricePerPod] = useState(PRICE_PER_POD_CONFIG.MIN); + const [pricePerPodInput, setPricePerPodInput] = useState(initialPrice); // set preferred token useEffect(() => { @@ -135,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) => { @@ -144,16 +219,91 @@ 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(removeTrailingZeros(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); + + 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(() => { + const numValue = Number.parseFloat(pricePerPodInput); + if (!Number.isNaN(numValue)) { + const formatted = clampAndFormatPrice(numValue); + setPricePerPod(formatted); + setPricePerPodInput(removeTrailingZeros(formatted.toFixed(PRICE_PER_POD_CONFIG.DECIMALS))); + } else { + const formatted = clampAndFormatPrice(PRICE_PER_POD_CONFIG.MIN); + setPricePerPod(formatted); + setPricePerPodInput(removeTrailingZeros(formatted.toFixed(PRICE_PER_POD_CONFIG.DECIMALS))); + } + }, [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], ); + // 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); + 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({ @@ -164,6 +314,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, @@ -195,13 +358,14 @@ export default function CreateOrder() { : undefined; const _maxPlaceInLine = TokenValue.fromHuman(maxPlaceInLine?.toString() || "0", PODS.decimals); - const _pricePerPod = TokenValue.fromHuman(pricePerPod?.toString() || "0", mainToken.decimals); - const minFill = TokenValue.fromHuman("1", 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(MIN_FILL_AMOUNT, PODS.decimals); const orderCallStruct = createPodOrder( account, _amount, - Number(_pricePerPod), + encodedPricePerPod, _maxPlaceInLine, minFill, fromMode, @@ -242,6 +406,9 @@ export default function CreateOrder() { diamondAddress, mainToken, swapBuild, + tokenIn.symbol, + podsOut, + amountIn, ]); const swapDataNotReady = (shouldSwap && (!swapData || !swapBuild)) || !!swapQuery.error; @@ -250,93 +417,214 @@ export default function CreateOrder() { const formIsFilled = !!pricePerPod && !!maxPlaceInLine && !!account && amountInTV.gt(0); const disabled = !formIsFilled || swapDataNotReady; + // Calculate orderRangeEnd for PodLineGraph overlay + const orderRangeEnd = useMemo(() => { + if (!maxPlaceInLine) return undefined; + return harvestableIndex.add(TokenValue.fromHuman(maxPlaceInLine.toString(), 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 */} +
+

I am willing to buy Pods up to:

+
+
+

0

+ +

1

+
+ e.target.select()} + placeholder="0.001" + outlined + endIcon={} + /> +
+ {/* Effective Temperature Display */} + {pricePerPod && pricePerPod > 0 && ( +
+

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

+
+ )} +
+ + {/* Order Using Section */} +
+
+

Order Using

+ +
+ + {shouldSwap && amountInTV.gt(0) && ( + + )} + {slippageWarning} +
+
+ + {disabled && formIsFilled && ( +
+ +
+ )} + {!disabled && ( + + )} +
+ + +
+
+
+ )} + + {/* 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}! +

+
+
- +
- )} - {!disabled && ( - - )} -
-
-
+ )}
); } -const ActionSummary = ({ - beansIn, - pricePerPod, - maxPlaceInLine, -}: { beansIn: TV; pricePerPod: number; maxPlaceInLine: number }) => { - const podsOut = beansIn.div(TokenValue.fromHuman(pricePerPod, 6)); +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 062a2934c..d3fa61534 100644 --- a/src/pages/market/actions/FillListing.tsx +++ b/src/pages/market/actions/FillListing.tsx @@ -1,12 +1,17 @@ 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 type { OverlayParams } from "@/components/MarketChartOverlay"; +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 { Slider } from "@/components/ui/Slider"; import { ANALYTICS_EVENTS } from "@/constants/analytics-events"; import { PODS } from "@/constants/internalTokens"; import fillPodListing from "@/encoders/fillPodListing"; @@ -24,22 +29,70 @@ 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 { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useNavigate, useSearchParams } from "react-router-dom"; import { toast } from "sonner"; -import { Address } from "viem"; +import { Address, encodeFunctionData } from "viem"; import { useAccount } from "wagmi"; -import CancelListing from "./CancelListing"; + +// Configuration constants +const PRICE_PER_POD_CONFIG = { + MAX: 1, + MIN: 0.001, + DECIMALS: 6, + DECIMAL_MULTIPLIER: 1_000_000, // 10^6 for 6 decimals +} as const; + +const PRICE_SLIDER_STEP = 0.001; +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}
; +}; + +/** + * 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; +}; const useFilterTokens = () => { const tokens = useTokenMap(); @@ -57,13 +110,20 @@ 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(); const farmerBalances = useFarmerBalances(); const harvestableIndex = useHarvestableIndex(); const navigate = useNavigate(); + const [searchParams] = useSearchParams(); + const listingId = searchParams.get("listingId"); + const placeInLineFromUrl = searchParams.get("placeInLine"); const filterTokens = useFilterTokens(); @@ -83,15 +143,36 @@ 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(""); 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(DEFAULT_PRICE_INPUT); + + // Place in line state + const podIndex = usePodIndex(); + const maxPlace = Number.parseInt(podIndex.toHuman()) - Number.parseInt(harvestableIndex.toHuman()) || 0; + const [maxPlaceInLine, setMaxPlaceInLine] = useState(undefined); + const [hasInitializedPlace, setHasInitializedPlace] = useState(false); + + // Set maxPlaceInLine to maxPlace by default when maxPlace is available (only once on initial load) + useEffect(() => { + if (maxPlace > 0 && !hasInitializedPlace && maxPlaceInLine === undefined) { + setMaxPlaceInLine(maxPlace); + setHasInitializedPlace(true); + } + }, [maxPlace, hasInitializedPlace, maxPlaceInLine]); const isUsingMain = tokensEqual(tokenIn, mainToken); @@ -127,6 +208,98 @@ export default function FillListing() { } }, [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; + + // 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(PRICE_PER_POD_CONFIG.DECIMALS)); + + // 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 the exact pod's place in line (no margin needed since it's the exact value) + // Clamp to valid range [0, maxPlace] + 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, placeInLineFromUrl]); + + // 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) => { @@ -137,16 +310,151 @@ 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); + + 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(() => { + 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.toFixed(PRICE_PER_POD_CONFIG.DECIMALS)); + } else { + setMaxPricePerPodInput("0.000000"); + setMaxPricePerPod(0); + } + }, [maxPricePerPodInput]); + + // 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], + ); + + /** + * 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 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 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 && 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 + 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 = maxPlaceInLine + ? { + start: harvestableIndex, + end: harvestableIndex.add(TokenValue.fromHuman(maxPlaceInLine.toString(), PODS.decimals)), + } + : undefined; + + return { listingPlots: plots, eligibleListingIds: eligible, rangeOverlay: overlay }; + }, [allListings, maxPricePerPod, maxPlaceInLine, 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]); + // 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", @@ -154,76 +462,223 @@ 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; + // Get eligible listings sorted by price (cheapest first) + const eligibleListings = useMemo(() => { + if (!allListings?.podListings.length || eligibleListingIds.length === 0) { + return []; + } + + const eligibleSet = new Set(eligibleListingIds); + 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); + + 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; + + // Single listing - use its price directly + 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; + + 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); + + 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; + + return { + avgPricePerPod: TokenValue.fromHuman(avgPricePerPod, mainToken.decimals), + avgPlaceInLine: TokenValue.fromHuman(avgPlaceInLine, PODS.decimals), + totalPods, + }; + }, [listingsToFill, 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 () => { - if (!listing) { - throw new Error("Listing not found"); - } + // Validate requirements if (!account.address) { + toast.error("Please connect your wallet"); throw new Error("Signer required"); } + 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 + 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, balance_source: balanceFrom, + 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, @@ -238,15 +693,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, - amountIn, + account.address, + listingsToFill, + mainTokensIn, balanceFrom, swapBuild, writeWithEstimateGas, @@ -255,134 +711,264 @@ export default function FillListing() { value, diamondAddress, mainToken, + tokenIn.symbol, + eligibleSummary, ]); - 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 listings to fill + const disabled = !mainTokensIn || mainTokensIn.eq(0) || listingsToFill.length === 0; 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.001" + outlined + 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()}

+ {/* Effective Temperature Display */} + {maxPricePerPod > 0 && ( +
+

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

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

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

+
+ )} +
+ + {/* 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="w-[108px]" + className="" + disabled={maxPlace === 0} + /> +
+ )} +
+ + {/* Show these sections only when maxPlaceInLine is greater than 0 */} + {maxPlaceInLine !== undefined && maxPlaceInLine > 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 && ( + <> +
+
+

Fill Using

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

Fill Using

- +
+ + {disabled && Number(amountIn) > 0 && ( +
+
- + )} +
+ - {!isUsingMain && amountInTV.gt(0) && ( - - )} - {slippageWarning} -
-
- - {disabled && Number(amountIn) > 0 && ( -
- -
- )} - {!disabled && ( - - )}
- - )} +
+ + )} +
+ )} + + {/* 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! +

- + +
+ +
+
)}
); } -const ActionSummary = ({ - pricePerPod, - plotPosition, - beanAmount, -}: { pricePerPod: TV; plotPosition: TV; beanAmount: TV }) => { - const podAmount = beanAmount.div(pricePerPod); +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 }: ActionSummaryProps) => { + // Calculate estimated pods to receive - memoized to avoid recalculation + const estimatedPods = useMemo(() => beanAmount.div(pricePerPod), [beanAmount, 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

); diff --git a/src/pages/market/actions/FillOrder.tsx b/src/pages/market/actions/FillOrder.tsx index 5bb0dd54a..a2f0b6a58 100644 --- a/src/pages/market/actions/FillOrder.tsx +++ b/src/pages/market/actions/FillOrder.tsx @@ -1,10 +1,10 @@ -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 { Button } from "@/components/ui/Button"; 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"; @@ -12,82 +12,270 @@ 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 { useHarvestableIndex } from "@/state/useFieldData"; +import { useFarmerField, useFarmerPlotsQuery } from "@/state/useFarmerField"; +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 { useNavigate, useSearchParams } 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"; +// Constants +const FIELD_ID = 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 navigate = useNavigate(); + const [searchParams] = useSearchParams(); + const orderId = searchParams.get("orderId"); 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 [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(() => { + 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 farmerField = useFarmerField(); + + 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, selectedOrderIds, mainToken.decimals]); - const harvestableIndex = useHarvestableIndex(); - 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", - }); - setPlot(plots); - }, - [plot.length], - ); + const amount = podRange[1] - podRange[0]; + + const ordersToFill = useMemo(() => { + const [rangeStart, rangeEnd] = podRange; + + return orderPositions + .filter((pos) => 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, + }; + }) + .filter((item) => item.amount > 0); + }, [orderPositions, podRange]); + + // Calculate weighted average price per pod once for reuse + const weightedAvgPricePerPod = useMemo(() => { + if (ordersToFill.length === 0 || amount === 0) return 0; + + // Single order - use its price directly + 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; + + 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]); + + const eligibleOrders = useMemo(() => { + if (!allOrders?.podOrders) return []; + + // 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) { + setPodRange([0, totalCapacity]); + prevTotalCapacityRef.current = totalCapacity; + } + }, [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 []; + + 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); + // 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 })); }, [queryClient, allQK]); @@ -97,149 +285,365 @@ 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; } - // Track pod order fill - 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 + 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); - 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, // order - account - fieldId: 0n, // plot - fieldId - maxPlaceInLine: BigInt(order.maxPlaceInLine), // order - maxPlaceInLine - pricePerPod: Number(order.pricePerPod), // order - pricePerPod - minFillAmount: BigInt(order.minFillAmount), // 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 - ], + 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, balanceTo, writeWithEstimateGas, setSubmitting, diamondAddress]); + }, [ + 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]); - 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) */} + { + // 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) => { + const newOrderIds = [...prev]; + plotIndices.forEach((orderId) => { + if (!newOrderIds.includes(orderId)) { + newOrderIds.push(orderId); + } + }); + return newOrderIds; + }); + } + }} + /> + + {/* 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 && selectedOrders.length > 0 && ( + <> - {isOwnOrder ? ( - - ) : ( + {selectedOrders + .filter((order) => order.farmer.id === account.address?.toLowerCase()) + .map((order) => ( + + ))} + + )} + + {/* 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

- -
-
-

Destination

- + {/* 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 */} +
+
+

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"} + % +

+
+
+
+
- {!disabled && ( - + {ordersToFill.length > 0 && amount > 0 && ( + )}
- )} + ); + })()} + + {/* 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! +

+
+ +
+ +
)}
); } -const ActionSummary = ({ - podAmount, - plotPosition, - pricePerPod, -}: { podAmount: TV; plotPosition: TV; pricePerPod: TV }) => { - const beansOut = podAmount.mul(pricePerPod); +interface ActionSummaryProps { + podAmount: number; + pricePerPod: number; +} + +const ActionSummary = ({ podAmount, pricePerPod }: ActionSummaryProps) => { + const beansOut = useMemo(() => podAmount * pricePerPod, [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

diff --git a/src/utils/podScore.ts b/src/utils/podScore.ts new file mode 100644 index 000000000..45cdc472a --- /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..232ba73af --- /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 }, + }; +} diff --git a/tailwind.config.js b/tailwind.config.js index 0d5f4ebad..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", @@ -248,6 +249,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 +266,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",