diff --git a/src/components/SowOrderDialog.tsx b/src/components/SowOrderDialog.tsx index 98bf61e1d..5ee24830d 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, useMemo, 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,203 @@ 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, 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(() => { + 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: true }); + + // maxPerSeason: TotalValueToSow + 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); } @@ -119,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 ( <> @@ -140,95 +339,133 @@ 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 + disabled={!(isMissingFields && isStep1)} + > +
+ +
+
+ + + ) : null} @@ -316,7 +553,11 @@ export const SowOrderV0TokenStrategyDialog = ({ const SowOrderV0FormErrors = ({ errors, -}: { errors: ReturnType["form"]["formState"]["errors"] }) => { + exceedsDeposits, +}: { + errors: ReturnType["form"]["formState"]["errors"]; + exceedsDeposits?: boolean; +}) => { const deduplicate = () => { const set = new Set(); for (const err of Object.values(errors)) { @@ -324,6 +565,10 @@ const SowOrderV0FormErrors = ({ set.add(err.message); } } + // Add exceeds deposits error if applicable + if (exceedsDeposits) { + set.add(sowOrderSchemaErrors.totalExceedsDeposits); + } return Array.from(set); }; diff --git a/src/components/Tractor/ModifySowOrderDialog.tsx b/src/components/Tractor/ModifySowOrderDialog.tsx index 186be81ba..93919823d 100644 --- a/src/components/Tractor/ModifySowOrderDialog.tsx +++ b/src/components/Tractor/ModifySowOrderDialog.tsx @@ -1,9 +1,11 @@ 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"; import TooltipSimple from "@/components/TooltipSimple"; +import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/Accordion"; import { Button } from "@/components/ui/Button"; import { Dialog, @@ -14,6 +16,8 @@ import { DialogPortal, 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"; @@ -24,18 +28,29 @@ 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, useState } from "react"; +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"; 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 +78,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 +119,136 @@ 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(); + 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 +284,134 @@ 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 */} + + {/* Execute during the Morning Auction */} + + {/* 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 +724,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: diff --git a/src/components/Tractor/Sow/SowOrderEstimatedTipPaid.tsx b/src/components/Tractor/Sow/SowOrderEstimatedTipPaid.tsx new file mode 100644 index 000000000..1cee49ed9 --- /dev/null +++ b/src/components/Tractor/Sow/SowOrderEstimatedTipPaid.tsx @@ -0,0 +1,129 @@ +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"; +import { SowOrderV0FormSchema } from "../form/SowOrderV0Schema"; +import { TractorOperatorTipStrategy, getTractorOperatorTipAmountFromPreset } from "../form/fields/sharedFields"; + +interface SowOrderEstimatedTipPaidProps { + averageTipPaid: number; + operatorTipPreset: TractorOperatorTipStrategy; +} + +export const SowOrderEstimatedTipPaid = ({ averageTipPaid, operatorTipPreset }: SowOrderEstimatedTipPaidProps) => { + 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, customOperatorTip, maxPerSeason, minSoil, totalAmount] = useWatch({ + control: form.control, + name: ["operatorTip", "customOperatorTip", "maxPerSeason", "minSoil", "totalAmount"], + }) as [string | undefined, 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; + + // 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, tipAmount, mainToken.decimals) ?? + TV.ZERO; + + if (total.eq(0) || tip.eq(0)) { + return { + min: TV.ZERO, + max: TV.ZERO, + }; + } + + // Min executions = total / maxPerSeason (fewer executions = lower tip) + const minTimes = max.gt(0) ? total.div(max) : 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, + customOperatorTip, + maxPerSeason, + minSoil, + totalAmount, + operatorTipPreset, + averageTipPaid, + mainToken.decimals, + cultivationFactor, + initialSoil, + pintoPrice, + isCultivationLoading, + isInitialSoilLoading, + ]); + + return ( + + +
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 + + + } + /> +
+ + + {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..257ac2068 --- /dev/null +++ b/src/components/Tractor/Sow/SowOrderSharedComponents.tsx @@ -0,0 +1,163 @@ +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 <>; + }; + + const morningAuction = values.morningAuction ? "Yes" : "No"; + + 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; + + 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..c392e4f5c --- /dev/null +++ b/src/components/Tractor/Sow/SowOrderTractorAdvancedForm.tsx @@ -0,0 +1,223 @@ +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 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 { 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"; + +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(); + + // 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(); + e.preventDefault(); + onCancel(); + }, + [onCancel], + ); + + const handleNext = useCallback( + async (e: React.MouseEvent) => { + e.stopPropagation(); + e.preventDefault(); + + // First validate individual fields + const isValid = await form.trigger(["minSoil", "maxPerSeason", "podLineLength"]); + if (!isValid) { + return; + } + + // Then validate cross-field relationships + const formData = form.getValues(); + const validationResult = validateAdvancedFormFields( + { + minSoil: formData.minSoil, + maxPerSeason: formData.maxPerSeason, + totalAmount: formData.totalAmount, + }, + form, + ); + + if (!validationResult.isValid) { + return; + } + + onSubmit(); + }, + [form, onSubmit], + ); + + return ( + + ( + + Min per Season + + } + /> + + + )} + /> + ( + + Max per Season + + } + /> + + + )} + /> + ( + + Pod Line Length + + + + + )} + /> + + 0} /> + + ); +}; + +// Error display component for advanced form validation errors +const AdvancedFormErrors = ({ errors }: { errors: string[] }) => { + if (!errors.length) return null; + + return ( + + {errors.map((err) => ( +
+ {err} +
+ ))} + + ); +}; + +const ButtonRow = ({ + handleBack, + handleNext, + hasErrors: crossFieldHasErrors, +}: { + handleBack: (e: React.MouseEvent) => void; + handleNext: (e: React.MouseEvent) => Promise; + hasErrors?: boolean; +}) => { + const { errors } = useFormState(); + + const hasFormErrors = Boolean(Object.keys(errors).length); + const hasErrors = hasFormErrors || crossFieldHasErrors; + + return ( + + ); +}; + +export default SowOrderTractorAdvancedForm; diff --git a/src/components/Tractor/form/SowOrderV0Fields.tsx b/src/components/Tractor/form/SowOrderV0Fields.tsx index c92b55684..d821dd02d 100644 --- a/src/components/Tractor/form/SowOrderV0Fields.tsx +++ b/src/components/Tractor/form/SowOrderV0Fields.tsx @@ -1,25 +1,34 @@ 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 { useReadBeanstalk_MaxTemperature } from "@/generated/contractHooks"; import { useTokenMap } from "@/hooks/pinto/useTokenMap"; -import { useScaledTemperature } from "@/hooks/useContinuousMorningTime"; -import { usePodLine } from "@/state/useFieldData"; +import { useTemperature } from "@/state/useFieldData"; 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"; 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 +36,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.", + morningAuction: + "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 { onChange: (e: React.ChangeEvent) => ReturnType; onBlur: (e: React.FocusEvent) => void; @@ -96,130 +113,176 @@ 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) + // Use toFixed to avoid floating-point precision issues (e.g., 65.599999 instead of 65.6) + const maxDecimals = Math.min(decimals, 6); + const truncatedValue = Number(value[0].toFixed(maxDecimals)); + // 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, totalAmountValue] = useWatch({ + control: ctx.control, + name: ["selectedTokenStrategy", "totalAmount"], + }); + 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]); + + // Check if total amount exceeds max deposits + const exceedsDeposits = useMemo(() => { + if (!maxAmount || !totalAmountValue) return false; + const cleaned = sanitizeNumericInputValue(totalAmountValue, decimals); + if (cleaned.nonAmount) return false; + return cleaned.tv.toNumber() > maxAmount.toNumber(); + }, [maxAmount, totalAmountValue, 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"); - handleCrossValidate(ctx, cleaned, "totalAmount", decimals, "lte"); return cleaned; }, }; }; + // Disable slider if no token strategy selected or no balance available + const isSliderDisabled = !tokenStrategy || !maxAmount || maxAmount.lte(0); + return ( - ( - - Min per Season -
+ + I want to Sow up to + + + ( } /> -
-
- )} - /> - ); -}; - -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 - - } - /> - - - )} - /> + )} + /> + + ); }; @@ -289,7 +352,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"; }; @@ -297,7 +360,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 { data: maxTemperature } = useReadBeanstalk_MaxTemperature(); + const temperatureState = useTemperature(); + + 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 max temperature from contract + const tempValue = + temperature && temperature !== "" + ? Number(temperature.replace(/,/g, "")) + : 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, maxTemperature, temperatureState.max]); return ( - ( - - Execute during the Morning Auction -
- - -
-
- )} - /> + +
Pods
+
+ +
{formatter.number(estimatedPods, { minValue: 0.01 })} PODS
+
+
); }; diff --git a/src/components/Tractor/form/SowOrderV0Schema.ts b/src/components/Tractor/form/SowOrderV0Schema.ts index dfa7f1bba..e686b2da2 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; @@ -22,38 +24,91 @@ export const sowOrderSchemaErrors = { minLteMax: "Min per Season cannot exceed Max per Season", minLteTotal: "Min per Season cannot exceed the total amount to Sow", maxLteTotal: "Max per Season cannot exceed the total amount to Sow", + totalExceedsDeposits: "Total amount cannot exceed your available deposits", + temperatureZero: "Temperature must be greater than 0", } 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"), + customOperatorTip: z.string().optional(), + 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 +134,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 +174,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() === ""; diff --git a/src/components/Tractor/form/fields/sharedFields.tsx b/src/components/Tractor/form/fields/sharedFields.tsx index a9019d2ae..e92324a80 100644 --- a/src/components/Tractor/form/fields/sharedFields.tsx +++ b/src/components/Tractor/form/fields/sharedFields.tsx @@ -222,8 +222,11 @@ export const OperatorTipFormField = ({ averageTipPaid, preset, setPreset }: Oper return ( - Tip Per Execution - + Tip per Execution + { + // 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. + * + * 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: selectCultivationFactor, + }, + }); +}; 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)); +}