From f4ae16549d9742362c88f8c5ef3b05a4418e6a9e Mon Sep 17 00:00:00 2001 From: feyyazcigim Date: Mon, 8 Dec 2025 02:32:07 +0300 Subject: [PATCH 01/21] Implement new tractor order form --- src/components/SowOrderDialog.tsx | 356 +++++++++---- .../Tractor/Sow/SowOrderEstimatedTipPaid.tsx | 61 +++ .../Tractor/Sow/SowOrderSharedComponents.tsx | 162 ++++++ .../Sow/SowOrderTractorAdvancedForm.tsx | 173 +++++++ .../Tractor/form/SowOrderV0Fields.tsx | 473 ++++++++++-------- 5 files changed, 917 insertions(+), 308 deletions(-) create mode 100644 src/components/Tractor/Sow/SowOrderEstimatedTipPaid.tsx create mode 100644 src/components/Tractor/Sow/SowOrderSharedComponents.tsx create mode 100644 src/components/Tractor/Sow/SowOrderTractorAdvancedForm.tsx diff --git a/src/components/SowOrderDialog.tsx b/src/components/SowOrderDialog.tsx index 98bf61e1d..60a49918f 100644 --- a/src/components/SowOrderDialog.tsx +++ b/src/components/SowOrderDialog.tsx @@ -1,3 +1,4 @@ +import { TV } from "@/classes/TokenValue"; import { Form } from "@/components/Form"; import ReviewTractorOrderDialog from "@/components/ReviewTractorOrderDialog"; import { @@ -6,20 +7,36 @@ import { useSowOrderV0Form, useSowOrderV0State, } from "@/components/Tractor/form/SowOrderV0Schema"; +import { MAIN_TOKEN } from "@/constants/tokens"; import useSowOrderV0Calculations from "@/hooks/tractor/useSowOrderV0Calculations"; import { tractorTokenStrategyUtil as StrategyUtil, TractorTokenStrategy } from "@/lib/Tractor"; import useTractorOperatorAverageTipPaid from "@/state/tractor/useTractorOperatorAverageTipPaid"; import { useFarmerSilo } from "@/state/useFarmerSilo"; +import { usePodLine } from "@/state/useFieldData"; +import { useChainConstant } from "@/utils/chain"; +import { formatter } from "@/utils/format"; +import { sanitizeNumericInputValue } from "@/utils/string"; +import { cn } from "@/utils/utils"; import { AnimatePresence, motion } from "framer-motion"; -import { useCallback, useEffect, useState } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; import React from "react"; import { useFormContext, useWatch } from "react-hook-form"; import { toast } from "sonner"; import { Col, Row } from "./Container"; import TooltipSimple from "./TooltipSimple"; +import { SowOrderEstimatedTipPaid } from "./Tractor/Sow/SowOrderEstimatedTipPaid"; +import { + SowOrderEntryFormParametersSummary, + SowOrderFormAdvancedParametersSummary, + SowOrderFormButtonRow, +} from "./Tractor/Sow/SowOrderSharedComponents"; +import SowOrderTractorAdvancedForm from "./Tractor/Sow/SowOrderTractorAdvancedForm"; import TractorTokenStrategyDialog from "./Tractor/TractorTokenStrategyDialog"; import SowOrderV0Fields from "./Tractor/form/SowOrderV0Fields"; +import { OperatorTipFormField, TractorOperatorTipStrategy } from "./Tractor/form/fields/sharedFields"; +import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "./ui/Accordion"; import { Button } from "./ui/Button"; +import { Separator } from "./ui/Separator"; import Warning from "./ui/Warning"; interface SowOrderDialogProps { @@ -31,7 +48,8 @@ interface SowOrderDialogProps { // Types enum FormStep { MAIN_FORM = 1, - OPERATOR_TIP = 2, + REVIEW = 2, + ADVANCED = 3, } export default function SowOrderDialog({ open, onOpenChange, onOrderPublished }: SowOrderDialogProps) { @@ -43,6 +61,21 @@ export default function SowOrderDialog({ open, onOpenChange, onOrderPublished }: const [formStep, setFormStep] = useState(FormStep.MAIN_FORM); const [showTokenSelectionDialog, setShowTokenSelectionDialog] = useState(false); const [showReview, setShowReview] = useState(false); + const [accordionValue, setAccordionValue] = useState(undefined); + const [operatorTipPreset, setOperatorTipPreset] = useState("Normal"); + + // Draft state management for advanced editing + const [draftState, setDraftState] = useState<{ + isActive: boolean; + originalValues: Partial | null; + }>({ + isActive: false, + originalValues: null, + }); + + // Refs for operator tip state management + const previousPresetRef = useRef(null); + const originalTipRef = useRef(null); const farmerDeposits = farmerSilo.deposits; @@ -72,38 +105,152 @@ export default function SowOrderDialog({ open, onOpenChange, onOrderPublished }: setDidInitTokenStrategy(true); }, [calculations.tokenWithHighestValue, calculations.isLoading, didInitTokenStrategy]); + // Set default values for minSoil, maxPerSeason, and podLineLength + const mainToken = useChainConstant(MAIN_TOKEN); + const podLine = usePodLine(); + const [totalAmount] = useWatch({ control: form.control, name: ["totalAmount"] }); + + // Set default values for minSoil and maxPerSeason based on totalAmount + useEffect(() => { + if (!totalAmount || totalAmount === "") return; + + const totalAmountTV = sanitizeNumericInputValue(totalAmount, mainToken.decimals).tv; + if (totalAmountTV.eq(0)) return; + + // Only set defaults if fields are empty (don't override user input) + const currentMinSoil = form.getValues("minSoil"); + const currentMaxPerSeason = form.getValues("maxPerSeason"); + + // minSoil: min(TotalValueToSow, 25 PINTO) + if (!currentMinSoil || currentMinSoil === "") { + const twentyFivePinto = TV.fromHuman(25, mainToken.decimals); + const minSoilValue = TV.min(totalAmountTV, twentyFivePinto); + const minSoilFormatted = formatter.number(minSoilValue); + form.setValue("minSoil", minSoilFormatted, { shouldValidate: true }); + } + + // maxPerSeason: TotalValueToSow + if (!currentMaxPerSeason || currentMaxPerSeason === "") { + const maxPerSeasonFormatted = formatter.number(totalAmountTV); + form.setValue("maxPerSeason", maxPerSeasonFormatted, { shouldValidate: true }); + } + }, [totalAmount, mainToken.decimals, form]); + + // Set default value for podLineLength: current pod line * 2 + useEffect(() => { + if (podLine.gt(0)) { + const podLineLengthValue = podLine.mul(2); + const podLineLengthFormatted = formatter.number(podLineLengthValue); + const currentValue = form.getValues("podLineLength"); + if (!currentValue || currentValue === "") { + form.setValue("podLineLength", podLineLengthFormatted, { shouldValidate: false }); + } + } + }, [podLine, form]); + const handleOpenTokenSelectionDialog = () => { setShowTokenSelectionDialog(true); }; + // Handlers for advanced form + const handleSetAdvanced = (e: React.MouseEvent) => { + e.stopPropagation(); + e.preventDefault(); + + // Store current form values as original before entering draft mode + setDraftState({ + isActive: true, + originalValues: form.getValues(), + }); + + setFormStep(FormStep.ADVANCED); + }; + + const handleAdvancedSubmit = () => { + // Commit the changes - clear draft state + setDraftState({ + isActive: false, + originalValues: null, + }); + setFormStep(FormStep.REVIEW); + }; + + const handleAdvancedCancel = () => { + // Revert changes - restore original values + if (draftState.originalValues) { + form.reset(draftState.originalValues); + } + + setDraftState({ + isActive: false, + originalValues: null, + }); + setFormStep(FormStep.REVIEW); + }; + + const handleSetAccordionValue = (value: string) => { + if (accordionValue === "advanced-settings" && formStep === FormStep.ADVANCED) { + return; + } + setAccordionValue(value); + }; + + const handleSetOperatorTipPreset = (preset: TractorOperatorTipStrategy) => { + if (preset === "Custom") { + if (operatorTipPreset !== "Custom") { + // First time going to Custom: store original state + previousPresetRef.current = operatorTipPreset; + originalTipRef.current = form.getValues("operatorTip") ?? null; + } else { + // Re-entering Custom: reset tip to original + cache current state for cancel + if (originalTipRef.current) { + form.setValue("operatorTip", originalTipRef.current); + } + } + } else { + // Switching to non-Custom preset: clear refs + previousPresetRef.current = null; + originalTipRef.current = null; + } + + setOperatorTipPreset(preset); + }; + // Main handlers const handleNext = async (e: React.MouseEvent) => { // prevent default to avoid form submission e.preventDefault(); + e.stopPropagation(); if (formStep === FormStep.MAIN_FORM) { const isValid = await form.trigger(); if (isValid) { - setFormStep(FormStep.OPERATOR_TIP); + setFormStep(FormStep.REVIEW); } return; } - await handleCreateBlueprint(form, farmerDeposits, { - onSuccess: () => { - setShowReview(true); - }, - onFailure: () => { - toast.error(e instanceof Error ? e.message : "Failed to create order"); - }, - }); + if (formStep === FormStep.REVIEW) { + await handleCreateBlueprint(form, farmerDeposits, { + onSuccess: () => { + setShowReview(true); + }, + onFailure: () => { + toast.error("Failed to create order"); + }, + }); + } }; const handleBack = (e: React.MouseEvent) => { // prevent default to avoid form submission e.preventDefault(); - if (formStep === FormStep.OPERATOR_TIP) { + e.stopPropagation(); + + if (formStep === FormStep.REVIEW) { setFormStep(FormStep.MAIN_FORM); + } else if (formStep === FormStep.ADVANCED) { + handleAdvancedCancel(); } else { onOpenChange(false); } @@ -140,95 +287,128 @@ export default function SowOrderDialog({ open, onOpenChange, onOrderPublished }:
- {/* I want to Sow up to */} - - {/* Min and Max per Season - combined in a single row */} -
-
- - -
-
- {/* Fund order using */} + {/* Sow Using */} + {/* I want to Sow up to */} + {/* Execute when Temperature is at least */} - {/* Execute when the length of the Pod Line is at most */} - - {/* Execute during the Morning Auction */} - + {/* Pods Display */} +
- ) : ( - // Step 2 - Operator Tip - - - {/* Title and separator for Step 2 */} -
-
🚜 Tip per Execution
-
-
- - - - + ) : formStep === FormStep.REVIEW ? ( + // Step 2 - Review + +
+
🚜 Review Sow Parameters
+ +
+ + + + + + + Advanced + + + + + + + + + + + + - )} - - - - -
Please fill in the following fields:
-
    - {missingFields.map((field) => ( -
  • {field}
  • - ))} -
-
- ) : null - } - side="top" - align="center" - // Only show tooltip when there are missing fields or errors - disabled={!(isMissingFields && isStep1)} - > -
+ ) : formStep === FormStep.ADVANCED ? ( + // Step 3 - Advanced Form + +
+
🚜 Advanced Parameters
+ +
+
+ +
+ + ) : null} + {formStep === FormStep.MAIN_FORM ? ( + <> + + -
- - + +
Please fill in the following fields:
+
    + {missingFields.map((field) => ( +
  • {field}
  • + ))} +
+ + ) : null + } + side="top" + align="center" + // Only show tooltip when there are missing fields or errors + disabled={!(isMissingFields && isStep1)} + > +
+ +
+
+ + + ) : null} diff --git a/src/components/Tractor/Sow/SowOrderEstimatedTipPaid.tsx b/src/components/Tractor/Sow/SowOrderEstimatedTipPaid.tsx new file mode 100644 index 000000000..4b3f2bd8d --- /dev/null +++ b/src/components/Tractor/Sow/SowOrderEstimatedTipPaid.tsx @@ -0,0 +1,61 @@ +import { TV } from "@/classes/TokenValue"; +import { Row } from "@/components/Container"; +import TooltipSimple from "@/components/TooltipSimple"; +import IconImage from "@/components/ui/IconImage"; +import { useMainToken } from "@/state/useTokenData"; +import { formatter } from "@/utils/format"; +import { postSanitizedSanitizedValue } from "@/utils/string"; +import { useMemo } from "react"; +import { useFormContext, useWatch } from "react-hook-form"; +import { SowOrderV0FormSchema } from "../form/SowOrderV0Schema"; + +export const SowOrderEstimatedTipPaid = () => { + const mainToken = useMainToken(); + const form = useFormContext(); + const values = useWatch({ control: form.control }); + const operatorTip = values.operatorTip; + const maxPerSeason = values.maxPerSeason; + const minSoil = values.minSoil; + const totalAmount = values.totalAmount; + + const tipEstimations = useMemo(() => { + const total = postSanitizedSanitizedValue(totalAmount ?? "", mainToken.decimals).tv; + const max = postSanitizedSanitizedValue(maxPerSeason ?? "", mainToken.decimals).tv; + const min = postSanitizedSanitizedValue(minSoil ?? "", mainToken.decimals).tv; + + const tip = postSanitizedSanitizedValue(operatorTip ?? "", mainToken.decimals).tv; + + if (total.eq(0) || tip.eq(0)) { + return { + min: TV.ZERO, + max: TV.ZERO, + }; + } + + // Min executions = total / maxPerSeason (fewer executions = lower tip) + // Max executions = total / minSoil (more executions = higher tip) + const minTimes = max.gt(0) ? total.div(max) : TV.ZERO; + const maxTimes = min.gt(0) ? total.div(min) : TV.ZERO; + + return { + min: minTimes.mul(tip), + max: maxTimes.mul(tip), + }; + }, [operatorTip, maxPerSeason, minSoil, totalAmount, mainToken.decimals]); + + return ( + + +
Estimated Total Tip Paid
+ +
+ + + {formatter.token(tipEstimations.min, mainToken)} - {formatter.token(tipEstimations.max, mainToken)} + +
+ ); +}; diff --git a/src/components/Tractor/Sow/SowOrderSharedComponents.tsx b/src/components/Tractor/Sow/SowOrderSharedComponents.tsx new file mode 100644 index 000000000..8d411ebee --- /dev/null +++ b/src/components/Tractor/Sow/SowOrderSharedComponents.tsx @@ -0,0 +1,162 @@ +import { Col, Row } from "@/components/Container"; +import TooltipSimple from "@/components/TooltipSimple"; +import { Button } from "@/components/ui/Button"; +import { Card } from "@/components/ui/Card"; +import IconImage from "@/components/ui/IconImage"; +import { Label } from "@/components/ui/Label"; +import { Separator } from "@/components/ui/Separator"; +import { useTokenMap } from "@/hooks/pinto/useTokenMap"; +import { tractorTokenStrategyUtil as StrategyUtil } from "@/lib/Tractor"; +import { TractorTokenStrategy } from "@/lib/Tractor/types"; +import { formatter } from "@/utils/format"; +import { getTokenIndex } from "@/utils/token"; +import { MayPromise } from "@/utils/types.generic"; +import React from "react"; +import { useFormContext, useFormState, useWatch } from "react-hook-form"; +import { TOOLTIP_COPY } from "../form/SowOrderV0Fields"; +import { SowOrderV0FormSchema } from "../form/SowOrderV0Schema"; +import { TractorFormButtonsRow } from "../form/fields/sharedFields"; + +// ============================================================================ +// Shared Components +// ============================================================================ + +export const SowOrderFormButtonRow = ({ + handleBack, + handleNext, + isLoading, +}: { + handleBack: (e: React.MouseEvent) => void; + handleNext: (e: React.MouseEvent) => MayPromise; + isLoading: boolean; +}) => { + const { errors } = useFormState(); + + const hasErrors = Boolean(Object.keys(errors).length); + + return ( + + ); +}; + +export const SowOrderEntryFormParametersSummary = () => { + const ctx = useFormContext(); + const values = useWatch({ control: ctx.control }); + const tokenMap = useTokenMap(); + + const totalPintosToSow = `${values.totalAmount} PINTO`; + const minimumTemperature = `${values.temperature}%`; + + const summary = StrategyUtil.getSummary( + (values.selectedTokenStrategy ?? { type: "LOWEST_SEEDS" }) as TractorTokenStrategy, + ); + + const renderTokenStrategy = () => { + if (summary.isLowestPrice) return "Token with Best Price"; + if (summary.isLowestSeeds) return "Token with Least Seeds"; + + const addresses = summary.addresses ?? []; + + if ((summary.isMulti || summary.isSingle) && !!addresses.length) { + return ( + + {addresses.map((adr) => { + const tk = tokenMap[getTokenIndex(adr)]; + return ( + + +
{tk.symbol}
+
+ ); + })} + + ); + } + + return <>; + }; + + return ( + <> + + + + + ); +}; + +export const SowOrderFormAdvancedParametersSummary = ({ + toggleEdit, +}: { + toggleEdit: (e: React.MouseEvent) => void; +}) => { + const ctx = useFormContext(); + const values = useWatch({ control: ctx.control }); + + const minSoil = values.minSoil; + const maxPerSeason = values.maxPerSeason; + const podLineLength = values.podLineLength; + const morningAuction = values.morningAuction; + + return ( + + + + + + + + + ); +}; + +const ReviewRow = ({ + label, + tooltip, + value, +}: { + label: string; + tooltip?: string; + value: string | JSX.Element; +}) => { + return ( + + + {tooltip ? ( + +
{label}
+ +
+ ) : ( + + )} +
+ {typeof value === "string" ?
{value}
: value} +
+ ); +}; diff --git a/src/components/Tractor/Sow/SowOrderTractorAdvancedForm.tsx b/src/components/Tractor/Sow/SowOrderTractorAdvancedForm.tsx new file mode 100644 index 000000000..5bb55c3cd --- /dev/null +++ b/src/components/Tractor/Sow/SowOrderTractorAdvancedForm.tsx @@ -0,0 +1,173 @@ +import { Col, Row } from "@/components/Container"; +import { FormControl, FormField, FormItem, FormLabel } from "@/components/Form"; +import IconImage from "@/components/ui/IconImage"; +import { Input } from "@/components/ui/Input"; +import { Switch } from "@/components/ui/Switch"; +import { MAIN_TOKEN } from "@/constants/tokens"; +import { useSharedNumericFormFieldHandlers } from "@/hooks/form/useSharedNumericFormFieldHandlers"; +import { usePodLine } from "@/state/useFieldData"; +import { useChainConstant } from "@/utils/chain"; +import { formatter } from "@/utils/format"; +import { sanitizeNumericInputValue } from "@/utils/string"; +import { useCallback } from "react"; +import { useFormContext, useFormState } from "react-hook-form"; +import { SowOrderV0FormSchema } from "../form/SowOrderV0Schema"; +import { TractorFormButtonsRow } from "../form/fields/sharedFields"; + +interface Props { + onSubmit: () => void; + onCancel: () => void; +} + +const MainTokenAdornment = () => { + const mainToken = useChainConstant(MAIN_TOKEN); + return ( +
+ + PINTO +
+ ); +}; + +const sharedInputProps = { + type: "text", + inputMode: "decimal", + pattern: "[0-9]*.?[0-9]*", + outlined: true, +} as const; + +const SowOrderTractorAdvancedForm = ({ onSubmit, onCancel }: Props) => { + const form = useFormContext(); + const mainToken = useChainConstant(MAIN_TOKEN); + const podLine = usePodLine(); + + const minSoilHandlers = useSharedNumericFormFieldHandlers(form, "minSoil", mainToken.decimals); + const maxPerSeasonHandlers = useSharedNumericFormFieldHandlers(form, "maxPerSeason", mainToken.decimals); + const podLineLengthHandlers = useSharedNumericFormFieldHandlers(form, "podLineLength", mainToken.decimals); + + const handleBack = useCallback( + (e: React.MouseEvent) => { + e.stopPropagation(); + e.preventDefault(); + onCancel(); + }, + [onCancel], + ); + + const handleNext = useCallback( + async (e: React.MouseEvent) => { + e.stopPropagation(); + e.preventDefault(); + + const isValid = await form.trigger(["minSoil", "maxPerSeason", "podLineLength", "morningAuction"]); + if (!isValid) { + return; + } + + onSubmit(); + }, + [form, onSubmit], + ); + + return ( + + ( + + Min per Season + + } + /> + + + )} + /> + ( + + Max per Season + + } + /> + + + )} + /> + ( + + Pod Line Length + + + + + )} + /> + ( + + Execute during the Morning Auction + + + + + )} + /> + + + ); +}; + +const ButtonRow = ({ + handleBack, + handleNext, +}: { + handleBack: (e: React.MouseEvent) => void; + handleNext: (e: React.MouseEvent) => Promise; +}) => { + const { errors } = useFormState(); + + const hasErrors = Boolean(Object.keys(errors).length); + + return ( + + ); +}; + +export default SowOrderTractorAdvancedForm; diff --git a/src/components/Tractor/form/SowOrderV0Fields.tsx b/src/components/Tractor/form/SowOrderV0Fields.tsx index c92b55684..d897d3a21 100644 --- a/src/components/Tractor/form/SowOrderV0Fields.tsx +++ b/src/components/Tractor/form/SowOrderV0Fields.tsx @@ -1,12 +1,14 @@ import arrowDown from "@/assets/misc/ChevronDown.svg"; +import podIcon from "@/assets/protocol/Pod.png"; import { FormControl, FormField, FormItem, FormLabel } from "@/components/Form"; import { Button } from "@/components/ui/Button"; import IconImage from "@/components/ui/IconImage"; import { Input } from "@/components/ui/Input"; +import { MultiSlider } from "@/components/ui/Slider"; +import { Switch } from "@/components/ui/Switch"; import { MAIN_TOKEN } from "@/constants/tokens"; import { useTokenMap } from "@/hooks/pinto/useTokenMap"; import { useScaledTemperature } from "@/hooks/useContinuousMorningTime"; -import { usePodLine } from "@/state/useFieldData"; import { useChainConstant } from "@/utils/chain"; import { formatter } from "@/utils/format"; import { postSanitizedSanitizedValue, sanitizeNumericInputValue, stringEq } from "@/utils/string"; @@ -14,12 +16,18 @@ import { getTokenIndex } from "@/utils/token"; import { useCallback, useEffect, useMemo, useState } from "react"; import { SowOrderV0FormSchema } from "./SowOrderV0Schema"; +import { TV } from "@/classes/TokenValue"; import { Col, Row } from "@/components/Container"; -import { Label } from "@/components/ui/Label"; +import { Label, TooltipLabel } from "@/components/ui/Label"; import { tractorTokenStrategyUtil as StrategyUtil } from "@/lib/Tractor"; import { TractorTokenStrategy } from "@/lib/Tractor/types"; +import { useFarmerBalances } from "@/state/useFarmerBalances"; +import { useFarmerSilo } from "@/state/useFarmerSilo"; +import { usePriceData } from "@/state/usePriceData"; +import useTokenData from "@/state/useTokenData"; import { cn } from "@/utils/utils"; import { useFormContext, useWatch } from "react-hook-form"; +import { useAccount } from "wagmi"; const sharedInputProps = { type: "text", @@ -27,6 +35,14 @@ const sharedInputProps = { pattern: "[0-9]*.?[0-9]*", } as const; +export const TOOLTIP_COPY = { + tokenStrategy: "The source token(s) to use for the Sow Order.", + totalAmount: "The total amount of PINTO to Sow in this order.", + temperature: + "The minimum Temperature at which this order can be executed. Temperature represents the current price relative to the moving average.", + morningAuction: "When enabled, this order will only execute during the Morning Auction period.", +} as const; + interface BaseIFormContextHandlers { onChange: (e: React.ChangeEvent) => ReturnType; onBlur: (e: React.FocusEvent) => void; @@ -96,73 +112,147 @@ const MainTokenAdornment = () => { ); }; -SowOrderV0Fields.TotalAmount = function TotalAmount() { - const ctx = useFormContext(); - const handlers = useSharedInputHandlers(ctx, "totalAmount"); - +const TotalAmountSlider = ({ + disabled, + ctx, + maxAmount, + handlers, +}: { + disabled?: boolean; + ctx: ReturnType>; + maxAmount?: TV; + handlers: ReturnType; +}) => { const decimals = useChainConstant(MAIN_TOKEN).decimals; - - const getHandlers = (): BaseIFormContextHandlers => { - return { - ...handlers, - onChange: (e: React.ChangeEvent) => { - const cleaned = handlers.onChange(e); - handleCrossValidate(ctx, cleaned, "minSoil", decimals, "gte"); - handleCrossValidate(ctx, cleaned, "maxPerSeason", decimals, "lte"); - return cleaned; - }, - }; - }; + const [totalAmount] = useWatch({ control: ctx.control, name: ["totalAmount"] }); + const sliderValue = useMemo(() => [Number(totalAmount.replace(/,/g, "") || "0")], [totalAmount]); + + const handleOnChange = useCallback( + (value: number[]) => { + // Truncate to max decimals (but limit to 6 for UI precision) + const precision = 10 ** Math.min(decimals, 6); + const truncatedValue = Math.floor(value[0] * precision) / precision; + // use the blur handler to set the value with commas + handlers.onBlur({ target: { value: truncatedValue.toString() } } as React.FocusEvent); + // Trigger cross validation after setting value + const cleaned = sanitizeNumericInputValue(truncatedValue.toString(), decimals); + handleCrossValidate(ctx, cleaned, "minSoil", decimals, "gte"); + handleCrossValidate(ctx, cleaned, "maxPerSeason", decimals, "lte"); + }, + [handlers, ctx, decimals], + ); return ( - ( - - I want to Sow up to - - } - /> - - - )} + ); }; -SowOrderV0Fields.MinSoil = function MinSoil() { +SowOrderV0Fields.TotalAmount = function TotalAmount({ + farmerDeposits, +}: { + farmerDeposits?: ReturnType["deposits"]; +}) { + const { address: accountAddress } = useAccount(); const ctx = useFormContext(); - const handlers = useSharedInputHandlers(ctx, "minSoil"); + const handlers = useSharedInputHandlers(ctx, "totalAmount"); const decimals = useChainConstant(MAIN_TOKEN).decimals; + const [tokenStrategy] = useWatch({ control: ctx.control, name: ["selectedTokenStrategy"] }); + const farmerBalances = useFarmerBalances(); + const priceData = usePriceData(); + const tokenData = useTokenData(); + + const maxAmount = useMemo(() => { + if (!accountAddress || !farmerDeposits) { + return undefined; + } + + const summary = StrategyUtil.getSummary(tokenStrategy); + let totalAmount = TV.ZERO; + + if (summary.type === "SPECIFIC_TOKEN" && summary.addresses) { + // Sum amounts for specific tokens + summary.addresses.forEach((address) => { + const token = tokenData.whitelistedTokens.find((t) => t.address === address); + if (token) { + const deposit = farmerDeposits.get(token); + if (deposit?.amount) { + // For LP tokens, use price to convert to main token value + if (token.isLP) { + const price = priceData.tokenPrices.get(token)?.instant; + if (price) { + totalAmount = totalAmount.add(deposit.amount.mul(price)); + } + } else { + // For main token, use amount directly + totalAmount = totalAmount.add(deposit.amount); + } + } else { + // Check balances if no deposits + const balance = farmerBalances.balances.get(token); + if (balance?.total) { + if (token.isLP) { + const price = priceData.tokenPrices.get(token)?.instant; + if (price) { + totalAmount = totalAmount.add(balance.total.mul(price)); + } + } else { + totalAmount = totalAmount.add(balance.total); + } + } + } + } + }); + } else { + // For LOWEST_SEEDS or LOWEST_PRICE, sum all available amounts + farmerDeposits.forEach((deposit, token) => { + if (deposit.amount) { + if (token.isLP) { + const price = priceData.tokenPrices.get(token)?.instant; + if (price) { + totalAmount = totalAmount.add(deposit.amount.mul(price)); + } + } else { + totalAmount = totalAmount.add(deposit.amount); + } + } + }); + } + + return totalAmount.gt(0) ? totalAmount : undefined; + }, [accountAddress, farmerDeposits, tokenStrategy, farmerBalances, priceData, tokenData]); + + const showSlider = maxAmount?.gt(0) && accountAddress; const getHandlers = (): BaseIFormContextHandlers => { return { ...handlers, onChange: (e: React.ChangeEvent) => { const cleaned = handlers.onChange(e); + handleCrossValidate(ctx, cleaned, "minSoil", decimals, "gte"); handleCrossValidate(ctx, cleaned, "maxPerSeason", decimals, "lte"); - handleCrossValidate(ctx, cleaned, "totalAmount", decimals, "lte"); return cleaned; }, }; }; return ( - ( - - Min per Season -
+ + I want to Sow up to + + {showSlider ? ( + + ) : null} + ( } /> -
-
- )} - /> - ); -}; - -SowOrderV0Fields.MaxPerSeason = function MaxPerSeason() { - const ctx = useFormContext(); - const handlers = useSharedInputHandlers(ctx, "maxPerSeason"); - - const decimals = useChainConstant(MAIN_TOKEN).decimals; - - const getHandlers = (): BaseIFormContextHandlers => { - return { - ...handlers, - onChange: (e: React.ChangeEvent) => { - const cleaned = handlers.onChange(e); - handleCrossValidate(ctx, cleaned, "minSoil", decimals, "gte"); - handleCrossValidate(ctx, cleaned, "totalAmount", decimals, "lte"); - return cleaned; - }, - }; - }; - - return ( - ( - - Max per Season - - } - /> - - - )} - /> + )} + /> + + ); }; @@ -297,7 +347,7 @@ SowOrderV0Fields.TokenStrategy = function TokenStrategy({ return (
- + Sow Using - ))} -
)} /> ); }; -const MorningAuctionButton = ({ - label, - value, - fieldValue, - onChange, -}: { label: string; value: boolean; fieldValue: boolean; onChange: (value: boolean) => void }) => { - const isActive = value === fieldValue; - return ( - - ); -}; - -SowOrderV0Fields.MorningAuction = function MorningAuction() { +SowOrderV0Fields.PodDisplay = function PodDisplay() { const ctx = useFormContext(); + const mainToken = useChainConstant(MAIN_TOKEN); + const [totalAmount, temperature] = useWatch({ control: ctx.control, name: ["totalAmount", "temperature"] }); + const currentTemperature = useScaledTemperature(); + + const estimatedPods = useMemo(() => { + if (!totalAmount || totalAmount === "") { + return TV.ZERO; + } + + const totalAmountTV = sanitizeNumericInputValue(totalAmount, mainToken.decimals).tv; + if (totalAmountTV.eq(0)) { + return TV.ZERO; + } + + // Use temperature from form if available, otherwise use current temperature + const tempValue = + temperature && temperature !== "" + ? Number(temperature.replace(/,/g, "")) + : currentTemperature.scaled?.toNumber() || 0; + + // Calculate pods: amount * (temperature + 100) / 100 + const multiplier = TV.fromHuman(tempValue + 100, 6).div(100); + return multiplier.mul(totalAmountTV); + }, [totalAmount, temperature, mainToken.decimals, currentTemperature.scaled]); return ( - ( - - Execute during the Morning Auction -
- - -
-
- )} - /> + +
Pods
+
+ +
{formatter.number(estimatedPods, { minValue: 0.01 })} PODS
+
+
); }; From 6dfb3f19a473443a413144f48a9869e9d8d03303 Mon Sep 17 00:00:00 2001 From: feyyazcigim Date: Mon, 8 Dec 2025 02:45:10 +0300 Subject: [PATCH 02/21] Update ModifySowOrderDialog based on new implementation --- .../Tractor/ModifySowOrderDialog.tsx | 329 +++++++++++++----- 1 file changed, 246 insertions(+), 83 deletions(-) diff --git a/src/components/Tractor/ModifySowOrderDialog.tsx b/src/components/Tractor/ModifySowOrderDialog.tsx index 186be81ba..8e416d484 100644 --- a/src/components/Tractor/ModifySowOrderDialog.tsx +++ b/src/components/Tractor/ModifySowOrderDialog.tsx @@ -4,6 +4,7 @@ import { TokenValue } from "@/classes/TokenValue"; import { Col, Row } from "@/components/Container"; import { Form } from "@/components/Form"; import TooltipSimple from "@/components/TooltipSimple"; +import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/Accordion"; import { Button } from "@/components/ui/Button"; import { Dialog, @@ -14,6 +15,7 @@ import { DialogPortal, DialogTitle, } from "@/components/ui/Dialog"; +import { Separator } from "@/components/ui/Separator"; import { useProtocolAddress } from "@/hooks/pinto/useProtocolAddress"; import { useGetTractorTokenStrategyWithBlueprint } from "@/hooks/tractor/useGetTractorTokenStrategy"; import useSignTractorBlueprint from "@/hooks/tractor/useSignTractorBlueprint"; @@ -27,15 +29,24 @@ import { useFarmerSilo } from "@/state/useFarmerSilo"; import { formatter } from "@/utils/format"; import { postSanitizedSanitizedValue } from "@/utils/string"; import { tokensEqual } from "@/utils/token"; +import { cn } from "@/utils/utils"; import { ArrowRightIcon } from "@radix-ui/react-icons"; import { useQueryClient } from "@tanstack/react-query"; -import { useEffect, useMemo, useState } from "react"; +import { useEffect, useMemo, useRef, useState } from "react"; import { toast } from "sonner"; import { encodeFunctionData } from "viem"; import { useAccount } from "wagmi"; import { SowOrderV0TokenStrategyDialog } from "../SowOrderDialog"; +import { SowOrderEstimatedTipPaid } from "./Sow/SowOrderEstimatedTipPaid"; +import { + SowOrderEntryFormParametersSummary, + SowOrderFormAdvancedParametersSummary, + SowOrderFormButtonRow, +} from "./Sow/SowOrderSharedComponents"; +import SowOrderTractorAdvancedForm from "./Sow/SowOrderTractorAdvancedForm"; import SowOrderV0Fields from "./form/SowOrderV0Fields"; import { useSowOrderV0Form, useSowOrderV0State } from "./form/SowOrderV0Schema"; +import { OperatorTipFormField, TractorOperatorTipStrategy } from "./form/fields/sharedFields"; interface ModifyTractorOrderDialogProps { open: boolean; @@ -63,6 +74,22 @@ export default function ModifyTractorOrderDialog({ // Local State const [showReview, setShowReview] = useState(false); const [showTokenSelectionDialog, setShowTokenSelectionDialog] = useState(false); + const [formStep, setFormStep] = useState<1 | 2 | 3>(1); // MAIN_FORM = 1, REVIEW = 2, ADVANCED = 3 + const [accordionValue, setAccordionValue] = useState(undefined); + const [operatorTipPreset, setOperatorTipPreset] = useState("Normal"); + + // Draft state management for advanced editing + const [draftState, setDraftState] = useState<{ + isActive: boolean; + originalValues: Partial["form"]["getValues"]> | null; + }>({ + isActive: false, + originalValues: null, + }); + + // Refs for operator tip state management + const previousPresetRef = useRef(null); + const originalTipRef = useRef(null); // Effects. Pre-fill form with existing order data const [didPrefill, setDidPrefill] = useState(false); @@ -88,30 +115,115 @@ export default function ModifyTractorOrderDialog({ } }, [open, existingOrder, didPrefill, prefillValues, getStrategyProps]); + // Handlers for advanced form + const handleSetAdvanced = (e: React.MouseEvent) => { + e.stopPropagation(); + e.preventDefault(); + + // Store current form values as original before entering draft mode + setDraftState({ + isActive: true, + originalValues: form.getValues(), + }); + + setFormStep(3); // ADVANCED + }; + + const handleAdvancedSubmit = () => { + // Commit the changes - clear draft state + setDraftState({ + isActive: false, + originalValues: null, + }); + setFormStep(2); // REVIEW + }; + + const handleAdvancedCancel = () => { + // Revert changes - restore original values + if (draftState.originalValues) { + form.reset(draftState.originalValues); + } + + setDraftState({ + isActive: false, + originalValues: null, + }); + setFormStep(2); // REVIEW + }; + + const handleSetAccordionValue = (value: string) => { + if (accordionValue === "advanced-settings" && formStep === 3) { + return; + } + setAccordionValue(value); + }; + + const handleSetOperatorTipPreset = (preset: TractorOperatorTipStrategy) => { + if (preset === "Custom") { + if (operatorTipPreset !== "Custom") { + // First time going to Custom: store original state + previousPresetRef.current = operatorTipPreset; + originalTipRef.current = form.getValues("operatorTip") ?? null; + } else { + // Re-entering Custom: reset tip to original + cache current state for cancel + if (originalTipRef.current) { + form.setValue("operatorTip", originalTipRef.current); + } + } + } else { + // Switching to non-Custom preset: clear refs + previousPresetRef.current = null; + originalTipRef.current = null; + } + + setOperatorTipPreset(preset); + }; + // Callbacks // Handle creating the modified order const handleNext = async (e: React.MouseEvent) => { e.preventDefault(); + e.stopPropagation(); - const isValid = await form.trigger(); - - if (!isValid) { + if (formStep === 1) { + // MAIN_FORM -> REVIEW + const isValid = await form.trigger(); + if (isValid) { + setFormStep(2); + } return; } - await handleCreateBlueprint(form, undefined, { - onSuccess: () => { - setShowReview(true); - }, - onFailure: () => { - toast.error(e instanceof Error ? e.message : "Failed to create order"); - }, - }); + if (formStep === 2) { + // REVIEW -> Create blueprint and show review dialog + await handleCreateBlueprint(form, undefined, { + onSuccess: () => { + setShowReview(true); + }, + onFailure: () => { + toast.error("Failed to create order"); + }, + }); + } }; // Handle back button - const handleBack = () => { - onOpenChange(false); + const handleBack = (e?: React.MouseEvent) => { + if (e) { + e.preventDefault(); + e.stopPropagation(); + } + + if (formStep === 2) { + // REVIEW -> MAIN_FORM + setFormStep(1); + } else if (formStep === 3) { + // ADVANCED -> REVIEW + handleAdvancedCancel(); + } else { + // MAIN_FORM -> Close dialog + onOpenChange(false); + } }; if (!open) return null; @@ -147,80 +259,129 @@ export default function ModifyTractorOrderDialog({
- {/* Main Form */} - - {/* I want to Sow up to */} - - {/* Min and Max per Season - combined in a single row */} -
-
- - -
-
- {/* Fund order using */} - setShowTokenSelectionDialog(true)} /> - {/* Execute when Temperature is at least */} - - {/* Execute when the length of the Pod Line is at most */} - - {/* Execute during the Morning Auction */} - - - -
- - - - -
Please fill in the following fields:
-
    - {missingFields.map((field) => ( -
  • {field}
  • - ))} -
