diff --git a/package.json b/package.json index f2b848f..a4da7af 100644 --- a/package.json +++ b/package.json @@ -37,7 +37,7 @@ "web-vitals": "^2.1.4" }, "scripts": { - "start": "set PORT=3006 && react-scripts start", + "start": "PORT=3006 react-scripts start", "build": "react-scripts build", "test": "react-scripts test", "eject": "react-scripts eject" diff --git a/src/api/client.ts b/src/api/client.ts index 41620c7..1c77d71 100644 --- a/src/api/client.ts +++ b/src/api/client.ts @@ -3,11 +3,18 @@ import { apiCall, cut0x, Handlers, sign, transformKittyForUi } from "./utils"; export const api = { "show-all-kitties": async () => { - const basicKitties = await apiCall("get-all-kitty-list", "GET"); + const allKittyResponse = await apiCall("get-all-kitty-list", "GET"); - const tradableKitties = await apiCall("get-all-tradable-kitty-list", "GET"); - const kittyList = tradableKitties["td_kitty_list"] || []; - const kittiesToAdd = kittyList.map((tdKitty: any) => ({ + if (allKittyResponse.error) { + throw new Error(`Error fetching kitties: ${allKittyResponse.data}`); + } + + const ownerKitties = allKittyResponse["owner_kitty_list"] || []; + + const tradableKittiesResponse = await apiCall("get-all-tradable-kitty-list", "GET"); + const tradableKitties = tradableKittiesResponse["td_kitty_list"] || []; + + const convertedTradableKitties = tradableKitties.map((tdKitty: any) => ({ kitty: { ...tdKitty["td_kitty"]["kitty_basic_data"], price: tdKitty["td_kitty"]["price"], @@ -16,16 +23,23 @@ export const api = { })); return { - ...basicKitties, - owner_kitty_list: [...basicKitties.owner_kitty_list, ...kittiesToAdd], + ...allKittyResponse, + owner_kitty_list: [...ownerKitties, ...convertedTradableKitties], }; }, "show-owned-kitties": async (user: string) => { - const ownedKitties = await apiCall("get-owned-kitty-list", "GET", { + const response = await apiCall("get-owned-kitty-list", "GET", { owner_public_key: cut0x(user), }); + + if (response.error) { + throw new Error(`Error fetching owned kitties: ${response.data}`); + } + + const kittyList = response["kitty_list"] || []; + return { - owner_kitty_list: ownedKitties["kitty_list"].map((kittyData: any) => ({ + owner_kitty_list: kittyList.map((kittyData: any) => ({ kitty: kittyData, owner_pub_key: cut0x(user), })), @@ -60,14 +74,23 @@ export const api = { "child-kitty-name": name, owner_public_key: cut0x(user), }; - const transaction = await apiCall( + const txResponse = await apiCall( "get-txn-and-inpututxolist-for-breed-kitty", "GET", txBody, ); - const signedTransaction = await sign(transaction, accounts); - return await apiCall(updateHandle, "POST", {}, signedTransaction); + if (!txResponse.transaction) { + throw new Error(txResponse.message || "Getting transaction for breeding kitty failed"); + } + + const signedTransaction = await sign(txResponse, accounts); + + const breedResponse = await apiCall(updateHandle, "POST", {}, signedTransaction); + + if (!breedResponse.child_kitty) { + throw new Error(breedResponse.message || "Kitty breeding failed"); + } }, "set-kitty-property": async (kitty: Partial) => { //price diff --git a/src/app/store.ts b/src/app/store.ts index 96a2abd..79d21ed 100644 --- a/src/app/store.ts +++ b/src/app/store.ts @@ -17,10 +17,14 @@ const rootReducer = combineReducers({ trading: tradingSlice, }) +// State is mostly transient and persisting was only leading to stale data +// after loading/refreshing the page. For this reason, the whitelist is +// currently empty. Add to it only after careful consideration. const persistConfig = { key: 'root', version: 1, storage, + whitelist: [], } const persistedReducer = persistReducer(persistConfig, rootReducer) export const store = configureStore({ diff --git a/src/components/LoadingStatus.tsx b/src/components/LoadingStatus.tsx new file mode 100644 index 0000000..97f69b7 --- /dev/null +++ b/src/components/LoadingStatus.tsx @@ -0,0 +1,15 @@ +import { CircularProgress, Flex } from "@chakra-ui/react"; + +type LoadingStatusProps = { + status: "idle" | "pending" | "succeeded" | "failed"; + message?: string; +}; + +export const LoadingStatus: React.FC = ({ status, message }) => { + return ( + + {status === "pending" && } + {message} + + ); +}; \ No newline at end of file diff --git a/src/components/Root.tsx b/src/components/Root.tsx index c526de1..bb71321 100644 --- a/src/components/Root.tsx +++ b/src/components/Root.tsx @@ -7,18 +7,13 @@ import { Button, Stack, Container, Flex } from '@chakra-ui/react'; import {SearchIcon, PersonCircleIcon} from "chakra-ui-ionicons"; import { WalletSelector } from "../features/wallet"; import { useAppSelector } from "../app/hooks"; -import { selectAccount, selectIsConnected } from "../features/wallet/walletSlice"; -import {getWalletBySource} from "@talisman-connect/wallets"; +import { selectAccount } from "../features/wallet/walletSlice"; export const Root = () => { const account = useAppSelector(selectAccount); // @ts-ignore const isConnected = !!window.accounts; - const wallet = getWalletBySource('talisman'); - useEffect(()=>{ - if (!account || !wallet) return; - },[account, wallet]) return (
diff --git a/src/features/kittiesList/index.ts b/src/features/kittiesList/index.ts index 412a308..c717230 100644 --- a/src/features/kittiesList/index.ts +++ b/src/features/kittiesList/index.ts @@ -40,19 +40,20 @@ const kittiesSlice = createSlice({ // standard reducer logic, with auto-generated action types per reducer }, extraReducers: (builder) => { + builder.addCase(getKitties.pending, (state) => { + state.loading = "pending"; + state.error = undefined; + }); builder.addCase(getKitties.fulfilled, (state, action) => { - if (action.payload?.message?.toLowerCase().includes("error")) { - state.error = action.payload.message; - } - if (!action.payload?.owner_kitty_list) { - state.loading = "failed"; - return; - } state.list = action.payload?.owner_kitty_list.map(transformKittyForUi); - state.loading = "succeeded"; state.error = undefined; }); + builder.addCase(getKitties.rejected, (state, action) => { + state.list = []; + state.loading = "failed"; + state.error = action.error.message; + }); }, }); diff --git a/src/features/wallet/index.tsx b/src/features/wallet/index.tsx index ff58000..da62732 100644 --- a/src/features/wallet/index.tsx +++ b/src/features/wallet/index.tsx @@ -1,35 +1,19 @@ -import React, { useEffect, useState } from "react"; +import { useEffect } from "react"; import { useToast, CircularProgress } from "@chakra-ui/react"; import { getWallets } from "@talismn/connect-wallets"; import { useAppDispatch } from "../../app/hooks"; -import { connect, login } from "./walletSlice"; -import { api } from "../../api/client"; -import { getCoins } from "../trade"; -import { getKitties } from "../kittiesList"; +import { login } from "./walletSlice"; import { decodeAddress } from "@polkadot/keyring"; import { u8aToHex } from "@polkadot/util"; const DAPP_NAME = process.env.REACT_APP_DAPP_NAME || "development"; -// get an array of wallets which are installed -const installedWallets = getWallets().filter((wallet) => wallet.installed); - -// get talisman from the array of installed wallets -const talismanWallet = installedWallets.find( - (wallet) => wallet.extensionName === "talisman", -); - export const WalletSelector = () => { - const [init, setInit] = useState(false); const dispatch = useAppDispatch(); const toast = useToast(); useEffect(() => { - setInit(true); - }, []); - - useEffect(() => { - const fetchData = async () => { + const connectWallet = async () => { toast({ title: "Talisman connecting", description: "Getting your information from Tuxedo", @@ -37,58 +21,87 @@ export const WalletSelector = () => { duration: 4000, isClosable: true, }); - if (talismanWallet) { - await talismanWallet.enable(DAPP_NAME); - talismanWallet.subscribeAccounts(async (accounts) => { - if (!accounts) { - toast({ - title: "Error", - description: "No accounts found", - status: "error", - duration: 4000, - isClosable: true, - }); - return; - } - //finish connecting + const installedWallets = getWallets().filter( + (wallet) => wallet.installed + ); + + const talismanWallet = installedWallets.find( + (wallet) => wallet.extensionName === "talisman" + ); + + if (!talismanWallet) { + toast({ + title: "Error connecting wallet", + description: "Talisman wallet not found", + status: "error", + duration: 4000, + isClosable: true, + }); + + return; + } + + await talismanWallet.enable(DAPP_NAME); + + talismanWallet.subscribeAccounts(async (accounts) => { + if (!accounts) { toast({ - title: "Talisman connected", - description: `Accounts: ${accounts.map((account) => account.name).join(" ,")} are connected`, - status: "success", + title: "Error connecting wallet", + description: "No Talisman accounts found", + status: "error", duration: 4000, isClosable: true, }); - //each account has its own signer, and it can't be saved to store - //maybe need to use other api to access it - // @ts-ignore - window.accounts = accounts; - dispatch( - login( - accounts.map((account) => ({ - address: account.address, - source: account.source, - name: account.name, - key: u8aToHex(decodeAddress(account.address)), - })), - ), - ); - dispatch(connect()); + return; + } + + //each account has its own signer, and it can't be saved to store + //maybe need to use other api to access it + // @ts-ignore + window.accounts = accounts; + + dispatch( + login( + accounts.map((account) => ({ + address: account.address, + source: account.source, + name: account.name, + key: u8aToHex(decodeAddress(account.address)), + })) + ) + ); + + toast({ + title: "Talisman connected", + description: `Accounts: ${accounts + .map((account) => account.name) + .join(" ,")} are connected`, + status: "success", + duration: 4000, + isClosable: true, }); - } + }); }; - if (init) { - fetchData().catch((error) => { + + // Connect wallet after slight delay to avoid intermittent problem with + // talismanWallet not being found + const timeout = setTimeout(() => { + connectWallet().catch((error) => { toast({ - title: "Talisman connecting", + title: "Error connecting wallet", description: error, status: "error", duration: 9000, isClosable: true, }); }); - } - }, [init]); + }, 100); + + return () => { + clearTimeout(timeout); + }; + }, [dispatch, toast]); return (
diff --git a/src/features/wallet/walletSlice.ts b/src/features/wallet/walletSlice.ts index c167804..9f24b05 100644 --- a/src/features/wallet/walletSlice.ts +++ b/src/features/wallet/walletSlice.ts @@ -4,12 +4,10 @@ import { wallet } from "../../types"; export interface WalletState { accounts?:wallet[]; - isConnected: boolean; } const initialState: WalletState = { accounts: undefined, - isConnected: false, }; // Then, handle actions in your reducers: @@ -23,18 +21,11 @@ const walletSlice = createSlice({ logout: (state) => { state.accounts = undefined; }, - connect: (state) => { - state.isConnected = true; - }, - disConnect: (state)=> { - state.isConnected = false; - } } }) export const selectAccount = (state: RootState) => state.wallet.accounts?.[0]; export const selectAccounts = (state: RootState) => state.wallet.accounts; -export const selectIsConnected = (state: RootState) => state.wallet.isConnected; -export const { login, logout, connect, disConnect } = walletSlice.actions; +export const { login, logout } = walletSlice.actions; export default walletSlice.reducer; diff --git a/src/pages/Breed.tsx b/src/pages/Breed.tsx index 6f88992..2860182 100644 --- a/src/pages/Breed.tsx +++ b/src/pages/Breed.tsx @@ -13,9 +13,6 @@ import { ModalOverlay, useDisclosure, Input, - Textarea, - Text, - Tag, useToast, } from "@chakra-ui/react"; import { useAppDispatch, useAppSelector } from "../app/hooks"; @@ -24,7 +21,6 @@ import { Kitty } from "../types"; import { selectAccount } from "../features/wallet/walletSlice"; import { postBreed, - selectChild, selectDad, selectMom, setDad, @@ -32,11 +28,12 @@ import { } from "../features/breeding"; export const Breed = () => { - const [state, setState] = useState<"breeding" | "result">("breeding"); const { isOpen, onClose } = useDisclosure(); const dispatch = useAppDispatch(); const [dads, moms] = useAppSelector(selectKitties).reduce<[Kitty[], Kitty[]]>( (acc, kitty) => { + if (kitty.status !== "RearinToGo") return acc; + if (kitty.gender === "male") { acc[0].push(kitty); } else { @@ -50,9 +47,19 @@ export const Breed = () => { const toast = useToast(); const selectedMom = useAppSelector(selectMom); const selectedDad = useAppSelector(selectDad); - const selectedChild = useAppSelector(selectChild); const [kittyName, setKittyName] = useState(""); + const renderKittyOption = (kitty: Kitty) => { + return ( + + ); + } + + const renderWarning = (parentType: "mom" | "dad") => +

No {parentType}s ready for breeding

+ const handleMomSelect = (event: React.FormEvent) => { dispatch(setMom({ dna: event.currentTarget.value })); }; @@ -61,9 +68,16 @@ export const Breed = () => { dispatch(setDad({ dna: event.currentTarget.value })); }; const startBreeding = () => { - if (!selectedDad || !selectedMom || kittyName === "") { + const warnings = []; + if (!selectedDad || !selectedMom) { + warnings.push("Please select both parents."); + } + if (kittyName.length !== 4) { + warnings.push("Kitty name must be 4 characters long."); + } + if (warnings.length > 0) { toast({ - title: "Be sure to enter kitty breeding details!", + title: warnings.join(" "), status: "warning", isClosable: true, duration: 10000, @@ -71,33 +85,66 @@ export const Breed = () => { }); return; } - dispatch( - postBreed({ - mom: selectedMom?.dna!, - dad: selectedDad?.dna!, - name: kittyName, - key: account?.key!, - }), - ); + + if (!selectedDad?.dna || !selectedMom?.dna) { + toast({ + title: "At least one parent is missing DNA, this shouldn't happen!", + status: "error", + isClosable: true, + duration: 10000, + position: "top-right", + }); + return; + } + + const breedParams = { + mom: selectedMom.dna, + dad: selectedDad.dna, + name: kittyName, + key: account?.key!, + }; + + // Dispatch the breeding action and show the result as a toast message. + // The results could be handled in the reducer but there is little benefit + // in this case since we only need to display a one-time message. + dispatch(postBreed(breedParams)) + .unwrap() + .then((() => { + toast({ + title: "Kitty bred successfully", + status: "success", + isClosable: true, + duration: 10000, + position: "top-right", + }); + })) + .catch((error) => { + const title = "Error breeding kitty"; + toast({ + title, + description: error.message, + status: "error", + isClosable: true, + duration: 10000, + position: "top-right", + }); + console.error(title, error); + }); }; useEffect(() => { if (!account) return; // we don't want to update if the kitties are already in store if (moms.length > 0 || dads.length > 0) return; dispatch(getKitties(account.address)); - }, [account, moms, dads]); + }, [account, moms, dads, dispatch]); useEffect(() => { - if (selectedDad && !selectedMom && moms.length > 0) { + // set default selection after loading list of parents + if (!selectedDad && !selectedMom && moms.length > 0 && dads.length > 0) { dispatch(setMom({ dna: moms[0].dna })); - } - }, [moms]); - - useEffect(() => { - if (selectedMom && !selectedDad && dads.length > 0) { dispatch(setDad({ dna: dads[0].dna })); } - }, [dads]); + }, [selectedDad, selectedMom, moms, dads, dispatch]); return ( <> @@ -121,25 +168,19 @@ export const Breed = () => { Mom + {moms.length === 0 && renderWarning("mom")} Dad + {dads.length === 0 && renderWarning("dad")} - Kitty name + Kitty name (4 characters) ) => setKittyName(event.currentTarget.value) diff --git a/src/pages/MyKitties.tsx b/src/pages/MyKitties.tsx index 3b0bca3..39e1329 100644 --- a/src/pages/MyKitties.tsx +++ b/src/pages/MyKitties.tsx @@ -25,27 +25,27 @@ import { import { Link, To, useNavigate } from "react-router-dom"; import { EggIcon, SearchIcon } from "chakra-ui-ionicons"; import { useAppDispatch, useAppSelector } from "../app/hooks"; -import { getKitties, selectKitties } from "../features/kittiesList"; +import { getKitties, selectError, selectKitties, selectStatus } from "../features/kittiesList"; import { selectAccount } from "../features/wallet/walletSlice"; import { setKitty } from "../features/kittyDetails"; import { Kitty } from "../types"; import Fuse, { FuseResult } from "fuse.js"; +import { LoadingStatus } from "../components/LoadingStatus"; +import { getStatusColor } from "../utils"; -const colors: Record = { - "ready to bread": "pink", - tired: "purple", - "had birth recently": "teal", -}; export const MyKitties = () => { const navigate = useNavigate(); const dispatch = useAppDispatch(); const list = useAppSelector(selectKitties); + const status = useAppSelector(selectStatus); + const error = useAppSelector(selectError); const account = useAppSelector(selectAccount); const [filteredList, setList] = useState[]>([]); const fuse = new Fuse(list, { shouldSort: true, keys: ["name", "hash", "status", "forSale"], }); + const message = error ?? filteredList.length === 0 ? "No kitties found" : undefined; useEffect(() => { if (!account) return; @@ -133,7 +133,7 @@ export const MyKitties = () => { {item?.breedings} - + {item?.status} @@ -148,6 +148,7 @@ export const MyKitties = () => { + diff --git a/src/pages/Search.tsx b/src/pages/Search.tsx index 2958dcd..edd6eab 100644 --- a/src/pages/Search.tsx +++ b/src/pages/Search.tsx @@ -18,7 +18,6 @@ import { InputLeftElement, Divider, Tooltip, - CircularProgress, } from "@chakra-ui/react"; import { To, useNavigate } from "react-router-dom"; import { SearchIcon } from "chakra-ui-ionicons"; @@ -32,6 +31,8 @@ import { } from "../features/kittiesList"; import { Kitty } from "../types"; import { setKitty } from "../features/kittyDetails"; +import { LoadingStatus } from "../components/LoadingStatus"; +import { getStatusColor } from "../utils"; const fuseOptions = { // isCaseSensitive: false, @@ -54,24 +55,22 @@ export const Search = () => { const navigate = useNavigate(); const dispatch = useAppDispatch(); const list = useAppSelector(selectKitties); - const loading = useAppSelector(selectStatus); + const status = useAppSelector(selectStatus); const error = useAppSelector(selectError); const fuse = new Fuse(list, fuseOptions); + + const message = error ?? filteredList.length === 0 ? "No kitties found" : undefined; + const handleRowClick = (page: To, kitty: Kitty) => () => { dispatch(setKitty(kitty)); navigate(page); }; useEffect(() => { dispatch(getKitties()); - }, []); + }, [dispatch]); useEffect(() => { setList(list.map((kitty) => ({ item: kitty, refIndex: 1 }))); }, [list]); - const colors: Record = { - RearinToGo: "pink", - tired: "purple", - "had birth recently": "teal", - }; const handleSearch = (e: React.FormEvent) => { if (e.currentTarget.value.length > 0) { @@ -148,7 +147,7 @@ export const Search = () => { {item?.breedings} - + {item?.status} @@ -163,12 +162,7 @@ export const Search = () => { - - {loading === "idle" && ( - - )} - {!!error && error} - + diff --git a/src/types/index.ts b/src/types/index.ts index ea20944..2f26eec 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -2,6 +2,8 @@ import { WalletAccount, } from '@talismn/connect-wallets'; +export type KittyStatus = "had birth recently" | "tired" | "RearinToGo"; + export type Kitty = { owner?: string; dna?: string; @@ -16,7 +18,7 @@ export type Kitty = { name: string; } breedings: number;// Number of breedings - status: string; + status: KittyStatus; forSale: boolean; price?: number; }; diff --git a/src/utils/index.ts b/src/utils/index.ts new file mode 100644 index 0000000..ba61232 --- /dev/null +++ b/src/utils/index.ts @@ -0,0 +1,13 @@ +import { KittyStatus } from "../types"; + +export const getStatusColor = (status: KittyStatus | undefined) => { + if (status === undefined) return "gray"; + + const colors: Record = { + RearinToGo: "pink", + tired: "purple", + "had birth recently": "teal", + }; + + return colors[status]; +};