diff --git a/src/ProtectedLayout.tsx b/src/ProtectedLayout.tsx index 09581e3b1..630ae118b 100644 --- a/src/ProtectedLayout.tsx +++ b/src/ProtectedLayout.tsx @@ -8,6 +8,7 @@ import Explorer from "./pages/Explorer"; import Field from "./pages/Field"; import { Market as MarketPage } from "./pages/Market"; import Overview from "./pages/Overview"; +import Referral from "./pages/Referral"; import Silo from "./pages/Silo"; import SiloToken from "./pages/SiloToken"; import Swap from "./pages/Swap"; @@ -60,6 +61,14 @@ export default function ProtectedLayout() { } /> + + + + } + /> (10); const [blockSkipAmount, setBlockSkipAmount] = useState("6"); // default to 6 blocks because the morning auction updates every 6 blocks (12 seconds on eth, 2 seconds on base, 12/2 = 6) + const [sunriseCount, setSunriseCount] = useState("1"); // number of times to call sunrise const [mockAddress, setMockAddress] = useAtom(mockAddressAtom); @@ -341,7 +341,7 @@ export default function DevPage() { const calculatePercentAmounts = (percent: number) => { console.log("priceData", priceData.pools); - const tokenOrder = ["WETH", "cbETH", "cbBTC", "USDC", "WSOL"]; + const tokenOrder = ["WETH", "cbETH", "cbBTC", "USDC", "WSOL", "wstETH"]; const amounts = tokenOrder.map((symbol) => { const pool = priceData.pools.find((p) => p.tokens.some((token) => token.symbol === symbol)); @@ -383,6 +383,25 @@ export default function DevPage() { } }; + const callSunriseN = async () => { + try { + const count = parseInt(sunriseCount); + if (Number.isNaN(count) || count < 1) { + toast.error("Please enter a valid number of seasons"); + return; + } + + setLoading("callSunriseN"); + await executeTask("callSunriseN", { n: count }); + toast.success(`Called sunrise ${count} time${count > 1 ? "s" : ""}`); + setLoading(null); + } catch (error) { + console.error("Failed to call sunrise N times:", error); + toast.error("Failed to call sunrise multiple times"); + setLoading(null); + } + }; + const handleQuickMint = async () => { if (!address) { toast.error("No wallet connected"); @@ -585,6 +604,18 @@ export default function DevPage() { Mint Me ETH/USDC/Pinto +
+ setSunriseCount(e.target.value)} + className="h-10 w-48" + /> + +
Deposits non-PINTO tokens into wells and then into beanstalk. Enter amounts in order: - WETH,cbETH,cbBTC,USDC,WSOL + WETH,cbETH,cbBTC,USDC,WSOL,wstETH
Token Balance Management
Available tokens: PINTO, WETH, USDC, cbBTC, cbETH, wstETH. You can use either token symbols or addresses. + Decimal precision is dependent on the token. (i.e "1" is 1 token)
+ + {/* SiloToken Gauge Data section */} +
); @@ -1180,8 +1215,8 @@ const ViewFunctionCaller = () => { className="h-10 px-3 py-2 text-sm border rounded-md bg-white" > - {viewFunctions.map((fn) => ( - @@ -1204,7 +1239,10 @@ const ViewFunctionCaller = () => {
Function Parameters:
{selectedFunctionObj.inputs.map((input, idx) => ( -
+
@@ -2243,3 +2281,185 @@ function FarmerSiloDeposits() { ); } + +// SiloToken Gauge Data Component +function SiloTokenGaugeData() { + const publicClient = usePublicClient(); + const protocolAddress = useProtocolAddress(); + const tokenData = useTokenData(); + const [loading, setLoading] = useState(false); + const [tokenSettings, setTokenSettings] = useState>(new Map()); + + const siloTokens = useMemo(() => { + if (!tokenData) return []; + return tokenData.whitelistedTokens; + }, [tokenData]); + + const fetchTokenSettings = async () => { + if (!publicClient || !protocolAddress || siloTokens.length === 0) return; + + setLoading(true); + try { + const settingsMap = new Map(); + + for (const token of siloTokens) { + try { + const settings = await publicClient.readContract({ + address: protocolAddress, + abi: diamondABI, + functionName: "tokenSettings", + args: [token.address], + }); + settingsMap.set(token.address, settings); + } catch (error) { + console.error(`Failed to fetch settings for ${token.symbol}:`, error); + } + } + + setTokenSettings(settingsMap); + } catch (error) { + console.error("Error fetching token settings:", error); + toast.error("Failed to fetch token settings"); + } finally { + setLoading(false); + } + }; + + useEffect(() => { + fetchTokenSettings(); + }, [publicClient, protocolAddress, siloTokens.length]); + + const formatBigInt = (value: any, decimals = 0) => { + if (!value) return "0"; + try { + const num = BigInt(value); + if (decimals > 0) { + const divisor = BigInt(10 ** decimals); + const wholePart = num / divisor; + const fractionalPart = num % divisor; + return `${wholePart}.${fractionalPart.toString().padStart(decimals, "0")}`; + } + return num.toString(); + } catch { + return String(value); + } + }; + + const formatBytes = (value: any) => { + if (!value) return "0x0"; + return String(value); + }; + + return ( + +

SiloToken Gauge Data

+ +
+
+ Displaying token settings for {siloTokens.length} silo token{siloTokens.length !== 1 ? "s" : ""} +
+ +
+ + {loading && tokenSettings.size === 0 ? ( +
+
+
+ Loading token settings... +
+
+ ) : tokenSettings.size === 0 ? ( +
No token settings available
+ ) : ( +
+ {siloTokens.map((token) => { + const settings = tokenSettings.get(token.address); + if (!settings) return null; + + return ( +
+
+ {token.symbol} +
+
{token.symbol}
+
{token.address}
+
+
+ +
+
+
+ Selector: + {formatBytes(settings.selector)} +
+
+ Stalk Earned Per Season: + {formatBigInt(settings.stalkEarnedPerSeason, 6)} +
+
+ Stalk Issued Per BDV: + {formatBigInt(settings.stalkIssuedPerBdv, 16)} +
+
+ Milestone Season: + {formatBigInt(settings.milestoneSeason)} +
+
+ Milestone Stem: + {formatBigInt(settings.milestoneStem)} +
+
+ +
+
+ Encode Type: + {formatBytes(settings.encodeType)} +
+
+ Delta Stalk Earned Per Season: + {formatBigInt(settings.deltaStalkEarnedPerSeason, 6)} +
+
+ Gauge Points: + {formatBigInt(settings.gaugePoints, 18)} +
+
+ Optimal % Deposited BDV: + {formatBigInt(settings.optimalPercentDepositedBdv, 6)}% +
+
+
+ +
+
Implementations:
+
+
+
Gauge Point Implementation
+
+
Target: {settings.gaugePointImplementation?.target || "N/A"}
+
Selector: {formatBytes(settings.gaugePointImplementation?.selector)}
+
Encode: {formatBytes(settings.gaugePointImplementation?.encodeType)}
+
+
+
+
Liquidity Weight Implementation
+
+
+ Target: {settings.liquidityWeightImplementation?.target || "N/A"} +
+
Selector: {formatBytes(settings.liquidityWeightImplementation?.selector)}
+
Encode: {formatBytes(settings.liquidityWeightImplementation?.encodeType)}
+
+
+
+
+
+ ); + })} +
+ )} + + ); +} diff --git a/src/components/HowToCard.tsx b/src/components/HowToCard.tsx new file mode 100644 index 000000000..f67bfe2fe --- /dev/null +++ b/src/components/HowToCard.tsx @@ -0,0 +1,43 @@ +import { Link } from "react-router-dom"; +import { StepItem } from "./ui/StepItem"; + +const steps = [ + { + title: "Qualify as a Referrer", + description: ( + <> + Sow at least 1,000 Pinto in the Field to unlock your referral link.{" "} + + Sow now + + + ), + }, + { + title: "Share Your Link", + description: "Copy your unique referral link and share it with friends, on social media, or anywhere else.", + }, + { + title: "Earn Rewards", + description: + "When someone uses your link and Sows Pinto, you earn 10% of the Pods they receive as a referral bonus.", + }, + { + title: "Get Credited", + description: + "Referral rewards are automatically credited to your wallet address when your referral completes their Sow transaction.", + }, +]; + +export function HowToCard() { + return ( +
+
How It Works
+
+ {steps.map((step, index) => ( + + ))} +
+
+ ); +} diff --git a/src/components/SowOrderDialog.tsx b/src/components/SowOrderDialog.tsx index 98bf61e1d..25b6514dc 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,43 @@ import { useSowOrderV0Form, useSowOrderV0State, } from "@/components/Tractor/form/SowOrderV0Schema"; +import { MAIN_TOKEN } from "@/constants/tokens"; +import { useReferralCode } from "@/hooks/tractor/useReferralCode"; 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 { isValidReferralCode } from "@/utils/referral"; +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 { useSearchParams } from "react-router-dom"; 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, + getTractorOperatorTipAmountFromPreset, +} 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,18 +55,36 @@ interface SowOrderDialogProps { // Types enum FormStep { MAIN_FORM = 1, - OPERATOR_TIP = 2, + REVIEW = 2, + ADVANCED = 3, } export default function SowOrderDialog({ open, onOpenChange, onOrderPublished }: SowOrderDialogProps) { // External hooks const farmerSilo = useFarmerSilo(); const { data: averageTipPaid = 1 } = useTractorOperatorAverageTipPaid(); + const [searchParams] = useSearchParams(); // Local state 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"); + const [referralPopoverOpen, setReferralPopoverOpen] = useState(false); + + // 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 +114,260 @@ 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 { referralCode: hookReferralCode, setReferralCode: setHookReferralCode } = useReferralCode(); + const [totalAmount, tokenStrategy, temperature] = useWatch({ + control: form.control, + name: ["totalAmount", "selectedTokenStrategy", "temperature"], + }); + + // Read referral code from URL params when dialog opens and sync with hook and form + const [didInitReferralCode, setDidInitReferralCode] = useState(false); + useEffect(() => { + if (!open) { + // Reset when dialog closes + setDidInitReferralCode(false); + return; + } + if (didInitReferralCode) return; + const refParam = searchParams.get("ref"); + // Fix: searchParams.get() converts + to space, so we need to restore it + const decodedRef = refParam ? refParam.replace(/ /g, "+") : null; + if (decodedRef && isValidReferralCode(decodedRef)) { + // Only set if valid + setHookReferralCode(decodedRef); + form.setValue("referralCode", decodedRef); + } else if (hookReferralCode && isValidReferralCode(hookReferralCode)) { + // If no URL param but hook has a valid value, sync to form + form.setValue("referralCode", hookReferralCode); + } + setDidInitReferralCode(true); + }, [open, searchParams, didInitReferralCode, setHookReferralCode, form, hookReferralCode]); + + // Sync hook referral code changes to form (only if valid) + useEffect(() => { + if ( + hookReferralCode && + isValidReferralCode(hookReferralCode) && + hookReferralCode !== form.getValues("referralCode") + ) { + form.setValue("referralCode", hookReferralCode); + } + }, [hookReferralCode, form]); + + // Sync customOperatorTip to operatorTip when Custom preset is selected + const customOperatorTip = useWatch({ control: form.control, name: "customOperatorTip" }); + useEffect(() => { + if (operatorTipPreset === "Custom" && customOperatorTip) { + form.setValue("operatorTip", customOperatorTip); + } + }, [customOperatorTip, operatorTipPreset, form]); + + // 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 and update operatorTip value + previousPresetRef.current = null; + originalTipRef.current = null; + + // Calculate and set the new tip value based on preset + const tipAmount = getTractorOperatorTipAmountFromPreset(preset, averageTipPaid, undefined, mainToken.decimals); + if (tipAmount) { + form.setValue("operatorTip", tipAmount.toHuman()); + } + } + + // For Custom preset, update operatorTip from customOperatorTip + if (preset === "Custom") { + const customTip = form.getValues("customOperatorTip"); + if (customTip) { + form.setValue("operatorTip", customTip); + } + } + + 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 +383,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 ( <> @@ -134,101 +399,145 @@ export default function SowOrderDialog({ open, onOpenChange, onOrderPublished }: {/* Title and separator */}
-
- 🚜 Specify Conditions for automated Sowing +
+
+ 🚜 Place a bid to Sow in the Field +
+
- {/* 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 */} + setReferralPopoverOpen(true)} />
- ) : ( - // Step 2 - Operator Tip - - - {/* Title and separator for Step 2 */} -
-
🚜 Tip per Execution
-
-
- - - - + ) : formStep === FormStep.REVIEW ? ( + // Step 2 - Review + +
+
🚜 Review your bid
+ +
+ + + + + + + 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 +625,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 +637,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/SowRequirementCard.tsx b/src/components/SowRequirementCard.tsx new file mode 100644 index 000000000..ed0265546 --- /dev/null +++ b/src/components/SowRequirementCard.tsx @@ -0,0 +1,48 @@ +import { TokenValue } from "@/classes/TokenValue"; +import { Progress } from "@/components/ui/Progress"; +import { formatter } from "@/utils/format"; + +interface SowRequirementCardProps { + totalBeansSown: TokenValue; + amountNeeded: TokenValue; + progressPercentage: number; + disabled?: boolean; +} + +export function SowRequirementCard({ + totalBeansSown, + amountNeeded, + progressPercentage, + disabled = false, +}: SowRequirementCardProps) { + return ( +
+
+
+
+ {disabled + ? "Connect your wallet to access referral features" + : "You have not Sown enough Pinto to be eligible to refer farmers"} +
+
+ +
+
+ Progress + + {disabled ? "- / 1000 Pinto sown" : `${formatter.number(totalBeansSown)} / 1000 Pinto sown`} + +
+ + {!disabled && !amountNeeded.isZero && ( +
{formatter.number(amountNeeded)} more Pinto needed
+ )} + {disabled &&
Connect wallet to view progress
} +
+
+
+ ); +} diff --git a/src/components/Tractor/ModifySowOrderDialog.tsx b/src/components/Tractor/ModifySowOrderDialog.tsx index 186be81ba..abbf79a50 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,28 +16,48 @@ 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 { useReferralCode } from "@/hooks/tractor/useReferralCode"; import useSignTractorBlueprint from "@/hooks/tractor/useSignTractorBlueprint"; import useSowOrderV0Calculations from "@/hooks/tractor/useSowOrderV0Calculations"; import useTransaction from "@/hooks/useTransaction"; -import { RequisitionEvent, SowBlueprintData, prepareRequisitionForTxn } from "@/lib/Tractor"; +import { RequisitionEvent, SowBlueprintData, decodeSowTractorData, prepareRequisitionForTxn } from "@/lib/Tractor"; 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 { encodeReferralAddress, isValidReferralCode } from "@/utils/referral"; +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 { useSearchParams } from "react-router-dom"; 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, + getTractorOperatorTipAmountFromPreset, +} from "./form/fields/sharedFields"; interface ModifyTractorOrderDialogProps { open: boolean; @@ -63,55 +85,247 @@ 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"); + const [referralPopoverOpen, setReferralPopoverOpen] = useState(false); + + // 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); + + // Referral code hook + const [searchParams] = useSearchParams(); + const { referralCode: hookReferralCode, setReferralCode: setHookReferralCode } = useReferralCode(); // Effects. Pre-fill form with existing order data const [didPrefill, setDidPrefill] = useState(false); useEffect(() => { + if (!open) { + // Reset when dialog closes + setDidPrefill(false); + return; + } if (didPrefill || getStrategyProps.isLoading || !existingOrder.decodedData) return; - if (open) { - const data = existingOrder.decodedData; - const tokenStrategy = getStrategyProps.getTokenStrategy(data); - - prefillValues({ - totalAmount: formatter.noDecTrunc(data.sowAmounts.totalAmountToSowAsString), - minSoil: formatter.noDecTrunc(data.sowAmounts.minAmountToSowPerSeasonAsString), - maxPerSeason: formatter.noDecTrunc(data.sowAmounts.maxAmountToSowPerSeasonAsString), - temperature: formatter.noDecTrunc(data.minTempAsString), - podLineLength: formatter.noDecTrunc(data.maxPodlineLengthAsString), - operatorTip: formatter.noDecTrunc(data.operatorParams.operatorTipAmountAsString), - morningAuction: data.runBlocksAfterSunrise === 0n, - selectedTokenStrategy: tokenStrategy ?? { type: "LOWEST_SEEDS" as const }, - }); - setDidPrefill(true); + const data = existingOrder.decodedData; + const tokenStrategy = getStrategyProps.getTokenStrategy(data); + + // Try to extract referral address from existing order + let referralCodeFromOrder: string | undefined; + try { + const decodedResult = decodeSowTractorData(existingOrder.requisition.blueprint.data); + if (decodedResult && "blueprintData" in decodedResult && decodedResult.referralAddress) { + // Encode referral address to referral code + referralCodeFromOrder = encodeReferralAddress(decodedResult.referralAddress); + } + } catch (e) { + console.debug("Could not extract referral address from existing order:", e); + } + + // Priority: URL param > existing order > hook value + const refParam = searchParams.get("ref"); + // Fix: searchParams.get() converts + to space, so we need to restore it + const decodedRef = refParam ? refParam.replace(/ /g, "+") : null; + const referralCodeCandidate = decodedRef || referralCodeFromOrder || hookReferralCode || ""; + + // Only use referral code if it's valid + const referralCodeToUse = + referralCodeCandidate && isValidReferralCode(referralCodeCandidate) ? referralCodeCandidate : ""; + + if (referralCodeToUse) { + setHookReferralCode(referralCodeToUse); + } + + prefillValues({ + totalAmount: formatter.noDecTrunc(data.sowAmounts.totalAmountToSowAsString), + minSoil: formatter.noDecTrunc(data.sowAmounts.minAmountToSowPerSeasonAsString), + maxPerSeason: formatter.noDecTrunc(data.sowAmounts.maxAmountToSowPerSeasonAsString), + temperature: formatter.noDecTrunc(data.minTempAsString), + podLineLength: formatter.noDecTrunc(data.maxPodlineLengthAsString), + operatorTip: formatter.noDecTrunc(data.operatorParams.operatorTipAmountAsString), + morningAuction: data.runBlocksAfterSunrise === 0n, + selectedTokenStrategy: tokenStrategy ?? { type: "LOWEST_SEEDS" as const }, + referralCode: referralCodeToUse, + }); + setDidPrefill(true); + }, [ + open, + existingOrder, + didPrefill, + prefillValues, + getStrategyProps, + searchParams, + hookReferralCode, + setHookReferralCode, + ]); + + // Sync hook referral code changes to form + useEffect(() => { + if (hookReferralCode && hookReferralCode !== form.getValues("referralCode")) { + form.setValue("referralCode", hookReferralCode); + } + }, [hookReferralCode, form]); + + // 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); } - }, [open, existingOrder, didPrefill, prefillValues, getStrategyProps]); + + 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 and update operatorTip value + previousPresetRef.current = null; + originalTipRef.current = null; + + // Calculate and set the new tip value based on preset + const tipAmount = getTractorOperatorTipAmountFromPreset( + preset, + averageTipValue, + form.getValues("customOperatorTip"), + mainToken.decimals, + ); + if (tipAmount) { + form.setValue("operatorTip", tipAmount.toHuman()); + } + } + + // For Custom preset, update operatorTip from customOperatorTip + if (preset === "Custom") { + const customTip = form.getValues("customOperatorTip"); + if (customTip) { + form.setValue("operatorTip", customTip); + } + } + + 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; @@ -135,8 +349,14 @@ export default function ModifyTractorOrderDialog({ -
- 🚜 Update Conditions for automated Sowing +
+
+ 🚜 Update Conditions for automated Sowing +
+
@@ -147,80 +367,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 */} + setReferralPopoverOpen(true)} /> + + + +
Please fill in the following fields:
+
    + {missingFields.map((field) => ( +
  • {field}
  • + ))} +
+
+ ) : null + } + side="top" + align="center" + disabled={!isMissingFields} + > +
+ +
+ + + + ) : formStep === 2 ? ( + // Step 2 - Review + +
+
🚜 Review your bid
+ +
+ + + + + + + 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 +807,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/Plow.tsx b/src/components/Tractor/Plow.tsx index 7a3524cba..f20046fd7 100644 --- a/src/components/Tractor/Plow.tsx +++ b/src/components/Tractor/Plow.tsx @@ -7,7 +7,7 @@ import useTokenData from "@/state/useTokenData"; import { formatter } from "@/utils/format"; import { Token } from "@/utils/types"; import { useCallback, useMemo, useState } from "react"; -import { BaseOrderType, ColumnConfig, ExecuteOrdersTab } from "./ExecuteOrdersTab"; +import { ColumnConfig, ExecuteOrdersTab } from "./ExecuteOrdersTab"; import { PlowDetails } from "./PlowDetails"; const BASESCAN_URL = "https://basescan.org/address/"; @@ -94,10 +94,25 @@ export function Plow() { return orders.filter((req) => { // Skip cancelled requisitions - if (req.isCancelled) return false; + if (req.isCancelled) { + // console.log("[Plow/filterOrders] Skipping cancelled order:", req.requisition.blueprintHash); + return false; + } // Skip requisitions with invalid data or non-positive tip - if (!req.decodedData || !req.decodedData.operatorParams) return false; + if (!req.decodedData) { + // console.log("[Plow/filterOrders] Skipping order with no decodedData:", req.requisition.blueprintHash); + return false; + } + + if (!req.decodedData.operatorParams) { + // console.log("[Plow/filterOrders] Skipping order with no operatorParams:", { + // hash: req.requisition.blueprintHash, + // decodedData: req.decodedData, + // }); + return false; + } + const tipAmount = req.decodedData.operatorParams.operatorTipAmount; // Skip requisitions with temperature requirements higher than current temperature @@ -108,8 +123,22 @@ export function Plow() { } } - return tipAmount > 0n; + const passes = tipAmount > 0n; + // console.log("[Plow/filterOrders] Order filter result:", { + // hash: req.requisition.blueprintHash, + // passes, + // tipAmount: tipAmount.toString(), + // }); + return passes; }); + // .map((req, idx, arr) => { + // if (idx === arr.length - 1) { + // console.log("[Plow/filterOrders] AFTER filtering:", { + // totalPassed: arr.length, + // }); + // } + // return req; + // }); }, [temperatures.scaled], ); diff --git a/src/components/Tractor/SoilOrderbook.tsx b/src/components/Tractor/SoilOrderbook.tsx index b558d49c9..e2f9e7bd1 100644 --- a/src/components/Tractor/SoilOrderbook.tsx +++ b/src/components/Tractor/SoilOrderbook.tsx @@ -8,7 +8,7 @@ import { Switch } from "@/components/ui/Switch"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/Table"; import { PINTO } from "@/constants/tokens"; import { useGetTractorTokenStrategyWithBlueprint } from "@/hooks/tractor/useGetTractorTokenStrategy"; -import { OrderbookEntry, SowBlueprintData, decodeSowTractorData } from "@/lib/Tractor"; +import { OrderbookEntry, SowBlueprintData, decodeSowTractorData, unwrapSowBlueprintData } from "@/lib/Tractor"; import { Blueprint } from "@/lib/Tractor/types"; import { useTractorSowOrderbook } from "@/state/tractor/useTractorSowOrders"; import useCachedLatestBlockQuery from "@/state/useCachedLatestBlockQuery"; @@ -131,7 +131,7 @@ export function SoilOrderbookContent({ return; } - const d = decodeSowTractorData(selectedOrder.requisition.blueprint.data); + const d = unwrapSowBlueprintData(decodeSowTractorData(selectedOrder.requisition.blueprint.data)); if (!d) return; setDecodedData({ type: "sow", @@ -152,8 +152,8 @@ export function SoilOrderbookContent({ } else if (sortBy === "tip") { sorted = [...requisitions].sort((a, b) => { try { - const dataA = decodeSowTractorData(a.requisition.blueprint.data); - const dataB = decodeSowTractorData(b.requisition.blueprint.data); + const dataA = unwrapSowBlueprintData(decodeSowTractorData(a.requisition.blueprint.data)); + const dataB = unwrapSowBlueprintData(decodeSowTractorData(b.requisition.blueprint.data)); if (!dataA || !dataB) return 0; const tipA = BigInt(dataA.operatorParams.operatorTipAmount); const tipB = BigInt(dataB.operatorParams.operatorTipAmount); @@ -175,7 +175,7 @@ export function SoilOrderbookContent({ let matchesTemperatureFilter = true; if (!showAboveCurrentTemp) { try { - const data = decodeSowTractorData(req.requisition.blueprint.data); + const data = unwrapSowBlueprintData(decodeSowTractorData(req.requisition.blueprint.data)); if (data) { const reqTemp = parseFloat(data.minTempAsString); matchesTemperatureFilter = reqTemp < temperature.max.toNumber(); @@ -202,7 +202,7 @@ export function SoilOrderbookContent({ if (sortedReqs.length > 0) { sortedReqs.forEach((req, idx) => { try { - const data = decodeSowTractorData(req.requisition.blueprint.data); + const data = unwrapSowBlueprintData(decodeSowTractorData(req.requisition.blueprint.data)); if (data) { // Parse the percentage string to just get the number const tempValue = parseFloat(data.minTempAsString); @@ -217,8 +217,10 @@ export function SoilOrderbookContent({ // Insert at the position where the first requisition temperature is GREATER than max temperature while ( insertIndex < sortedReqs.length && - parseFloat(decodeSowTractorData(sortedReqs[insertIndex].requisition.blueprint.data)?.minTempAsString || "0") <= - maxTemp + parseFloat( + unwrapSowBlueprintData(decodeSowTractorData(sortedReqs[insertIndex].requisition.blueprint.data)) + ?.minTempAsString || "0", + ) <= maxTemp ) { insertIndex++; } @@ -296,7 +298,7 @@ export function SoilOrderbookContent({ const renderRequisitionRow = (req, index) => { let decodedData: SowBlueprintData | null = null; try { - decodedData = decodeSowTractorData(req.requisition.blueprint.data); + decodedData = unwrapSowBlueprintData(decodeSowTractorData(req.requisition.blueprint.data)); } catch (error) { console.error("Failed to decode data for requisition:", error); } diff --git a/src/components/Tractor/Sow/SowOrderEstimatedTipPaid.tsx b/src/components/Tractor/Sow/SowOrderEstimatedTipPaid.tsx new file mode 100644 index 000000000..5e79a5f09 --- /dev/null +++ b/src/components/Tractor/Sow/SowOrderEstimatedTipPaid.tsx @@ -0,0 +1,147 @@ +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 { NUMBER_ABBR_THRESHOLDS, 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 + // Also check if initialSoil is effectively zero (≀ 0) + if ( + !cultivationFactor || + !initialSoil || + initialSoil.lte(0) || + isCultivationLoading || + isInitialSoilLoading || + !pintoPrice + ) { + // Fallback to simple division: total / minSoil + 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); + } + + const result = { + min: minTimes.mul(tip), + max: maxTimes.mul(tip), + }; + + return result; + }, [ + 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.number(tipEstimations.min, { + maxDecimals: 2, + compact: tipEstimations.min.toNumber() >= NUMBER_ABBR_THRESHOLDS.BILLION, + })}{" "} + -{" "} + {formatter.number(tipEstimations.max, { + maxDecimals: 2, + compact: tipEstimations.max.toNumber() >= NUMBER_ABBR_THRESHOLDS.BILLION, + })} + +
+ ); +}; 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/TractorRequisitionsTable.tsx b/src/components/Tractor/TractorRequisitionsTable.tsx index 68b57ae27..3c1438d2f 100644 --- a/src/components/Tractor/TractorRequisitionsTable.tsx +++ b/src/components/Tractor/TractorRequisitionsTable.tsx @@ -3,7 +3,12 @@ import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@ import { diamondABI } from "@/constants/abi/diamondABI"; import { useProtocolAddress } from "@/hooks/pinto/useProtocolAddress"; import useTransaction from "@/hooks/useTransaction"; -import { TractorRequisitionEvent as RequisitionEvent, SowBlueprintData, decodeSowTractorData } from "@/lib/Tractor"; +import { + TractorRequisitionEvent as RequisitionEvent, + SowBlueprintData, + decodeSowTractorData, + unwrapSowBlueprintData, +} from "@/lib/Tractor"; import useTractorPublishedRequisitions from "@/state/tractor/useTractorPublishedRequisitions"; import { useEffect } from "react"; import { toast } from "sonner"; @@ -108,7 +113,7 @@ export function TractorRequisitionsTable({ refreshTrigger = 0 }: TractorRequisit } | null = null; try { - const decoded = decodeSowTractorData(req.requisition.blueprint.data); + const decoded = unwrapSowBlueprintData(decodeSowTractorData(req.requisition.blueprint.data)); if (decoded) { decodedData = { minTempAsString: decoded.minTempAsString, diff --git a/src/components/Tractor/farmer-orders/TractorFarmerMixedOrders.utils.ts b/src/components/Tractor/farmer-orders/TractorFarmerMixedOrders.utils.ts index 20de7715b..571a00297 100644 --- a/src/components/Tractor/farmer-orders/TractorFarmerMixedOrders.utils.ts +++ b/src/components/Tractor/farmer-orders/TractorFarmerMixedOrders.utils.ts @@ -19,7 +19,23 @@ export function transformSowOrderToUnified( throw new Error("Missing decoded data for Sow order"); } - const data = req.decodedData; + // Handle both unwrapped and wrapped referral formats + let data: SowBlueprintData; + if ("blueprintData" in req.decodedData && typeof req.decodedData.blueprintData === "object") { + // Wrapped referral format: { blueprintData, referralAddress } + console.warn("[TractorFarmerMixedOrders] Received wrapped referral format, extracting blueprintData"); + data = (req.decodedData as any).blueprintData; + } else { + // Regular SowBlueprintData format + data = req.decodedData; + } + + // Additional safety check + if (!data.sowAmounts) { + console.error("[TractorFarmerMixedOrders] Invalid decoded data structure:", req.decodedData); + throw new Error("Invalid decoded data structure - missing sowAmounts"); + } + const totalAmount = TokenValue.fromBlockchain(data.sowAmounts.totalAmountToSow, 6); // Calculate progress from executions diff --git a/src/components/Tractor/farmer-orders/TractorOrdersPanel.tsx b/src/components/Tractor/farmer-orders/TractorOrdersPanel.tsx index 79b352122..196fd4c05 100644 --- a/src/components/Tractor/farmer-orders/TractorOrdersPanel.tsx +++ b/src/components/Tractor/farmer-orders/TractorOrdersPanel.tsx @@ -111,10 +111,21 @@ function TractorOrdersPanelGeneric({ sowOrders .filter((req) => stringEq(req.requisition.blueprint.publisher, address)) .forEach((req) => { - const decodedData = ORDER_TYPE_REGISTRY.sow.decodeData(req.requisition.blueprint.data); - if (!decodedData) { + const decodedResult = ORDER_TYPE_REGISTRY.sow.decodeData(req.requisition.blueprint.data); + if (!decodedResult) { return; } + + // Handle both unwrapped and wrapped referral formats + let decodedData: any; + if ("blueprintData" in decodedResult && typeof decodedResult.blueprintData === "object") { + // Wrapped referral format: { blueprintData, referralAddress } + decodedData = decodedResult.blueprintData; + } else { + // Regular SowBlueprintData format + decodedData = decodedResult; + } + const reqWithDecodedData = { ...req, decodedData }; const orderExecutions = executionsByHash?.[req.requisition.blueprintHash] || []; diff --git a/src/components/Tractor/form/SowOrderV0Fields.tsx b/src/components/Tractor/form/SowOrderV0Fields.tsx index c92b55684..f993012ae 100644 --- a/src/components/Tractor/form/SowOrderV0Fields.tsx +++ b/src/components/Tractor/form/SowOrderV0Fields.tsx @@ -1,25 +1,38 @@ import arrowDown from "@/assets/misc/ChevronDown.svg"; +import settingsIcon from "@/assets/misc/Settings.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 { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/Popover"; +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 { NUMBER_ABBR_THRESHOLDS, formatter } from "@/utils/format"; +import { MAX_INPUT_VALUE, 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"; + +import { useReferralCode } from "@/hooks/tractor/useReferralCode"; const sharedInputProps = { type: "text", @@ -27,6 +40,19 @@ const sharedInputProps = { pattern: "[0-9]*.?[0-9]*", } as const; +const TEMPERATURE_SLIDER_BOUNDS = { + MIN_OFFSET: 100, + MAX_OFFSET: 300, +} 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; @@ -41,13 +67,9 @@ const useSharedInputHandlers = ( const handleNumericInputChange = useCallback( (e: React.ChangeEvent) => { - const cleaned = sanitizeNumericInputValue(e.target.value, mainToken.decimals); + const cleaned = sanitizeNumericInputValue(e.target.value, mainToken.decimals, false, MAX_INPUT_VALUE); - if (cleaned.nonAmount) { - ctx.setValue(name, cleaned.str, { shouldValidate: true }); - } else { - ctx.setValue(name, cleaned.str, { shouldValidate: true }); - } + ctx.setValue(name, cleaned.str, { shouldValidate: true }); return cleaned; }, [ctx.setValue, mainToken.decimals, name], @@ -96,130 +118,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 +357,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 +365,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 ( - - ); -}; +// TODO: ADD REFERRAL CODE VALIDATOR! -SowOrderV0Fields.MorningAuction = function MorningAuction() { +const BONUS_MULTIPLIER = 0.1; + +SowOrderV0Fields.PodDisplay = function PodDisplay({ + onOpenReferralPopover, +}: { + onOpenReferralPopover?: () => void; +}) { 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 { validReferralCodeFromStorage } = useReferralCode(); + + 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]); + + const bonusPods = useMemo(() => { + return estimatedPods.mul(BONUS_MULTIPLIER); + }, [estimatedPods]); + + // Use validReferralCodeFromStorage for conditional rendering (from localStorage, cleared when invalid) + const hasReferralCode = Boolean(validReferralCodeFromStorage); return ( - ( - - Execute during the Morning Auction -
- - + + +
Pods
+
+ +
+ {formatter.number(estimatedPods, { + minValue: 0.01, + compact: estimatedPods.toNumber() >= NUMBER_ABBR_THRESHOLDS.TRILLION, + })}{" "} + Pods
- +
+
+ {hasReferralCode ? ( + +
Bonus Pods
+
+ +
+ {formatter.number(bonusPods, { + minValue: 0.01, + compact: bonusPods.toNumber() >= NUMBER_ABBR_THRESHOLDS.TRILLION, + })}{" "} + Pods +
+
+
+ ) : ( + + + )} - /> + ); }; @@ -662,8 +773,16 @@ SowOrderV0Fields.ExecutionsAndTip = function ExecutionsAndTip({ className }: { c lowerBound = Math.max(1, lowerBound); const lowerTip = lowerBound * tipValue; + // Helper to format tip values with compact notation for large numbers + const formatTip = (val: number) => { + if (val >= NUMBER_ABBR_THRESHOLDS.BILLION) { + return formatter.number(val, { maxDecimals: 2, compact: true }); + } + return val.toFixed(2); + }; + if (min.eq(0)) { - return `~${lowerTip.toFixed(2)}-∞`; + return `~${formatTip(lowerTip)}-∞`; } let upperBound = Math.ceil(total.div(min).toNumber()); @@ -671,9 +790,9 @@ SowOrderV0Fields.ExecutionsAndTip = function ExecutionsAndTip({ className }: { c const upperTip = upperBound * tipValue; if (lowerTip === upperTip) { - return `~${lowerTip.toFixed(2)}`; + return `~${formatTip(lowerTip)}`; } else { - return `~${lowerTip.toFixed(2)}-${upperTip.toFixed(2)}`; + return `~${formatTip(lowerTip)}-${formatTip(upperTip)}`; } } catch (e) { console.error("Error calculating total tip:", e); @@ -699,4 +818,50 @@ SowOrderV0Fields.ExecutionsAndTip = function ExecutionsAndTip({ className }: { c ); }; +// Referral Code Popover component +SowOrderV0Fields.ReferralCodePopover = function ReferralCodePopover({ + children, + open, + onOpenChange, +}: { + children?: React.ReactNode; + open?: boolean; + onOpenChange?: (open: boolean) => void; +}) { + const { referralCode, isReferralCodeValid, setReferralCode } = useReferralCode(); + + return ( + + + {children ?? ( + + )} + + +
+
Referral Code
+
+ setReferralCode(e.target.value)} + className={isReferralCodeValid ? "border-green-500" : ""} + /> + {isReferralCodeValid && ( +
+ βœ“ + Valid referral code +
+ )} + {referralCode && !isReferralCodeValid &&
Invalid referral code
} +
+
+
+
+ ); +}; + export default SowOrderV0Fields; diff --git a/src/components/Tractor/form/SowOrderV0Schema.ts b/src/components/Tractor/form/SowOrderV0Schema.ts index dfa7f1bba..633b521f3 100644 --- a/src/components/Tractor/form/SowOrderV0Schema.ts +++ b/src/components/Tractor/form/SowOrderV0Schema.ts @@ -1,20 +1,23 @@ 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"; import FormUtils from "@/utils/form"; +import { decodeReferralAddress, isValidReferralCode } from "@/utils/referral"; const { - schema: { tokenStrategy, positiveNumber, addCTXErrors }, + schema: { tokenStrategy, positiveNumber }, validate: { lte }, } = FormUtils; @@ -22,38 +25,92 @@ 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, + referralCode: z.string().optional(), // Optional referral code +}); + +// 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; @@ -68,6 +125,7 @@ export const defaultSowOrderDialogValues: Partial = { morningAuction: false, operatorTip: "1", selectedTokenStrategy: { type: "LOWEST_SEEDS" }, + referralCode: "", }; export type SowOrderV0Form = { @@ -79,9 +137,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", }); @@ -93,8 +164,18 @@ export const useSowOrderV0Form = (): SowOrderV0Form => { ); const getAreAllFieldsFilled = useCallback(() => { - return Object.values(form.getValues()).every((v) => { - if (typeof v === "string") return Boolean(v.trim()); + const values = form.getValues(); + // Fields that are optional and shouldn't be checked + const optionalFields = ["referralCode"]; + + return Object.keys(values).every((key) => { + // Skip optional fields + if (optionalFields.includes(key)) { + return true; + } + + const value = values[key as keyof SowOrderV0FormSchema]; + if (typeof value === "string") return Boolean(value.trim()); return true; }); }, [form.getValues]); @@ -106,7 +187,21 @@ 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"]; + // Fields that are optional and shouldn't be shown as missing + const optionalFields = ["referralCode"]; + const missingFields = Object.keys(values).filter((key) => { + // Skip auto-populated fields + if (autoPopulatedFields.includes(key)) { + return false; + } + // Skip optional fields + if (optionalFields.includes(key)) { + return false; + } + const value = values[key as keyof SowOrderV0FormSchema]; if (typeof value === "string") { return value.trim() === ""; @@ -181,6 +276,12 @@ export const useSowOrderV0State = () => { try { const formData = form.getValues(); + // Decode referral code if provided and valid + let referralAddress: `0x${string}` | undefined; + if (formData.referralCode && isValidReferralCode(formData.referralCode)) { + referralAddress = decodeReferralAddress(formData.referralCode) || undefined; + } + const { data, operatorPasteInstrs, rawCall, depositOptimizationCalls } = await createSowTractorData({ totalAmountToSow: formData.totalAmount, temperature: formData.temperature, @@ -196,6 +297,7 @@ export const useSowOrderV0State = () => { farmerDeposits: deposits, userAddress: deposits ? address : undefined, protocolAddress: deposits ? protocolAddress : undefined, + referralAddress, // Pass the decoded referral address }); const newBlueprint = createBlueprint({ diff --git a/src/components/Tractor/form/fields/sharedFields.tsx b/src/components/Tractor/form/fields/sharedFields.tsx index a9019d2ae..5105ec789 100644 --- a/src/components/Tractor/form/fields/sharedFields.tsx +++ b/src/components/Tractor/form/fields/sharedFields.tsx @@ -14,8 +14,8 @@ import { useSharedNumericFormFieldHandlers } from "@/hooks/form/useSharedNumeric import { useTokenMap } from "@/hooks/pinto/useTokenMap"; import { tractorTokenStrategyUtil as StrategyUtil, TractorTokenStrategy } from "@/lib/Tractor"; import { useMainToken } from "@/state/useTokenData"; -import { formatter } from "@/utils/format"; -import { sanitizeNumericInputValue } from "@/utils/string"; +import { NUMBER_ABBR_THRESHOLDS, formatter } from "@/utils/format"; +import { MAX_INPUT_VALUE, sanitizeNumericInputValue } from "@/utils/string"; import { getTokenIndex } from "@/utils/token"; import { cn, exists } from "@/utils/utils"; import { @@ -222,8 +222,11 @@ export const OperatorTipFormField = ({ averageTipPaid, preset, setPreset }: Oper return ( - Tip Per Execution - + Tip per Execution + { if (!amount) return "--"; const str = typeof amount === "string" ? amount : amount.toHuman(); + const num = Number(str.replace(/,/g, "")); + // Use compact format for large numbers (>= 1 billion) + if (num >= NUMBER_ABBR_THRESHOLDS.BILLION) { + return `${formatter.number(str, { minDecimals: 2, maxDecimals: 2, compact: true })}`; + } return `${formatter.number(str, { minDecimals: 2, maxDecimals: 3 })}`; }; @@ -279,11 +287,11 @@ const InlineTipFormField = ({ const [value, setValue] = useState(ctx.getValues("customOperatorTip") ?? ""); const handleValueChange = (e: React.ChangeEvent) => { - const cleaned = sanitizeNumericInputValue(e.target.value, mainToken.decimals); + const cleaned = sanitizeNumericInputValue(e.target.value, mainToken.decimals, false, MAX_INPUT_VALUE); setValue(cleaned.str); }; - const invalidValue = sanitizeNumericInputValue(value, mainToken.decimals).nonAmount; + const invalidValue = sanitizeNumericInputValue(value, mainToken.decimals, false, MAX_INPUT_VALUE).nonAmount; const handleConfirmClick = () => { if (invalidValue) { @@ -381,7 +389,7 @@ export const OperatorTipPresetDropdown = ({ return; } // re-validate the custom tip value - const cleaned = sanitizeNumericInputValue(customTipValue, mainToken.decimals); + const cleaned = sanitizeNumericInputValue(customTipValue, mainToken.decimals, false, MAX_INPUT_VALUE); // set the custom tip value in the form ctx.setValue("customOperatorTip", cleaned.str); // Set the selected preset to custom, reset the cached preset, and close the custom input @@ -496,16 +504,19 @@ const OperatorTipPreset = ({ )} onClick={() => onClick(preset)} > - {preset.icon} - - +
{preset.icon}
+ + {preset.type} - + {formatOperatorTipAmount(amount)} {mainToken.symbol} {preset.endIcon ? ( -
+
{preset.endIcon}
) : ( diff --git a/src/components/charts/AdvancedChart.tsx b/src/components/charts/AdvancedChart.tsx index 4bce088df..8cd184b9d 100644 --- a/src/components/charts/AdvancedChart.tsx +++ b/src/components/charts/AdvancedChart.tsx @@ -153,10 +153,10 @@ export const AdvancedChart = () => { const filtered = useMemo(() => { const output: TVChartFormattedData[][] = []; if (selectedCharts.length === 0) return output; - selectedCharts.forEach((selection, selectedIndex) => { + selectedCharts.forEach((selection) => { const selectedChart = chartSetupData[selection]; const _output: TVChartFormattedData[] = []; - seasonsData.data.forEach((seasonData, index) => { + seasonsData.data.forEach((seasonData) => { // Verify a datapoint is available for this season (some data, like tractor, is not since season 1) if (selectedChart.priceScaleKey in seasonData) { const formatValue = selectedChart.valueFormatter; diff --git a/src/components/charts/LineChart.tsx b/src/components/charts/LineChart.tsx index 75430e6a8..3dc32f676 100644 --- a/src/components/charts/LineChart.tsx +++ b/src/components/charts/LineChart.tsx @@ -18,7 +18,7 @@ import { LineChartHorizontalReferenceLine, plugins } from "./chartHelpers"; Chart.register(LineController, LineElement, LinearScale, LogarithmicScale, CategoryScale, PointElement, Filler); export type LineChartData = { - values: number[]; + values: Array; } & Record; export type MakeGradientFunction = ( @@ -96,8 +96,14 @@ const LineChart = React.memo( const [yTickMin, yTickMax] = useMemo(() => { // Otherwise calculate based on data - const maxData = data.reduce((acc, next) => Math.max(acc, ...next.values), Number.MIN_SAFE_INTEGER); - const minData = data.reduce((acc, next) => Math.min(acc, ...next.values), Number.MAX_SAFE_INTEGER); + const maxData = data.reduce( + (acc, next) => Math.max(acc, ...next.values.filter((v): v is number => v !== null)), + Number.MIN_SAFE_INTEGER, + ); + const minData = data.reduce( + (acc, next) => Math.min(acc, ...next.values.filter((v): v is number => v !== null)), + Number.MAX_SAFE_INTEGER, + ); const maxTick = maxData === minData && maxData === 0 ? 1 : maxData; let minTick = minData - (maxData - minData) * 0.1; diff --git a/src/components/charts/MarketPerformanceChart.tsx b/src/components/charts/MarketPerformanceChart.tsx index 75c2c8abe..c3ba8ada8 100644 --- a/src/components/charts/MarketPerformanceChart.tsx +++ b/src/components/charts/MarketPerformanceChart.tsx @@ -165,20 +165,32 @@ const MarketPerformanceChart = ({ season, size, className }: MarketPerformanceCh const chartData: LineChartData[] = []; const tokens: (Token | undefined)[] = []; const chartStrokeGradients: StrokeGradientFunction[] = []; - for (const token of ["NET", "WETH", "cbETH", "cbBTC", "WSOL"]) { + const allTokens = ["NET", "WETH", "cbETH", "wstETH", "cbBTC", "WSOL"]; + const tokensPresent: string[] = []; + for (const token of allTokens) { + if (allData[token]?.length > 0) { + tokensPresent.push(token); + } + } + for (const token of tokensPresent) { + const missingDatapoints = allData.NET.length - allData[token].length; for (let i = 0; i < allData[token].length; i++) { - chartData[i] ??= { + chartData[i + missingDatapoints] ??= { timestamp: allData[token][i].timestamp, values: [], }; if (dataType !== DataType.PRICE) { - chartData[i].values.push(allData[token][i].value); + chartData[i + missingDatapoints].values.push(allData[token][i].value); } else { - chartData[i].values.push( + chartData[i + missingDatapoints].values.push( transformValue(allData[token][i].value, minValues[token], maxValues[token], priceTransformRanges[token]), ); } } + for (let i = 0; i < missingDatapoints; i++) { + // Datapoint was missing for this token but present for other tokens + chartData[i].values.push(null); + } const tokenObj = tokenConfig.find((t) => t.symbol === token); tokens.push(tokenObj); chartStrokeGradients.push(gradientFunctions.solid(tokenObj?.color ?? "green")); @@ -298,6 +310,8 @@ const MarketPerformanceChart = ({ season, size, className }: MarketPerformanceCh {chartDataset.tokens.map((token, idx) => { const tokenSymbol = token?.symbol ?? "NET"; const isNetToken = tokenSymbol === "NET"; + const missingDatapoints = allData.NET.length - allData[tokenSymbol].length; + const value = allData[tokenSymbol][displayIndex - missingDatapoints]?.value; return (
{tokenSymbol === "NET" && "Total: "}

- {displayValueFormatter(allData[tokenSymbol][displayIndex].value)} + {typeof value === "number" ? displayValueFormatter(value) : "-"}

{idx < Object.keys(allData).length - 1 && ( diff --git a/src/components/charts/TVChart.tsx b/src/components/charts/TVChart.tsx index da839ad39..a75c131e1 100644 --- a/src/components/charts/TVChart.tsx +++ b/src/components/charts/TVChart.tsx @@ -16,9 +16,7 @@ import { createChart, } from "lightweight-charts"; import { MutableRefObject, useEffect, useMemo, useRef, useState } from "react"; -import { GearIcon } from "../Icons"; import TooltipSimple from "../TooltipSimple"; -import { ResponsivePopover } from "../ui/ResponsivePopover"; export type TVChartFormattedData = { time: Time; diff --git a/src/components/nav/ChartSelectPanel.tsx b/src/components/nav/ChartSelectPanel.tsx index c9dc7660a..6e468245a 100644 --- a/src/components/nav/ChartSelectPanel.tsx +++ b/src/components/nav/ChartSelectPanel.tsx @@ -6,10 +6,10 @@ import { useDebouncedEffect } from "@/utils/useDebounce"; import { cn } from "@/utils/utils"; import { useAtom } from "jotai"; import { isEqual } from "lodash"; -import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { memo, useCallback, useEffect, useMemo, useState } from "react"; import { renderAnnouncement } from "../AnnouncementBanner"; import { ChevronDownIcon, SearchIcon } from "../Icons"; -import { MIN_ADV_SEASON, chartSeasonInputsAtom, selectedChartsAtom } from "../charts/AdvancedChart"; +import { chartSeasonInputsAtom, selectedChartsAtom } from "../charts/AdvancedChart"; import { Input } from "../ui/Input"; import { ScrollArea } from "../ui/ScrollArea"; import { Separator } from "../ui/Separator"; @@ -105,10 +105,11 @@ const ChartSelectPanel = memo(() => { } else { // When selecting, initialize season input to min value const chartData = chartSetupData.find((chart) => chart.index === selection); - if (chartData && chartData.inputOptions === "SEASON") { + const opts = chartData?.inputOptions; + if (opts && opts.type === "SEASON") { setInternalSeasonInputs((prev) => ({ ...prev, - [chartData.id]: MIN_ADV_SEASON, + [chartData.id]: opts.minSeason, })); } selectedItems.push(selection); @@ -159,7 +160,15 @@ const ChartSelectPanel = memo(() => { () => { const clampedInputs = {}; for (const chartId in rawSeasonInputs) { - clampedInputs[chartId] = Math.max(MIN_ADV_SEASON, Math.min(currentSeason, rawSeasonInputs[chartId])); + const chartData = chartSetupData.find((chart) => chart.id === chartId); + if (!chartData || !chartData.inputOptions) { + // Should be unreachable (season input cannot change for chart that didn't configure seasons) + continue; + } + clampedInputs[chartId] = Math.max( + chartData.inputOptions.minSeason, + Math.min(currentSeason, rawSeasonInputs[chartId]), + ); } setInternalSeasonInputs(clampedInputs); }, @@ -230,7 +239,7 @@ const ChartSelectPanel = memo(() => {
{data.shortDescription}
- {isSelected && data.inputOptions === "SEASON" && ( + {isSelected && data.inputOptions?.type === "SEASON" && (
e.stopPropagation()}>