initial commit

This commit is contained in:
crowetic 2025-06-06 13:19:55 -07:00
commit d3a17fde00
44 changed files with 5529 additions and 0 deletions

24
.gitignore vendored Normal file
View File

@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

3
.prettierignore Normal file
View File

@ -0,0 +1,3 @@
node_modules
build
dist

23
.prettierrc Normal file
View File

@ -0,0 +1,23 @@
{
"arrowParens": "always",
"bracketSameLine": false,
"bracketSpacing": true,
"embeddedLanguageFormatting": "auto",
"endOfLine": "lf",
"experimentalTernaries": false,
"htmlWhitespaceSensitivity": "css",
"insertPragma": false,
"jsxBracketSameLine": false,
"jsxSingleQuote": false,
"printWidth": 80,
"proseWrap": "preserve",
"quoteProps": "as-needed",
"requirePragma": false,
"semi": true,
"singleAttributePerLine": false,
"singleQuote": true,
"tabWidth": 2,
"trailingComma": "es5",
"useTabs": false,
"vueIndentScriptAndStyle": false
}

View File

@ -0,0 +1,8 @@
{
"hash": "964445a2",
"configHash": "294caa02",
"lockfileHash": "75101775",
"browserHash": "3e05e511",
"optimized": {},
"chunks": {}
}

3
.vite/deps/package.json Normal file
View File

@ -0,0 +1,3 @@
{
"type": "module"
}

63
README.md Normal file
View File

@ -0,0 +1,63 @@
# React + TypeScript + Vite
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
Currently, two official plugins are available:
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react/README.md) uses [Babel](https://babeljs.io/) for Fast Refresh
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
## Expanding the ESLint configuration
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
```js
export default tseslint.config({
extends: [
// Remove ...tseslint.configs.recommended and replace with this
...tseslint.configs.recommendedTypeChecked,
// Alternatively, use this for stricter rules
...tseslint.configs.strictTypeChecked,
// Optionally, add this for stylistic rules
...tseslint.configs.stylisticTypeChecked,
],
languageOptions: {
// other options...
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
},
});
```
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
```js
// eslint.config.js
import reactX from 'eslint-plugin-react-x';
import reactDom from 'eslint-plugin-react-dom';
export default tseslint.config({
plugins: {
// Add the react-x and react-dom plugins
'react-x': reactX,
'react-dom': reactDom,
},
rules: {
// other rules...
// Enable its recommended typescript rules
...reactX.configs['recommended-typescript'].rules,
...reactDom.configs.recommended.rules,
},
});
```
## Internationalization of the app (I18N)
This template supports internationalization (i18n) using [i18next](https://www.i18next.com/), allowing seamless translation of UI text into multiple languages.
The setup includes modularized translation files (namespaces), language detection, context and runtime language switching.
Files with translation are in `src/i18n/locales/<locale>` folder.
`core` namespace is already present and active.

37
eslint.config.js Normal file
View File

@ -0,0 +1,37 @@
import js from '@eslint/js';
import globals from 'globals';
import reactHooks from 'eslint-plugin-react-hooks';
import reactRefresh from 'eslint-plugin-react-refresh';
import tseslint from 'typescript-eslint';
import prettierPlugin from 'eslint-plugin-prettier';
import prettierConfig from 'eslint-config-prettier';
export default tseslint.config(
{ ignores: ['dist'] },
{
extends: [js.configs.recommended, ...tseslint.configs.recommended],
files: ['**/*.{ts,tsx}'],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
},
plugins: {
'react-hooks': reactHooks,
'react-refresh': reactRefresh,
prettier: prettierPlugin,
},
rules: {
...reactHooks.configs.recommended.rules,
'react-refresh/only-export-components': [
'warn',
{ allowConstantExport: true },
],
'prettier/prettier': 'error',
},
},
{
// This disables ESLint rules that would conflict with Prettier
name: 'prettier-config',
rules: prettierConfig.rules,
}
);

13
index.html Normal file
View File

@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/x-icon" href="/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Qortal Q-App</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

4419
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

47
package.json Normal file
View File

@ -0,0 +1,47 @@
{
"name": "q-charts",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"build": "tsc -b && vite build",
"dev": "vite",
"format:check": "prettier --check .",
"format": "prettier --write .",
"initialize": "node scripts/initialize.js",
"lint": "eslint .",
"lint:fix": "eslint . --fix",
"preview": "vite preview"
},
"dependencies": {
"@emotion/react": "^11.14.0",
"@emotion/styled": "^11.14.0",
"@mui/icons-material": "^7.0.1",
"@mui/material": "^7.0.1",
"apexcharts": "^4.7.0",
"i18next": "^25.1.2",
"jotai": "^2.12.4",
"qapp-core": "^1.0.31",
"react": "^19.0.0",
"react-apexcharts": "^1.7.0",
"react-dom": "^19.0.0",
"react-i18next": "^15.5.1",
"react-router-dom": "^7.3.0"
},
"devDependencies": {
"@eslint/js": "^9.21.0",
"@types/react": "^19.0.10",
"@types/react-dom": "^19.0.4",
"@vitejs/plugin-react": "^4.3.4",
"eslint": "^9.21.0",
"eslint-config-prettier": "^10.1.5",
"eslint-plugin-prettier": "^5.4.0",
"eslint-plugin-react-hooks": "^5.1.0",
"eslint-plugin-react-refresh": "^0.4.19",
"globals": "^15.15.0",
"prettier": "^3.5.3",
"typescript": "~5.7.2",
"typescript-eslint": "^8.24.1",
"vite": "^6.2.0"
}
}

