forked from Qortal/q-shop
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:
45
.gitea/workflows/release.yml
Normal file
45
.gitea/workflows/release.yml
Normal 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
4
.gitignore
vendored
@@ -22,3 +22,7 @@ dist-ssr
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
|
||||
# More
|
||||
.runner
|
||||
.gitea.env
|
||||
|
||||
4
config.yaml
Normal file
4
config.yaml
Normal file
@@ -0,0 +1,4 @@
|
||||
runner:
|
||||
capacity: 1
|
||||
labels:
|
||||
- 'self-hosted'
|
||||
50
docs/RELEASE_NOTES_v1.1.0.md
Normal file
50
docs/RELEASE_NOTES_v1.1.0.md
Normal file
@@ -0,0 +1,50 @@
|
||||
# Q‑Shop v1.1.0 — Release Notes
|
||||
|
||||
Release date: 2025‑08‑23
|
||||
|
||||
## 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.
|
||||
|
||||
## What’s 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: de‑duplicated 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 per‑coin 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.
|
||||
|
||||
27
docs/RELEASE_NOTES_v1.1.1.md
Normal file
27
docs/RELEASE_NOTES_v1.1.1.md
Normal file
@@ -0,0 +1,27 @@
|
||||
# Q‑Shop v1.1.1 — Release Notes
|
||||
|
||||
Release date: 2025‑08‑23
|
||||
|
||||
## 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 non‑QORT 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/`.
|
||||
|
||||
23
docs/RELEASE_NOTES_v1.1.2.md
Normal file
23
docs/RELEASE_NOTES_v1.1.2.md
Normal file
@@ -0,0 +1,23 @@
|
||||
# Q‑Shop v1.1.2 — Release Notes
|
||||
|
||||
Release date: 2025‑08‑23
|
||||
|
||||
## Summary
|
||||
Adds a new Service product type and full support for free (0‑price) items, including end‑to‑end 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/0‑price 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 Q‑Mail notification is sent to the seller.
|
||||
- Validation updated to allow 0; negative prices remain blocked.
|
||||
- Minor UI/logic tweaks
|
||||
- Total and per‑item 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/`.
|
||||
|
||||
105
docs/ROAD_TO_RELEASE_v1.1.0.md
Normal file
105
docs/ROAD_TO_RELEASE_v1.1.0.md
Normal file
@@ -0,0 +1,105 @@
|
||||
# Q‑Shop 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 back‑compat.
|
||||
|
||||
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 as‑is.
|
||||
|
||||
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 (2‑decimals).
|
||||
- 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 non‑supported coins) and pre-fills values when editing.
|
||||
- Product card pricing now uses PNG icons for all coins; for non‑QORT 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.
|
||||
11
docs/USER_ANNOUNCEMENT_v1.1.0.md
Normal file
11
docs/USER_ANNOUNCEMENT_v1.1.0.md
Normal file
@@ -0,0 +1,11 @@
|
||||
# Q‑Shop v1.1.0 — What’s New
|
||||
|
||||
Q‑Shop just got a big upgrade. Here’s 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 shop’s data container? There’s 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.
|
||||
|
||||
That’s it! Update to 1.1.0, add your wallet address(es) for the new coins, and enjoy a smoother Q‑Shop experience.
|
||||
9
docs/USER_ANNOUNCEMENT_v1.1.1.md
Normal file
9
docs/USER_ANNOUNCEMENT_v1.1.1.md
Normal file
@@ -0,0 +1,9 @@
|
||||
# Q‑Shop v1.1.1 — What’s 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.
|
||||
|
||||
That’s it — smoother pricing and clearer info. Update to 1.1.1 and enjoy!
|
||||
13
docs/USER_ANNOUNCEMENT_v1.1.2.md
Normal file
13
docs/USER_ANNOUNCEMENT_v1.1.2.md
Normal file
@@ -0,0 +1,13 @@
|
||||
# Q‑Shop v1.1.2 — What’s 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; you’ll still get an order + Q‑Mail 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.
|
||||
|
||||
That’s it — easy service listings and truly free items!
|
||||
|
||||
41
package-lock.json
generated
41
package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -226,7 +226,7 @@ export const GenericModal: React.FC<GenericModalProps> = ({
|
||||
</Typography>
|
||||
)}
|
||||
<Typography variant="h6" component="h2" gutterBottom>
|
||||
Upload {service}
|
||||
Publish {service}
|
||||
</Typography>
|
||||
<Box
|
||||
{...getRootProps()}
|
||||
|
||||
62
src/components/common/PaymentProof.tsx
Normal file
62
src/components/common/PaymentProof.tsx
Normal 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;
|
||||
@@ -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
|
||||
|
||||
@@ -194,7 +194,7 @@ const VideoModal: React.FC<VideoModalProps> = ({
|
||||
</Typography>
|
||||
)}
|
||||
<Typography variant="h6" component="h2" gutterBottom>
|
||||
Upload Video
|
||||
Publish Video
|
||||
</Typography>
|
||||
<Box
|
||||
{...getRootProps()}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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;
|
||||
|
||||
28
src/constants/coin-icons.ts
Normal file
28
src/constants/coin-icons.ts
Normal 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];
|
||||
};
|
||||
103
src/constants/coin-registry.ts
Normal file
103
src/constants/coin-registry.ts
Normal 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;
|
||||
};
|
||||
@@ -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
1
src/global.d.ts
vendored
@@ -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
27
src/lib/coins.ts
Normal 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
20
src/lib/explorers.ts
Normal 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
22
src/lib/pricing.ts
Normal 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
259
src/lib/validation.ts
Normal 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' };
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
))
|
||||
) : (
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"}
|
||||
|
||||
@@ -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" ? (
|
||||
|
||||
@@ -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 ? (
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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",
|
||||
}));
|
||||
|
||||
|
||||
@@ -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;
|
||||
@@ -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
15
src/types/shims.d.ts
vendored
Normal 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 {};
|
||||
14
src/utils/qortalRequestFunctions.ts
Normal file
14
src/utils/qortalRequestFunctions.ts
Normal 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 : '';
|
||||
}
|
||||
@@ -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.",
|
||||
|
||||
Reference in New Issue
Block a user