diff --git a/src/components/ui/masked-input.tsx b/src/components/ui/masked-input.tsx index 89ab45ae..b336c9cb 100644 --- a/src/components/ui/masked-input.tsx +++ b/src/components/ui/masked-input.tsx @@ -78,4 +78,35 @@ const TokenAmountInput = React.forwardRef( TokenAmountInput.displayName = 'TokenAmountInput'; -export { MaskedInput, TokenAmountInput }; +// Integer input that only accepts positive integers +interface IntegerInputProps extends MaskedInputProps { + min?: number; + max?: number; +} + +const IntegerInput = React.forwardRef( + ({ min = 0, max, ...props }, ref) => ( + { + const { floatValue } = values; + if (floatValue === undefined) return true; + + if (min !== undefined && floatValue < min) return false; + if (max !== undefined && floatValue > max) return false; + + return true; + }} + /> + ), +); + +IntegerInput.displayName = 'IntegerInput'; + +export { MaskedInput, TokenAmountInput, IntegerInput }; diff --git a/src/contexts/ErrorContext.tsx b/src/contexts/ErrorContext.tsx index ae3135ba..8cb2b457 100644 --- a/src/contexts/ErrorContext.tsx +++ b/src/contexts/ErrorContext.tsx @@ -18,7 +18,7 @@ import { import { ErrorKind } from '../bindings'; export interface CustomError { - kind: ErrorKind | 'walletconnect' | 'upload'; + kind: ErrorKind | 'walletconnect' | 'upload' | 'invalid'; reason: string; } diff --git a/src/hooks/useDefaultOfferExpiry.ts b/src/hooks/useDefaultOfferExpiry.ts index 1ef7ab3a..0067af9b 100644 --- a/src/hooks/useDefaultOfferExpiry.ts +++ b/src/hooks/useDefaultOfferExpiry.ts @@ -41,22 +41,8 @@ export function useDefaultOfferExpiry() { setExpiry(validateExpiry(newExpiry)); }; - // Calculate total seconds - const getTotalSeconds = (): number | null => { - if (!validatedExpiry.enabled) return null; - - // the || operates on the result of parseInt, so if parseInt returns NaN, - // it will return 1 etc. because NaN is falsy - const days = parseInt(validatedExpiry.days) || 1; - const hours = parseInt(validatedExpiry.hours) || 0; - const minutes = parseInt(validatedExpiry.minutes) || 0; - - return days * 24 * 60 * 60 + hours * 60 * 60 + minutes * 60; - }; - return { expiry: validatedExpiry, setExpiry: setValidatedExpiry, - getTotalSeconds, }; } diff --git a/src/pages/MakeOffer.tsx b/src/pages/MakeOffer.tsx index e6b3c186..d2a66e7e 100644 --- a/src/pages/MakeOffer.tsx +++ b/src/pages/MakeOffer.tsx @@ -16,7 +16,7 @@ import { } from '@/components/ui/dialog'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; -import { TokenAmountInput } from '@/components/ui/masked-input'; +import { IntegerInput, TokenAmountInput } from '@/components/ui/masked-input'; import { Switch } from '@/components/ui/switch'; import { useErrors } from '@/hooks/useErrors'; import { uploadToDexie, uploadToMintGarden } from '@/lib/offerUpload'; @@ -33,7 +33,7 @@ import { PlusIcon, TrashIcon, } from 'lucide-react'; -import { useEffect, useState } from 'react'; +import { useEffect, useRef, useState } from 'react'; import { useNavigate } from 'react-router-dom'; import { useDefaultOfferExpiry } from '@/hooks/useDefaultOfferExpiry'; @@ -53,7 +53,11 @@ export function MakeOffer() { const [config, setConfig] = useState(null); const network = config?.network_id ?? 'mainnet'; - const { expiry, getTotalSeconds } = useDefaultOfferExpiry(); + const { expiry } = useDefaultOfferExpiry(); + + // Use refs to store initial values that won't trigger re-renders + const initialExpiryRef = useRef(expiry); + const initialStateRef = useRef(state); useEffect(() => { commands.networkConfig().then((config) => setConfig(config)); @@ -64,17 +68,28 @@ export function MakeOffer() { setMintGardenLink(''); }, [offer]); + // Only run once when component mounts useEffect(() => { - if (expiry.enabled && state.expiration === null) { - useOfferState.setState({ - expiration: { - days: expiry.days.toString(), - hours: expiry.hours.toString(), - minutes: expiry.minutes.toString(), - }, - }); + const initialExpiry = initialExpiryRef.current; + const initialState = initialStateRef.current; + + if (initialExpiry.enabled && initialState.expiration === null) { + const isAllZero = + (parseInt(initialExpiry.days) || 0) === 0 && + (parseInt(initialExpiry.hours) || 0) === 0 && + (parseInt(initialExpiry.minutes) || 0) === 0; + + if (!isAllZero) { + useOfferState.setState({ + expiration: { + days: initialExpiry.days.toString(), + hours: initialExpiry.hours.toString(), + minutes: initialExpiry.minutes.toString(), + }, + }); + } } - }, [expiry, state.expiration]); + }, []); const handleMake = async () => { setPending(true); @@ -84,6 +99,23 @@ export function MakeOffer() { state.offered.cats.length === 0 && state.offered.nfts.length === 1; + let expiresAtSecond = null; + if (state.expiration !== null) { + const days = parseInt(state.expiration.days) || 0; + const hours = parseInt(state.expiration.hours) || 0; + const minutes = parseInt(state.expiration.minutes) || 0; + const totalSeconds = days * 24 * 60 * 60 + hours * 60 * 60 + minutes * 60; + if (totalSeconds <= 0) { + addError({ + kind: 'invalid', + reason: t`Expiration must be at least 1 second in the future`, + }); + setPending(false); + return; + } + expiresAtSecond = Math.ceil(Date.now() / 1000) + totalSeconds; + } + const data = await commands.makeOffer({ offered_assets: { xch: toMojos( @@ -111,10 +143,7 @@ export function MakeOffer() { (state.fee || '0').toString(), walletState.sync.unit.decimals, ), - expires_at_second: - state.expiration === null - ? null - : Math.ceil(Date.now() / 1000) + (getTotalSeconds() ?? 0), + expires_at_second: expiresAtSecond, }); clearOffer(); @@ -127,9 +156,9 @@ export function MakeOffer() { const invalid = state.expiration !== null && - (isNaN(Number(state.expiration.days)) || - isNaN(Number(state.expiration.hours)) || - isNaN(Number(state.expiration.minutes))); + (parseInt(state.expiration.days) || 0) === 0 && + (parseInt(state.expiration.hours) || 0) === 0 && + (parseInt(state.expiration.minutes) || 0) === 0; return ( <> @@ -219,7 +248,11 @@ export function MakeOffer() { onCheckedChange={(value) => { if (value) { useOfferState.setState({ - expiration: { days: '1', hours: '', minutes: '' }, + expiration: { + days: initialExpiryRef.current.days.toString(), + hours: initialExpiryRef.current.hours.toString(), + minutes: initialExpiryRef.current.minutes.toString(), + }, }); } else { useOfferState.setState({ expiration: null }); @@ -231,16 +264,17 @@ export function MakeOffer() { {state.expiration !== null && (
- { + min={0} + onValueChange={(values) => { if (state.expiration === null) return; useOfferState.setState({ expiration: { ...state.expiration, - days: e.target.value, + days: values.value, }, }); }} @@ -253,16 +287,17 @@ export function MakeOffer() {
- { + min={0} + onValueChange={(values) => { if (state.expiration === null) return; useOfferState.setState({ expiration: { ...state.expiration, - hours: e.target.value, + hours: values.value, }, }); }} @@ -275,16 +310,17 @@ export function MakeOffer() {
- { + min={0} + onValueChange={(values) => { if (state.expiration === null) return; useOfferState.setState({ expiration: { ...state.expiration, - minutes: e.target.value, + minutes: values.value, }, }); }} diff --git a/src/pages/Settings.tsx b/src/pages/Settings.tsx index 2cd8f4f7..7f62a10a 100644 --- a/src/pages/Settings.tsx +++ b/src/pages/Settings.tsx @@ -6,6 +6,7 @@ import { Button } from '@/components/ui/button'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; +import { IntegerInput } from '@/components/ui/masked-input'; import { Select, SelectContent, @@ -136,14 +137,15 @@ function GlobalSettings() { {expiry.enabled && (
- { + min={0} + onValueChange={(values) => { setExpiry({ ...expiry, - days: e.target.value, + days: values.value, }); }} /> @@ -155,14 +157,15 @@ function GlobalSettings() {
- { + min={0} + onValueChange={(values) => { setExpiry({ ...expiry, - hours: e.target.value, + hours: values.value, }); }} /> @@ -174,14 +177,15 @@ function GlobalSettings() {
- { + min={0} + onValueChange={(values) => { setExpiry({ ...expiry, - minutes: e.target.value, + minutes: values.value, }); }} />