BIN
public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

30
scripts/initialize.js Normal file
View File

@ -0,0 +1,30 @@
import { writeFile, access } from 'fs/promises';
import { constants } from 'fs';
import { randomBytes } from 'crypto';
import { join, dirname } from 'path';
import { fileURLToPath } from 'url';
// Resolve __dirname in ES Modules
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
// Define the file path (adjusted for new location in scripts/)
const filePath = join(__dirname, '..', 'src', 'qapp-config.ts');
try {
// Check if file already exists
await access(filePath, constants.F_OK);
console.log('⚠️ qapp-config.ts already exists. Skipping creation.');
} catch {
// File does not exist, proceed to create it
const publicSalt = randomBytes(32).toString('base64');
const tsContent = `export const publicSalt = "${publicSalt}";\n`;
try {
await writeFile(filePath, tsContent, 'utf8');
console.log('✅ qapp-config.ts has been created with a unique public salt.');
} catch (error) {
console.error('❌ Error writing qapp-config.ts:', error);
}
}

3
settings.json Normal file
View File

@ -0,0 +1,3 @@
{
"indentRainbow.colorOnWhiteSpaceOnly": true
}

73
src/App.tsx Normal file
View File

@ -0,0 +1,73 @@
import React, { useEffect, useState } from 'react';
import QortCandlestickChart, {
Candle,
} from './components/QortCandlestickChart';
import { fetchTrades, aggregateCandles } from './utils/qortTrades';
const DEFAULT_BLOCKCHAIN = 'LITECOIN';
const ONE_HOUR = 60 * 60 * 1000;
const App: React.FC = () => {
const [candles, setCandles] = useState<Candle[]>([]);
const [interval, setInterval] = useState(ONE_HOUR);
const [minTimestamp, setMinTimestamp] = useState(
Date.now() - 30 * 24 * ONE_HOUR
); // 30 days ago
// Youd call this whenever the user changes range, interval, etc
useEffect(() => {
let cancelled = false;
fetchTrades({
foreignBlockchain: DEFAULT_BLOCKCHAIN,
minimumTimestamp: minTimestamp,
limit: 1000,
offset: 0,
reverse: true,
})
.then((trades) => {
if (!cancelled) setCandles(aggregateCandles(trades, interval));
})
.catch((e) => {
if (!cancelled) setCandles([]);
console.error(e);
});
return () => {
cancelled = true;
};
}, [interval, minTimestamp]);
// Add controls as needed for changing interval and time range
const [blockchain, setBlockchain] = useState(DEFAULT_BLOCKCHAIN);
return (
<div style={{ background: '#10161c', minHeight: '100vh', padding: 32 }}>
<div style={{ marginBottom: 16 }}>
<label>
Pair:&nbsp;
<select
value={blockchain}
onChange={(e) => setBlockchain(e.target.value)}
>
<option value="LITECOIN">LTC</option>
<option value="RAVENCOIN">RVN</option>
<option value="BITCOIN">BTC</option>
<option value="DIGIBYTE">DGB</option>
<option value="PIRATECHAIN">ARRR</option>
<option value="DOGECOIN">DOGE</option>
</select>
</label>
&nbsp; Interval:&nbsp;
<button onClick={() => setInterval(ONE_HOUR)}>1H</button>
<button onClick={() => setInterval(24 * ONE_HOUR)}>1D</button>
&nbsp; Start Date:&nbsp;
<input
type="date"
value={new Date(minTimestamp).toISOString().slice(0, 10)}
onChange={(e) => setMinTimestamp(new Date(e.target.value).getTime())}
/>
</div>
<QortCandlestickChart candles={candles} />
</div>
);
};
export default App;

