chore(release): v1.1.0\n\n- Bump version to 1.1.0\n- Add release notes and user announcement\n- Add Gitea release workflow\n- Finalize coin support and UI fixes

This commit is contained in:
q-shop-release-bot
2025-08-23 06:03:35 -04:00
parent 81ffd14d89
commit 37b3fc0427
29 changed files with 1394 additions and 613 deletions

View File

@@ -0,0 +1,36 @@
name: build-and-zip
on:
push:
tags:
- 'v*'
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,91 @@
# 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).
## 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.

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.

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-shop",
"private": true,
"version": "1.0.0",
"version": "1.1.0",
"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

@@ -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

@@ -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) {
@@ -382,8 +390,9 @@ export const Cart = () => {
return;
}
let responseSendCoin = null
let signature = null
let responseSendCoin: any = null
let signature: string | null = null
let txId: string | null = null
if(coinToUse === CoinFilter.qort){
responseSendCoin = await qortalRequest({
@@ -392,24 +401,27 @@ export const Cart = () => {
destinationAddress: address,
amount: priceToPay,
});
signature = responseSendCoin.signature;
signature = responseSendCoin?.signature ?? responseSendCoin?.data?.signature ?? null;
} 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",
})
);
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 +447,10 @@ export const Cart = () => {
payment: {
total: priceToPay,
currency: coinToUse,
transactionSignature: signature,
arrrAddressUsed: coinToUse === CoinFilter.arrr ? storeToUse?.foreignCoins?.ARRR.trim() : ""
transactionSignature: signature || "",
addressUsed: coinToUse !== CoinFilter.qort ? (storeToUse?.foreignCoins?.[coinToUse]?.trim() || "") : "",
arrrAddressUsed: coinToUse === CoinFilter.arrr ? storeToUse?.foreignCoins?.ARRR.trim() : "",
txId: coinToUse !== CoinFilter.qort ? (txId || "") : ""
},
communicationMethod: ["Q-Mail"],
};
@@ -460,8 +474,10 @@ export const Cart = () => {
payment: {
total: priceToPay,
currency: coinToUse,
transactionSignature: signature,
arrrAddressUsed: coinToUse === CoinFilter.arrr ? storeToUse?.foreignCoins?.ARRR.trim() : ""
transactionSignature: signature || "",
addressUsed: coinToUse !== CoinFilter.qort ? (storeToUse?.foreignCoins?.[coinToUse]?.trim() || "") : "",
arrrAddressUsed: coinToUse === CoinFilter.arrr ? storeToUse?.foreignCoins?.ARRR.trim() : "",
txId: coinToUse !== CoinFilter.qort ? (txId || "") : ""
},
communicationMethod: ["Q-Mail"],
};
@@ -1356,6 +1372,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,
@@ -94,9 +99,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 +142,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 });
}
}

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);
};
@@ -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,5 +1,5 @@
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";
@@ -22,10 +22,10 @@ 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 +38,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,19 +57,26 @@ 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));
}
}
return (
@@ -112,33 +111,34 @@ export const ProductCard: React.FC<ProductCardProps> = ({ product, exchangeRate,
>
{filterCoin === CoinFilter.qort && (
<AcceptedCoinRow>
<QortalSVG
color={theme.palette.text.primary}
height={"23"}
width={"23"}
/>{" "}
{price}
</AcceptedCoinRow>
<QortalSVG
color={theme.palette.text.primary}
height={"23"}
width={"23"}
/>{" "}
{price}
</AcceptedCoinRow>
)}
{filterCoin === CoinFilter.arrr && (
<AcceptedCoinRow>
<ARRRSVG
color={theme.palette.text.primary}
height={"23"}
width={"23"}
/>{" "}
{price}
</AcceptedCoinRow>
{filterCoin !== CoinFilter.qort && (
<AcceptedCoinRow>
{filterCoin === CoinFilter.arrr ? (
<ARRRSVG color={theme.palette.text.primary} height={"23"} width={"23"} />
) : (
<span style={{ fontWeight: 600 }}>{filterCoin}</span>
)}
{" "}{price}
{qortApprox ? (
<div style={{ fontSize: 12, opacity: 0.7, marginTop: 2 }}> {qortApprox} QORT</div>
) : null}
</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 +163,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 = () => {
@@ -189,35 +198,26 @@ export const Store = () => {
: 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 +229,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 +640,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 +768,56 @@ 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>1 QORT = {exchangeRate} {coinToUse}</ExchangeRateTitle>
</ExchangeRateRow>
<ExchangeRateRow>
<ExchangeRateTitle>
1 {String(coinToUse)} = {Number((1 / (exchangeRate || 1)).toFixed(8))} QORT
</ExchangeRateTitle>
</ExchangeRateRow>
<ExchangeRateRow>
<ExchangeRateSubTitle>
{`Rate calculated by recent trade portal trades`}
</ExchangeRateSubTitle>
</ExchangeRateRow>
<ExchangeRateRow style={{ gap: "10px" }}>
<AcceptedCoin src={coinPng('QORT') || ''} alt="QORT-logo" />
<CompareArrowsSVG
color={theme.palette.text.primary}
height={"32"}
width={"32"}
/>
<AcceptedCoin src={coinPng(String(coinToUse)) || ''} alt={`${coinToUse}-logo`} />
</ExchangeRateRow>
</ExchangeRateCard>
)}
</FiltersSubContainer>
<FiltersTitle>
Date Product Added
@@ -836,30 +851,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 +915,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

@@ -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

@@ -6,9 +6,7 @@ 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,
@@ -109,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] =
@@ -447,15 +446,9 @@ const GlobalWrapper: React.FC<Props> = ({ children, setTheme }) => {
);
const editStore = React.useCallback(
async ({
title,
description,
location,
shipsTo,
logo,
foreignCoins,
supportedCoins,
}: onPublishParamEdit) => {
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");
@@ -806,8 +799,10 @@ const GlobalWrapper: React.FC<Props> = ({ children, setTheme }) => {
)}
<EditStoreModal
open={isOpenEditStoreModal}
onClose={onCloseEditStoreModal}
onPublish={editStore}
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 */}
@@ -876,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.",