Merge pull request 'Added multiple name support' (#1) from greenflame089/q-shop:main into main

Reviewed-on: #1
Reviewed-by: crowetic <jason@crowetic.com>
This commit was merged in pull request #1.
This commit is contained in:
2025-09-10 18:52:33 +00:00
41 changed files with 1686 additions and 726 deletions

View File

@@ -0,0 +1,45 @@
name: build-and-zip
on:
# Build on any branch push and on version tags (v*)
push:
branches:
- '**'
tags:
- 'v*'
# Build on any pull request (any source/target branches)
pull_request:
branches:
- '**'
# Also build on release events in Gitea
release:
types: [published, created]
jobs:
build:
runs-on: self-hosted
steps:
- name: Checkout
uses: actions/checkout@v3
- name: Use Node LTS
uses: actions/setup-node@v3
with:
node-version: 'lts/*'
- name: Install deps
run: npm ci
- name: Build
run: npm run build
- name: Zip dist
run: |
cd dist
zip -r ../dist.zip .
- name: Upload artifact
uses: actions/upload-artifact@v3
with:
name: dist
path: dist.zip

4
.gitignore vendored
View File

@@ -22,3 +22,7 @@ dist-ssr
*.njsproj
*.sln
*.sw?
# More
.runner
.gitea.env

4
config.yaml Normal file
View File

@@ -0,0 +1,4 @@
runner:
capacity: 1
labels:
- 'self-hosted'

View File

@@ -0,0 +1,50 @@
# QShop v1.1.0 — Release Notes
Release date: 20250823
## Highlights
- New coin support: BTC, LTC, DOGE, DGB, RVN in addition to QORT (native) and ARRR.
- Improved store editing experience and safety controls.
- Clearer pricing UX with dynamic exchange card and date sorting restored.
## Whats New
- Supported coins
- Added BTC, LTC, DOGE, DGB, RVN across the app: store setup, badges, filtering, pricing, and icons.
- Centralized PNG icon mapping for all coins in `src/constants/coin-icons.ts`.
- Edit Store modal
- Cancel reliably closes the modal.
- Advanced Settings restored with a “Recreate Shop Data” action:
- Publishes a fresh empty Data Container (DOCUMENT) to QDN for the current shop.
- Resets cached product lists to avoid UI inconsistencies.
- Confirmation modal with warnings to prevent accidental use.
- Supported Coins dropdown now uses PNG icons for all coins.
- Local draft preservation: unsaved edits persist while the modal is open (localStorage) and can be cleared.
- Sidebar filters (Store page)
- Prices In: deduplicated header; single clear section.
- Exchange Rate card:
- Hidden when viewing prices in QORT.
- Shows dynamic coin icon for the selected coin.
- Displays both directions: `1 QORT = X COIN` and `1 COIN = Y QORT`.
- Date Product Added: restored “Most Recent” and “Oldest” sort options.
## Improvements
- Pricing lookup now integrates percoin keys via `getPriceHint()` with graceful fallback to QORT when rate is unavailable.
- Safer store edit flow with explicit error notifications and loading indicators.
## Fixes
- TypeScript/JSX issues in `EditStoreModal.tsx` causing build failures.
- Inconsistent close behavior for the edit modal.
- Mixed icon sources (SVG vs. PNG) resolved for consistency.
## Developer Notes
- Build: `npm ci && npm run build` (Node LTS). Output in `dist/`.
- Icons: coin assets located under `src/assets/img/` and referenced via `coinPng()`.
- Recreate Shop Data publishes `identifier: <storeId>-datacontainer` to QDN with an empty `products` map. Use only for recovery.
## Upgrade Guide
- No config changes required.
- After updating, shop owners can edit their store to add newly supported coins and addresses. Pricing UX updates apply automatically.
## Known Considerations
- Exchange rates rely on recent trade portal data; when unavailable, the app reverts to QORT pricing and notifies the user.

View File

@@ -0,0 +1,27 @@
# QShop v1.1.1 — Release Notes
Release date: 20250823
## Summary
Minor UI/UX improvements and a fix to price entry for all supported coins.
## Changes
- Product pricing form
- Shows price fields for every coin your shop supports (hides others).
- Pre-fills existing prices when editing a product.
- Product card price display
- Uses PNG icons for all coins.
- For nonQORT selections, shows: `[COIN icon] amount [QORT icon] qortEquivalent`.
- QORT amounts rounded to 2 decimals; no letter tickers.
- Exchange rate card (sidebar)
- Removed background panel to prevent text overflow.
- Icon-based display with both directions:
- `[QORT icon] 1 = X [COIN icon]`
- `[COIN icon] 1 = Y [QORT icon]`
- Compact formatting (>=1 → 4 decimals; <1 → 4 significant digits).
- Removed the arrow/icons row below the subtitle.
## Notes
- This is a patch over v1.1.0 (which introduced BTC/LTC/DOGE/DGB/RVN support, Edit Shop improvements, and initial pricing UX updates).
- Build: `npm ci && npm run build`. Artifacts in `dist/`.

View File

@@ -0,0 +1,23 @@
# QShop v1.1.2 — Release Notes
Release date: 20250823
## Summary
Adds a new Service product type and full support for free (0price) items, including endtoend checkout without sending a payment transaction.
## Changes
- Product types
- New: Service — intended for paid services (no goods delivered).
- Checkout logic treats Service like Digital (no shipping section when the cart contains only Digital/Service items).
- Free/0price items
- Product creation/editing accepts 0 as a valid price (QORT and supported coins).
- Cart/Checkout displays 0 amounts and correctly computes totals.
- When the order total is 0, payment is skipped; the order is still created and a QMail notification is sent to the seller.
- Validation updated to allow 0; negative prices remain blocked.
- Minor UI/logic tweaks
- Total and peritem price UI now renders when the amount is 0.
## Notes
- Backward compatible: existing products are unaffected.
- Build: `npm ci && npm run build`. Output in `dist/`.

View File

@@ -0,0 +1,105 @@
# QShop v1.1.0 — Road to Release
Goal: Ship new coin support and UI fixes with a clean build and verified UX.
## Scope
- Stabilize Edit Store modal interactions and advanced actions.
- Unify coin iconography across dropdowns and cards using PNG assets.
- Clean up and enhance sidebar filtering UX (Prices In, Exchange Rate, Date sort).
## Tasks
1) EditStoreModal: Cancel button closes modal
- Wire Cancel to dispatch `toggleEditStoreModal(false)` via GlobalWrapper pattern.
- Maintain existing optional `closeEditStoreModal` prop flow for backcompat.
2) EditStoreModal: Coin dropdown icons use PNGs
- Replace QORT/ARRR SVG usage with PNGs via `coinPng()`.
- Ensure all supported coins (QORT, ARRR, BTC, LTC, DOGE, DGB, RVN) have PNGs imported in `src/constants/coin-icons.ts`.
3) EditStoreModal: Restore Advanced Settings + Recreate Shop Data
- Add an “Advanced Settings” checkbox next to action buttons.
- When checked, show a red “Recreate Shop Data” button.
- Handler publishes an empty Data Container document to QDN using:
- identifier: `${storeIdentifier}-${DATA_CONTAINER_BASE}`
- structure: `{ storeId, shortStoreId, owner, products: {} }`
- Show progress/errors via global loader + notifications. Confirm outcome.
4) Sidebar: Fix Prices In duplicate header
- In `src/pages/Store/Store/Store.tsx`, remove the extra Typography header under the FiltersTitle.
5) Sidebar: Exchange rate card behavior
- Hide the exchange rate card when `coinToUse === 'QORT'`.
- Use `coinPng('QORT')` for the left icon and `coinPng(coinToUse)` for the right icon.
- Keep title “1 QORT = {rate} {COIN}” and error fallback notifications asis.
6) Sidebar: Restore Date Product Added sort checkboxes
- Add a new “Date Product Added” group with:
- “Most Recent” → sets `filterDate = DateFilter.newest` and resets price filter
- “Oldest” → sets `filterDate = DateFilter.oldest` and resets price filter
- Leverage existing `filterDate` logic already present in the selector.
## File Targets
- `src/components/modals/EditStoreModal.tsx`
- Cancel wiring to close
- PNG icons in coin dropdown
- Advanced Settings checkbox + Recreate Shop Data button and handler
- `src/pages/Store/Store/Store.tsx`
- Remove duplicate Prices In header
- Conditional exchange rate card with dynamic right icon
- Add “Date Product Added” group and two checkboxes
- `src/constants/coin-icons.ts`
- Verify PNG imports for BTC/LTC/DOGE/DGB/RVN exist and are referenced by `coinPng()`
- `src/assets/img/*`
- Ensure PNG assets for all supported coins are present; add missing ones if needed.
## Validation
- Typecheck + build: `npm run build` on Node LTS.
- Manual checks:
- Edit Store modal: Cancel closes; Advanced → Recreate button visible and functional.
- Coin dropdown in Edit/Store pages shows PNG icons for all coins.
- Prices In has a single header; exchange card hidden for QORT and dynamic for others.
- Exchange card shows both directions: `1 QORT = X COIN` and `1 COIN = Y QORT`.
- Date sort toggles between Most Recent / Oldest and matches product ordering.
## Release Prep
- Smoke test store creation/edit flows and product list in a test profile.
- Verify new coin prices and iconography across product cards and details.
- Update changelog/README for new coins and UI fixes.
- Bump `package.json` to 1.1.0.
- Add `docs/RELEASE_NOTES_v1.1.0.md` and `docs/USER_ANNOUNCEMENT_v1.1.0.md`.
- Add `.gitea/workflows/release.yml` to build on tag and zip `dist/` as artifact.
- Create tag `v1.1.0` and push to trigger workflow.
- Draft Gitea release and attach `dist.zip` (then publish to Qortal).
## v1.1.1 Patch Plan
- Show price inputs for all supported coins in ProductForm.
- Unify product card pricing with PNG icons + QORT equivalency (2decimals).
- Simplify Exchange Rate card visuals and format values compactly.
- Bump to 1.1.1, add release/announcement docs, tag and release.
## v1.1.1 Progress
- Implemented ProductForm price fields for all supported coins.
- Updated product cards and Exchange Rate card per spec.
- Added docs/RELEASE_NOTES_v1.1.1.md and docs/USER_ANNOUNCEMENT_v1.1.1.md.
## Open Questions
- Recreate Shop Data: ok to publish immediately from Edit modal, or should we gate behind an extra confirmation modal?
- Should `onPublishParamEdit` include `storeIdentifier` for edits, or remain excluded as now?
## Progress Log
- Implemented EditStoreModal fixes:
- Cancel now dispatches `toggleEditStoreModal(false)` and supports legacy close flag.
- Restored Advanced Settings with “Recreate Shop Data” flow and confirmation modal; publishes empty data container and resets product cache.
- Switched Supported Coins dropdown icons to PNG via `coinPng()` for all coins.
- Sidebar Store page updates:
- Removed duplicate “Prices In” header.
- Exchange rate card now hidden for QORT and shows dynamic coin icon for the selected coin.
- Restored “Date Product Added” section with Most Recent/Oldest.
- Verified a clean production build with Vite.
- Added reverse rate display to exchange card.
- Bumped version to 1.1.0 in `package.json`.
- Added release notes and user announcement under `docs/`.
- Added Gitea workflow to build and archive `dist.zip` on tag.
- Product pricing form now shows price fields for all supported coins in the shop (hides nonsupported coins) and pre-fills values when editing.
- Product card pricing now uses PNG icons for all coins; for nonQORT selections the card shows both the selected coin amount and the QORT equivalent (QORT rounded to 2 decimals) using icons only.
- Exchange rate card updated: removed background, switched to icons, shows both directions with compact formatting (>=1 → 4 decimals; <1 → 4 significant digits), and removed the arrows row.

View File

@@ -0,0 +1,11 @@
# QShop v1.1.0 — Whats New
QShop just got a big upgrade. Heres the short version:
- More coins: In addition to QORT and ARRR, your shops can now price and accept BTC, LTC, DOGE, DGB, and RVN.
- Easier editing: The Edit Shop modal is smoother — Cancel closes properly, and you can quickly clear unsaved changes.
- Advanced tools: Need to reset your shops data container? Theres a clear “Recreate Shop Data” option under Advanced Settings (with a confirmation to keep you safe).
- Clearer prices: Pick your “Prices In” coin from the sidebar and see live exchange info — including both 1 QORT = X COIN and 1 COIN = Y QORT — with the right coin icons.
- Better sorting: “Date Product Added” is back, so you can view the newest or oldest items first.
Thats it! Update to 1.1.0, add your wallet address(es) for the new coins, and enjoy a smoother QShop experience.

View File

@@ -0,0 +1,9 @@
# QShop v1.1.1 — Whats New
Quick polish update:
- Enter prices for every coin your shop supports (not just QORT/ARRR).
- Product prices now show coin icons everywhere, with a clear QORT equivalent.
- Exchange rate box is cleaner, uses icons, and shows both directions.
Thats it — smoother pricing and clearer info. Update to 1.1.1 and enjoy!

View File

@@ -0,0 +1,13 @@
# QShop v1.1.2 — Whats New
Two highly requested updates:
- Service product type: Create service listings (no shipping). If the cart has only Digital/Service items, checkout skips delivery details.
- Free items (0 price): Set price to 0 in QORT (and/or other supported coins). Buyers can place the order without sending a payment; youll still get an order + QMail notification.
How to use
- When adding a product, choose Type → Service or Digital as needed.
- Enter 0 to make an item free. Negative values are not allowed.
Thats it — easy service listings and truly free items!

41
package-lock.json generated
View File