23
src/AppWrapper.tsx Normal file
View File

@ -0,0 +1,23 @@
import { Routes } from './routes/Routes.tsx';
import { GlobalProvider } from 'qapp-core';
import { publicSalt } from './qapp-config.ts';
export const AppWrapper = () => {
return (
<GlobalProvider
config={{
appName: 'Q-Charts',
auth: {
balanceSetting: {
interval: 180000,
onlyOnMount: false,
},
authenticateOnMount: true,
},
publicSalt: publicSalt,
}}
>
<Routes />
</GlobalProvider>
);
};

View File

@ -0,0 +1,49 @@
import React from 'react';
import Chart from 'react-apexcharts';
import type { ApexOptions } from 'apexcharts';
export interface Candle {
x: number;
y: [number, number, number, number];
}
interface Props {
candles: Candle[];
}
const QortCandlestickChart: React.FC<Props> = ({ candles }) => {
const options: ApexOptions = {
chart: {
type: 'candlestick',
height: 420,
background: '#181e24',
toolbar: { show: true },
},
title: {
text: 'QORT/LTC Price (1h Candles)',
align: 'left',
style: { color: '#fff' },
},
xaxis: {
type: 'datetime',
labels: { style: { colors: '#ccc' } },
},
yaxis: {
tooltip: { enabled: true },
labels: { style: { colors: '#ccc' } },
},
theme: { mode: 'dark' },
};
const series = [
{
data: candles,
},
];
return (
<Chart options={options} series={series} type="candlestick" height={420} />
);
};
export default QortCandlestickChart;

View File

@ -0,0 +1,75 @@
import { useEffect } from 'react';
import { To, useNavigate } from 'react-router-dom';
import { EnumTheme, themeAtom } from '../state/global/system';
import { useSetAtom } from 'jotai';
import { useTranslation } from 'react-i18next';
import { supportedLanguages } from '../i18n/i18n';
type Language = 'de' | 'en' | 'es' | 'fr' | 'it' | 'ja' | 'ru' | 'zh';
type Theme = 'dark' | 'light';
interface CustomWindow extends Window {
_qdnTheme: Theme;
_qdnLang: Language;
}
const customWindow = window as unknown as CustomWindow;
export const useIframe = () => {
const setTheme = useSetAtom(themeAtom);
const { i18n } = useTranslation();
const navigate = useNavigate();
useEffect(() => {
const themeColorDefault = customWindow?._qdnTheme;
if (themeColorDefault === 'dark') {
setTheme(EnumTheme.DARK);
} else if (themeColorDefault === 'light') {
setTheme(EnumTheme.LIGHT);
}
const languageDefault = customWindow?._qdnLang;
if (supportedLanguages?.includes(languageDefault)) {
i18n.changeLanguage(languageDefault);
}
function handleNavigation(event: {
data: {
action: string;
path: To;
theme: Theme;
language: Language;
};
}) {
if (event.data?.action === 'NAVIGATE_TO_PATH' && event.data.path) {
navigate(event.data.path); // Navigate directly to the specified path
// Send a response back to the parent window after navigation is handled
window.parent.postMessage(
{ action: 'NAVIGATION_SUCCESS', path: event.data.path },
'*'
);
} else if (event.data?.action === 'THEME_CHANGED' && event.data.theme) {
const themeColor = event.data.theme;
if (themeColor === 'dark') {
setTheme(EnumTheme.DARK);
} else if (themeColor === 'light') {
setTheme(EnumTheme.LIGHT);
}
} else if (
event.data?.action === 'LANGUAGE_CHANGED' &&
event.data.language
) {
if (!supportedLanguages?.includes(event.data.language)) return;
i18n.changeLanguage(event.data.language);
}
}
window.addEventListener('message', handleNavigation);
return () => {
window.removeEventListener('message', handleNavigation);
};
}, [navigate, setTheme]);
return { navigate };
};

