diff --git a/src/assets/svgs/CircleSVG.tsx b/src/assets/svgs/CircleSVG.tsx new file mode 100644 index 0000000..6954397 --- /dev/null +++ b/src/assets/svgs/CircleSVG.tsx @@ -0,0 +1,23 @@ +import { IconTypes } from "./IconTypes"; + +export const CircleSVG: React.FC = ({ + color, + height, + width, + className, + onClickFunc, +}) => { + return ( + + + + ); +}; diff --git a/src/assets/svgs/EmptyCircleSVG.tsx b/src/assets/svgs/EmptyCircleSVG.tsx new file mode 100644 index 0000000..c7177c6 --- /dev/null +++ b/src/assets/svgs/EmptyCircleSVG.tsx @@ -0,0 +1,23 @@ +import { IconTypes } from "./IconTypes"; + +export const EmptyCircleSVG: React.FC = ({ + color, + height, + width, + className, + onClickFunc, +}) => { + return ( + + + ); +}; + + + diff --git a/src/components/common/MultiplePublish/MultiplePublish.tsx b/src/components/common/MultiplePublish/MultiplePublish.tsx new file mode 100644 index 0000000..5c4c062 --- /dev/null +++ b/src/components/common/MultiplePublish/MultiplePublish.tsx @@ -0,0 +1,214 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { + Box, + Button, + CircularProgress, + Modal, + Typography, + useTheme, + } from "@mui/material"; + import React, { useCallback, useEffect, useState, useRef } from "react"; + import { CircleSVG } from "../../../assets/svgs/CircleSVG"; + import { EmptyCircleSVG } from "../../../assets/svgs/EmptyCircleSVG"; +import { styled } from "@mui/system"; + +interface Publish { + resources: any[]; + action: string; +} + +interface MultiplePublishProps { + publishes: Publish; + isOpen: boolean; + onSubmit: ()=> void + onError: (message?: string)=> void +} + export const MultiplePublish = ({ publishes, isOpen, onSubmit, onError}: MultiplePublishProps) => { + const theme = useTheme(); + const listOfSuccessfulPublishesRef = useRef([]) + const [listOfSuccessfulPublishes, setListOfSuccessfulPublishes] = useState< + any[] + >([]); + const [listOfUnsuccessfulPublishes, setListOfUnSuccessfulPublishes] = useState< + any[] + >([]); + const [currentlyInPublish, setCurrentlyInPublish] = useState(null); + const hasStarted = useRef(false); + const publish = useCallback(async (pub: any) => { + const lengthOfResources = pub?.resources?.length + const lengthOfTimeout = lengthOfResources * 30000 + return await qortalRequestWithTimeout(pub, lengthOfTimeout); + }, []); + const [isPublishing, setIsPublishing] = useState(true) + + const handlePublish = useCallback( + async (pub: any) => { + try { + setCurrentlyInPublish(pub?.identifier); + setIsPublishing(true) + const res = await publish(pub); + + onSubmit() + setListOfUnSuccessfulPublishes([]) + + } catch (error: any) { + const unsuccessfulPublishes = error?.error?.unsuccessfulPublishes || [] + if(error?.error === 'User declined request'){ + onError() + return + } + + if(error?.error === 'The request timed out'){ + onError("The request timed out") + + return + } + + + if(unsuccessfulPublishes?.length > 0){ + setListOfUnSuccessfulPublishes(unsuccessfulPublishes) + + } + } finally { + + setIsPublishing(false) + } + }, + [publish] + ); + + const retry = ()=> { + let newlistOfMultiplePublishes: any[] = []; + listOfUnsuccessfulPublishes?.forEach((item)=> { + const findPub = publishes?.resources.find((res: any)=> res?.identifier === item.identifier) + if(findPub){ + newlistOfMultiplePublishes.push(findPub) + } + }) + const multiplePublish = { + ...publishes, + resources: newlistOfMultiplePublishes + }; + handlePublish(multiplePublish) + } + + const startPublish = useCallback( + async (pubs: any) => { + await handlePublish(pubs); + }, + [handlePublish, onSubmit, listOfSuccessfulPublishes, publishes] + ); + + useEffect(() => { + if (publishes && !hasStarted.current) { + hasStarted.current = true; + startPublish(publishes); + } + }, [startPublish, publishes, listOfSuccessfulPublishes]); + + + return ( + + + {publishes?.resources?.map((publish: any) => { + const unpublished = listOfUnsuccessfulPublishes.map(item => item?.identifier) + return ( + + {publish?.identifier} + {!isPublishing && hasStarted.current ? ( + <> + {!unpublished.includes(publish.identifier) ? ( + + ) : ( + + )} + + ): } + + + ); + })} + {!isPublishing && listOfUnsuccessfulPublishes.length > 0 && ( + <> + Some files were not published. Please try again. It's important that all the files get published. Maybe wait a couple minutes if the error keeps occurring + + + )} + + + + ); + }; + + + export const ModalBody = styled(Box)(({ theme }) => ({ + position: "absolute", + backgroundColor: theme.palette.background.default, + borderRadius: "4px", + top: "50%", + left: "50%", + transform: "translate(-50%, -50%)", + width: "75%", + maxWidth: "900px", + padding: "15px 35px", + display: "flex", + flexDirection: "column", + gap: "17px", + overflowY: "auto", + maxHeight: "95vh", + boxShadow: + theme.palette.mode === "dark" + ? "0px 4px 5px 0px hsla(0,0%,0%,0.14), 0px 1px 10px 0px hsla(0,0%,0%,0.12), 0px 2px 4px -1px hsla(0,0%,0%,0.2)" + : "rgba(99, 99, 99, 0.2) 0px 2px 8px 0px", + "&::-webkit-scrollbar-track": { + backgroundColor: theme.palette.background.paper, + }, + "&::-webkit-scrollbar-track:hover": { + backgroundColor: theme.palette.background.paper, + }, + "&::-webkit-scrollbar": { + width: "16px", + height: "10px", + backgroundColor: theme.palette.mode === "light" ? "#f6f8fa" : "#292d3e", + }, + "&::-webkit-scrollbar-thumb": { + backgroundColor: theme.palette.mode === "light" ? "#d3d9e1" : "#575757", + borderRadius: "8px", + backgroundClip: "content-box", + border: "4px solid transparent", + }, + "&::-webkit-scrollbar-thumb:hover": { + backgroundColor: theme.palette.mode === "light" ? "#b7bcc4" : "#474646", + }, + })); \ No newline at end of file diff --git a/src/components/common/useModal.tsx b/src/components/common/useModal.tsx new file mode 100644 index 0000000..a393d39 --- /dev/null +++ b/src/components/common/useModal.tsx @@ -0,0 +1,46 @@ +import { useRef, useState } from 'react'; + +interface State { + isShow: boolean; +} +export const useModal = () => { + const [state, setState] = useState({ + isShow: false, + }); + const promiseConfig = useRef(null); + const show = async () => { + return new Promise((resolve, reject) => { + promiseConfig.current = { + resolve, + reject, + }; + setState({ + isShow: true, + }); + }); + }; + + const hide = () => { + setState({ + isShow: false, + }); + }; + + const onOk = (payload?:any) => { + const { resolve } = promiseConfig.current; + hide(); + resolve(payload); + }; + + const onCancel = () => { + const { reject } = promiseConfig.current; + hide(); + reject(); + }; + return { + show, + onOk, + onCancel, + isShow: state.isShow + }; +}; \ No newline at end of file diff --git a/src/components/modals/EditStoreModal.tsx b/src/components/modals/EditStoreModal.tsx index 675d2c6..de07ae4 100644 --- a/src/components/modals/EditStoreModal.tsx +++ b/src/components/modals/EditStoreModal.tsx @@ -140,8 +140,9 @@ const MyModal: React.FC = ({ open, onClose, onPublish }) => { setLogo(currentStore?.logo || null); setLocation(currentStore?.location || ""); setShipsTo(currentStore?.shipsTo || ""); - setSupportedCoinsSelected(currentStore?.supportedCoins || ["QORT"]); - setArrrWalletAddress(currentStore?.foreignCoins?.ARRR || ""); + const selectedCoinsList = [...new Set([...(currentStore?.supportedCoins || []), 'QORT'])]; + setSupportedCoinsSelected(selectedCoinsList) + setArrrWalletAddress(currentStore?.foreignCoins?.ARRR || "") } }, [currentStore, storeId, open]); @@ -233,15 +234,12 @@ const MyModal: React.FC = ({ open, onClose, onPublish }) => { } } catch (error) { console.error(error); - navigate("/"); dispatch( setNotification({ msg: "Error when creating the data container. Please try again!", alertType: "error", }) ); - dispatch(updateRecentlyVisitedStoreId("")); - dispatch(clearDataCotainer()); } }; diff --git a/src/pages/Cart/Cart.tsx b/src/pages/Cart/Cart.tsx index 48cdbec..770a802 100644 --- a/src/pages/Cart/Cart.tsx +++ b/src/pages/Cart/Cart.tsx @@ -81,6 +81,8 @@ import { AcceptedCoin } from "../StoreList/StoreList-styles"; import { ARRRSVG } from "../../assets/svgs/ARRRSVG"; import { setPreferredCoin } from "../../state/features/storeSlice"; import { CoinFilter } from "../Store/Store/Store"; +import { useModal } from "../../components/common/useModal"; +import { MultiplePublish } from "../../components/common/MultiplePublish/MultiplePublish"; /* Currency must be replaced in the order confirmation email by proper currency */ @@ -92,7 +94,8 @@ interface CountryProps { export const Cart = () => { const theme = useTheme(); const isMobile = useMediaQuery(theme.breakpoints.down("sm")); - + const {isShow, onCancel, onOk, show} = useModal() + const [publishes, setPublishes] = useState(null); const uid = new ShortUniqueId({ length: 10, }); @@ -754,7 +757,9 @@ export const Cart = () => { encrypt: true, publicKeys: [resAddress.publicKey, usernamePublicKey], }; - await qortalRequest(multiplePublish); + setPublishes(multiplePublish) + await show() + // await qortalRequest(multiplePublish); // Clear this cart state from global carts redux dispatch(removeCartFromCarts({ storeId })); // Clear cart local state @@ -787,6 +792,7 @@ export const Cart = () => { ); } finally { dispatch(setIsLoadingGlobal(false)); + setPublishes(null) } }; @@ -828,6 +834,18 @@ export const Cart = () => { return ( <> + {isShow && ( + { + onCancel() + }} + onSubmit={() => { + onOk() + }} + publishes={publishes} + /> + )} { + const {isShow, onCancel, onOk, show} = useModal() + const [publishes, setPublishes] = useState(null); + const theme = useTheme(); const dispatch = useDispatch(); const navigate = useNavigate(); @@ -362,7 +367,9 @@ export const ProductManager = () => { action: "PUBLISH_MULTIPLE_QDN_RESOURCES", resources: [...publishMultipleCatalogues, publishDataContainer], }; - await qortalRequest(multiplePublish); + setPublishes(multiplePublish) + await show() + // await qortalRequest(multiplePublish); // Clear productsToSave from Redux store dispatch(clearAllProductsToSave()); @@ -431,6 +438,8 @@ export const ProductManager = () => { dispatch(setNotification(notificationObj)); throw new Error("Failed to send message"); + } finally { + setPublishes(null) } } @@ -656,6 +665,18 @@ export const ProductManager = () => { {/* Confirm Remove Product from productsToSave in global state */} + {isShow && ( + { + onCancel() + }} + onSubmit={() => { + onOk() + }} + publishes={publishes} + /> + )} ); }; diff --git a/src/wrappers/GlobalWrapper.tsx b/src/wrappers/GlobalWrapper.tsx index 9454f7f..1ec44f7 100644 --- a/src/wrappers/GlobalWrapper.tsx +++ b/src/wrappers/GlobalWrapper.tsx @@ -48,6 +48,8 @@ import { import QortalLogo from "../assets/img/Q-AppsLogo.webp"; import { DownloadCircleSVG } from "../assets/svgs/DownloadCircleSVG"; import { UAParser } from "ua-parser-js"; +import { useModal } from "../components/common/useModal"; +import { MultiplePublish } from "../components/common/MultiplePublish/MultiplePublish"; interface Props { children: React.ReactNode; @@ -65,7 +67,7 @@ const GlobalWrapper: React.FC = ({ children, setTheme }) => { const dispatch = useDispatch(); const navigate = useNavigate(); const theme = useTheme(); - + const [isCreatingShop, setIsCreatingShop] = useState(false) // Determine which OS they're on const parser = new UAParser(); @@ -109,7 +111,8 @@ const GlobalWrapper: React.FC = ({ children, setTheme }) => { useState(false); const [retryDataContainer, setRetryDataContainer] = useState(false); const [showDownloadModal, setShowDownloadModal] = useState(false); - + const {isShow, onCancel, onOk, show} = useModal() + const [publishes, setPublishes] = useState(null); useEffect(() => { if (!user?.name) return; @@ -269,104 +272,7 @@ const GlobalWrapper: React.FC = ({ children, setTheme }) => { } }, []); - const handlePublishDataContainer = React.useCallback(async () => { - try { - const dataContainerToBase64 = await objectToBase64(storedDataContainer); - // Publish Data Container to QDN - const resourceResponse = await qortalRequest({ - action: "PUBLISH_QDN_RESOURCE", - name: storedDataContainer?.owner, - service: "DOCUMENT", - data64: dataContainerToBase64, - identifier: `${storedDataContainer?.storeId}-${DATA_CONTAINER_BASE}`, - filename: "datacontainer.json", - }); - if (isSuccessful(resourceResponse)) { - await new Promise((res, rej) => { - setTimeout(() => { - res(); - }, 1000); - }); - dispatch( - setDataContainer({ - ...storedDataContainer, - id: `${storedDataContainer?.storeId}-${DATA_CONTAINER_BASE}`, - }) - ); - dispatch( - setNotification({ - msg: "Shop successfully created", - alertType: "success", - }) - ); - setCloseCreateStoreModal(true); - setRetryDataContainer(false); - setOpenDataContainer(false); - } else { - setOpenDataContainer(true); - } - } catch (error) { - console.error(error); - dispatch( - setNotification({ - msg: "You must create a data container in order to create a shop!", - alertType: "error", - }) - ); - // Try again after 8 seconds automatically - setOpenDataContainer(true); - let interval: number | undefined = undefined; - const dataContainerToBase64 = await objectToBase64(storedDataContainer); - interval = window.setInterval(async () => { - try { - const resourceResponse = await qortalRequest({ - action: "PUBLISH_QDN_RESOURCE", - name: storedDataContainer?.owner, - service: "DOCUMENT", - data64: dataContainerToBase64, - identifier: `${storedDataContainer?.storeId}-${DATA_CONTAINER_BASE}`, - filename: "datacontainer.json", - }); - if (isSuccessful(resourceResponse)) { - await new Promise((res, rej) => { - setTimeout(() => { - res(); - }, 1000); - }); - dispatch( - setDataContainer({ - ...storedDataContainer, - id: `${storedDataContainer?.storeId}-${DATA_CONTAINER_BASE}`, - }) - ); - dispatch( - setNotification({ - msg: "Shop successfully created", - alertType: "success", - }) - ); - setCloseCreateStoreModal(true); - setRetryDataContainer(false); - setOpenDataContainer(false); - clearInterval(interval); - } - } catch (error) { - console.error(error); - setRetryDataContainer(false); - // clear interval - if (interval) { - clearInterval(interval); - } - dispatch( - setNotification({ - msg: "You must create a data container in order to create a shop!", - alertType: "error", - }) - ); - } - }, 8000); - } - }, [storedDataContainer]); + // If they successfully create a store but not a data container, keep the data-container information in the state. // Wait 8 seconds and try again automatically. If it fails again, then tell them to republish again. @@ -382,6 +288,8 @@ const GlobalWrapper: React.FC = ({ children, setTheme }) => { foreignCoins, supportedCoins, }: onPublishParam) => { + if(isCreatingShop) return + setIsCreatingShop(true) if (!user || !user.name) throw new Error("Cannot publish: You do not have a Qortal name"); if (!title) throw new Error("A title is required"); @@ -429,7 +337,7 @@ const GlobalWrapper: React.FC = ({ children, setTheme }) => { `**coins:QORTtrue,ARRR${supportedCoins.includes("ARRR")}**` + description.slice(0, 180); - const resourceResponse = await qortalRequest({ + const resourceStore = { action: "PUBLISH_QDN_RESOURCE", name: name, service: "STORE", @@ -438,13 +346,9 @@ const GlobalWrapper: React.FC = ({ children, setTheme }) => { title, description: metadescription, identifier: identifier, - }); - if (isSuccessful(resourceResponse)) { - await new Promise((res, rej) => { - setTimeout(() => { - res(); - }, 1000); - }); + } + + const createdAt = Date.now(); const dataContainer = { storeId: identifier, @@ -472,15 +376,43 @@ const GlobalWrapper: React.FC = ({ children, setTheme }) => { updated: createdAt, }; + const dataContainerToBase64 = await objectToBase64(dataContainer); + // Publish Data Container to QDN + const resourceDatacontainer = { + action: "PUBLISH_QDN_RESOURCE", + name: name, + service: "DOCUMENT", + data64: dataContainerToBase64, + identifier: `${identifier}-${DATA_CONTAINER_BASE}`, + filename: "datacontainer.json", + } + + const multiplePublish = { + action: "PUBLISH_MULTIPLE_QDN_RESOURCES", + resources: [resourceStore, resourceDatacontainer], + }; + setPublishes(multiplePublish) + await show() dispatch(setCurrentStore(storefullObj)); dispatch(addToHashMapStores(storefullObj)); dispatch(addToStores(storefullObj)); dispatch(addToAllMyStores(storeData)); - setStoredDataContainer(dataContainer); - setRetryDataContainer(true); - } else { - throw new Error("Failed to create store"); - } + dispatch( + setDataContainer({ + ...dataContainer, + id: `${identifier}-${DATA_CONTAINER_BASE}`, + }) + ); + dispatch( + setNotification({ + msg: "Shop successfully created", + alertType: "success", + }) + ); + setCloseCreateStoreModal(true); + setOpenDataContainer(false); + + } catch (error: any) { let notificationObj: any = null; if (typeof error === "string") { @@ -507,6 +439,8 @@ const GlobalWrapper: React.FC = ({ children, setTheme }) => { console.error(error); throw new Error("An unknown error occurred"); } + } finally { + setIsCreatingShop(false) } }, [user] @@ -821,24 +755,22 @@ const GlobalWrapper: React.FC = ({ children, setTheme }) => { } }, [recentlyVisitedStoreId, myStores, userOwnDataContainer]); - // Handle publishing of data container when creating a store, or if it fails too (Need this to be able to call it from the reusable modal) - useEffect(() => { - const publishDataContainer = async () => { - // Publish Data Container to QDN here - await handlePublishDataContainer(); - }; - if ( - retryDataContainer && - storedDataContainer && - Object.keys(storedDataContainer).length > 0 - ) { - publishDataContainer(); - } - // We only want to run this when retryDataContainer changes, or else storedDataContainer will be cleared beforehand. - }, [retryDataContainer]); + return ( <> + {isShow && ( + { + onCancel() + }} + onSubmit={() => { + onOk() + }} + publishes={publishes} + /> + )} {isLoadingGlobal && } {isOpenCreateStoreModal && user?.name && (