forked from Qortal/q-shop
Search and sort #2
@@ -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.
|
||||
@@ -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/`.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
@@ -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!
|
||||
|
||||
@@ -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!
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
Generated
+78
-158
File diff suppressed because it is too large
Load Diff
+1
-1
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "q-shop",
|
||||
"private": true,
|
||||
"version": "1.1.2",
|
||||
"version": "1.3.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
@@ -14,11 +14,14 @@ 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";
|
||||
import { useIframe } from './hooks/useIframe'
|
||||
|
||||
function App() {
|
||||
// const themeColor = window._qdnTheme
|
||||
|
||||
const [theme, setTheme] = useState("dark");
|
||||
useIframe()
|
||||
|
||||
return (
|
||||
<Provider store={store}>
|
||||
@@ -35,6 +38,7 @@ function App() {
|
||||
path="/product-manager/:store"
|
||||
element={<ProductManager />}
|
||||
/>
|
||||
<Route path="/search" element={<Search />} />
|
||||
<Route path="/my-orders" element={<MyOrders />} />
|
||||
<Route path="/:user/:store" element={<Store />} />
|
||||
<Route path="/" element={<StoreList />} />
|
||||
|
||||
@@ -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 (
|
||||
<Zoom in={visible} unmountOnExit>
|
||||
<Fab
|
||||
size="medium"
|
||||
color="primary"
|
||||
onClick={scrollToTop}
|
||||
aria-label="Scroll to top"
|
||||
title="Scroll to top"
|
||||
sx={{
|
||||
position: 'fixed',
|
||||
bottom: 24,
|
||||
right: 24,
|
||||
zIndex: (t) => t.zIndex.drawer + 1, // above app bar/drawers, below modals
|
||||
boxShadow: theme.shadows[6],
|
||||
}}
|
||||
>
|
||||
<KeyboardArrowUpIcon />
|
||||
</Fab>
|
||||
</Zoom>
|
||||
)
|
||||
}
|
||||
|
||||
export default ScrollToTopButton
|
||||
|
||||
@@ -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<ShippingRegionsSelectProps> = ({
|
||||
label = "Ships To",
|
||||
placeholder = "Select destinations",
|
||||
value,
|
||||
onChange,
|
||||
helperText,
|
||||
error,
|
||||
}) => {
|
||||
const theme = useTheme();
|
||||
const [anchorEl, setAnchorEl] = useState<HTMLElement | null>(null);
|
||||
const [expandedNodes, setExpandedNodes] = useState<Record<string, boolean>>(
|
||||
() => ({})
|
||||
);
|
||||
|
||||
const openMenu = (event: React.MouseEvent<HTMLElement>) => {
|
||||
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<string>) => 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 (
|
||||
<Box key={node.id}>
|
||||
<ListItem
|
||||
dense
|
||||
disableGutters
|
||||
sx={{
|
||||
pl: paddingLeft,
|
||||
alignItems: "center",
|
||||
py: 0.5,
|
||||
}}
|
||||
>
|
||||
{hasChildren ? (
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => toggleExpanded(node.id)}
|
||||
sx={{
|
||||
mr: 1,
|
||||
transform: isNodeExpanded(node.id)
|
||||
? "rotate(180deg)"
|
||||
: "rotate(0deg)",
|
||||
transition: "transform 0.2s ease",
|
||||
}}
|
||||
>
|
||||
<ExpandMoreSVG
|
||||
width={"20"}
|
||||
height={"20"}
|
||||
color={theme.palette.text.primary}
|
||||
/>
|
||||
</IconButton>
|
||||
) : (
|
||||
<Box sx={{ width: 32, mr: 1 }} />
|
||||
)}
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Checkbox
|
||||
size="small"
|
||||
checked={checked}
|
||||
indeterminate={indeterminate}
|
||||
onChange={(event) =>
|
||||
toggleNodeSelection(node, event.target.checked)
|
||||
}
|
||||
/>
|
||||
}
|
||||
label={
|
||||
<Typography variant="body2" sx={{ userSelect: "none" }}>
|
||||
{node.label}
|
||||
</Typography>
|
||||
}
|
||||
/>
|
||||
</ListItem>
|
||||
{hasChildren && (
|
||||
<Collapse in={isNodeExpanded(node.id)} timeout="auto" unmountOnExit>
|
||||
{node.children?.map((child) =>
|
||||
child.children && child.children.length > 0
|
||||
? renderNode(child, depth + 1)
|
||||
: renderLeaf(child, depth + 1)
|
||||
)}
|
||||
</Collapse>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
const renderLeaf = (node: ShippingNode, depth = 0) => {
|
||||
const leaves = getDescendantLeafIds(node.id);
|
||||
const leafId = leaves[0];
|
||||
const isChecked = selectionSet.has(leafId);
|
||||
|
||||
return (
|
||||
<ListItem
|
||||
dense
|
||||
disableGutters
|
||||
key={node.id}
|
||||
sx={{ pl: depth * 16 + 32, py: 0.25 }}
|
||||
>
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Checkbox
|
||||
size="small"
|
||||
checked={isChecked}
|
||||
onChange={(event) => toggleLeaf(leafId, event.target.checked)}
|
||||
/>
|
||||
}
|
||||
label={
|
||||
<Typography variant="body2" sx={{ userSelect: "none" }}>
|
||||
{node.label}
|
||||
</Typography>
|
||||
}
|
||||
/>
|
||||
</ListItem>
|
||||
);
|
||||
};
|
||||
|
||||
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 (
|
||||
<FormControl fullWidth>
|
||||
<TextField
|
||||
label={label}
|
||||
value={summaryText}
|
||||
onClick={openMenu}
|
||||
InputProps={{ readOnly: true }}
|
||||
helperText={helperText}
|
||||
error={error}
|
||||
/>
|
||||
<Menu
|
||||
anchorEl={anchorEl}
|
||||
open={Boolean(anchorEl)}
|
||||
onClose={closeMenu}
|
||||
anchorOrigin={{ vertical: "bottom", horizontal: "left" }}
|
||||
transformOrigin={{ vertical: "top", horizontal: "left" }}
|
||||
PaperProps={{
|
||||
sx: {
|
||||
maxHeight: 420,
|
||||
minWidth: 320,
|
||||
p: 0,
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Box sx={{ px: 1.5, py: 1 }}>
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Checkbox
|
||||
checked={anyChecked}
|
||||
indeterminate={anyIndeterminate}
|
||||
onChange={(event) => handleToggleAny(event.target.checked)}
|
||||
/>
|
||||
}
|
||||
label={
|
||||
<Typography variant="body2" sx={{ fontWeight: 600 }}>
|
||||
Any
|
||||
</Typography>
|
||||
}
|
||||
/>
|
||||
</Box>
|
||||
<List dense disablePadding>
|
||||
{SHIPPING_REGION_TREE.filter((node) => node.type === "continent").map(
|
||||
(continent) => renderNode(continent)
|
||||
)}
|
||||
</List>
|
||||
</Menu>
|
||||
</FormControl>
|
||||
);
|
||||
};
|
||||
@@ -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<Props> = ({
|
||||
|
||||
const searchValRef = useRef("");
|
||||
const inputRef = useRef<HTMLInputElement>(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<HTMLDivElement>) => {
|
||||
const target = event?.currentTarget as unknown as HTMLButtonElement | null;
|
||||
@@ -129,9 +137,48 @@ const NavBar: React.FC<Props> = ({
|
||||
searchValRef.current = "";
|
||||
if (!inputRef.current) return;
|
||||
inputRef.current.value = "";
|
||||
setSearchTerm("");
|
||||
}}
|
||||
/>
|
||||
</ThemeSelectRow>
|
||||
{/* Centered Search Bar */}
|
||||
<Box
|
||||
sx={{
|
||||
flex: 1,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
px: 2,
|
||||
}}
|
||||
>
|
||||
<TextField
|
||||
inputRef={inputRef}
|
||||
placeholder="Search shops and items..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => 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: (
|
||||
<InputAdornment position="end">
|
||||
<IconButton aria-label="search" onClick={submitSearch}>
|
||||
<SearchIcon />
|
||||
</IconButton>
|
||||
</InputAdornment>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
@@ -220,7 +267,7 @@ const NavBar: React.FC<Props> = ({
|
||||
handleCloseStoreDropdown();
|
||||
}}
|
||||
>
|
||||
Create Store
|
||||
+ Create Store
|
||||
</DropdownText>
|
||||
</DropdownContainer>
|
||||
{myStores.length > 0 &&
|
||||
|
||||
@@ -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;"
|
||||
}
|
||||
}));
|
||||
}));
|
||||
|
||||
@@ -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<CreateStoreModalProps> = ({
|
||||
const [title, setTitle] = useState<string>("");
|
||||
const [description, setDescription] = useState<string>("");
|
||||
const [location, setLocation] = useState<string>("");
|
||||
const [shipsTo, setShipsTo] = useState<string>("");
|
||||
const [shipsTo, setShipsTo] = useState<string[]>([]);
|
||||
const [shippingInfo, setShippingInfo] = useState<string>("");
|
||||
const [errorMessage, setErrorMessage] = useState<string>("");
|
||||
const [storeIdentifier, setStoreIdentifier] = useState("");
|
||||
const [logo, setLogo] = useState<string | null>(null);
|
||||
@@ -89,6 +93,11 @@ const CreateStoreModal: React.FC<CreateStoreModalProps> = ({
|
||||
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<CreateStoreModalProps> = ({
|
||||
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<CreateStoreModalProps> = ({
|
||||
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<CreateStoreModalProps> = ({
|
||||
variant="filled"
|
||||
/>
|
||||
|
||||
<CustomInputField
|
||||
id="modal-shipsTo-input"
|
||||
label="Ships To"
|
||||
<ShippingRegionsSelect
|
||||
value={shipsTo}
|
||||
onChange={(e: any) => setShipsTo(e.target.value)}
|
||||
onChange={setShipsTo}
|
||||
helperText={
|
||||
errorMessage &&
|
||||
errorMessage.toLowerCase().includes("shipping destination")
|
||||
? errorMessage
|
||||
: undefined
|
||||
}
|
||||
error={
|
||||
!!errorMessage &&
|
||||
errorMessage.toLowerCase().includes("shipping destination")
|
||||
}
|
||||
/>
|
||||
|
||||
<CustomInputField
|
||||
id="modal-shipping-info-input"
|
||||
label="Shipping Info (optional)"
|
||||
value={shippingInfo}
|
||||
onChange={(e: any) => setShippingInfo(e.target.value)}
|
||||
fullWidth
|
||||
required
|
||||
multiline
|
||||
rows={3}
|
||||
variant="filled"
|
||||
/>
|
||||
|
||||
@@ -377,7 +406,8 @@ const CreateStoreModal: React.FC<CreateStoreModalProps> = ({
|
||||
/>
|
||||
|
||||
<FormControl fullWidth sx={{ marginBottom: 2 }}></FormControl>
|
||||
{errorMessage && (
|
||||
{errorMessage &&
|
||||
!errorMessage.toLowerCase().includes("shipping destination") && (
|
||||
<Typography color="error" variant="body1">
|
||||
{errorMessage}
|
||||
</Typography>
|
||||
|
||||
@@ -38,12 +38,27 @@ 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";
|
||||
import { ShippingRegionsSelect } from "../common/ShippingRegionsSelect";
|
||||
import {
|
||||
resolveLegacyShipsToSelection,
|
||||
sanitizeShippingSelection,
|
||||
} from "../../constants/shippingRegions";
|
||||
|
||||
type AnyObject = Record<string, any>;
|
||||
|
||||
@@ -51,10 +66,11 @@ export interface onPublishParamEdit {
|
||||
title: string;
|
||||
description: string;
|
||||
location: string;
|
||||
shipsTo: string;
|
||||
shipsTo: string[];
|
||||
logo: string;
|
||||
foreignCoins: Record<string, string>;
|
||||
supportedCoins: string[];
|
||||
shippingInfo?: string;
|
||||
}
|
||||
|
||||
|
||||
@@ -94,14 +110,18 @@ const EditStoreModal: React.FC<EditStoreModalProps> = ({
|
||||
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 {}
|
||||
@@ -111,7 +131,12 @@ const EditStoreModal: React.FC<EditStoreModalProps> = ({
|
||||
const [title, setTitle] = useState<string>(store?.title || "");
|
||||
const [description, setDescription] = useState<string>(store?.description || "");
|
||||
const [location, setLocation] = useState<string>(store?.location || "");
|
||||
const [shipsTo, setShipsTo] = useState<string>(store?.shipsTo || "");
|
||||
const [shipsTo, setShipsTo] = useState<string[]>(
|
||||
resolveLegacyShipsToSelection(store?.shipsTo)
|
||||
);
|
||||
const [shippingInfo, setShippingInfo] = useState<string>(
|
||||
(store?.shippingInfo as string) || ""
|
||||
);
|
||||
const [errorMessage, setErrorMessage] = useState<string>("");
|
||||
const [storeIdentifier, setStoreIdentifier] = useState<string>(store?.storeIdentifier || "");
|
||||
const [logo, setLogo] = useState<string | null>(store?.logo || null);
|
||||
@@ -122,9 +147,28 @@ const EditStoreModal: React.FC<EditStoreModalProps> = ({
|
||||
|
||||
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
|
||||
@@ -132,7 +176,8 @@ const EditStoreModal: React.FC<EditStoreModalProps> = ({
|
||||
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"]);
|
||||
@@ -147,6 +192,10 @@ const EditStoreModal: React.FC<EditStoreModalProps> = ({
|
||||
setErrorMessage("A logo is required");
|
||||
return;
|
||||
}
|
||||
if (!shipsTo || shipsTo.length === 0) {
|
||||
setErrorMessage("Please select at least one shipping destination");
|
||||
return;
|
||||
}
|
||||
const foreignCoins: Record<string, string> = {};
|
||||
supportedCoinsSelected
|
||||
.filter((coin) => coin !== "QORT")
|
||||
@@ -165,11 +214,12 @@ const EditStoreModal: React.FC<EditStoreModalProps> = ({
|
||||
...store,
|
||||
title,
|
||||
description,
|
||||
shipsTo,
|
||||
shipsTo: sanitizeShippingSelection(shipsTo),
|
||||
location,
|
||||
logo,
|
||||
foreignCoins,
|
||||
supportedCoins: supportedCoinsSelected,
|
||||
shippingInfo: shippingInfo.trim(),
|
||||
};
|
||||
await save(payload);
|
||||
|
||||
@@ -245,38 +295,203 @@ const EditStoreModal: React.FC<EditStoreModalProps> = ({
|
||||
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<string, ProductDataContainer> = {};
|
||||
const rebuiltCatalogues: CatalogueDataContainer[] = [];
|
||||
const processedCatalogueIds = new Set<string>();
|
||||
|
||||
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<string, true> = {};
|
||||
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));
|
||||
}
|
||||
@@ -360,13 +575,29 @@ const EditStoreModal: React.FC<EditStoreModalProps> = ({
|
||||
variant="filled"
|
||||
/>
|
||||
|
||||
<CustomInputField
|
||||
id="modal-shipsTo-input"
|
||||
label="Ships To"
|
||||
<ShippingRegionsSelect
|
||||
value={shipsTo}
|
||||
onChange={(e: any) => setShipsTo(e.target.value)}
|
||||
onChange={setShipsTo}
|
||||
helperText={
|
||||
errorMessage &&
|
||||
errorMessage.toLowerCase().includes("shipping destination")
|
||||
? errorMessage
|
||||
: undefined
|
||||
}
|
||||
error={
|
||||
!!errorMessage &&
|
||||
errorMessage.toLowerCase().includes("shipping destination")
|
||||
}
|
||||
/>
|
||||
|
||||
<CustomInputField
|
||||
id="modal-shipping-info-input"
|
||||
label="Shipping Info (optional)"
|
||||
value={shippingInfo}
|
||||
onChange={(e: any) => setShippingInfo(e.target.value)}
|
||||
fullWidth
|
||||
required
|
||||
multiline
|
||||
rows={3}
|
||||
variant="filled"
|
||||
/>
|
||||
|
||||
@@ -439,7 +670,8 @@ const EditStoreModal: React.FC<EditStoreModalProps> = ({
|
||||
/>
|
||||
|
||||
<FormControl fullWidth sx={{ marginBottom: 2 }} />
|
||||
{errorMessage && (
|
||||
{errorMessage &&
|
||||
!errorMessage.toLowerCase().includes("shipping destination") && (
|
||||
<Typography color="error" variant="body1">
|
||||
{errorMessage}
|
||||
</Typography>
|
||||
@@ -469,7 +701,8 @@ const EditStoreModal: React.FC<EditStoreModalProps> = ({
|
||||
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: "" });
|
||||
@@ -506,7 +739,7 @@ const EditStoreModal: React.FC<EditStoreModalProps> = ({
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
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.
|
||||
</div>
|
||||
<ButtonRow>
|
||||
<CancelButton variant="outlined" color="error" onClick={() => setShowCreateNewDataContainerModal(false)}>
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
@@ -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<string, string> = {
|
||||
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<string, ShippingNode>();
|
||||
|
||||
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<string, string> = {};
|
||||
|
||||
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<string, string[]> = {
|
||||
[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<string, string> = {
|
||||
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<string, Set<string>>();
|
||||
|
||||
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<string, string[]>();
|
||||
const nodeById = new Map<string, ShippingNode>();
|
||||
const labelLookup = new Map<string, ShippingNode>();
|
||||
|
||||
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<string>();
|
||||
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<string>,
|
||||
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));
|
||||
};
|
||||
@@ -0,0 +1,276 @@
|
||||
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";
|
||||
id: string; // store identifier
|
||||
owner: string; // name
|
||||
title?: string;
|
||||
description?: string;
|
||||
logo?: string;
|
||||
updated?: number;
|
||||
created?: number;
|
||||
}
|
||||
|
||||
export interface ItemResult {
|
||||
type: "item";
|
||||
id: string; // product id
|
||||
owner: string; // store owner name
|
||||
storeId: string;
|
||||
catalogueId: string;
|
||||
title?: string;
|
||||
description?: string;
|
||||
image?: string;
|
||||
priceQort?: number;
|
||||
created?: number;
|
||||
}
|
||||
|
||||
export interface UseGlobalSearchResult {
|
||||
shops: ShopResult[];
|
||||
items: ItemResult[];
|
||||
loading: boolean;
|
||||
error?: string;
|
||||
loadMoreItems: () => 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<string | undefined>) => {
|
||||
const query = toLower(q);
|
||||
return fields.some((f) => toLower(f).includes(query));
|
||||
};
|
||||
|
||||
export const useGlobalSearch = (query: string): UseGlobalSearchResult => {
|
||||
const [shops, setShops] = useState<ShopResult[]>([]);
|
||||
const [items, setItems] = useState<ItemResult[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | undefined>();
|
||||
|
||||
// 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 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 };
|
||||
}
|
||||
});
|
||||
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<number> => {
|
||||
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 };
|
||||
};
|
||||
@@ -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 };
|
||||
};
|
||||
@@ -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<ProductFormProps> = ({
|
||||
});
|
||||
};
|
||||
|
||||
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<ProductFormProps> = ({
|
||||
<CancelButton variant="outlined" color="error" onClick={onClose}>
|
||||
Cancel
|
||||
</CancelButton>
|
||||
{editProduct && (
|
||||
<DeleteButton onClick={handleDelete} variant="contained">
|
||||
Delete Product
|
||||
</DeleteButton>
|
||||
)}
|
||||
<CreateButton onClick={handleSubmit} variant="contained">
|
||||
{editProduct ? "Edit Product" : "Add Product"}
|
||||
</CreateButton>
|
||||
|
||||
@@ -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 = () => {
|
||||
>
|
||||
<ProductToSaveCard>
|
||||
<CardHeader>{product?.title}</CardHeader>
|
||||
{product?.isDelete && (
|
||||
<Bulletpoints style={{ color: '#d43232', fontWeight: 600 }}>
|
||||
Action: Delete
|
||||
</Bulletpoints>
|
||||
)}
|
||||
<Bulletpoints>
|
||||
<QortalSVG
|
||||
color={"#000000"}
|
||||
|
||||
@@ -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<keyof Data>("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]}
|
||||
</>
|
||||
</TableCell>
|
||||
@@ -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 && (
|
||||
<span style={{ marginLeft: 4 }}>
|
||||
{sortDir === "asc" ? "▲" : "▼"}
|
||||
</span>
|
||||
)}
|
||||
</TableCell>
|
||||
);
|
||||
})}
|
||||
@@ -164,7 +239,7 @@ export const SimpleTable = ({
|
||||
<TableHead>{fixedHeaderContent()}</TableHead>
|
||||
<TableBody>
|
||||
{processedData
|
||||
.sort((a, b) => a.rowData.created - b.rowData.created)
|
||||
.sort((a, b) => comparator(a.rowData, b.rowData))
|
||||
.map(({ index, rowData }) => (
|
||||
<StyledTableRow key={index}>
|
||||
{rowContent(index, rowData, openProduct)}
|
||||
|
||||
@@ -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 (
|
||||
<Card onClick={go} sx={{ cursor: "pointer" }}>
|
||||
{image && (
|
||||
<CardMedia component="img" height="140" image={image} alt={title} sx={{ objectFit: "contain" }} />
|
||||
)}
|
||||
<CardContent sx={{ display: 'flex', flexDirection: 'column', gap: 0.5 }}>
|
||||
<Typography variant="subtitle1" gutterBottom noWrap>{title || "Untitled"}</Typography>
|
||||
{priceQort !== undefined && !Number.isNaN(priceQort) && (
|
||||
<Typography variant="body2" color="text.secondary">{priceQort} QORT</Typography>
|
||||
)}
|
||||
{description && (
|
||||
<Typography variant="body2" color="text.secondary" noWrap>
|
||||
{description}
|
||||
</Typography>
|
||||
)}
|
||||
<Box sx={{ mt: 'auto', display: 'flex', justifyContent: 'space-between' }}>
|
||||
<Typography variant="caption" color="text.secondary">{owner}</Typography>
|
||||
</Box>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
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 (
|
||||
<Card onClick={go} sx={{ cursor: 'pointer' }}>
|
||||
<CardMedia component="img" height="140" image={logo || DefaultStoreImage} alt={title} sx={{ objectFit: 'contain' }} />
|
||||
<CardContent sx={{ display: 'flex', flexDirection: 'column', gap: 0.5 }}>
|
||||
<Typography variant="subtitle1" gutterBottom noWrap>{title || 'Untitled Store'}</Typography>
|
||||
{description && (
|
||||
<Typography variant="body2" color="text.secondary" noWrap>{description}</Typography>
|
||||
)}
|
||||
<Box sx={{ mt: 'auto', display: 'flex', justifyContent: 'space-between' }}>
|
||||
<Typography variant="caption" color="text.secondary">{owner}</Typography>
|
||||
</Box>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
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 (
|
||||
<Box sx={{ p: 2 }}>
|
||||
<Typography variant="h6" sx={{ mb: 1 }}>Search results for “{q}”</Typography>
|
||||
<Tabs
|
||||
value={tab}
|
||||
onChange={(_, v) => setTab(v)}
|
||||
sx={{ mb: 2 }}
|
||||
>
|
||||
<Tab value="all" label={`All (${shops.length + items.length})`} />
|
||||
<Tab value="shops" label={`Shops (${shops.length})`} />
|
||||
<Tab value="items" label={`Items (${items.length})`} />
|
||||
</Tabs>
|
||||
|
||||
{loading && (
|
||||
<Box sx={{ display: "flex", alignItems: "center", gap: 1, mb: 2 }}>
|
||||
<CircularProgress size={18} />
|
||||
<Typography variant="body2">Searching…</Typography>
|
||||
</Box>
|
||||
)}
|
||||
{error && (
|
||||
<Typography color="error" sx={{ mb: 2 }}>{error}</Typography>
|
||||
)}
|
||||
|
||||
<Grid container spacing={3}>
|
||||
{filtered.shops.map((s) => (
|
||||
<Grid key={`shop-${s.owner}-${s.id}`} item xs={12} sm={6} md={6} lg={3}>
|
||||
<StoreSquareCard
|
||||
title={s.title}
|
||||
description={s.description}
|
||||
logo={s.logo}
|
||||
owner={s.owner}
|
||||
storeId={s.id}
|
||||
/>
|
||||
</Grid>
|
||||
))}
|
||||
{filtered.items.map((it) => (
|
||||
<Grid key={`item-${it.catalogueId}-${it.id}`} item xs={12} sm={6} md={6} lg={3}>
|
||||
<ItemCard
|
||||
title={it.title}
|
||||
description={it.description}
|
||||
image={it.image}
|
||||
owner={it.owner}
|
||||
storeId={it.storeId}
|
||||
productId={it.id}
|
||||
catalogueId={it.catalogueId}
|
||||
priceQort={it.priceQort}
|
||||
/>
|
||||
</Grid>
|
||||
))}
|
||||
</Grid>
|
||||
|
||||
{tab !== "shops" && hasMoreItems && (
|
||||
<Box sx={{ display: "flex", justifyContent: "center", mt: 3 }}>
|
||||
<Button variant="contained" onClick={loadMoreItems}>Load more items</Button>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default Search;
|
||||
@@ -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";
|
||||
@@ -87,6 +91,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";
|
||||
@@ -356,6 +361,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 (
|
||||
@@ -392,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({
|
||||
@@ -399,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,
|
||||
})
|
||||
);
|
||||
|
||||
@@ -511,21 +541,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<string, string> = {
|
||||
"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;
|
||||
|
||||
@@ -1102,8 +1174,8 @@ const switchCoin = async ()=> {
|
||||
}
|
||||
shipsTo={
|
||||
username === user?.name
|
||||
? currentStore?.shipsTo || ""
|
||||
: currentViewedStore?.shipsTo || ""
|
||||
? currentStore?.shipsTo ?? []
|
||||
: currentViewedStore?.shipsTo ?? []
|
||||
}
|
||||
supportedCoins={
|
||||
username === user?.name
|
||||
@@ -1115,6 +1187,11 @@ const switchCoin = async ()=> {
|
||||
? currentStore?.foreignCoins || {}
|
||||
: currentViewedStore?.foreignCoins || {}
|
||||
}
|
||||
shippingInfo={
|
||||
username === user?.name
|
||||
? (currentStore?.shippingInfo as string) || ""
|
||||
: (currentViewedStore?.shippingInfo as string) || ""
|
||||
}
|
||||
/>
|
||||
</ReusableModalStyled>
|
||||
<ReusableModalStyled
|
||||
|
||||
@@ -2,6 +2,7 @@ import { FC, useEffect, useState } from "react";
|
||||
import {
|
||||
AcceptedCoin,
|
||||
AcceptedCoinsRow,
|
||||
BottomInfoContainer,
|
||||
ExpandDescriptionIcon,
|
||||
OpenStoreCard,
|
||||
StoreCardDescription,
|
||||
@@ -9,6 +10,7 @@ import {
|
||||
StoreCardImageContainer,
|
||||
StoreCardInfo,
|
||||
StoreCardOwner,
|
||||
StoreCardTimestamp,
|
||||
StoreCardTitle,
|
||||
StoresRow,
|
||||
StyledStoreCard,
|
||||
@@ -38,6 +40,7 @@ interface StoreCardProps {
|
||||
storeOwner: string;
|
||||
userName: string;
|
||||
supportedCoins: string[];
|
||||
bottomLabel?: string;
|
||||
}
|
||||
|
||||
export const StoreCard: FC<StoreCardProps> = ({
|
||||
@@ -47,7 +50,8 @@ export const StoreCard: FC<StoreCardProps> = ({
|
||||
storeId,
|
||||
storeOwner,
|
||||
userName,
|
||||
supportedCoins
|
||||
supportedCoins,
|
||||
bottomLabel
|
||||
}) => {
|
||||
const navigate = useNavigate();
|
||||
const dispatch = useDispatch();
|
||||
@@ -134,12 +138,17 @@ export const StoreCard: FC<StoreCardProps> = ({
|
||||
/>
|
||||
)}
|
||||
</StoreCardInfo>
|
||||
<AcceptedCoinsRow>
|
||||
{(supportedCoins || []).map((sym) => (
|
||||
<AcceptedCoin key={sym} src={coinPng(sym) || ""} alt={`${sym}-logo`} />
|
||||
))}
|
||||
</AcceptedCoinsRow>
|
||||
<StoreCardOwner>{storeOwner}</StoreCardOwner>
|
||||
<BottomInfoContainer>
|
||||
<AcceptedCoinsRow>
|
||||
{(supportedCoins || []).map((sym) => (
|
||||
<AcceptedCoin key={sym} src={coinPng(sym) || ""} alt={`${sym}-logo`} />
|
||||
))}
|
||||
</AcceptedCoinsRow>
|
||||
<StoreCardOwner>{storeOwner}</StoreCardOwner>
|
||||
{bottomLabel && (
|
||||
<StoreCardTimestamp>{bottomLabel}</StoreCardTimestamp>
|
||||
)}
|
||||
</BottomInfoContainer>
|
||||
{storeOwner === userName && (
|
||||
<StyledTooltip placement="top" title="You own this store">
|
||||
<YouOwnIcon>
|
||||
|
||||
@@ -21,9 +21,12 @@ 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";
|
||||
import {
|
||||
resolveLegacyShipsToSelection,
|
||||
summarizeShippingSelection,
|
||||
} from "../../../constants/shippingRegions";
|
||||
|
||||
interface StoreDetailsProps {
|
||||
storeTitle: string;
|
||||
@@ -32,10 +35,11 @@ interface StoreDetailsProps {
|
||||
storeDescription: string;
|
||||
dateCreated: number;
|
||||
location: string;
|
||||
shipsTo: string;
|
||||
shipsTo: string | string[];
|
||||
setOpenStoreDetails: (open: boolean) => void;
|
||||
supportedCoins: string[];
|
||||
foreignCoins: ForeignCoins
|
||||
foreignCoins: ForeignCoins;
|
||||
shippingInfo?: string;
|
||||
}
|
||||
|
||||
export const StoreDetails: FC<StoreDetailsProps> = ({
|
||||
@@ -48,9 +52,19 @@ export const StoreDetails: FC<StoreDetailsProps> = ({
|
||||
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 (
|
||||
<>
|
||||
<HeaderRow>
|
||||
@@ -110,17 +124,32 @@ export const StoreDetails: FC<StoreDetailsProps> = ({
|
||||
</IconsRow>
|
||||
{location}
|
||||
</CardRow>
|
||||
<CardRow>
|
||||
<IconsRow>
|
||||
<ShippingSVG
|
||||
color={theme.palette.text.primary}
|
||||
width={"22px"}
|
||||
height={"22px"}
|
||||
/>
|
||||
Ships To
|
||||
</IconsRow>
|
||||
{shipsTo}
|
||||
</CardRow>
|
||||
{shippingSelection.length > 0 && (
|
||||
<CardRow>
|
||||
<IconsRow>
|
||||
<ShippingSVG
|
||||
color={theme.palette.text.primary}
|
||||
width={"22px"}
|
||||
height={"22px"}
|
||||
/>
|
||||
Ships To
|
||||
</IconsRow>
|
||||
{shippingSummary}
|
||||
</CardRow>
|
||||
)}
|
||||
{shippingInfoText && (
|
||||
<CardRow>
|
||||
<IconsRow>
|
||||
<DescriptionSVG
|
||||
width={"22"}
|
||||
height={"22"}
|
||||
color={theme.palette.text.primary}
|
||||
/>
|
||||
Shipping Info
|
||||
</IconsRow>
|
||||
<StoreDescription>{shippingInfoText}</StoreDescription>
|
||||
</CardRow>
|
||||
)}
|
||||
<CardRow>
|
||||
<IconsRow>
|
||||
<CurrencySVG
|
||||
@@ -131,19 +160,26 @@ export const StoreDetails: FC<StoreDetailsProps> = ({
|
||||
Accepted Coins
|
||||
</IconsRow>
|
||||
<CurrencyRow>
|
||||
<QortalSVG
|
||||
color={theme.palette.text.primary}
|
||||
width={"32px"}
|
||||
height={"32px"}
|
||||
{/* Always show QORT */}
|
||||
<img
|
||||
src={coinPng("QORT") || ""}
|
||||
alt={`QORT-logo`}
|
||||
width={32}
|
||||
height={32}
|
||||
/>
|
||||
{foreignCoins?.ARRR && (
|
||||
<ARRRSVG
|
||||
color={theme.palette.text.primary}
|
||||
width={"32px"}
|
||||
height={"32px"}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Show all other supported coins that have wallet addresses configured */}
|
||||
{foreignCoins && supportedCoins &&
|
||||
Object.keys(foreignCoins)
|
||||
.filter((sym) => sym !== "QORT" && supportedCoins.includes(sym))
|
||||
.map((sym) => (
|
||||
<img
|
||||
key={sym}
|
||||
src={coinPng(sym) || ""}
|
||||
alt={`${sym}-logo`}
|
||||
width={32}
|
||||
height={32}
|
||||
/>
|
||||
))}
|
||||
</CurrencyRow>
|
||||
</CardRow>
|
||||
<CardRow>
|
||||
|
||||
@@ -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<AddReviewProps> = ({
|
||||
storeId,
|
||||
storeTitle,
|
||||
setOpenLeaveReview
|
||||
setOpenLeaveReview,
|
||||
storeOwner
|
||||
}) => {
|
||||
const dispatch = useDispatch();
|
||||
const user = useSelector((state: RootState) => state.auth.user);
|
||||
@@ -133,7 +136,10 @@ export const AddReview: FC<AddReviewProps> = ({
|
||||
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);
|
||||
|
||||
@@ -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<StoreReviewsProps> = ({
|
||||
const [userHasStoreOrder, setUserHasStoreOrder] = useState<boolean>(false);
|
||||
const [hasFetched, setHasFetched] = useState<boolean>(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<string, string> = {
|
||||
"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<StoreReviewsProps> = ({
|
||||
});
|
||||
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<StoreReviewsProps> = ({
|
||||
};
|
||||
}
|
||||
).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<StoreReviewsProps> = ({
|
||||
storeId={storeId}
|
||||
storeTitle={storeTitle}
|
||||
setOpenLeaveReview={setOpenLeaveReview}
|
||||
storeOwner={storeOwner || ""}
|
||||
/>
|
||||
</ReusableModal>
|
||||
</>
|
||||
|
||||
@@ -79,7 +79,7 @@ export const StyledStoreCard = styled(Grid)<StoreListProps>(
|
||||
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,13 +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: "Livvic",
|
||||
color: theme.palette.text.primary,
|
||||
fontSize: "12px",
|
||||
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",
|
||||
|
||||
@@ -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,20 +16,113 @@ import {
|
||||
LogoRow,
|
||||
StoresRow,
|
||||
} from "./StoreList-styles";
|
||||
import { Grid, Skeleton, useTheme } 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 } from "../../constants/identifiers";
|
||||
import { STORE_BASE, CATALOGUE_BASE, DATA_CONTAINER_BASE } from "../../constants/identifiers";
|
||||
import { ExpandMoreSVG } from "../../assets/svgs/ExpandMoreSVG";
|
||||
import {
|
||||
resolveLegacyShipsToSelection,
|
||||
summarizeShippingSelection,
|
||||
sanitizeShippingSelection,
|
||||
} from "../../constants/shippingRegions";
|
||||
|
||||
type SortOption = "updated" | "created" | "location";
|
||||
|
||||
const LOCATION_EXPANDED_STORAGE_KEY = "storeListLocationExpanded";
|
||||
const DEFAULT_LOCATION_LABEL = "See shipping info";
|
||||
|
||||
const normalizeLabelKey = (value: string) => {
|
||||
if (!value) return "";
|
||||
return value
|
||||
.normalize("NFKD")
|
||||
.replace(/[\u0300-\u036f]/g, "")
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]/g, "");
|
||||
};
|
||||
|
||||
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[0] + word.slice(1).toLowerCase()
|
||||
)
|
||||
.join(" ");
|
||||
};
|
||||
|
||||
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 [];
|
||||
}
|
||||
if (typeof shipsTo === "string") {
|
||||
return [];
|
||||
}
|
||||
return [];
|
||||
};
|
||||
|
||||
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);
|
||||
|
||||
const [filterUserStores, setFilterUserStores] = useState<boolean>(false);
|
||||
const [sortBy, setSortBy] = useState<SortOption>("updated");
|
||||
const [catalogueLatestByStoreId, setCatalogueLatestByStoreId] = useState<Record<string, number>>({});
|
||||
const [datacontainerLatestByStoreId, setDatacontainerLatestByStoreId] = useState<Record<string, number>>({});
|
||||
const [hasFetchedAllStores, setHasFetchedAllStores] = useState<boolean>(false);
|
||||
const [isFetchingAllStores, setIsFetchingAllStores] = useState<boolean>(false);
|
||||
|
||||
// Recently visited section state
|
||||
const [recentExpanded, setRecentExpanded] = useState<boolean>(() => {
|
||||
try { return localStorage.getItem('recentlyVisitedExpanded') === 'true'; } catch { return false; }
|
||||
});
|
||||
const [recentEntries, setRecentEntries] = useState<Array<{ owner: string; id: string; visitedAt?: number }>>(() => {
|
||||
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<boolean>(false);
|
||||
const [hasLoadedRecent, setHasLoadedRecent] = useState<boolean>(() => {
|
||||
// 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;
|
||||
});
|
||||
const [locationExpanded, setLocationExpanded] = useState<Record<string, boolean>>(() => {
|
||||
if (typeof window === "undefined") return {};
|
||||
try {
|
||||
const raw = localStorage.getItem(LOCATION_EXPANDED_STORAGE_KEY);
|
||||
if (!raw) return {};
|
||||
const parsed = JSON.parse(raw) as Record<string, boolean>;
|
||||
if (parsed && typeof parsed === "object") {
|
||||
return Object.keys(parsed).reduce((acc, key) => {
|
||||
acc[key] = Boolean(parsed[key]);
|
||||
return acc;
|
||||
}, {} as Record<string, boolean>);
|
||||
}
|
||||
} 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
|
||||
@@ -40,25 +133,113 @@ 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();
|
||||
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]);
|
||||
|
||||
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 () => {
|
||||
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;
|
||||
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 +252,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 +265,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 +300,298 @@ export const StoreList = () => {
|
||||
setFilterUserStores(event.target.checked);
|
||||
};
|
||||
|
||||
// Memoize the filtered stores to prevent rerenders
|
||||
const filteredStores = useMemo(() => {
|
||||
const handleSortChange = (
|
||||
event: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>
|
||||
) => {
|
||||
const value = event.target.value as SortOption;
|
||||
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 }));
|
||||
}
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
// 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) => {
|
||||
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]);
|
||||
|
||||
// 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
|
||||
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`;
|
||||
}
|
||||
};
|
||||
|
||||
const baseStores = useMemo(() => {
|
||||
let filtered = filterUserStores ? myStores : stores;
|
||||
filtered = filtered.filter((store: Store) => hashMapStores[store.id]?.isValid);
|
||||
filtered = filtered.filter((store: Store) => {
|
||||
if (invalidStoreIds[store.id]) return false;
|
||||
return hashMapStores[store.id]?.isValid !== false;
|
||||
});
|
||||
return filtered;
|
||||
}, [filterUserStores, stores, myStores, user?.name, hashMapStores]);
|
||||
}, [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);
|
||||
const cat = normalizeTs(catalogueLatestByStoreId[s.id] ?? 0);
|
||||
const dc = normalizeTs(datacontainerLatestByStoreId[s.id] ?? 0);
|
||||
return Math.max(base, cat, dc);
|
||||
}
|
||||
return normalizeTs(s.created ?? 0);
|
||||
};
|
||||
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<string, { label: string; 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))
|
||||
.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 (
|
||||
<StoresRow
|
||||
item
|
||||
xs={12}
|
||||
sm={6}
|
||||
md={6}
|
||||
lg={3}
|
||||
key={storeId}
|
||||
>
|
||||
<Skeleton
|
||||
variant="rectangular"
|
||||
style={{
|
||||
width: "100%",
|
||||
height: "500px",
|
||||
paddingBottom: "60px",
|
||||
objectFit: "contain",
|
||||
visibility: "visible",
|
||||
borderRadius: "8px",
|
||||
}}
|
||||
/>
|
||||
</StoresRow>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<StoreCard
|
||||
storeTitle={storeTitle || ""}
|
||||
storeLogo={storeLogo || ""}
|
||||
storeDescription={storeDescription || ""}
|
||||
storeId={storeId || ""}
|
||||
storeOwner={storeOwner || ""}
|
||||
key={storeId}
|
||||
userName={user?.name || ""}
|
||||
supportedCoins={supportedCoins}
|
||||
bottomLabel={bottomLabel}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const canRenderLazyLoad = sortBy === "location" ? true : (!recentExpanded || hasLoadedRecent);
|
||||
|
||||
// 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 (
|
||||
<>
|
||||
@@ -139,6 +612,20 @@ export const StoreList = () => {
|
||||
</WelcomeCol>
|
||||
</LogoRow>
|
||||
<WelcomeCol>
|
||||
<MyStoresCard>
|
||||
<TextField
|
||||
select
|
||||
label="Sort Shops"
|
||||
size="small"
|
||||
value={sortBy}
|
||||
onChange={handleSortChange}
|
||||
sx={{ minWidth: 220 }}
|
||||
>
|
||||
<MenuItem value="updated">Recently Updated</MenuItem>
|
||||
<MenuItem value="created">Recently Created</MenuItem>
|
||||
<MenuItem value="location">Sort by Location</MenuItem>
|
||||
</TextField>
|
||||
</MyStoresCard>
|
||||
{user && (
|
||||
<MyStoresCard>
|
||||
<MyStoresCheckbox
|
||||
@@ -149,72 +636,151 @@ export const StoreList = () => {
|
||||
See My Stores
|
||||
</MyStoresCard>
|
||||
)}
|
||||
{totalShops > 0 && (
|
||||
<Box sx={{ width: "100%", mt: 1 }}>
|
||||
<Typography variant="body2" sx={{ mb: allLoaded ? 0 : 0.5 }} aria-live="polite">
|
||||
{allLoaded
|
||||
? `Loaded ${loadedShops} ${loadedShops === 1 ? "shop" : "shops"}`
|
||||
: `Loading ${loadedShops} of ${totalShops}`}
|
||||
</Typography>
|
||||
{!allLoaded && (
|
||||
<LinearProgress variant="determinate" value={loadingPercent} />
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
</WelcomeCol>
|
||||
</WelcomeRow>
|
||||
<Grid item xs={12}>
|
||||
<Grid container spacing={3}>
|
||||
{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'];
|
||||
if (!hasHash) {
|
||||
return (
|
||||
<StoresRow
|
||||
item
|
||||
xs={12}
|
||||
sm={6}
|
||||
md={6}
|
||||
lg={3}
|
||||
key={storeId}
|
||||
>
|
||||
<Skeleton
|
||||
variant="rectangular"
|
||||
style={{
|
||||
width: "100%",
|
||||
height: "500px",
|
||||
paddingBottom: "60px",
|
||||
objectFit: "contain",
|
||||
visibility: "visible",
|
||||
borderRadius: "8px",
|
||||
}}
|
||||
/>
|
||||
</StoresRow>
|
||||
);
|
||||
} else {
|
||||
{sortBy !== "location" && (
|
||||
<Grid item xs={12}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: recentExpanded ? 1 : 2 }}>
|
||||
<IconButton
|
||||
aria-label={recentExpanded ? 'Collapse Recently Visited' : 'Expand Recently Visited'}
|
||||
size="small"
|
||||
onClick={handleToggleRecent}
|
||||
sx={{ p: 0.5 }}
|
||||
>
|
||||
<Box sx={{ transform: recentExpanded ? 'rotate(180deg)' : 'rotate(0deg)', transition: 'transform 0.2s ease' }}>
|
||||
<ExpandMoreSVG
|
||||
width={"24"}
|
||||
height={"24"}
|
||||
color={theme.palette.text.primary}
|
||||
/>
|
||||
</Box>
|
||||
</IconButton>
|
||||
<Typography
|
||||
variant="h6"
|
||||
sx={{ cursor: 'pointer', userSelect: 'none', fontFamily: 'Raleway' }}
|
||||
onClick={handleToggleRecent}
|
||||
>
|
||||
Recently Visited
|
||||
</Typography>
|
||||
</Box>
|
||||
{recentExpanded && (
|
||||
<Grid container spacing={3} sx={{ mb: 3 }}>
|
||||
{recentEntries
|
||||
.filter((e) => e.owner !== 'Bester')
|
||||
.slice(0, recentVisibleCount)
|
||||
.map((e) => {
|
||||
const meta = hashMapStores[e.id];
|
||||
if (!meta || meta.isValid === false) {
|
||||
return (
|
||||
<StoresRow item xs={12} sm={6} md={6} lg={3} key={`${e.owner}-${e.id}`}>
|
||||
<Skeleton
|
||||
variant="rectangular"
|
||||
style={{
|
||||
width: "100%",
|
||||
height: "500px",
|
||||
paddingBottom: "60px",
|
||||
objectFit: "contain",
|
||||
visibility: "visible",
|
||||
borderRadius: "8px",
|
||||
}}
|
||||
/>
|
||||
</StoresRow>
|
||||
);
|
||||
}
|
||||
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 (
|
||||
<StoreCard
|
||||
storeTitle={storeTitle || ""}
|
||||
storeLogo={storeLogo || ""}
|
||||
storeDescription={storeDescription || ""}
|
||||
storeId={storeId || ""}
|
||||
storeOwner={storeOwner || ""}
|
||||
key={storeId}
|
||||
storeTitle={storeTitle}
|
||||
storeLogo={storeLogo}
|
||||
storeDescription={storeDescription}
|
||||
storeId={storeId}
|
||||
storeOwner={storeOwner}
|
||||
key={`${storeOwner}-${storeId}`}
|
||||
userName={user?.name || ""}
|
||||
supportedCoins={supportedCoins}
|
||||
/>
|
||||
);
|
||||
}
|
||||
})}
|
||||
<LazyLoad onLoadMore={getStores}></LazyLoad>
|
||||
})}
|
||||
</Grid>
|
||||
)}
|
||||
{recentExpanded && isLoadingRecent && (
|
||||
<Box sx={{ display: 'flex', justifyContent: 'center', mt: 1, mb: 3 }}>
|
||||
<LinearProgress sx={{ width: '100%' }} />
|
||||
</Box>
|
||||
)}
|
||||
</Grid>
|
||||
</Grid>
|
||||
)}
|
||||
{sortBy === "location" ? (
|
||||
<>
|
||||
{locationSections.map(({ key, label, stores: storesInLocation }) => {
|
||||
const expanded = locationExpanded[key] ?? true;
|
||||
return (
|
||||
<Grid item xs={12} key={key}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: expanded ? 1 : 2 }}>
|
||||
<IconButton
|
||||
aria-label={expanded ? `Collapse ${label}` : `Expand ${label}`}
|
||||
size="small"
|
||||
onClick={() => handleToggleLocationSection(key)}
|
||||
sx={{ p: 0.5 }}
|
||||
>
|
||||
<Box sx={{ transform: expanded ? 'rotate(180deg)' : 'rotate(0deg)', transition: 'transform 0.2s ease' }}>
|
||||
<ExpandMoreSVG
|
||||
width={"24"}
|
||||
height={"24"}
|
||||
color={theme.palette.text.primary}
|
||||
/>
|
||||
</Box>
|
||||
</IconButton>
|
||||
<Typography
|
||||
variant="h6"
|
||||
sx={{ cursor: 'pointer', userSelect: 'none', fontFamily: 'Raleway' }}
|
||||
onClick={() => handleToggleLocationSection(key)}
|
||||
>
|
||||
{label}
|
||||
</Typography>
|
||||
</Box>
|
||||
{expanded && (
|
||||
<Grid container spacing={3} sx={{ mb: 3 }}>
|
||||
{storesInLocation.map((store) => renderStoreRow(store))}
|
||||
</Grid>
|
||||
)}
|
||||
</Grid>
|
||||
);
|
||||
})}
|
||||
{canRenderLazyLoad && (
|
||||
<Grid item xs={12}>
|
||||
<LazyLoad onLoadMore={getStores}></LazyLoad>
|
||||
</Grid>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<Grid item xs={12}>
|
||||
<Grid container spacing={3}>
|
||||
{sortedStores.length > 0 &&
|
||||
sortedStores.map((store: Store) => renderStoreRow(store))}
|
||||
{canRenderLazyLoad && (
|
||||
<LazyLoad onLoadMore={getStores}></LazyLoad>
|
||||
)}
|
||||
</Grid>
|
||||
</Grid>
|
||||
)}
|
||||
</StoresContainer>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -24,7 +24,8 @@ export interface CurrentStore {
|
||||
shortStoreId: string;
|
||||
logo?: string;
|
||||
location?: string;
|
||||
shipsTo?: string;
|
||||
shipsTo?: string | string[];
|
||||
shippingInfo?: string;
|
||||
foreignCoins: ForeignCoins;
|
||||
supportedCoins: string[];
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ interface GlobalState {
|
||||
isFiltering: boolean;
|
||||
filterValue: string;
|
||||
hashMapStores: Record<string, Store>;
|
||||
invalidStoreIds: Record<string, boolean>;
|
||||
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: [],
|
||||
@@ -73,6 +75,7 @@ export interface Product {
|
||||
status?: string;
|
||||
mainImageIndex?: number;
|
||||
isUpdate?: boolean;
|
||||
isDelete?: boolean;
|
||||
}
|
||||
|
||||
export interface Store {
|
||||
@@ -88,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[];
|
||||
@@ -149,9 +153,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) => {
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
@@ -54,6 +58,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;
|
||||
@@ -194,13 +199,18 @@ const GlobalWrapper: React.FC<Props> = ({ 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 || [],
|
||||
@@ -208,6 +218,7 @@ const GlobalWrapper: React.FC<Props> = ({ children, setTheme }) => {
|
||||
shortStoreId: responseData?.shortStoreId,
|
||||
supportedCoins: responseData?.supportedCoins || [],
|
||||
foreignCoins: responseData?.foreignCoins || {},
|
||||
shippingInfo: responseData?.shippingInfo,
|
||||
})
|
||||
);
|
||||
// Set listProducts in the Redux global state
|
||||
@@ -285,6 +296,7 @@ const GlobalWrapper: React.FC<Props> = ({ children, setTheme }) => {
|
||||
logo,
|
||||
foreignCoins,
|
||||
supportedCoins,
|
||||
shippingInfo,
|
||||
}: onPublishParam) => {
|
||||
if(isCreatingShop) return
|
||||
setIsCreatingShop(true)
|
||||
@@ -293,7 +305,13 @@ const GlobalWrapper: React.FC<Props> = ({ 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;
|
||||
@@ -317,12 +335,13 @@ const GlobalWrapper: React.FC<Props> = ({ 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");
|
||||
@@ -364,6 +383,7 @@ const GlobalWrapper: React.FC<Props> = ({ children, setTheme }) => {
|
||||
id: storeIdentifier,
|
||||
shortStoreId: formatStoreIdentifier,
|
||||
logo: logo,
|
||||
shippingInfo: storeObj.shippingInfo,
|
||||
};
|
||||
// Store Full Object to send to redux hashMapStores
|
||||
const storefullObj = {
|
||||
@@ -447,14 +467,29 @@ const GlobalWrapper: React.FC<Props> = ({ 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;
|
||||
|
||||
@@ -467,11 +502,12 @@ const GlobalWrapper: React.FC<Props> = ({ children, setTheme }) => {
|
||||
title,
|
||||
description,
|
||||
location,
|
||||
shipsTo,
|
||||
shipsTo: normalizedShipsTo,
|
||||
logo,
|
||||
shortStoreId: currentStore.shortStoreId ?? shortStoreId,
|
||||
foreignCoins,
|
||||
supportedCoins,
|
||||
shippingInfo: shippingInfo?.trim?.() || "",
|
||||
};
|
||||
|
||||
try {
|
||||
@@ -623,6 +659,11 @@ const GlobalWrapper: React.FC<Props> = ({ 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(
|
||||
@@ -631,7 +672,7 @@ const GlobalWrapper: React.FC<Props> = ({ 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 || [],
|
||||
@@ -639,6 +680,7 @@ const GlobalWrapper: React.FC<Props> = ({ children, setTheme }) => {
|
||||
shortStoreId: shopResource?.shortStoreId,
|
||||
supportedCoins: shopResource?.supportedCoins || [],
|
||||
foreignCoins: shopResource?.foreignCoins || {},
|
||||
shippingInfo: shopResource?.shippingInfo,
|
||||
})
|
||||
);
|
||||
// Fetch data container data on QDN (product resources)
|
||||
@@ -923,6 +965,7 @@ const GlobalWrapper: React.FC<Props> = ({ children, setTheme }) => {
|
||||
</ReusableModal>
|
||||
)}
|
||||
{children}
|
||||
<ScrollToTopButton />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user