@@ -1,18 +1,20 @@
{
"name": "q-blog",
"version": "0.0.0",
"name": "q-shop",
"version": "1.0.0",
"lockfileVersion": 2,
"requires": true,
"packages": {
"": {
"name": "q-blog",
"version": "0.0.0",
"name": "q-shop",
"version": "1.0.0",
"dependencies": {
"@emotion/react": "^11.10.6",
"@emotion/styled": "^11.10.6",
"@mui/icons-material": "^5.11.11",
"@mui/material": "^5.11.13",
"@noble/hashes": "^1.4.0",
"@reduxjs/toolkit": "^1.9.3",
"@scure/base": "^1.1.5",
"@types/react-grid-layout": "^1.3.2",
"axios": "^1.3.4",
"compressorjs": "^1.2.1",
@@ -1111,6 +1113,18 @@
"react": "^17.0.0 || ^18.0.0"
}
},
"node_modules/@noble/hashes": {
"version": "1.8.0",
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz",
"integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==",
"license": "MIT",
"engines": {
"node": "^14.21.3 || >=16"
},
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/@popperjs/core": {
"version": "2.11.6",
"resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.6.tgz",
@@ -1444,6 +1458,15 @@
"node": ">=14"
}
},
"node_modules/@scure/base": {
"version": "1.2.6",
"resolved": "https://registry.npmjs.org/@scure/base/-/base-1.2.6.tgz",
"integrity": "sha512-g/nm5FgUa//MCj1gV09zTJTaM6KBAHqLN907YVQqf7zC49+DcO4B1so4ZX07Ef10Twr6nuqYEH9GEggFXA4Fmg==",
"license": "MIT",
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/@swc/core": {
"version": "1.3.41",
"resolved": "https://registry.npmjs.org/@swc/core/-/core-1.3.41.tgz",
@@ -4745,6 +4768,11 @@
"react-is": "^18.2.0"
}
},
"@noble/hashes": {
"version": "1.8.0",
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz",
"integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A=="
},
"@popperjs/core": {
"version": "2.11.6",
"resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.6.tgz",
@@ -4996,6 +5024,11 @@
"resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.4.0.tgz",
"integrity": "sha512-BJ9SxXux8zAg991UmT8slpwpsd31K1dHHbD3Ba4VzD+liLQ4WAMSxQp2d2ZPRPfN0jN2NPRowcSSoM7lCaF08Q=="
},
"@scure/base": {
"version": "1.2.6",
"resolved": "https://registry.npmjs.org/@scure/base/-/base-1.2.6.tgz",
"integrity": "sha512-g/nm5FgUa//MCj1gV09zTJTaM6KBAHqLN907YVQqf7zC49+DcO4B1so4ZX07Ef10Twr6nuqYEH9GEggFXA4Fmg=="
},
"@swc/core": {
"version": "1.3.41",
"resolved": "https://registry.npmjs.org/@swc/core/-/core-1.3.41.tgz",

View File

@@ -1,7 +1,7 @@
{
"name": "q-blog",
"name": "q-shop",
"private": true,
"version": "0.0.0",
"version": "1.1.2",
"type": "module",
"scripts": {
"dev": "vite",
@@ -39,7 +39,9 @@
"slate": "^0.91.4",
"slate-history": "^0.86.0",
"slate-react": "^0.91.11",
"ua-parser-js": "^1.0.37"
"ua-parser-js": "^1.0.37",
"@scure/base": "^1.1.5",
"@noble/hashes": "^1.4.0"
},
"devDependencies": {
"@mui/types": "^7.2.3",

View File

@@ -226,7 +226,7 @@ export const GenericModal: React.FC<GenericModalProps> = ({
</Typography>
)}
<Typography variant="h6" component="h2" gutterBottom>
Upload {service}
Publish {service}
</Typography>
<Box
{...getRootProps()}

View File

@@ -0,0 +1,62 @@
import React from "react";
import LaunchIcon from "@mui/icons-material/Launch";
import ContentCopyIcon from "@mui/icons-material/ContentCopy";
import { Tooltip, Chip } from "@mui/material";
import { explorerTxUrl } from "../../lib/explorers";
import { CoinTicker } from "../../constants/coin-registry";
type Props = {
coin: string | CoinTicker;
proof?: string | null;
label?: string;
size?: "small" | "medium";
};
const truncate = (s: string, head = 8, tail = 6) => {
if (!s) return "";
if (s.length <= head + tail + 3) return s;
return s.slice(0, head) + "..." + s.slice(-tail);
};
const PaymentProof: React.FC<Props> = ({ coin, proof, label, size = "small" }) => {
const ticker = String(coin || "").toUpperCase() as CoinTicker;
const id = (proof || "").trim();
if (!id) return null;
const href = explorerTxUrl(ticker, id);
const onCopy = () => {
try { navigator.clipboard.writeText(id); } catch {}
};
if (href) {
return (
<Tooltip title={`View on explorer: ${ticker}`}>
<Chip
size={size}
variant="outlined"
color="primary"
label={label || `Proof`}
onClick={() => window.open(href, "_blank", "noopener,noreferrer")}
onDelete={onCopy}
deleteIcon={<ContentCopyIcon fontSize="small" />}
icon={<LaunchIcon fontSize="small" />}
/>
</Tooltip>
);
}
return (
<Tooltip title={id}>
<Chip
size={size}
variant="outlined"
label={label || truncate(id)}
onDelete={onCopy}
deleteIcon={<ContentCopyIcon fontSize="small" />}
/>
</Tooltip>
);
};
export default PaymentProof;

View File

@@ -202,7 +202,7 @@ const PostPublishModal: React.FC<PostModalProps> = ({
<StyledModal open={open} onClose={onClose}>
<ModalContent>
<Typography variant="h6" component="h2" gutterBottom>
Upload Blog Post
Publish Blog Post
</Typography>
<TextField

View File

@@ -194,7 +194,7 @@ const VideoModal: React.FC<VideoModalProps> = ({
</Typography>
)}
<Typography variant="h6" component="h2" gutterBottom>
Upload Video
Publish Video
</Typography>
<Box
{...getRootProps()}

View File

@@ -1,14 +1,19 @@
import React, { useRef, useState } from "react";
import React, { useEffect, useRef, useState } from "react";
import { RootState } from "../../../state/store";
import { useSelector } from "react-redux";
import { useDispatch, useSelector } from "react-redux";
import { setSelectedName } from "../../../state/features/authSlice";
import { Box, Popover, useTheme } from "@mui/material";
import {
setAllMyStores,
clearViewedStoreDataContainer,
clearReviews,
setStoreId,
setStoreOwner,
setCurrentViewedStore
} from "../../../state/features/storeSlice";
import ExitToAppIcon from "@mui/icons-material/ExitToApp";
import { useNavigate } from "react-router-dom";
import {
resetProducts,
toggleCreateStoreModal
} from "../../../state/features/globalSlice";
import { useDispatch } from "react-redux";
import { resetProducts, toggleCreateStoreModal } from "../../../state/features/globalSlice";
import { BlockedNamesModal } from "../../common/BlockedNamesModal/BlockedNamesModal";
import EmailIcon from "@mui/icons-material/Email";
import {
@@ -30,10 +35,10 @@ import QShopLogo from "../../../assets/img/QShopLogo.webp";
import QShopLogoLight from "../../../assets/img/QShopLogoLight.webp";
import ExpandMoreIcon from "@mui/icons-material/ExpandMore";
import PersonOffIcon from "@mui/icons-material/PersonOff";
import { Store } from "../../../state/features/storeSlice";
import { OrdersSVG } from "../../../assets/svgs/OrdersSVG";
import { resetOrders } from "../../../state/features/orderSlice";
interface Props {
isAuthenticated: boolean;
userName: string | null;
@@ -56,8 +61,13 @@ const NavBar: React.FC<Props> = ({
const navigate = useNavigate();
const dispatch = useDispatch();
const theme = useTheme();
const names = useSelector((state: RootState) => state.auth.user?.names || []);
const selectedName = useSelector((state: RootState) => state.auth.user?.selectedName) || userName;
// Get All My Stores from Redux To Display In Store Manager Dropdown
const [avatarErrored, setAvatarErrored] = useState(false);
useEffect(() => {
setAvatarErrored(false);
}, [selectedName, userAvatar]);
const myStores = useSelector((state: RootState) => state.store.myStores);
const hashMapStores = useSelector(
@@ -153,7 +163,7 @@ const NavBar: React.FC<Props> = ({
}
}}
>
My Stores
{myStores.length > 0 ? "My Stores" : "Create Store"}
<StoreManagerIcon
color={theme.palette.text.primary}
height={"32"}
@@ -169,8 +179,8 @@ const NavBar: React.FC<Props> = ({
setOpenUserDropdown(true);
}}
>
<NavbarName>{userName}</NavbarName>
{!userAvatar ? (
<NavbarName>{selectedName || userName}</NavbarName>
{!userAvatar || avatarErrored ? (
<AccountCircleSVG
color={theme.palette.text.primary}
width="32"
@@ -180,6 +190,8 @@ const NavBar: React.FC<Props> = ({
<img
src={userAvatar}
alt="User Avatar"
onError={() => setAvatarErrored(true)}
key={String(selectedName)}
width="32"
height="32"
style={{
@@ -218,7 +230,7 @@ const NavBar: React.FC<Props> = ({
onClick={() => {
dispatch(resetOrders());
dispatch(resetProducts());
navigate(`/${userName}/${store.id}`);
navigate(`/${(selectedName || userName)}/${store.id}`);
handleCloseStoreDropdown();
}}
>
@@ -237,6 +249,27 @@ const NavBar: React.FC<Props> = ({
horizontal: "left"
}}
>
{names.map((n: string) => (
<DropdownContainer
key={n}
onClick={() => {
dispatch(setSelectedName(n));
dispatch(setAllMyStores([]));
dispatch(setStoreId(null));
dispatch(setStoreOwner(null));
dispatch(setCurrentViewedStore(null));
dispatch(clearViewedStoreDataContainer());
dispatch(clearReviews());
dispatch(resetOrders());
dispatch(resetProducts());
handleCloseUserDropdown();
}}
>
<DropdownText style={{ color: "#FFFFFF", fontWeight: n === selectedName ? 600 : 400 }}>
{n}
</DropdownText>
</DropdownContainer>
))}
<DropdownContainer
onClick={() => {
handleCloseUserDropdown();

View File

@@ -37,6 +37,7 @@ import { supportedCoinsArray } from "../../constants/supported-coins";
import { QortalSVG } from "../../assets/svgs/QortalSVG";
import { ARRRSVG } from "../../assets/svgs/ARRRSVG";
import { setNotification } from "../../state/features/notificationsSlice";
export interface ForeignCoins {
[key: string]: string;
}
@@ -60,7 +61,6 @@ interface CreateStoreModalProps {
username: string;
}
const CreateStoreModal: React.FC<CreateStoreModalProps> = ({
open,
closeCreateStoreModal,
@@ -78,11 +78,9 @@ const CreateStoreModal: React.FC<CreateStoreModalProps> = ({
const [errorMessage, setErrorMessage] = useState<string>("");
const [storeIdentifier, setStoreIdentifier] = useState("");
const [logo, setLogo] = useState<string | null>(null);
const [supportedCoinsSelected, setSupportedCoinsSelected] = useState<
string[]
>(["QORT"]);
const [supportedCoinsSelected, setSupportedCoinsSelected] = useState<string[]>(["QORT"]);
const [qortWalletAddress, setQortWalletAddress] = useState<string>("");
const [arrrWalletAddress, setArrrWalletAddress] = useState<string>("");
const [foreignWallets, setForeignWallets] = useState<Record<string, string>>({ ARRR: "" });
const handlePublish = async (): Promise<void> => {
try {
@@ -91,12 +89,14 @@ const CreateStoreModal: React.FC<CreateStoreModalProps> = ({
setErrorMessage("A logo is required");
return;
}
const foreignCoins: ForeignCoins = {
ARRR: arrrWalletAddress
}
supportedCoinsSelected.filter((coin)=> coin !== 'QORT').forEach((item: string)=> {
if(!foreignCoins[item]) throw new Error(`Please add a ${item} address`)
})
const foreignCoins: ForeignCoins = {};
supportedCoinsSelected
.filter((coin) => coin !== "QORT")
.forEach((item: string) => {
const addr = foreignWallets[item]?.trim();
if (!addr) throw new Error(`Please add a ${item} address`);
foreignCoins[item] = addr;
});
await onPublish({
title,
description,
@@ -104,10 +104,8 @@ const CreateStoreModal: React.FC<CreateStoreModalProps> = ({
location,
storeIdentifier,
logo,
foreignCoins: {
ARRR: arrrWalletAddress
},
supportedCoins: supportedCoinsSelected
foreignCoins: foreignCoins,
supportedCoins: supportedCoinsSelected,
});
} catch (error: any) {
setErrorMessage(error.message);
@@ -118,8 +116,9 @@ const CreateStoreModal: React.FC<CreateStoreModalProps> = ({
setTitle("");
setDescription("");
setErrorMessage("");
setArrrWalletAddress("")
setSupportedCoinsSelected(["QORT"])
// Reset foreign wallets (ARRR preserved as known default key)
setForeignWallets({ ARRR: "" });
setSupportedCoinsSelected(["QORT"]);
dispatch(toggleCreateStoreModal(false));
};
@@ -157,39 +156,35 @@ const CreateStoreModal: React.FC<CreateStoreModalProps> = ({
const handleChipRemove = (chip: string) => {
if (chip === "QORT") return;
setSupportedCoinsSelected(prevChips => prevChips.filter(c => c !== chip));
setSupportedCoinsSelected((prevChips) => prevChips.filter((c) => c !== chip));
};
const importAddress = async (coin: string)=> {
const importAddress = async (coin: string) => {
try {
dispatch(setIsLoadingGlobal(true));
const res = await qortalRequest({
action: 'GET_USER_WALLET',
coin
})
if(res?.address){
setArrrWalletAddress(res.address)
action: "GET_USER_WALLET",
coin,
});
if (res?.address) {
setForeignWallets((prev) => ({ ...prev, [coin]: res.address }));
}
} catch (error) {
dispatch(
setNotification({
alertType: "error",
msg: "Unable to import ARRR address. Please insert it manually",
msg: `Unable to import ${coin} address. Please insert it manually`,
})
);
} finally {
dispatch(setIsLoadingGlobal(false));
}
}
};
return (
<Modal
open={open}
aria-labelledby="modal-title"
aria-describedby="modal-description"
>
<Modal open={open} aria-labelledby="modal-title" aria-describedby="modal-description">
<ModalBody>
<ModalTitle id="modal-title">Create Shop</ModalTitle>
{!logo ? (
@@ -280,8 +275,9 @@ const CreateStoreModal: React.FC<CreateStoreModalProps> = ({
variant="filled"
/>
{/* QORT Wallet Input Field */}
{/* <WalletRow>
{/* QORT Wallet Input Field (currently unused) */}
{/*
<WalletRow>
<CustomInputField
id="modal-qort-wallet-input"
label="QORT Wallet Address"
@@ -300,43 +296,39 @@ const CreateStoreModal: React.FC<CreateStoreModalProps> = ({
title="Import your QORT Wallet Address from your current account"
>
<IconButton disableFocusRipple={true} disableRipple={true}>
<DownloadArrrWalletIcon
color={theme.palette.text.primary}
height="40"
width="40"
/>
</IconButton>
</Tooltip>
</WalletRow> */}
{/* ARRR Wallet Input Field */}
<WalletRow>
<CustomInputField
id="modal-arrr-wallet-input"
label="ARRR Wallet Address"
value={arrrWalletAddress}
onChange={(e: any) => {
setArrrWalletAddress(e.target.value);
}}
fullWidth
required
variant="filled"
/>
<Tooltip
TransitionComponent={Zoom}
placement="top"
arrow={true}
title="Import your ARRR Wallet Address from your current account"
>
<IconButton disableFocusRipple={true} disableRipple={true} onClick={()=> importAddress('ARRR')}>
<DownloadArrrWalletIcon
color={theme.palette.text.primary}
height="40"
width="40"
/>
<DownloadArrrWalletIcon color={theme.palette.text.primary} height="40" width="40" />
</IconButton>
</Tooltip>
</WalletRow>
*/}
{/* Foreign coin wallet inputs */}
{supportedCoinsSelected
.filter((coin) => coin !== "QORT")
.map((coin) => (
<WalletRow key={coin}>
<CustomInputField
id={`modal-wallet-${coin}`}
label={`${coin} Wallet Address`}
value={foreignWallets[coin] || ""}
onChange={(e: any) => setForeignWallets((prev) => ({ ...prev, [coin]: e.target.value }))}
fullWidth
required
variant="filled"
/>
<Tooltip
TransitionComponent={Zoom}
placement="top"
arrow={true}
title={`Import your ${coin} Wallet Address from your current account`}
>
<IconButton disableFocusRipple={true} disableRipple={true} onClick={() => importAddress(coin)}>
<DownloadArrrWalletIcon color={theme.palette.text.primary} height="40" width="40" />
</IconButton>
</Tooltip>
</WalletRow>
))}
{/* Coin selection available for your shop */}
<FilterSelect
disableClearable
@@ -355,9 +347,7 @@ const CreateStoreModal: React.FC<CreateStoreModalProps> = ({
<FiltersChip
key={value}
label={value}
onDelete={
value !== "QORT" ? () => handleChipRemove(value) : undefined
}
onDelete={value !== "QORT" ? () => handleChipRemove(value) : undefined}
clickable={value === "QORT" ? false : true}
/>
);
@@ -367,28 +357,17 @@ const CreateStoreModal: React.FC<CreateStoreModalProps> = ({
const isDisabled = option === "QORT";
return (
<FiltersOption {...props}>
<FiltersCheckbox
disabled={isDisabled}
checked={supportedCoinsSelected.some(coin => coin === option)}
/>
<FiltersCheckbox disabled={isDisabled} checked={supportedCoinsSelected.some((coin) => coin === option)} />
{option === "QORT" ? (
<QortalSVG
height="22"
width="22"
color={theme.palette.text.primary}
/>
<QortalSVG height="22" width="22" color={theme.palette.text.primary} />
) : option === "ARRR" ? (
<ARRRSVG
height="22"
width="22"
color={theme.palette.text.primary}
/>
<ARRRSVG height="22" width="22" color={theme.palette.text.primary} />
) : null}
<span style={{ marginLeft: "5px" }}>{option}</span>
</FiltersOption>
);
}}
renderInput={params => (
renderInput={(params) => (
<FilterSelectMenuItems
{...params}
label="Supported Coins"

View File

@@ -1,42 +1,33 @@
import { useState, useEffect } from "react";
import { ChangeEvent, useState, useEffect } from "react";
import {
Typography,
Modal,
FormControl,
useTheme,
IconButton,
Tooltip,
Zoom,
Box,
Tooltip,
} from "@mui/material";
import { useDispatch, useSelector } from "react-redux";
import {
toggleCreateStoreModal,
setDataContainer,
resetListProducts,
resetProducts
} from "../../state/features/globalSlice";
import { RootState } from "../../state/store";
import ImageUploader from "../common/ImageUploader";
import {
ModalTitle,
StoreLogoPreview,
AddLogoButton,
AddLogoIcon,
WalletRow,
TimesIcon,
LogoPreviewRow,
CustomInputField,
ModalBody,
ButtonRow,
CancelButton,
CreateButton,
CustomInputField,
WalletRow,
DownloadArrrWalletIcon,
LogoPreviewRow,
ModalBody,
ModalTitle,
StoreLogoPreview,
TimesIcon,
AdvancedSettingsBox,
EditStoreButtonsRow,
CreateNewDataContainerRow,
CreateNewDataContainerButton,
} from "./CreateStoreModal-styles";
import ImageUploader from "../common/ImageUploader";
import {
FilterSelect,
FilterSelectMenuItems,
@@ -45,121 +36,173 @@ import {
FiltersOption,
} from "../../pages/Store/Store/Store-styles";
import { supportedCoinsArray } from "../../constants/supported-coins";
import { QortalSVG } from "../../assets/svgs/QortalSVG";
import { ARRRSVG } from "../../assets/svgs/ARRRSVG";
import { coinPng } from "../../constants/coin-icons";
import { useDispatch } from "react-redux";
import { setIsLoadingGlobal, toggleEditStoreModal, setDataContainer, resetListProducts, resetProducts } from "../../state/features/globalSlice";
import { setNotification } from "../../state/features/notificationsSlice";
import { ReusableModal } from "./ReusableModal";
import { DATA_CONTAINER_BASE, STORE_BASE } from "../../constants/identifiers";
import { DATA_CONTAINER_BASE } from "../../constants/identifiers";
import { objectToBase64 } from "../../utils/toBase64";
import { ShortDataContainer } from "../../wrappers/GlobalWrapper";
import { setNotification } from "../../state/features/notificationsSlice";
interface ForeignCoins {
[key: string]: string;
}
type AnyObject = Record<string, any>;
export interface onPublishParamEdit {
title: string;
description: string;
shipsTo: string;
location: string;
shipsTo: string;
logo: string;
foreignCoins: ForeignCoins;
foreignCoins: Record<string, string>;
supportedCoins: string[];
}
interface MyModalProps {
interface EditStoreModalProps {
open: boolean;
onClose: () => void;
onPublish: (param: onPublishParamEdit) => Promise<void>;
store: AnyObject;
/** Back-compat: GlobalWrapper may pass onPublish instead of onUpdate */
onPublish?: (param: onPublishParamEdit) => Promise<void>;
onUpdate?: (param: onPublishParamEdit) => Promise<void>;
onClose?: () => void;
closeEditStoreModal?: boolean;
setCloseEditStoreModal?: (val: boolean) => void;
username: string;
}
const MyModal: React.FC<MyModalProps> = ({ open, onClose, onPublish }) => {
const EditStoreModal: React.FC<EditStoreModalProps> = ({
open,
store,
onPublish,
onUpdate,
onClose,
closeEditStoreModal,
setCloseEditStoreModal,
username,
}) => {
const dispatch = useDispatch();
const currentStore = useSelector(
(state: RootState) => state.global.currentStore
);
const user = useSelector((state: RootState) => state.auth.user);
const storeId = useSelector((state: RootState) => state.store.storeId);
const [title, setTitle] = useState<string>("");
const [description, setDescription] = useState<string>("");
const [location, setLocation] = useState<string>("");
const [shipsTo, setShipsTo] = useState<string>("");
const [errorMessage, setErrorMessage] = useState<string>("");
const [logo, setLogo] = useState<string | null>(null);
const [supportedCoinsSelected, setSupportedCoinsSelected] = useState<
string[]
>(["QORT"]);
const [qortWalletAddress, setQortWalletAddress] = useState<string>("");
const [arrrWalletAddress, setArrrWalletAddress] = useState<string>("");
const [showAdvancedSettings, setShowAdvancedSettings] =
useState<boolean>(false);
const [showCreateNewDataContainerModal, setShowCreateNewDataContainerModal] =
useState<boolean>(false);
const theme = useTheme();
const handlePublish = async (): Promise<void> => {
// Persist unsaved edits (so closing the modal/backdrop click won't discard)
const STORAGE_KEY_EDIT = `qshop_edit_${store?.storeIdentifier || ""}`;
const [showAdvancedSettings, setShowAdvancedSettings] = useState<boolean>(false);
const [showCreateNewDataContainerModal, setShowCreateNewDataContainerModal] = useState<boolean>(false);
useEffect(() => {
if (!open) return;
try {
const cached = localStorage.getItem(STORAGE_KEY_EDIT);
if (cached) {
const parsed = JSON.parse(cached);
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.logo) setLogo(parsed.logo);
if (Array.isArray(parsed.supportedCoinsSelected)) setSupportedCoinsSelected(parsed.supportedCoinsSelected);
if (parsed.foreignWallets && typeof parsed.foreignWallets === 'object') setForeignWallets(parsed.foreignWallets);
}
}
} catch {}
}, [open]);
// Seed from existing store (guard for undefined/null)
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 [errorMessage, setErrorMessage] = useState<string>("");
const [storeIdentifier, setStoreIdentifier] = useState<string>(store?.storeIdentifier || "");
const [logo, setLogo] = useState<string | null>(store?.logo || null);
const [supportedCoinsSelected, setSupportedCoinsSelected] = useState<string[]>(store?.supportedCoins || ["QORT"]);
const [foreignWallets, setForeignWallets] = useState<Record<string, string>>(
store?.foreignCoins || { ARRR: "" }
);
useEffect(() => {
if (!open) return;
const payload = { title, description, location, shipsTo, logo, supportedCoinsSelected, foreignWallets };
try { localStorage.setItem(STORAGE_KEY_EDIT, JSON.stringify(payload)); } catch {}
}, [open, title, description, location, shipsTo, logo, supportedCoinsSelected, foreignWallets]);
useEffect(() => {
// Keep fields in sync if a different store is loaded while open
if (store) {
setTitle(store.title || "");
setDescription(store.description || "");
setLocation(store.location || "");
setShipsTo(store.shipsTo || "");
setStoreIdentifier(store.storeIdentifier || "");
setLogo(store.logo || null);
setSupportedCoinsSelected(store.supportedCoins || ["QORT"]);
setForeignWallets(store.foreignCoins || { ARRR: "" });
}
}, [store]);
const handleUpdate = async (): Promise<void> => {
try {
setErrorMessage("");
if (!logo) {
setErrorMessage("A logo is required");
return;
}
const foreignCoins: ForeignCoins = {
ARRR: arrrWalletAddress,
};
const foreignCoins: Record<string, string> = {};
supportedCoinsSelected
.filter(coin => coin !== "QORT")
.filter((coin) => coin !== "QORT")
.forEach((item: string) => {
if (!foreignCoins[item])
throw new Error(`Please add a ${item} address`);
const addr = foreignWallets[item]?.trim();
if (!addr) throw new Error(`Please add a ${item} address`);
foreignCoins[item] = addr;
});
await onPublish({
const save = onUpdate ?? onPublish;
if (!save) {
throw new Error("Save handler missing (onUpdate/onPublish).");
}
const payload: onPublishParamEdit = {
...store,
title,
description,
shipsTo,
location,
logo,
foreignCoins: {
ARRR: arrrWalletAddress,
},
foreignCoins,
supportedCoins: supportedCoinsSelected,
});
handleClose();
};
await save(payload);
setCloseEditStoreModal?.(true);
onClose?.();
} catch (error: any) {
setErrorMessage(error.message);
setErrorMessage(error?.message || String(error));
}
};
const handleInputChangeId = (event: ChangeEvent<HTMLInputElement>) => {
let newValue = event.target.value
.replace(/[^a-zA-Z0-9\s-]/g, "")
.replace(/\s+/g, "-")
.replace(/-+/g, "-")
.trim();
if (newValue.toLowerCase().includes("post")) {
newValue = newValue.replace(/post/gi, "");
}
if (newValue.toLowerCase().includes("q-shop")) {
newValue = newValue.replace(/q-shop/gi, "");
}
setStoreIdentifier(newValue);
};
// Close modal if parent signals it
useEffect(() => {
if (open && currentStore && storeId === currentStore.id) {
setTitle(currentStore?.title || "");
setDescription(currentStore?.description || "");
setLogo(currentStore?.logo || null);
setLocation(currentStore?.location || "");
setShipsTo(currentStore?.shipsTo || "");
const selectedCoinsList = [...new Set([...(currentStore?.supportedCoins || []), 'QORT'])];
setSupportedCoinsSelected(selectedCoinsList)
setArrrWalletAddress(currentStore?.foreignCoins?.ARRR || "")
if (closeEditStoreModal) {
setCloseEditStoreModal?.(false);
onClose?.();
}
}, [currentStore, storeId, open]);
const handleClose = (): void => {
setTitle("");
setDescription("");
setErrorMessage("");
setDescription("");
setLogo(null);
setLocation("");
setShipsTo("");
setArrrWalletAddress("");
setSupportedCoinsSelected(["QORT"]);
setShowAdvancedSettings(false);
dispatch(toggleCreateStoreModal(false));
onClose();
};
}, [closeEditStoreModal]);
const handleChipSelect = (value: string[]) => {
setSupportedCoinsSelected(value);
@@ -167,103 +210,89 @@ const MyModal: React.FC<MyModalProps> = ({ open, onClose, onPublish }) => {
const handleChipRemove = (chip: string) => {
if (chip === "QORT") return;
setSupportedCoinsSelected(prevChips => prevChips.filter(c => c !== chip));
setSupportedCoinsSelected((prevChips) => prevChips.filter((c) => c !== chip));
};
const importAddress = async (coin: string) => {
try {
dispatch(setIsLoadingGlobal(true));
const res = await qortalRequest({
action: "GET_USER_WALLET",
coin,
});
if (res?.address) {
setArrrWalletAddress(res.address);
setForeignWallets((prev) => ({ ...prev, [coin]: res.address }));
}
} catch (error) {
console.error(error);
// Show inline and global notification
setErrorMessage(`Unable to import ${coin} address. Please insert it manually`);
// Optional legacy notification
// @ts-ignore
setNotification && dispatch(setNotification({ alertType: "error", msg: `Unable to import ${coin} address. Please insert it manually` }));
} finally {
dispatch(setIsLoadingGlobal(false));
}
};
// Recreate Shop Data
const handleClose = (): void => {
setShowAdvancedSettings(false);
if (setCloseEditStoreModal) setCloseEditStoreModal(true);
// Ensure the modal actually closes
dispatch(toggleEditStoreModal(false));
onClose?.();
};
const handleRecreateShopData = async () => {
if (!currentStore || !user?.name) {
dispatch(
setNotification({
msg: "Error! Missing shop data or name",
alertType: "error",
})
);
return;
}
try {
const shortStoreId = currentStore?.shortStoreId;
if (!store?.id || !username) {
dispatch(setNotification({ msg: "Error! Missing shop data or name", alertType: "error" }));
return;
}
dispatch(setIsLoadingGlobal(true));
const shortStoreId = store?.shortStoreId || storeIdentifier || "";
const dataContainer: ShortDataContainer = {
storeId: currentStore.id,
shortStoreId: shortStoreId,
owner: user.name,
storeId: store.id,
shortStoreId,
owner: username,
products: {},
};
const dataContainerToBase64 = await objectToBase64(dataContainer);
const dataContainerCreated = await qortalRequest({
action: "PUBLISH_QDN_RESOURCE",
name: user?.name,
name: username,
service: "DOCUMENT",
data64: dataContainerToBase64,
identifier: `${currentStore.id}-${DATA_CONTAINER_BASE}`,
identifier: `${store.id}-${DATA_CONTAINER_BASE}`,
filename: "datacontainer.json",
});
if (dataContainerCreated && !dataContainerCreated.error) {
dispatch(
setDataContainer({
...dataContainer,
id: `${currentStore.id}-${DATA_CONTAINER_BASE}`,
})
);
dispatch(setDataContainer({ ...dataContainer, id: `${store.id}-${DATA_CONTAINER_BASE}` } as any));
dispatch(resetListProducts());
dispatch(resetProducts());
dispatch(
setNotification({
msg: "Data Container Created!",
alertType: "success",
})
);
onClose();
dispatch(setNotification({ msg: "Data Container Created!", alertType: "success" }));
setShowCreateNewDataContainerModal(false);
setShowAdvancedSettings(false);
} else {
dispatch(setNotification({ msg: "Error creating data container", alertType: "error" }));
}
} catch (error) {
console.error(error);
dispatch(
setNotification({
msg: "Error when creating the data container. Please try again!",
alertType: "error",
})
);
dispatch(setNotification({ msg: "Error when creating data container", alertType: "error" }));
} finally {
dispatch(setIsLoadingGlobal(false));
}
};
return (
<Modal
open={open}
onClose={onClose}
aria-labelledby="modal-title"
aria-describedby="modal-description"
>
<>
<Modal open={open} onClose={handleClose} aria-labelledby="modal-title" aria-describedby="modal-description">
<ModalBody>
<ModalTitle id="modal-title" variant="h6">
Edit Shop
</ModalTitle>
<ModalTitle id="modal-title">Edit Shop</ModalTitle>
{!logo ? (
<ImageUploader onPick={(img: string) => setLogo(img)}>
<AddLogoButton>
Add Shop Logo
<AddLogoIcon
sx={{
height: "25px",
width: "auto",
}}
></AddLogoIcon>
<AddLogoIcon sx={{ height: "25px", width: "auto" }} />
</AddLogoButton>
</ImageUploader>
) : (
@@ -274,123 +303,101 @@ const MyModal: React.FC<MyModalProps> = ({ open, onClose, onPublish }) => {
onClickFunc={() => setLogo(null)}
height={"32"}
width={"32"}
></TimesIcon>
/>
</LogoPreviewRow>
)}
<CustomInputField
id="modal-url-preview"
label="Url Preview"
value={`/${username}/${storeIdentifier}`}
fullWidth
disabled
variant="filled"
/>
<CustomInputField
id="modal-shopId-input"
label="Shop Id"
value={storeIdentifier}
onChange={handleInputChangeId}
fullWidth
inputProps={{ maxLength: 25 }}
required
variant="filled"
/>
<CustomInputField
id="modal-title-input"
label="Title"
value={title}
onChange={e => setTitle(e.target.value)}
inputProps={{ maxLength: 50 }}
onChange={(e: any) => setTitle(e.target.value)}
fullWidth
required
variant="filled"
inputProps={{ maxLength: 50 }}
/>
<CustomInputField
id="modal-description-input"
label="Description"
value={description}
onChange={e => setDescription(e.target.value)}
onChange={(e: any) => setDescription(e.target.value)}
multiline
rows={4}
fullWidth
required
variant="filled"
/>
<CustomInputField
id="modal-location-input"
label="Location"
value={location}
onChange={e => setLocation(e.target.value)}
onChange={(e: any) => setLocation(e.target.value)}
fullWidth
required
variant="filled"
/>
<CustomInputField
id="modal-shipsTo-input"
label="Ships To"
value={shipsTo}
onChange={e => setShipsTo(e.target.value)}
onChange={(e: any) => setShipsTo(e.target.value)}
fullWidth
required
variant="filled"
/>
{/* QORT Wallet Input Field */}
{/* <WalletRow>
<CustomInputField
id="modal-qort-wallet-input"
label="QORT Wallet Address"
value={qortWalletAddress}
onChange={(e: any) => {
setQortWalletAddress(e.target.value);
}}
fullWidth
required
variant="filled"
/>
<Tooltip
TransitionComponent={Zoom}
placement="top"
arrow={true} const importAddress = async (coin: string)=> {
try {
const res = await qortalRequest({
action: 'GET_USER_WALLET',
coin
})
if(res?.address){
setArrrWalletAddress(res.address)
}
console.log({res})
} catch (error) {
}
}
title="Import your QORT Wallet Address from your current account"
>
<IconButton disableFocusRipple={true} disableRipple={true}>
<DownloadArrrWalletIcon
color={theme.palette.text.primary}
height="40"
width="40"
{/* Foreign coin wallet inputs */}
{supportedCoinsSelected
.filter((coin) => coin !== "QORT")
.map((coin) => (
<WalletRow key={coin}>
<CustomInputField
id={`modal-wallet-${coin}`}
label={`${coin} Wallet Address`}
value={foreignWallets[coin] || ""}
onChange={(e: any) => setForeignWallets((prev) => ({ ...prev, [coin]: e.target.value }))}
fullWidth
required
variant="filled"
/>
</IconButton>
</Tooltip>
</WalletRow> */}
<Tooltip
TransitionComponent={Zoom}
placement="top"
arrow
title={`Import your ${coin} Wallet Address from your current account`}
>
<IconButton disableFocusRipple disableRipple onClick={() => importAddress(coin)}>
<DownloadArrrWalletIcon color={theme.palette.text.primary} height="40" width="40" />
</IconButton>
</Tooltip>
</WalletRow>
))}
{/* ARRR Wallet Input Field */}
<WalletRow>
<CustomInputField
id="modal-arrr-wallet-input"
label="ARRR Wallet Address"
value={arrrWalletAddress}
onChange={(e: any) => {
setArrrWalletAddress(e.target.value);
}}
fullWidth
required
variant="filled"
/>
<Tooltip
TransitionComponent={Zoom}
placement="top"
arrow={true}
title="Import your ARRR Wallet Address from your current account"
>
<IconButton
disableFocusRipple={true}
disableRipple={true}
onClick={() => importAddress("ARRR")}
>
<DownloadArrrWalletIcon
color={theme.palette.text.primary}
height="40"
width="40"
/>
</IconButton>
</Tooltip>
</WalletRow>
{/* Coin selection available for your shop */}
<FilterSelect
disableClearable
multiple
@@ -403,45 +410,26 @@ const MyModal: React.FC<MyModalProps> = ({ open, onClose, onPublish }) => {
handleChipSelect(value as string[]);
}}
renderTags={(values: any) =>
values.map((value: string) => {
return (
<FiltersChip
key={value}
label={value}
onDelete={
value !== "QORT" ? () => handleChipRemove(value) : undefined
}
clickable={value === "QORT" ? false : true}
/>
);
})
values.map((value: string) => (
<FiltersChip
key={value}
label={value}
onDelete={value !== "QORT" ? () => handleChipRemove(value) : undefined}
clickable={value === "QORT" ? false : true}
/>
))
}
renderOption={(props, option: any) => {
const isDisabled = option === "QORT";
return (
<FiltersOption {...props}>
<FiltersCheckbox
disabled={isDisabled}
checked={supportedCoinsSelected.some(coin => coin === option)}
/>
{option === "QORT" ? (
<QortalSVG
height="22"
width="22"
color={theme.palette.text.primary}
/>
) : option === "ARRR" ? (
<ARRRSVG
height="22"
width="22"
color={theme.palette.text.primary}
/>
) : null}
<FiltersCheckbox disabled={isDisabled} checked={supportedCoinsSelected.some((coin) => coin === option)} />
<img src={coinPng(option) || ""} alt={`${option}-logo`} width={22} height={22} />
<span style={{ marginLeft: "5px" }}>{option}</span>
</FiltersOption>
);
}}
renderInput={params => (
renderInput={(params) => (
<FilterSelectMenuItems
{...params}
label="Supported Coins"
@@ -449,23 +437,20 @@ const MyModal: React.FC<MyModalProps> = ({ open, onClose, onPublish }) => {
/>
)}
/>
{showAdvancedSettings && (
<CreateNewDataContainerRow
onClick={() => {
setShowCreateNewDataContainerModal(true);
}}
>
<CreateNewDataContainerButton>
Recreate Shop Data
</CreateNewDataContainerButton>
</CreateNewDataContainerRow>
)}
<FormControl fullWidth sx={{ marginBottom: 2 }}></FormControl>
<FormControl fullWidth sx={{ marginBottom: 2 }} />
{errorMessage && (
<Typography color="error" variant="body1">
{errorMessage}
</Typography>
)}
{showAdvancedSettings && (
<CreateNewDataContainerRow onClick={() => setShowCreateNewDataContainerModal(true)}>
<CreateNewDataContainerButton>
Recreate Shop Data
</CreateNewDataContainerButton>
</CreateNewDataContainerRow>
)}
<ButtonRow sx={{ display: "flex", justifyContent: "space-between" }}>
<AdvancedSettingsBox>
<Typography>Advanced Settings</Typography>
@@ -475,58 +460,65 @@ const MyModal: React.FC<MyModalProps> = ({ open, onClose, onPublish }) => {
/>
</AdvancedSettingsBox>
<EditStoreButtonsRow>
<CancelButton
variant="outlined"
color="error"
onClick={handleClose}
>
<Tooltip title="Discard local edits (keeps saved store as-is)">
<CancelButton
variant="text"
onClick={() => {
try { localStorage.removeItem(STORAGE_KEY_EDIT); } catch {}
if (store) {
setTitle(store.title || "");
setDescription(store.description || "");
setLocation(store.location || "");
setShipsTo(store.shipsTo || "");
setLogo(store.logo || null);
setSupportedCoinsSelected(store.supportedCoins || ["QORT"]);
setForeignWallets(store.foreignCoins || { ARRR: "" });
}
}}
>
Clear Changes
</CancelButton>
</Tooltip>
<CancelButton variant="outlined" color="error" onClick={handleClose}>
Cancel
</CancelButton>
<CreateButton variant="contained" onClick={handlePublish}>
Edit Shop
<CreateButton variant="contained" onClick={handleUpdate}>
Save Changes
</CreateButton>
</EditStoreButtonsRow>
</ButtonRow>
<ReusableModal
open={showCreateNewDataContainerModal}
customStyles={{
width: "50%",
maxWidth: 1700,
height: "auto",
backgroundColor:
theme.palette.mode === "light" ? "#e8e8e8" : "#32333c",
position: "relative",
padding: "25px",
borderRadius: "3px",
overflowY: "auto",
overflowX: "hidden",
maxHeight: "90vh",
}}
>
<Box>
Warning! Are you sure you want to recreate your shop's data? This
will clear all your shop's products. This should only be done as a
last resort if you cannot access your product manager or if you are
experiencing issues products displaying properly.
</Box>
<ButtonRow>
<CancelButton
variant="outlined"
color="error"
onClick={() => {
setShowCreateNewDataContainerModal(false);
}}
>
Cancel
</CancelButton>
<CreateButton variant="contained" onClick={handleRecreateShopData}>
Recreate Shop Data
</CreateButton>
</ButtonRow>
</ReusableModal>
</ModalBody>
</Modal>
<ReusableModal
open={showCreateNewDataContainerModal}
onClose={() => setShowCreateNewDataContainerModal(false)}
customStyles={{
width: "50%",
maxWidth: 1700,
height: "auto",
backgroundColor: theme.palette.mode === "light" ? "#e8e8e8" : "#32333c",
position: "relative",
padding: "25px",
borderRadius: "3px",
overflowY: "auto",
overflowX: "hidden",
maxHeight: "90vh",
}}
>
<div>
Warning! Recreating your shop data clears all products. Use only as a last resort.
</div>
<ButtonRow>
<CancelButton variant="outlined" color="error" onClick={() => setShowCreateNewDataContainerModal(false)}>
Cancel
</CancelButton>
<CreateNewDataContainerButton onClick={handleRecreateShopData}>
Recreate Shop Data
</CreateNewDataContainerButton>
</ButtonRow>
</ReusableModal>
</>
);
};
export default MyModal;
export default EditStoreModal;

View File

@@ -0,0 +1,28 @@
// src/constants/coin-icons.ts
// Central mapping for coin PNG assets used across the app.
// All keys are UPPERCASE tickers.
import QORT from "../assets/img/qort.png";
import ARRR from "../assets/img/arrr.png";
import BTC from "../assets/img/btc.png";
import LTC from "../assets/img/ltc.png";
import DOGE from "../assets/img/doge.png";
import DGB from "../assets/img/dgb.png";
import RVN from "../assets/img/rvn.png";
export type CoinTicker = "QORT" | "ARRR" | "BTC" | "LTC" | "DOGE" | "DGB" | "RVN";
export const CoinIcons: Record<string, string> = {
QORT,
ARRR,
BTC,
LTC,
DOGE,
DGB,
RVN,
};
/** Return PNG path for a coin ticker (case-insensitive). */
export const coinPng = (coin: string | CoinTicker): string | undefined => {
const key = String(coin || "").toUpperCase();
return CoinIcons[key];
};

View File

@@ -0,0 +1,103 @@
// src/constants/coin-registry.ts
export type CoinTicker = 'QORT' | 'ARRR' | 'BTC' | 'LTC' | 'DOGE' | 'DGB' | 'RVN';
export interface CoinMeta {
ticker: CoinTicker;
decimals: number;
/** Non-QORT UTXO coins */
isUtxo: boolean;
/** Whether the UI should offer an optional fee input during send */
supportsCustomFee: boolean;
/** Optional UI-only suggested fee range (coin units) */
feeHintMin?: number;
feeHintMax?: number;
/** Build a transaction explorer URL for a given txid/signature */
txExplorer?: (id: string) => string;
/** Build an address explorer URL for a given address (for seller addr proof if needed) */
addressExplorer?: (address: string) => string;
}
export const CoinRegistry: Record<CoinTicker, CoinMeta> = {
QORT: {
ticker: 'QORT',
decimals: 8,
isUtxo: false,
supportsCustomFee: false,
txExplorer: (sig: string) => `https://explorer.qortal.org/transaction/${encodeURIComponent(sig)}`,
addressExplorer: (addr: string) => `https://explorer.qortal.org/address/${encodeURIComponent(addr)}`,
},
ARRR: {
ticker: 'ARRR',
decimals: 8,
isUtxo: true,
supportsCustomFee: false, // keep off until tested
txExplorer: (txid: string) => `https://explorer.pirate.black/tx/${encodeURIComponent(txid)}`,
addressExplorer: (a: string) => `https://explorer.pirate.black/address/${encodeURIComponent(a)}`,
},
BTC: {
ticker: 'BTC',
decimals: 8,
isUtxo: true,
supportsCustomFee: true,
feeHintMin: 0.000005,
feeHintMax: 0.001,
txExplorer: (txid: string) => `https://mempool.space/tx/${encodeURIComponent(txid)}`,
addressExplorer: (a: string) => `https://mempool.space/address/${encodeURIComponent(a)}`,
},
LTC: {
ticker: 'LTC',
decimals: 8,
isUtxo: true,
supportsCustomFee: true,
feeHintMin: 0.00001,
feeHintMax: 0.01,
txExplorer: (txid: string) => `https://litecoinspace.org/tx/${encodeURIComponent(txid)}`,
addressExplorer: (a: string) => `https://litecoinspace.org/address/${encodeURIComponent(a)}`,
},
DOGE: {
ticker: 'DOGE',
decimals: 8,
isUtxo: true,
supportsCustomFee: true,
feeHintMin: 0.1,
feeHintMax: 5,
txExplorer: (txid: string) => `https://dogechain.info/tx/${encodeURIComponent(txid)}`,
addressExplorer: (a: string) => `https://dogechain.info/address/${encodeURIComponent(a)}`,
},
DGB: {
ticker: 'DGB',
decimals: 8,
isUtxo: true,
supportsCustomFee: true,
feeHintMin: 0.0001,
feeHintMax: 0.1,
txExplorer: (txid: string) => `https://digiexplorer.info/tx/${encodeURIComponent(txid)}`,
addressExplorer: (a: string) => `https://digiexplorer.info/address/${encodeURIComponent(a)}`,
},
RVN: {
ticker: 'RVN',
decimals: 8,
isUtxo: true,
supportsCustomFee: true,
feeHintMin: 0.001,
feeHintMax: 0.1,
txExplorer: (txid: string) => `https://ravencoin.network/tx/${encodeURIComponent(txid)}`,
addressExplorer: (a: string) => `https://ravencoin.network/address/${encodeURIComponent(a)}`,
},
};
export const isUtxo = (coin: CoinTicker) => CoinRegistry[coin].isUtxo;
export const supportsCustomFee = (coin: CoinTicker) => CoinRegistry[coin].supportsCustomFee;
export const txExplorerUrl = (coin: CoinTicker, id?: string | null) =>
id ? CoinRegistry[coin].txExplorer?.(id) ?? null : null;
export const addressExplorerUrl = (coin: CoinTicker, a?: string | null) =>
a ? CoinRegistry[coin].addressExplorer?.(a) ?? null : null;
export const feeHintRange = (coin: CoinTicker): {min: number, max: number} | null => {
const meta = CoinRegistry[coin];
if (meta?.feeHintMin != null && meta?.feeHintMax != null) {
return { min: Number(meta.feeHintMin), max: Number(meta.feeHintMax) };
}
return null;
};

View File

@@ -1,8 +1,2 @@
enum SupportedCoins {
ARRR = 'ARRR',
QORT = 'QORT',
}
export const supportedCoinsArray: string[] = Object.values(SupportedCoins);
export const supportedCoinsArray = ['QORT','ARRR','BTC','LTC','DOGE','DGB','RVN'] as const;
export type SupportedCoin = typeof supportedCoinsArray[number];

1
src/global.d.ts vendored
View File

@@ -29,6 +29,7 @@ interface QortalRequestOptions {
coin?: string
destinationAddress?: string
amount?: number
fee?: string | number
blob?: Blob
mimeType?: string
file?: File

27
src/lib/coins.ts Normal file
View File

@@ -0,0 +1,27 @@
export type CoinSymbol = 'QORT' | 'ARRR' | 'BTC' | 'LTC' | 'DOGE' | 'DGB' | 'RVN';
export type CoinMeta = {
symbol: CoinSymbol;
display: string;
isNative: boolean; // QORT = native
priceKey?: string; // used for /crosschain/price/{priceKey}?inverse=true
supportsCustomFee?: boolean; // allow user-specified fee at checkout
};
export const COINS: Record<CoinSymbol, CoinMeta> = {
QORT: { symbol: 'QORT', display: 'Qortal', isNative: true, priceKey: 'QORT', supportsCustomFee: false },
ARRR: { symbol: 'ARRR', display: 'Pirate Chain', isNative: false, priceKey: 'PIRATECHAIN', supportsCustomFee: false },
BTC: { symbol: 'BTC', display: 'Bitcoin', isNative: false, priceKey: 'BITCOIN', supportsCustomFee: true },
LTC: { symbol: 'LTC', display: 'Litecoin', isNative: false, priceKey: 'LITECOIN', supportsCustomFee: true },
DOGE: { symbol: 'DOGE', display: 'Dogecoin', isNative: false, priceKey: 'DOGECOIN', supportsCustomFee: true },
DGB: { symbol: 'DGB', display: 'DigiByte', isNative: false, priceKey: 'DIGIBYTE', supportsCustomFee: true },
RVN: { symbol: 'RVN', display: 'Ravencoin', isNative: false, priceKey: 'RAVENCOIN', supportsCustomFee: true },
};
export const isNative = (coin: string) => coin === 'QORT';
export const isForeign = (coin: string) => !isNative(coin as CoinSymbol);
export const supportsCustomFee = (coin: string) => {
const meta = COINS[coin as CoinSymbol];
return !!meta?.supportsCustomFee;
};

20
src/lib/explorers.ts Normal file
View File

@@ -0,0 +1,20 @@
// src/lib/explorers.ts
import { CoinRegistry, CoinTicker } from "../constants/coin-registry";
export const explorerTxUrl = (ticker: string | CoinTicker, id: string): string | undefined => {
const key = String(ticker || "").toUpperCase() as CoinTicker;
const meta = (CoinRegistry as any)[key];
if (meta && typeof meta.txExplorer === "function" && id) {
try { return meta.txExplorer(id); } catch {}
}
return undefined;
};
export const explorerAddressUrl = (ticker: string | CoinTicker, addr: string): string | undefined => {
const key = String(ticker || "").toUpperCase() as CoinTicker;
const meta = (CoinRegistry as any)[key];
if (meta && typeof meta.addressExplorer === "function" && addr) {
try { return meta.addressExplorer(addr); } catch {}
}
return undefined;
};

22
src/lib/pricing.ts Normal file
View File

@@ -0,0 +1,22 @@
import { COINS } from './coins';
/**
* Returns a numeric exchange ratio representing how many {coin} per 1 QORT,
* mirroring prior ARRR behavior which used ?inverse=true and divided by 1e8.
* Returns null if unavailable.
*/
export async function getPriceHint(coin: string): Promise<number | null> {
try {
const meta = COINS[coin as keyof typeof COINS];
if (!meta || !meta.priceKey || coin === 'QORT') return null;
const url = `/crosschain/price/${meta.priceKey}?maxtrades=10&inverse=true`;
const resp = await fetch(url, { method: 'GET', headers: { 'Content-Type': 'application/json' }});
const txt = await resp.text();
const ratio = (+txt) / 100000000;
if (isNaN(ratio)) throw new Error('Cannot get exchange rate');
return ratio;
} catch (e) {
return null;
}
}

259
src/lib/validation.ts Normal file
View File

@@ -0,0 +1,259 @@
import { bech32, bech32m, base58check as _base58check } from '@scure/base';
import { sha256 } from '@noble/hashes/sha256';
/** Result type for address validation */
const b58check = _base58check(sha256);
export type ValidationResult = {
valid: boolean;
type?: string;
variant?: 'base58' | 'bech32' | 'bech32m';
hrp?: string;
reason?: string;
};
/** ---------- Base58 helpers ---------- */
function tryBase58(addr: string, allowedVersions: number[]): ValidationResult | null {
try {
const decoded = b58check.decode(addr);
const version = decoded[0];
if (!allowedVersions.includes(version)) {
return { valid: false, variant: 'base58', reason: `unexpected version byte ${version}` };
}
const payloadLen = decoded.length - 1;
if (payloadLen !== 20)
return { valid: false, variant: 'base58', reason: `unexpected payload length ${payloadLen}` };
return { valid: true, variant: 'base58' };
} catch {
return null;
}
}
/** ---------- Bech32 / Bech32m helpers ---------- */
function tryBech(addr: string, expectedHrps: string[]): ValidationResult | null {
const a = addr.trim();
if (!a) return { valid: false, reason: 'empty' };
const lower = a.toLowerCase();
const hrp = lower.split('1', 1)[0] || '';
if (!expectedHrps.includes(hrp)) return null;
let decOk: any = null;
let decmOk: any = null;
try {
decOk = bech32.decode(lower as `${string}1${string}`);
} catch (e) {
// ignore decode errors; we'll try bech32m or return null
}
try {
decmOk = bech32m.decode(lower as `${string}1${string}`);
} catch (e) {
// ignore decode errors; handled by returning null below
}
// Prefer a valid verdict over invalid; evaluate both
if (decOk) {
const words = decOk.words;
if (words.length >= 1) {
const witver = words[0];
if (witver === 0) {
const program = bech32.fromWords(words.slice(1));
if (program.length === 20 || program.length === 32) {
return {
valid: true,
variant: 'bech32',
hrp: decOk.prefix,
type: program.length === 20 ? 'p2wpkh' : 'p2wsh',
};
}
}
}
}
if (decmOk) {
const words = decmOk.words;
if (words.length >= 1) {
const witver = words[0];
if (witver !== 0) {
const program = bech32m.fromWords(words.slice(1));
if (program.length >= 2 && program.length <= 40) {
return {
valid: true,
variant: 'bech32m',
hrp: decmOk.prefix,
type: witver === 1 && program.length === 32 ? 'taproot' : `witver-${witver}`,
};
}
}
}
}
if (decOk) return { valid: false, variant: 'bech32', hrp, reason: 'invalid bech32 witness data' };
if (decmOk)
return { valid: false, variant: 'bech32m', hrp, reason: 'invalid bech32m witness data' };
return null;
}
/** ---------- Public validators ---------- */
export function validateBitcoinAddress(
addr: string,
net: 'mainnet' | 'testnet' = 'mainnet',
): ValidationResult {
const a = addr?.trim() ?? '';
if (!a) return { valid: false, reason: 'empty' };
// Base58
const base = tryBase58(a, net === 'mainnet' ? [0x00, 0x05] : [0x6f, 0xc4]); // p2pkh, p2sh
if (base) return base;
// Bech
const bech = tryBech(a, net === 'mainnet' ? ['bc'] : ['tb']);
if (bech) return bech;
return { valid: false, reason: 'unrecognized format' };
}
export function validateLitecoinAddress(
addr: string,
net: 'mainnet' | 'testnet' = 'mainnet',
): ValidationResult {
const a = addr?.trim() ?? '';
if (!a) return { valid: false, reason: 'empty' };
// Base58: L (0x30), M (0x32), and legacy 3 (0x05)
const base = tryBase58(a, net === 'mainnet' ? [0x30, 0x32, 0x05] : [0x6f, 0x3a]); // testnet p2pkh 0x6f, p2sh 0x3a
if (base) return base;
// Bech32 HRPs for Litecoin: ltc, tltc; also MWEB: ltcmweb, tltcmweb (treat as bech32(m))
const hrps = net === 'mainnet' ? ['ltc', 'ltcmweb'] : ['tltc', 'tltcmweb'];
const bech = tryBech(a, hrps);
if (bech) return bech;
return { valid: false, reason: 'unrecognized format' };
}
/** Qortal address validation through Core API */
export async function validateQortalAddress(addr: string): Promise<ValidationResult> {
const a = addr?.trim() ?? '';
if (!a) return { valid: false, reason: 'empty' };
try {
const res = await fetch(`/addresses/validate/${encodeURIComponent(a)}`);
if (!res.ok) return { valid: false, reason: `HTTP ${res.status}` };
const data = await res.json();
if (typeof data?.isValid === 'boolean') {
return { valid: data.isValid, reason: data.message };
}
return { valid: false, reason: 'unexpected API response' };
} catch (e: any) {
return { valid: false, reason: e?.message || 'network error' };
}
}
/** Coin router convenience */
export function validateAddress(
coin: string,
addr: string,
net: 'mainnet' | 'testnet' = 'mainnet',
): ValidationResult | Promise<ValidationResult> {
switch (coin) {
case 'BTC':
return validateBitcoinAddress(addr, net);
case 'LTC':
return validateLitecoinAddress(addr, net);
case 'DOGE':
return validateDogecoinAddress(addr, net);
case 'DGB':
return validateDigibyteAddress(addr, net);
case 'RVN':
return validateRavencoinAddress(addr, net);
case 'QORT':
return validateQortalAddress(addr);
case 'ARRR':
return validateArrrAddress(addr, net);
default:
return { valid: false, reason: 'unsupported coin' };
}
}
/** Convert Litecoin 'M...' P2SH (0x32) to legacy '3...' P2SH (0x05) for Core compatibility */
export function normalizeLitecoinAddressForSend(addr: string): {
normalized?: string;
ok: boolean;
reason?: string;
} {
const a = addr?.trim() ?? '';
if (!a) return { ok: false, reason: 'empty' };
if (/^(ltc1|tltc1)/i.test(a) || a.startsWith('3')) return { ok: true, normalized: a };
try {
const decoded = b58check.decode(a);
const version = decoded[0];
if (version === 0x32) {
decoded[0] = 0x05;
return { ok: true, normalized: b58check.encode(decoded) };
}
return { ok: true, normalized: a };
} catch {
return { ok: false, reason: 'invalid base58' };
}
}
export function validateDigibyteAddress(
addr: string,
net: 'mainnet' | 'testnet' = 'mainnet',
): ValidationResult {
const a = addr?.trim() ?? '';
if (!a) return { valid: false, reason: 'empty' };
// Base58: D (0x1e) for P2PKH; P2SH new 'S' (0x3f) and legacy '3' (0x05)
const allowed = net === 'mainnet' ? [0x1e, 0x3f, 0x05] : [0x7e, 0x3f];
const base = tryBase58(a, allowed);
if (base) return base;
// Bech32 HRPs: dgb (mainnet), tdgb (testnet)
const hrps = net === 'mainnet' ? ['dgb'] : ['tdgb'];
const bech = tryBech(a, hrps);
if (bech) return bech;
return { valid: false, reason: 'unrecognized format' };
}
export function validateDogecoinAddress(
addr: string,
net: 'mainnet' | 'testnet' = 'mainnet',
): ValidationResult {
const a = addr?.trim() ?? '';
if (!a) return { valid: false, reason: 'empty' };
// Base58 mainnet: P2PKH D (0x1e), P2SH 9/A (0x16). Testnet roughly n (0x6f), 2 (0xc4).
const allowed = net === 'mainnet' ? [0x1e, 0x16] : [0x6f, 0xc4];
const base = tryBase58(a, allowed);
if (base) return base;
// As of now, Dogecoin does not use bech32 for payments on mainnet.
return { valid: false, reason: 'unrecognized format' };
}
export function validateRavencoinAddress(
addr: string,
net: 'mainnet' | 'testnet' = 'mainnet',
): ValidationResult {
const a = addr?.trim() ?? '';
if (!a) return { valid: false, reason: 'empty' };
// Base58 mainnet: P2PKH R (0x3c), P2SH r (0x7a)
const allowed = net === 'mainnet' ? [0x3c, 0x7a] : [0x6f, 0xc4];
const base = tryBase58(a, allowed);
if (base) return base;
return { valid: false, reason: 'unrecognized format' };
}
export function validateArrrAddress(
addr: string,
net: 'mainnet' | 'testnet' = 'mainnet',
): ValidationResult {
const a = addr?.trim() ?? '';
if (!a) return { valid: false, reason: 'empty' };
// ARRR Sapling bech32: HRP 'zs' on mainnet, 43-byte payload (~78 chars). Lowercase and ensure it contains the separator '1'.
try {
const s = a.toLowerCase();
if (!s.includes('1')) throw new Error('not bech32');
const { prefix, words } = bech32.decode(s as `${string}1${string}`);
if (prefix !== 'zs') return { valid: false, reason: 'wrong HRP (expected zs)' };
const bytes = new Uint8Array(bech32.fromWords(words));
if (bytes.length !== 43)
return {
valid: false,
variant: 'bech32',
reason: `unexpected payload length ${bytes.length}`,
};
return { valid: true, variant: 'bech32' };
} catch {
return { valid: false, reason: 'unrecognized format' };
}
}

View File

@@ -7,8 +7,9 @@ import {
useMediaQuery,
FormControl,
SelectChangeEvent,
Box,
} from "@mui/material";
import { TextField } from "@mui/material";
import {
addQuantityToCart,
subtractQuantityFromCart,
@@ -81,6 +82,8 @@ import { AcceptedCoin } from "../StoreList/StoreList-styles";
import { ARRRSVG } from "../../assets/svgs/ARRRSVG";
import { setPreferredCoin } from "../../state/features/storeSlice";
import { CoinFilter } from "../Store/Store/Store";
import { supportsCustomFee } from "../../lib/coins";
import { feeHintRange } from "../../constants/coin-registry";
import { useModal } from "../../components/common/useModal";
import { MultiplePublish } from "../../components/common/MultiplePublish/MultiplePublish";
@@ -140,6 +143,7 @@ export const Cart = () => {
const [deliveryNote, setDeliveryNote] = useState<string>("");
const [confirmPurchaseModalOpen, setConfirmPurchaseModalOpen] =
useState<boolean>(false);
const [customFee, setCustomFee] = useState<string>("");
const preferredCoin = useSelector((state: RootState) => state.store.preferredCoin);
const [exchangeRate, setExchangeRate] = useState<number | null>(
null
@@ -183,11 +187,8 @@ export const Cart = () => {
const switchCoin = async ()=> {
dispatch(setIsLoadingGlobal(true));
await calculateARRRExchangeRate()
dispatch(setIsLoadingGlobal(false));
}
useEffect(()=> {
@@ -196,6 +197,7 @@ export const Cart = () => {
}
}, [preferredCoin, storeToUse])
const coinToUse = useMemo(()=> {
if(preferredCoin === CoinFilter.arrr && storeToUse?.supportedCoins?.includes(CoinFilter.arrr)){
return CoinFilter.arrr
@@ -204,6 +206,12 @@ export const Cart = () => {
}
}, [preferredCoin, storeToUse])
// Fee hints derived from registry for the selected coin
const feeHint = useMemo(() => feeHintRange(String(coinToUse).toUpperCase() as any), [coinToUse]);
const feeHelper: string | undefined = feeHint ? `Suggested: ${feeHint.min}${feeHint.max} ${String(coinToUse)}` : undefined;
const feeOutOfRange: boolean = !!feeHint && customFee !== "" && (Number(customFee) < (feeHint as any).min || Number(customFee) > (feeHint as any).max);
// Set cart & orders to local state
useEffect(() => {
if (storeId && Object.keys(carts).length > 0) {
@@ -238,7 +246,7 @@ export const Cart = () => {
setState(selectedOption?.text || null);
};
// Check to see if any of the products in the cart are digital and that there are none which are physical
// Check to see if any of the products in the cart are non-shipping (digital/service) and that there are none which are physical
const isDigitalOrder = useMemo(() => {
if (!localCart) return false;
return Object.keys(localCart.orders).every(key => {
@@ -250,7 +258,7 @@ export const Cart = () => {
product = catalogueHashMap[catalogueId]?.products[productId];
}
if (!product) return false;
return product.type === "digital";
return product.type === "digital" || product.type === "service";
});
}, [localCart]);
@@ -328,7 +336,7 @@ export const Cart = () => {
} else if(price && exchangeRate && coinToUse !== CoinFilter.qort){
price = +price * exchangeRate
}
if (!price) {
if (price === undefined || price === null || Number.isNaN(price as any)) {
dispatch(
setNotification({
alertType: "error",
@@ -363,6 +371,7 @@ export const Cart = () => {
);
return;
}
// Fetch seller address/publicKey for encryption and identifiers
let res = await qortalRequest({
action: "GET_NAME_DATA",
name: storeOwner,
@@ -382,34 +391,41 @@ export const Cart = () => {
return;
}
let responseSendCoin = null
let signature = null
// Prepare payment fields and optionally send payment when total > 0
let responseSendCoin: any = null
let signature: string | null = null
let txId: string | null = null
const totalToPayNum = Number(priceToPay)
if(coinToUse === CoinFilter.qort){
responseSendCoin = await qortalRequest({
action: "SEND_COIN",
coin: "QORT",
destinationAddress: address,
amount: priceToPay,
});
signature = responseSendCoin.signature;
} else if(coinToUse === CoinFilter.arrr){
if(!storeToUse?.foreignCoins?.ARRR) throw new Error('Store has not set an ARRR address')
responseSendCoin = await qortalRequest({
action: "SEND_COIN",
coin: "ARRR",
destinationAddress: storeToUse?.foreignCoins?.ARRR.trim(),
amount: priceToPay,
});
} else {
dispatch(
setNotification({
alertType: "error",
msg: "Currency not found",
})
);
if (totalToPayNum > 0) {
if(coinToUse === CoinFilter.qort){
responseSendCoin = await qortalRequest({
action: "SEND_COIN",
coin: "QORT",
destinationAddress: address,
amount: priceToPay,
});
signature = responseSendCoin?.signature ?? responseSendCoin?.data?.signature ?? null;
} else {
const coinTicker = String(coinToUse).toUpperCase();
if (!storeToUse?.foreignCoins?.[coinTicker]) throw new Error(`Store has not set a ${coinTicker} address`);
const dest = storeToUse?.foreignCoins?.[coinTicker].trim();
const sendParams: any = {
action: 'SEND_COIN',
coin: coinTicker,
destinationAddress: dest,
amount: priceToPay,
};
if (coinTicker !== 'QORT' && coinTicker !== 'ARRR' && customFee) {
sendParams.fee = customFee;
}
responseSendCoin = await qortalRequest(sendParams);
if (coinTicker === 'QORT') {
signature = responseSendCoin?.signature ?? responseSendCoin?.data?.signature ?? null;
} else {
txId = (responseSendCoin && (responseSendCoin.txId || responseSendCoin.transactionId || (responseSendCoin.data && responseSendCoin.data.txId))) || null;
}
}
}
try {
@@ -435,8 +451,10 @@ export const Cart = () => {
payment: {
total: priceToPay,
currency: coinToUse,
transactionSignature: signature,
arrrAddressUsed: coinToUse === CoinFilter.arrr ? storeToUse?.foreignCoins?.ARRR.trim() : ""
transactionSignature: signature || "",
addressUsed: totalToPayNum > 0 && coinToUse !== CoinFilter.qort ? (storeToUse?.foreignCoins?.[coinToUse]?.trim() || "") : "",
arrrAddressUsed: totalToPayNum > 0 && coinToUse === CoinFilter.arrr ? storeToUse?.foreignCoins?.ARRR.trim() : "",
txId: totalToPayNum > 0 && coinToUse !== CoinFilter.qort ? (txId || "") : ""
},
communicationMethod: ["Q-Mail"],
};
@@ -460,8 +478,10 @@ export const Cart = () => {
payment: {
total: priceToPay,
currency: coinToUse,
transactionSignature: signature,
arrrAddressUsed: coinToUse === CoinFilter.arrr ? storeToUse?.foreignCoins?.ARRR.trim() : ""
transactionSignature: signature || "",
addressUsed: totalToPayNum > 0 && coinToUse !== CoinFilter.qort ? (storeToUse?.foreignCoins?.[coinToUse]?.trim() || "") : "",
arrrAddressUsed: totalToPayNum > 0 && coinToUse === CoinFilter.arrr ? storeToUse?.foreignCoins?.ARRR.trim() : "",
txId: totalToPayNum > 0 && coinToUse !== CoinFilter.qort ? (txId || "") : ""
},
communicationMethod: ["Q-Mail"],
};
@@ -481,9 +501,8 @@ export const Cart = () => {
};
const mailId = mailUid();
let identifier = `qortal_qmail_${storeOwner?.slice(0, 20)}_${address.slice(
-6
)}_mail_${mailId}`;
const addressSuffix = (address && typeof address === 'string' && address.length >= 6) ? address.slice(-6) : 'FREE';
let identifier = `qortal_qmail_${storeOwner?.slice(0, 20)}_${addressSuffix}_mail_${mailId}`;
// HTML with the order details being sent to seller by Q-Mail
const htmlContent = `
@@ -1098,12 +1117,12 @@ export const Cart = () => {
{price}
</span>
</ProductPriceFont>
{price && (
<ProductPriceFont>
Total Price:
<span>
{coinToUse === CoinFilter.qort && (
<QortalSVG
{(price !== undefined && price !== null) && (
<ProductPriceFont>
Total Price:
<span>
{coinToUse === CoinFilter.qort && (
<QortalSVG
color={theme.palette.text.primary}
height={"20"}
width={"20"}
@@ -1356,6 +1375,20 @@ export const Cart = () => {
<ConfirmPurchaseRow>
Are you sure you wish to complete this purchase?
</ConfirmPurchaseRow>
<ConfirmPurchaseRow>
{!(coinToUse === CoinFilter.qort || coinToUse === CoinFilter.arrr) && supportsCustomFee(coinToUse) && (
<TextField
label="Network fee (optional)"
helperText={feeHelper}
error={feeOutOfRange}
placeholder="e.g. 0.0002"
value={customFee}
onChange={(e) => setCustomFee(e.target.value)}
variant="filled"
fullWidth
/>
)}
</ConfirmPurchaseRow>
<ConfirmPurchaseRow style={{ gap: "15px" }}>
<CancelButton
variant="outlined"

View File

@@ -13,6 +13,7 @@ import { Order } from "../../../state/features/orderSlice";
import { CircularProgress } from "@mui/material";
import { StyledTableRow } from "./OrderTable-styles";
import { setNotification } from "../../../state/features/notificationsSlice";
import PaymentProof from "../../../components/common/PaymentProof";
const tableCellFontSize = "16px";
@@ -154,6 +155,12 @@ export const OrderTable = ({
processedOrders.map(({ index, rowData }) => (
<StyledTableRow key={index}>
{rowContent(index, rowData, openOrder)}
<TableCell sx={{ whiteSpace: 'nowrap' }}>
<PaymentProof
coin={(rowData?.payment?.currency === 'qort' ? 'QORT' : String(rowData?.payment?.currency || '').toUpperCase()) as any}
proof={((rowData?.payment?.currency === 'qort') ? (rowData as any)?.payment?.transactionSignature : (rowData as any)?.payment?.txId) as any}
/>
</TableCell>
</StyledTableRow>
))
) : (

View File

@@ -39,13 +39,18 @@ interface ProductFormProps {
interface ProductObj {
title?: string;
description?: string;
price: number;
price: number; // base QORT price
images: string[];
category?: string;
priceARRR?: number;
// Optional per-coin prices (e.g., priceARRR, priceBTC, priceLTC, priceDOGE, priceDGB, priceRVN)
priceQORT?: number;
priceARRR?: number;
priceBTC?: number;
priceLTC?: number;
priceDOGE?: number;
priceDGB?: number;
priceRVN?: number;
}
export const ProductForm: React.FC<ProductFormProps> = ({
onClose,
onSubmit,
@@ -79,9 +84,6 @@ export const ProductForm: React.FC<ProductFormProps> = ({
const editProductQortPrice =
editProduct?.price?.find((item: Price) => item?.currency === "qort")
?.value || product.price;
const editProductARRRPrice =
editProduct?.price?.find((item: Price) => item?.currency === CoinFilter.arrr)
?.value || "";
const handleInputChange = (event: React.ChangeEvent<HTMLInputElement>) => {
setProduct({
@@ -94,9 +96,10 @@ export const ProductForm: React.FC<ProductFormProps> = ({
const price = Number(value);
setProduct({ ...product, price: price });
};
const handleProductPriceChangeForeign = (value: string, coin:string) => {
const handleProductPriceChangeForeign = (value: string, coin: string) => {
const price = Number(value);
setProduct({ ...product, [`price${coin}`]: price });
const key = `price${coin}` as keyof ProductObj;
setProduct({ ...product, [key]: price });
};
const handleSelectChange = (event: SelectChangeEvent<string | null>) => {
@@ -136,14 +139,12 @@ export const ProductForm: React.FC<ProductFormProps> = ({
value: product.price,
})
for (const value of Object.values(CoinFilter)) {
if(product[`price${value}`]){
price.push(
{
currency: value,
value: +(product[`price${value}`] || 0),
},
)
for (const value of Object.values(CoinFilter) as string[]) {
const key = `price${value}` as keyof ProductObj;
const maybe = product[key] as unknown;
const num = typeof maybe === 'number' ? maybe : Number(maybe as any);
if (!Number.isNaN(num) && num > 0) {
price.push({ currency: value, value: +num });
}
}
@@ -272,20 +273,26 @@ export const ProductForm: React.FC<ProductFormProps> = ({
onChangeFunc={handleProductPriceChange}
required={true}
/>
{currentStore?.supportedCoins?.includes(CoinFilter.arrr) && (
<CustomNumberField
name="arrr-price"
label="Price in ARRR"
variant={Variant.filled}
initialValue={editProductARRRPrice.toString()}
addIconButtons={false}
minValue={0}
maxValue={Number.MAX_SAFE_INTEGER}
allowDecimals={true}
onChangeFunc={(val)=>handleProductPriceChangeForeign(val, CoinFilter.arrr)}
required={false}
/>
)}
{(currentStore?.supportedCoins || [])
.filter((c) => c && c !== CoinFilter.qort)
.map((coin) => {
const initial = editProduct?.price?.find((p: Price) => p?.currency === coin)?.value;
return (
<CustomNumberField
key={coin}
name={`price-${coin}`}
label={`Price in ${coin}`}
variant={Variant.filled}
initialValue={(initial ?? "").toString()}
addIconButtons={false}
minValue={0}
maxValue={Number.MAX_SAFE_INTEGER}
allowDecimals={true}
onChangeFunc={(val) => handleProductPriceChangeForeign(val, coin)}
required={false}
/>
);
})}
<Box>
<FormControl fullWidth>
@@ -305,6 +312,7 @@ export const ProductForm: React.FC<ProductFormProps> = ({
>
<CustomMenuItem value="digital">Digital</CustomMenuItem>
<CustomMenuItem value="physical">Physical</CustomMenuItem>
<CustomMenuItem value="service">Service</CustomMenuItem>
</CustomSelect>
</FormControl>
</Box>

View File

@@ -203,7 +203,7 @@ export const ProductManager = () => {
const priceInQort = product?.price?.find(
(item: Price) => item?.currency === "qort"
)?.value;
if (!priceInQort)
if (priceInQort === undefined || priceInQort === null || Number.isNaN(priceInQort as any))
throw new Error("Cannot find price for one of your products");
const lastCatalogueInList = listOfCataloguesToPublish.at(-1);
if (
@@ -301,10 +301,10 @@ export const ProductManager = () => {
const priceInQort = product?.price?.find(
(item: Price) => item?.currency === "qort"
)?.value;
if (!priceInQort)
if (priceInQort === undefined || priceInQort === null || Number.isNaN(priceInQort as any))
throw new Error("Cannot find price for one of your products");
if (priceInQort <= 0)
throw new Error("Price cannot be less than or equal to 0");
if ((priceInQort as number) < 0)
throw new Error("Price cannot be negative");
dataContainerToPublish.products[product.id] = {
created: product.created,
priceQort: priceInQort,

View File

@@ -54,6 +54,7 @@ import { CustomInputField } from "../../../components/modals/CreateStoreModal-st
import { CustomMenuItem } from "../NewProduct/NewProduct-styles";
import { CoinFilter } from "../../Store/Store/Store";
import { ARRRSVG } from "../../../assets/svgs/ARRRSVG";
import PaymentProof from "../../../components/common/PaymentProof";
/* <QortalSVG /> must be replaced by <ARRRSVG /> everywhere here depending on the payment info */
@@ -88,6 +89,8 @@ export const ShowOrder: FC<ShowOrderProps> = ({
const [statusLoader, setStatusLoader] = useState(false);
const toTicker = (c: any) => (c === CoinFilter.qort ? "QORT" : c === CoinFilter.arrr ? "ARRR" : String(c).toUpperCase());
const closeModal = () => {
setIsOpen(false);
};
@@ -223,7 +226,7 @@ export const ShowOrder: FC<ShowOrderProps> = ({
}
};
// Check to see if any of the products in the order are digital and that there are none which are physical. Hide delivery details in the order if that's the case.
// Check to see if any of the products in the order are non-shipping (digital/service) and that there are none which are physical. Hide delivery details if that's the case.
const isDigitalOrder = useMemo(() => {
if (order && order?.details) {
if (!order) return false;
@@ -232,7 +235,7 @@ export const ShowOrder: FC<ShowOrderProps> = ({
.every(key => {
const product = order?.details?.[key]?.product;
if (!product) return false;
return product?.type === "digital";
return product?.type === "digital" || product?.type === "service";
});
} else {
return false;
@@ -490,9 +493,15 @@ export const ShowOrder: FC<ShowOrderProps> = ({
<span style={{ fontWeight: 300 }}>
{order?.payment?.arrrAddressUsed}
</span>
<div style={{ marginTop: 8 }}>
<PaymentProof coin={toTicker(coinToUse) as any} proof={order?.payment?.txId as any} />
</div>
</>
)}
<CloseDetailsCardIcon
<div style={{ marginTop: 8 }}>
<PaymentProof coin={"QORT" as any} proof={order?.payment?.transactionSignature as any} />
</div>
<CloseDetailsCardIcon
width={"18"}
height={"18"}
color={theme.palette.text.primary}
@@ -514,6 +523,9 @@ export const ShowOrder: FC<ShowOrderProps> = ({
</>
);
})}
<div style={{ marginTop: 8 }}>
<PaymentProof coin={"QORT" as any} proof={order?.payment?.transactionSignature as any} />
</div>
<CloseDetailsCardIcon
width={"18"}
height={"18"}

View File

@@ -1,10 +1,10 @@
import { useMemo } from "react";
import { Card, CardContent, CardMedia, useTheme } from "@mui/material";
import { CardMedia, useTheme } from "@mui/material";
import { RootState } from "../../../state/store";
import { Product } from "../../../state/features/storeSlice";
import { useDispatch, useSelector } from "react-redux";
import { setProductToCart } from "../../../state/features/cartSlice";
import { QortalSVG } from "../../../assets/svgs/QortalSVG";
import { coinPng } from "../../../constants/coin-icons";
import {
AddToCartButton,
ProductDescription,
@@ -16,16 +16,15 @@ import { CartSVG } from "../../../assets/svgs/CartSVG";
import { useNavigate } from "react-router-dom";
import { setNotification } from "../../../state/features/notificationsSlice";
import { AcceptedCoinRow } from "../Store/Store-styles";
import { ARRRSVG } from "../../../assets/svgs/ARRRSVG";
import { CoinFilter } from "../Store/Store";
function addEllipsis(str: string, limit: number) {
if (str.length > limit) {
return str.substring(0, limit - 3) + "...";
} else {
return str;
}
return str;
}
interface ProductCardProps {
product: Product;
exchangeRate: number | null;
@@ -38,19 +37,11 @@ export const ProductCard: React.FC<ProductCardProps> = ({ product, exchangeRate,
const theme = useTheme();
const storeId = useSelector((state: RootState) => state.store.storeId);
const storeOwner = useSelector((state: RootState) => state.store.storeOwner);
const user = useSelector((state: RootState) => state.auth.user);
const catalogueHashMap = useSelector((state: RootState) => state.global.catalogueHashMap);
const catalogueHashMap = useSelector(
(state: RootState) => state.global.catalogueHashMap
);
const userName = useMemo(() => {
if (!user?.name) return "";
return user.name;
}, [user]);
const userName = useMemo(() => user?.name || "", [user]);
const profileImg = product?.images?.[0];
@@ -65,21 +56,33 @@ export const ProductCard: React.FC<ProductCardProps> = ({ product, exchangeRate,
return;
}
navigate(
`/${
product?.user || catalogueHashMap[product?.catalogueId]?.user
}/${storeId}/${product?.id}/${product.catalogueId}`
`/${product?.user || catalogueHashMap[product?.catalogueId]?.user}/${storeId}/${product?.id}/${product.catalogueId}`
);
};
let price = product?.price?.find(item => item?.currency === "qort")?.value;
const priceArrr = product?.price?.find(item => item?.currency === CoinFilter.arrr)?.value;
if(filterCoin === CoinFilter.arrr && priceArrr) {
price = +priceArrr
const priceQort = product?.price?.find(item => item?.currency === "qort")?.value;
const priceForeign = product?.price?.find(item => item?.currency === filterCoin)?.value;
let price = priceQort;
if (filterCoin !== CoinFilter.qort && priceForeign) {
price = +priceForeign;
} else if (priceQort && exchangeRate && filterCoin !== CoinFilter.qort) {
price = +priceQort * exchangeRate;
}
else if(price && exchangeRate && filterCoin !== CoinFilter.qort){
price = +price * exchangeRate
let qortApprox: number | undefined;
if (filterCoin !== CoinFilter.qort) {
if (priceForeign && exchangeRate) {
qortApprox = Number((+priceForeign / exchangeRate).toFixed(8));
} else if (priceQort) {
qortApprox = Number((+priceQort).toFixed(8));
}
}
const fmtQort = (n?: number) => {
if (n === null || n === undefined || isNaN(n as any)) return "";
return Number(n).toFixed(2);
};
return (
<StyledCard>
<CardMedia
@@ -110,35 +113,27 @@ export const ProductCard: React.FC<ProductCardProps> = ({ product, exchangeRate,
width: "100%",
}}
>
{filterCoin === CoinFilter.qort && (
{filterCoin === CoinFilter.qort ? (
<AcceptedCoinRow>
<QortalSVG
color={theme.palette.text.primary}
height={"23"}
width={"23"}
/>{" "}
{price}
</AcceptedCoinRow>
<img src={coinPng('QORT') || ''} alt="QORT" width={23} height={23} />
{" "}{fmtQort(price as number)}
</AcceptedCoinRow>
) : (
<AcceptedCoinRow>
<img src={coinPng(filterCoin) || ''} alt={filterCoin} width={23} height={23} />
{" "}{price}
<span style={{ width: 12 }} />
<img src={coinPng('QORT') || ''} alt="QORT" width={23} height={23} />
{" "}{fmtQort(qortApprox ?? (priceQort as number))}
</AcceptedCoinRow>
)}
{filterCoin === CoinFilter.arrr && (
<AcceptedCoinRow>
<ARRRSVG
color={theme.palette.text.primary}
height={"23"}
width={"23"}
/>{" "}
{price}
</AcceptedCoinRow>
)}
</ProductDescription>
</StyledCardContent>
<div style={{ height: "37px" }}>
{storeOwner !== userName && (
<AddToCartButton
style={{
cursor:
product.status === "AVAILABLE" ? "pointer" : "not-allowed",
cursor: product.status === "AVAILABLE" ? "pointer" : "not-allowed",
}}
color="primary"
onClick={() => {
@@ -163,11 +158,7 @@ export const ProductCard: React.FC<ProductCardProps> = ({ product, exchangeRate,
>
{product.status === "AVAILABLE" ? (
<>
<CartSVG
color={theme.palette.text.primary}
height={"18"}
width={"18"}
/>{" "}
<CartSVG color={theme.palette.text.primary} height={"18"} width={"18"} />{" "}
Add to Cart
</>
) : product.status === "OUT_OF_STOCK" ? (

View File

@@ -18,6 +18,8 @@ import {
Rating,
Typography,
Skeleton,
Select,
MenuItem,
} from "@mui/material";
import {
Catalogue,
@@ -79,6 +81,7 @@ import { ExpandMoreSVG } from "../../../assets/svgs/ExpandMoreSVG";
import { StoreDetails } from "../StoreDetails/StoreDetails";
import { StoreReviews } from "../StoreReviews/StoreReviews";
import { setNotification } from "../../../state/features/notificationsSlice";
import { getPriceHint } from "../../../lib/pricing";
import {
DATA_CONTAINER_BASE,
REVIEW_BASE,
@@ -86,6 +89,7 @@ import {
} from "../../../constants/identifiers";
import QORT from "../../../assets/img/qort.png";
import ARRR from "../../../assets/img/arrr.png";
import { coinPng } from "../../../constants/coin-icons";
import {
AcceptedCoin,
ExchangeRateCard,
@@ -114,6 +118,11 @@ enum DateFilter {
export enum CoinFilter {
qort = "QORT",
arrr = "ARRR",
btc = "BTC",
ltc = "LTC",
doge = "DOGE",
dgb = "DGB",
rvn = "RVN",
}
export const Store = () => {
@@ -183,41 +192,39 @@ export const Store = () => {
null
);
const formatRate = (n: number): string => {
if (!isFinite(n)) return "";
const abs = Math.abs(n);
if (abs >= 1) return Number(n).toFixed(4);
return Number(n).toPrecision(4);
};
const storeToUse = useMemo(()=> {
return username === user?.name
? currentStore
: currentViewedStore
}, [username, user?.name, currentStore, currentViewedStore])
const calculateARRRExchangeRate = async()=> {
const calculateExchangeRate = async (coin: string) => {
try {
const url = '/crosschain/price/PIRATECHAIN?maxtrades=10&inverse=true'
const info = await fetch(url, {
method: "GET",
headers: {
"Content-Type": "application/json",
},
});
const responseDataStore = await info.text();
const ratio = +responseDataStore /100000000
if(isNaN(ratio)) throw new Error('Cannot get exchange rate')
setExchangeRate(ratio)
} catch (error) {
dispatch(setPreferredCoin(CoinFilter.qort))
dispatch(
setNotification({
alertType: "error",
msg: "Cannot get exchange rate- reverted to QORT",
})
);
const ratio = await getPriceHint(coin);
if (!ratio) {
dispatch(setPreferredCoin(CoinFilter.qort));
dispatch(setNotification({ alertType: "warning", msg: "Cannot get exchange rate- reverted to QORT" }));
setExchangeRate(null);
return;
}
setExchangeRate(ratio);
} catch (e) {
dispatch(setPreferredCoin(CoinFilter.qort));
dispatch(setNotification({ alertType: "warning", msg: "Cannot get exchange rate- reverted to QORT" }));
setExchangeRate(null);
}
};
}
const switchCoin = async ()=> {
const switchCoin = async ()=> {
dispatch(setIsLoadingGlobal(true));
await calculateARRRExchangeRate()
await calculateExchangeRate(preferredCoin as string);
dispatch(setIsLoadingGlobal(false));
}
@@ -229,14 +236,16 @@ export const Store = () => {
}, [userOwnDataContainer]);
useEffect(()=> {
if(preferredCoin === CoinFilter.arrr && storeToUse?.supportedCoins?.includes(CoinFilter.arrr)){
if(preferredCoin !== CoinFilter.qort && storeToUse?.supportedCoins?.includes(preferredCoin)){
switchCoin()
}
} else {
setExchangeRate(null)
}
}, [preferredCoin, storeToUse])
const coinToUse = useMemo(()=> {
if(preferredCoin === CoinFilter.arrr && storeToUse?.supportedCoins?.includes(CoinFilter.arrr)){
return CoinFilter.arrr
if(storeToUse?.supportedCoins?.includes(preferredCoin)){
return preferredCoin
} else {
return CoinFilter.qort
}
@@ -638,7 +647,7 @@ export const Store = () => {
setCategoryChips(prevChips => prevChips.filter(c => c !== chip));
};
if (isLoadingGlobal) return;
if (isLoadingGlobal) return null;
if (!currentViewedStore && username !== user?.name && !isLoadingGlobal)
return (
@@ -766,43 +775,57 @@ export const Store = () => {
/>
</FiltersTitle>
<FiltersSubContainer>
<FiltersRow>
<AcceptedCoinRow>
QORT
<AcceptedCoin src={QORT} alt="QORT-logo" />
</AcceptedCoinRow>
<FiltersCheckbox
checked={coinToUse === CoinFilter.qort}
onChange={() => {
if (coinToUse !== CoinFilter.qort) {
dispatch(setPreferredCoin(CoinFilter.qort))
}
{/* Prices In single-select */}
<FormControl fullWidth size="small" sx={{ mt: 1 }}>
<Select
value={String(coinToUse).toUpperCase()}
onChange={(e) => {
const sym = String(e.target.value);
const cf = (CoinFilter as any)[sym.toLowerCase()];
if (cf) dispatch(setPreferredCoin(cf));
}}
inputProps={{ "aria-label": "controlled" }}
/>
</FiltersRow>
{storeToUse?.foreignCoins?.ARRR && storeToUse?.supportedCoins?.includes('ARRR') && (
<FiltersRow>
<AcceptedCoinRow>
ARRR
<AcceptedCoin src={ARRR} alt="ARRR-logo" />
</AcceptedCoinRow>
<FiltersCheckbox
checked={coinToUse === CoinFilter.arrr}
onChange={() => {
if (coinToUse !== CoinFilter.arrr) {
dispatch(setPreferredCoin(CoinFilter.arrr))
}
}}
inputProps={{ "aria-label": "controlled" }}
/>
</FiltersRow>
>
{[
"QORT",
...(storeToUse?.supportedCoins || []).filter(Boolean)
].filter((v, i, a) => a.indexOf(v) === i).map((sym) => (
<MenuItem key={sym} value={sym}>
<span style={{ display: "inline-flex", alignItems: "center", gap: 8 }}>
<img src={coinPng(sym) || ""} alt={`${sym}-logo`} width={22} height={22} />
{sym}
</span>
</MenuItem>
))}
</Select>
</FormControl>
{coinToUse !== CoinFilter.qort && exchangeRate && (
<ExchangeRateCard>
<ExchangeRateRow>
<ExchangeRateTitle>
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 8 }}>
<AcceptedCoin src={coinPng('QORT') || ''} alt="QORT" />
1 = {formatRate(exchangeRate)}
<AcceptedCoin src={coinPng(String(coinToUse)) || ''} alt={`${coinToUse}`} />
</span>
</ExchangeRateTitle>
</ExchangeRateRow>
<ExchangeRateRow>
<ExchangeRateTitle>
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 8 }}>
<AcceptedCoin src={coinPng(String(coinToUse)) || ''} alt={`${coinToUse}`} />
1 = {formatRate(1 / (exchangeRate || 1))}
<AcceptedCoin src={coinPng('QORT') || ''} alt="QORT" />
</span>
</ExchangeRateTitle>
</ExchangeRateRow>
<ExchangeRateRow>
<ExchangeRateSubTitle>
{`Rate calculated by recent trade portal trades`}
</ExchangeRateSubTitle>
</ExchangeRateRow>
</ExchangeRateCard>
)}
</FiltersSubContainer>
<FiltersTitle>
Date Product Added
@@ -836,30 +859,6 @@ export const Store = () => {
/>
</FiltersRow>
</FiltersSubContainer>
{coinToUse === CoinFilter.arrr && exchangeRate && (
<FiltersSubContainer>
<ExchangeRateCard>
<ExchangeRateRow>
<ExchangeRateTitle>1 QORT = {exchangeRate} ARRR</ExchangeRateTitle>
</ExchangeRateRow>
<ExchangeRateRow>
<ExchangeRateSubTitle>
{`Rate calculated by recent trade portal trades`}
</ExchangeRateSubTitle>
</ExchangeRateRow>
<ExchangeRateRow style={{ gap: "10px" }}>
<AcceptedCoin src={QORT} alt="QORT-logo" />
<CompareArrowsSVG
color={theme.palette.text.primary}
height={"32"}
width={"32"}
/>
<AcceptedCoin src={ARRR} alt="ARRR-logo" />
</ExchangeRateRow>
</ExchangeRateCard>
</FiltersSubContainer>
)}
</FiltersContainer>
</FiltersCol>
@@ -924,7 +923,18 @@ export const Store = () => {
)}
</AcceptedCoinRow>
</OfferedCoinsRow>
{/* other coin icons */}
{storeToUse?.foreignCoins && storeToUse?.supportedCoins && Object.keys(storeToUse.foreignCoins)
.filter(sym => sym !== 'QORT' && sym !== 'ARRR' && storeToUse.supportedCoins?.includes(sym as any))
.map((sym) => (
<AcceptedCoin
key={sym}
style={{ width: "26px", height: "26px" }}
src={coinPng(sym) || ""}
alt={`${sym}-logo`}
/>
))}
</OfferedCoinsRow>
</StoreTitleCol>
</StoreTitleCard>
{username === user?.name ? (

View File

@@ -28,6 +28,7 @@ import { RootState } from "../../../state/store";
import { clearViewedStoreDataContainer } from "../../../state/features/storeSlice";
import QORT from "../../../assets/img/qort.png";
import ARRR from "../../../assets/img/arrr.png";
import { coinPng } from "../../../constants/coin-icons";
interface StoreCardProps {
storeTitle: string;
@@ -134,10 +135,9 @@ export const StoreCard: FC<StoreCardProps> = ({
)}
</StoreCardInfo>
<AcceptedCoinsRow>
<AcceptedCoin src={QORT} alt="QORT-logo" />
{supportedCoins?.includes('ARRR') && (
<AcceptedCoin src={ARRR} alt="ARRR-logo" />
)}
{(supportedCoins || []).map((sym) => (
<AcceptedCoin key={sym} src={coinPng(sym) || ""} alt={`${sym}-logo`} />
))}
</AcceptedCoinsRow>
<StoreCardOwner>{storeOwner}</StoreCardOwner>
{storeOwner === userName && (

View File

@@ -306,9 +306,6 @@ export const ExchangeRateCard = styled(Box)(({ theme }) => ({
gap: "10px",
padding: "10px 15px",
width: "100%",
height: "200px",
backgroundColor: theme.palette.background.paper,
borderRadius: "10px",
marginTop: "10px",
}));

View File

@@ -6,6 +6,9 @@ interface AuthState {
address: string;
publicKey: string;
name?: string;
names?: string[];
primaryName?: string;
selectedName?: string;
} | null;
}
const initialState: AuthState = {
@@ -19,9 +22,15 @@ export const authSlice = createSlice({
addUser: (state, action) => {
state.user = action.payload;
},
setSelectedName: (state, action) => {
if (state.user) {
state.user.selectedName = action.payload;
state.user.name = action.payload;
}
},
},
});
export const { addUser } = authSlice.actions;
export const { addUser, setSelectedName } = authSlice.actions;
export default authSlice.reducer;

View File

@@ -41,8 +41,10 @@ interface Delivery {
interface Payment {
total: number
currency: string
transactionSignature: string
arrrAddressUsed?:string
transactionSignature?: string
arrrAddressUsed?: string
addressUsed?: string
txId?: string
}
enum CommunicationMethod {
QMail = 'Q-Mail'

15
src/types/shims.d.ts vendored Normal file
View File

@@ -0,0 +1,15 @@
// Ambient shim types to unblock build in Cart and elsewhere until dedicated patches land.
declare global {
// Some paths reference a global `responseSendCoin`; provide a lenient shape.
const responseSendCoin: {
txId?: string;
transactionId?: string;
signature?: string;
data?: { txId?: string; signature?: string };
};
// Some codebases have untyped qortalRequest; keep it permissive.
function qortalRequest(body: any): Promise<any>;
}
export {};

View File

@@ -0,0 +1,14 @@
export interface NameRecord {
name: string
owner: string
}
export async function getAccountNames(address: string): Promise<string[]> {
const res = await qortalRequest({ action: 'GET_ACCOUNT_NAMES', address });
return (res || []).map((r: any) => r.name);
}
export async function getPrimaryAccountName(address: string): Promise<string> {
const res = await qortalRequest({ action: 'GET_PRIMARY_NAME', address });
return typeof res === 'string' ? res : '';
}

View File

@@ -1,13 +1,12 @@
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 { RootState } from "../state/store";
import CreateStoreModal, {
onPublishParam,
} from "../components/modals/CreateStoreModal";
import EditStoreModal, {
onPublishParamEdit,
} from "../components/modals/EditStoreModal";
import EditStoreModal from "../components/modals/EditStoreModal";
import {
setCurrentStore,
setDataContainer,
@@ -30,6 +29,11 @@ import {
addToHashMapStores,
addToStores,
setAllMyStores,
clearViewedStoreDataContainer,
clearReviews,
setStoreId,
setStoreOwner,
setCurrentViewedStore
} from "../state/features/storeSlice";
import { useFetchStores } from "../hooks/useFetchStores";
import { DATA_CONTAINER_BASE, STORE_BASE } from "../constants/identifiers";
@@ -103,6 +107,7 @@ const GlobalWrapper: React.FC<Props> = ({ children, setTheme }) => {
const [userAvatar, setUserAvatar] = useState<string>("");
const [closeCreateStoreModal, setCloseCreateStoreModal] =
useState<boolean>(false);
const [closeEditStoreModal, setCloseEditStoreModal] = useState<boolean>(false);
const [hasAttemptedToFetchShopInitial, setHasAttemptedToFetchShopInitial] =
useState<boolean>(false);
const [storedDataContainer, setStoredDataContainer] =
@@ -113,17 +118,18 @@ const GlobalWrapper: React.FC<Props> = ({ children, setTheme }) => {
const [showDownloadModal, setShowDownloadModal] = useState<boolean>(false);
const {isShow, onCancel, onOk, show} = useModal()
const [publishes, setPublishes] = useState<any>(null);
useEffect(() => {
if (!user?.name) return;
useEffect(() => {
if (!user?.selectedName && !user?.name) return;
setUserAvatar("");
getAvatar();
}, [user?.name]);
}, [user?.selectedName, user?.name]);
const getAvatar = async () => {
try {
let url = await qortalRequest({
action: "GET_QDN_RESOURCE_URL",
name: user?.name,
name: user?.selectedName || user?.name,
service: "THUMBNAIL",
identifier: "qortal_avatar",
});
@@ -133,27 +139,10 @@ const GlobalWrapper: React.FC<Props> = ({ children, setTheme }) => {
setUserAvatar(url);
} catch (error) {
console.error(error);
setUserAvatar("");
}
};
async function getNameInfo(address: string) {
const response = await fetch("/names/address/" + address);
const nameData = await response.json();
if (nameData?.length > 0) {
return nameData[0].name;
} else {
return "";
}
}
// Function to determine if the response is successful for a PUBLISH_QDN_RESOURCE request
function isSuccessful(response: any) {
return (
response && response.type && response.timestamp && response.signature
);
}
async function verifyIfStoreIdExists(username: string, identifier: string) {
let doesExist = true;
const url2 = `/arbitrary/resources?service=STORE&identifier=${identifier}&exactmatchnames=true&name=${username}&limit=1&includemetadata=true`;
@@ -262,10 +251,19 @@ const GlobalWrapper: React.FC<Props> = ({ children, setTheme }) => {
action: "GET_USER_ACCOUNT",
});
const name = await getNameInfo(account.address);
dispatch(addUser({ ...account, name }));
const blog = await getMyCurrentStore(name);
const [names, primaryName] = await Promise.all([
getAccountNames(account.address),
getPrimaryAccountName(account.address),
]);
const selectedName = primaryName || (names && names[0]) || "";
dispatch(addUser({
...account,
names,
primaryName,
selectedName,
name: selectedName,
}));
const blog = await getMyCurrentStore(selectedName);
setHasAttemptedToFetchShopInitial(true);
} catch (error) {
console.error(error);
@@ -290,13 +288,14 @@ const GlobalWrapper: React.FC<Props> = ({ children, setTheme }) => {
}: onPublishParam) => {
if(isCreatingShop) return
setIsCreatingShop(true)
if (!user || !user.name)
if (!user || (!user.selectedName && !user.name))
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 name = user.name;
const name = user?.selectedName ?? user?.name;
if (!name) return;
let formatStoreIdentifier = storeIdentifier;
if (formatStoreIdentifier.endsWith("-")) {
formatStoreIdentifier = formatStoreIdentifier.slice(0, -1);
@@ -447,23 +446,17 @@ const GlobalWrapper: React.FC<Props> = ({ children, setTheme }) => {
);
const editStore = React.useCallback(
async ({
title,
description,
location,
shipsTo,
logo,
foreignCoins,
supportedCoins,
}: onPublishParamEdit) => {
if (!user || !user.name || !currentStore)
async (param: any) => {
const { title, description, location, shipsTo, logo, foreignCoins, supportedCoins } = 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");
if (!currentStore.id) throw new Error("Store id is required");
const name = user.name;
const name = user.selectedName || user.name;
const parts: string[] = currentStore.id.split(`${STORE_BASE}-`);
const shortStoreId: string = parts[1];
@@ -547,9 +540,10 @@ const GlobalWrapper: React.FC<Props> = ({ children, setTheme }) => {
// Get my stores
const getMyStores = async () => {
if (!user || !user?.name) return;
const activeName = user?.selectedName || user?.name;
if (!user || !activeName) return;
try {
const name = user?.name;
const name = activeName;
const query = STORE_BASE;
const url = `/arbitrary/resources/search?service=STORE&name=${name}&query=${query}&limit=20&prefix=true&exactmatchnames=true&mode=ALL&includemetadata=false&reverse=true`;
const response = await fetch(url, {
@@ -598,11 +592,10 @@ const GlobalWrapper: React.FC<Props> = ({ children, setTheme }) => {
askForAccountInformation();
}, []);
// Fetch My Stores on Mount once Auth Is Complete
useEffect(() => {
if (!user?.name) return;
if (!user?.selectedName && !user?.name) return;
getMyStores();
}, [user]);
}, [user?.selectedName, user?.name]);
// Listener useEffect to fetch dataContainer and store data if store?.id changes and it is ours
// Make sure myStores is not empty before executing
@@ -668,13 +661,13 @@ const GlobalWrapper: React.FC<Props> = ({ children, setTheme }) => {
id: `${myStoreFound.id}-${DATA_CONTAINER_BASE}`,
})
);
} else if (user?.name && recentlyVisitedStoreId) {
} else if ((user?.selectedName || user?.name) && recentlyVisitedStoreId) {
// Call to see if the datacontainer actually exists
const dataContainerExists = await qortalRequest({
action: "SEARCH_QDN_RESOURCES",
service: "DOCUMENT",
identifier: `${recentlyVisitedStoreId}-${DATA_CONTAINER_BASE}`,
name: user?.name,
name: myStoreFound.owner,
prefix: false,
exactMatchNames: true,
limit: 0,
@@ -694,7 +687,7 @@ const GlobalWrapper: React.FC<Props> = ({ children, setTheme }) => {
const dataContainer = {
storeId: recentlyVisitedStoreId,
shortStoreId: formatStoreIdentifier,
owner: user?.name,
owner: myStoreFound.owner,
products: {},
};
const dataContainerToBase64 = await objectToBase64(
@@ -703,7 +696,7 @@ const GlobalWrapper: React.FC<Props> = ({ children, setTheme }) => {
try {
const dataContainerCreated = await qortalRequest({
action: "PUBLISH_QDN_RESOURCE",
name: user?.name,
name: myStoreFound.owner,
service: "DOCUMENT",
data64: dataContainerToBase64,
identifier: `${recentlyVisitedStoreId}-${DATA_CONTAINER_BASE}`,
@@ -755,7 +748,28 @@ const GlobalWrapper: React.FC<Props> = ({ children, setTheme }) => {
}
}, [recentlyVisitedStoreId, myStores, userOwnDataContainer]);
useEffect(() => {
if (!user?.selectedName && !user?.name) return;
dispatch(setAllMyStores([]));
dispatch(updateRecentlyVisitedStoreId(""));
dispatch(clearDataCotainer());
dispatch(resetListProducts());
}, [user?.selectedName]);
useEffect(() => {
const activeName = user?.selectedName || user?.name;
if (!activeName) return;
dispatch(setAllMyStores([]));
dispatch(setStoreId(null));
dispatch(setStoreOwner(null));
dispatch(setCurrentViewedStore(null));
dispatch(clearViewedStoreDataContainer());
dispatch(clearReviews());
// Trigger your existing loader (whatever you currently call on auth)
// e.g., getMyStores(); or askForAccountInformation(); or a dedicated fetch
// If you currently load on mount only, add a call here:
// getMyStores();
}, [user?.selectedName, user?.name, dispatch]);
return (
<>
@@ -772,7 +786,7 @@ const GlobalWrapper: React.FC<Props> = ({ children, setTheme }) => {
/>
)}
{isLoadingGlobal && <PageLoader />}
{isOpenCreateStoreModal && user?.name && (
{isOpenCreateStoreModal && (user?.selectedName || user?.name) && (
<CreateStoreModal
open={isOpenCreateStoreModal}
closeCreateStoreModal={closeCreateStoreModal}
@@ -780,14 +794,16 @@ const GlobalWrapper: React.FC<Props> = ({ children, setTheme }) => {
setCloseCreateStoreModal(val)
}
onPublish={createStore}
username={user?.name || ""}
username={user?.selectedName || user?.name || ""}
/>
)}
<EditStoreModal
open={isOpenEditStoreModal}
onClose={onCloseEditStoreModal}
onPublish={editStore}
username={user?.name || ""}
onUpdate={editStore}
closeEditStoreModal={closeEditStoreModal}
setCloseEditStoreModal={setCloseEditStoreModal}
store={currentStore as any}
username={user?.selectedName || user?.name || ""}
/>
{/* Trigger reusable modal if something goes wrong during creation of the datacontainer */}
<ReusableModal
@@ -814,8 +830,8 @@ const GlobalWrapper: React.FC<Props> = ({ children, setTheme }) => {
</ReusableModal>
<NavBar
setTheme={(val: string) => setTheme(val)}
isAuthenticated={!!user?.name}
userName={user?.name || ""}
isAuthenticated={!!(user?.selectedName || user?.name)}
userName={user?.selectedName || user?.name || ""}
userAvatar={userAvatar}
authenticate={askForAccountInformation}
displayDownloadGatewayModalFunc={displayDownloadQortalGatewayModalFunc}
@@ -855,7 +871,7 @@ const GlobalWrapper: React.FC<Props> = ({ children, setTheme }) => {
<DownloadNowButton
onClick={() => {
const userOS = parser.getOS().name;
if (userOS?.includes("Android" || "iOS")) {
if (userOS?.includes("Android") || userOS?.includes("iOS")) {
dispatch(
setNotification({
msg: "Qortal is not available on mobile devices yet. Please download on a desktop or laptop.",