56
src/i18n/i18n.ts Normal file
View File

@ -0,0 +1,56 @@
import i18n from 'i18next';
import { initReactI18next } from 'react-i18next';
import {
capitalizeAll,
capitalizeFirstChar,
capitalizeFirstWord,
} from './processors';
// Load all locale JSON files
const modules = import.meta.glob('./locales/**/*.json', {
eager: true,
}) as Record<string, any>;
// Dynamically detect unique language codes
export const supportedLanguages: string[] = Array.from(
new Set(
Object.keys(modules)
.map((path) => {
const match = path.match(/\.\/locales\/([^/]+)\//);
return match ? match[1] : null;
})
.filter((lang): lang is string => typeof lang === 'string')
)
);
// Construct i18n resources object
const resources: Record<string, Record<string, any>> = {};
for (const path in modules) {
// Path format: './locales/en/core.json'
const match = path.match(/\.\/locales\/([^/]+)\/([^/]+)\.json$/);
if (!match) continue;
const [, lang, ns] = match;
resources[lang] = resources[lang] || {};
resources[lang][ns] = modules[path].default;
}
i18n
.use(initReactI18next)
.use(capitalizeAll as any)
.use(capitalizeFirstChar as any)
.use(capitalizeFirstWord as any)
.init({
resources,
fallbackLng: 'en',
lng: navigator.language,
supportedLngs: supportedLanguages,
ns: ['core'],
defaultNS: 'core',
interpolation: { escapeValue: false },
react: { useSuspense: false },
debug: import.meta.env.MODE === 'development',
});
export default i18n;

View File

@ -0,0 +1,4 @@
{
"using_theme": "this application is using the theme:",
"welcome": "welcome to Qortal"
}

32
src/i18n/processors.ts Normal file
View File

@ -0,0 +1,32 @@
export const capitalizeAll = {
type: 'postProcessor',
name: 'capitalizeAll',
process: (value: string) => value.toUpperCase(),
};
export const capitalizeFirstChar = {
type: 'postProcessor',
name: 'capitalizeFirstChar',
process: (value: string) => value.charAt(0).toUpperCase() + value.slice(1),
};
export const capitalizeFirstWord = {
type: 'postProcessor',
name: 'capitalizeFirstWord',
process: (value: string) => {
if (!value?.trim()) return value;
const trimmed = value.trimStart();
const firstSpaceIndex = trimmed.indexOf(' ');
if (firstSpaceIndex === -1) {
return trimmed.charAt(0).toUpperCase() + trimmed.slice(1);
}
const firstWord = trimmed.slice(0, firstSpaceIndex);
const restOfString = trimmed.slice(firstSpaceIndex);
const trailingSpaces = value.slice(trimmed.length);
return firstWord.toUpperCase() + restOfString + trailingSpaces;
},
};

29
src/index.css Normal file
View File

@ -0,0 +1,29 @@
@font-face {
font-family: 'Inter';
src: url('./styles/fonts/Inter-SemiBold.ttf') format('truetype');
font-weight: 600;
}
@font-face {
font-family: 'Inter';
src: url('./styles/fonts/Inter-ExtraBold.ttf') format('truetype');
font-weight: 800;
}
@font-face {
font-family: 'Inter';
src: url('./styles/fonts/Inter-Bold.ttf') format('truetype');
font-weight: 700;
}
@font-face {
font-family: 'Inter';
src: url('./styles/fonts/Inter-Regular.ttf') format('truetype');
font-weight: 400;
}
:root {
line-height: 1.2;
padding: 0px;
margin: 0px;
box-sizing: border-box;
font-family: 'Inter';
}

14
src/main.tsx Normal file
View File

@ -0,0 +1,14 @@
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import ThemeProviderWrapper from './styles/theme/theme-provider.tsx';
import { AppWrapper } from './AppWrapper.tsx';
import './index.css';
import './i18n/i18n.ts';
createRoot(document.getElementById('root')!).render(
<StrictMode>
<ThemeProviderWrapper>
<AppWrapper />
</ThemeProviderWrapper>
</StrictMode>
);

1
src/qapp-config.ts Normal file
View File

@ -0,0 +1 @@
export const publicSalt = '6GsJWj6LbBdjd1f+CLHCLIDBIsegsd4GV4fFt12Csjs=';

32
src/routes/Routes.tsx Normal file
View File

@ -0,0 +1,32 @@
import { createBrowserRouter, RouterProvider } from 'react-router-dom';
import Layout from '../styles/Layout';
import App from '../App';
// Use a custom type if you need it
interface CustomWindow extends Window {
_qdnBase: string;
}
const customWindow = window as unknown as CustomWindow;
const baseUrl = customWindow?._qdnBase || '';
export function Routes() {
const router = createBrowserRouter(
[
{
path: '/',
element: <Layout />,
children: [
{
index: true,
element: <App />,
},
],
},
],
{
basename: baseUrl,
}
);
return <RouterProvider router={router} />;
}

View File

@ -0,0 +1,9 @@
import { atom } from 'jotai';
export enum EnumTheme {
LIGHT = 1,
DARK = 2,
}
// Atom to hold the current theme
export const themeAtom = atom<EnumTheme>(EnumTheme.DARK);

17
src/styles/Layout.tsx Normal file
View File

@ -0,0 +1,17 @@
import { Outlet } from 'react-router-dom';
import { useIframe } from '../hooks/useIframeListener';
const Layout = () => {
useIframe();
return (
<>
{/* Add Header here */}
<main>
<Outlet /> {/* This is where page content will be rendered */}
</main>
{/* Add Footer here */}
</>
);
};
export default Layout;

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -0,0 +1,23 @@
import React, { FC } from 'react';
import { ThemeProvider } from '@emotion/react';
import { lightTheme, darkTheme } from './theme';
import { CssBaseline } from '@mui/material';
import { EnumTheme, themeAtom } from '../../state/global/system';
import { useAtom } from 'jotai';
interface ThemeProviderWrapperProps {
children: React.ReactNode;
}
const ThemeProviderWrapper: FC<ThemeProviderWrapperProps> = ({ children }) => {
const [theme] = useAtom(themeAtom);
return (
<ThemeProvider theme={theme === EnumTheme.LIGHT ? lightTheme : darkTheme}>
<CssBaseline />
{children}
</ThemeProvider>
);
};
export default ThemeProviderWrapper;

119
src/styles/theme/theme.ts Normal file
View File

@ -0,0 +1,119 @@
import { createTheme } from '@mui/material/styles';
const commonThemeOptions = {
typography: {
fontFamily: ['Inter'].join(','),
h1: {
fontSize: '2rem',
fontWeight: 600,
},
h2: {
fontSize: '1.75rem',
fontWeight: 500,
},
h3: {
fontSize: '1.5rem',
fontWeight: 500,
},
h4: {
fontSize: '1.25rem',
fontWeight: 500,
},
h5: {
fontSize: '1rem',
fontWeight: 500,
},
h6: {
fontSize: '0.875rem',
fontWeight: 500,
},
body1: {
fontSize: '1rem',
fontWeight: 400,
lineHeight: 1.5,
letterSpacing: '0.5px',
},
body2: {
fontSize: '0.875rem',
fontWeight: 400,
lineHeight: 1.4,
letterSpacing: '0.2px',
},
},
spacing: 8,
shape: {
borderRadius: 4,
},
breakpoints: {
values: {
xs: 0,
sm: 600,
md: 900,
lg: 1200,
xl: 1536,
},
},
MuiDialog: {
styleOverrides: {
paper: {
backgroundImage: 'none',
},
},
},
MuiPopover: {
styleOverrides: {
paper: {
backgroundImage: 'none',
},
},
},
};
const lightTheme = createTheme({
...commonThemeOptions,
palette: {
mode: 'light',
primary: {
main: 'rgb(63, 81, 181)',
dark: 'rgb(113, 198, 212)',
light: 'rgb(180, 200, 235)',
},
secondary: {
main: 'rgba(194, 222, 236, 1)',
},
background: {
default: 'rgba(250, 250, 250, 1)',
paper: 'rgb(220, 220, 220)', // darker card background
},
text: {
primary: 'rgba(0, 0, 0, 0.87)', // 87% black (slightly softened)
secondary: 'rgba(0, 0, 0, 0.6)', // 60% black
},
},
});
const darkTheme = createTheme({
...commonThemeOptions,
palette: {
mode: 'dark',
primary: {
main: 'rgb(100, 155, 240)',
dark: 'rgb(45, 92, 201)',
light: 'rgb(130, 185, 255)',
},
secondary: {
main: 'rgb(69, 173, 255)',
},
background: {
default: 'rgb(49, 51, 56)',
paper: 'rgb(62, 64, 68)',
},
text: {
primary: 'rgb(255, 255, 255)',
secondary: 'rgb(179, 179, 179)',
},
},
});
export { lightTheme, darkTheme };

View File

@ -0,0 +1,131 @@
/* eslint-disable prettier/prettier */
import React, { useEffect, useState } from 'react';
import Dialog from '@mui/material/Dialog';
import DialogTitle from '@mui/material/DialogTitle';
import DialogContent from '@mui/material/DialogContent';
import DialogActions from '@mui/material/DialogActions';
import Button from '@mui/material/Button';
import Typography from '@mui/material/Typography';
import CircularProgress from '@mui/material/CircularProgress';
export interface Trade {
tradeTimestamp: number;
qortAmount: string;
btcAmount: string;
foreignAmount: string;
}
interface FetchAllTradesModalProps {
open: boolean;
onClose: () => void;
onComplete: (allTrades: Trade[]) => void;
foreignBlockchain: string;
batchSize?: number;
minTimestamp?: number;
}
const FetchAllTradesModal: React.FC<FetchAllTradesModalProps> = ({
open,
onClose,
onComplete,
foreignBlockchain,
batchSize = 200,
minTimestamp = 0,
}) => {
const [progress, setProgress] = useState(0);
const [totalFetched, setTotalFetched] = useState(0);
const [error, setError] = useState<string | null>(null);
const [isFetching, setIsFetching] = useState(false);
useEffect(() => {
let cancelled = false;
if (!open) return;
async function fetchAllTrades() {
setError(null);
setProgress(0);
setTotalFetched(0);
setIsFetching(true);
let allTrades: Trade[] = [];
let offset = 0;
let keepFetching = true;
try {
while (keepFetching && !cancelled) {
const params = new URLSearchParams({
foreignBlockchain,
offset: offset.toString(),
limit: batchSize.toString(),
minimumTimestamp: minTimestamp.toString(),
reverse: 'false',
});
const url = `/crosschain/trades?${params.toString()}`;
const resp = await fetch(url);
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
const trades: Trade[] = await resp.json();
allTrades = allTrades.concat(trades);
setTotalFetched(allTrades.length);
setProgress(trades.length);
offset += trades.length;
if (trades.length < batchSize) keepFetching = false;
}
if (!cancelled) {
onComplete(allTrades);
}
} catch (e) {
if (!cancelled) setError(String(e));
} finally {
if (!cancelled) setIsFetching(false);
}
}
fetchAllTrades();
return () => {
cancelled = true;
};
}, [open, foreignBlockchain, batchSize, minTimestamp, onComplete]);
return (
<Dialog open={open} onClose={isFetching ? undefined : onClose} maxWidth="xs" fullWidth>
<DialogTitle>Fetching All Trades</DialogTitle>
<DialogContent>
{isFetching ? (
<>
<Typography gutterBottom>
Obtaining all trades for <b>{foreignBlockchain}</b>.<br />
This could take a while, please be patient...
</Typography>
<Typography gutterBottom>
<b>{totalFetched}</b> trades fetched (last batch: {progress})
</Typography>
<CircularProgress />
</>
) : error ? (
<Typography color="error" gutterBottom>
Error: {error}
</Typography>
) : (
<Typography color="success.main" gutterBottom>
Fetch complete.
</Typography>
)}
</DialogContent>
<DialogActions>
<Button
onClick={onClose}
disabled={isFetching}
variant="contained"
color={isFetching ? 'inherit' : 'primary'}
>
{isFetching ? 'Fetching...' : 'Close'}
</Button>
</DialogActions>
</Dialog>
);
};
export default FetchAllTradesModal;

98
src/utils/qortTrades.ts Normal file
View File

@ -0,0 +1,98 @@
// src/utils/qortTrades.ts
export interface Trade {
tradeTimestamp: number;
qortAmount: string;
btcAmount: string;
foreignAmount: string;
}
export interface FetchTradesOptions {
foreignBlockchain: string;
minimumTimestamp: number;
buyerPublicKey?: string;
sellerPublicKey?: string;
limit?: number; // default 1000
offset?: number;
reverse?: boolean;
}
export async function fetchTrades({
foreignBlockchain,
minimumTimestamp,
buyerPublicKey,
sellerPublicKey,
limit = 1000,
offset = 0,
reverse = false,
}: FetchTradesOptions): Promise<Trade[]> {
const params = new URLSearchParams({
foreignBlockchain,
minimumTimestamp: String(minimumTimestamp),
limit: String(limit),
offset: String(offset),
reverse: String(reverse),
});
if (buyerPublicKey) params.append('buyerPublicKey', buyerPublicKey);
if (sellerPublicKey) params.append('sellerPublicKey', sellerPublicKey);
const url = `crosschain/trades?${params.toString()}`;
const resp = await fetch(url);
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
return await resp.json();
}
// Candle chart utility
export type Candle = { x: number; y: [number, number, number, number] };
export function aggregateCandles(
trades: Trade[],
intervalMs: number
): Candle[] {
const sorted = trades
.slice()
.sort((a, b) => a.tradeTimestamp - b.tradeTimestamp);
const candles: Candle[] = [];
let current: {
bucket: number;
open: number;
high: number;
low: number;
close: number;
} | null = null;
const getPrice = (trade: Trade) => {
const qort = parseFloat(trade.qortAmount);
const ltc = parseFloat(trade.foreignAmount);
return qort > 0 ? ltc / qort : null;
};
for (const trade of sorted) {
const price = getPrice(trade);
if (!price) continue;
const bucket = Math.floor(trade.tradeTimestamp / intervalMs) * intervalMs;
if (!current || current.bucket !== bucket) {
if (current)
candles.push({
x: current.bucket,
y: [current.open, current.high, current.low, current.close],
});
current = {
bucket,
open: price,
high: price,
low: price,
close: price,
};
} else {
current.high = Math.max(current.high, price);
current.low = Math.min(current.low, price);
current.close = price;
}
}
if (current)
candles.push({
x: current.bucket,
y: [current.open, current.high, current.low, current.close],
});
return candles;
}

1
src/vite-env.d.ts vendored Normal file
View File

@ -0,0 +1 @@
/// <reference types="vite/client" />

27
tsconfig.app.json Normal file
View File

@ -0,0 +1,27 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true,
"types": ["qapp-core/global"]
},
"include": ["src"]
}

7
tsconfig.json Normal file
View File

@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}

25
tsconfig.node.json Normal file
View File

@ -0,0 +1,25 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "ES2022",
"lib": ["ES2023"],
"module": "ESNext",
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true,
"types": ["qapp-core/global"]
},
"include": ["vite.config.ts"]
}

11
vite.config.ts Normal file
View File

@ -0,0 +1,11 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
// https://vite.dev/config/
export default defineConfig({
plugins: [react()],
base: '',
optimizeDeps: {
include: ['@mui/material', '@mui/styled-engine', '@mui/system'],
},
});