-
- ) : null - } - side="top" - align="center" - // Only show tooltip when there are missing fields or errors - disabled={!isMissingFields} - > -
+ {formStep === 1 ? ( + // Step 1 - Main Form + + + {/* Sow Using */} + setShowTokenSelectionDialog(true)} /> + {/* I want to Sow up to */} + + {/* Execute when Temperature is at least */} + + {/* Pods Display */} + + + + +
Please fill in the following fields:
+
    + {missingFields.map((field) => ( +
  • {field}
  • + ))} +
+
+ ) : null + } + side="top" + align="center" + disabled={!isMissingFields} + > +
+ +
+ + + + ) : formStep === 2 ? ( + // Step 2 - Review + +
+
🚜 Review Sow Parameters
+ +
+ + + + + + + Advanced + + + + + + + + + + + + + + + ) : formStep === 3 ? ( + // Step 3 - Advanced Form + +
+
🚜 Advanced Parameters
+ +
+
+
- - + + ) : null}
{/* Token Selection Dialog */} ) => { const strategy = prev as ExtendedTractorTokenStrategy; switch (true) { case strategy.type === "SPECIFIC_TOKEN": - return strategy.token?.symbol ?? "Unknown Token"; + return ( + (strategy as { type: "SPECIFIC_TOKEN"; token?: { symbol: string } }).token?.symbol ?? "Unknown Token" + ); case strategy.type === "LOWEST_PRICE": return "Token with lowest price"; default: @@ -531,7 +694,7 @@ const RenderTokenStrategyDiff = ({ prev, curr }: RenderDiffProps { switch (true) { case strategy.type === "SPECIFIC_TOKEN": - return strategy.token?.symbol ?? "Unknown Token"; + return (strategy as { type: "SPECIFIC_TOKEN"; token?: { symbol: string } }).token?.symbol ?? "Unknown Token"; case strategy.type === "LOWEST_PRICE": return "Token with lowest price"; default: From b6863aafd3be8de864c153f386990c9dea0b5451 Mon Sep 17 00:00:00 2001 From: feyyazcigim Date: Mon, 8 Dec 2025 04:15:39 +0300 Subject: [PATCH 03/21] Fix slider bugs, change validation --- src/components/SowOrderDialog.tsx | 22 ++- .../Tractor/ModifySowOrderDialog.tsx | 29 +++- .../Tractor/Sow/SowOrderSharedComponents.tsx | 2 - .../Sow/SowOrderTractorAdvancedForm.tsx | 37 +++-- .../Tractor/form/SowOrderV0Fields.tsx | 45 +++--- .../Tractor/form/SowOrderV0Schema.ts | 133 ++++++++++++++---- 6 files changed, 187 insertions(+), 81 deletions(-) diff --git a/src/components/SowOrderDialog.tsx b/src/components/SowOrderDialog.tsx index 60a49918f..b5e506d86 100644 --- a/src/components/SowOrderDialog.tsx +++ b/src/components/SowOrderDialog.tsx @@ -117,23 +117,15 @@ export default function SowOrderDialog({ open, onOpenChange, onOrderPublished }: const totalAmountTV = sanitizeNumericInputValue(totalAmount, mainToken.decimals).tv; if (totalAmountTV.eq(0)) return; - // Only set defaults if fields are empty (don't override user input) - const currentMinSoil = form.getValues("minSoil"); - const currentMaxPerSeason = form.getValues("maxPerSeason"); - // minSoil: min(TotalValueToSow, 25 PINTO) - if (!currentMinSoil || currentMinSoil === "") { - const twentyFivePinto = TV.fromHuman(25, mainToken.decimals); - const minSoilValue = TV.min(totalAmountTV, twentyFivePinto); - const minSoilFormatted = formatter.number(minSoilValue); - form.setValue("minSoil", minSoilFormatted, { shouldValidate: true }); - } + const twentyFivePinto = TV.fromHuman(25, mainToken.decimals); + const minSoilValue = TV.min(totalAmountTV, twentyFivePinto); + const minSoilFormatted = formatter.number(minSoilValue); + form.setValue("minSoil", minSoilFormatted, { shouldValidate: true }); // maxPerSeason: TotalValueToSow - if (!currentMaxPerSeason || currentMaxPerSeason === "") { - const maxPerSeasonFormatted = formatter.number(totalAmountTV); - form.setValue("maxPerSeason", maxPerSeasonFormatted, { shouldValidate: true }); - } + const maxPerSeasonFormatted = formatter.number(totalAmountTV); + form.setValue("maxPerSeason", maxPerSeasonFormatted, { shouldValidate: true }); }, [totalAmount, mainToken.decimals, form]); // Set default value for podLineLength: current pod line * 2 @@ -293,6 +285,8 @@ export default function SowOrderDialog({ open, onOpenChange, onOrderPublished }: {/* Execute when Temperature is at least */} + {/* Execute during the Morning Auction */} + {/* Pods Display */} diff --git a/src/components/Tractor/ModifySowOrderDialog.tsx b/src/components/Tractor/ModifySowOrderDialog.tsx index 8e416d484..8ae633559 100644 --- a/src/components/Tractor/ModifySowOrderDialog.tsx +++ b/src/components/Tractor/ModifySowOrderDialog.tsx @@ -1,5 +1,6 @@ import { diamondABI } from "@/constants/abi/diamondABI"; +import { TV } from "@/classes/TokenValue"; import { TokenValue } from "@/classes/TokenValue"; import { Col, Row } from "@/components/Container"; import { Form } from "@/components/Form"; @@ -16,6 +17,7 @@ import { DialogTitle, } from "@/components/ui/Dialog"; import { Separator } from "@/components/ui/Separator"; +import { MAIN_TOKEN } from "@/constants/tokens"; import { useProtocolAddress } from "@/hooks/pinto/useProtocolAddress"; import { useGetTractorTokenStrategyWithBlueprint } from "@/hooks/tractor/useGetTractorTokenStrategy"; import useSignTractorBlueprint from "@/hooks/tractor/useSignTractorBlueprint"; @@ -26,13 +28,15 @@ import { useGetBlueprintHash } from "@/lib/Tractor/blueprint"; import { Blueprint, ExtendedTractorTokenStrategy, Requisition, TractorTokenStrategy } from "@/lib/Tractor/types"; import useTractorOperatorAverageTipPaid from "@/state/tractor/useTractorOperatorAverageTipPaid"; import { useFarmerSilo } from "@/state/useFarmerSilo"; +import { useChainConstant } from "@/utils/chain"; import { formatter } from "@/utils/format"; -import { postSanitizedSanitizedValue } from "@/utils/string"; +import { postSanitizedSanitizedValue, sanitizeNumericInputValue } from "@/utils/string"; import { tokensEqual } from "@/utils/token"; import { cn } from "@/utils/utils"; import { ArrowRightIcon } from "@radix-ui/react-icons"; import { useQueryClient } from "@tanstack/react-query"; import { useEffect, useMemo, useRef, useState } from "react"; +import { useWatch } from "react-hook-form"; import { toast } from "sonner"; import { encodeFunctionData } from "viem"; import { useAccount } from "wagmi"; @@ -115,6 +119,27 @@ export default function ModifyTractorOrderDialog({ } }, [open, existingOrder, didPrefill, prefillValues, getStrategyProps]); + // Set default values for minSoil and maxPerSeason based on totalAmount + const mainToken = useChainConstant(MAIN_TOKEN); + const [totalAmount] = useWatch({ control: form.control, name: ["totalAmount"] }); + + useEffect(() => { + if (!totalAmount || totalAmount === "") return; + + const totalAmountTV = sanitizeNumericInputValue(totalAmount, mainToken.decimals).tv; + if (totalAmountTV.eq(0)) return; + + // minSoil: min(TotalValueToSow, 25 PINTO) + const twentyFivePinto = TV.fromHuman(25, mainToken.decimals); + const minSoilValue = TV.min(totalAmountTV, twentyFivePinto); + const minSoilFormatted = formatter.number(minSoilValue); + form.setValue("minSoil", minSoilFormatted, { shouldValidate: false }); + + // maxPerSeason: TotalValueToSow + const maxPerSeasonFormatted = formatter.number(totalAmountTV); + form.setValue("maxPerSeason", maxPerSeasonFormatted, { shouldValidate: false }); + }, [totalAmount, mainToken.decimals, form]); + // Handlers for advanced form const handleSetAdvanced = (e: React.MouseEvent) => { e.stopPropagation(); @@ -269,6 +294,8 @@ export default function ModifyTractorOrderDialog({ {/* Execute when Temperature is at least */} + {/* Execute during the Morning Auction */} + {/* Pods Display */} diff --git a/src/components/Tractor/Sow/SowOrderSharedComponents.tsx b/src/components/Tractor/Sow/SowOrderSharedComponents.tsx index 8d411ebee..a1062ce7f 100644 --- a/src/components/Tractor/Sow/SowOrderSharedComponents.tsx +++ b/src/components/Tractor/Sow/SowOrderSharedComponents.tsx @@ -107,7 +107,6 @@ export const SowOrderFormAdvancedParametersSummary = ({ const minSoil = values.minSoil; const maxPerSeason = values.maxPerSeason; const podLineLength = values.podLineLength; - const morningAuction = values.morningAuction; return ( @@ -126,7 +125,6 @@ export const SowOrderFormAdvancedParametersSummary = ({ tooltip="The maximum pod line length at which this order can be executed." value={podLineLength ? `${formatter.number(podLineLength)} PODS` : "--"} /> -
} /> @@ -472,7 +474,8 @@ SowOrderV0Fields.PodDisplay = function PodDisplay() { const ctx = useFormContext(); const mainToken = useChainConstant(MAIN_TOKEN); const [totalAmount, temperature] = useWatch({ control: ctx.control, name: ["totalAmount", "temperature"] }); - const currentTemperature = useScaledTemperature(); + const { data: maxTemperature } = useReadBeanstalk_MaxTemperature(); + const temperatureState = useTemperature(); const estimatedPods = useMemo(() => { if (!totalAmount || totalAmount === "") { @@ -484,16 +487,18 @@ SowOrderV0Fields.PodDisplay = function PodDisplay() { return TV.ZERO; } - // Use temperature from form if available, otherwise use current temperature + // Use temperature from form if available, otherwise use max temperature from contract const tempValue = temperature && temperature !== "" ? Number(temperature.replace(/,/g, "")) - : currentTemperature.scaled?.toNumber() || 0; + : maxTemperature !== undefined + ? TV.fromBigInt(maxTemperature, 6).toNumber() + : temperatureState.max?.toNumber() || 0; // Calculate pods: amount * (temperature + 100) / 100 const multiplier = TV.fromHuman(tempValue + 100, 6).div(100); return multiplier.mul(totalAmountTV); - }, [totalAmount, temperature, mainToken.decimals, currentTemperature.scaled]); + }, [totalAmount, temperature, mainToken.decimals, maxTemperature, temperatureState.max]); return ( diff --git a/src/components/Tractor/form/SowOrderV0Schema.ts b/src/components/Tractor/form/SowOrderV0Schema.ts index dfa7f1bba..8ac742717 100644 --- a/src/components/Tractor/form/SowOrderV0Schema.ts +++ b/src/components/Tractor/form/SowOrderV0Schema.ts @@ -1,12 +1,14 @@ import { TokenValue } from "@/classes/TokenValue"; +import { MAIN_TOKEN } from "@/constants/tokens"; import { useProtocolAddress } from "@/hooks/pinto/useProtocolAddress"; import { useTokenMap } from "@/hooks/pinto/useTokenMap"; import { Blueprint, TractorTokenStrategy, createBlueprint, createSowTractorData } from "@/lib/Tractor"; import { useFarmerSilo } from "@/state/useFarmerSilo"; +import { useChainConstant } from "@/utils/chain"; import { getTokenIndex } from "@/utils/token"; import { Token } from "@/utils/types"; import { zodResolver } from "@hookform/resolvers/zod"; -import { useCallback, useState } from "react"; +import { useCallback, useMemo, useState } from "react"; import { useForm } from "react-hook-form"; import { useAccount, usePublicClient } from "wagmi"; import { z } from "zod"; @@ -14,7 +16,7 @@ import { z } from "zod"; import FormUtils from "@/utils/form"; const { - schema: { tokenStrategy, positiveNumber, addCTXErrors }, + schema: { tokenStrategy, positiveNumber }, validate: { lte }, } = FormUtils; @@ -25,35 +27,85 @@ export const sowOrderSchemaErrors = { } as const; // Main schema for sow order dialog -export const sowOrderDialogSchema = z - .object({ - totalAmount: positiveNumber("Total Amount"), - minSoil: positiveNumber("Min per Season"), - maxPerSeason: positiveNumber("Max per Season"), - temperature: positiveNumber("Temperature"), - podLineLength: positiveNumber("Pod Line Length"), - morningAuction: z.boolean().default(false), - operatorTip: positiveNumber("Operator Tip"), - selectedTokenStrategy: tokenStrategy, - }) - .superRefine((data, ctx) => { - // Cross-field validation: minSoil <= maxPerSeason - if (!lte(data.minSoil, data.maxPerSeason, 6, 6)) { - addCTXErrors(ctx, sowOrderSchemaErrors.minLteMax, ["minSoil", "maxPerSeason"]); - } - }) - .superRefine((data, ctx) => { - // Cross-field validation: minSoil <= totalAmount - if (!lte(data.minSoil, data.totalAmount, 6, 6)) { - addCTXErrors(ctx, sowOrderSchemaErrors.minLteTotal, ["minSoil", "totalAmount"]); +export const sowOrderDialogSchema = z.object({ + totalAmount: positiveNumber("Total Amount"), + minSoil: positiveNumber("Min per Season"), + maxPerSeason: positiveNumber("Max per Season"), + temperature: positiveNumber("Temperature"), + podLineLength: positiveNumber("Pod Line Length"), + morningAuction: z.boolean().default(false), + operatorTip: positiveNumber("Operator Tip"), + selectedTokenStrategy: tokenStrategy, +}); + +// Validation helper for advanced form +export const validateAdvancedFormFields = ( + data: { + minSoil: string; + maxPerSeason: string; + totalAmount: string; + }, + form: ReturnType>, +): { isValid: boolean; errors: string[] } => { + let hasErrors = false; + const minSoilErrors: string[] = []; + const maxPerSeasonErrors: string[] = []; + const allErrors: string[] = []; + + // Cross-field validation: minSoil <= maxPerSeason + if (!lte(data.minSoil, data.maxPerSeason, 6, 6)) { + minSoilErrors.push(sowOrderSchemaErrors.minLteMax); + maxPerSeasonErrors.push(sowOrderSchemaErrors.minLteMax); + allErrors.push(sowOrderSchemaErrors.minLteMax); + hasErrors = true; + } + + // Cross-field validation: minSoil <= totalAmount + if (!lte(data.minSoil, data.totalAmount, 6, 6)) { + minSoilErrors.push(sowOrderSchemaErrors.minLteTotal); + allErrors.push(sowOrderSchemaErrors.minLteTotal); + hasErrors = true; + } + + // Cross-field validation: maxPerSeason <= totalAmount + if (!lte(data.maxPerSeason, data.totalAmount, 6, 6)) { + maxPerSeasonErrors.push(sowOrderSchemaErrors.maxLteTotal); + allErrors.push(sowOrderSchemaErrors.maxLteTotal); + hasErrors = true; + } + + // Set errors (show first error message for each field) + if (minSoilErrors.length > 0) { + form.setError("minSoil", { + type: "manual", + message: minSoilErrors[0], + }); + } else { + // Clear errors if validation passes + const currentError = form.formState.errors.minSoil?.message; + if (currentError === sowOrderSchemaErrors.minLteMax || currentError === sowOrderSchemaErrors.minLteTotal) { + form.clearErrors("minSoil"); } - }) - .superRefine((data, ctx) => { - // Cross-field validation: maxPerSeason <= totalAmount - if (!lte(data.maxPerSeason, data.totalAmount, 6, 6)) { - addCTXErrors(ctx, sowOrderSchemaErrors.maxLteTotal, ["maxPerSeason", "totalAmount"]); + } + + if (maxPerSeasonErrors.length > 0) { + form.setError("maxPerSeason", { + type: "manual", + message: maxPerSeasonErrors[0], + }); + } else { + // Clear errors if validation passes + const currentError = form.formState.errors.maxPerSeason?.message; + if (currentError === sowOrderSchemaErrors.minLteMax || currentError === sowOrderSchemaErrors.maxLteTotal) { + form.clearErrors("maxPerSeason"); } - }); + } + + // Remove duplicates from errors + const uniqueErrors = Array.from(new Set(allErrors)); + + return { isValid: !hasErrors, errors: uniqueErrors }; +}; // Type inference from schema export type SowOrderV0FormSchema = z.infer; @@ -79,9 +131,22 @@ export type SowOrderV0Form = { }; export const useSowOrderV0Form = (): SowOrderV0Form => { + const mainToken = useChainConstant(MAIN_TOKEN); + + const defaultValues = useMemo( + () => ({ + ...defaultSowOrderDialogValues, + selectedTokenStrategy: { + type: "SPECIFIC_TOKEN" as const, + addresses: [mainToken.address as `0x${string}`], + }, + }), + [mainToken.address], + ); + const form = useForm({ resolver: zodResolver(sowOrderDialogSchema), - defaultValues: { ...defaultSowOrderDialogValues }, + defaultValues, mode: "onChange", }); @@ -106,7 +171,15 @@ export const useSowOrderV0Form = (): SowOrderV0Form => { const getMissingFields = useCallback(() => { const values = form.getValues(); + // Fields that are auto-populated and shouldn't be shown as missing + const autoPopulatedFields = ["minSoil", "maxPerSeason", "podLineLength"]; + const missingFields = Object.keys(values).filter((key) => { + // Skip auto-populated fields + if (autoPopulatedFields.includes(key)) { + return false; + } + const value = values[key as keyof SowOrderV0FormSchema]; if (typeof value === "string") { return value.trim() === ""; From 869dc673cff7163d62e315ad8ee90d3081366dd5 Mon Sep 17 00:00:00 2001 From: "claude[bot]" <209825114+claude[bot]@users.noreply.github.com> Date: Mon, 8 Dec 2025 01:48:14 +0000 Subject: [PATCH 04/21] Update tractor order form tooltips and labels MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Shorten temperature tooltip to remove redundant explanation - Change 'Execute during the Morning Auction' to 'Execute during the Morning' - Update morning tooltip with detailed explanation about 10-minute period 🤖 Generated with [Claude Code](https://claude.ai/code) Co-authored-by: fr1jo --- src/components/Tractor/form/SowOrderV0Fields.tsx | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/src/components/Tractor/form/SowOrderV0Fields.tsx b/src/components/Tractor/form/SowOrderV0Fields.tsx index b0b201a31..ff97c67d0 100644 --- a/src/components/Tractor/form/SowOrderV0Fields.tsx +++ b/src/components/Tractor/form/SowOrderV0Fields.tsx @@ -39,9 +39,8 @@ const sharedInputProps = { export const TOOLTIP_COPY = { tokenStrategy: "The source token(s) to use for the Sow Order.", totalAmount: "The total amount of PINTO to Sow in this order.", - temperature: - "The minimum Temperature at which this order can be executed. Temperature represents the current price relative to the moving average.", - morningAuction: "When enabled, this order will only execute during the Morning Auction period.", + temperature: "The minimum Temperature at which this order can be executed.", + morningAuction: "The morning is the first 10 minutes of the Season, where the Temperature slowly increases to its maximum. Farmers can opt for their orders to execute during the Morning, such that their orders fill first.", } as const; interface BaseIFormContextHandlers { @@ -460,7 +459,7 @@ SowOrderV0Fields.MorningAuction = function MorningAuction() { name="morningAuction" render={({ field }) => ( - Execute during the Morning Auction + Execute during the Morning From 52a58189c868005b879d1a42e32ac56d6d7c631e Mon Sep 17 00:00:00 2001 From: fr1j0 Date: Sun, 7 Dec 2025 20:57:03 -0500 Subject: [PATCH 05/21] refactor: Extract shared Tractor order form logic into reusable hooks --- src/components/Tractor/form/SowOrderV0Fields.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/components/Tractor/form/SowOrderV0Fields.tsx b/src/components/Tractor/form/SowOrderV0Fields.tsx index ff97c67d0..5ad9a079e 100644 --- a/src/components/Tractor/form/SowOrderV0Fields.tsx +++ b/src/components/Tractor/form/SowOrderV0Fields.tsx @@ -40,7 +40,8 @@ export const TOOLTIP_COPY = { tokenStrategy: "The source token(s) to use for the Sow Order.", totalAmount: "The total amount of PINTO to Sow in this order.", temperature: "The minimum Temperature at which this order can be executed.", - morningAuction: "The morning is the first 10 minutes of the Season, where the Temperature slowly increases to its maximum. Farmers can opt for their orders to execute during the Morning, such that their orders fill first.", + morningAuction: + "The morning is the first 10 minutes of the Season, where the Temperature slowly increases to its maximum. Farmers can opt for their orders to execute during the Morning, such that their orders fill first.", } as const; interface BaseIFormContextHandlers { From 1dc2b6327527cebe9ad67d287f8669e788e786e7 Mon Sep 17 00:00:00 2001 From: fr1j0 Date: Sun, 7 Dec 2025 21:28:02 -0500 Subject: [PATCH 06/21] refactor: Consolidate shared Tractor order form logic and improve code reusability --- src/components/SowOrderDialog.tsx | 5 +++- .../Tractor/ModifySowOrderDialog.tsx | 5 +++- .../Tractor/Sow/SowOrderEstimatedTipPaid.tsx | 25 +++++++++++++------ 3 files changed, 25 insertions(+), 10 deletions(-) diff --git a/src/components/SowOrderDialog.tsx b/src/components/SowOrderDialog.tsx index b5e506d86..b583bb0d0 100644 --- a/src/components/SowOrderDialog.tsx +++ b/src/components/SowOrderDialog.tsx @@ -329,7 +329,10 @@ export default function SowOrderDialog({ open, onOpenChange, onOrderPublished }: preset={operatorTipPreset} setPreset={handleSetOperatorTipPreset} /> - + diff --git a/src/components/Tractor/ModifySowOrderDialog.tsx b/src/components/Tractor/ModifySowOrderDialog.tsx index 8ae633559..93919823d 100644 --- a/src/components/Tractor/ModifySowOrderDialog.tsx +++ b/src/components/Tractor/ModifySowOrderDialog.tsx @@ -391,7 +391,10 @@ export default function ModifyTractorOrderDialog({ preset={operatorTipPreset} setPreset={handleSetOperatorTipPreset} /> - + diff --git a/src/components/Tractor/Sow/SowOrderEstimatedTipPaid.tsx b/src/components/Tractor/Sow/SowOrderEstimatedTipPaid.tsx index 4b3f2bd8d..440c87623 100644 --- a/src/components/Tractor/Sow/SowOrderEstimatedTipPaid.tsx +++ b/src/components/Tractor/Sow/SowOrderEstimatedTipPaid.tsx @@ -8,22 +8,31 @@ import { postSanitizedSanitizedValue } from "@/utils/string"; import { useMemo } from "react"; import { useFormContext, useWatch } from "react-hook-form"; import { SowOrderV0FormSchema } from "../form/SowOrderV0Schema"; +import { TractorOperatorTipStrategy, getTractorOperatorTipAmountFromPreset } from "../form/fields/sharedFields"; -export const SowOrderEstimatedTipPaid = () => { +interface SowOrderEstimatedTipPaidProps { + averageTipPaid: number; + operatorTipPreset: TractorOperatorTipStrategy; +} + +export const SowOrderEstimatedTipPaid = ({ averageTipPaid, operatorTipPreset }: SowOrderEstimatedTipPaidProps) => { const mainToken = useMainToken(); const form = useFormContext(); - const values = useWatch({ control: form.control }); - const operatorTip = values.operatorTip; - const maxPerSeason = values.maxPerSeason; - const minSoil = values.minSoil; - const totalAmount = values.totalAmount; + + const [customOperatorTip, maxPerSeason, minSoil, totalAmount] = useWatch({ + control: form.control, + name: ["customOperatorTip", "maxPerSeason", "minSoil", "totalAmount"], + }) as [string | undefined, string, string, string]; const tipEstimations = useMemo(() => { const total = postSanitizedSanitizedValue(totalAmount ?? "", mainToken.decimals).tv; const max = postSanitizedSanitizedValue(maxPerSeason ?? "", mainToken.decimals).tv; const min = postSanitizedSanitizedValue(minSoil ?? "", mainToken.decimals).tv; - const tip = postSanitizedSanitizedValue(operatorTip ?? "", mainToken.decimals).tv; + // Calculate tip from preset (same as OperatorTipPresetDropdown does) + const tip = + getTractorOperatorTipAmountFromPreset(operatorTipPreset, averageTipPaid, customOperatorTip, mainToken.decimals) ?? + TV.ZERO; if (total.eq(0) || tip.eq(0)) { return { @@ -41,7 +50,7 @@ export const SowOrderEstimatedTipPaid = () => { min: minTimes.mul(tip), max: maxTimes.mul(tip), }; - }, [operatorTip, maxPerSeason, minSoil, totalAmount, mainToken.decimals]); + }, [customOperatorTip, maxPerSeason, minSoil, totalAmount, operatorTipPreset, averageTipPaid, mainToken.decimals]); return ( From a6c470ad3e2711089710f8d4f431a2d75bac757f Mon Sep 17 00:00:00 2001 From: fr1j0 Date: Sun, 7 Dec 2025 21:38:03 -0500 Subject: [PATCH 07/21] refactor: Consolidate Sow order estimated tip paid calculation logic --- src/components/Tractor/Sow/SowOrderEstimatedTipPaid.tsx | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/components/Tractor/Sow/SowOrderEstimatedTipPaid.tsx b/src/components/Tractor/Sow/SowOrderEstimatedTipPaid.tsx index 440c87623..c2822425a 100644 --- a/src/components/Tractor/Sow/SowOrderEstimatedTipPaid.tsx +++ b/src/components/Tractor/Sow/SowOrderEstimatedTipPaid.tsx @@ -19,9 +19,9 @@ export const SowOrderEstimatedTipPaid = ({ averageTipPaid, operatorTipPreset }: const mainToken = useMainToken(); const form = useFormContext(); - const [customOperatorTip, maxPerSeason, minSoil, totalAmount] = useWatch({ + const [operatorTip, maxPerSeason, minSoil, totalAmount] = useWatch({ control: form.control, - name: ["customOperatorTip", "maxPerSeason", "minSoil", "totalAmount"], + name: ["operatorTip", "maxPerSeason", "minSoil", "totalAmount"], }) as [string | undefined, string, string, string]; const tipEstimations = useMemo(() => { @@ -31,7 +31,7 @@ export const SowOrderEstimatedTipPaid = ({ averageTipPaid, operatorTipPreset }: // Calculate tip from preset (same as OperatorTipPresetDropdown does) const tip = - getTractorOperatorTipAmountFromPreset(operatorTipPreset, averageTipPaid, customOperatorTip, mainToken.decimals) ?? + getTractorOperatorTipAmountFromPreset(operatorTipPreset, averageTipPaid, operatorTip, mainToken.decimals) ?? TV.ZERO; if (total.eq(0) || tip.eq(0)) { @@ -50,7 +50,7 @@ export const SowOrderEstimatedTipPaid = ({ averageTipPaid, operatorTipPreset }: min: minTimes.mul(tip), max: maxTimes.mul(tip), }; - }, [customOperatorTip, maxPerSeason, minSoil, totalAmount, operatorTipPreset, averageTipPaid, mainToken.decimals]); + }, [operatorTip, maxPerSeason, minSoil, totalAmount, operatorTipPreset, averageTipPaid, mainToken.decimals]); return ( From ec1261af1ddd2da465c40d4421f1f0adc564f8bc Mon Sep 17 00:00:00 2001 From: fr1j0 Date: Sun, 7 Dec 2025 22:55:30 -0500 Subject: [PATCH 08/21] refactor: Move cultivation factor logic and math utilities to shared hooks and constants --- .../Tractor/Sow/SowOrderEstimatedTipPaid.tsx | 49 ++++++++++- src/constants/calculations.ts | 16 ++++ src/hooks/pinto/useCultivationFactor.ts | 34 ++++++++ src/utils/math.ts | 83 +++++++++++++++++++ 4 files changed, 179 insertions(+), 3 deletions(-) create mode 100644 src/constants/calculations.ts create mode 100644 src/hooks/pinto/useCultivationFactor.ts create mode 100644 src/utils/math.ts diff --git a/src/components/Tractor/Sow/SowOrderEstimatedTipPaid.tsx b/src/components/Tractor/Sow/SowOrderEstimatedTipPaid.tsx index c2822425a..ed4a7ef57 100644 --- a/src/components/Tractor/Sow/SowOrderEstimatedTipPaid.tsx +++ b/src/components/Tractor/Sow/SowOrderEstimatedTipPaid.tsx @@ -2,8 +2,13 @@ import { TV } from "@/classes/TokenValue"; import { Row } from "@/components/Container"; import TooltipSimple from "@/components/TooltipSimple"; import IconImage from "@/components/ui/IconImage"; +import { DEFAULT_DELTA, INITIAL_CULTIVATION_FACTOR } from "@/constants/calculations"; +import { useCultivationFactor } from "@/hooks/pinto/useCultivationFactor"; +import { useInitialSoil } from "@/state/useFieldData"; +import { usePriceData } from "@/state/usePriceData"; import { useMainToken } from "@/state/useTokenData"; import { formatter } from "@/utils/format"; +import { solveArithmeticSeriesForN } from "@/utils/math"; import { postSanitizedSanitizedValue } from "@/utils/string"; import { useMemo } from "react"; import { useFormContext, useWatch } from "react-hook-form"; @@ -19,6 +24,11 @@ export const SowOrderEstimatedTipPaid = ({ averageTipPaid, operatorTipPreset }: const mainToken = useMainToken(); const form = useFormContext(); + // Fetch data for accurate arithmetic series calculation + const { data: cultivationFactor, isLoading: isCultivationLoading } = useCultivationFactor(); + const { initialSoil, isLoading: isInitialSoilLoading } = useInitialSoil(); + const { price: pintoPrice } = usePriceData(); + const [operatorTip, maxPerSeason, minSoil, totalAmount] = useWatch({ control: form.control, name: ["operatorTip", "maxPerSeason", "minSoil", "totalAmount"], @@ -42,15 +52,48 @@ export const SowOrderEstimatedTipPaid = ({ averageTipPaid, operatorTipPreset }: } // Min executions = total / maxPerSeason (fewer executions = lower tip) - // Max executions = total / minSoil (more executions = higher tip) const minTimes = max.gt(0) ? total.div(max) : TV.ZERO; - const maxTimes = min.gt(0) ? total.div(min) : TV.ZERO; + + // Max executions using accurate arithmetic series calculation + let maxTimes: TV; + + // Check if we have all required data for accurate calculation + if (!cultivationFactor || !initialSoil || isCultivationLoading || isInitialSoilLoading || !pintoPrice) { + // Fallback to simple division while loading or if data unavailable + maxTimes = min.gt(0) ? total.div(min) : TV.ZERO; + } else { + // Calculate initial value: initialSoil * INITIAL_CULTIVATION_FACTOR / cultivationFactor + const initialValue = initialSoil.mul(INITIAL_CULTIVATION_FACTOR).div(cultivationFactor); + + // Calculate delta: (DEFAULT_DELTA * initialValue / 1e6) * pintoPrice / 1e6 + // Note: pintoPrice is TokenValue with 6 decimals, so divide by 1e6 to normalize + const delta = initialValue.mul(DEFAULT_DELTA).div(1e6).mul(pintoPrice).div(1e6); + + // Solve for number of executions using arithmetic series + const maxExecutions = solveArithmeticSeriesForN(total, initialValue, delta); + + // Convert number to TokenValue + maxTimes = TV.fromHuman(maxExecutions, mainToken.decimals); + } return { min: minTimes.mul(tip), max: maxTimes.mul(tip), }; - }, [operatorTip, maxPerSeason, minSoil, totalAmount, operatorTipPreset, averageTipPaid, mainToken.decimals]); + }, [ + operatorTip, + maxPerSeason, + minSoil, + totalAmount, + operatorTipPreset, + averageTipPaid, + mainToken.decimals, + cultivationFactor, + initialSoil, + pintoPrice, + isCultivationLoading, + isInitialSoilLoading, + ]); return ( diff --git a/src/constants/calculations.ts b/src/constants/calculations.ts new file mode 100644 index 000000000..41b3d50fa --- /dev/null +++ b/src/constants/calculations.ts @@ -0,0 +1,16 @@ +/** + * Constants for arithmetic series calculations used in orderbook contexts. + * + * These constants are used for calculating accurate execution counts when + * values ramp up linearly (e.g., soil increasing with each sow). + */ + +/** + * Initial cultivation factor constant for normalizing soil calculations to a baseline cultivation factor. + */ +export const INITIAL_CULTIVATION_FACTOR = 1; // 1% + +/** + * Default delta constant for calculating the step increase per execution in the arithmetic series. + */ +export const DEFAULT_DELTA = 0.5; // 0.5%. this can be found by calling `getGaugeData(0)` on the diamond contract. diff --git a/src/hooks/pinto/useCultivationFactor.ts b/src/hooks/pinto/useCultivationFactor.ts new file mode 100644 index 000000000..6e328112f --- /dev/null +++ b/src/hooks/pinto/useCultivationFactor.ts @@ -0,0 +1,34 @@ +import { TokenValue } from "@/classes/TokenValue"; +import { diamondABI } from "@/constants/abi/diamondABI"; +import { useProtocolAddress } from "@/hooks/pinto/useProtocolAddress"; +import { decodeAbiParameters } from "viem"; +import { useReadContract } from "wagmi"; + +/** + * Hook to fetch the cultivation factor from the protocol's gauge data. + * + * The cultivation factor is retrieved by calling `getGaugeData(0)` on the diamond contract, + * which returns bytes data. We decode the first uint256 from this data to get the cultivation factor. + * + * @returns The cultivation factor as a TokenValue with 18 decimals + */ +export const useCultivationFactor = () => { + const protocolAddress = useProtocolAddress(); + + return useReadContract({ + address: protocolAddress, + abi: diamondABI, + functionName: "getGaugeValue", + args: [0], // gaugeId = 0 + query: { + enabled: !!protocolAddress, + select: (data: `0x${string}`) => { + // Decode first uint256 from bytes + const [cultivationFactor] = decodeAbiParameters([{ type: "uint256" }], data); + + // Return as TokenValue with 18 decimals (standard Solidity precision) + return TokenValue.fromBlockchain(cultivationFactor, 6); + }, + }, + }); +}; diff --git a/src/utils/math.ts b/src/utils/math.ts new file mode 100644 index 000000000..bdd638b07 --- /dev/null +++ b/src/utils/math.ts @@ -0,0 +1,83 @@ +import { TokenValue } from "@/classes/TokenValue"; + +/** + * Solves arithmetic series sum for number of terms using the quadratic formula. + * + * Given an arithmetic series where: + * - S = total sum (target amount) + * - a = first term (initial value) + * - d = common difference (delta/step) + * - n = number of terms (what we're solving for) + * + * The sum formula is: S = n/2 * (2a + (n-1)d) + * + * Rearranging to quadratic form: (d/2)n² + (a - d/2)n - S = 0 + * + * Using quadratic formula: n = (-(a - d/2) + √((a - d/2)² + 2dS)) / d + * + * @param totalAmount - The target sum (S) as a TokenValue + * @param initialValue - The first term in the series (a) as a TokenValue + * @param delta - The common difference between terms (d) as a TokenValue + * @returns The number of terms needed (n) as a regular number + * + * @example + * // For a Sow order with ramping soil: + * const initialSoil = TV.fromHuman(100, 6); + * const delta = TV.fromHuman(5, 6); + * const target = TV.fromHuman(1000, 6); + * const executions = solveArithmeticSeriesForN(target, initialSoil, delta); + */ +export function solveArithmeticSeriesForN( + totalAmount: TokenValue, + initialValue: TokenValue, + delta: TokenValue, +): number { + // Edge case: if delta is zero, fallback to simple division + if (delta.eq(0)) { + if (initialValue.eq(0)) return 0; + return Number(totalAmount.div(initialValue).toHuman()); + } + + // Edge case: if initialValue is zero and delta is positive + if (initialValue.eq(0)) { + // Series: 0 + d + 2d + 3d + ... + (n-1)d = S + // Sum = d * (0 + 1 + 2 + ... + (n-1)) = d * n(n-1)/2 = S + // Solving: n² - n - 2S/d = 0 + // n = (1 + √(1 + 8S/d)) / 2 + const term = totalAmount.mul(8).div(delta); + const discriminant = TokenValue.ONE.add(term); + + if (discriminant.lt(0)) return 0; + + const sqrtDiscriminant = Math.sqrt(Number(discriminant.toHuman())); + const n = (1 + sqrtDiscriminant) / 2; + + return Math.ceil(n); + } + + // Standard case: solve (d/2)n² + (a - d/2)n - S = 0 + // n = (-(a - d/2) + √((a - d/2)² + 2dS)) / d + + // Calculate b = (a - d/2) + const halfDelta = delta.div(2); + const b = initialValue.sub(halfDelta); + + // Calculate discriminant = b² + 2dS + const bSquared = b.mul(b); + const twoDeltaS = delta.mul(2).mul(totalAmount); + const discriminant = bSquared.add(twoDeltaS); + + // Handle negative discriminant (shouldn't happen in practice for valid inputs) + if (discriminant.lt(0)) { + console.warn("Negative discriminant in arithmetic series calculation, returning 0"); + return 0; + } + + // Calculate n = (-b + √discriminant) / d (take positive root) + const sqrtDiscriminant = Math.sqrt(Number(discriminant.toHuman())); + const numerator = -Number(b.toHuman()) + sqrtDiscriminant; + const n = numerator / Number(delta.toHuman()); + + // Return ceiling to ensure we have enough executions + return Math.ceil(Math.max(0, n)); +} From a6fd3795cf43e0e1f51acabd13125a9199009e01 Mon Sep 17 00:00:00 2001 From: fr1j0 Date: Sun, 7 Dec 2025 23:42:17 -0500 Subject: [PATCH 09/21] refactor: Consolidate Sow order form logic with shared hooks and improved tip calculation --- .../Tractor/Sow/SowOrderEstimatedTipPaid.tsx | 28 +++++++++++++++---- .../Tractor/form/SowOrderV0Schema.ts | 1 + .../Tractor/form/fields/sharedFields.tsx | 4 +-- 3 files changed, 25 insertions(+), 8 deletions(-) diff --git a/src/components/Tractor/Sow/SowOrderEstimatedTipPaid.tsx b/src/components/Tractor/Sow/SowOrderEstimatedTipPaid.tsx index ed4a7ef57..1cee49ed9 100644 --- a/src/components/Tractor/Sow/SowOrderEstimatedTipPaid.tsx +++ b/src/components/Tractor/Sow/SowOrderEstimatedTipPaid.tsx @@ -29,10 +29,10 @@ export const SowOrderEstimatedTipPaid = ({ averageTipPaid, operatorTipPreset }: const { initialSoil, isLoading: isInitialSoilLoading } = useInitialSoil(); const { price: pintoPrice } = usePriceData(); - const [operatorTip, maxPerSeason, minSoil, totalAmount] = useWatch({ + const [operatorTip, customOperatorTip, maxPerSeason, minSoil, totalAmount] = useWatch({ control: form.control, - name: ["operatorTip", "maxPerSeason", "minSoil", "totalAmount"], - }) as [string | undefined, string, string, string]; + name: ["operatorTip", "customOperatorTip", "maxPerSeason", "minSoil", "totalAmount"], + }) as [string | undefined, string | undefined, string, string, string]; const tipEstimations = useMemo(() => { const total = postSanitizedSanitizedValue(totalAmount ?? "", mainToken.decimals).tv; @@ -40,8 +40,10 @@ export const SowOrderEstimatedTipPaid = ({ averageTipPaid, operatorTipPreset }: const min = postSanitizedSanitizedValue(minSoil ?? "", mainToken.decimals).tv; // Calculate tip from preset (same as OperatorTipPresetDropdown does) + // Use customOperatorTip when preset is Custom, otherwise use operatorTip + const tipAmount = operatorTipPreset === "Custom" ? customOperatorTip : operatorTip; const tip = - getTractorOperatorTipAmountFromPreset(operatorTipPreset, averageTipPaid, operatorTip, mainToken.decimals) ?? + getTractorOperatorTipAmountFromPreset(operatorTipPreset, averageTipPaid, tipAmount, mainToken.decimals) ?? TV.ZERO; if (total.eq(0) || tip.eq(0)) { @@ -82,6 +84,7 @@ export const SowOrderEstimatedTipPaid = ({ averageTipPaid, operatorTipPreset }: }; }, [ operatorTip, + customOperatorTip, maxPerSeason, minSoil, totalAmount, @@ -98,10 +101,23 @@ export const SowOrderEstimatedTipPaid = ({ averageTipPaid, operatorTipPreset }: return ( -
Estimated Total Tip Paid
+
Estimated Total Tip
+ The total tip paid depends on the number of executions needed to fill your order, based on the Soil supply + and Cultivation Factor.{" "} + + Learn more + + + } />
diff --git a/src/components/Tractor/form/SowOrderV0Schema.ts b/src/components/Tractor/form/SowOrderV0Schema.ts index 8ac742717..e86f2d5d3 100644 --- a/src/components/Tractor/form/SowOrderV0Schema.ts +++ b/src/components/Tractor/form/SowOrderV0Schema.ts @@ -35,6 +35,7 @@ export const sowOrderDialogSchema = z.object({ podLineLength: positiveNumber("Pod Line Length"), morningAuction: z.boolean().default(false), operatorTip: positiveNumber("Operator Tip"), + customOperatorTip: z.string().optional(), selectedTokenStrategy: tokenStrategy, }); diff --git a/src/components/Tractor/form/fields/sharedFields.tsx b/src/components/Tractor/form/fields/sharedFields.tsx index a9019d2ae..75a72e729 100644 --- a/src/components/Tractor/form/fields/sharedFields.tsx +++ b/src/components/Tractor/form/fields/sharedFields.tsx @@ -222,8 +222,8 @@ export const OperatorTipFormField = ({ averageTipPaid, preset, setPreset }: Oper return ( - Tip Per Execution - + Tip per Execution + Date: Sun, 7 Dec 2025 23:54:45 -0500 Subject: [PATCH 10/21] refactor: Update SowOrderV0Fields to use shared estimation and calculation hooks --- src/components/Tractor/form/SowOrderV0Fields.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/Tractor/form/SowOrderV0Fields.tsx b/src/components/Tractor/form/SowOrderV0Fields.tsx index 5ad9a079e..2c4217c2d 100644 --- a/src/components/Tractor/form/SowOrderV0Fields.tsx +++ b/src/components/Tractor/form/SowOrderV0Fields.tsx @@ -339,7 +339,7 @@ SowOrderV0Fields.TokenStrategy = function TokenStrategy({ } else if (strategy?.type === "LOWEST_PRICE") { return "Token with Best Price"; } else if (strategy?.type === "SPECIFIC_TOKEN") { - return selectedToken?.symbol || "Select Token"; + return selectedToken ? `Dep. ${selectedToken.symbol}` : "Select Token"; } return "Select Deposited Silo Token"; }; From b39023ec68548001d6290e07d8ba011fd0958a24 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Mon, 8 Dec 2025 06:00:39 +0000 Subject: [PATCH 11/21] fix: extract select function to stable reference in useCultivationFactor Extract the select function outside the useCultivationFactor hook to maintain a stable reference and prevent unnecessary re-renders. This improves performance by avoiding function recreation on every render. Co-authored-by: fr1jo --- src/hooks/pinto/useCultivationFactor.ts | 20 +++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/src/hooks/pinto/useCultivationFactor.ts b/src/hooks/pinto/useCultivationFactor.ts index 6e328112f..07e22b1b7 100644 --- a/src/hooks/pinto/useCultivationFactor.ts +++ b/src/hooks/pinto/useCultivationFactor.ts @@ -4,6 +4,18 @@ import { useProtocolAddress } from "@/hooks/pinto/useProtocolAddress"; import { decodeAbiParameters } from "viem"; import { useReadContract } from "wagmi"; +/** + * Stable select function to transform gauge data into cultivation factor. + * Extracted outside the hook to maintain a stable reference. + */ +const selectCultivationFactor = (data: `0x${string}`) => { + // Decode first uint256 from bytes + const [cultivationFactor] = decodeAbiParameters([{ type: "uint256" }], data); + + // Return as TokenValue with 18 decimals (standard Solidity precision) + return TokenValue.fromBlockchain(cultivationFactor, 6); +}; + /** * Hook to fetch the cultivation factor from the protocol's gauge data. * @@ -22,13 +34,7 @@ export const useCultivationFactor = () => { args: [0], // gaugeId = 0 query: { enabled: !!protocolAddress, - select: (data: `0x${string}`) => { - // Decode first uint256 from bytes - const [cultivationFactor] = decodeAbiParameters([{ type: "uint256" }], data); - - // Return as TokenValue with 18 decimals (standard Solidity precision) - return TokenValue.fromBlockchain(cultivationFactor, 6); - }, + select: selectCultivationFactor, }, }); }; From 44dfc8d3be2305efeeccc441448b7ffb48de698d Mon Sep 17 00:00:00 2001 From: feyyazcigim Date: Mon, 8 Dec 2025 20:05:49 +0300 Subject: [PATCH 12/21] Revert error handling for advanced form --- .../Sow/SowOrderTractorAdvancedForm.tsx | 65 +++++++++++++++---- 1 file changed, 53 insertions(+), 12 deletions(-) diff --git a/src/components/Tractor/Sow/SowOrderTractorAdvancedForm.tsx b/src/components/Tractor/Sow/SowOrderTractorAdvancedForm.tsx index 06bdc1b2c..c392e4f5c 100644 --- a/src/components/Tractor/Sow/SowOrderTractorAdvancedForm.tsx +++ b/src/components/Tractor/Sow/SowOrderTractorAdvancedForm.tsx @@ -1,17 +1,15 @@ -import { Col, Row } from "@/components/Container"; +import { Col } from "@/components/Container"; import { FormControl, FormField, FormItem, FormLabel } from "@/components/Form"; import IconImage from "@/components/ui/IconImage"; import { Input } from "@/components/ui/Input"; -import { Switch } from "@/components/ui/Switch"; +import Warning from "@/components/ui/Warning"; import { MAIN_TOKEN } from "@/constants/tokens"; import { useSharedNumericFormFieldHandlers } from "@/hooks/form/useSharedNumericFormFieldHandlers"; import { usePodLine } from "@/state/useFieldData"; import { useChainConstant } from "@/utils/chain"; import { formatter } from "@/utils/format"; -import { sanitizeNumericInputValue } from "@/utils/string"; -import { useCallback } from "react"; -import { useFormContext, useFormState } from "react-hook-form"; -import { toast } from "sonner"; +import { useCallback, useEffect, useState } from "react"; +import { useFormContext, useFormState, useWatch } from "react-hook-form"; import { SowOrderV0FormSchema, validateAdvancedFormFields } from "../form/SowOrderV0Schema"; import { TractorFormButtonsRow } from "../form/fields/sharedFields"; @@ -42,10 +40,38 @@ const SowOrderTractorAdvancedForm = ({ onSubmit, onCancel }: Props) => { const mainToken = useChainConstant(MAIN_TOKEN); const podLine = usePodLine(); + // State for tracking cross-field validation errors + const [crossFieldErrors, setCrossFieldErrors] = useState([]); + const minSoilHandlers = useSharedNumericFormFieldHandlers(form, "minSoil", mainToken.decimals); const maxPerSeasonHandlers = useSharedNumericFormFieldHandlers(form, "maxPerSeason", mainToken.decimals); const podLineLengthHandlers = useSharedNumericFormFieldHandlers(form, "podLineLength", mainToken.decimals); + // Watch the relevant fields to trigger validation on change + const [minSoil, maxPerSeason, totalAmount] = useWatch({ + control: form.control, + name: ["minSoil", "maxPerSeason", "totalAmount"], + }); + + // Run cross-field validation whenever watched values change + useEffect(() => { + if (!minSoil || !maxPerSeason || !totalAmount) { + setCrossFieldErrors([]); + return; + } + + const validationResult = validateAdvancedFormFields( + { + minSoil, + maxPerSeason, + totalAmount, + }, + form, + ); + + setCrossFieldErrors(validationResult.errors); + }, [minSoil, maxPerSeason, totalAmount, form]); + const handleBack = useCallback( (e: React.MouseEvent) => { e.stopPropagation(); @@ -78,10 +104,6 @@ const SowOrderTractorAdvancedForm = ({ onSubmit, onCancel }: Props) => { ); if (!validationResult.isValid) { - // Show toast with all validation errors - validationResult.errors.forEach((error) => { - toast.error(error); - }); return; } @@ -148,7 +170,23 @@ const SowOrderTractorAdvancedForm = ({ onSubmit, onCancel }: Props) => {
)} /> - + + 0} /> + + ); +}; + +// Error display component for advanced form validation errors +const AdvancedFormErrors = ({ errors }: { errors: string[] }) => { + if (!errors.length) return null; + + return ( + + {errors.map((err) => ( +
+ {err} +
+ ))} ); }; @@ -156,13 +194,16 @@ const SowOrderTractorAdvancedForm = ({ onSubmit, onCancel }: Props) => { const ButtonRow = ({ handleBack, handleNext, + hasErrors: crossFieldHasErrors, }: { handleBack: (e: React.MouseEvent) => void; handleNext: (e: React.MouseEvent) => Promise; + hasErrors?: boolean; }) => { const { errors } = useFormState(); - const hasErrors = Boolean(Object.keys(errors).length); + const hasFormErrors = Boolean(Object.keys(errors).length); + const hasErrors = hasFormErrors || crossFieldHasErrors; return ( Date: Mon, 8 Dec 2025 20:11:35 +0300 Subject: [PATCH 13/21] Add morning auction row to summary --- src/components/Tractor/Sow/SowOrderSharedComponents.tsx | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/components/Tractor/Sow/SowOrderSharedComponents.tsx b/src/components/Tractor/Sow/SowOrderSharedComponents.tsx index a1062ce7f..e12770965 100644 --- a/src/components/Tractor/Sow/SowOrderSharedComponents.tsx +++ b/src/components/Tractor/Sow/SowOrderSharedComponents.tsx @@ -87,11 +87,14 @@ export const SowOrderEntryFormParametersSummary = () => { return <>; }; + const morningAuction = values.morningAuction ? "Yes" : "No"; + return ( <> + ); }; From 87dbbc2c5b5159fb434e3a4ba6d249da584bf85e Mon Sep 17 00:00:00 2001 From: feyyazcigim Date: Thu, 11 Dec 2025 02:27:52 +0300 Subject: [PATCH 14/21] Fix auto fill on temperature input --- src/components/Tractor/form/SowOrderV0Fields.tsx | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/components/Tractor/form/SowOrderV0Fields.tsx b/src/components/Tractor/form/SowOrderV0Fields.tsx index 2c4217c2d..dd185eb20 100644 --- a/src/components/Tractor/form/SowOrderV0Fields.tsx +++ b/src/components/Tractor/form/SowOrderV0Fields.tsx @@ -14,7 +14,7 @@ import { useChainConstant } from "@/utils/chain"; import { formatter } from "@/utils/format"; import { postSanitizedSanitizedValue, sanitizeNumericInputValue, stringEq } from "@/utils/string"; import { getTokenIndex } from "@/utils/token"; -import { useCallback, useEffect, useMemo, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { SowOrderV0FormSchema } from "./SowOrderV0Schema"; import { TV } from "@/classes/TokenValue"; @@ -402,6 +402,7 @@ SowOrderV0Fields.Temperature = function Temperature() { const handlers = useSharedInputHandlers(ctx, "temperature"); const { data: maxTemperature } = useReadBeanstalk_MaxTemperature(); const temperature = useTemperature(); + const hasInitialized = useRef(false); const currentTempValue = useMemo(() => { // Use max temperature from contract if available, otherwise use temperature state @@ -414,12 +415,14 @@ SowOrderV0Fields.Temperature = function Temperature() { const minTemp = useMemo(() => Math.max(0, currentTempValue - 100), [currentTempValue]); const maxTemp = useMemo(() => currentTempValue + 100, [currentTempValue]); - // Set default value to current temperature if not set + // Set default value to current temperature only on initial mount useEffect(() => { + if (hasInitialized.current) return; const currentValue = ctx.getValues("temperature"); if (!currentValue || currentValue === "") { ctx.setValue("temperature", currentTempValue.toString(), { shouldValidate: false }); } + hasInitialized.current = true; }, [currentTempValue, ctx]); return ( From 5db2afe0c07dc5477335a2f0e27b43053b0e12cb Mon Sep 17 00:00:00 2001 From: feyyazcigim Date: Thu, 11 Dec 2025 02:47:05 +0300 Subject: [PATCH 15/21] Fix type and change tooltips --- src/components/Tractor/Sow/SowOrderSharedComponents.tsx | 2 +- src/components/Tractor/form/SowOrderV0Fields.tsx | 3 +-- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/src/components/Tractor/Sow/SowOrderSharedComponents.tsx b/src/components/Tractor/Sow/SowOrderSharedComponents.tsx index e12770965..257ac2068 100644 --- a/src/components/Tractor/Sow/SowOrderSharedComponents.tsx +++ b/src/components/Tractor/Sow/SowOrderSharedComponents.tsx @@ -94,7 +94,7 @@ export const SowOrderEntryFormParametersSummary = () => { - + ); }; diff --git a/src/components/Tractor/form/SowOrderV0Fields.tsx b/src/components/Tractor/form/SowOrderV0Fields.tsx index dd185eb20..91c273094 100644 --- a/src/components/Tractor/form/SowOrderV0Fields.tsx +++ b/src/components/Tractor/form/SowOrderV0Fields.tsx @@ -40,8 +40,7 @@ export const TOOLTIP_COPY = { tokenStrategy: "The source token(s) to use for the Sow Order.", totalAmount: "The total amount of PINTO to Sow in this order.", temperature: "The minimum Temperature at which this order can be executed.", - morningAuction: - "The morning is the first 10 minutes of the Season, where the Temperature slowly increases to its maximum. Farmers can opt for their orders to execute during the Morning, such that their orders fill first.", + morningAuction: "When enabled, this order will only execute during the Morning Auction period.", } as const; interface BaseIFormContextHandlers { From 28ed77d523ffd9c91f1a041acd426ec8e5cbba93 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 11 Dec 2025 00:55:58 +0000 Subject: [PATCH 16/21] Update Morning tooltip text with detailed explanation Co-authored-by: fr1jo --- src/components/Tractor/form/SowOrderV0Fields.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/Tractor/form/SowOrderV0Fields.tsx b/src/components/Tractor/form/SowOrderV0Fields.tsx index 91c273094..35f60774b 100644 --- a/src/components/Tractor/form/SowOrderV0Fields.tsx +++ b/src/components/Tractor/form/SowOrderV0Fields.tsx @@ -40,7 +40,7 @@ export const TOOLTIP_COPY = { tokenStrategy: "The source token(s) to use for the Sow Order.", totalAmount: "The total amount of PINTO to Sow in this order.", temperature: "The minimum Temperature at which this order can be executed.", - morningAuction: "When enabled, this order will only execute during the Morning Auction period.", + morningAuction: "The morning is the first 10 minutes of the Season, where the Temperature slowly increases to its maximum. Farmers can opt for their orders to execute during the Morning, such that their orders fill first.", } as const; interface BaseIFormContextHandlers { From 421072b0699b35bd06c1102fa491d5181c173678 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 11 Dec 2025 00:56:16 +0000 Subject: [PATCH 17/21] chore: auto-format and lint code MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- src/components/Tractor/form/SowOrderV0Fields.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/components/Tractor/form/SowOrderV0Fields.tsx b/src/components/Tractor/form/SowOrderV0Fields.tsx index 35f60774b..dd185eb20 100644 --- a/src/components/Tractor/form/SowOrderV0Fields.tsx +++ b/src/components/Tractor/form/SowOrderV0Fields.tsx @@ -40,7 +40,8 @@ export const TOOLTIP_COPY = { tokenStrategy: "The source token(s) to use for the Sow Order.", totalAmount: "The total amount of PINTO to Sow in this order.", temperature: "The minimum Temperature at which this order can be executed.", - morningAuction: "The morning is the first 10 minutes of the Season, where the Temperature slowly increases to its maximum. Farmers can opt for their orders to execute during the Morning, such that their orders fill first.", + morningAuction: + "The morning is the first 10 minutes of the Season, where the Temperature slowly increases to its maximum. Farmers can opt for their orders to execute during the Morning, such that their orders fill first.", } as const; interface BaseIFormContextHandlers { From 7b00dfc721b4a32dba3c7488bcfa41f39513409f Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 11 Dec 2025 01:13:51 +0000 Subject: [PATCH 18/21] Update tractor order form tooltips - Capitalize 'Morning' and add newline to morning tooltip - Update 'Tip per Execution' tooltip to clarify PINTO payment Co-authored-by: fr1jo --- src/components/Tractor/form/SowOrderV0Fields.tsx | 2 +- src/components/Tractor/form/fields/sharedFields.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/components/Tractor/form/SowOrderV0Fields.tsx b/src/components/Tractor/form/SowOrderV0Fields.tsx index dd185eb20..1ab79a84b 100644 --- a/src/components/Tractor/form/SowOrderV0Fields.tsx +++ b/src/components/Tractor/form/SowOrderV0Fields.tsx @@ -41,7 +41,7 @@ export const TOOLTIP_COPY = { totalAmount: "The total amount of PINTO to Sow in this order.", temperature: "The minimum Temperature at which this order can be executed.", morningAuction: - "The morning is the first 10 minutes of the Season, where the Temperature slowly increases to its maximum. Farmers can opt for their orders to execute during the Morning, such that their orders fill first.", + "The Morning is the first 10 minutes of the Season, where the Temperature slowly increases to its maximum.\nFarmers can opt for their orders to execute during the Morning, such that their orders fill first.", } as const; interface BaseIFormContextHandlers { diff --git a/src/components/Tractor/form/fields/sharedFields.tsx b/src/components/Tractor/form/fields/sharedFields.tsx index 75a72e729..64b7cffa2 100644 --- a/src/components/Tractor/form/fields/sharedFields.tsx +++ b/src/components/Tractor/form/fields/sharedFields.tsx @@ -223,7 +223,7 @@ export const OperatorTipFormField = ({ averageTipPaid, preset, setPreset }: Oper Tip per Execution - + Date: Thu, 11 Dec 2025 01:14:11 +0000 Subject: [PATCH 19/21] chore: auto-format and lint code MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- src/components/Tractor/form/fields/sharedFields.tsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/components/Tractor/form/fields/sharedFields.tsx b/src/components/Tractor/form/fields/sharedFields.tsx index 64b7cffa2..e92324a80 100644 --- a/src/components/Tractor/form/fields/sharedFields.tsx +++ b/src/components/Tractor/form/fields/sharedFields.tsx @@ -223,7 +223,10 @@ export const OperatorTipFormField = ({ averageTipPaid, preset, setPreset }: Oper Tip per Execution - + Date: Thu, 11 Dec 2025 19:11:50 +0300 Subject: [PATCH 20/21] Improve error handling --- src/components/SowOrderDialog.tsx | 80 +++++++++++++++++-- .../Tractor/form/SowOrderV0Fields.tsx | 15 +++- .../Tractor/form/SowOrderV0Schema.ts | 2 + 3 files changed, 89 insertions(+), 8 deletions(-) diff --git a/src/components/SowOrderDialog.tsx b/src/components/SowOrderDialog.tsx index b583bb0d0..5ee24830d 100644 --- a/src/components/SowOrderDialog.tsx +++ b/src/components/SowOrderDialog.tsx @@ -18,7 +18,7 @@ import { formatter } from "@/utils/format"; import { sanitizeNumericInputValue } from "@/utils/string"; import { cn } from "@/utils/utils"; import { AnimatePresence, motion } from "framer-motion"; -import { useCallback, useEffect, useRef, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import React from "react"; import { useFormContext, useWatch } from "react-hook-form"; import { toast } from "sonner"; @@ -108,7 +108,66 @@ export default function SowOrderDialog({ open, onOpenChange, onOrderPublished }: // Set default values for minSoil, maxPerSeason, and podLineLength const mainToken = useChainConstant(MAIN_TOKEN); const podLine = usePodLine(); - const [totalAmount] = useWatch({ control: form.control, name: ["totalAmount"] }); + const [totalAmount, tokenStrategy, temperature] = useWatch({ + control: form.control, + name: ["totalAmount", "selectedTokenStrategy", "temperature"], + }); + + // Calculate max amount based on farmer deposits and token strategy + const maxDepositAmount = useMemo(() => { + if (!farmerDeposits) return undefined; + + const summary = StrategyUtil.getSummary(tokenStrategy); + let total = TV.ZERO; + + if (summary.type === "SPECIFIC_TOKEN" && summary.addresses) { + summary.addresses.forEach((address) => { + farmerDeposits.forEach((deposit, token) => { + if (token.address.toLowerCase() === address.toLowerCase() && deposit.amount) { + if (token.isLP) { + const price = calculations.priceData.tokenPrices.get(token)?.instant; + if (price) { + total = total.add(deposit.amount.mul(price)); + } + } else { + total = total.add(deposit.amount); + } + } + }); + }); + } else { + farmerDeposits.forEach((deposit, token) => { + if (deposit.amount) { + if (token.isLP) { + const price = calculations.priceData.tokenPrices.get(token)?.instant; + if (price) { + total = total.add(deposit.amount.mul(price)); + } + } else { + total = total.add(deposit.amount); + } + } + }); + } + + return total.gt(0) ? total : undefined; + }, [farmerDeposits, tokenStrategy, calculations.priceData]); + + // Check if total amount exceeds max deposits + const exceedsDeposits = useMemo(() => { + if (!maxDepositAmount || !totalAmount) return false; + const cleaned = sanitizeNumericInputValue(totalAmount, mainToken.decimals); + if (cleaned.nonAmount) return false; + return cleaned.tv.toNumber() > maxDepositAmount.toNumber(); + }, [maxDepositAmount, totalAmount, mainToken.decimals]); + + // Check if temperature is zero or empty + const temperatureIsZero = useMemo(() => { + if (!temperature) return false; + const cleaned = sanitizeNumericInputValue(temperature, 6); + if (cleaned.nonAmount) return false; + return cleaned.tv.toNumber() === 0; + }, [temperature]); // Set default values for minSoil and maxPerSeason based on totalAmount useEffect(() => { @@ -258,7 +317,8 @@ export default function SowOrderDialog({ open, onOpenChange, onOrderPublished }: const isStep1 = formStep === FormStep.MAIN_FORM; - const nextDisabled = (isLoading || isMissingFields || !allFieldsValid) && isStep1; + const nextDisabled = + (isLoading || isMissingFields || !allFieldsValid || exceedsDeposits || temperatureIsZero) && isStep1; return ( <> @@ -352,7 +412,7 @@ export default function SowOrderDialog({ open, onOpenChange, onOrderPublished }: ) : null} {formStep === FormStep.MAIN_FORM ? ( <> - +