From e9afcf9a196a9ee7a24109a9dee87f96e4e0af36 Mon Sep 17 00:00:00 2001 From: q-shop-release-bot Date: Sat, 13 Sep 2025 00:32:14 -0400 Subject: [PATCH 01/19] Fix accepted coins in store details --- package-lock.json | 4 +-- src/pages/Store/StoreDetails/StoreDetails.tsx | 34 +++++++++++-------- 2 files changed, 22 insertions(+), 16 deletions(-) diff --git a/package-lock.json b/package-lock.json index f53eac5..2f269d2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "q-shop", - "version": "1.0.0", + "version": "1.1.2", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "q-shop", - "version": "1.0.0", + "version": "1.1.2", "dependencies": { "@emotion/react": "^11.10.6", "@emotion/styled": "^11.10.6", diff --git a/src/pages/Store/StoreDetails/StoreDetails.tsx b/src/pages/Store/StoreDetails/StoreDetails.tsx index 78c60b3..d23225c 100644 --- a/src/pages/Store/StoreDetails/StoreDetails.tsx +++ b/src/pages/Store/StoreDetails/StoreDetails.tsx @@ -21,9 +21,8 @@ import { DescriptionSVG } from "../../../assets/svgs/DescriptionSVG"; import { LocationSVG } from "../../../assets/svgs/LocationSVG"; import { ShippingSVG } from "../../../assets/svgs/ShippingSVG"; import { CurrencySVG } from "../../../assets/svgs/CurrencySVG"; -import { QortalSVG } from "../../../assets/svgs/QortalSVG"; -import { ARRRSVG } from "../../../assets/svgs/ARRRSVG"; import { ForeignCoins } from "../../../components/modals/CreateStoreModal"; +import { coinPng } from "../../../constants/coin-icons"; interface StoreDetailsProps { storeTitle: string; @@ -131,19 +130,26 @@ export const StoreDetails: FC = ({ Accepted Coins - - {foreignCoins?.ARRR && ( - - )} - + {/* Show all other supported coins that have wallet addresses configured */} + {foreignCoins && supportedCoins && + Object.keys(foreignCoins) + .filter((sym) => sym !== "QORT" && supportedCoins.includes(sym)) + .map((sym) => ( + {`${sym}-logo`} + ))} -- 2.43.0 From bb9168f0f9bba077949f9b8707ff3b66b6fab7c4 Mon Sep 17 00:00:00 2001 From: q-shop-release-bot Date: Sat, 13 Sep 2025 01:09:21 -0400 Subject: [PATCH 02/19] Sort by recently updated or created --- src/pages/Store/StoreCard/StoreCard.tsx | 8 +- src/pages/StoreList/StoreList-styles.tsx | 12 ++ src/pages/StoreList/StoreList.tsx | 182 +++++++++++++++++++---- 3 files changed, 175 insertions(+), 27 deletions(-) diff --git a/src/pages/Store/StoreCard/StoreCard.tsx b/src/pages/Store/StoreCard/StoreCard.tsx index ea9f607..7ae236b 100644 --- a/src/pages/Store/StoreCard/StoreCard.tsx +++ b/src/pages/Store/StoreCard/StoreCard.tsx @@ -9,6 +9,7 @@ import { StoreCardImageContainer, StoreCardInfo, StoreCardOwner, + StoreCardTimestamp, StoreCardTitle, StoresRow, StyledStoreCard, @@ -38,6 +39,7 @@ interface StoreCardProps { storeOwner: string; userName: string; supportedCoins: string[]; + bottomLabel?: string; } export const StoreCard: FC = ({ @@ -47,7 +49,8 @@ export const StoreCard: FC = ({ storeId, storeOwner, userName, - supportedCoins + supportedCoins, + bottomLabel }) => { const navigate = useNavigate(); const dispatch = useDispatch(); @@ -139,6 +142,9 @@ export const StoreCard: FC = ({ ))} + {bottomLabel && ( + {bottomLabel} + )} {storeOwner} {storeOwner === userName && ( diff --git a/src/pages/StoreList/StoreList-styles.tsx b/src/pages/StoreList/StoreList-styles.tsx index e232b72..c4c6ef2 100644 --- a/src/pages/StoreList/StoreList-styles.tsx +++ b/src/pages/StoreList/StoreList-styles.tsx @@ -203,6 +203,18 @@ export const StoreCardOwner = styled(Typography)(({ theme }) => ({ userSelect: "none", })); +export const StoreCardTimestamp = styled(Typography)(({ theme }) => ({ + fontFamily: "Karla", + color: theme.palette.text.primary, + fontSize: "12px", + position: "absolute", + bottom: "5px", + left: "50%", + transform: "translateX(-50%)", + opacity: 0.8, + userSelect: "none", +})); + export const StyledTooltip = styled(Tooltip)(({ theme }) => ({ "& .MuiTooltip-tooltip": { fontFamily: "Karla", diff --git a/src/pages/StoreList/StoreList.tsx b/src/pages/StoreList/StoreList.tsx index 7f0ff94..1391ba1 100644 --- a/src/pages/StoreList/StoreList.tsx +++ b/src/pages/StoreList/StoreList.tsx @@ -1,4 +1,4 @@ -import { useState, useCallback, useMemo } from "react"; +import { useState, useCallback, useMemo, useEffect } from "react"; import { useDispatch, useSelector } from "react-redux"; import { RootState } from "../../state/store"; import LazyLoad from "../../components/common/LazyLoad"; @@ -16,12 +16,12 @@ import { LogoRow, StoresRow, } from "./StoreList-styles"; -import { Grid, Skeleton, useTheme } from "@mui/material"; +import { Grid, Skeleton, useTheme, TextField, MenuItem } from "@mui/material"; import { StoreCard } from "../Store/StoreCard/StoreCard"; import QShopLogoLight from "../../assets/img/QShopLogoLight.webp"; import QShopLogoDark from "../../assets/img/QShopLogo.webp"; import DefaultStoreImage from "../../assets/img/Q-AppsLogo.webp"; -import { STORE_BASE } from "../../constants/identifiers"; +import { STORE_BASE, CATALOGUE_BASE } from "../../constants/identifiers"; export const StoreList = () => { const dispatch = useDispatch(); @@ -30,6 +30,10 @@ export const StoreList = () => { const user = useSelector((state: RootState) => state.auth.user); const [filterUserStores, setFilterUserStores] = useState(false); + const [sortBy, setSortBy] = useState<"updated" | "created">("updated"); + const [catalogueLatestByStoreId, setCatalogueLatestByStoreId] = useState>({}); + const [hasFetchedAllStores, setHasFetchedAllStores] = useState(false); + const [isFetchingAllStores, setIsFetchingAllStores] = useState(false); // TODO: Need skeleton at first while the data is being fetched // Will rerender and replace if the hashmap wasn't found initially @@ -44,21 +48,29 @@ export const StoreList = () => { const { getStore, checkAndUpdateResource } = useFetchStores(); const getUserStores = useCallback(async () => { + if (hasFetchedAllStores || isFetchingAllStores) return; try { - const offset = stores.length; + setIsFetchingAllStores(true); const query = STORE_BASE; - // Fetch list of user stores' resources from Qortal blockchain - const url = `/arbitrary/resources/search?service=STORE&query=${query}&limit=20&mode=ALL&prefix=true&includemetadata=false&offset=${offset}&reverse=true`; - const response = await fetch(url, { - method: "GET", - headers: { - "Content-Type": "application/json", - }, - }); - const responseData = await response.json(); - // Data returned from that endpoint of the API - // tags, category, categoryName are not being used at the moment - const structureData = responseData.map((storeItem: any): Store => { + const pageSize = 100; + let offset = 0; + let allRaw: any[] = []; + while (true) { + const url = `/arbitrary/resources/search?service=STORE&query=${query}&limit=${pageSize}&mode=ALL&prefix=true&includemetadata=false&offset=${offset}&reverse=true`; + const response = await fetch(url, { + method: "GET", + headers: { + "Content-Type": "application/json", + }, + }); + const responseData = await response.json(); + if (!Array.isArray(responseData) || responseData.length === 0) break; + allRaw = allRaw.concat(responseData); + offset += responseData.length; + if (responseData.length < pageSize) break; + } + // Map raw to Store structure + const structureData: Store[] = allRaw.map((storeItem: any): Store => { return { title: storeItem?.metadata?.title, category: storeItem?.metadata?.category, @@ -71,10 +83,12 @@ export const StoreList = () => { id: storeItem.identifier, }; }); - // Add stores to localstate & guard against duplicates - const copiedStores: Store[] = [...stores]; + + // Upsert into redux and guard duplicates + const existing = [...stores]; + const copiedStores: Store[] = [...existing]; structureData.forEach((storeItem: Store) => { - const index = stores.findIndex((p: Store) => p.id === storeItem.id); + const index = existing.findIndex((p: Store) => p.id === storeItem.id); if (index !== -1) { copiedStores[index] = storeItem; } else { @@ -82,28 +96,32 @@ export const StoreList = () => { } }); dispatch(upsertStores(copiedStores)); - // Get the store raw data from getStore API Call only if the hashmapStore doesn't have the store or if the store is more recently updated than the existing store + + // Fetch raw store data as needed for (const content of structureData) { if (content.owner && content.id) { const res = checkAndUpdateResource({ id: content.id, - updated: content.updated, + updated: Number(content.updated ?? content.created ?? 0), }); - // If the store is not already inside the hashmap, fetch the store raw data. We wrap this function in a timeout util function because stores with errors will hang the app and take a long time to load. With this, the max load time will be of 5 seconds for an error store. if (res) { getStore(content.owner, content.id, content); } } } + + setHasFetchedAllStores(true); } catch (error) { console.error(error); + } finally { + setIsFetchingAllStores(false); } - }, [stores]); + }, [stores, hasFetchedAllStores, isFetchingAllStores, checkAndUpdateResource, getStore]); // Get all stores on mount or if user changes const getStores = useCallback(async () => { await getUserStores(); - }, [getUserStores, user?.name]); + }, [getUserStores]); // Filter to show only the user's stores @@ -113,12 +131,100 @@ export const StoreList = () => { setFilterUserStores(event.target.checked); }; + const handleSortChange = ( + event: React.ChangeEvent + ) => { + const value = event.target.value as "updated" | "created"; + setSortBy(value); + }; + + // Fetch latest catalogue publish timestamp per store (by shortStoreId) + const fetchLatestCatalogueTime = useCallback( + async (owner: string, shortStoreId: string, storeId: string) => { + try { + const query = `${CATALOGUE_BASE}-${shortStoreId}`; + const url = `/arbitrary/resources/search?service=DOCUMENT&query=${query}&limit=1&includemetadata=false&mode=ALL&reverse=true&prefix=true&name=${owner}&exactmatchnames=true`; + const res = await fetch(url, { + method: "GET", + headers: { "Content-Type": "application/json" }, + }); + const data = await res.json(); + if (Array.isArray(data) && data.length > 0) { + const latest = Math.max(Number(data[0]?.updated || 0), Number(data[0]?.created || 0)); + setCatalogueLatestByStoreId((prev) => ({ ...prev, [storeId]: latest })); + } else { + setCatalogueLatestByStoreId((prev) => ({ ...prev, [storeId]: 0 })); + } + } catch (e) { + // Non-fatal; just skip catalogue updated time for this store + setCatalogueLatestByStoreId((prev) => ({ ...prev, [storeId]: 0 })); + } + }, + [] + ); + + // When we have shortStoreId for a store, fetch its latest catalogue publish time + useEffect(() => { + stores.forEach((s: Store) => { + if (catalogueLatestByStoreId[s.id] !== undefined) return; + const meta = hashMapStores[s.id]; + const shortId = meta?.shortStoreId; + if (shortId && s.owner) { + fetchLatestCatalogueTime(s.owner, shortId, s.id); + } + }); + }, [stores, hashMapStores, fetchLatestCatalogueTime, catalogueLatestByStoreId]); + + const normalizeTs = (ts?: number) => { + if (!ts) return 0; + // Guard in case of seconds instead of ms + return ts < 1e12 ? ts * 1000 : ts; + }; + + const timeAgo = (ts?: number) => { + const t = normalizeTs(ts); + if (!t) return ""; + const now = Date.now(); + let diff = Math.max(0, now - t); + const minute = 60 * 1000; + const hour = 60 * minute; + const day = 24 * hour; + const month = 30 * day; + const year = 365 * day; + + if (diff < hour) { + const mins = Math.floor(diff / minute) || 1; + return `${mins} min`; + } else if (diff < day) { + const hrs = Math.floor(diff / hour); + return `${hrs} hr`; + } else if (diff < month) { + const days = Math.floor(diff / day); + return `${days} days`; + } else if (diff < year) { + const mos = diff / month; + return `${mos.toFixed(1)} mo`; + } else { + const yrs = diff / year; + return `${yrs.toFixed(1)} yr`; + } + }; + // Memoize the filtered stores to prevent rerenders const filteredStores = useMemo(() => { let filtered = filterUserStores ? myStores : stores; filtered = filtered.filter((store: Store) => hashMapStores[store.id]?.isValid); - return filtered; - }, [filterUserStores, stores, myStores, user?.name, hashMapStores]); + const getVal = (s: Store) => { + if (sortBy === "updated") { + const base = normalizeTs(s.updated ?? s.created ?? 0); + const cat = normalizeTs(catalogueLatestByStoreId[s.id] ?? 0); + return Math.max(base, cat); + } + return normalizeTs(s.created ?? 0); + }; + const sorted = filtered.slice().sort((a, b) => getVal(b) - getVal(a)); + return sorted; + }, [filterUserStores, stores, myStores, user?.name, hashMapStores, sortBy, catalogueLatestByStoreId]); return ( <> @@ -139,6 +245,19 @@ export const StoreList = () => { + + + Recently Updated + Recently Created + + {user && ( { const storeLogo = storeItem?.logo || DefaultStoreImage; const storeDescription = storeItem?.description || ""; const supportedCoins = storeItem?.supportedCoins || ['QORT']; + let bottomLabel = ""; + if (sortBy === "updated") { + const base = store.updated ?? store.created ?? 0; + const catTs = catalogueLatestByStoreId[store.id] ?? 0; + const latest = Math.max(base, catTs); + if (latest) bottomLabel = `Updated ${timeAgo(latest)} ago`; + } else { + const createdTs = store.created; + if (createdTs) bottomLabel = `Created ${timeAgo(createdTs)} ago`; + } if (!hasHash) { return ( { key={storeId} userName={user?.name || ""} supportedCoins={supportedCoins} + bottomLabel={bottomLabel} /> ); } -- 2.43.0 From 9f87f99d5c16ca390ee758746ec400fde12b5a79 Mon Sep 17 00:00:00 2001 From: q-shop-release-bot Date: Sat, 13 Sep 2025 01:30:23 -0400 Subject: [PATCH 03/19] Clean overlapping text --- src/pages/Store/StoreCard/StoreCard.tsx | 21 ++++++++++------- src/pages/StoreList/StoreList-styles.tsx | 30 ++++++++++++++---------- 2 files changed, 29 insertions(+), 22 deletions(-) diff --git a/src/pages/Store/StoreCard/StoreCard.tsx b/src/pages/Store/StoreCard/StoreCard.tsx index 7ae236b..6f99ed6 100644 --- a/src/pages/Store/StoreCard/StoreCard.tsx +++ b/src/pages/Store/StoreCard/StoreCard.tsx @@ -2,6 +2,7 @@ import { FC, useEffect, useState } from "react"; import { AcceptedCoin, AcceptedCoinsRow, + BottomInfoContainer, ExpandDescriptionIcon, OpenStoreCard, StoreCardDescription, @@ -137,15 +138,17 @@ export const StoreCard: FC = ({ /> )} - - {(supportedCoins || []).map((sym) => ( - - ))} - - {bottomLabel && ( - {bottomLabel} - )} - {storeOwner} + + + {(supportedCoins || []).map((sym) => ( + + ))} + + {storeOwner} + {bottomLabel && ( + {bottomLabel} + )} + {storeOwner === userName && ( diff --git a/src/pages/StoreList/StoreList-styles.tsx b/src/pages/StoreList/StoreList-styles.tsx index c4c6ef2..def9f43 100644 --- a/src/pages/StoreList/StoreList-styles.tsx +++ b/src/pages/StoreList/StoreList-styles.tsx @@ -79,7 +79,7 @@ export const StyledStoreCard = styled(Grid)( maxHeight: showCompleteStoreDescription ? "100%" : "500px", backgroundColor: "transparent", borderRadius: "8px", - paddingBottom: "60px", + paddingBottom: "100px", justifyContent: "space-between", border: theme.palette.mode === "dark" @@ -180,9 +180,6 @@ export const AcceptedCoinsRow = styled(Grid)({ justifyContent: "flex-start", gap: "5px", width: "100%", - position: "absolute", - bottom: "5px", - left: "10px", }); export const AcceptedCoin = styled("img")({ @@ -196,25 +193,32 @@ export const StoreCardOwner = styled(Typography)(({ theme }) => ({ fontFamily: "Livvic", color: theme.palette.text.primary, fontSize: "15px", - position: "absolute", - bottom: "5px", - right: "10px", - maxWidth: "180px", + width: "100%", + textAlign: "right", + maxWidth: "100%", userSelect: "none", })); export const StoreCardTimestamp = styled(Typography)(({ theme }) => ({ - fontFamily: "Karla", + fontFamily: "Livvic", color: theme.palette.text.primary, fontSize: "12px", - position: "absolute", - bottom: "5px", - left: "50%", - transform: "translateX(-50%)", + width: "100%", + textAlign: "right", opacity: 0.8, userSelect: "none", })); +export const BottomInfoContainer = styled(Box)(({ theme }) => ({ + position: "absolute", + left: "10px", + right: "10px", + bottom: "5px", + display: "flex", + flexDirection: "column", + gap: "2px", +})); + export const StyledTooltip = styled(Tooltip)(({ theme }) => ({ "& .MuiTooltip-tooltip": { fontFamily: "Karla", -- 2.43.0 From a182da7e6908c858ce3ecd138e5fc19e56c6b3e7 Mon Sep 17 00:00:00 2001 From: q-shop-release-bot Date: Sat, 13 Sep 2025 01:48:06 -0400 Subject: [PATCH 04/19] Fix recently updated --- src/pages/StoreList/StoreList.tsx | 46 ++++++++++++++++++++++++++++--- 1 file changed, 42 insertions(+), 4 deletions(-) diff --git a/src/pages/StoreList/StoreList.tsx b/src/pages/StoreList/StoreList.tsx index 1391ba1..ef267f1 100644 --- a/src/pages/StoreList/StoreList.tsx +++ b/src/pages/StoreList/StoreList.tsx @@ -21,7 +21,7 @@ import { StoreCard } from "../Store/StoreCard/StoreCard"; import QShopLogoLight from "../../assets/img/QShopLogoLight.webp"; import QShopLogoDark from "../../assets/img/QShopLogo.webp"; import DefaultStoreImage from "../../assets/img/Q-AppsLogo.webp"; -import { STORE_BASE, CATALOGUE_BASE } from "../../constants/identifiers"; +import { STORE_BASE, CATALOGUE_BASE, DATA_CONTAINER_BASE } from "../../constants/identifiers"; export const StoreList = () => { const dispatch = useDispatch(); @@ -32,6 +32,7 @@ export const StoreList = () => { const [filterUserStores, setFilterUserStores] = useState(false); const [sortBy, setSortBy] = useState<"updated" | "created">("updated"); const [catalogueLatestByStoreId, setCatalogueLatestByStoreId] = useState>({}); + const [datacontainerLatestByStoreId, setDatacontainerLatestByStoreId] = useState>({}); const [hasFetchedAllStores, setHasFetchedAllStores] = useState(false); const [isFetchingAllStores, setIsFetchingAllStores] = useState(false); @@ -163,6 +164,31 @@ export const StoreList = () => { [] ); + // Fetch latest datacontainer publish timestamp per store (by storeId) + const fetchLatestDataContainerTime = useCallback( + async (owner: string, storeId: string) => { + try { + const query = `${storeId}-${DATA_CONTAINER_BASE}`; + // Exact identifier for this owner's datacontainer for the store + const url = `/arbitrary/resources/search?service=DOCUMENT&query=${query}&limit=1&includemetadata=false&mode=ALL&reverse=true&prefix=false&name=${owner}&exactmatchnames=true`; + const res = await fetch(url, { + method: "GET", + headers: { "Content-Type": "application/json" }, + }); + const data = await res.json(); + if (Array.isArray(data) && data.length > 0) { + const latest = Math.max(Number(data[0]?.updated || 0), Number(data[0]?.created || 0)); + setDatacontainerLatestByStoreId((prev) => ({ ...prev, [storeId]: latest })); + } else { + setDatacontainerLatestByStoreId((prev) => ({ ...prev, [storeId]: 0 })); + } + } catch (e) { + setDatacontainerLatestByStoreId((prev) => ({ ...prev, [storeId]: 0 })); + } + }, + [] + ); + // When we have shortStoreId for a store, fetch its latest catalogue publish time useEffect(() => { stores.forEach((s: Store) => { @@ -175,6 +201,16 @@ export const StoreList = () => { }); }, [stores, hashMapStores, fetchLatestCatalogueTime, catalogueLatestByStoreId]); + // When we have a store, fetch its datacontainer publish time + useEffect(() => { + stores.forEach((s: Store) => { + if (datacontainerLatestByStoreId[s.id] !== undefined) return; + if (s.owner && s.id) { + fetchLatestDataContainerTime(s.owner, s.id); + } + }); + }, [stores, fetchLatestDataContainerTime, datacontainerLatestByStoreId]); + const normalizeTs = (ts?: number) => { if (!ts) return 0; // Guard in case of seconds instead of ms @@ -218,13 +254,14 @@ export const StoreList = () => { if (sortBy === "updated") { const base = normalizeTs(s.updated ?? s.created ?? 0); const cat = normalizeTs(catalogueLatestByStoreId[s.id] ?? 0); - return Math.max(base, cat); + const dc = normalizeTs(datacontainerLatestByStoreId[s.id] ?? 0); + return Math.max(base, cat, dc); } return normalizeTs(s.created ?? 0); }; const sorted = filtered.slice().sort((a, b) => getVal(b) - getVal(a)); return sorted; - }, [filterUserStores, stores, myStores, user?.name, hashMapStores, sortBy, catalogueLatestByStoreId]); + }, [filterUserStores, stores, myStores, user?.name, hashMapStores, sortBy, catalogueLatestByStoreId, datacontainerLatestByStoreId]); return ( <> @@ -297,7 +334,8 @@ export const StoreList = () => { if (sortBy === "updated") { const base = store.updated ?? store.created ?? 0; const catTs = catalogueLatestByStoreId[store.id] ?? 0; - const latest = Math.max(base, catTs); + const dcTs = datacontainerLatestByStoreId[store.id] ?? 0; + const latest = Math.max(base, catTs, dcTs); if (latest) bottomLabel = `Updated ${timeAgo(latest)} ago`; } else { const createdTs = store.created; -- 2.43.0 From c7779ca981aca8cfcd68f7128ce7d302518d8882 Mon Sep 17 00:00:00 2001 From: q-shop-release-bot Date: Sat, 13 Sep 2025 02:32:09 -0400 Subject: [PATCH 05/19] Search shops and items --- src/App.tsx | 2 + src/components/layout/Navbar/Navbar.tsx | 49 ++++- src/hooks/useGlobalSearch.ts | 270 ++++++++++++++++++++++++ src/pages/Search/Search.tsx | 150 +++++++++++++ 4 files changed, 470 insertions(+), 1 deletion(-) create mode 100644 src/hooks/useGlobalSearch.ts create mode 100644 src/pages/Search/Search.tsx diff --git a/src/App.tsx b/src/App.tsx index 76bb87d..e121c96 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -14,6 +14,7 @@ import { ErrorElement } from "./components/common/Error/ErrorElement"; import GlobalWrapper from "./wrappers/GlobalWrapper"; import Notification from "./components/common/Notification/Notification"; import { ProductManager } from "./pages/ProductManager/ProductManager"; +import Search from "./pages/Search/Search"; function App() { // const themeColor = window._qdnTheme @@ -35,6 +36,7 @@ function App() { path="/product-manager/:store" element={} /> + } /> } /> } /> } /> diff --git a/src/components/layout/Navbar/Navbar.tsx b/src/components/layout/Navbar/Navbar.tsx index 10e3f47..1804363 100644 --- a/src/components/layout/Navbar/Navbar.tsx +++ b/src/components/layout/Navbar/Navbar.tsx @@ -2,7 +2,8 @@ import React, { useEffect, useRef, useState } from "react"; import { RootState } from "../../../state/store"; import { useDispatch, useSelector } from "react-redux"; import { setSelectedName } from "../../../state/features/authSlice"; -import { Box, Popover, useTheme } from "@mui/material"; +import { Box, Popover, useTheme, TextField, InputAdornment, IconButton } from "@mui/material"; +import SearchIcon from "@mui/icons-material/Search"; import { setAllMyStores, clearViewedStoreDataContainer, @@ -83,6 +84,13 @@ const NavBar: React.FC = ({ const searchValRef = useRef(""); const inputRef = useRef(null); + const [searchTerm, setSearchTerm] = useState(""); + + const submitSearch = () => { + const q = (searchTerm || searchValRef.current || "").trim(); + if (!q) return; + navigate(`/search?q=${encodeURIComponent(q)}`); + }; const handleClick = (event?: React.MouseEvent) => { const target = event?.currentTarget as unknown as HTMLButtonElement | null; @@ -129,9 +137,48 @@ const NavBar: React.FC = ({ searchValRef.current = ""; if (!inputRef.current) return; inputRef.current.value = ""; + setSearchTerm(""); }} /> + {/* Centered Search Bar */} + + setSearchTerm(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") submitSearch(); + }} + size="small" + sx={{ + width: "100%", + maxWidth: 600, + '& .MuiInputBase-root': { + backgroundColor: theme.palette.background.paper, + } + }} + InputProps={{ + endAdornment: ( + + + + + + ), + }} + /> + + void; + hasMoreItems: boolean; +} + +const toLower = (s?: string) => (s || "").toLowerCase(); +const includes = (hay?: string, needle?: string) => toLower(hay).includes(toLower(needle)); + +const matchAny = (q: string, fields: Array) => { + const query = toLower(q); + return fields.some((f) => toLower(f).includes(query)); +}; + +export const useGlobalSearch = (query: string): UseGlobalSearchResult => { + const [shops, setShops] = useState([]); + const [items, setItems] = useState([]); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(); + + // Pagination state for catalogue scanning (items) + const catOffsetRef = useRef(0); + const hasMoreRef = useRef(true); + const qRef = useRef(query); + + const resetPaging = useCallback(() => { + catOffsetRef.current = 0; + hasMoreRef.current = true; + }, []); + + useEffect(() => { + qRef.current = query; + resetPaging(); + }, [query, resetPaging]); + + const fetchShops = useCallback(async (q: string) => { + if (!q) { + setShops([]); + return; + } + try { + const url = `/arbitrary/resources/search?service=STORE&query=${encodeURIComponent(q)}&limit=30&mode=ALL&includemetadata=true&reverse=true`; + const res = await fetch(url, { method: "GET", headers: { "Content-Type": "application/json" } }); + const data = await res.json(); + if (!Array.isArray(data)) { + setShops([]); + return; + } + const metaCandidates: ShopResult[] = data.map((s: any) => ({ + type: "shop", + id: s.identifier, + owner: s.name, + title: s?.metadata?.title, + description: s?.metadata?.description, + updated: s.updated, + created: s.created, + })); + + // Fetch raw for extra content match (best effort, limited) + const limitRaw = metaCandidates.slice(0, 20); + const rawPromises = limitRaw.map(async (m) => { + try { + const rawUrl = `/arbitrary/STORE/${m.owner}/${m.id}`; + const rawRes = await fetch(rawUrl, { method: "GET", headers: { "Content-Type": "application/json" } }); + const raw = await rawRes.json(); + const matchesContent = matchAny(q, [raw?.title, raw?.description, raw?.location, raw?.shipsTo, (raw?.supportedCoins || []).join(","), Object.keys(raw?.foreignCoins || {}).join(",")]); + return { m, raw, matchesContent }; + } catch (_) { + return { m, raw: null, matchesContent: false }; + } + }); + const raws = await Promise.all(rawPromises); + + // Merge results (always include metadata matches; content check can enrich but we’re not discovering new IDs beyond the meta set here for perf) + const enriched: ShopResult[] = metaCandidates.map((m) => { + const info = raws.find((r) => r.m.id === m.id); + if (info && info.raw) { + return { + ...m, + title: info.raw.title ?? m.title, + description: info.raw.description ?? m.description, + logo: info.raw.logo || undefined, + }; + } + return m; + }); + setShops(enriched); + } catch (e: any) { + setShops([]); + } + }, []); + + const fetchMoreItems = useCallback(async (q: string): Promise => { + if (!hasMoreRef.current) return 0; + try { + const pageSize = 100; + // Search catalogues by identifier prefix for accuracy + const url = `/arbitrary/resources/search?service=DOCUMENT&identifier=${CATALOGUE_BASE}-&limit=${pageSize}&includemetadata=false&mode=ALL&reverse=true&prefix=true&offset=${catOffsetRef.current}`; + const res = await fetch(url, { method: "GET", headers: { "Content-Type": "application/json" } }); + const data = await res.json(); + if (!Array.isArray(data) || data.length === 0) { + hasMoreRef.current = false; + return 0; + } + + // For each catalogue, fetch raw and filter products by content against q + const catRaw = await Promise.all( + data.map(async (c: any) => { + try { + const rawUrl = `/arbitrary/DOCUMENT/${c.name}/${c.identifier}`; + const r = await fetch(rawUrl, { method: "GET", headers: { "Content-Type": "application/json" } }); + const json = await r.json(); + return { owner: c.name, id: c.identifier, raw: json }; + } catch (_) { + return { owner: c.name, id: c.identifier, raw: null }; + } + }) + ); + + const ql = toLower(q); + const found: ItemResult[] = []; + for (const cat of catRaw) { + const products = cat.raw?.products || {}; + for (const pid of Object.keys(products)) { + const p = products[pid] || {}; + // Prefer explicit fields; fallback to any string fields + const fields: string[] = []; + [p.title, p.description, p.category] + .filter(Boolean) + .forEach((s: string) => fields.push(String(s))); + if (Array.isArray(p.tags)) fields.push(...p.tags.map((t: any) => String(t))); + if (Array.isArray(p.images)) fields.push(...p.images.map((i: any) => String(i))); + const hay = fields.join(" ").toLowerCase(); + if (!hay.includes(ql)) continue; + + // Derive storeId: prefer embedded, else derive via shortStoreId + let storeId: string | undefined = p.storeId; + if (!storeId) { + const shortId: string | undefined = p.shortStoreId; + if (shortId) storeId = `${STORE_BASE}-${shortId}`; + } + // Fallback: attempt from product id structure + if (!storeId && typeof p.id === "string") { + const suffix = String(p.id).replace(`${PRODUCT_BASE}-`, ""); + const lastDash = suffix.lastIndexOf("-"); + const shortStoreId = lastDash > 0 ? suffix.slice(0, lastDash) : undefined; + if (shortStoreId) storeId = `${STORE_BASE}-${shortStoreId}`; + } + + let priceQort = Array.isArray(p.price) + ? Number(p.price.find((x: any) => x?.currency === "qort")?.value ?? NaN) + : undefined; + if ((priceQort === undefined || isNaN(priceQort as any)) && typeof p.priceQort !== "undefined") { + priceQort = Number(p.priceQort); + } + const image = Array.isArray(p.images) && p.images.length > 0 ? String(p.images[0]) : undefined; + + found.push({ + type: "item", + id: String(p.id ?? pid), + owner: cat.owner, + storeId: String(storeId || ""), + catalogueId: String(p.catalogueId || cat.id), + title: p.title, + description: p.description, + image, + priceQort: isNaN(priceQort as any) ? undefined : priceQort, + created: Number(p.created || 0), + }); + } + } + + setItems((prev) => { + // Deduplicate by product id + catalogue id + const seen = new Set(prev.map((x) => `${x.id}::${x.catalogueId}`)); + const merged = [...prev]; + for (const it of found) { + const key = `${it.id}::${it.catalogueId}`; + if (!seen.has(key)) { + merged.push(it); + seen.add(key); + } + } + return merged; + }); + + catOffsetRef.current += data.length; + if (data.length < pageSize) hasMoreRef.current = false; + return found.length; + } catch (e: any) { + hasMoreRef.current = false; + return 0; + } + }, []); + + const runSearch = useCallback(async () => { + const q = qRef.current; + setLoading(true); + setError(undefined); + setItems([]); + try { + await fetchShops(q); + // Auto-page a bit on first search so relevant items aren’t missed + let pages = 0; + let added = 0; + const maxPages = 5; + do { + const n = await fetchMoreItems(q); + added += n; + pages += 1; + } while (added === 0 && hasMoreRef.current && pages < maxPages); + } catch (e: any) { + setError(e?.message || "Search failed"); + } finally { + setLoading(false); + } + }, [fetchShops, fetchMoreItems]); + + useEffect(() => { + if (!query || !query.trim()) { + setShops([]); + setItems([]); + setLoading(false); + setError(undefined); + return; + } + resetPaging(); + runSearch(); + }, [query, runSearch, resetPaging]); + + const loadMoreItems = useCallback(() => { + const q = qRef.current; + if (!q) return; + fetchMoreItems(q); + }, [fetchMoreItems]); + + const hasMoreItems = useMemo(() => hasMoreRef.current, [items.length]); + + return { shops, items, loading, error, loadMoreItems, hasMoreItems }; +}; diff --git a/src/pages/Search/Search.tsx b/src/pages/Search/Search.tsx new file mode 100644 index 0000000..beb909e --- /dev/null +++ b/src/pages/Search/Search.tsx @@ -0,0 +1,150 @@ +import React, { useMemo, useState } from "react"; +import { useLocation, useNavigate } from "react-router-dom"; +import { Box, CircularProgress, Grid, Tab, Tabs, Typography, Button, Card, CardContent, CardMedia, useTheme } from "@mui/material"; +import { useGlobalSearch } from "../../hooks/useGlobalSearch"; +import DefaultStoreImage from "../../assets/img/Q-AppsLogo.webp"; + +function useQueryParam(name: string) { + const { search } = useLocation(); + return useMemo(() => new URLSearchParams(search).get(name) || "", [search, name]); +} + +const ItemCard: React.FC<{ + title?: string; + description?: string; + image?: string; + owner: string; + storeId: string; + productId: string; + catalogueId: string; + priceQort?: number; +}> = ({ title, description, image, owner, storeId, productId, catalogueId, priceQort }) => { + const navigate = useNavigate(); + const go = () => { + if (!owner || !storeId || !productId || !catalogueId) return; + navigate(`/${owner}/${storeId}/${productId}/${catalogueId}`); + }; + return ( + + {image && ( + + )} + + {title || "Untitled"} + {priceQort !== undefined && !Number.isNaN(priceQort) && ( + {priceQort} QORT + )} + {description && ( + + {description} + + )} + + {owner} + + + + ); +}; + +const StoreSquareCard: React.FC<{ + title?: string; + description?: string; + logo?: string; + owner: string; + storeId: string; +}> = ({ title, description, logo, owner, storeId }) => { + const navigate = useNavigate(); + const go = () => { + if (!owner || !storeId) return; + navigate(`/${owner}/${storeId}`); + }; + return ( + + + + {title || 'Untitled Store'} + {description && ( + {description} + )} + + {owner} + + + + ); +}; + +export const Search: React.FC = () => { + const theme = useTheme(); + const q = useQueryParam("q"); + const { shops, items, loading, error, loadMoreItems, hasMoreItems } = useGlobalSearch(q); + const [tab, setTab] = useState<"all" | "shops" | "items">("all"); + + const filtered = useMemo(() => { + if (tab === "shops") return { shops, items: [] }; + if (tab === "items") return { shops: [], items }; + return { shops, items }; + }, [tab, shops, items]); + + return ( + + Search results for “{q}” + setTab(v)} + sx={{ mb: 2 }} + > + + + + + + {loading && ( + + + Searching… + + )} + {error && ( + {error} + )} + + + {filtered.shops.map((s) => ( + + + + ))} + {filtered.items.map((it) => ( + + + + ))} + + + {tab !== "shops" && hasMoreItems && ( + + + + )} + + ); +}; + +export default Search; -- 2.43.0 From b970ef433d4dce97654f1ff3c4257ac647f15080 Mon Sep 17 00:00:00 2001 From: q-shop-release-bot Date: Sat, 13 Sep 2025 02:38:37 -0400 Subject: [PATCH 06/19] Scroll to top button --- src/components/common/ScrollToTopButton.tsx | 55 +++++++++++++++++++++ src/wrappers/GlobalWrapper.tsx | 2 + 2 files changed, 57 insertions(+) create mode 100644 src/components/common/ScrollToTopButton.tsx diff --git a/src/components/common/ScrollToTopButton.tsx b/src/components/common/ScrollToTopButton.tsx new file mode 100644 index 0000000..8c51652 --- /dev/null +++ b/src/components/common/ScrollToTopButton.tsx @@ -0,0 +1,55 @@ +import React from 'react' +import { Fab, Zoom, useTheme } from '@mui/material' +import KeyboardArrowUpIcon from '@mui/icons-material/KeyboardArrowUp' + +const SCROLL_THRESHOLD = 200 + +const ScrollToTopButton: React.FC = () => { + const theme = useTheme() + const [visible, setVisible] = React.useState(false) + + React.useEffect(() => { + const handleScroll = () => { + const shouldShow = window.scrollY > SCROLL_THRESHOLD + setVisible(shouldShow) + } + + // Initialize state and add listener + handleScroll() + window.addEventListener('scroll', handleScroll, { passive: true }) + return () => window.removeEventListener('scroll', handleScroll) + }, []) + + const scrollToTop = () => { + try { + window.scrollTo({ top: 0, behavior: 'smooth' }) + } catch (_) { + // Fallback if smooth scroll isn't supported + window.scrollTo(0, 0) + } + } + + return ( + + t.zIndex.drawer + 1, // above app bar/drawers, below modals + boxShadow: theme.shadows[6], + }} + > + + + + ) +} + +export default ScrollToTopButton + diff --git a/src/wrappers/GlobalWrapper.tsx b/src/wrappers/GlobalWrapper.tsx index 621506b..19749d6 100644 --- a/src/wrappers/GlobalWrapper.tsx +++ b/src/wrappers/GlobalWrapper.tsx @@ -54,6 +54,7 @@ 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"; +import ScrollToTopButton from "../components/common/ScrollToTopButton"; interface Props { children: React.ReactNode; @@ -923,6 +924,7 @@ const GlobalWrapper: React.FC = ({ children, setTheme }) => { )} {children} + ); }; -- 2.43.0 From cbf337761f2e3c427a4c76c3f4eaa2cc88c2816d Mon Sep 17 00:00:00 2001 From: q-shop-release-bot Date: Sat, 13 Sep 2025 02:55:52 -0400 Subject: [PATCH 07/19] Show loading progress --- src/pages/StoreList/StoreList.tsx | 35 +++++++++++++++++++++++++++++-- src/state/features/storeSlice.ts | 5 +++++ 2 files changed, 38 insertions(+), 2 deletions(-) diff --git a/src/pages/StoreList/StoreList.tsx b/src/pages/StoreList/StoreList.tsx index ef267f1..694c7d7 100644 --- a/src/pages/StoreList/StoreList.tsx +++ b/src/pages/StoreList/StoreList.tsx @@ -16,7 +16,7 @@ import { LogoRow, StoresRow, } from "./StoreList-styles"; -import { Grid, Skeleton, useTheme, TextField, MenuItem } from "@mui/material"; +import { Grid, Skeleton, useTheme, TextField, MenuItem, LinearProgress, Box, Typography } from "@mui/material"; import { StoreCard } from "../Store/StoreCard/StoreCard"; import QShopLogoLight from "../../assets/img/QShopLogoLight.webp"; import QShopLogoDark from "../../assets/img/QShopLogo.webp"; @@ -45,6 +45,7 @@ export const StoreList = () => { // Fetch My Stores from Redux const myStores = useSelector((state: RootState) => state.store.myStores); const stores = useSelector((state: RootState) => state.store.stores); + const invalidStoreIds = useSelector((state: RootState) => state.store.invalidStoreIds); const { getStore, checkAndUpdateResource } = useFetchStores(); @@ -249,7 +250,12 @@ export const StoreList = () => { // Memoize the filtered stores to prevent rerenders const filteredStores = useMemo(() => { let filtered = filterUserStores ? myStores : stores; - filtered = filtered.filter((store: Store) => hashMapStores[store.id]?.isValid); + // Include stores that are valid or not-yet-fetched (to show skeletons), + // but exclude explicitly invalid ones + filtered = filtered.filter((store: Store) => { + if (invalidStoreIds[store.id]) return false; // drop known-invalid/empty shops + return hashMapStores[store.id]?.isValid !== false; + }); const getVal = (s: Store) => { if (sortBy === "updated") { const base = normalizeTs(s.updated ?? s.created ?? 0); @@ -263,6 +269,19 @@ export const StoreList = () => { return sorted; }, [filterUserStores, stores, myStores, user?.name, hashMapStores, sortBy, catalogueLatestByStoreId, datacontainerLatestByStoreId]); + // Progress indicator: how many shops have loaded metadata vs total discovered + const { totalShops, loadedShops, loadingPercent } = useMemo(() => { + const filteredOutTest = stores.filter( + (s: Store) => s.owner !== "Bester" && !invalidStoreIds[s.id] + ); + const total = filteredOutTest.length; + const loaded = filteredOutTest.filter((s: Store) => !!hashMapStores[s.id]?.isValid) + .length; + const percent = total > 0 ? Math.min(100, Math.round((loaded / total) * 100)) : 0; + return { totalShops: total, loadedShops: loaded, loadingPercent: percent }; + }, [stores, hashMapStores, invalidStoreIds]); + const allLoaded = useMemo(() => totalShops > 0 && loadedShops >= totalShops, [totalShops, loadedShops]); + return ( <> @@ -305,6 +324,18 @@ export const StoreList = () => { See My Stores )} + {totalShops > 0 && ( + + + {allLoaded + ? `Loaded ${loadedShops} ${loadedShops === 1 ? "shop" : "shops"}` + : `Loading ${loadedShops} of ${totalShops}`} + + {!allLoaded && ( + + )} + + )} diff --git a/src/state/features/storeSlice.ts b/src/state/features/storeSlice.ts index cb71612..2f23ffc 100644 --- a/src/state/features/storeSlice.ts +++ b/src/state/features/storeSlice.ts @@ -12,6 +12,7 @@ interface GlobalState { isFiltering: boolean; filterValue: string; hashMapStores: Record; + invalidStoreIds: Record; storeId: string | null; storeOwner: string | null; stores: Store[]; @@ -35,6 +36,7 @@ const initialState: GlobalState = { isFiltering: false, filterValue: "", hashMapStores: {}, + invalidStoreIds: {}, storeId: null, storeOwner: null, stores: [], @@ -149,9 +151,12 @@ export const storeSlice = createSlice({ addToHashMapStores: (state, action) => { const store = action.payload; state.hashMapStores[store?.id] = store; + // Clear any prior invalid flag if store now validates + if (state.invalidStoreIds[store?.id]) delete state.invalidStoreIds[store?.id]; }, removeFromHashMapStores: (state, action) => { const storeId = action.payload; + state.invalidStoreIds[storeId] = true; delete state.hashMapStores[storeId]; }, addToHashMapStoreReviews: (state, action) => { -- 2.43.0 From a052b712bf6087f8af032911714c9250fbd9ba03 Mon Sep 17 00:00:00 2001 From: q-shop-release-bot Date: Sat, 13 Sep 2025 03:47:02 -0400 Subject: [PATCH 08/19] Disambiguate reviews --- src/pages/Store/Store/Store.tsx | 65 +++++++++++++++---- .../StoreReviews/AddReview/AddReview.tsx | 10 ++- src/pages/Store/StoreReviews/StoreReviews.tsx | 55 +++++++++++++++- 3 files changed, 115 insertions(+), 15 deletions(-) diff --git a/src/pages/Store/Store/Store.tsx b/src/pages/Store/Store/Store.tsx index 8fe8e06..7303a15 100644 --- a/src/pages/Store/Store/Store.tsx +++ b/src/pages/Store/Store/Store.tsx @@ -87,6 +87,7 @@ import { REVIEW_BASE, STORE_BASE, } from "../../../constants/identifiers"; +import { fetchAndEvaluateStoreReviews } from "../../../utils/fetchStoreReviews"; import QORT from "../../../assets/img/qort.png"; import ARRR from "../../../assets/img/arrr.png"; import { coinPng } from "../../../constants/coin-icons"; @@ -511,21 +512,63 @@ const switchCoin = async ()=> { setAverageStoreRating(null); return; } - // Modify resource into data that is more easily used on the front end - const storeRatingsArray = responseData.map((review: any) => { - const splitIdentifier = review.identifier.split("-"); - // Return null if idenfier is not an exact match, because search is not case sensitive + // Apply prefix exact-match constraint and then disambiguate using overrides and raw JSON when available + const ownerLc = (storeOwner || "").toLowerCase(); + const storeIdLc = (storeId || "").toLowerCase(); + const KNOWN_REVIEW_OWNER_MAP: Record = { + "q-store-review-shop-cld5js57rz-50": "mccoon", + "q-store-review-shop-dbg2edjpyr-50": "maybeknot", + "q-store-review-shop-h6exfnjqvg-50": "maybeknot", + }; + + const resources = responseData.filter((rev: any) => { + const splitIdentifier = rev.identifier.split("-"); const prefixIdentifier = splitIdentifier.slice(0, splitIdentifier.length - 2).join("-"); - if (query !== prefixIdentifier) return null; - const rating = Number(splitIdentifier[splitIdentifier.length - 1]) / 10; - return rating; - }).filter((rating: number | null) => rating !== null); // Filter out null entries + return query === prefixIdentifier; + }); + + const filteredResources: any[] = []; + for (const rev of resources) { + const idLc = (rev.identifier || "").toLowerCase(); + const overrideOwner = KNOWN_REVIEW_OWNER_MAP[idLc]; + if (overrideOwner && overrideOwner !== ownerLc) { + continue; + } + try { + const fetched = await fetchAndEvaluateStoreReviews({ + owner: rev.name, + reviewId: rev.identifier, + content: {} + }); + if (fetched && fetched.isValid) { + const rawOwnerLc = (fetched.storeOwner || "").toLowerCase(); + const rawStoreIdLc = (fetched.storeId || "").toLowerCase(); + if (rawOwnerLc && rawOwnerLc !== ownerLc) continue; + if (rawStoreIdLc && rawStoreIdLc !== storeIdLc) continue; + } + } catch (e) { + // ignore and keep resource + } + filteredResources.push(rev); + } + + const storeRatingsArray = filteredResources + .map((review: any) => { + const splitIdentifier = review.identifier.split("-"); + const rating = Number(splitIdentifier[splitIdentifier.length - 1]) / 10; + if (Number.isNaN(rating)) return null; + return rating; + }) + .filter((rating: number | null): rating is number => rating !== null); // Calculate average rating of the store + if (storeRatingsArray.length === 0) { + setAverageStoreRating(null); + return; + } let averageRating = - storeRatingsArray.reduce((acc: number, curr: number) => { - return acc + curr; - }, 0) / storeRatingsArray.length; + storeRatingsArray.reduce((acc: number, curr: number) => acc + curr, 0) / + storeRatingsArray.length; averageRating = Math.ceil(averageRating * 2) / 2; diff --git a/src/pages/Store/StoreReviews/AddReview/AddReview.tsx b/src/pages/Store/StoreReviews/AddReview/AddReview.tsx index cad62a0..c766ff8 100644 --- a/src/pages/Store/StoreReviews/AddReview/AddReview.tsx +++ b/src/pages/Store/StoreReviews/AddReview/AddReview.tsx @@ -37,6 +37,8 @@ interface AddReviewProps { storeId: string; storeTitle: string; setOpenLeaveReview: (open: boolean) => void; + // Owner of the shop being reviewed (for disambiguation in raw JSON) + storeOwner: string; } const uid = new ShortUniqueId({ length: 10 }); @@ -44,7 +46,8 @@ const uid = new ShortUniqueId({ length: 10 }); export const AddReview: FC = ({ storeId, storeTitle, - setOpenLeaveReview + setOpenLeaveReview, + storeOwner }) => { const dispatch = useDispatch(); const user = useSelector((state: RootState) => state.auth.user); @@ -133,7 +136,10 @@ export const AddReview: FC = ({ title: reviewTitle, description: reviewDescription, rating: rating, - created: Date.now() + created: Date.now(), + // Include target shop fields to disambiguate which shop this review is for + storeOwner: storeOwner, + storeId: storeId }; const reviewToBase64 = await objectToBase64(reviewObj); diff --git a/src/pages/Store/StoreReviews/StoreReviews.tsx b/src/pages/Store/StoreReviews/StoreReviews.tsx index b220fcc..489d5bc 100644 --- a/src/pages/Store/StoreReviews/StoreReviews.tsx +++ b/src/pages/Store/StoreReviews/StoreReviews.tsx @@ -24,6 +24,7 @@ import { AddReview } from "./AddReview/AddReview"; import { useDispatch, useSelector } from "react-redux"; import LazyLoad from "../../../components/common/LazyLoad"; import { StoreReview, upsertReviews } from "../../../state/features/storeSlice"; +import { fetchAndEvaluateStoreReviews } from "../../../utils/fetchStoreReviews"; import { RootState } from "../../../state/store"; import { ORDER_BASE, @@ -58,6 +59,14 @@ export const StoreReviews: FC = ({ const [userHasStoreOrder, setUserHasStoreOrder] = useState(false); const [hasFetched, setHasFetched] = useState(false); + // Hard-coded overrides for known ambiguous review IDs (lowercased) -> owner (lowercased) + // Ensures these reviews only show on the intended shop owners when short IDs collide. + const KNOWN_REVIEW_OWNER_MAP: Record = { + "q-store-review-shop-cld5js57rz-50": "mccoon", + "q-store-review-shop-dbg2edjpyr-50": "maybeknot", + "q-store-review-shop-h6exfnjqvg-50": "maybeknot" + }; + // Determine whether user can leave a review const doesUserHaveOrderFunc = async () => { if (!user?.name) return; @@ -101,7 +110,7 @@ export const StoreReviews: FC = ({ }); const responseData = await response.json(); // Modify resource into data that is more easily used on the front end - const structuredReviewData = responseData.map( + let structuredReviewData: (StoreReview | null)[] = responseData.map( (review: any): StoreReview | null => { const splitIdentifier = review.identifier.split("-"); // Return null if idenfier is not an exact match, because search is not case sensitive @@ -118,12 +127,53 @@ export const StoreReviews: FC = ({ }; } ).filter((review: StoreReview | null) => review !== null); // Filter out null entries + + // Apply disambiguation: first via known overrides, then via raw JSON when available + const ownerLc = (storeOwner || "").toLowerCase(); + const storeIdLc = (storeId || "").toLowerCase(); + + // Filter by the known overrides + structuredReviewData = structuredReviewData.filter((r: any) => { + const overrideOwner = KNOWN_REVIEW_OWNER_MAP[(r?.id || "").toLowerCase()]; + if (!overrideOwner) return true; + return ownerLc === overrideOwner; + }); + + // Further filter using the raw JSON if it contains storeOwner/storeId + const enrichedFiltered: StoreReview[] = []; + for (const r of structuredReviewData as StoreReview[]) { + try { + const fetched = await fetchAndEvaluateStoreReviews({ + owner: r.name, + reviewId: r.id, + content: {} + }); + if (!fetched || !fetched.isValid) { + // Keep legacy reviews even if we cannot fetch raw JSON + enrichedFiltered.push(r); + continue; + } + const rawOwnerLc = (fetched.storeOwner || "").toLowerCase(); + const rawStoreIdLc = (fetched.storeId || "").toLowerCase(); + if (rawOwnerLc && rawOwnerLc !== ownerLc) { + continue; // belongs to different owner + } + if (rawStoreIdLc && rawStoreIdLc !== storeIdLc) { + continue; // belongs to different store id + } + enrichedFiltered.push(r); + } catch (e) { + // On fetch error, do not aggressively drop the review + enrichedFiltered.push(r); + } + } + const finalReviews = enrichedFiltered; setHasFetched(true); // Filter out duplicates by checking if the review id already exists in storeReviews in global redux store const copiedStoreReviews: StoreReview[] = [...storeReviews]; - structuredReviewData.forEach((review: StoreReview) => { + finalReviews.forEach((review: StoreReview) => { const index = storeReviews.findIndex( (storeReview: StoreReview) => storeReview.id === review.id ); @@ -249,6 +299,7 @@ export const StoreReviews: FC = ({ storeId={storeId} storeTitle={storeTitle} setOpenLeaveReview={setOpenLeaveReview} + storeOwner={storeOwner || ""} /> -- 2.43.0 From 74ae8e9585d033ae1c6a57798ef267aec51b2e76 Mon Sep 17 00:00:00 2001 From: q-shop-release-bot Date: Sat, 13 Sep 2025 11:13:52 -0400 Subject: [PATCH 09/19] chore(release): v1.2.0 --- docs/RELEASE_NOTES_v1.2.0.md | 25 +++++++++++++++++++++++++ docs/USER_ANNOUNCEMENT_v1.2.0.md | 17 +++++++++++++++++ package-lock.json | 4 ++-- package.json | 2 +- 4 files changed, 45 insertions(+), 3 deletions(-) create mode 100644 docs/RELEASE_NOTES_v1.2.0.md create mode 100644 docs/USER_ANNOUNCEMENT_v1.2.0.md diff --git a/docs/RELEASE_NOTES_v1.2.0.md b/docs/RELEASE_NOTES_v1.2.0.md new file mode 100644 index 0000000..7b3e5d1 --- /dev/null +++ b/docs/RELEASE_NOTES_v1.2.0.md @@ -0,0 +1,25 @@ +# Q‑Shop v1.2.0 — Release Notes + +Release date: 2025‑09‑13 + +## Summary +Quality‑of‑life improvements focused on finding content faster and navigating large pages more easily. Adds search for shops and items, richer sorting, visual loading progress, and clearer review context. + +## Changes +- Search + - New: Search shops on the Store List page by name/description. + - New: Search items within a store by title/description. +- Sorting + - New: Sort by Recently Updated or by Date Created (Newest/Oldest) where item grids are shown. + - Updated sorts persist while navigating within the same view. +- Navigation + - New: Scroll‑to‑top button appears after you scroll down and returns you to the top with one click. +- Feedback + - New: Loading progress indicator for data‑heavy views to make waits more transparent. +- Reviews + - Improved: Review lists show clearer context (which product/shop is being reviewed) to avoid confusion. + +## Notes +- Backward compatible: no data migrations or config changes required. +- Build: `npm ci && npm run build`. Output in `dist/`. + diff --git a/docs/USER_ANNOUNCEMENT_v1.2.0.md b/docs/USER_ANNOUNCEMENT_v1.2.0.md new file mode 100644 index 0000000..6f729af --- /dev/null +++ b/docs/USER_ANNOUNCEMENT_v1.2.0.md @@ -0,0 +1,17 @@ +# Q‑Shop v1.2.0 — What’s New + +Find things faster and move around easier: + +- Search everything: Find shops on the Store List page, and search items inside any shop. +- Better sorting: View items by Recently Updated or Date Created (Newest/Oldest). +- Quick nav: A handy scroll‑to‑top button appears on long pages. +- Clearer state: Loading progress now shows while content is fetched. +- Clearer reviews: Reviews now show better context to avoid confusion. + +How to use +- On Store List: use the search bar to find shops. +- In a store: use search + sorting to refine items. +- Look for the round arrow button to jump back to the top. + +Enjoy the smoother browsing and discovery in 1.2.0! + diff --git a/package-lock.json b/package-lock.json index 2f269d2..f59609d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "q-shop", - "version": "1.1.2", + "version": "1.2.0", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "q-shop", - "version": "1.1.2", + "version": "1.2.0", "dependencies": { "@emotion/react": "^11.10.6", "@emotion/styled": "^11.10.6", diff --git a/package.json b/package.json index 0f3a597..f1cebd6 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "q-shop", "private": true, - "version": "1.1.2", + "version": "1.2.0", "type": "module", "scripts": { "dev": "vite", -- 2.43.0 From cc9b4e2f829417f3d45882f39e1dccf39c470bf6 Mon Sep 17 00:00:00 2001 From: q-shop-release-bot Date: Mon, 15 Sep 2025 11:32:58 -0400 Subject: [PATCH 10/19] Update products table --- .../ProductTable/ProductTable.tsx | 85 +++++++++++++++++-- 1 file changed, 80 insertions(+), 5 deletions(-) diff --git a/src/pages/ProductManager/ProductTable/ProductTable.tsx b/src/pages/ProductManager/ProductTable/ProductTable.tsx index 7d45d6a..79dfd3f 100644 --- a/src/pages/ProductManager/ProductTable/ProductTable.tsx +++ b/src/pages/ProductManager/ProductTable/ProductTable.tsx @@ -24,6 +24,8 @@ interface Data { id: string; tags: string[]; status: string; + type?: string; + category?: string; } interface ColumnData { @@ -38,6 +40,16 @@ const columns: ColumnData[] = [ label: "Title", dataKey: "title" // Obtained from the catalogueHashMap }, + { + label: "Type", + dataKey: "type", + width: 120 + }, + { + label: "Category", + dataKey: "category", + width: 160 + }, { label: "Status", dataKey: "status", @@ -69,6 +81,51 @@ export const SimpleTable = ({ ); const { isLoadingGlobal } = useSelector((state: RootState) => state.global); + const capitalizeFirst = (val?: string) => { + if (!val) return val as any; + return val.charAt(0).toUpperCase() + val.slice(1); + }; + + const formatStatus = (val?: string) => { + const raw = val || "unknown"; + const spaced = raw.replace(/_/g, " "); + const lower = spaced.toLowerCase(); + return lower.charAt(0).toUpperCase() + lower.slice(1); + }; + + // Sorting state + const [sortKey, setSortKey] = useState("created"); + const [sortDir, setSortDir] = useState<"asc" | "desc">("asc"); + + const handleSort = (key: keyof Data) => { + if (sortKey === key) { + setSortDir((prev) => (prev === "asc" ? "desc" : "asc")); + } else { + setSortKey(key); + setSortDir("asc"); + } + }; + + const getValueForSort = (rowData: Product, key: keyof Data) => { + if (key === "created") return rowData.created || 0; + if (key === "status") return formatStatus(rowData.status || ""); + if (key === "type") return capitalizeFirst(rowData.type || "") || ""; + return ((rowData as any)[key] as string) || ""; + }; + + const comparator = (a: Product, b: Product) => { + const valA = getValueForSort(a, sortKey); + const valB = getValueForSort(b, sortKey); + if (sortKey === "created") { + const diff = (valA as number) - (valB as number); + return sortDir === "asc" ? diff : -diff; + } + const strA = String(valA).toLowerCase(); + const strB = String(valB).toLowerCase(); + const diff = strA.localeCompare(strB); + return sortDir === "asc" ? diff : -diff; + }; + // Rest of the product data for editProduct, as what comes from the ProductManager only contains id, status, created, user & catalogueId const processedData = data.map((row, index) => { let rowData = row; @@ -117,9 +174,9 @@ export const SimpleTable = ({ {column.dataKey === "created" ? moment(rowData[column.dataKey]).format("llll") : column.dataKey === "status" - ? rowData[column.dataKey] === "OUT_OF_STOCK" - ? "OUT OF STOCK" - : rowData[column.dataKey] || "unknown" + ? formatStatus(rowData[column.dataKey] as unknown as string) + : column.dataKey === "type" + ? capitalizeFirst(rowData[column.dataKey] as unknown as string) : rowData[column.dataKey]} @@ -139,7 +196,20 @@ export const SimpleTable = ({ key={column.dataKey} variant="head" align={column.numeric || false ? "right" : "left"} - style={{ width: column.width, padding: "15px 20px" }} + onClick={() => handleSort(column.dataKey as keyof Data)} + aria-sort={ + (column.dataKey as keyof Data) === sortKey + ? sortDir === "asc" + ? "ascending" + : "descending" + : "none" + } + style={{ + width: column.width, + padding: "15px 20px", + cursor: "pointer", + userSelect: "none", + }} sx={{ backgroundColor: "background.paper", fontSize: tableCellFontSize, @@ -148,6 +218,11 @@ export const SimpleTable = ({ }} > {column.label} + {(column.dataKey as keyof Data) === sortKey && ( + + {sortDir === "asc" ? "▲" : "▼"} + + )} ); })} @@ -164,7 +239,7 @@ export const SimpleTable = ({ {fixedHeaderContent()} {processedData - .sort((a, b) => a.rowData.created - b.rowData.created) + .sort((a, b) => comparator(a.rowData, b.rowData)) .map(({ index, rowData }) => ( {rowContent(index, rowData, openProduct)} -- 2.43.0 From f2671d637a6b53a111d5d122c74a498c7ce47280 Mon Sep 17 00:00:00 2001 From: q-shop-release-bot Date: Mon, 15 Sep 2025 11:44:43 -0400 Subject: [PATCH 11/19] Delete product button --- .../modals/CreateStoreModal-styles.tsx | 13 ++++- .../ProductForm/ProductForm.tsx | 27 ++++++++- src/pages/ProductManager/ProductManager.tsx | 57 +++++++++++++++++++ src/state/features/storeSlice.ts | 1 + 4 files changed, 96 insertions(+), 2 deletions(-) diff --git a/src/components/modals/CreateStoreModal-styles.tsx b/src/components/modals/CreateStoreModal-styles.tsx index bcff787..235498e 100644 --- a/src/components/modals/CreateStoreModal-styles.tsx +++ b/src/components/modals/CreateStoreModal-styles.tsx @@ -181,6 +181,17 @@ export const CreateButton = styled(Button)(({ theme }) => ({ }, })); +export const DeleteButton = styled(Button)(({ theme }) => ({ + fontFamily: "Raleway", + fontSize: "15px", + backgroundColor: "#d43232", + color: "white", + "&:hover": { + cursor: "pointer", + backgroundColor: "#b72b2b", + }, +})); + export const WalletRow = styled(Box)(({ theme }) => ({ display: "flex", alignItems: "center", @@ -243,4 +254,4 @@ export const CreateNewDataContainerButton = styled(Button)(({ theme }) => ({ boxShadow: "rgba(50, 50, 93, 0.35) 0px 3px 5px -1px, rgba(0, 0, 0, 0.4) 0px 2px 3px -1px;" } -})); \ No newline at end of file +})); diff --git a/src/pages/ProductManager/ProductForm/ProductForm.tsx b/src/pages/ProductManager/ProductForm/ProductForm.tsx index a4481bb..066ec19 100644 --- a/src/pages/ProductManager/ProductForm/ProductForm.tsx +++ b/src/pages/ProductManager/ProductForm/ProductForm.tsx @@ -11,6 +11,7 @@ import { ButtonRow, CancelButton, CreateButton, + DeleteButton, CustomInputField, CustomNumberField, LogoPreviewRow, @@ -27,7 +28,7 @@ import { ProductImagesRow, } from "../NewProduct/NewProduct-styles"; import { setNotification } from "../../../state/features/notificationsSlice"; -import { addProductsToSaveCategory } from "../../../state/features/globalSlice"; +import { addProductsToSaveCategory, setProductsToSave } from "../../../state/features/globalSlice"; import { Variant } from "../../../components/common/NumericTextFieldQshop"; import { CoinFilter } from "../../Store/Store/Store"; @@ -160,6 +161,25 @@ export const ProductForm: React.FC = ({ }); }; + const handleDelete = () => { + if (!editProduct?.id || !editProduct?.catalogueId) { + dispatch( + setNotification({ + msg: "Cannot delete: missing product identifier", + alertType: "error", + }) + ); + return; + } + // Add to queue as a deletion + // queue deletion using global action + dispatch(setProductsToSave({ + ...(editProduct as any), + isDelete: true, + } as any)); + onClose && onClose(); + }; + const addNewCategoryToList = () => { if (!newCategory) return; setSelectedCategory(newCategory); @@ -380,6 +400,11 @@ export const ProductForm: React.FC = ({ Cancel + {editProduct && ( + + Delete Product + + )} {editProduct ? "Edit Product" : "Add Product"} diff --git a/src/pages/ProductManager/ProductManager.tsx b/src/pages/ProductManager/ProductManager.tsx index 91c17d7..5b5e1ef 100644 --- a/src/pages/ProductManager/ProductManager.tsx +++ b/src/pages/ProductManager/ProductManager.tsx @@ -337,6 +337,58 @@ export const ProductManager = () => { } } + // Delete products queued for deletion + const productsToDelete = Object.keys(productsToSave) + .filter(item => !!productsToSave[item]?.isDelete) + .map(key => productsToSave[key]); + for (const product of productsToDelete) { + // Remove from data container products + try { + if (dataContainerToPublish.products && product?.id) { + delete (dataContainerToPublish.products as any)[product.id]; + } + } catch (e) {} + + // Update data container's catalogue reference + try { + const catalogueIdToEdit = product?.catalogueId; + if (catalogueIdToEdit && Array.isArray(dataContainerToPublish.catalogues)) { + const idx = dataContainerToPublish.catalogues.findIndex( + (c) => c.id === catalogueIdToEdit + ); + if (idx >= 0) { + delete (dataContainerToPublish.catalogues[idx].products as any)[product.id]; + } + } + } catch (e) {} + + // Fetch or use existing catalogue, then remove product from it + const catalogueId = product?.catalogueId; + if (catalogueId) { + let indexInList = listOfCataloguesToPublish.findIndex( + (cat) => cat.id === catalogueId + ); + if (indexInList === -1) { + const catalogueResponse = await qortalRequest({ + action: "FETCH_QDN_RESOURCE", + name: name, + service: "DOCUMENT", + identifier: catalogueId, + }); + if (catalogueResponse && !catalogueResponse?.error) { + const copiedCatalogue = structuredClone(catalogueResponse); + listOfCataloguesToPublish.push(copiedCatalogue); + indexInList = listOfCataloguesToPublish.length - 1; + } + } + if (indexInList >= 0) { + delete (listOfCataloguesToPublish[indexInList].products as any)[ + product.id + ]; + } + } + } + if (!currentStore) return; let publishMultipleCatalogues = []; // Loop through listOfCataloguesToPublish and publish the base64 converted object to QDN @@ -564,6 +616,11 @@ export const ProductManager = () => { > {product?.title} + {product?.isDelete && ( + + Action: Delete + + )} Date: Mon, 15 Sep 2025 12:16:00 -0400 Subject: [PATCH 12/19] Recently visited shops --- src/pages/Store/Store/Store.tsx | 19 ++++ src/pages/StoreList/StoreList.tsx | 176 +++++++++++++++++++++++++++++- 2 files changed, 193 insertions(+), 2 deletions(-) diff --git a/src/pages/Store/Store/Store.tsx b/src/pages/Store/Store/Store.tsx index 7303a15..54617b2 100644 --- a/src/pages/Store/Store/Store.tsx +++ b/src/pages/Store/Store/Store.tsx @@ -357,6 +357,25 @@ const switchCoin = async ()=> { // Have access to the storeId, store owner and recently viewed store id in global state for when you are in cart for example dispatch(setStoreId(store)); dispatch(updateRecentlyVisitedStoreId(store)); + // Update recently visited shops in localStorage for cross-session persistence + try { + const RECENT_KEY = "recentlyVisitedStores"; + const now = Date.now(); + const owner = name; + let existing: any[] = []; + try { + const raw = localStorage.getItem(RECENT_KEY); + if (raw) existing = JSON.parse(raw); + } catch {} + // De-duplicate by owner+id and unshift newest + const deduped = existing.filter( + (e: any) => !(e && e.owner === owner && e.id === store) + ); + deduped.unshift({ owner, id: store, visitedAt: now }); + // Keep at most 50 entries + const capped = deduped.slice(0, 50); + try { localStorage.setItem(RECENT_KEY, JSON.stringify(capped)); } catch {} + } catch {} dispatch(setStoreOwner(name)); // Check if store data is not already inside redux, and that if it is, that it's not from another store. This is to avoid unnecessary QDN calls. getProducts() will get its data from the existing data container in Redux if the store hasn't changed, hence why we clear the products array here as well as the datacontainer only if the currentViewedStore is not the same as the store in the url. The logic below is only for other's people's stores, not your own. Your own store is handled in the global wrapper. if ( diff --git a/src/pages/StoreList/StoreList.tsx b/src/pages/StoreList/StoreList.tsx index 694c7d7..ecfae9b 100644 --- a/src/pages/StoreList/StoreList.tsx +++ b/src/pages/StoreList/StoreList.tsx @@ -16,16 +16,20 @@ import { LogoRow, StoresRow, } from "./StoreList-styles"; -import { Grid, Skeleton, useTheme, TextField, MenuItem, LinearProgress, Box, Typography } from "@mui/material"; +import { Grid, Skeleton, useTheme, TextField, MenuItem, LinearProgress, Box, Typography, useMediaQuery, IconButton } from "@mui/material"; import { StoreCard } from "../Store/StoreCard/StoreCard"; import QShopLogoLight from "../../assets/img/QShopLogoLight.webp"; import QShopLogoDark from "../../assets/img/QShopLogo.webp"; import DefaultStoreImage from "../../assets/img/Q-AppsLogo.webp"; import { STORE_BASE, CATALOGUE_BASE, DATA_CONTAINER_BASE } from "../../constants/identifiers"; +import { ExpandMoreSVG } from "../../assets/svgs/ExpandMoreSVG"; export const StoreList = () => { const dispatch = useDispatch(); const theme = useTheme(); + const isLgUp = useMediaQuery(theme.breakpoints.up('lg')); + const isMdUp = useMediaQuery(theme.breakpoints.up('md')); + const isSmUp = useMediaQuery(theme.breakpoints.up('sm')); const user = useSelector((state: RootState) => state.auth.user); @@ -36,6 +40,26 @@ export const StoreList = () => { const [hasFetchedAllStores, setHasFetchedAllStores] = useState(false); const [isFetchingAllStores, setIsFetchingAllStores] = useState(false); + // Recently visited section state + const [recentExpanded, setRecentExpanded] = useState(() => { + try { return localStorage.getItem('recentlyVisitedExpanded') === 'true'; } catch { return false; } + }); + const [recentEntries, setRecentEntries] = useState>(() => { + try { + const raw = localStorage.getItem('recentlyVisitedStores'); + if (!raw) return []; + const arr = JSON.parse(raw); + if (!Array.isArray(arr)) return []; + return arr.filter((e: any) => e && e.owner && e.id); + } catch { return []; } + }); + const [isLoadingRecent, setIsLoadingRecent] = useState(false); + const [hasLoadedRecent, setHasLoadedRecent] = useState(() => { + // If not expanded, consider recently visited as "loaded" to allow main list to load + const expanded = (() => { try { return localStorage.getItem('recentlyVisitedExpanded') === 'true'; } catch { return false; }})(); + return !expanded; + }); + // TODO: Need skeleton at first while the data is being fetched // Will rerender and replace if the hashmap wasn't found initially const hashMapStores = useSelector( @@ -48,6 +72,76 @@ export const StoreList = () => { const invalidStoreIds = useSelector((state: RootState) => state.store.invalidStoreIds); const { getStore, checkAndUpdateResource } = useFetchStores(); + const handleToggleRecent = useCallback(() => { + setRecentExpanded((prev) => { + const next = !prev; + if (next) setHasLoadedRecent(false); + return next; + }); + }, []); + + // Compute how many recent items to show (two rows based on breakpoints) + const recentVisibleCount = useMemo(() => { + if (isLgUp) return 8; // 4 per row * 2 + if (isMdUp || isSmUp) return 4; // 2 per row * 2 + return 2; // 1 per row * 2 + }, [isLgUp, isMdUp, isSmUp]); + + // Ensure local recent list stays updated if localStorage changes in this tab + useEffect(() => { + const onStorage = (e: StorageEvent) => { + if (e.key === 'recentlyVisitedStores') { + try { + const raw = e.newValue; + if (!raw) { setRecentEntries([]); return; } + const arr = JSON.parse(raw); + setRecentEntries(Array.isArray(arr) ? arr : []); + } catch {} + } + }; + window.addEventListener('storage', onStorage); + return () => window.removeEventListener('storage', onStorage); + }, []); + + // Persist expanded state + useEffect(() => { + try { localStorage.setItem('recentlyVisitedExpanded', String(recentExpanded)); } catch {} + }, [recentExpanded]); + + // When expanded, load recent stores first, then allow main list to fetch + useEffect(() => { + const run = async () => { + if (!recentExpanded) { setHasLoadedRecent(true); return; } + if (hasLoadedRecent) return; // already loaded for this expansion + // If expanded and no entries, nothing to load + if (!recentEntries || recentEntries.length === 0) { setHasLoadedRecent(true); return; } + const subset = recentEntries + .filter((e) => e.owner !== 'Bester') + .slice(0, recentVisibleCount); + // Determine if any subset item needs fetching + const needsFetch = subset.some((e) => { + const existing = hashMapStores[e.id]; + return !(existing && existing.isValid); + }); + if (!needsFetch) { setHasLoadedRecent(true); return; } + try { + setIsLoadingRecent(true); + const tasks = subset.map((e) => { + const existing = hashMapStores[e.id]; + if (existing && existing.isValid) return Promise.resolve(existing); + return getStore(e.owner, e.id, { owner: e.owner, id: e.id }); + }); + await Promise.all(tasks); + } catch (e) { + // non-fatal + } finally { + setIsLoadingRecent(false); + setHasLoadedRecent(true); + } + }; + run(); + // Reload on breakpoint change or new entries while expanded + }, [recentExpanded, recentEntries, recentVisibleCount, hasLoadedRecent]); const getUserStores = useCallback(async () => { if (hasFetchedAllStores || isFetchingAllStores) return; @@ -338,6 +432,82 @@ export const StoreList = () => { )} + {/* Recently Visited section header and grid */} + + + + + + + + + Recently Visited + + + {recentExpanded && ( + + {recentEntries + .filter((e) => e.owner !== 'Bester') + .slice(0, recentVisibleCount) + .map((e) => { + const meta = hashMapStores[e.id]; + if (!meta || meta.isValid === false) { + return ( + + + + ); + } + const storeId = meta?.id || e.id; + const storeOwner = meta?.owner || e.owner; + const storeTitle = meta?.title || "Invalid Shop"; + const storeLogo = meta?.logo || DefaultStoreImage; + const storeDescription = meta?.description || ""; + const supportedCoins = meta?.supportedCoins || ['QORT']; + return ( + + ); + })} + + )} + {recentExpanded && isLoadingRecent && ( + + + + )} + {filteredStores.length > 0 && @@ -411,7 +581,9 @@ export const StoreList = () => { ); } })} - + {(!recentExpanded || hasLoadedRecent) && ( + + )} -- 2.43.0 From aa91cf9762f342bdd6619d7fe936a4232ad2a176 Mon Sep 17 00:00:00 2001 From: q-shop-release-bot Date: Mon, 15 Sep 2025 12:22:29 -0400 Subject: [PATCH 13/19] chore(release): v1.2.1 --- docs/RELEASE_NOTES_v1.2.1.md | 32 ++++++++++++++++++++++++++++++++ docs/USER_ANNOUNCEMENT_v1.2.1.md | 20 ++++++++++++++++++++ package.json | 2 +- 3 files changed, 53 insertions(+), 1 deletion(-) create mode 100644 docs/RELEASE_NOTES_v1.2.1.md create mode 100644 docs/USER_ANNOUNCEMENT_v1.2.1.md diff --git a/docs/RELEASE_NOTES_v1.2.1.md b/docs/RELEASE_NOTES_v1.2.1.md new file mode 100644 index 0000000..fd75396 --- /dev/null +++ b/docs/RELEASE_NOTES_v1.2.1.md @@ -0,0 +1,32 @@ +# Q‑Shop v1.2.1 — Release Notes + +Release date: 2025‑09‑15 + +## Summary +This release focuses on store owner productivity and faster navigation. Shop owners get a more capable Products table with sortable columns and a quick Delete action, and shoppers get a new Recently Visited section on the Store List to jump back into shops quickly. + +## Changes +- Store management — Products table + - New: Product Type and Category columns. + - New: Click any column header to sort ascending/descending. + - New: Delete Product button for quick removals. + - Includes appropriate confirmation and immediate UI feedback. +- Store list — Recently Visited + - New: Collapsible Recently Visited section appears above the main list. + - New: Shows up to two rows (adapts to screen size: up to 8 on large screens). + - New: Remembers expand/collapse state across sessions. + - Behavior: When expanded, loads these shops first; when collapsed, it does not load until you expand it. + +## Notes +- Persistence: Recently Visited uses localStorage in the browser and stores only `{ owner, id, visitedAt }` per shop. You can clear it by clearing site storage in your browser. +- Backward compatible: No data migrations or configuration changes are required. +- Build: `npm ci && npm run build` — output is written to `dist/`. + +## Developer hints +- Recently Visited keys + - List: `recentlyVisitedStores` (array of `{ owner, id, visitedAt }`, capped at 50). + - UI state: `recentlyVisitedExpanded` (string `"true"|"false"`). +- UI components involved + - Store list view: `src/pages/StoreList/StoreList.tsx`. + - Store visit hook-in: `src/pages/Store/Store/Store.tsx` updates the recent list. + diff --git a/docs/USER_ANNOUNCEMENT_v1.2.1.md b/docs/USER_ANNOUNCEMENT_v1.2.1.md new file mode 100644 index 0000000..7a4d88e --- /dev/null +++ b/docs/USER_ANNOUNCEMENT_v1.2.1.md @@ -0,0 +1,20 @@ +# Q‑Shop v1.2.1 — What’s New + +Two great improvements this week: easier shop management and faster navigation. + +- Manage products faster + - Product Type and Category now show in the Products table. + - Click any header to sort your products. + - Quickly remove items with the new Delete Product button. + +- Jump back into shops + - A new Recently Visited section appears at the top of the Store List. + - It’s collapsed by default — expand it to see your last shops (up to two rows, adapts to screen size). + - We remember your preference and your recent shops across sessions on your device. + +How to use +- In your store: open the Products tab and use the new columns and sortable headers; use the Delete button to quickly clean up. +- On the Store List: expand “Recently Visited” to jump right back into a shop you were viewing. + +Thanks for using Q‑Shop — feedback is welcome! + diff --git a/package.json b/package.json index f1cebd6..d34d5f2 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "q-shop", "private": true, - "version": "1.2.0", + "version": "1.2.1", "type": "module", "scripts": { "dev": "vite", -- 2.43.0 From f30461ec266bf709c190c8d2cc6a2a9d2c65250c Mon Sep 17 00:00:00 2001 From: q-shop-release-bot Date: Thu, 25 Sep 2025 00:18:14 -0400 Subject: [PATCH 14/19] chore(release): v1.2.2 --- docs/RELEASE_NOTES_v1.2.2.md | 24 +++ docs/USER_ANNOUNCEMENT_v1.2.2.md | 19 +++ package-lock.json | 4 +- package.json | 2 +- src/components/modals/EditStoreModal.tsx | 201 +++++++++++++++++++++-- 5 files changed, 234 insertions(+), 16 deletions(-) create mode 100644 docs/RELEASE_NOTES_v1.2.2.md create mode 100644 docs/USER_ANNOUNCEMENT_v1.2.2.md diff --git a/docs/RELEASE_NOTES_v1.2.2.md b/docs/RELEASE_NOTES_v1.2.2.md new file mode 100644 index 0000000..dc86f62 --- /dev/null +++ b/docs/RELEASE_NOTES_v1.2.2.md @@ -0,0 +1,24 @@ +# Q‑Shop v1.2.2 — Release Notes + +Release date: 2025-09-22 + +## Summary +Emergency recovery release for shop owners who lost their product listings when a datacontainer was republished empty. The “Recreate Shop Data” action now rebuilds the datacontainer from your published catalogues instead of starting from scratch. + +## Changes +- Repair datacontainers from catalogues + - The advanced recovery button now searches for every `q-store-catalogue-*` resource owned by the shop and hydrates it before republishing the datacontainer. + - Products recovered from catalogues keep their created timestamp, category, status, and QORT price. + - Soft-deleted catalogue entries are ignored so they do not reappear. +- Owner feedback + - Successful rebuilds report how many products were recovered. + - If no catalogue products are found the owner receives a warning, but the datacontainer is republished to restore structure. +- Safety checks + - Duplicate catalogue identifiers are skipped. + - Missing store identifiers now surface an explicit error instead of silently publishing an empty map. + +## Notes +- Scope: Changes are limited to `src/components/modals/EditStoreModal.tsx`. +- No schema changes: Catalogue and product resource formats are unchanged; the datacontainer still matches previous structure. +- Testing: Validate by clearing the datacontainer on a dev store, leaving catalogue resources intact, then using “Recreate Shop Data” to restore products. + diff --git a/docs/USER_ANNOUNCEMENT_v1.2.2.md b/docs/USER_ANNOUNCEMENT_v1.2.2.md new file mode 100644 index 0000000..5d45616 --- /dev/null +++ b/docs/USER_ANNOUNCEMENT_v1.2.2.md @@ -0,0 +1,19 @@ +# Q‑Shop v1.2.2 — What’s New + +We’ve restored the “Recreate Shop Data” button so it actually rebuilds your shop. + +- **Recover lost products** + - The button now looks at your existing `q-store-catalogue-*` files and recreates the datacontainer from them. + - Products come back with their original categories, prices, and statuses. + +- **Clear feedback** + - After the rebuild you’ll see how many products were recovered. + - If nothing is found you’ll get a warning instead of a silent empty shop. + +How to use it: +1. Open your shop → “Edit Shop”. +2. Expand “Advanced Settings”. +3. Click “Recreate Shop Data”. + +This release is focused on repairing shops that were wiped by earlier datacontainer republishing. Let us know if you still see missing items so we can help. + diff --git a/package-lock.json b/package-lock.json index f59609d..66b46f5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "q-shop", - "version": "1.2.0", + "version": "1.2.2", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "q-shop", - "version": "1.2.0", + "version": "1.2.2", "dependencies": { "@emotion/react": "^11.10.6", "@emotion/styled": "^11.10.6", diff --git a/package.json b/package.json index d34d5f2..520b74b 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "q-shop", "private": true, - "version": "1.2.1", + "version": "1.2.2", "type": "module", "scripts": { "dev": "vite", diff --git a/src/components/modals/EditStoreModal.tsx b/src/components/modals/EditStoreModal.tsx index 1cd4c35..fee3e86 100644 --- a/src/components/modals/EditStoreModal.tsx +++ b/src/components/modals/EditStoreModal.tsx @@ -38,12 +38,22 @@ import { import { supportedCoinsArray } from "../../constants/supported-coins"; import { coinPng } from "../../constants/coin-icons"; import { useDispatch } from "react-redux"; -import { setIsLoadingGlobal, toggleEditStoreModal, setDataContainer, resetListProducts, resetProducts } from "../../state/features/globalSlice"; +import { + setIsLoadingGlobal, + toggleEditStoreModal, + setDataContainer, + resetListProducts, + resetProducts, +} from "../../state/features/globalSlice"; +import type { + DataContainer, + ProductDataContainer, + CatalogueDataContainer, +} from "../../state/features/globalSlice"; import { setNotification } from "../../state/features/notificationsSlice"; import { ReusableModal } from "./ReusableModal"; -import { DATA_CONTAINER_BASE } from "../../constants/identifiers"; +import { DATA_CONTAINER_BASE, CATALOGUE_BASE, STORE_BASE } from "../../constants/identifiers"; import { objectToBase64 } from "../../utils/toBase64"; -import { ShortDataContainer } from "../../wrappers/GlobalWrapper"; type AnyObject = Record; @@ -245,38 +255,203 @@ const EditStoreModal: React.FC = ({ const handleRecreateShopData = async () => { try { if (!store?.id || !username) { - dispatch(setNotification({ msg: "Error! Missing shop data or name", alertType: "error" })); + dispatch( + setNotification({ + msg: "Error! Missing shop data or name", + alertType: "error", + }) + ); return; } + dispatch(setIsLoadingGlobal(true)); - const shortStoreId = store?.shortStoreId || storeIdentifier || ""; - const dataContainer: ShortDataContainer = { + + let shortStoreId = store?.shortStoreId || storeIdentifier || ""; + if (!shortStoreId && typeof store?.id === "string") { + const parts = store.id.split(`${STORE_BASE}-`); + if (parts.length > 1) { + shortStoreId = parts[1]; + } + } + + if (!shortStoreId) { + dispatch( + setNotification({ + msg: "Unable to determine shop identifier", + alertType: "error", + }) + ); + return; + } + + const cataloguePrefix = `${CATALOGUE_BASE}-${shortStoreId}`; + const searchUrl = `/arbitrary/resources/search?service=DOCUMENT&query=${cataloguePrefix}&limit=500&includemetadata=false&mode=ALL&prefix=true&reverse=false&name=${username}&exactmatchnames=true`; + + const catalogueSearchResponse = await fetch(searchUrl, { + method: "GET", + headers: { "Content-Type": "application/json" }, + }); + + const catalogueSearchData = await catalogueSearchResponse.json(); + if (!Array.isArray(catalogueSearchData)) { + throw new Error("Unable to fetch catalogue list"); + } + + const relevantCatalogues = catalogueSearchData.filter((catalogue: any) => { + if (!catalogue || typeof catalogue !== "object") return false; + const identifier = catalogue.identifier; + if (typeof identifier !== "string") return false; + return identifier.startsWith(cataloguePrefix); + }); + + const rebuiltProducts: Record = {}; + const rebuiltCatalogues: CatalogueDataContainer[] = []; + const processedCatalogueIds = new Set(); + + let recoveredProducts = 0; + for (const catalogue of relevantCatalogues) { + const identifier = catalogue.identifier; + if (typeof identifier !== "string") continue; + if (processedCatalogueIds.has(identifier)) continue; + processedCatalogueIds.add(identifier); + try { + const catalogueResource = await qortalRequest({ + action: "FETCH_QDN_RESOURCE", + name: username, + service: "DOCUMENT", + identifier, + }); + + if (!catalogueResource || typeof catalogueResource !== "object") { + continue; + } + + const catalogueId = + typeof catalogueResource.id === "string" + ? catalogueResource.id + : identifier; + const catalogueProducts = catalogueResource.products; + + if ( + !catalogueProducts || + typeof catalogueProducts !== "object" || + Array.isArray(catalogueProducts) + ) { + continue; + } + + const normalizedProducts: Record = {}; + for (const [productId, productValue] of Object.entries( + catalogueProducts + )) { + if (!productId || typeof productId !== "string") continue; + if (!productValue || typeof productValue !== "object") continue; + if ((productValue as any).isDelete) continue; + + const priceArray = Array.isArray((productValue as any).price) + ? (productValue as any).price + : []; + const qortPriceEntry = priceArray.find((entry: any) => { + const currency = entry?.currency; + return ( + typeof currency === "string" && + currency.toUpperCase() === "QORT" && + entry?.value !== undefined + ); + }); + const priceQort = Number(qortPriceEntry?.value ?? 0); + if (!Number.isFinite(priceQort)) continue; + + const createdRaw = (productValue as any).created; + const created = + typeof createdRaw === "number" && Number.isFinite(createdRaw) + ? createdRaw + : Date.now(); + const category = + typeof (productValue as any).category === "string" + ? (productValue as any).category + : ""; + const status = + typeof (productValue as any).status === "string" + ? (productValue as any).status + : "AVAILABLE"; + + rebuiltProducts[productId] = { + created, + priceQort, + category, + catalogueId, + status, + }; + + normalizedProducts[productId] = true; + recoveredProducts += 1; + } + + if (Object.keys(normalizedProducts).length > 0) { + rebuiltCatalogues.push({ + id: catalogueId, + products: normalizedProducts, + }); + } + } catch (error) { + console.error("Failed to rebuild catalogue", identifier, error); + } + } + + const dataContainerId = `${store.id}-${DATA_CONTAINER_BASE}`; + const dataContainer: DataContainer = { storeId: store.id, shortStoreId, owner: username, - products: {}, + products: rebuiltProducts, + catalogues: rebuiltCatalogues, + id: dataContainerId, }; + const dataContainerToBase64 = await objectToBase64(dataContainer); const dataContainerCreated = await qortalRequest({ action: "PUBLISH_QDN_RESOURCE", name: username, service: "DOCUMENT", data64: dataContainerToBase64, - identifier: `${store.id}-${DATA_CONTAINER_BASE}`, + identifier: dataContainerId, filename: "datacontainer.json", }); + if (dataContainerCreated && !dataContainerCreated.error) { - dispatch(setDataContainer({ ...dataContainer, id: `${store.id}-${DATA_CONTAINER_BASE}` } as any)); dispatch(resetListProducts()); dispatch(resetProducts()); - dispatch(setNotification({ msg: "Data Container Created!", alertType: "success" })); + dispatch(setDataContainer(dataContainer)); + dispatch( + setNotification({ + msg: + recoveredProducts > 0 + ? `Data container rebuilt with ${recoveredProducts} product${ + recoveredProducts === 1 ? "" : "s" + }` + : "Data container recreated, but no products were found", + alertType: recoveredProducts > 0 ? "success" : "warning", + }) + ); setShowCreateNewDataContainerModal(false); setShowAdvancedSettings(false); } else { - dispatch(setNotification({ msg: "Error creating data container", alertType: "error" })); + dispatch( + setNotification({ + msg: "Error publishing rebuilt data container", + alertType: "error", + }) + ); } } catch (error) { - dispatch(setNotification({ msg: "Error when creating data container", alertType: "error" })); + console.error("Error rebuilding data container", error); + dispatch( + setNotification({ + msg: "Error when rebuilding data container", + alertType: "error", + }) + ); } finally { dispatch(setIsLoadingGlobal(false)); } @@ -506,7 +681,7 @@ const EditStoreModal: React.FC = ({ }} >
- Warning! ⚠️ Recreating your shop data clears all products. Use only as a last resort. + Warning! ⚠️ Recreating your shop data attempts to rebuild all products. Use only as a last resort.
setShowCreateNewDataContainerModal(false)}> -- 2.43.0 From c163693fc7a5b7fcedb8cc50b1d101bd3934b7c7 Mon Sep 17 00:00:00 2001 From: q-shop-release-bot Date: Thu, 23 Oct 2025 14:43:44 -0400 Subject: [PATCH 15/19] Fix back button --- src/App.tsx | 2 ++ src/hooks/useIframe.tsx | 23 +++++++++++++++++++++++ 2 files changed, 25 insertions(+) create mode 100644 src/hooks/useIframe.tsx diff --git a/src/App.tsx b/src/App.tsx index e121c96..da6a462 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -15,11 +15,13 @@ import GlobalWrapper from "./wrappers/GlobalWrapper"; import Notification from "./components/common/Notification/Notification"; import { ProductManager } from "./pages/ProductManager/ProductManager"; import Search from "./pages/Search/Search"; +import { useIframe } from './hooks/useIframe' function App() { // const themeColor = window._qdnTheme const [theme, setTheme] = useState("dark"); + useIframe() return ( diff --git a/src/hooks/useIframe.tsx b/src/hooks/useIframe.tsx new file mode 100644 index 0000000..ce2bfbe --- /dev/null +++ b/src/hooks/useIframe.tsx @@ -0,0 +1,23 @@ +import { useEffect } from "react"; +import { useNavigate } from "react-router-dom"; +export const useIframe = () => { + const navigate = useNavigate(); + useEffect(() => { + function handleNavigation(event: MessageEvent) { + if (event.data?.action === "NAVIGATE_TO_PATH" && event.data.path) { + console.log("Navigating to path within React app:", event.data.path); + navigate(event.data.path); // Navigate directly to the specified path + // Send a response back to the parent window after navigation is handled + window.parent.postMessage( + { action: "NAVIGATION_SUCCESS", path: event.data.path }, + "*" + ); + } + } + window.addEventListener("message", handleNavigation); + return () => { + window.removeEventListener("message", handleNavigation); + }; + }, [navigate]); + return { navigate }; +}; -- 2.43.0 From 2ae82f7eefdf5dc9dd886a3b497a91b5d4257b62 Mon Sep 17 00:00:00 2001 From: q-shop-release-bot Date: Thu, 23 Oct 2025 16:52:23 -0400 Subject: [PATCH 16/19] Sort by shipping location --- src/pages/StoreList/StoreList.tsx | 577 ++++++++++++++++++++++-------- 1 file changed, 422 insertions(+), 155 deletions(-) diff --git a/src/pages/StoreList/StoreList.tsx b/src/pages/StoreList/StoreList.tsx index ecfae9b..a540c20 100644 --- a/src/pages/StoreList/StoreList.tsx +++ b/src/pages/StoreList/StoreList.tsx @@ -24,6 +24,126 @@ import DefaultStoreImage from "../../assets/img/Q-AppsLogo.webp"; import { STORE_BASE, CATALOGUE_BASE, DATA_CONTAINER_BASE } from "../../constants/identifiers"; import { ExpandMoreSVG } from "../../assets/svgs/ExpandMoreSVG"; +type SortOption = "updated" | "created" | "location"; + +const LOCATION_EXPANDED_STORAGE_KEY = "storeListLocationExpanded"; +const DEFAULT_LOCATION_LABEL = "Ships to Unspecified"; + +const normalizeLocationKey = (value: string) => { + if (!value) return ""; + const stripped = value + .normalize("NFKD") + .replace(/[\u0300-\u036f]/g, "") + .toLowerCase() + .replace(/[^a-z0-9]/g, ""); + return stripped; +}; + +const ANY_LOCATION_SYNONYMS = [ + "Any", + "all", + "all shipping options", + "any country", + "any place via Q-Mail", + "anywhere", + "anywhere..", + "Available at location only but gift certs delivered anywhere.", + "digital/anywhere", + "digitally everywhere", + "Domestic, international", + "europe & international", + "everywhere", + "globally", + "Iceland, Poland, UK, USA, Canada & all around the world", + "internet - digital products", + "Milky Way", + "n/a: digital services", + "na", + "online", + "online video call", + "pdf download", + "Qortal", + "The aethers", + "US and Canada, and will ship to any location with confirmed Qortal Account address", + "USA preferred / Worldwide Possible", + "world wide", + "world wide.", + "worldwide", + "world-wide." +]; + +const EE_LOCATION_SYNONYMS = [ + "Eesti", + "Üle Eesti" +]; + +const US50_LOCATION_SYNONYMS = [ + "United States", + "US", + "USA", + "U.S.", + "U.S", + "U.S.A", + "U.S.A.", + "nationally", + "nationwide", + "Stateside so far, international being researched.", + "US only for now" +]; + +const US48_LOCATION_SYNONYMS = [ + "US (Continental)", + "Continental US", + "CONUS", + "Free shipping in the land currently known as USA within the North American continent, excluding Alaska" +]; + +const LOCATION_CANONICAL_LABELS: Record = [ + { label: "Any", synonyms: ANY_LOCATION_SYNONYMS }, + { label: "Estonia", synonyms: EE_LOCATION_SYNONYMS }, + { label: "United States", synonyms: US50_LOCATION_SYNONYMS }, + { label: "US (Continental)", synonyms: US48_LOCATION_SYNONYMS } +].reduce((acc, { label, synonyms }) => { + synonyms.forEach((entry) => { + const key = normalizeLocationKey(entry); + if (key) acc[key] = label; + }); + return acc; +}, {} as Record); + +const MULTI_LOCATION_OVERRIDES: Record = { + [normalizeLocationKey("Austria Germany Switzerland")]: ["Austria", "Germany", "Switzerland"], + [normalizeLocationKey("Canada and USA")]: ["Canada", "United States"], + [normalizeLocationKey("Canada, USA, Mexico")]: ["Canada", "United States", "Mexico"], + [normalizeLocationKey("Europe, Mexico")]: ["Europe", "Mexico"], + [normalizeLocationKey("Iceland, Poland")]: ["Iceland", "Poland"], + [normalizeLocationKey("US and Canada")]: ["United States", "Canada"], + [normalizeLocationKey("USA, CANADA, EU, OCEANA")]: ["United States", "Canada", "European Union", "Oceania"], + [normalizeLocationKey("USA, Europe, United Kindom, Australia")]: ["United States", "Europe", "United Kingdom", "Australia"], +}; + +const prettifyLocationLabel = (label: string) => { + if (!label) return DEFAULT_LOCATION_LABEL; + if (/[a-z]/.test(label)) return label; + return label + .split(/\s+/) + .map((word) => (word.length <= 3 ? word.toUpperCase() : word.charAt(0) + word.slice(1).toLowerCase())) + .join(" "); +}; + +const resolveLocation = (raw: string) => { + const trimmed = raw.trim(); + if (!trimmed) { + return { key: "unspecified", label: DEFAULT_LOCATION_LABEL }; + } + const normalized = normalizeLocationKey(trimmed); + const canonicalLabel = LOCATION_CANONICAL_LABELS[normalized]; + const label = canonicalLabel || prettifyLocationLabel(trimmed) || DEFAULT_LOCATION_LABEL; + const keyBase = canonicalLabel ? normalizeLocationKey(canonicalLabel) : normalized; + const key = keyBase || "unspecified"; + return { key, label }; +}; + export const StoreList = () => { const dispatch = useDispatch(); const theme = useTheme(); @@ -34,7 +154,7 @@ export const StoreList = () => { const user = useSelector((state: RootState) => state.auth.user); const [filterUserStores, setFilterUserStores] = useState(false); - const [sortBy, setSortBy] = useState<"updated" | "created">("updated"); + const [sortBy, setSortBy] = useState("updated"); const [catalogueLatestByStoreId, setCatalogueLatestByStoreId] = useState>({}); const [datacontainerLatestByStoreId, setDatacontainerLatestByStoreId] = useState>({}); const [hasFetchedAllStores, setHasFetchedAllStores] = useState(false); @@ -59,6 +179,23 @@ export const StoreList = () => { const expanded = (() => { try { return localStorage.getItem('recentlyVisitedExpanded') === 'true'; } catch { return false; }})(); return !expanded; }); + const [locationExpanded, setLocationExpanded] = useState>(() => { + if (typeof window === "undefined") return {}; + try { + const raw = localStorage.getItem(LOCATION_EXPANDED_STORAGE_KEY); + if (!raw) return {}; + const parsed = JSON.parse(raw) as Record; + if (parsed && typeof parsed === "object") { + return Object.keys(parsed).reduce((acc, key) => { + acc[key] = Boolean(parsed[key]); + return acc; + }, {} as Record); + } + } catch { + // ignore parse/storage errors + } + return {}; + }); // TODO: Need skeleton at first while the data is being fetched // Will rerender and replace if the hashmap wasn't found initially @@ -108,6 +245,15 @@ export const StoreList = () => { try { localStorage.setItem('recentlyVisitedExpanded', String(recentExpanded)); } catch {} }, [recentExpanded]); + useEffect(() => { + if (typeof window === "undefined") return; + try { + localStorage.setItem(LOCATION_EXPANDED_STORAGE_KEY, JSON.stringify(locationExpanded)); + } catch { + // ignore persistence issues + } + }, [locationExpanded]); + // When expanded, load recent stores first, then allow main list to fetch useEffect(() => { const run = async () => { @@ -230,7 +376,7 @@ export const StoreList = () => { const handleSortChange = ( event: React.ChangeEvent ) => { - const value = event.target.value as "updated" | "created"; + const value = event.target.value as SortOption; setSortBy(value); }; @@ -341,15 +487,19 @@ export const StoreList = () => { } }; - // Memoize the filtered stores to prevent rerenders - const filteredStores = useMemo(() => { + const baseStores = useMemo(() => { let filtered = filterUserStores ? myStores : stores; - // Include stores that are valid or not-yet-fetched (to show skeletons), - // but exclude explicitly invalid ones filtered = filtered.filter((store: Store) => { - if (invalidStoreIds[store.id]) return false; // drop known-invalid/empty shops + if (invalidStoreIds[store.id]) return false; return hashMapStores[store.id]?.isValid !== false; }); + return filtered; + }, [filterUserStores, myStores, stores, invalidStoreIds, hashMapStores]); + + const sortedStores = useMemo(() => { + if (sortBy === "location") { + return baseStores.slice(); + } const getVal = (s: Store) => { if (sortBy === "updated") { const base = normalizeTs(s.updated ?? s.created ?? 0); @@ -359,9 +509,148 @@ export const StoreList = () => { } return normalizeTs(s.created ?? 0); }; - const sorted = filtered.slice().sort((a, b) => getVal(b) - getVal(a)); - return sorted; - }, [filterUserStores, stores, myStores, user?.name, hashMapStores, sortBy, catalogueLatestByStoreId, datacontainerLatestByStoreId]); + return baseStores.slice().sort((a, b) => getVal(b) - getVal(a)); + }, [baseStores, sortBy, catalogueLatestByStoreId, datacontainerLatestByStoreId]); + + const locationSections = useMemo(() => { + if (sortBy !== "location") return []; + const groups = new Map(); + baseStores.forEach((store) => { + if (store.owner === "Bester") return; + const meta = hashMapStores[store.id]; + const shipsToRaw = (meta?.shipsTo ?? store.shipsTo ?? "").trim(); + const override = MULTI_LOCATION_OVERRIDES[normalizeLocationKey(shipsToRaw)]; + const locations = override && override.length > 0 ? override : [shipsToRaw || ""]; + const seenKeys = new Set(); + locations.forEach((entry) => { + const { key, label } = resolveLocation(entry); + if (!key || seenKeys.has(key)) return; + seenKeys.add(key); + const existing = groups.get(key); + if (existing) { + existing.stores.push(store); + } else { + groups.set(key, { label, stores: [store] }); + } + }); + }); + const collator = new Intl.Collator(undefined, { sensitivity: "base", numeric: true }); + return Array.from(groups.entries()) + .sort((a, b) => collator.compare(a[1].label, b[1].label)) + .map(([key, { label, stores: storesInLocation }]) => { + const sortedGroup = storesInLocation.slice().sort((a, b) => { + const aTitle = (hashMapStores[a.id]?.title ?? a.title ?? "").toLowerCase(); + const bTitle = (hashMapStores[b.id]?.title ?? b.title ?? "").toLowerCase(); + if (aTitle && bTitle) return aTitle.localeCompare(bTitle); + if (aTitle) return -1; + if (bTitle) return 1; + return (a.title ?? "").localeCompare(b.title ?? ""); + }); + return { key, label, stores: sortedGroup }; + }); + }, [sortBy, baseStores, hashMapStores]); + + useEffect(() => { + if (sortBy !== "location") return; + setLocationExpanded((prev) => { + const next = { ...prev }; + let changed = false; + locationSections.forEach(({ key }) => { + if (next[key] === undefined) { + next[key] = true; + changed = true; + } + }); + Object.keys(next).forEach((storedKey) => { + if (!locationSections.some((section) => section.key === storedKey)) { + delete next[storedKey]; + changed = true; + } + }); + return changed ? next : prev; + }); + }, [sortBy, locationSections]); + + const handleToggleLocationSection = useCallback((sectionKey: string) => { + setLocationExpanded((prev) => { + const current = prev[sectionKey]; + return { + ...prev, + [sectionKey]: current === undefined ? false : !current, + }; + }); + }, []); + + const renderStoreRow = (store: Store) => { + if (store.owner === "Bester") return null; + let storeItem = store; + let hasHash = false; + const existingStore = hashMapStores[store.id]; + + if (existingStore) { + storeItem = existingStore; + hasHash = true; + } + + const storeId = storeItem?.id || store.id; + const storeOwner = storeItem?.owner || ""; + const storeTitle = storeItem?.title || "Invalid Shop"; + const storeLogo = storeItem?.logo || DefaultStoreImage; + const storeDescription = storeItem?.description || ""; + const supportedCoins = storeItem?.supportedCoins || ["QORT"]; + let bottomLabel = ""; + if (sortBy === "updated") { + const base = store.updated ?? store.created ?? 0; + const catTs = catalogueLatestByStoreId[store.id] ?? 0; + const dcTs = datacontainerLatestByStoreId[store.id] ?? 0; + const latest = Math.max(base, catTs, dcTs); + if (latest) bottomLabel = `Updated ${timeAgo(latest)} ago`; + } else if (sortBy === "created") { + const createdTs = store.created; + if (createdTs) bottomLabel = `Created ${timeAgo(createdTs)} ago`; + } + + if (!hasHash) { + return ( + + + + ); + } + + return ( + + ); + }; + + const canRenderLazyLoad = sortBy === "location" ? true : (!recentExpanded || hasLoadedRecent); // Progress indicator: how many shops have loaded metadata vs total discovered const { totalShops, loadedShops, loadingPercent } = useMemo(() => { @@ -406,6 +695,7 @@ export const StoreList = () => { > Recently Updated Recently Created + Sort by Location {user && ( @@ -432,160 +722,137 @@ export const StoreList = () => { )} - {/* Recently Visited section header and grid */} - - - - - - - - - Recently Visited - - - {recentExpanded && ( - - {recentEntries - .filter((e) => e.owner !== 'Bester') - .slice(0, recentVisibleCount) - .map((e) => { - const meta = hashMapStores[e.id]; - if (!meta || meta.isValid === false) { - return ( - - - - ); - } - const storeId = meta?.id || e.id; - const storeOwner = meta?.owner || e.owner; - const storeTitle = meta?.title || "Invalid Shop"; - const storeLogo = meta?.logo || DefaultStoreImage; - const storeDescription = meta?.description || ""; - const supportedCoins = meta?.supportedCoins || ['QORT']; - return ( - - ); - })} - - )} - {recentExpanded && isLoadingRecent && ( - - + {sortBy !== "location" && ( + + + + + + + + + Recently Visited + - )} - - - - {filteredStores.length > 0 && - filteredStores - // Get rid of the Bester shop (test shop) - .filter((store: Store) => store.owner !== "Bester") - .map((store: Store) => { - let storeItem = store; - let hasHash = false; - const existingStore = hashMapStores[store.id]; - - // Check in case hashmap data isn't there yet due to async API calls. - // If it's not there, component will rerender once it receives the metadata - if (existingStore) { - storeItem = existingStore; - hasHash = true; - } - const storeId = storeItem?.id || ""; - const storeOwner = storeItem?.owner || ""; - const storeTitle = storeItem?.title || "Invalid Shop"; - const storeLogo = storeItem?.logo || DefaultStoreImage; - const storeDescription = storeItem?.description || ""; - const supportedCoins = storeItem?.supportedCoins || ['QORT']; - let bottomLabel = ""; - if (sortBy === "updated") { - const base = store.updated ?? store.created ?? 0; - const catTs = catalogueLatestByStoreId[store.id] ?? 0; - const dcTs = datacontainerLatestByStoreId[store.id] ?? 0; - const latest = Math.max(base, catTs, dcTs); - if (latest) bottomLabel = `Updated ${timeAgo(latest)} ago`; - } else { - const createdTs = store.created; - if (createdTs) bottomLabel = `Created ${timeAgo(createdTs)} ago`; - } - if (!hasHash) { - return ( - - - - ); - } else { + {recentExpanded && ( + + {recentEntries + .filter((e) => e.owner !== 'Bester') + .slice(0, recentVisibleCount) + .map((e) => { + const meta = hashMapStores[e.id]; + if (!meta || meta.isValid === false) { + return ( + + + + ); + } + const storeId = meta?.id || e.id; + const storeOwner = meta?.owner || e.owner; + const storeTitle = meta?.title || "Invalid Shop"; + const storeLogo = meta?.logo || DefaultStoreImage; + const storeDescription = meta?.description || ""; + const supportedCoins = meta?.supportedCoins || ['QORT']; return ( ); - } - })} - {(!recentExpanded || hasLoadedRecent) && ( - + })} + + )} + {recentExpanded && isLoadingRecent && ( + + + )} - + )} + {sortBy === "location" ? ( + <> + {locationSections.map(({ key, label, stores: storesInLocation }) => { + const expanded = locationExpanded[key] ?? true; + return ( + + + handleToggleLocationSection(key)} + sx={{ p: 0.5 }} + > + + + + + handleToggleLocationSection(key)} + > + {label} + + + {expanded && ( + + {storesInLocation.map((store) => renderStoreRow(store))} + + )} + + ); + })} + {canRenderLazyLoad && ( + + + + )} + + ) : ( + + + {sortedStores.length > 0 && + sortedStores.map((store: Store) => renderStoreRow(store))} + {canRenderLazyLoad && ( + + )} + + + )} ); -- 2.43.0 From fcb612949344ed03f087e02eec22e0f0dfbbcfc7 Mon Sep 17 00:00:00 2001 From: q-shop-release-bot Date: Thu, 23 Oct 2025 17:00:37 -0400 Subject: [PATCH 17/19] Create store button text --- src/components/layout/Navbar/Navbar.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/layout/Navbar/Navbar.tsx b/src/components/layout/Navbar/Navbar.tsx index 1804363..54e7bb8 100644 --- a/src/components/layout/Navbar/Navbar.tsx +++ b/src/components/layout/Navbar/Navbar.tsx @@ -267,7 +267,7 @@ const NavBar: React.FC = ({ handleCloseStoreDropdown(); }} > - Create Store + + Create Store {myStores.length > 0 && -- 2.43.0 From 43039fea1e02dd1cc7d33a9524f4e8b7acaa922c Mon Sep 17 00:00:00 2001 From: q-shop-release-bot Date: Thu, 23 Oct 2025 18:40:41 -0400 Subject: [PATCH 18/19] Standard shipsTo and custom shippingInfo --- .../common/ShippingRegionsSelect.tsx | 312 ++++++ src/components/modals/CreateStoreModal.tsx | 48 +- src/components/modals/EditStoreModal.tsx | 92 +- src/constants/country-by-continent.json | 978 ++++++++++++++++++ src/constants/shippingRegions.ts | 436 ++++++++ src/hooks/useGlobalSearch.ts | 8 +- src/pages/Store/Store/Store.tsx | 23 +- src/pages/Store/StoreDetails/StoreDetails.tsx | 58 +- src/pages/StoreList/StoreList.tsx | 160 +-- src/state/features/globalSlice.ts | 3 +- src/state/features/storeSlice.ts | 3 +- src/utils/checkStructure.ts | 7 +- src/wrappers/GlobalWrapper.tsx | 55 +- 13 files changed, 2012 insertions(+), 171 deletions(-) create mode 100644 src/components/common/ShippingRegionsSelect.tsx create mode 100644 src/constants/country-by-continent.json create mode 100644 src/constants/shippingRegions.ts diff --git a/src/components/common/ShippingRegionsSelect.tsx b/src/components/common/ShippingRegionsSelect.tsx new file mode 100644 index 0000000..4842b8c --- /dev/null +++ b/src/components/common/ShippingRegionsSelect.tsx @@ -0,0 +1,312 @@ +import { useState, useMemo, useCallback, useEffect } from "react"; +import { + Box, + Checkbox, + Collapse, + FormControl, + FormControlLabel, + IconButton, + List, + ListItem, + Menu, + TextField, + Typography, + useTheme, +} from "@mui/material"; +import { + SHIPPING_LEAF_IDS, + SHIPPING_REGION_TREE, + ShippingNode, + getDescendantLeafIds, + getDisplayLabelForId, + getSelectableIdsForNode, + sanitizeShippingSelection, + summarizeShippingSelection, +} from "../../constants/shippingRegions"; +import { ExpandMoreSVG } from "../../assets/svgs/ExpandMoreSVG"; + +interface ShippingRegionsSelectProps { + label?: string; + placeholder?: string; + value: string[]; + onChange: (value: string[]) => void; + helperText?: string; + error?: boolean; +} + +const SUMMARY_THRESHOLD = 3; + +export const ShippingRegionsSelect: React.FC = ({ + label = "Ships To", + placeholder = "Select destinations", + value, + onChange, + helperText, + error, +}) => { + const theme = useTheme(); + const [anchorEl, setAnchorEl] = useState(null); + const [expandedNodes, setExpandedNodes] = useState>( + () => ({}) + ); + + const openMenu = (event: React.MouseEvent) => { + setAnchorEl(event.currentTarget); + }; + + const closeMenu = () => setAnchorEl(null); + + const sanitizedValue = useMemo( + () => sanitizeShippingSelection(value), + [value] + ); + + useEffect(() => { + if (sanitizedValue.length !== value.length) { + onChange(sanitizedValue); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [sanitizedValue, value.length]); + + const selectionSet = useMemo( + () => new Set(sanitizedValue), + [sanitizedValue] + ); + + const totalLeafCount = SHIPPING_LEAF_IDS.length; + const anyChecked = selectionSet.size === totalLeafCount && totalLeafCount > 0; + const anyIndeterminate = + selectionSet.size > 0 && selectionSet.size < totalLeafCount; + + const applySelection = useCallback( + (updater: (draft: Set) => void) => { + const draft = new Set(selectionSet); + updater(draft); + const next = Array.from(draft); + next.sort((a, b) => + getDisplayLabelForId(a).localeCompare(getDisplayLabelForId(b)) + ); + onChange(next); + }, + [selectionSet, onChange] + ); + + const handleToggleAny = (checked: boolean) => { + applySelection((draft) => { + draft.clear(); + if (checked) { + SHIPPING_LEAF_IDS.forEach((id) => draft.add(id)); + } + }); + }; + + const toggleNodeSelection = (node: ShippingNode, checked: boolean) => { + const leaves = getSelectableIdsForNode(node.id); + applySelection((draft) => { + leaves.forEach((leafId) => { + if (checked) { + draft.add(leafId); + } else { + draft.delete(leafId); + } + }); + }); + }; + + const toggleLeaf = (leafId: string, checked: boolean) => { + applySelection((draft) => { + if (checked) { + draft.add(leafId); + } else { + draft.delete(leafId); + } + }); + }; + + const isNodeExpanded = (id: string) => expandedNodes[id] ?? false; + + const toggleExpanded = (id: string) => { + setExpandedNodes((prev) => ({ + ...prev, + [id]: !prev[id], + })); + }; + + const getNodeCheckState = (node: ShippingNode) => { + const leaves = getSelectableIdsForNode(node.id); + if (leaves.length === 0) { + const isChecked = selectionSet.has(node.id); + return { checked: isChecked, indeterminate: false }; + } + const selectedCount = leaves.filter((leaf) => selectionSet.has(leaf)).length; + if (selectedCount === 0) return { checked: false, indeterminate: false }; + if (selectedCount === leaves.length) + return { checked: true, indeterminate: false }; + return { checked: false, indeterminate: true }; + }; + + const renderNode = (node: ShippingNode, depth = 0) => { + if (node.type === "any") return null; + const hasChildren = !!node.children && node.children.length > 0; + const { checked, indeterminate } = getNodeCheckState(node); + + const paddingLeft = depth === 0 ? 0 : depth * 16; + + return ( + + + {hasChildren ? ( + toggleExpanded(node.id)} + sx={{ + mr: 1, + transform: isNodeExpanded(node.id) + ? "rotate(180deg)" + : "rotate(0deg)", + transition: "transform 0.2s ease", + }} + > + + + ) : ( + + )} + + toggleNodeSelection(node, event.target.checked) + } + /> + } + label={ + + {node.label} + + } + /> + + {hasChildren && ( + + {node.children?.map((child) => + child.children && child.children.length > 0 + ? renderNode(child, depth + 1) + : renderLeaf(child, depth + 1) + )} + + )} + + ); + }; + + const renderLeaf = (node: ShippingNode, depth = 0) => { + const leaves = getDescendantLeafIds(node.id); + const leafId = leaves[0]; + const isChecked = selectionSet.has(leafId); + + return ( + + toggleLeaf(leafId, event.target.checked)} + /> + } + label={ + + {node.label} + + } + /> + + ); + }; + + const summaryLabels = useMemo( + () => summarizeShippingSelection(Array.from(selectionSet)), + [selectionSet] + ); + + let summaryText = placeholder; + if (anyChecked) { + summaryText = "Any (all destinations)"; + } else if (summaryLabels.length > 0) { + if (summaryLabels.length <= SUMMARY_THRESHOLD) { + summaryText = summaryLabels.join(", "); + } else { + summaryText = `${summaryLabels.length} destinations selected`; + } + } + + return ( + + + + + handleToggleAny(event.target.checked)} + /> + } + label={ + + Any + + } + /> + + + {SHIPPING_REGION_TREE.filter((node) => node.type === "continent").map( + (continent) => renderNode(continent) + )} + + + + ); +}; diff --git a/src/components/modals/CreateStoreModal.tsx b/src/components/modals/CreateStoreModal.tsx index 31a39fc..2a7b0ae 100644 --- a/src/components/modals/CreateStoreModal.tsx +++ b/src/components/modals/CreateStoreModal.tsx @@ -37,6 +37,8 @@ import { supportedCoinsArray } from "../../constants/supported-coins"; import { QortalSVG } from "../../assets/svgs/QortalSVG"; import { ARRRSVG } from "../../assets/svgs/ARRRSVG"; import { setNotification } from "../../state/features/notificationsSlice"; +import { ShippingRegionsSelect } from "../common/ShippingRegionsSelect"; +import { sanitizeShippingSelection } from "../../constants/shippingRegions"; export interface ForeignCoins { [key: string]: string; @@ -45,12 +47,13 @@ export interface ForeignCoins { export interface onPublishParam { title: string; description: string; - shipsTo: string; + shipsTo: string[]; location: string; storeIdentifier: string; logo: string; foreignCoins: ForeignCoins; supportedCoins: string[]; + shippingInfo?: string; } interface CreateStoreModalProps { @@ -74,7 +77,8 @@ const CreateStoreModal: React.FC = ({ const [title, setTitle] = useState(""); const [description, setDescription] = useState(""); const [location, setLocation] = useState(""); - const [shipsTo, setShipsTo] = useState(""); + const [shipsTo, setShipsTo] = useState([]); + const [shippingInfo, setShippingInfo] = useState(""); const [errorMessage, setErrorMessage] = useState(""); const [storeIdentifier, setStoreIdentifier] = useState(""); const [logo, setLogo] = useState(null); @@ -89,6 +93,11 @@ const CreateStoreModal: React.FC = ({ setErrorMessage("A logo is required"); return; } + const normalizedShipsTo = sanitizeShippingSelection(shipsTo); + if (!normalizedShipsTo || normalizedShipsTo.length === 0) { + setErrorMessage("Please select at least one shipping destination"); + return; + } const foreignCoins: ForeignCoins = {}; supportedCoinsSelected .filter((coin) => coin !== "QORT") @@ -100,12 +109,13 @@ const CreateStoreModal: React.FC = ({ await onPublish({ title, description, - shipsTo, + shipsTo: normalizedShipsTo, location, storeIdentifier, logo, foreignCoins: foreignCoins, supportedCoins: supportedCoinsSelected, + shippingInfo: shippingInfo.trim(), }); } catch (error: any) { setErrorMessage(error.message); @@ -115,6 +125,9 @@ const CreateStoreModal: React.FC = ({ const handleClose = (): void => { setTitle(""); setDescription(""); + setLocation(""); + setShipsTo([]); + setShippingInfo(""); setErrorMessage(""); // Reset foreign wallets (ARRR preserved as known default key) setForeignWallets({ ARRR: "" }); @@ -265,13 +278,29 @@ const CreateStoreModal: React.FC = ({ variant="filled" /> - setShipsTo(e.target.value)} + onChange={setShipsTo} + helperText={ + errorMessage && + errorMessage.toLowerCase().includes("shipping destination") + ? errorMessage + : undefined + } + error={ + !!errorMessage && + errorMessage.toLowerCase().includes("shipping destination") + } + /> + + setShippingInfo(e.target.value)} fullWidth - required + multiline + rows={3} variant="filled" /> @@ -377,7 +406,8 @@ const CreateStoreModal: React.FC = ({ /> - {errorMessage && ( + {errorMessage && + !errorMessage.toLowerCase().includes("shipping destination") && ( {errorMessage} diff --git a/src/components/modals/EditStoreModal.tsx b/src/components/modals/EditStoreModal.tsx index fee3e86..0981412 100644 --- a/src/components/modals/EditStoreModal.tsx +++ b/src/components/modals/EditStoreModal.tsx @@ -54,6 +54,11 @@ import { setNotification } from "../../state/features/notificationsSlice"; import { ReusableModal } from "./ReusableModal"; import { DATA_CONTAINER_BASE, CATALOGUE_BASE, STORE_BASE } from "../../constants/identifiers"; import { objectToBase64 } from "../../utils/toBase64"; +import { ShippingRegionsSelect } from "../common/ShippingRegionsSelect"; +import { + resolveLegacyShipsToSelection, + sanitizeShippingSelection, +} from "../../constants/shippingRegions"; type AnyObject = Record; @@ -61,10 +66,11 @@ export interface onPublishParamEdit { title: string; description: string; location: string; - shipsTo: string; + shipsTo: string[]; logo: string; foreignCoins: Record; supportedCoins: string[]; + shippingInfo?: string; } @@ -104,14 +110,18 @@ const EditStoreModal: React.FC = ({ const cached = localStorage.getItem(STORAGE_KEY_EDIT); if (cached) { const parsed = JSON.parse(cached); - if (parsed && typeof parsed === 'object') { + if (parsed && typeof parsed === "object") { if (parsed.title) setTitle(parsed.title); if (parsed.description) setDescription(parsed.description); if (parsed.location) setLocation(parsed.location); - if (parsed.shipsTo) setShipsTo(parsed.shipsTo); + if (parsed.shipsTo) + setShipsTo(resolveLegacyShipsToSelection(parsed.shipsTo)); + if (parsed.shippingInfo) setShippingInfo(parsed.shippingInfo); if (parsed.logo) setLogo(parsed.logo); - if (Array.isArray(parsed.supportedCoinsSelected)) setSupportedCoinsSelected(parsed.supportedCoinsSelected); - if (parsed.foreignWallets && typeof parsed.foreignWallets === 'object') setForeignWallets(parsed.foreignWallets); + if (Array.isArray(parsed.supportedCoinsSelected)) + setSupportedCoinsSelected(parsed.supportedCoinsSelected); + if (parsed.foreignWallets && typeof parsed.foreignWallets === "object") + setForeignWallets(parsed.foreignWallets); } } } catch {} @@ -121,7 +131,12 @@ const EditStoreModal: React.FC = ({ const [title, setTitle] = useState(store?.title || ""); const [description, setDescription] = useState(store?.description || ""); const [location, setLocation] = useState(store?.location || ""); - const [shipsTo, setShipsTo] = useState(store?.shipsTo || ""); + const [shipsTo, setShipsTo] = useState( + resolveLegacyShipsToSelection(store?.shipsTo) + ); + const [shippingInfo, setShippingInfo] = useState( + (store?.shippingInfo as string) || "" + ); const [errorMessage, setErrorMessage] = useState(""); const [storeIdentifier, setStoreIdentifier] = useState(store?.storeIdentifier || ""); const [logo, setLogo] = useState(store?.logo || null); @@ -132,9 +147,28 @@ const EditStoreModal: React.FC = ({ useEffect(() => { if (!open) return; - const payload = { title, description, location, shipsTo, logo, supportedCoinsSelected, foreignWallets }; + const payload = { + title, + description, + location, + shipsTo, + shippingInfo, + logo, + supportedCoinsSelected, + foreignWallets, + }; try { localStorage.setItem(STORAGE_KEY_EDIT, JSON.stringify(payload)); } catch {} - }, [open, title, description, location, shipsTo, logo, supportedCoinsSelected, foreignWallets]); + }, [ + open, + title, + description, + location, + shipsTo, + shippingInfo, + logo, + supportedCoinsSelected, + foreignWallets, + ]); useEffect(() => { // Keep fields in sync if a different store is loaded while open @@ -142,7 +176,8 @@ const EditStoreModal: React.FC = ({ setTitle(store.title || ""); setDescription(store.description || ""); setLocation(store.location || ""); - setShipsTo(store.shipsTo || ""); + setShipsTo(resolveLegacyShipsToSelection(store.shipsTo)); + setShippingInfo((store.shippingInfo as string) || ""); setStoreIdentifier(store.storeIdentifier || ""); setLogo(store.logo || null); setSupportedCoinsSelected(store.supportedCoins || ["QORT"]); @@ -157,6 +192,10 @@ const EditStoreModal: React.FC = ({ setErrorMessage("A logo is required"); return; } + if (!shipsTo || shipsTo.length === 0) { + setErrorMessage("Please select at least one shipping destination"); + return; + } const foreignCoins: Record = {}; supportedCoinsSelected .filter((coin) => coin !== "QORT") @@ -175,11 +214,12 @@ const EditStoreModal: React.FC = ({ ...store, title, description, - shipsTo, + shipsTo: sanitizeShippingSelection(shipsTo), location, logo, foreignCoins, supportedCoins: supportedCoinsSelected, + shippingInfo: shippingInfo.trim(), }; await save(payload); @@ -535,13 +575,29 @@ const EditStoreModal: React.FC = ({ variant="filled" /> - setShipsTo(e.target.value)} + onChange={setShipsTo} + helperText={ + errorMessage && + errorMessage.toLowerCase().includes("shipping destination") + ? errorMessage + : undefined + } + error={ + !!errorMessage && + errorMessage.toLowerCase().includes("shipping destination") + } + /> + + setShippingInfo(e.target.value)} fullWidth - required + multiline + rows={3} variant="filled" /> @@ -614,7 +670,8 @@ const EditStoreModal: React.FC = ({ /> - {errorMessage && ( + {errorMessage && + !errorMessage.toLowerCase().includes("shipping destination") && ( {errorMessage} @@ -644,7 +701,8 @@ const EditStoreModal: React.FC = ({ setTitle(store.title || ""); setDescription(store.description || ""); setLocation(store.location || ""); - setShipsTo(store.shipsTo || ""); + setShipsTo(resolveLegacyShipsToSelection(store.shipsTo)); + setShippingInfo((store.shippingInfo as string) || ""); setLogo(store.logo || null); setSupportedCoinsSelected(store.supportedCoins || ["QORT"]); setForeignWallets(store.foreignCoins || { ARRR: "" }); diff --git a/src/constants/country-by-continent.json b/src/constants/country-by-continent.json new file mode 100644 index 0000000..c1c4f7e --- /dev/null +++ b/src/constants/country-by-continent.json @@ -0,0 +1,978 @@ +[ + { + "country": "Afghanistan", + "continent": "Asia" + }, + { + "country": "Albania", + "continent": "Europe" + }, + { + "country": "Algeria", + "continent": "Africa" + }, + { + "country": "American Samoa", + "continent": "Oceania" + }, + { + "country": "Andorra", + "continent": "Europe" + }, + { + "country": "Angola", + "continent": "Africa" + }, + { + "country": "Anguilla", + "continent": "North America" + }, + { + "country": "Antarctica", + "continent": "Antarctica" + }, + { + "country": "Antigua and Barbuda", + "continent": "North America" + }, + { + "country": "Argentina", + "continent": "South America" + }, + { + "country": "Armenia", + "continent": "Asia" + }, + { + "country": "Aruba", + "continent": "North America" + }, + { + "country": "Australia", + "continent": "Oceania" + }, + { + "country": "Austria", + "continent": "Europe" + }, + { + "country": "Azerbaijan", + "continent": "Asia" + }, + { + "country": "Bahamas", + "continent": "North America" + }, + { + "country": "Bahrain", + "continent": "Asia" + }, + { + "country": "Bangladesh", + "continent": "Asia" + }, + { + "country": "Barbados", + "continent": "North America" + }, + { + "country": "Belarus", + "continent": "Europe" + }, + { + "country": "Belgium", + "continent": "Europe" + }, + { + "country": "Belize", + "continent": "North America" + }, + { + "country": "Benin", + "continent": "Africa" + }, + { + "country": "Bermuda", + "continent": "North America" + }, + { + "country": "Bhutan", + "continent": "Asia" + }, + { + "country": "Bolivia", + "continent": "South America" + }, + { + "country": "Bosnia and Herzegovina", + "continent": "Europe" + }, + { + "country": "Botswana", + "continent": "Africa" + }, + { + "country": "Bouvet Island", + "continent": "Antarctica" + }, + { + "country": "Brazil", + "continent": "South America" + }, + { + "country": "British Indian Ocean Territory", + "continent": "Africa" + }, + { + "country": "Brunei", + "continent": "Asia" + }, + { + "country": "Bulgaria", + "continent": "Europe" + }, + { + "country": "Burkina Faso", + "continent": "Africa" + }, + { + "country": "Burundi", + "continent": "Africa" + }, + { + "country": "Cambodia", + "continent": "Asia" + }, + { + "country": "Cameroon", + "continent": "Africa" + }, + { + "country": "Canada", + "continent": "North America" + }, + { + "country": "Cape Verde", + "continent": "Africa" + }, + { + "country": "Cayman Islands", + "continent": "North America" + }, + { + "country": "Central African Republic", + "continent": "Africa" + }, + { + "country": "Chad", + "continent": "Africa" + }, + { + "country": "Chile", + "continent": "South America" + }, + { + "country": "China", + "continent": "Asia" + }, + { + "country": "Christmas Island", + "continent": "Oceania" + }, + { + "country": "Cocos (Keeling) Islands", + "continent": "Oceania" + }, + { + "country": "Colombia", + "continent": "South America" + }, + { + "country": "Comoros", + "continent": "Africa" + }, + { + "country": "Congo", + "continent": "Africa" + }, + { + "country": "Cook Islands", + "continent": "Oceania" + }, + { + "country": "Costa Rica", + "continent": "North America" + }, + { + "country": "Croatia", + "continent": "Europe" + }, + { + "country": "Cuba", + "continent": "North America" + }, + { + "country": "Cyprus", + "continent": "Asia" + }, + { + "country": "Czech Republic", + "continent": "Europe" + }, + { + "country": "Denmark", + "continent": "Europe" + }, + { + "country": "Djibouti", + "continent": "Africa" + }, + { + "country": "Dominica", + "continent": "North America" + }, + { + "country": "Dominican Republic", + "continent": "North America" + }, + { + "country": "East Timor", + "continent": "Asia" + }, + { + "country": "Ecuador", + "continent": "South America" + }, + { + "country": "Egypt", + "continent": "Africa" + }, + { + "country": "El Salvador", + "continent": "North America" + }, + { + "country": "England", + "continent": "Europe" + }, + { + "country": "Equatorial Guinea", + "continent": "Africa" + }, + { + "country": "Eritrea", + "continent": "Africa" + }, + { + "country": "Estonia", + "continent": "Europe" + }, + { + "country": "Eswatini", + "continent": "Africa" + }, + { + "country": "Ethiopia", + "continent": "Africa" + }, + { + "country": "Falkland Islands", + "continent": "South America" + }, + { + "country": "Faroe Islands", + "continent": "Europe" + }, + { + "country": "Fiji Islands", + "continent": "Oceania" + }, + { + "country": "Finland", + "continent": "Europe" + }, + { + "country": "France", + "continent": "Europe" + }, + { + "country": "French Guiana", + "continent": "South America" + }, + { + "country": "French Polynesia", + "continent": "Oceania" + }, + { + "country": "French Southern territories", + "continent": "Antarctica" + }, + { + "country": "Gabon", + "continent": "Africa" + }, + { + "country": "Gambia", + "continent": "Africa" + }, + { + "country": "Georgia", + "continent": "Asia" + }, + { + "country": "Germany", + "continent": "Europe" + }, + { + "country": "Ghana", + "continent": "Africa" + }, + { + "country": "Gibraltar", + "continent": "Europe" + }, + { + "country": "Greece", + "continent": "Europe" + }, + { + "country": "Greenland", + "continent": "North America" + }, + { + "country": "Grenada", + "continent": "North America" + }, + { + "country": "Guadeloupe", + "continent": "North America" + }, + { + "country": "Guam", + "continent": "Oceania" + }, + { + "country": "Guatemala", + "continent": "North America" + }, + { + "country": "Guinea", + "continent": "Africa" + }, + { + "country": "Guinea-Bissau", + "continent": "Africa" + }, + { + "country": "Guyana", + "continent": "South America" + }, + { + "country": "Haiti", + "continent": "North America" + }, + { + "country": "Heard Island and McDonald Islands", + "continent": "Antarctica" + }, + { + "country": "Holy See (Vatican City State)", + "continent": "Europe" + }, + { + "country": "Honduras", + "continent": "North America" + }, + { + "country": "Hong Kong", + "continent": "Asia" + }, + { + "country": "Hungary", + "continent": "Europe" + }, + { + "country": "Iceland", + "continent": "Europe" + }, + { + "country": "India", + "continent": "Asia" + }, + { + "country": "Indonesia", + "continent": "Asia" + }, + { + "country": "Iran", + "continent": "Asia" + }, + { + "country": "Iraq", + "continent": "Asia" + }, + { + "country": "Ireland", + "continent": "Europe" + }, + { + "country": "Israel", + "continent": "Asia" + }, + { + "country": "Italy", + "continent": "Europe" + }, + { + "country": "Ivory Coast", + "continent": "Africa" + }, + { + "country": "Jamaica", + "continent": "North America" + }, + { + "country": "Japan", + "continent": "Asia" + }, + { + "country": "Jordan", + "continent": "Asia" + }, + { + "country": "Kazakhstan", + "continent": "Asia" + }, + { + "country": "Kenya", + "continent": "Africa" + }, + { + "country": "Kiribati", + "continent": "Oceania" + }, + { + "country": "Kuwait", + "continent": "Asia" + }, + { + "country": "Kyrgyzstan", + "continent": "Asia" + }, + { + "country": "Laos", + "continent": "Asia" + }, + { + "country": "Latvia", + "continent": "Europe" + }, + { + "country": "Lebanon", + "continent": "Asia" + }, + { + "country": "Lesotho", + "continent": "Africa" + }, + { + "country": "Liberia", + "continent": "Africa" + }, + { + "country": "Libya", + "continent": "Africa" + }, + { + "country": "Liechtenstein", + "continent": "Europe" + }, + { + "country": "Lithuania", + "continent": "Europe" + }, + { + "country": "Luxembourg", + "continent": "Europe" + }, + { + "country": "Macao", + "continent": "Asia" + }, + { + "country": "North Macedonia", + "continent": "Europe" + }, + { + "country": "Madagascar", + "continent": "Africa" + }, + { + "country": "Malawi", + "continent": "Africa" + }, + { + "country": "Malaysia", + "continent": "Asia" + }, + { + "country": "Maldives", + "continent": "Asia" + }, + { + "country": "Mali", + "continent": "Africa" + }, + { + "country": "Malta", + "continent": "Europe" + }, + { + "country": "Marshall Islands", + "continent": "Oceania" + }, + { + "country": "Martinique", + "continent": "North America" + }, + { + "country": "Mauritania", + "continent": "Africa" + }, + { + "country": "Mauritius", + "continent": "Africa" + }, + { + "country": "Mayotte", + "continent": "Africa" + }, + { + "country": "Mexico", + "continent": "North America" + }, + { + "country": "Micronesia, Federated States of", + "continent": "Oceania" + }, + { + "country": "Moldova", + "continent": "Europe" + }, + { + "country": "Monaco", + "continent": "Europe" + }, + { + "country": "Mongolia", + "continent": "Asia" + }, + { + "country": "Montenegro", + "continent": "Europe" + }, + { + "country": "Montserrat", + "continent": "North America" + }, + { + "country": "Morocco", + "continent": "Africa" + }, + { + "country": "Mozambique", + "continent": "Africa" + }, + { + "country": "Myanmar", + "continent": "Asia" + }, + { + "country": "Namibia", + "continent": "Africa" + }, + { + "country": "Nauru", + "continent": "Oceania" + }, + { + "country": "Nepal", + "continent": "Asia" + }, + { + "country": "Netherlands", + "continent": "Europe" + }, + { + "country": "Netherlands Antilles", + "continent": "North America" + }, + { + "country": "New Caledonia", + "continent": "Oceania" + }, + { + "country": "New Zealand", + "continent": "Oceania" + }, + { + "country": "Nicaragua", + "continent": "North America" + }, + { + "country": "Niger", + "continent": "Africa" + }, + { + "country": "Nigeria", + "continent": "Africa" + }, + { + "country": "Niue", + "continent": "Oceania" + }, + { + "country": "Norfolk Island", + "continent": "Oceania" + }, + { + "country": "North Korea", + "continent": "Asia" + }, + { + "country": "Northern Ireland", + "continent": "Europe" + }, + { + "country": "Northern Mariana Islands", + "continent": "Oceania" + }, + { + "country": "Norway", + "continent": "Europe" + }, + { + "country": "Oman", + "continent": "Asia" + }, + { + "country": "Pakistan", + "continent": "Asia" + }, + { + "country": "Palau", + "continent": "Oceania" + }, + { + "country": "Palestine", + "continent": "Asia" + }, + { + "country": "Panama", + "continent": "North America" + }, + { + "country": "Papua New Guinea", + "continent": "Oceania" + }, + { + "country": "Paraguay", + "continent": "South America" + }, + { + "country": "Peru", + "continent": "South America" + }, + { + "country": "Philippines", + "continent": "Asia" + }, + { + "country": "Pitcairn", + "continent": "Oceania" + }, + { + "country": "Poland", + "continent": "Europe" + }, + { + "country": "Portugal", + "continent": "Europe" + }, + { + "country": "Puerto Rico", + "continent": "North America" + }, + { + "country": "Qatar", + "continent": "Asia" + }, + { + "country": "Reunion", + "continent": "Africa" + }, + { + "country": "Romania", + "continent": "Europe" + }, + { + "country": "Russia", + "continent": "Europe" + }, + { + "country": "Rwanda", + "continent": "Africa" + }, + { + "country": "Saint Helena", + "continent": "Africa" + }, + { + "country": "Saint Kitts and Nevis", + "continent": "North America" + }, + { + "country": "Saint Lucia", + "continent": "North America" + }, + { + "country": "Saint Pierre and Miquelon", + "continent": "North America" + }, + { + "country": "Saint Vincent and the Grenadines", + "continent": "North America" + }, + { + "country": "Samoa", + "continent": "Oceania" + }, + { + "country": "San Marino", + "continent": "Europe" + }, + { + "country": "Sao Tome and Principe", + "continent": "Africa" + }, + { + "country": "Saudi Arabia", + "continent": "Asia" + }, + { + "country": "Scotland", + "continent": "Europe" + }, + { + "country": "Senegal", + "continent": "Africa" + }, + { + "country": "Serbia", + "continent": "Europe" + }, + { + "country": "Seychelles", + "continent": "Africa" + }, + { + "country": "Sierra Leone", + "continent": "Africa" + }, + { + "country": "Singapore", + "continent": "Asia" + }, + { + "country": "Slovakia", + "continent": "Europe" + }, + { + "country": "Slovenia", + "continent": "Europe" + }, + { + "country": "Solomon Islands", + "continent": "Oceania" + }, + { + "country": "Somalia", + "continent": "Africa" + }, + { + "country": "South Africa", + "continent": "Africa" + }, + { + "country": "South Georgia and the South Sandwich Islands", + "continent": "Antarctica" + }, + { + "country": "South Korea", + "continent": "Asia" + }, + { + "country": "South Sudan", + "continent": "Africa" + }, + { + "country": "Spain", + "continent": "Europe" + }, + { + "country": "Sri Lanka", + "continent": "Asia" + }, + { + "country": "Sudan", + "continent": "Africa" + }, + { + "country": "Suriname", + "continent": "South America" + }, + { + "country": "Svalbard and Jan Mayen", + "continent": "Europe" + }, + { + "country": "Sweden", + "continent": "Europe" + }, + { + "country": "Switzerland", + "continent": "Europe" + }, + { + "country": "Syria", + "continent": "Asia" + }, + { + "country": "Tajikistan", + "continent": "Asia" + }, + { + "country": "Tanzania", + "continent": "Africa" + }, + { + "country": "Thailand", + "continent": "Asia" + }, + { + "country": "The Democratic Republic of Congo", + "continent": "Africa" + }, + { + "country": "Togo", + "continent": "Africa" + }, + { + "country": "Tokelau", + "continent": "Oceania" + }, + { + "country": "Tonga", + "continent": "Oceania" + }, + { + "country": "Trinidad and Tobago", + "continent": "North America" + }, + { + "country": "Tunisia", + "continent": "Africa" + }, + { + "country": "Turkey", + "continent": "Asia" + }, + { + "country": "Turkmenistan", + "continent": "Asia" + }, + { + "country": "Turks and Caicos Islands", + "continent": "North America" + }, + { + "country": "Tuvalu", + "continent": "Oceania" + }, + { + "country": "Uganda", + "continent": "Africa" + }, + { + "country": "Ukraine", + "continent": "Europe" + }, + { + "country": "United Arab Emirates", + "continent": "Asia" + }, + { + "country": "United Kingdom", + "continent": "Europe" + }, + { + "country": "United States", + "continent": "North America" + }, + { + "country": "United States Minor Outlying Islands", + "continent": "Oceania" + }, + { + "country": "Uruguay", + "continent": "South America" + }, + { + "country": "Uzbekistan", + "continent": "Asia" + }, + { + "country": "Vanuatu", + "continent": "Oceania" + }, + { + "country": "Venezuela", + "continent": "South America" + }, + { + "country": "Vietnam", + "continent": "Asia" + }, + { + "country": "Virgin Islands, British", + "continent": "North America" + }, + { + "country": "Virgin Islands, U.S.", + "continent": "North America" + }, + { + "country": "Wales", + "continent": "Europe" + }, + { + "country": "Wallis and Futuna", + "continent": "Oceania" + }, + { + "country": "Western Sahara", + "continent": "Africa" + }, + { + "country": "Yemen", + "continent": "Asia" + }, + { + "country": "Zambia", + "continent": "Africa" + }, + { + "country": "Zimbabwe", + "continent": "Africa" + } +] diff --git a/src/constants/shippingRegions.ts b/src/constants/shippingRegions.ts new file mode 100644 index 0000000..02021e8 --- /dev/null +++ b/src/constants/shippingRegions.ts @@ -0,0 +1,436 @@ +import rawCountryContinents from "./country-by-continent.json"; +import usStates from "./states.json"; + +export type ShippingNodeType = "any" | "continent" | "country" | "state"; + +export interface ShippingNode { + id: string; + label: string; + type: ShippingNodeType; + parentId?: string; + children?: ShippingNode[]; +} + +const slugify = (value: string) => + value + .normalize("NFKD") + .replace(/[\u0300-\u036f]/g, "") + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, ""); + +const CONTINENT_LABELS: Record = { + Africa: "Africa", + Antarctica: "Antarctica", + Asia: "Asia", + Europe: "Europe", + "North America": "North America", + "South America": "South America", + Oceania: "Oceania", +}; + +const CONTINENT_ORDER = [ + "North America", + "South America", + "Europe", + "Asia", + "Africa", + "Oceania", + "Antarctica", +]; + +const ANY_NODE: ShippingNode = { + id: "any", + label: "Any", + type: "any", +}; + +const continentNodesMap = new Map(); + +CONTINENT_ORDER.forEach((continentName) => { + const label = CONTINENT_LABELS[continentName] || continentName; + const node: ShippingNode = { + id: `continent:${slugify(continentName)}`, + label, + type: "continent", + children: [], + }; + continentNodesMap.set(continentName, node); +}); + +const normalizeLookupValue = (value: string) => + value + .normalize("NFKD") + .replace(/[\u0300-\u036f]/g, "") + .toLowerCase() + .replace(/[^a-z0-9]+/g, " ") + .trim(); + +export const ANY_LOCATION_SYNONYMS = [ + "Any", + "all", + "all shipping options", + "any country", + "any place via Q-Mail", + "anywhere", + "anywhere..", + "digital/anywhere", + "digitally everywhere", + "Domestic, international", + "europe & international", + "everywhere", + "globally", + "Iceland, Poland, UK, USA, Canada & all around the world", + "internet - digital products", + "Milky Way", + "n/a: digital services", + "na", + "online", + "online video call", + "pdf download", + "Qortal", + "The aethers", + "US and Canada, and will ship to any location with confirmed Qortal Account address", + "USA preferred / Worldwide Possible", + "world wide", + "world wide.", + "worldwide", + "world-wide." +]; + +export const US50_LOCATION_SYNONYMS = [ + "United States", + "US", + "USA", + "U.S.", + "U.S", + "U.S.A", + "U.S.A.", + "nationally", + "nationwide", + "Stateside so far, international being researched.", + "US only for now" +]; + +export const US48_LOCATION_SYNONYMS = [ + "US (Continental)", + "Continental US", + "CONUS", + "Free shipping in the land currently known as USA within the North American continent, excluding Alaska" +]; + +const LEGACY_LABEL_ALIASES: Record = {}; + +ANY_LOCATION_SYNONYMS.forEach((entry) => { + LEGACY_LABEL_ALIASES[normalizeLookupValue(entry)] = "Any"; +}); + +US50_LOCATION_SYNONYMS.forEach((entry) => { + LEGACY_LABEL_ALIASES[normalizeLookupValue(entry)] = "United States"; +}); + +US48_LOCATION_SYNONYMS.forEach((entry) => { + LEGACY_LABEL_ALIASES[normalizeLookupValue(entry)] = "US (Continental)"; +}); + +const LEGACY_MULTI_LOCATION_OVERRIDES: Record = { + [normalizeLookupValue("Austria Germany Switzerland")]: [ + "Austria", + "Germany", + "Switzerland", + ], + [normalizeLookupValue("Canada and USA")]: ["Canada", "United States"], + [normalizeLookupValue("Canada, USA, Mexico")]: [ + "Canada", + "United States", + "Mexico", + ], + [normalizeLookupValue("Europe, Mexico")]: ["Europe", "Mexico"], + [normalizeLookupValue("Iceland, Poland")]: ["Iceland", "Poland"], + [normalizeLookupValue("US and Canada")]: ["United States", "Canada"], + [normalizeLookupValue("USA, CANADA, EU, OCEANIA")]: [ + "United States", + "Canada", + "Europe", + "Oceania", + ], + [normalizeLookupValue("USA, Europe, United Kingdom, Australia")]: [ + "United States", + "Europe", + "United Kingdom", + "Australia", + ], +}; + +const COUNTRY_NAME_OVERRIDES: Record = { + brunei: "Brunei Darussalam", + "east timor": "Timor-Leste", + eswatini: "Swaziland", + "falkland islands": "Falkland Islands (Islas Malvinas)", + "fiji islands": "Fiji", + iran: "Iran, Islamic Republic Of", + "ivory coast": "Cote D'Ivoire", + laos: "Lao People's Democratic Republic", + libya: "Libyan Arab Jamahiriya", + "north macedonia": "North Macedonia", + moldova: "Moldova", + "north korea": "North Korea", + palestine: "Palestine", + russia: "Russia", + serbia: "Serbia", + "south korea": "South Korea", + "south sudan": "South Sudan", + syria: "Syria", + tanzania: "Tanzania", + "the democratic republic of congo": "Democratic Republic of the Congo", +}; + +const dedupeCountries = new Map>(); + +rawCountryContinents.forEach(({ country, continent }) => { + if (!continentNodesMap.has(continent)) return; + const normalized = normalizeLookupValue(country); + if (!dedupeCountries.has(continent)) { + dedupeCountries.set(continent, new Set()); + } + dedupeCountries.get(continent)!.add( + COUNTRY_NAME_OVERRIDES[normalized] || country + ); +}); + +const addUnitedStatesStates = (parent: ShippingNode) => { + parent.children = (parent.children || []).concat( + usStates.map((state) => ({ + id: `state:us-${state.value.toLowerCase()}`, + label: state.text, + type: "state" as const, + parentId: parent.id, + })) + ); +}; + +dedupeCountries.forEach((countriesSet, continent) => { + const continentNode = continentNodesMap.get(continent); + if (!continentNode) return; + + const countries = Array.from(countriesSet).sort((a, b) => + a.localeCompare(b) + ); + + continentNode.children = countries.map((countryName) => { + const node: ShippingNode = { + id: `country:${slugify(countryName)}`, + label: countryName, + type: "country", + parentId: continentNode.id, + children: [], + }; + if (countryName.toLowerCase() === "united states") { + addUnitedStatesStates(node); + } + if (node.children && node.children.length === 0) { + delete node.children; + } + return node; + }); +}); + +const continents = CONTINENT_ORDER.map( + (continentName) => continentNodesMap.get(continentName)! +).filter(Boolean); + +export const SHIPPING_REGION_TREE: ShippingNode[] = [ANY_NODE, ...continents]; +export const SHIPPING_CONTINENT_NODES = continents; + +const descendantLeafMap = new Map(); +const nodeById = new Map(); +const labelLookup = new Map(); + +const collectDescendantLeaves = (node: ShippingNode): string[] => { + nodeById.set(node.id, node); + labelLookup.set(normalizeLookupValue(node.label), node); + if (!node.children || node.children.length === 0) { + descendantLeafMap.set(node.id, [node.id]); + return [node.id]; + } + const leaves = node.children.flatMap(collectDescendantLeaves); + descendantLeafMap.set(node.id, leaves); + return leaves; +}; + +SHIPPING_REGION_TREE.forEach((node) => collectDescendantLeaves(node)); + +const leafIds = Array.from( + new Set( + SHIPPING_REGION_TREE.flatMap((node) => descendantLeafMap.get(node.id) || []) + ) +).filter((id) => { + const node = nodeById.get(id); + return node ? !node.children || node.children.length === 0 : false; +}); + +export const SHIPPING_LEAF_IDS = leafIds; +export const SHIPPING_NODE_BY_ID = nodeById; +export const SHIPPING_DESCENDANT_LEAVES = descendantLeafMap; + +export const getShippingNode = (id: string): ShippingNode | undefined => + SHIPPING_NODE_BY_ID.get(id); + +export const getShippingNodeByLabel = (label: string): ShippingNode | undefined => + labelLookup.get(normalizeLookupValue(label)); + +export const isLeafShippingNode = (id: string): boolean => { + const node = SHIPPING_NODE_BY_ID.get(id); + if (!node) return false; + return !node.children || node.children.length === 0; +}; + +export const getDescendantLeafIds = (id: string): string[] => { + if (SHIPPING_DESCENDANT_LEAVES.has(id)) { + return SHIPPING_DESCENDANT_LEAVES.get(id)!; + } + if (isLeafShippingNode(id)) return [id]; + return []; +}; + +export const getAllLeafIds = (): string[] => [...SHIPPING_LEAF_IDS]; + +export const getDisplayLabelForId = (id: string): string => { + const node = SHIPPING_NODE_BY_ID.get(id); + return node?.label ?? id; +}; + +export const getAncestorChain = (id: string): ShippingNode[] => { + const chain: ShippingNode[] = []; + let current = SHIPPING_NODE_BY_ID.get(id); + while (current) { + chain.unshift(current); + if (!current.parentId) break; + current = SHIPPING_NODE_BY_ID.get(current.parentId); + } + return chain; +}; + +export const getSelectableIdsForNode = (id: string): string[] => { + const leaves = getDescendantLeafIds(id); + if (leaves.length === 0 && isLeafShippingNode(id)) return [id]; + return leaves; +}; + +export const sanitizeShippingSelection = (ids: string[]): string[] => { + const unique = new Set(); + ids.forEach((id) => { + if (isLeafShippingNode(id)) unique.add(id); + }); + return Array.from(unique); +}; + +const resolveLegacyValueInternal = (raw: string, depth = 0): string[] => { + if (!raw || depth > 5) return []; + const normalized = normalizeLookupValue(raw); + if (!normalized) return []; + + const override = LEGACY_MULTI_LOCATION_OVERRIDES[normalized]; + if (override) { + const expanded = override.flatMap((entry) => + resolveLegacyValueInternal(entry, depth + 1) + ); + if (expanded.length > 0) return sanitizeShippingSelection(expanded); + } + + const aliasTarget = LEGACY_LABEL_ALIASES[normalized]; + if (aliasTarget) { + if (aliasTarget === "Any") { + return getAllLeafIds(); + } + const aliasNode = getShippingNodeByLabel(aliasTarget); + if (aliasNode) { + return getSelectableIdsForNode(aliasNode.id); + } + return resolveLegacyValueInternal(aliasTarget, depth + 1); + } + + const node = getShippingNodeByLabel(raw) || labelLookup.get(normalized); + if (node) { + return getSelectableIdsForNode(node.id); + } + + if (/[,&/]/.test(raw) || /\band\b/i.test(raw)) { + const prepared = raw + .replace(/\band\b/gi, ",") + .replace(/[&/]/g, ","); + const parts = prepared + .split(",") + .map((part) => part.trim()) + .filter(Boolean); + if (parts.length > 1) { + const aggregated = parts.flatMap((part) => + resolveLegacyValueInternal(part, depth + 1) + ); + if (aggregated.length > 0) { + return sanitizeShippingSelection(aggregated); + } + } + } + + return []; +}; + +export const resolveLegacyShipsToValue = (value: string): string[] => + resolveLegacyValueInternal(value); + +export const resolveLegacyShipsToSelection = ( + value: string | string[] | undefined +): string[] => { + if (!value) return []; + if (Array.isArray(value)) { + return sanitizeShippingSelection(value); + } + return resolveLegacyValueInternal(value); +}; + +const addLabelAndPrune = ( + result: string[], + remaining: Set, + label: string, + ids: string[] +) => { + result.push(label); + ids.forEach((id) => remaining.delete(id)); +}; + +export const summarizeShippingSelection = (ids: string[]): string[] => { + const sanitized = sanitizeShippingSelection(ids); + if (sanitized.length === 0) return []; + const remaining = new Set(sanitized); + if (remaining.size === SHIPPING_LEAF_IDS.length) { + return ["Any"]; + } + const summary: string[] = []; + + continents.forEach((continentNode) => { + const leaves = getDescendantLeafIds(continentNode.id); + if (leaves.length === 0) return; + const allSelected = leaves.every((id) => remaining.has(id)); + if (allSelected) { + addLabelAndPrune(summary, remaining, continentNode.label, leaves); + } + }); + + continents.forEach((continentNode) => { + continentNode.children?.forEach((countryNode) => { + const leaves = getDescendantLeafIds(countryNode.id); + if (leaves.length === 0) return; + const allSelected = leaves.every((id) => remaining.has(id)); + if (allSelected) { + addLabelAndPrune(summary, remaining, countryNode.label, leaves); + } + }); + }); + + remaining.forEach((id) => { + summary.push(getDisplayLabelForId(id)); + }); + + return summary.sort((a, b) => a.localeCompare(b)); +}; diff --git a/src/hooks/useGlobalSearch.ts b/src/hooks/useGlobalSearch.ts index 7e61b4e..d865d20 100644 --- a/src/hooks/useGlobalSearch.ts +++ b/src/hooks/useGlobalSearch.ts @@ -1,5 +1,6 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { STORE_BASE, CATALOGUE_BASE, PRODUCT_BASE } from "../constants/identifiers"; +import { getDisplayLabelForId } from "../constants/shippingRegions"; export interface ShopResult { type: "shop"; @@ -93,7 +94,12 @@ export const useGlobalSearch = (query: string): UseGlobalSearchResult => { const rawUrl = `/arbitrary/STORE/${m.owner}/${m.id}`; const rawRes = await fetch(rawUrl, { method: "GET", headers: { "Content-Type": "application/json" } }); const raw = await rawRes.json(); - const matchesContent = matchAny(q, [raw?.title, raw?.description, raw?.location, raw?.shipsTo, (raw?.supportedCoins || []).join(","), Object.keys(raw?.foreignCoins || {}).join(",")]); + const shipsToField = Array.isArray(raw?.shipsTo) + ? raw.shipsTo + .map((id: string) => getDisplayLabelForId(id)) + .join(", ") + : raw?.shipsTo; + const matchesContent = matchAny(q, [raw?.title, raw?.description, raw?.location, shipsToField, (raw?.supportedCoins || []).join(","), Object.keys(raw?.foreignCoins || {}).join(",")]); return { m, raw, matchesContent }; } catch (_) { return { m, raw: null, matchesContent: false }; diff --git a/src/pages/Store/Store/Store.tsx b/src/pages/Store/Store/Store.tsx index 54617b2..95efde1 100644 --- a/src/pages/Store/Store/Store.tsx +++ b/src/pages/Store/Store/Store.tsx @@ -38,6 +38,10 @@ import { } from "../../../state/features/storeSlice"; import LazyLoad from "../../../components/common/LazyLoad"; import ContextMenuResource from "../../../components/common/ContextMenu/ContextMenuResource"; +import { + resolveLegacyShipsToSelection, + sanitizeShippingSelection, +} from "../../../constants/shippingRegions"; import { setStoreId, setStoreOwner } from "../../../state/features/storeSlice"; import { ProductCard } from "../ProductCard/ProductCard"; import { ProductDataContainer } from "../../../state/features/globalSlice"; @@ -412,6 +416,11 @@ const switchCoin = async ()=> { }, }); const responseData = await resource.json(); + const normalizedShipsTo = sanitizeShippingSelection( + Array.isArray(responseData?.shipsTo) + ? responseData?.shipsTo + : resolveLegacyShipsToSelection(responseData?.shipsTo) + ); // Set shop data to redux now that you have the info/metadata & resource dispatch( setCurrentViewedStore({ @@ -419,14 +428,15 @@ const switchCoin = async ()=> { id: myStore.identifier, title: responseData?.title || "", location: responseData?.location, - shipsTo: responseData?.shipsTo, + shipsTo: normalizedShipsTo, description: responseData?.description || "", category: myStore.metadata?.category, tags: myStore.metadata?.tags || [], logo: responseData?.logo || "", shortStoreId: responseData?.shortStoreId, supportedCoins: responseData?.supportedCoins || [], - foreignCoins: responseData?.foreignCoins || {} + foreignCoins: responseData?.foreignCoins || {}, + shippingInfo: responseData?.shippingInfo, }) ); @@ -1164,8 +1174,8 @@ const switchCoin = async ()=> { } shipsTo={ username === user?.name - ? currentStore?.shipsTo || "" - : currentViewedStore?.shipsTo || "" + ? currentStore?.shipsTo ?? [] + : currentViewedStore?.shipsTo ?? [] } supportedCoins={ username === user?.name @@ -1177,6 +1187,11 @@ const switchCoin = async ()=> { ? currentStore?.foreignCoins || {} : currentViewedStore?.foreignCoins || {} } + shippingInfo={ + username === user?.name + ? (currentStore?.shippingInfo as string) || "" + : (currentViewedStore?.shippingInfo as string) || "" + } /> void; supportedCoins: string[]; - foreignCoins: ForeignCoins + foreignCoins: ForeignCoins; + shippingInfo?: string; } export const StoreDetails: FC = ({ @@ -47,9 +52,19 @@ export const StoreDetails: FC = ({ shipsTo, setOpenStoreDetails, supportedCoins, - foreignCoins + foreignCoins, + shippingInfo, }) => { const theme = useTheme(); + const shippingSelection = Array.isArray(shipsTo) + ? shipsTo + : resolveLegacyShipsToSelection(shipsTo); + const shippingSummary = shippingSelection.length + ? summarizeShippingSelection(shippingSelection).join(", ") + : ""; + const shippingInfoText = Array.isArray(shipsTo) + ? shippingInfo?.trim() + : (shipsTo as string)?.trim() || shippingInfo?.trim(); return ( <> @@ -109,17 +124,32 @@ export const StoreDetails: FC = ({ {location}
- - - - Ships To - - {shipsTo} - + {shippingSelection.length > 0 && ( + + + + Ships To + + {shippingSummary} + + )} + {shippingInfoText && ( + + + + Shipping Info + + {shippingInfoText} + + )} { +const normalizeLabelKey = (value: string) => { if (!value) return ""; - const stripped = value + return value .normalize("NFKD") .replace(/[\u0300-\u036f]/g, "") .toLowerCase() .replace(/[^a-z0-9]/g, ""); - return stripped; -}; - -const ANY_LOCATION_SYNONYMS = [ - "Any", - "all", - "all shipping options", - "any country", - "any place via Q-Mail", - "anywhere", - "anywhere..", - "Available at location only but gift certs delivered anywhere.", - "digital/anywhere", - "digitally everywhere", - "Domestic, international", - "europe & international", - "everywhere", - "globally", - "Iceland, Poland, UK, USA, Canada & all around the world", - "internet - digital products", - "Milky Way", - "n/a: digital services", - "na", - "online", - "online video call", - "pdf download", - "Qortal", - "The aethers", - "US and Canada, and will ship to any location with confirmed Qortal Account address", - "USA preferred / Worldwide Possible", - "world wide", - "world wide.", - "worldwide", - "world-wide." -]; - -const EE_LOCATION_SYNONYMS = [ - "Eesti", - "Üle Eesti" -]; - -const US50_LOCATION_SYNONYMS = [ - "United States", - "US", - "USA", - "U.S.", - "U.S", - "U.S.A", - "U.S.A.", - "nationally", - "nationwide", - "Stateside so far, international being researched.", - "US only for now" -]; - -const US48_LOCATION_SYNONYMS = [ - "US (Continental)", - "Continental US", - "CONUS", - "Free shipping in the land currently known as USA within the North American continent, excluding Alaska" -]; - -const LOCATION_CANONICAL_LABELS: Record = [ - { label: "Any", synonyms: ANY_LOCATION_SYNONYMS }, - { label: "Estonia", synonyms: EE_LOCATION_SYNONYMS }, - { label: "United States", synonyms: US50_LOCATION_SYNONYMS }, - { label: "US (Continental)", synonyms: US48_LOCATION_SYNONYMS } -].reduce((acc, { label, synonyms }) => { - synonyms.forEach((entry) => { - const key = normalizeLocationKey(entry); - if (key) acc[key] = label; - }); - return acc; -}, {} as Record); - -const MULTI_LOCATION_OVERRIDES: Record = { - [normalizeLocationKey("Austria Germany Switzerland")]: ["Austria", "Germany", "Switzerland"], - [normalizeLocationKey("Canada and USA")]: ["Canada", "United States"], - [normalizeLocationKey("Canada, USA, Mexico")]: ["Canada", "United States", "Mexico"], - [normalizeLocationKey("Europe, Mexico")]: ["Europe", "Mexico"], - [normalizeLocationKey("Iceland, Poland")]: ["Iceland", "Poland"], - [normalizeLocationKey("US and Canada")]: ["United States", "Canada"], - [normalizeLocationKey("USA, CANADA, EU, OCEANA")]: ["United States", "Canada", "European Union", "Oceania"], - [normalizeLocationKey("USA, Europe, United Kindom, Australia")]: ["United States", "Europe", "United Kingdom", "Australia"], }; const prettifyLocationLabel = (label: string) => { @@ -127,21 +48,27 @@ const prettifyLocationLabel = (label: string) => { if (/[a-z]/.test(label)) return label; return label .split(/\s+/) - .map((word) => (word.length <= 3 ? word.toUpperCase() : word.charAt(0) + word.slice(1).toLowerCase())) + .map((word) => + word.length <= 3 ? word.toUpperCase() : word[0] + word.slice(1).toLowerCase() + ) .join(" "); }; -const resolveLocation = (raw: string) => { - const trimmed = raw.trim(); - if (!trimmed) { - return { key: "unspecified", label: DEFAULT_LOCATION_LABEL }; +const extractLocationLabels = ( + shipsTo: string | string[] | undefined +): string[] => { + if (Array.isArray(shipsTo)) { + const selection = sanitizeShippingSelection(shipsTo); + if (selection.length) { + const summary = summarizeShippingSelection(selection); + return summary.length ? summary : [DEFAULT_LOCATION_LABEL]; + } + return []; } - const normalized = normalizeLocationKey(trimmed); - const canonicalLabel = LOCATION_CANONICAL_LABELS[normalized]; - const label = canonicalLabel || prettifyLocationLabel(trimmed) || DEFAULT_LOCATION_LABEL; - const keyBase = canonicalLabel ? normalizeLocationKey(canonicalLabel) : normalized; - const key = keyBase || "unspecified"; - return { key, label }; + if (typeof shipsTo === "string") { + return []; + } + return []; }; export const StoreList = () => { @@ -515,25 +442,26 @@ export const StoreList = () => { const locationSections = useMemo(() => { if (sortBy !== "location") return []; const groups = new Map(); - baseStores.forEach((store) => { - if (store.owner === "Bester") return; - const meta = hashMapStores[store.id]; - const shipsToRaw = (meta?.shipsTo ?? store.shipsTo ?? "").trim(); - const override = MULTI_LOCATION_OVERRIDES[normalizeLocationKey(shipsToRaw)]; - const locations = override && override.length > 0 ? override : [shipsToRaw || ""]; - const seenKeys = new Set(); - locations.forEach((entry) => { - const { key, label } = resolveLocation(entry); - if (!key || seenKeys.has(key)) return; - seenKeys.add(key); - const existing = groups.get(key); - if (existing) { - existing.stores.push(store); - } else { - groups.set(key, { label, stores: [store] }); - } - }); + baseStores.forEach((store) => { + if (store.owner === "Bester") return; + const meta = hashMapStores[store.id]; + const shipsToValue = meta?.shipsTo ?? store.shipsTo; + const derivedLabels = extractLocationLabels(shipsToValue); + const labels = derivedLabels.length > 0 ? derivedLabels : [DEFAULT_LOCATION_LABEL]; + const uniqueLabels = Array.from(new Set(labels)); + uniqueLabels.forEach((label) => { + const normalizedKey = normalizeLabelKey(label) || "unspecified"; + const existing = groups.get(normalizedKey); + if (existing) { + if (!existing.stores.includes(store)) existing.stores.push(store); + } else { + groups.set(normalizedKey, { label, stores: [store] }); + } }); + }); + if (groups.size === 0) { + return []; + } const collator = new Intl.Collator(undefined, { sensitivity: "base", numeric: true }); return Array.from(groups.entries()) .sort((a, b) => collator.compare(a[1].label, b[1].label)) diff --git a/src/state/features/globalSlice.ts b/src/state/features/globalSlice.ts index d8aa90f..06137fe 100644 --- a/src/state/features/globalSlice.ts +++ b/src/state/features/globalSlice.ts @@ -24,7 +24,8 @@ export interface CurrentStore { shortStoreId: string; logo?: string; location?: string; - shipsTo?: string; + shipsTo?: string | string[]; + shippingInfo?: string; foreignCoins: ForeignCoins; supportedCoins: string[]; } diff --git a/src/state/features/storeSlice.ts b/src/state/features/storeSlice.ts index 12d46f7..7a60668 100644 --- a/src/state/features/storeSlice.ts +++ b/src/state/features/storeSlice.ts @@ -91,7 +91,8 @@ export interface Store { isValid?: boolean; logo?: string; location?: string; - shipsTo?: string; + shipsTo?: string | string[]; + shippingInfo?: string; shortStoreId?: string; foreignCoins?: ForeignCoins; supportedCoins?: string[]; diff --git a/src/utils/checkStructure.ts b/src/utils/checkStructure.ts index 1e67a4f..07f3830 100644 --- a/src/utils/checkStructure.ts +++ b/src/utils/checkStructure.ts @@ -41,7 +41,12 @@ export const checkStructureStore = (content: any) => { if (!content?.title) isValid = false; if (!content?.created) isValid = false; if (!content?.description) isValid = false; - if (!content?.shipsTo) isValid = false; + const shipsToValue = content?.shipsTo; + if ( + !shipsToValue || + (Array.isArray(shipsToValue) && shipsToValue.length === 0) + ) + isValid = false; if (!content?.shortStoreId) isValid = false; return isValid; }; diff --git a/src/wrappers/GlobalWrapper.tsx b/src/wrappers/GlobalWrapper.tsx index 19749d6..1ee4866 100644 --- a/src/wrappers/GlobalWrapper.tsx +++ b/src/wrappers/GlobalWrapper.tsx @@ -2,6 +2,10 @@ import React, { useEffect, useState } from "react"; import { useDispatch, useSelector } from "react-redux"; import { addUser } from "../state/features/authSlice"; import { getAccountNames, getPrimaryAccountName } from "../utils/qortalRequestFunctions"; +import { + resolveLegacyShipsToSelection, + sanitizeShippingSelection, +} from "../constants/shippingRegions"; import { RootState } from "../state/store"; import CreateStoreModal, { onPublishParam, @@ -195,13 +199,18 @@ const GlobalWrapper: React.FC = ({ children, setTheme }) => { }); const responseData2 = await response2.json(); // Set currentStore in the Redux global state + const normalizedCurrentShipsTo = sanitizeShippingSelection( + Array.isArray(responseData?.shipsTo) + ? responseData.shipsTo + : resolveLegacyShipsToSelection(responseData?.shipsTo) + ); dispatch( setCurrentStore({ created: responseData?.created || "", id: store.identifier, title: responseData?.title || "", location: responseData?.location, - shipsTo: responseData?.shipsTo, + shipsTo: normalizedCurrentShipsTo, description: responseData?.description || "", category: store.metadata?.category, tags: store.metadata?.tags || [], @@ -209,6 +218,7 @@ const GlobalWrapper: React.FC = ({ children, setTheme }) => { shortStoreId: responseData?.shortStoreId, supportedCoins: responseData?.supportedCoins || [], foreignCoins: responseData?.foreignCoins || {}, + shippingInfo: responseData?.shippingInfo, }) ); // Set listProducts in the Redux global state @@ -286,6 +296,7 @@ const GlobalWrapper: React.FC = ({ children, setTheme }) => { logo, foreignCoins, supportedCoins, + shippingInfo, }: onPublishParam) => { if(isCreatingShop) return setIsCreatingShop(true) @@ -294,7 +305,13 @@ const GlobalWrapper: React.FC = ({ children, setTheme }) => { if (!title) throw new Error("A title is required"); if (!description) throw new Error("A description is required"); if (!location) throw new Error("A location is required"); - if (!shipsTo) throw new Error("Ships to is required"); + const normalizedShipsTo = sanitizeShippingSelection( + Array.isArray(shipsTo) + ? shipsTo + : resolveLegacyShipsToSelection(shipsTo) + ); + if (!normalizedShipsTo.length) + throw new Error("Ships to is required"); const name = user?.selectedName ?? user?.name; if (!name) return; let formatStoreIdentifier = storeIdentifier; @@ -318,12 +335,13 @@ const GlobalWrapper: React.FC = ({ children, setTheme }) => { title, description, location, - shipsTo, + shipsTo: normalizedShipsTo, created: Date.now(), shortStoreId: formatStoreIdentifier, logo, foreignCoins, supportedCoins, + shippingInfo: shippingInfo?.trim?.() || "", }; if (!storeObj.shortStoreId) { throw new Error("Please insert a valid store id"); @@ -365,6 +383,7 @@ const GlobalWrapper: React.FC = ({ children, setTheme }) => { id: storeIdentifier, shortStoreId: formatStoreIdentifier, logo: logo, + shippingInfo: storeObj.shippingInfo, }; // Store Full Object to send to redux hashMapStores const storefullObj = { @@ -448,14 +467,29 @@ const GlobalWrapper: React.FC = ({ children, setTheme }) => { const editStore = React.useCallback( async (param: any) => { - const { title, description, location, shipsTo, logo, foreignCoins, supportedCoins } = param as any; + const { + title, + description, + location, + shipsTo, + logo, + foreignCoins, + supportedCoins, + shippingInfo, + } = param as any; if (!user || (!user.selectedName && !user.name) || !currentStore) throw new Error("Cannot publish: You do not have a Qortal name"); if (!title) throw new Error("A title is required"); if (!description) throw new Error("A description is required"); if (!location) throw new Error("A location is required"); - if (!shipsTo) throw new Error("Ships to is required"); + const normalizedShipsTo = sanitizeShippingSelection( + Array.isArray(shipsTo) + ? shipsTo + : resolveLegacyShipsToSelection(shipsTo) + ); + if (!normalizedShipsTo.length) + throw new Error("Ships to is required"); if (!currentStore.id) throw new Error("Store id is required"); const name = user.selectedName || user.name; @@ -468,11 +502,12 @@ const GlobalWrapper: React.FC = ({ children, setTheme }) => { title, description, location, - shipsTo, + shipsTo: normalizedShipsTo, logo, shortStoreId: currentStore.shortStoreId ?? shortStoreId, foreignCoins, supportedCoins, + shippingInfo: shippingInfo?.trim?.() || "", }; try { @@ -624,6 +659,11 @@ const GlobalWrapper: React.FC = ({ children, setTheme }) => { }, }); const shopResource = await shopData.json(); + const shopShipsTo = sanitizeShippingSelection( + Array.isArray(shopResource?.shipsTo) + ? shopResource.shipsTo + : resolveLegacyShipsToSelection(shopResource?.shipsTo) + ); // Clear product list from redux global state dispatch(resetListProducts()); dispatch( @@ -632,7 +672,7 @@ const GlobalWrapper: React.FC = ({ children, setTheme }) => { id: myStoreFound.id, title: shopResource?.title || "", location: shopResource?.location, - shipsTo: shopResource?.shipsTo, + shipsTo: shopShipsTo, description: shopResource?.description || "", category: myStoreFound?.category, tags: myStoreFound?.tags || [], @@ -640,6 +680,7 @@ const GlobalWrapper: React.FC = ({ children, setTheme }) => { shortStoreId: shopResource?.shortStoreId, supportedCoins: shopResource?.supportedCoins || [], foreignCoins: shopResource?.foreignCoins || {}, + shippingInfo: shopResource?.shippingInfo, }) ); // Fetch data container data on QDN (product resources) -- 2.43.0 From 32f192403ef051a7268a9ea8f004431be228b873 Mon Sep 17 00:00:00 2001 From: q-shop-release-bot Date: Thu, 23 Oct 2025 18:55:57 -0400 Subject: [PATCH 19/19] chore(release): v1.3.0 --- docs/DEV_ANNOUNCEMENT_v1.3.0.md | 16 +++ docs/RELEASE_NOTES_v1.3.0.md | 39 +++++ docs/USER_ANNOUNCEMENT_v1.3.0.md | 16 +++ package-lock.json | 236 ++++++++++--------------------- package.json | 2 +- 5 files changed, 150 insertions(+), 159 deletions(-) create mode 100644 docs/DEV_ANNOUNCEMENT_v1.3.0.md create mode 100644 docs/RELEASE_NOTES_v1.3.0.md create mode 100644 docs/USER_ANNOUNCEMENT_v1.3.0.md diff --git a/docs/DEV_ANNOUNCEMENT_v1.3.0.md b/docs/DEV_ANNOUNCEMENT_v1.3.0.md new file mode 100644 index 0000000..8cc9f85 --- /dev/null +++ b/docs/DEV_ANNOUNCEMENT_v1.3.0.md @@ -0,0 +1,16 @@ +# Q-Shop v1.3.0 — Developer Heads-up + +This release standardises shipping metadata and introduces a location-aware shop listing, plus a small navigation fix. + +- **Hierarchical shipping selector** + - `shipsTo` now stores canonical leaf ids (regions → countries → US states). + - Legacy string values are still accepted; they render under the new “Shipping Info” field until owners migrate. + +- **Location sorting & grouping** + - Shop list supports “Sort by Location”, grouping by resolved leaf labels. + - Legacy shops without structured data appear under a “See shipping info” section so they remain discoverable. + +- **Routing polish** + - Back navigation no longer triggers a full reload, preventing redundant authentication prompts. + +Encourage shop owners (or migrate data programmatically) to adopt the new array format—the structured ids feed search, filtering, and future features without further schema changes. diff --git a/docs/RELEASE_NOTES_v1.3.0.md b/docs/RELEASE_NOTES_v1.3.0.md new file mode 100644 index 0000000..02f43bf --- /dev/null +++ b/docs/RELEASE_NOTES_v1.3.0.md @@ -0,0 +1,39 @@ +# Q-Shop v1.3.0 — Release Notes + +Release date: 2025-10-23 + +## Overview +This minor release focuses on shipping clarity. We’ve introduced a hierarchical shipping selector, surfaced those destinations throughout the UI, and added a new “Sort by Location” option for the shop list. Legacy shops remain compatible, but owners are encouraged to migrate to the structured format for better visibility. We also fixed a navigation annoyance where using the back button forced a full reload and second authentication. + +## Highlights + +### Location-aware ship-to data +- `shipsTo` now stores a list of canonical identifiers representing regions, countries, or U.S. states. The selector lets owners toggle entire continents or drill down to individual destinations. +- A new optional “Shipping Info” text field captures amplifying details (delivery partners, exclusions, digital-only caveats, etc.). +- Legacy string-based `shipsTo` values are preserved. They render inside the Shipping Info panel and do not populate the structured checklist until the owner edits the shop. +- We provide default grouping for the public shop list: legacy shops appear under “See shipping info” so users still know to read the notes. + +### Sort shops by shipping destination +- The storefront now offers “Sort by Location” alongside the existing “Recently Updated/Created” filters. +- Shops are grouped by resolved labels (e.g., “Europe”, “Canada”, “California”) with collapsible sections. Portions of the tree can be collapsed/expanded per user preference. +- Structured data drives grouping; legacy string entries are skipped from the primary locations list to avoid ambiguous labels. + +### Navigation polish +- The browser’s back button now returns you to the prior screen without forcing a full reload. This avoids the extra authentication prompt that previously appeared when navigating back from detail pages. + +## Migration and compatibility +- No schema change is required for existing data; legacy strings remain valid and migrate automatically once owners edit their shop. +- Structured ids are normalised, so search and future filters can rely on consistent keys. +- Encourage owners to update their shipping selections. Structured data makes shops more discoverable and signals current maintenance to customers. + +## File touchpoints +- Core logic: `src/constants/shippingRegions.ts`, `src/components/common/ShippingRegionsSelect.tsx`, `src/wrappers/GlobalWrapper.tsx`. +- UI: `src/components/modals/CreateStoreModal.tsx`, `src/components/modals/EditStoreModal.tsx`, `src/pages/StoreList/StoreList.tsx`, `src/pages/Store/StoreDetails/StoreDetails.tsx`, `src/pages/Store/Store/Store.tsx`. +- Utilities & state: `src/state/features/{storeSlice,globalSlice}.ts`, `src/hooks/useGlobalSearch.ts`, `src/utils/checkStructure.ts`. + +## Testing notes +- Verify that editing a shop with the new selector saves an array of ids and displays the summary and shipping info correctly. +- Confirm that a legacy shop (string `shipsTo`) shows the note under “Shipping Info” and is omitted from the location grouping. +- Exercise the back button across shop detail pages to confirm it no longer triggers a reload/auth cycle. + +Thanks to everyone helping test the new shipping workflow—this lays the groundwork for richer filtering in upcoming releases. diff --git a/docs/USER_ANNOUNCEMENT_v1.3.0.md b/docs/USER_ANNOUNCEMENT_v1.3.0.md new file mode 100644 index 0000000..dbb081c --- /dev/null +++ b/docs/USER_ANNOUNCEMENT_v1.3.0.md @@ -0,0 +1,16 @@ +# Q-Shop v1.3.0 — What’s New + +We’ve made it easier to know where every shop ships, while keeping the experience smoother when you’re browsing. + +- **Sort shops by destination** + - There’s a new “Sort by Location” option that groups shops by the countries or regions they ship to. + - Shops that haven’t been updated yet are listed under “See shipping info” so you still know where they deliver. + +- **Quickly update shipping areas** + - Shop owners now pick destinations from a checklist of regions, countries, or U.S. states—no typing required. + - A new “Shipping Info” note lets owners add clarifying details after selecting their locations. + +- **Smoother navigation** + - The back button now returns you to the previous screen without forcing a reload or an extra authentication. + +If you run a shop, consider updating your shipping details to the new picker—it helps buyers find you faster and shows that your store is actively maintained. diff --git a/package-lock.json b/package-lock.json index 66b46f5..5f5eee8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "q-shop", - "version": "1.2.2", + "version": "1.3.0", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "q-shop", - "version": "1.2.2", + "version": "1.3.0", "dependencies": { "@emotion/react": "^11.10.6", "@emotion/styled": "^11.10.6", @@ -293,6 +293,7 @@ "version": "11.10.6", "resolved": "https://registry.npmjs.org/@emotion/react/-/react-11.10.6.tgz", "integrity": "sha512-6HT8jBmcSkfzO7mc+N1L9uwvOnlcGoix8Zn7srt+9ga0MjREo6lRpuVX0kzo6Jp6oTqDhREOFsygN6Ew4fEQbw==", + "peer": true, "dependencies": { "@babel/runtime": "^7.18.3", "@emotion/babel-plugin": "^11.10.6", @@ -333,6 +334,7 @@ "version": "11.10.6", "resolved": "https://registry.npmjs.org/@emotion/styled/-/styled-11.10.6.tgz", "integrity": "sha512-OXtBzOmDSJo5Q0AFemHCfl+bUueT8BIcPSxu0EGTpGk6DmI5dnhSzQANm1e1ze0YZL7TDyAyy6s/b/zmGOS3Og==", + "peer": true, "dependencies": { "@babel/runtime": "^7.18.3", "@emotion/babel-plugin": "^11.10.6", @@ -847,7 +849,6 @@ "resolved": "https://registry.npmjs.org/@jridgewell/source-map/-/source-map-0.3.2.tgz", "integrity": "sha512-m7O9o2uR8k2ObDysZYzdfhb08VuEml5oWGiosa1VdaPZ/A6QyPkAJuwN0Q1lhULOf6B7MtQmHENS743hWtCrgw==", "dev": true, - "peer": true, "dependencies": { "@jridgewell/gen-mapping": "^0.3.0", "@jridgewell/trace-mapping": "^0.3.9" @@ -942,6 +943,7 @@ "version": "5.11.13", "resolved": "https://registry.npmjs.org/@mui/material/-/material-5.11.13.tgz", "integrity": "sha512-2CnSj43F+159LbGmTLLQs5xbGYMiYlpTByQhP7c7cMX6opbScctBFE1PuyElpAmwW8Ag9ysfZH1d1MFAmJQkjg==", + "peer": true, "dependencies": { "@babel/runtime": "^7.21.0", "@mui/base": "5.0.0-alpha.121", @@ -1280,6 +1282,7 @@ "version": "3.7.0", "resolved": "https://registry.npmjs.org/@react-spectrum/provider/-/provider-3.7.0.tgz", "integrity": "sha512-6Uch60R5PKJC+ZY3VweVadaCoaIj6Nd85TKfFH4jf8NEzXv5CqjwUzwV5aQ6u3k1K24Ves7RpLlK53HW1wUm6w==", + "peer": true, "dependencies": { "@react-aria/i18n": "^3.7.0", "@react-aria/overlays": "^3.13.0", @@ -1666,7 +1669,6 @@ "resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-8.21.3.tgz", "integrity": "sha512-fa7GkppZVEByMWGbTtE5MbmXWJTVbrjjaS8K6uQj+XtuuUv1fsuPAxhygfqLmsb/Ufb3CV8deFCpiMfAgi00Sw==", "dev": true, - "peer": true, "dependencies": { "@types/estree": "*", "@types/json-schema": "*" @@ -1677,7 +1679,6 @@ "resolved": "https://registry.npmjs.org/@types/eslint-scope/-/eslint-scope-3.7.4.tgz", "integrity": "sha512-9K4zoImiZc3HlIp6AVUDE4CWYx22a+lhSZMYNpbjW04+YF0KWj4pJXnEMjdnFTiQibFFmElcsasJXDbdI/EPhA==", "dev": true, - "peer": true, "dependencies": { "@types/eslint": "*", "@types/estree": "*" @@ -1687,13 +1688,13 @@ "version": "0.0.51", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-0.0.51.tgz", "integrity": "sha512-CuPgU6f3eT/XgKKPqKd/gLZV1Xmvf1a2R5POBOGQa6uv82xpls89HU5zKeVoyR8XzHd1RGNOlQlvUe3CFkjWNQ==", - "dev": true, - "peer": true + "dev": true }, "node_modules/@types/hoist-non-react-statics": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/@types/hoist-non-react-statics/-/hoist-non-react-statics-3.3.1.tgz", "integrity": "sha512-iMIqiko6ooLrTh1joXodJK5X9xeEALT1kM5G3ZLhD3hszxBdIEd5C75U834D9mLcINgD4OyZf5uQXjkuYydWvA==", + "peer": true, "dependencies": { "@types/react": "*", "hoist-non-react-statics": "^3.3.0" @@ -1736,6 +1737,7 @@ "version": "18.0.28", "resolved": "https://registry.npmjs.org/@types/react/-/react-18.0.28.tgz", "integrity": "sha512-RD0ivG1kEztNBdoAK7lekI9M+azSnitIn85h4iOiaLjaTrMjzslhaqCGaI4IyCJ1RljWiLCEu4jyrLLgqxBTew==", + "peer": true, "dependencies": { "@types/prop-types": "*", "@types/scheduler": "*", @@ -1756,6 +1758,7 @@ "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.0.11.tgz", "integrity": "sha512-O38bPbI2CWtgw/OoQoY+BRelw7uysmXbWvw3nLWO21H1HSh+GOlqPuXshJfjmpNlKiiSDG9cc1JZAaMmVdcTlw==", "devOptional": true, + "peer": true, "dependencies": { "@types/react": "*" } @@ -1817,7 +1820,6 @@ "resolved": "https://registry.npmjs.org/@webassemblyjs/ast/-/ast-1.11.1.tgz", "integrity": "sha512-ukBh14qFLjxTQNTXocdyksN5QdM28S1CxHt2rdskFyL+xFV7VremuBLVbmCePj+URalXBENx/9Lm7lnhihtCSw==", "dev": true, - "peer": true, "dependencies": { "@webassemblyjs/helper-numbers": "1.11.1", "@webassemblyjs/helper-wasm-bytecode": "1.11.1" @@ -1827,29 +1829,25 @@ "version": "1.11.1", "resolved": "https://registry.npmjs.org/@webassemblyjs/floating-point-hex-parser/-/floating-point-hex-parser-1.11.1.tgz", "integrity": "sha512-iGRfyc5Bq+NnNuX8b5hwBrRjzf0ocrJPI6GWFodBFzmFnyvrQ83SHKhmilCU/8Jv67i4GJZBMhEzltxzcNagtQ==", - "dev": true, - "peer": true + "dev": true }, "node_modules/@webassemblyjs/helper-api-error": { "version": "1.11.1", "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-api-error/-/helper-api-error-1.11.1.tgz", "integrity": "sha512-RlhS8CBCXfRUR/cwo2ho9bkheSXG0+NwooXcc3PAILALf2QLdFyj7KGsKRbVc95hZnhnERon4kW/D3SZpp6Tcg==", - "dev": true, - "peer": true + "dev": true }, "node_modules/@webassemblyjs/helper-buffer": { "version": "1.11.1", "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-buffer/-/helper-buffer-1.11.1.tgz", "integrity": "sha512-gwikF65aDNeeXa8JxXa2BAk+REjSyhrNC9ZwdT0f8jc4dQQeDQ7G4m0f2QCLPJiMTTO6wfDmRmj/pW0PsUvIcA==", - "dev": true, - "peer": true + "dev": true }, "node_modules/@webassemblyjs/helper-numbers": { "version": "1.11.1", "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-numbers/-/helper-numbers-1.11.1.tgz", "integrity": "sha512-vDkbxiB8zfnPdNK9Rajcey5C0w+QJugEglN0of+kmO8l7lDb77AnlKYQF7aarZuCrv+l0UvqL+68gSDr3k9LPQ==", "dev": true, - "peer": true, "dependencies": { "@webassemblyjs/floating-point-hex-parser": "1.11.1", "@webassemblyjs/helper-api-error": "1.11.1", @@ -1860,15 +1858,13 @@ "version": "1.11.1", "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-wasm-bytecode/-/helper-wasm-bytecode-1.11.1.tgz", "integrity": "sha512-PvpoOGiJwXeTrSf/qfudJhwlvDQxFgelbMqtq52WWiXC6Xgg1IREdngmPN3bs4RoO83PnL/nFrxucXj1+BX62Q==", - "dev": true, - "peer": true + "dev": true }, "node_modules/@webassemblyjs/helper-wasm-section": { "version": "1.11.1", "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-wasm-section/-/helper-wasm-section-1.11.1.tgz", "integrity": "sha512-10P9No29rYX1j7F3EVPX3JvGPQPae+AomuSTPiF9eBQeChHI6iqjMIwR9JmOJXwpnn/oVGDk7I5IlskuMwU/pg==", "dev": true, - "peer": true, "dependencies": { "@webassemblyjs/ast": "1.11.1", "@webassemblyjs/helper-buffer": "1.11.1", @@ -1881,7 +1877,6 @@ "resolved": "https://registry.npmjs.org/@webassemblyjs/ieee754/-/ieee754-1.11.1.tgz", "integrity": "sha512-hJ87QIPtAMKbFq6CGTkZYJivEwZDbQUgYd3qKSadTNOhVY7p+gfP6Sr0lLRVTaG1JjFj+r3YchoqRYxNH3M0GQ==", "dev": true, - "peer": true, "dependencies": { "@xtuc/ieee754": "^1.2.0" } @@ -1891,7 +1886,6 @@ "resolved": "https://registry.npmjs.org/@webassemblyjs/leb128/-/leb128-1.11.1.tgz", "integrity": "sha512-BJ2P0hNZ0u+Th1YZXJpzW6miwqQUGcIHT1G/sf72gLVD9DZ5AdYTqPNbHZh6K1M5VmKvFXwGSWZADz+qBWxeRw==", "dev": true, - "peer": true, "dependencies": { "@xtuc/long": "4.2.2" } @@ -1900,15 +1894,13 @@ "version": "1.11.1", "resolved": "https://registry.npmjs.org/@webassemblyjs/utf8/-/utf8-1.11.1.tgz", "integrity": "sha512-9kqcxAEdMhiwQkHpkNiorZzqpGrodQQ2IGrHHxCy+Ozng0ofyMA0lTqiLkVs1uzTRejX+/O0EOT7KxqVPuXosQ==", - "dev": true, - "peer": true + "dev": true }, "node_modules/@webassemblyjs/wasm-edit": { "version": "1.11.1", "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-edit/-/wasm-edit-1.11.1.tgz", "integrity": "sha512-g+RsupUC1aTHfR8CDgnsVRVZFJqdkFHpsHMfJuWQzWU3tvnLC07UqHICfP+4XyL2tnr1amvl1Sdp06TnYCmVkA==", "dev": true, - "peer": true, "dependencies": { "@webassemblyjs/ast": "1.11.1", "@webassemblyjs/helper-buffer": "1.11.1", @@ -1925,7 +1917,6 @@ "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-gen/-/wasm-gen-1.11.1.tgz", "integrity": "sha512-F7QqKXwwNlMmsulj6+O7r4mmtAlCWfO/0HdgOxSklZfQcDu0TpLiD1mRt/zF25Bk59FIjEuGAIyn5ei4yMfLhA==", "dev": true, - "peer": true, "dependencies": { "@webassemblyjs/ast": "1.11.1", "@webassemblyjs/helper-wasm-bytecode": "1.11.1", @@ -1939,7 +1930,6 @@ "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-opt/-/wasm-opt-1.11.1.tgz", "integrity": "sha512-VqnkNqnZlU5EB64pp1l7hdm3hmQw7Vgqa0KF/KCNO9sIpI6Fk6brDEiX+iCOYrvMuBWDws0NkTOxYEb85XQHHw==", "dev": true, - "peer": true, "dependencies": { "@webassemblyjs/ast": "1.11.1", "@webassemblyjs/helper-buffer": "1.11.1", @@ -1952,7 +1942,6 @@ "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-parser/-/wasm-parser-1.11.1.tgz", "integrity": "sha512-rrBujw+dJu32gYB7/Lup6UhdkPx9S9SnobZzRVL7VcBH9Bt9bCBLEuX/YXOOtBsOZ4NQrRykKhffRWHvigQvOA==", "dev": true, - "peer": true, "dependencies": { "@webassemblyjs/ast": "1.11.1", "@webassemblyjs/helper-api-error": "1.11.1", @@ -1967,7 +1956,6 @@ "resolved": "https://registry.npmjs.org/@webassemblyjs/wast-printer/-/wast-printer-1.11.1.tgz", "integrity": "sha512-IQboUWM4eKzWW+N/jij2sRatKMh99QEelo3Eb2q0qXkvPRISAj8Qxtmw5itwqK+TTkBuUIE45AxYPToqPtL5gg==", "dev": true, - "peer": true, "dependencies": { "@webassemblyjs/ast": "1.11.1", "@xtuc/long": "4.2.2" @@ -1977,15 +1965,13 @@ "version": "1.2.0", "resolved": "https://registry.npmjs.org/@xtuc/ieee754/-/ieee754-1.2.0.tgz", "integrity": "sha512-DX8nKgqcGwsc0eJSqYt5lwP4DH5FlHnmuWWBRy7X0NcaGR0ZtuyeESgMwTYVEtxmsNGY+qit4QYT/MIYTOTPeA==", - "dev": true, - "peer": true + "dev": true }, "node_modules/@xtuc/long": { "version": "4.2.2", "resolved": "https://registry.npmjs.org/@xtuc/long/-/long-4.2.2.tgz", "integrity": "sha512-NuHqBY1PB/D8xU6s/thBgOAiAP7HOYDQ32+BFZILJ8ivkUkAHQnWfn6WhL79Owj1qmUnoN/YPhktdIoucipkAQ==", - "dev": true, - "peer": true + "dev": true }, "node_modules/acorn": { "version": "8.8.2", @@ -2005,7 +1991,6 @@ "resolved": "https://registry.npmjs.org/acorn-import-assertions/-/acorn-import-assertions-1.8.0.tgz", "integrity": "sha512-m7VZ3jwz4eK6A4Vtt8Ew1/mNbP24u0FhdyfA7fSvnJR6LMdfOYnmuIrrJAgrYfYJ10F/otaHTtrtrtmHdMNzEw==", "dev": true, - "peer": true, "peerDependencies": { "acorn": "^8" } @@ -2015,6 +2000,7 @@ "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", "dev": true, + "peer": true, "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", @@ -2150,8 +2136,7 @@ "version": "1.1.2", "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", - "dev": true, - "peer": true + "dev": true }, "node_modules/callsites": { "version": "3.1.0", @@ -2183,8 +2168,7 @@ "type": "tidelift", "url": "https://tidelift.com/funding/github/npm/caniuse-lite" } - ], - "peer": true + ] }, "node_modules/chalk": { "version": "2.4.2", @@ -2212,7 +2196,6 @@ "resolved": "https://registry.npmjs.org/chrome-trace-event/-/chrome-trace-event-1.0.3.tgz", "integrity": "sha512-p3KULyQg4S7NIHixdwbGX+nFHkoBiA4YQmyWtjb8XngSKV124nJmRysgAeujbUVb15vh+RvFUfCPqU7rXk+hZg==", "dev": true, - "peer": true, "engines": { "node": ">=6.0" } @@ -2258,8 +2241,7 @@ "version": "2.20.3", "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", - "dev": true, - "peer": true + "dev": true }, "node_modules/compressorjs": { "version": "1.2.1", @@ -2393,8 +2375,7 @@ "version": "1.4.334", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.4.334.tgz", "integrity": "sha512-laZ1odk+TRen6q0GeyQx/JEkpD3iSZT7ewopCpKqg9bTjP1l8XRfU3Bg20CFjNPZkp5+NDBl3iqd4o/kPO+Vew==", - "dev": true, - "peer": true + "dev": true }, "node_modules/emojis-list": { "version": "3.0.0", @@ -2410,7 +2391,6 @@ "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.12.0.tgz", "integrity": "sha512-QHTXI/sZQmko1cbDoNAa3mJ5qhWUUNAq3vR0/YiD379fWQrcfuoX1+HW2S0MTt7XmoPLapdaDKUtelUSPic7hQ==", "dev": true, - "peer": true, "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.2.0" @@ -2431,8 +2411,7 @@ "version": "0.9.3", "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-0.9.3.tgz", "integrity": "sha512-1HQ2M2sPtxwnvOvT1ZClHyQDiggdNjURWpY2we6aMKCQiUVxTmVs2UYPLIrD84sS+kMdUwfBSylbJPwNnBrnHQ==", - "dev": true, - "peer": true + "dev": true }, "node_modules/esbuild": { "version": "0.17.11", @@ -2476,7 +2455,6 @@ "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.1.1.tgz", "integrity": "sha512-k0er2gUkLf8O0zKJiAhmkTnJlTvINGv7ygDNPbeIsX/TJjGJZHuh9B2UxbsaEkmlEo9MfhrSzmhIlhRlI2GXnw==", "dev": true, - "peer": true, "engines": { "node": ">=6" } @@ -2497,7 +2475,6 @@ "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-5.1.1.tgz", "integrity": "sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw==", "dev": true, - "peer": true, "dependencies": { "esrecurse": "^4.3.0", "estraverse": "^4.1.1" @@ -2511,7 +2488,6 @@ "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", "dev": true, - "peer": true, "dependencies": { "estraverse": "^5.2.0" }, @@ -2524,7 +2500,6 @@ "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", "dev": true, - "peer": true, "engines": { "node": ">=4.0" } @@ -2534,7 +2509,6 @@ "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-4.3.0.tgz", "integrity": "sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==", "dev": true, - "peer": true, "engines": { "node": ">=4.0" } @@ -2544,7 +2518,6 @@ "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", "integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==", "dev": true, - "peer": true, "engines": { "node": ">=0.8.x" } @@ -2636,8 +2609,7 @@ "version": "0.4.1", "resolved": "https://registry.npmjs.org/glob-to-regexp/-/glob-to-regexp-0.4.1.tgz", "integrity": "sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw==", - "dev": true, - "peer": true + "dev": true }, "node_modules/globals": { "version": "11.12.0", @@ -2651,8 +2623,7 @@ "version": "4.2.11", "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", - "dev": true, - "peer": true + "dev": true }, "node_modules/has": { "version": "1.0.3", @@ -2776,7 +2747,6 @@ "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-27.5.1.tgz", "integrity": "sha512-7vuh85V5cdDofPyxn58nrPjBktZo0u9x1g8WtjQol+jZDaE+fhN+cIvTj11GndBnMnyfrUOG1sZQxCdjKh+DKg==", "dev": true, - "peer": true, "dependencies": { "@types/node": "*", "merge-stream": "^2.0.0", @@ -2791,7 +2761,6 @@ "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", "dev": true, - "peer": true, "engines": { "node": ">=8" } @@ -2801,7 +2770,6 @@ "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", "dev": true, - "peer": true, "dependencies": { "has-flag": "^4.0.0" }, @@ -2869,7 +2837,6 @@ "resolved": "https://registry.npmjs.org/loader-runner/-/loader-runner-4.3.0.tgz", "integrity": "sha512-3R/1M+yS3j5ou80Me59j7F9IMs4PXs3VqRrm0TU3AbKPxlmpoY1TNscJV/oGJXo8qCatFGTfDbY6W6ipGOYXfg==", "dev": true, - "peer": true, "engines": { "node": ">=6.11.5" } @@ -2921,8 +2888,7 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==", - "dev": true, - "peer": true + "dev": true }, "node_modules/mime-db": { "version": "1.52.0", @@ -2972,15 +2938,13 @@ "version": "2.6.2", "resolved": "https://registry.npmjs.org/neo-async/-/neo-async-2.6.2.tgz", "integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==", - "dev": true, - "peer": true + "dev": true }, "node_modules/node-releases": { "version": "2.0.10", "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.10.tgz", "integrity": "sha512-5GFldHPXVG/YZmFzJvKK2zDSzPKhEp0+ZR5SVaoSag9fsL5YgHbUHDfnG5494ISANDcK4KwPXAx2xqVEydmd7w==", - "dev": true, - "peer": true + "dev": true }, "node_modules/object-assign": { "version": "4.1.1", @@ -3155,7 +3119,6 @@ "resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz", "integrity": "sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==", "dev": true, - "peer": true, "dependencies": { "safe-buffer": "^5.1.0" } @@ -3164,6 +3127,7 @@ "version": "18.2.0", "resolved": "https://registry.npmjs.org/react/-/react-18.2.0.tgz", "integrity": "sha512-/3IjMdb2L9QbBdWiW5e3P2/npwMBaU9mHCSCUzNln0ZCYbcfTsGbTJrU/kGemdH2IWmB2ioZ+zkxtmq6g09fGQ==", + "peer": true, "dependencies": { "loose-envify": "^1.1.0" }, @@ -3224,6 +3188,7 @@ "version": "18.2.0", "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.2.0.tgz", "integrity": "sha512-6IMTriUmvsjHUjNtEDudZfuDQUoWXVxKHhlEGSk81n4YFS+r/Kl99wXiwlVXtPBtJenozv2P+hxDsw9eA7Xo6g==", + "peer": true, "dependencies": { "loose-envify": "^1.1.0", "scheduler": "^0.23.0" @@ -3363,6 +3328,7 @@ "version": "8.0.5", "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-8.0.5.tgz", "integrity": "sha512-Q2f6fCKxPFpkXt1qNRZdEDLlScsDWyrgSj0mliK59qU6W5gvBiKkdMEG2lJzhd1rCctf0hb6EtePPLZ2e0m1uw==", + "peer": true, "dependencies": { "@babel/runtime": "^7.12.1", "@types/hoist-non-react-statics": "^3.3.1", @@ -3494,6 +3460,7 @@ "version": "4.2.1", "resolved": "https://registry.npmjs.org/redux/-/redux-4.2.1.tgz", "integrity": "sha512-LAUYz4lc+Do8/g7aeRa8JkyDErK6ekstQaqWQrNRW//MY1TvCEpMtpTWvlQ+FPbWCx+Xixu/6SHt5N0HR+SB4w==", + "peer": true, "dependencies": { "@babel/runtime": "^7.9.2" } @@ -3574,8 +3541,7 @@ "type": "consulting", "url": "https://feross.org/support" } - ], - "peer": true + ] }, "node_modules/scheduler": { "version": "0.23.0", @@ -3626,7 +3592,6 @@ "resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-6.0.1.tgz", "integrity": "sha512-owoXEFjWRllis8/M1Q+Cw5k8ZH40e3zhp/ovX+Xr/vi1qj6QesbyXXViFbpNvWvPNAD62SutwEXavefrLJWj7w==", "dev": true, - "peer": true, "dependencies": { "randombytes": "^2.1.0" } @@ -3649,6 +3614,7 @@ "version": "0.91.4", "resolved": "https://registry.npmjs.org/slate/-/slate-0.91.4.tgz", "integrity": "sha512-aUJ3rpjrdi5SbJ5G1Qjr3arytfRkEStTmHjBfWq2A2Q8MybacIzkScSvGJjQkdTk3djCK9C9SEOt39sSeZFwTw==", + "peer": true, "dependencies": { "immer": "^9.0.6", "is-plain-object": "^5.0.0", @@ -3709,7 +3675,6 @@ "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz", "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==", "dev": true, - "peer": true, "dependencies": { "buffer-from": "^1.0.0", "source-map": "^0.6.0" @@ -3720,7 +3685,6 @@ "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", "dev": true, - "peer": true, "engines": { "node": ">=0.10.0" } @@ -3791,7 +3755,6 @@ "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.2.1.tgz", "integrity": "sha512-GNzQvQTOIP6RyTfE2Qxb8ZVlNmw0n88vp1szwWRimP02mnTsx3Wtn5qRdqY9w2XduFNUgvOwhNnQsjwCp+kqaQ==", "dev": true, - "peer": true, "engines": { "node": ">=6" } @@ -3820,7 +3783,6 @@ "resolved": "https://registry.npmjs.org/terser-webpack-plugin/-/terser-webpack-plugin-5.3.7.tgz", "integrity": "sha512-AfKwIktyP7Cu50xNjXF/6Qb5lBNzYaWpU6YfoX3uZicTx0zTy0stDDCsvjDapKsSDvOeWo5MEq4TmdBy2cNoHw==", "dev": true, - "peer": true, "dependencies": { "@jridgewell/trace-mapping": "^0.3.17", "jest-worker": "^27.4.5", @@ -3942,7 +3904,6 @@ "url": "https://tidelift.com/funding/github/npm/browserslist" } ], - "peer": true, "dependencies": { "escalade": "^3.1.1", "picocolors": "^1.0.0" @@ -3976,6 +3937,7 @@ "resolved": "https://registry.npmjs.org/vite/-/vite-4.2.0.tgz", "integrity": "sha512-AbDTyzzwuKoRtMIRLGNxhLRuv1FpRgdIw+1y6AQG73Q5+vtecmvzKo/yk8X/vrHDpETRTx01ABijqUHIzBXi0g==", "dev": true, + "peer": true, "dependencies": { "esbuild": "^0.17.5", "postcss": "^8.4.21", @@ -4025,7 +3987,6 @@ "resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.4.0.tgz", "integrity": "sha512-Lcvm7MGST/4fup+ifyKi2hjyIAwcdI4HRgtvTpIUxBRhB+RFtUh8XtDOxUfctVCnhVi+QQj49i91OyvzkJl6cg==", "dev": true, - "peer": true, "dependencies": { "glob-to-regexp": "^0.4.1", "graceful-fs": "^4.1.2" @@ -4092,7 +4053,6 @@ "resolved": "https://registry.npmjs.org/webpack-sources/-/webpack-sources-3.2.3.tgz", "integrity": "sha512-/DyMEOrDgLKKIG0fmvtz+4dUX/3Ghozwgm6iPp8KRhvn+eQf9+Q7GWxVNMk3+uCPWfdXYC4ExGBckIXdFEfH1w==", "dev": true, - "peer": true, "engines": { "node": ">=10.13.0" } @@ -4314,6 +4274,7 @@ "version": "11.10.6", "resolved": "https://registry.npmjs.org/@emotion/react/-/react-11.10.6.tgz", "integrity": "sha512-6HT8jBmcSkfzO7mc+N1L9uwvOnlcGoix8Zn7srt+9ga0MjREo6lRpuVX0kzo6Jp6oTqDhREOFsygN6Ew4fEQbw==", + "peer": true, "requires": { "@babel/runtime": "^7.18.3", "@emotion/babel-plugin": "^11.10.6", @@ -4346,6 +4307,7 @@ "version": "11.10.6", "resolved": "https://registry.npmjs.org/@emotion/styled/-/styled-11.10.6.tgz", "integrity": "sha512-OXtBzOmDSJo5Q0AFemHCfl+bUueT8BIcPSxu0EGTpGk6DmI5dnhSzQANm1e1ze0YZL7TDyAyy6s/b/zmGOS3Og==", + "peer": true, "requires": { "@babel/runtime": "^7.18.3", "@emotion/babel-plugin": "^11.10.6", @@ -4642,7 +4604,6 @@ "resolved": "https://registry.npmjs.org/@jridgewell/source-map/-/source-map-0.3.2.tgz", "integrity": "sha512-m7O9o2uR8k2ObDysZYzdfhb08VuEml5oWGiosa1VdaPZ/A6QyPkAJuwN0Q1lhULOf6B7MtQmHENS743hWtCrgw==", "dev": true, - "peer": true, "requires": { "@jridgewell/gen-mapping": "^0.3.0", "@jridgewell/trace-mapping": "^0.3.9" @@ -4699,6 +4660,7 @@ "version": "5.11.13", "resolved": "https://registry.npmjs.org/@mui/material/-/material-5.11.13.tgz", "integrity": "sha512-2CnSj43F+159LbGmTLLQs5xbGYMiYlpTByQhP7c7cMX6opbScctBFE1PuyElpAmwW8Ag9ysfZH1d1MFAmJQkjg==", + "peer": true, "requires": { "@babel/runtime": "^7.21.0", "@mui/base": "5.0.0-alpha.121", @@ -4898,6 +4860,7 @@ "version": "3.7.0", "resolved": "https://registry.npmjs.org/@react-spectrum/provider/-/provider-3.7.0.tgz", "integrity": "sha512-6Uch60R5PKJC+ZY3VweVadaCoaIj6Nd85TKfFH4jf8NEzXv5CqjwUzwV5aQ6u3k1K24Ves7RpLlK53HW1wUm6w==", + "peer": true, "requires": { "@react-aria/i18n": "^3.7.0", "@react-aria/overlays": "^3.13.0", @@ -5130,7 +5093,6 @@ "resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-8.21.3.tgz", "integrity": "sha512-fa7GkppZVEByMWGbTtE5MbmXWJTVbrjjaS8K6uQj+XtuuUv1fsuPAxhygfqLmsb/Ufb3CV8deFCpiMfAgi00Sw==", "dev": true, - "peer": true, "requires": { "@types/estree": "*", "@types/json-schema": "*" @@ -5141,7 +5103,6 @@ "resolved": "https://registry.npmjs.org/@types/eslint-scope/-/eslint-scope-3.7.4.tgz", "integrity": "sha512-9K4zoImiZc3HlIp6AVUDE4CWYx22a+lhSZMYNpbjW04+YF0KWj4pJXnEMjdnFTiQibFFmElcsasJXDbdI/EPhA==", "dev": true, - "peer": true, "requires": { "@types/eslint": "*", "@types/estree": "*" @@ -5151,13 +5112,13 @@ "version": "0.0.51", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-0.0.51.tgz", "integrity": "sha512-CuPgU6f3eT/XgKKPqKd/gLZV1Xmvf1a2R5POBOGQa6uv82xpls89HU5zKeVoyR8XzHd1RGNOlQlvUe3CFkjWNQ==", - "dev": true, - "peer": true + "dev": true }, "@types/hoist-non-react-statics": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/@types/hoist-non-react-statics/-/hoist-non-react-statics-3.3.1.tgz", "integrity": "sha512-iMIqiko6ooLrTh1joXodJK5X9xeEALT1kM5G3ZLhD3hszxBdIEd5C75U834D9mLcINgD4OyZf5uQXjkuYydWvA==", + "peer": true, "requires": { "@types/react": "*", "hoist-non-react-statics": "^3.3.0" @@ -5200,6 +5161,7 @@ "version": "18.0.28", "resolved": "https://registry.npmjs.org/@types/react/-/react-18.0.28.tgz", "integrity": "sha512-RD0ivG1kEztNBdoAK7lekI9M+azSnitIn85h4iOiaLjaTrMjzslhaqCGaI4IyCJ1RljWiLCEu4jyrLLgqxBTew==", + "peer": true, "requires": { "@types/prop-types": "*", "@types/scheduler": "*", @@ -5220,6 +5182,7 @@ "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.0.11.tgz", "integrity": "sha512-O38bPbI2CWtgw/OoQoY+BRelw7uysmXbWvw3nLWO21H1HSh+GOlqPuXshJfjmpNlKiiSDG9cc1JZAaMmVdcTlw==", "devOptional": true, + "peer": true, "requires": { "@types/react": "*" } @@ -5278,7 +5241,6 @@ "resolved": "https://registry.npmjs.org/@webassemblyjs/ast/-/ast-1.11.1.tgz", "integrity": "sha512-ukBh14qFLjxTQNTXocdyksN5QdM28S1CxHt2rdskFyL+xFV7VremuBLVbmCePj+URalXBENx/9Lm7lnhihtCSw==", "dev": true, - "peer": true, "requires": { "@webassemblyjs/helper-numbers": "1.11.1", "@webassemblyjs/helper-wasm-bytecode": "1.11.1" @@ -5288,29 +5250,25 @@ "version": "1.11.1", "resolved": "https://registry.npmjs.org/@webassemblyjs/floating-point-hex-parser/-/floating-point-hex-parser-1.11.1.tgz", "integrity": "sha512-iGRfyc5Bq+NnNuX8b5hwBrRjzf0ocrJPI6GWFodBFzmFnyvrQ83SHKhmilCU/8Jv67i4GJZBMhEzltxzcNagtQ==", - "dev": true, - "peer": true + "dev": true }, "@webassemblyjs/helper-api-error": { "version": "1.11.1", "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-api-error/-/helper-api-error-1.11.1.tgz", "integrity": "sha512-RlhS8CBCXfRUR/cwo2ho9bkheSXG0+NwooXcc3PAILALf2QLdFyj7KGsKRbVc95hZnhnERon4kW/D3SZpp6Tcg==", - "dev": true, - "peer": true + "dev": true }, "@webassemblyjs/helper-buffer": { "version": "1.11.1", "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-buffer/-/helper-buffer-1.11.1.tgz", "integrity": "sha512-gwikF65aDNeeXa8JxXa2BAk+REjSyhrNC9ZwdT0f8jc4dQQeDQ7G4m0f2QCLPJiMTTO6wfDmRmj/pW0PsUvIcA==", - "dev": true, - "peer": true + "dev": true }, "@webassemblyjs/helper-numbers": { "version": "1.11.1", "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-numbers/-/helper-numbers-1.11.1.tgz", "integrity": "sha512-vDkbxiB8zfnPdNK9Rajcey5C0w+QJugEglN0of+kmO8l7lDb77AnlKYQF7aarZuCrv+l0UvqL+68gSDr3k9LPQ==", "dev": true, - "peer": true, "requires": { "@webassemblyjs/floating-point-hex-parser": "1.11.1", "@webassemblyjs/helper-api-error": "1.11.1", @@ -5321,15 +5279,13 @@ "version": "1.11.1", "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-wasm-bytecode/-/helper-wasm-bytecode-1.11.1.tgz", "integrity": "sha512-PvpoOGiJwXeTrSf/qfudJhwlvDQxFgelbMqtq52WWiXC6Xgg1IREdngmPN3bs4RoO83PnL/nFrxucXj1+BX62Q==", - "dev": true, - "peer": true + "dev": true }, "@webassemblyjs/helper-wasm-section": { "version": "1.11.1", "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-wasm-section/-/helper-wasm-section-1.11.1.tgz", "integrity": "sha512-10P9No29rYX1j7F3EVPX3JvGPQPae+AomuSTPiF9eBQeChHI6iqjMIwR9JmOJXwpnn/oVGDk7I5IlskuMwU/pg==", "dev": true, - "peer": true, "requires": { "@webassemblyjs/ast": "1.11.1", "@webassemblyjs/helper-buffer": "1.11.1", @@ -5342,7 +5298,6 @@ "resolved": "https://registry.npmjs.org/@webassemblyjs/ieee754/-/ieee754-1.11.1.tgz", "integrity": "sha512-hJ87QIPtAMKbFq6CGTkZYJivEwZDbQUgYd3qKSadTNOhVY7p+gfP6Sr0lLRVTaG1JjFj+r3YchoqRYxNH3M0GQ==", "dev": true, - "peer": true, "requires": { "@xtuc/ieee754": "^1.2.0" } @@ -5352,7 +5307,6 @@ "resolved": "https://registry.npmjs.org/@webassemblyjs/leb128/-/leb128-1.11.1.tgz", "integrity": "sha512-BJ2P0hNZ0u+Th1YZXJpzW6miwqQUGcIHT1G/sf72gLVD9DZ5AdYTqPNbHZh6K1M5VmKvFXwGSWZADz+qBWxeRw==", "dev": true, - "peer": true, "requires": { "@xtuc/long": "4.2.2" } @@ -5361,15 +5315,13 @@ "version": "1.11.1", "resolved": "https://registry.npmjs.org/@webassemblyjs/utf8/-/utf8-1.11.1.tgz", "integrity": "sha512-9kqcxAEdMhiwQkHpkNiorZzqpGrodQQ2IGrHHxCy+Ozng0ofyMA0lTqiLkVs1uzTRejX+/O0EOT7KxqVPuXosQ==", - "dev": true, - "peer": true + "dev": true }, "@webassemblyjs/wasm-edit": { "version": "1.11.1", "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-edit/-/wasm-edit-1.11.1.tgz", "integrity": "sha512-g+RsupUC1aTHfR8CDgnsVRVZFJqdkFHpsHMfJuWQzWU3tvnLC07UqHICfP+4XyL2tnr1amvl1Sdp06TnYCmVkA==", "dev": true, - "peer": true, "requires": { "@webassemblyjs/ast": "1.11.1", "@webassemblyjs/helper-buffer": "1.11.1", @@ -5386,7 +5338,6 @@ "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-gen/-/wasm-gen-1.11.1.tgz", "integrity": "sha512-F7QqKXwwNlMmsulj6+O7r4mmtAlCWfO/0HdgOxSklZfQcDu0TpLiD1mRt/zF25Bk59FIjEuGAIyn5ei4yMfLhA==", "dev": true, - "peer": true, "requires": { "@webassemblyjs/ast": "1.11.1", "@webassemblyjs/helper-wasm-bytecode": "1.11.1", @@ -5400,7 +5351,6 @@ "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-opt/-/wasm-opt-1.11.1.tgz", "integrity": "sha512-VqnkNqnZlU5EB64pp1l7hdm3hmQw7Vgqa0KF/KCNO9sIpI6Fk6brDEiX+iCOYrvMuBWDws0NkTOxYEb85XQHHw==", "dev": true, - "peer": true, "requires": { "@webassemblyjs/ast": "1.11.1", "@webassemblyjs/helper-buffer": "1.11.1", @@ -5413,7 +5363,6 @@ "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-parser/-/wasm-parser-1.11.1.tgz", "integrity": "sha512-rrBujw+dJu32gYB7/Lup6UhdkPx9S9SnobZzRVL7VcBH9Bt9bCBLEuX/YXOOtBsOZ4NQrRykKhffRWHvigQvOA==", "dev": true, - "peer": true, "requires": { "@webassemblyjs/ast": "1.11.1", "@webassemblyjs/helper-api-error": "1.11.1", @@ -5428,7 +5377,6 @@ "resolved": "https://registry.npmjs.org/@webassemblyjs/wast-printer/-/wast-printer-1.11.1.tgz", "integrity": "sha512-IQboUWM4eKzWW+N/jij2sRatKMh99QEelo3Eb2q0qXkvPRISAj8Qxtmw5itwqK+TTkBuUIE45AxYPToqPtL5gg==", "dev": true, - "peer": true, "requires": { "@webassemblyjs/ast": "1.11.1", "@xtuc/long": "4.2.2" @@ -5438,15 +5386,13 @@ "version": "1.2.0", "resolved": "https://registry.npmjs.org/@xtuc/ieee754/-/ieee754-1.2.0.tgz", "integrity": "sha512-DX8nKgqcGwsc0eJSqYt5lwP4DH5FlHnmuWWBRy7X0NcaGR0ZtuyeESgMwTYVEtxmsNGY+qit4QYT/MIYTOTPeA==", - "dev": true, - "peer": true + "dev": true }, "@xtuc/long": { "version": "4.2.2", "resolved": "https://registry.npmjs.org/@xtuc/long/-/long-4.2.2.tgz", "integrity": "sha512-NuHqBY1PB/D8xU6s/thBgOAiAP7HOYDQ32+BFZILJ8ivkUkAHQnWfn6WhL79Owj1qmUnoN/YPhktdIoucipkAQ==", - "dev": true, - "peer": true + "dev": true }, "acorn": { "version": "8.8.2", @@ -5460,7 +5406,6 @@ "resolved": "https://registry.npmjs.org/acorn-import-assertions/-/acorn-import-assertions-1.8.0.tgz", "integrity": "sha512-m7VZ3jwz4eK6A4Vtt8Ew1/mNbP24u0FhdyfA7fSvnJR6LMdfOYnmuIrrJAgrYfYJ10F/otaHTtrtrtmHdMNzEw==", "dev": true, - "peer": true, "requires": {} }, "ajv": { @@ -5468,6 +5413,7 @@ "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", "dev": true, + "peer": true, "requires": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", @@ -5565,8 +5511,7 @@ "version": "1.1.2", "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", - "dev": true, - "peer": true + "dev": true }, "callsites": { "version": "3.1.0", @@ -5582,8 +5527,7 @@ "version": "1.0.30001469", "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001469.tgz", "integrity": "sha512-Rcp7221ScNqQPP3W+lVOYDyjdR6dC+neEQCttoNr5bAyz54AboB4iwpnWgyi8P4YUsPybVzT4LgWiBbI3drL4g==", - "dev": true, - "peer": true + "dev": true }, "chalk": { "version": "2.4.2", @@ -5606,8 +5550,7 @@ "version": "1.0.3", "resolved": "https://registry.npmjs.org/chrome-trace-event/-/chrome-trace-event-1.0.3.tgz", "integrity": "sha512-p3KULyQg4S7NIHixdwbGX+nFHkoBiA4YQmyWtjb8XngSKV124nJmRysgAeujbUVb15vh+RvFUfCPqU7rXk+hZg==", - "dev": true, - "peer": true + "dev": true }, "classnames": { "version": "2.3.2", @@ -5644,8 +5587,7 @@ "version": "2.20.3", "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", - "dev": true, - "peer": true + "dev": true }, "compressorjs": { "version": "1.2.1", @@ -5752,8 +5694,7 @@ "version": "1.4.334", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.4.334.tgz", "integrity": "sha512-laZ1odk+TRen6q0GeyQx/JEkpD3iSZT7ewopCpKqg9bTjP1l8XRfU3Bg20CFjNPZkp5+NDBl3iqd4o/kPO+Vew==", - "dev": true, - "peer": true + "dev": true }, "emojis-list": { "version": "3.0.0", @@ -5766,7 +5707,6 @@ "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.12.0.tgz", "integrity": "sha512-QHTXI/sZQmko1cbDoNAa3mJ5qhWUUNAq3vR0/YiD379fWQrcfuoX1+HW2S0MTt7XmoPLapdaDKUtelUSPic7hQ==", "dev": true, - "peer": true, "requires": { "graceful-fs": "^4.2.4", "tapable": "^2.2.0" @@ -5784,8 +5724,7 @@ "version": "0.9.3", "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-0.9.3.tgz", "integrity": "sha512-1HQ2M2sPtxwnvOvT1ZClHyQDiggdNjURWpY2we6aMKCQiUVxTmVs2UYPLIrD84sS+kMdUwfBSylbJPwNnBrnHQ==", - "dev": true, - "peer": true + "dev": true }, "esbuild": { "version": "0.17.11", @@ -5821,8 +5760,7 @@ "version": "3.1.1", "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.1.1.tgz", "integrity": "sha512-k0er2gUkLf8O0zKJiAhmkTnJlTvINGv7ygDNPbeIsX/TJjGJZHuh9B2UxbsaEkmlEo9MfhrSzmhIlhRlI2GXnw==", - "dev": true, - "peer": true + "dev": true }, "escape-string-regexp": { "version": "4.0.0", @@ -5834,7 +5772,6 @@ "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-5.1.1.tgz", "integrity": "sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw==", "dev": true, - "peer": true, "requires": { "esrecurse": "^4.3.0", "estraverse": "^4.1.1" @@ -5845,7 +5782,6 @@ "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", "dev": true, - "peer": true, "requires": { "estraverse": "^5.2.0" }, @@ -5854,8 +5790,7 @@ "version": "5.3.0", "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", - "dev": true, - "peer": true + "dev": true } } }, @@ -5863,15 +5798,13 @@ "version": "4.3.0", "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-4.3.0.tgz", "integrity": "sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==", - "dev": true, - "peer": true + "dev": true }, "events": { "version": "3.3.0", "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", "integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==", - "dev": true, - "peer": true + "dev": true }, "exenv": { "version": "1.2.2", @@ -5933,8 +5866,7 @@ "version": "0.4.1", "resolved": "https://registry.npmjs.org/glob-to-regexp/-/glob-to-regexp-0.4.1.tgz", "integrity": "sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw==", - "dev": true, - "peer": true + "dev": true }, "globals": { "version": "11.12.0", @@ -5945,8 +5877,7 @@ "version": "4.2.11", "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", - "dev": true, - "peer": true + "dev": true }, "has": { "version": "1.0.3", @@ -6044,7 +5975,6 @@ "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-27.5.1.tgz", "integrity": "sha512-7vuh85V5cdDofPyxn58nrPjBktZo0u9x1g8WtjQol+jZDaE+fhN+cIvTj11GndBnMnyfrUOG1sZQxCdjKh+DKg==", "dev": true, - "peer": true, "requires": { "@types/node": "*", "merge-stream": "^2.0.0", @@ -6055,15 +5985,13 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", - "dev": true, - "peer": true + "dev": true }, "supports-color": { "version": "8.1.1", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", "dev": true, - "peer": true, "requires": { "has-flag": "^4.0.0" } @@ -6114,8 +6042,7 @@ "version": "4.3.0", "resolved": "https://registry.npmjs.org/loader-runner/-/loader-runner-4.3.0.tgz", "integrity": "sha512-3R/1M+yS3j5ou80Me59j7F9IMs4PXs3VqRrm0TU3AbKPxlmpoY1TNscJV/oGJXo8qCatFGTfDbY6W6ipGOYXfg==", - "dev": true, - "peer": true + "dev": true }, "loader-utils": { "version": "2.0.4", @@ -6158,8 +6085,7 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==", - "dev": true, - "peer": true + "dev": true }, "mime-db": { "version": "1.52.0", @@ -6194,15 +6120,13 @@ "version": "2.6.2", "resolved": "https://registry.npmjs.org/neo-async/-/neo-async-2.6.2.tgz", "integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==", - "dev": true, - "peer": true + "dev": true }, "node-releases": { "version": "2.0.10", "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.10.tgz", "integrity": "sha512-5GFldHPXVG/YZmFzJvKK2zDSzPKhEp0+ZR5SVaoSag9fsL5YgHbUHDfnG5494ISANDcK4KwPXAx2xqVEydmd7w==", - "dev": true, - "peer": true + "dev": true }, "object-assign": { "version": "4.1.1", @@ -6324,7 +6248,6 @@ "resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz", "integrity": "sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==", "dev": true, - "peer": true, "requires": { "safe-buffer": "^5.1.0" } @@ -6333,6 +6256,7 @@ "version": "18.2.0", "resolved": "https://registry.npmjs.org/react/-/react-18.2.0.tgz", "integrity": "sha512-/3IjMdb2L9QbBdWiW5e3P2/npwMBaU9mHCSCUzNln0ZCYbcfTsGbTJrU/kGemdH2IWmB2ioZ+zkxtmq6g09fGQ==", + "peer": true, "requires": { "loose-envify": "^1.1.0" } @@ -6370,6 +6294,7 @@ "version": "18.2.0", "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.2.0.tgz", "integrity": "sha512-6IMTriUmvsjHUjNtEDudZfuDQUoWXVxKHhlEGSk81n4YFS+r/Kl99wXiwlVXtPBtJenozv2P+hxDsw9eA7Xo6g==", + "peer": true, "requires": { "loose-envify": "^1.1.0", "scheduler": "^0.23.0" @@ -6482,6 +6407,7 @@ "version": "8.0.5", "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-8.0.5.tgz", "integrity": "sha512-Q2f6fCKxPFpkXt1qNRZdEDLlScsDWyrgSj0mliK59qU6W5gvBiKkdMEG2lJzhd1rCctf0hb6EtePPLZ2e0m1uw==", + "peer": true, "requires": { "@babel/runtime": "^7.12.1", "@types/hoist-non-react-statics": "^3.3.1", @@ -6554,6 +6480,7 @@ "version": "4.2.1", "resolved": "https://registry.npmjs.org/redux/-/redux-4.2.1.tgz", "integrity": "sha512-LAUYz4lc+Do8/g7aeRa8JkyDErK6ekstQaqWQrNRW//MY1TvCEpMtpTWvlQ+FPbWCx+Xixu/6SHt5N0HR+SB4w==", + "peer": true, "requires": { "@babel/runtime": "^7.9.2" } @@ -6602,8 +6529,7 @@ "version": "5.2.1", "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", - "dev": true, - "peer": true + "dev": true }, "scheduler": { "version": "0.23.0", @@ -6647,7 +6573,6 @@ "resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-6.0.1.tgz", "integrity": "sha512-owoXEFjWRllis8/M1Q+Cw5k8ZH40e3zhp/ovX+Xr/vi1qj6QesbyXXViFbpNvWvPNAD62SutwEXavefrLJWj7w==", "dev": true, - "peer": true, "requires": { "randombytes": "^2.1.0" } @@ -6666,6 +6591,7 @@ "version": "0.91.4", "resolved": "https://registry.npmjs.org/slate/-/slate-0.91.4.tgz", "integrity": "sha512-aUJ3rpjrdi5SbJ5G1Qjr3arytfRkEStTmHjBfWq2A2Q8MybacIzkScSvGJjQkdTk3djCK9C9SEOt39sSeZFwTw==", + "peer": true, "requires": { "immer": "^9.0.6", "is-plain-object": "^5.0.0", @@ -6712,7 +6638,6 @@ "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz", "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==", "dev": true, - "peer": true, "requires": { "buffer-from": "^1.0.0", "source-map": "^0.6.0" @@ -6722,8 +6647,7 @@ "version": "0.6.1", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", - "dev": true, - "peer": true + "dev": true } } }, @@ -6773,8 +6697,7 @@ "version": "2.2.1", "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.2.1.tgz", "integrity": "sha512-GNzQvQTOIP6RyTfE2Qxb8ZVlNmw0n88vp1szwWRimP02mnTsx3Wtn5qRdqY9w2XduFNUgvOwhNnQsjwCp+kqaQ==", - "dev": true, - "peer": true + "dev": true }, "terser": { "version": "5.16.6", @@ -6794,7 +6717,6 @@ "resolved": "https://registry.npmjs.org/terser-webpack-plugin/-/terser-webpack-plugin-5.3.7.tgz", "integrity": "sha512-AfKwIktyP7Cu50xNjXF/6Qb5lBNzYaWpU6YfoX3uZicTx0zTy0stDDCsvjDapKsSDvOeWo5MEq4TmdBy2cNoHw==", "dev": true, - "peer": true, "requires": { "@jridgewell/trace-mapping": "^0.3.17", "jest-worker": "^27.4.5", @@ -6860,7 +6782,6 @@ "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.0.10.tgz", "integrity": "sha512-OztqDenkfFkbSG+tRxBeAnCVPckDBcvibKd35yDONx6OU8N7sqgwc7rCbkJ/WcYtVRZ4ba68d6byhC21GFh7sQ==", "dev": true, - "peer": true, "requires": { "escalade": "^3.1.1", "picocolors": "^1.0.0" @@ -6886,6 +6807,7 @@ "resolved": "https://registry.npmjs.org/vite/-/vite-4.2.0.tgz", "integrity": "sha512-AbDTyzzwuKoRtMIRLGNxhLRuv1FpRgdIw+1y6AQG73Q5+vtecmvzKo/yk8X/vrHDpETRTx01ABijqUHIzBXi0g==", "dev": true, + "peer": true, "requires": { "esbuild": "^0.17.5", "fsevents": "~2.3.2", @@ -6899,7 +6821,6 @@ "resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.4.0.tgz", "integrity": "sha512-Lcvm7MGST/4fup+ifyKi2hjyIAwcdI4HRgtvTpIUxBRhB+RFtUh8XtDOxUfctVCnhVi+QQj49i91OyvzkJl6cg==", "dev": true, - "peer": true, "requires": { "glob-to-regexp": "^0.4.1", "graceful-fs": "^4.1.2" @@ -6947,8 +6868,7 @@ "version": "3.2.3", "resolved": "https://registry.npmjs.org/webpack-sources/-/webpack-sources-3.2.3.tgz", "integrity": "sha512-/DyMEOrDgLKKIG0fmvtz+4dUX/3Ghozwgm6iPp8KRhvn+eQf9+Q7GWxVNMk3+uCPWfdXYC4ExGBckIXdFEfH1w==", - "dev": true, - "peer": true + "dev": true }, "worker-loader": { "version": "3.0.8", diff --git a/package.json b/package.json index 520b74b..0e13516 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "q-shop", "private": true, - "version": "1.2.2", + "version": "1.3.0", "type": "module", "scripts": { "dev": "vite", -- 2.43.0