feat: initial Pezkuwi Apps rebrand from polkadot-apps

Rebranded terminology:
- Polkadot → Pezkuwi
- Kusama → Dicle
- Westend → Zagros
- Rococo → PezkuwiChain
- Substrate → Bizinikiwi
- parachain → teyrchain

Custom logos with Kurdistan brand colors (#e6007a → #86e62a):
- bizinikiwi-hexagon.svg
- sora-bizinikiwi.svg
- hezscanner.svg
- heztreasury.svg
- pezkuwiscan.svg
- pezkuwistats.svg
- pezkuwiassembly.svg
- pezkuwiholic.svg
This commit is contained in:
2026-01-07 13:05:27 +03:00
commit d21bfb1320
5867 changed files with 329019 additions and 0 deletions
@@ -0,0 +1,78 @@
// Copyright 2017-2025 @pezkuwi/app-coretime authors & contributors
// SPDX-License-Identifier: Apache-2.0
import type { ReactNode } from 'react';
import type { ApiPromise } from '@pezkuwi/api';
import type { CoretimeInformation } from '@pezkuwi/react-hooks/types';
import React, { createContext, useContext, useMemo } from 'react';
import { useCoretimeInformation } from '@pezkuwi/react-hooks';
import { createGet, estimateTime } from './utils/index.js';
interface CoretimeContextProps {
coretimeInfo: CoretimeInformation | null;
get: ReturnType<typeof createGet> | null;
saleStartDate: string | null
saleEndDate: string | null
currentRegionEnd: number | null
currentRegionStart: number | null
}
const initState = {
coretimeInfo: null,
currentRegionEnd: null,
currentRegionStart: null,
get: null,
saleEndDate: null,
saleStartDate: null
};
const CoretimeContext = createContext<CoretimeContextProps>(initState);
export const CoretimeProvider = ({ api,
children,
isApiReady }: {
children: ReactNode;
api: ApiPromise;
isApiReady: boolean;
}) => {
const coretimeInfo = useCoretimeInformation(api, isApiReady);
const get = useMemo(() => {
if (coretimeInfo?.constants) {
return createGet(coretimeInfo.constants);
}
return null;
}, [coretimeInfo?.constants]);
const currentRegionEnd = useMemo(() => coretimeInfo ? coretimeInfo?.salesInfo.regionEnd - coretimeInfo?.config.regionLength : 0, [coretimeInfo]);
const currentRegionStart = useMemo(() => coretimeInfo ? coretimeInfo.salesInfo.regionEnd - coretimeInfo?.config.regionLength * 2 : 0, [coretimeInfo]);
const saleStartDate = useMemo(() => get && coretimeInfo && estimateTime(currentRegionStart, get.blocks.relay(coretimeInfo?.status?.lastTimeslice), coretimeInfo.constants.relay)?.formattedDate, [currentRegionStart, coretimeInfo, get]);
const saleEndDate = useMemo(() => get && coretimeInfo && estimateTime(currentRegionEnd, get.blocks.relay(coretimeInfo?.status?.lastTimeslice), coretimeInfo.constants.relay)?.formattedDate, [currentRegionEnd, coretimeInfo, get]);
const value = useMemo(() => {
if (!coretimeInfo || !currentRegionEnd || !currentRegionStart || !get || !saleEndDate || !saleStartDate) {
return initState;
}
return {
coretimeInfo: coretimeInfo ?? null,
currentRegionEnd,
currentRegionStart,
get,
saleEndDate,
saleStartDate
};
}, [coretimeInfo, currentRegionEnd, currentRegionStart, get, saleEndDate, saleStartDate]);
return (
<CoretimeContext.Provider value={value}>
{children}
</CoretimeContext.Provider>
);
};
export const useCoretimeContext = () => useContext(CoretimeContext);
@@ -0,0 +1,69 @@
// Copyright 2017-2025 @pezkuwi/app-coretime authors & contributors
// SPDX-License-Identifier: Apache-2.0
import type { ApiPromise } from '@pezkuwi/api';
import type { TabItem } from '@pezkuwi/react-components/types';
import type { RelayName } from './types.js';
import React, { useRef } from 'react';
import { Route, Routes } from 'react-router-dom';
import { Spinner, Tabs } from '@pezkuwi/react-components';
import { useCall } from '@pezkuwi/react-hooks';
import Overview from './Overview/index.js';
import Sale from './Sale/index.js';
import { useTranslation } from './translate.js';
interface Props {
basePath: string;
className?: string;
api: ApiPromise;
isApiReady: boolean;
}
function createItemsRef (t: (key: string, options?: { replace: Record<string, unknown> }) => string): TabItem[] {
return [
{
isRoot: true,
name: 'overview',
text: t('Overview')
},
{
name: 'sale',
text: t('Sale')
}
];
}
function CoretimePage ({ api, basePath, className, isApiReady }: Props): React.ReactElement<Props> {
const { t } = useTranslation();
const itemsRef = useRef(createItemsRef(t));
const relayName = useCall<string>(isApiReady && api?.rpc.system.chain)?.toString().toLowerCase() as RelayName;
return (
<main className={className}>
<Tabs
basePath={basePath}
items={itemsRef.current}
/>
{!relayName
? <div><Spinner /></div>
: <Routes>
<Route path={basePath}>
<Route
element={<Overview relayName={relayName} />}
index
/>
<Route
element={<Sale relayName={relayName} />}
path='sale'
/>
</Route>
</Routes>
}
</main>
);
}
export default React.memo(CoretimePage);
@@ -0,0 +1,126 @@
// Copyright 2017-2025 @pezkuwi/app-coretime authors & contributors
// SPDX-License-Identifier: Apache-2.0
import type { ChainInformation } from '@pezkuwi/react-hooks/types';
import type { ActiveFilters } from '../types.js';
import React, { useCallback, useState } from 'react';
import { Button, Dropdown, Input } from '@pezkuwi/react-components';
import { useTranslation } from '../translate.js';
import { FilterType, useBlocksSort, useSearchFilter, useTypeFilter } from './filters/index.js';
interface Props {
chainInfo: Record<number, ChainInformation>;
data: number[];
onFilter: (data: number[]) => void;
}
function Filters ({ chainInfo, data: initialData, onFilter }: Props): React.ReactElement<Props> {
const { t } = useTranslation();
const [activeFilters, setActiveFilters] = useState<ActiveFilters>({
search: [],
type: []
});
const { apply: applyBlocksSort, direction, onApply: onApplySort, reset: resetSort } = useBlocksSort({
chainInfo,
data: initialData,
onFilter: (data) => handleFilter(data, FilterType.BLOCKS)
});
const { apply: applySearchFilter, onApply: onApplySearch, reset: resetSearch, searchValue } = useSearchFilter({
data: initialData,
onFilter: (data) => handleFilter(data, FilterType.SEARCH)
});
const { apply: applyTypeFilter, onApply: onApplyType, reset: resetType, selectedType, typeOptions } = useTypeFilter({
chainInfo,
data: initialData,
onFilter: (data) => handleFilter(data, FilterType.TYPE)
});
/**
* 1. Applies additional filtering already present in the filters
* 2. Performs filtering based on the filter type
*/
const handleFilter = useCallback((
filteredData: number[],
filterType: FilterType
): void => {
let resultData = filteredData;
if (filterType !== FilterType.SEARCH) {
resultData = applySearchFilter(resultData, activeFilters.search);
}
if (filterType !== FilterType.TYPE) {
resultData = applyTypeFilter(resultData, activeFilters.type);
}
if (filterType !== FilterType.BLOCKS && direction) {
resultData = applyBlocksSort(resultData, direction);
}
if (filterType !== FilterType.BLOCKS) {
setActiveFilters((prev) => ({
...prev,
[filterType]: filteredData.length === initialData.length ? [] : filteredData
}));
}
onFilter(resultData);
}, [initialData, onFilter, activeFilters, direction, applyBlocksSort, applyTypeFilter, applySearchFilter]);
const resetAllFilters = useCallback(() => {
resetSearch();
resetType();
resetSort();
setActiveFilters({ search: [], type: [] });
onFilter(initialData);
}, [initialData, onFilter, resetSearch, resetType, resetSort]);
const hasActiveFilters = searchValue || selectedType || direction;
return (
<div style={{ alignItems: 'center', display: 'flex', flexDirection: 'row', gap: '10px' }}>
<div style={{ minWidth: '250px' }}>
<Input
aria-label={t('Search by teyrchain id or name')}
className='full isSmall'
label={t('Search')}
onChange={onApplySearch}
placeholder={t('teyrchain id or name')}
value={searchValue}
/>
</div>
<Dropdown
className='isSmall'
label={t('type')}
onChange={onApplyType}
options={typeOptions}
placeholder='select type'
value={selectedType}
/>
<div style={{ height: '20px' }}>
<Button
icon={direction ? (direction === 'DESC' ? 'arrow-up' : 'arrow-down') : 'sort'}
label={t('blocks')}
onClick={onApplySort}
/>
</div>
{hasActiveFilters && (
<div style={{ height: '20px' }}>
<Button
icon='times'
label={t('Reset filters')}
onClick={resetAllFilters}
/>
</div>
)}
</div>
);
}
export default Filters;
@@ -0,0 +1,103 @@
// Copyright 2017-2025 @pezkuwi/app-coretime authors & contributors
// SPDX-License-Identifier: Apache-2.0
import type { BrokerStatus, CoreDescription, PalletBrokerConfigRecord, PalletBrokerSaleInfoRecord, RegionInfo } from '@pezkuwi/react-hooks/types';
import type { RelayName } from '../types.js';
import React, { useMemo } from 'react';
import { CardSummary, SummaryBox } from '@pezkuwi/react-components';
import { BN } from '@pezkuwi/util';
import { useCoretimeContext } from '../CoretimeContext.js';
import { useTranslation } from '../translate.js';
import { FirstCycleStart } from '../utils/index.js';
interface Props {
coreDscriptors?: CoreDescription[];
saleInfo: PalletBrokerSaleInfoRecord
config: PalletBrokerConfigRecord,
region: RegionInfo[],
status: BrokerStatus,
teyrchainCount: number
relayName: RelayName,
}
function Summary ({ config, teyrchainCount, relayName, status }: Props): React.ReactElement<Props> {
const { t } = useTranslation();
const { coretimeInfo, currentRegionEnd, currentRegionStart, saleEndDate, saleStartDate } = useCoretimeContext();
const saleNumber = useMemo(() => {
if (relayName && currentRegionEnd) {
return Math.floor(
(currentRegionEnd - FirstCycleStart.timeslice.coretime[relayName]) / config.regionLength
);
}
return undefined;
}, [currentRegionEnd, relayName, config]);
const timeslicesSinceCycleStart = useMemo(() => currentRegionEnd && new BN(config?.regionLength).sub((new BN(currentRegionEnd)).sub(new BN(status.lastTimeslice))), [status, config, currentRegionEnd]);
return (
<SummaryBox>
<section>
{status &&
<CardSummary label={t('sale number')}>
<div>
{saleNumber}
</div>
</CardSummary>
}
<CardSummary label={t('timeslice')}>
{status?.lastTimeslice}
</CardSummary>
<CardSummary label={t('teyrchains')}>
{teyrchainCount && teyrchainCount}
</CardSummary>
{config && status && currentRegionEnd && saleEndDate && saleStartDate && timeslicesSinceCycleStart && coretimeInfo?.constants &&
<>
<CardSummary
className='media--800'
label={t('timeslice progress')}
progress={{
hideGraph: true,
hideValue: false,
isBlurred: false,
total: new BN(config?.regionLength),
value: timeslicesSinceCycleStart,
withTime: false
}}
/>
<CardSummary
label={t('cycle')}
progress={{
total: new BN(config.regionLength).mul(new BN(coretimeInfo?.constants.relay.blocksPerTimeslice)),
value: timeslicesSinceCycleStart.mul(new BN(coretimeInfo?.constants.relay.blocksPerTimeslice)),
withTime: true
}}
/>
</>
}
</section>
<section className='media--1200'>
<CardSummary label={t('sale dates')}>
<div>
<div style={{ fontSize: '14px' }}>{saleStartDate}</div>
<div style={{ fontSize: '14px' }}>{saleEndDate}</div>
</div>
</CardSummary>
<CardSummary label={t('sale ts')}>
<div>
<div style={{ fontSize: '14px' }}>{currentRegionStart}</div>
<div style={{ fontSize: '14px' }}>{currentRegionEnd}</div>
</div>
</CardSummary>
</section>
</SummaryBox>
);
}
export default React.memo(Summary);
@@ -0,0 +1,7 @@
// Copyright 2017-2025 @pezkuwi/app-coretime authors & contributors
// SPDX-License-Identifier: Apache-2.0
export * from '../../types.js';
export * from './useBlockSort.js';
export * from './useSearchFilter.js';
export * from './useTypeFilter.js';
@@ -0,0 +1,56 @@
// Copyright 2017-2025 @pezkuwi/app-coretime authors & contributors
// SPDX-License-Identifier: Apache-2.0
import type { ChainInformation } from '@pezkuwi/react-hooks/types';
import type { ChainInfoFilterProps, SortDirection } from '../../types.js';
import { useCallback, useState } from 'react';
export function sortByBlocks (data: number[], chainInfo: Record<number, ChainInformation>, direction: SortDirection): number[] {
if (!data || !chainInfo || !direction) {
return data || [];
}
const filteredData = data.filter((block) => !!chainInfo[block]?.workTaskInfo[0]);
return [...filteredData].sort((a, b) => {
const aInfo = chainInfo[a]?.workTaskInfo[0];
const bInfo = chainInfo[b]?.workTaskInfo[0];
return direction === 'DESC'
? bInfo.lastBlock - aInfo.lastBlock
: aInfo.lastBlock - bInfo.lastBlock;
});
}
const getNextSortState = (current: SortDirection): SortDirection =>
({ '': 'DESC', ASC: '', DESC: 'ASC' } as const)[current];
export function useBlocksSort ({ chainInfo, data, onFilter }: ChainInfoFilterProps) {
const [direction, setDirection] = useState<SortDirection>('');
const apply = useCallback((data: number[], sort: SortDirection): number[] => {
return sort
? sortByBlocks(data, chainInfo, sort)
: data;
}, [chainInfo]);
const onApply = useCallback(() => {
const nextDirection = getNextSortState(direction);
setDirection(nextDirection);
onFilter(nextDirection ? sortByBlocks(data, chainInfo, nextDirection) : data);
}, [data, chainInfo, onFilter, direction]);
const reset = useCallback(() => {
setDirection('');
onFilter(data || []);
}, [data, onFilter]);
return {
apply,
direction,
onApply,
reset
};
}
@@ -0,0 +1,78 @@
// Copyright 2017-2025 @pezkuwi/app-coretime authors & contributors
// SPDX-License-Identifier: Apache-2.0
import React, { useCallback, useMemo, useState } from 'react';
import { useRelayEndpoints } from '@pezkuwi/react-hooks/useParaEndpoints';
interface UseSearchFilterProps {
data: number[];
onFilter: (data: number[]) => void;
}
export function useSearchFilter ({ data, onFilter }: UseSearchFilterProps) {
const [searchValue, setSearchValue] = useState('');
const endpoints = useRelayEndpoints();
const endPointsMap = useMemo(() =>
Object.fromEntries(
endpoints
.filter((e) => e?.text && e.paraId)
.map((e) => [
React.isValidElement(e.text) ? '' : String(e.text),
e.paraId
])
),
[endpoints]
);
const apply = useCallback((data: number[], activeSearch: number[]): number[] => {
return activeSearch.length > 0
? data.filter((id) => activeSearch.includes(id))
: data;
}, []);
const reset = useCallback(() => {
setSearchValue('');
onFilter(data);
}, [data, onFilter]);
const onInputChange = useCallback((v: string) => {
setSearchValue(v);
const searchLower = v.trim().toLowerCase();
if (!searchLower) {
onFilter(data);
return;
}
const matchingIds = new Set<number>();
for (const item of data) {
const itemStr = item.toString().toLowerCase();
if (itemStr.includes(searchLower)) {
matchingIds.add(item);
continue;
}
for (const [key, value] of Object.entries(endPointsMap)) {
if (key.toLowerCase().includes(searchLower) && value === item) {
matchingIds.add(item);
break;
}
}
}
const filteredData = Array.from(matchingIds);
onFilter(apply(data, filteredData));
}, [data, endPointsMap, onFilter, apply]);
return {
apply,
onApply: onInputChange,
reset,
searchValue
};
}
@@ -0,0 +1,76 @@
// Copyright 2017-2025 @pezkuwi/app-coretime authors & contributors
// SPDX-License-Identifier: Apache-2.0
import type { FlagColor } from '@pezkuwi/react-components/types';
import type { ChainInfoFilterProps } from '../../types.js';
import React, { useCallback, useState } from 'react';
import { Tag } from '@pezkuwi/react-components';
import { CoreTimeTypes } from '@pezkuwi/react-hooks/constants';
import { coretimeTypeColours } from '../../utils/index.js';
const coretimeTypes = Object.keys(CoreTimeTypes).filter((key) => isNaN(Number(key)));
const typeOptions = [
{
text: 'All',
value: 'All'
},
...coretimeTypes.map((type) => ({
text: (
<Tag
color={coretimeTypeColours[CoreTimeTypes[type as keyof typeof CoreTimeTypes]] as FlagColor}
label={type}
/>
),
value: CoreTimeTypes[type as keyof typeof CoreTimeTypes].toString()
}))
];
export function useTypeFilter ({ chainInfo, data, onFilter }: ChainInfoFilterProps) {
const [selectedType, setSelectedType] = useState<string>('');
const [activeType, setActiveType] = useState<number[]>([]);
const apply = useCallback((data: number[], activeType: number[]): number[] => {
return activeType.length > 0
? data.filter((id) => activeType.includes(id))
: data;
}, []);
const reset = useCallback(() => {
setSelectedType('');
setActiveType([]);
onFilter(data);
}, [data, onFilter]);
const onDropDownChange = useCallback((v: string) => {
setSelectedType(v);
if (!v || v === 'All') {
setActiveType([]);
onFilter(data);
return;
}
const filteredData = data.filter((paraId) => {
const taskInfo = chainInfo[paraId]?.workTaskInfo;
return taskInfo?.length > 0 && taskInfo[0].type.toString() === v;
});
setActiveType(filteredData);
onFilter(apply(data, filteredData));
}, [chainInfo, data, onFilter, apply]);
return {
activeType,
apply,
onApply: onDropDownChange,
reset,
selectedType,
typeOptions
};
}
@@ -0,0 +1,42 @@
// Copyright 2017-2025 @pezkuwi/app-coretime authors & contributors
// SPDX-License-Identifier: Apache-2.0
import type { RelayName } from '../types.js';
import React from 'react';
import { useCoretimeContext } from '../CoretimeContext.js';
import TeyrchainsTable from '../TeyrchainsTable.js';
import Summary from './Summary.js';
interface Props {
className?: string;
relayName: RelayName
}
function Overview ({ className, relayName }: Props): React.ReactElement<Props> {
const { coretimeInfo } = useCoretimeContext();
return (
<main className={className}>
{coretimeInfo && (
<Summary
config={coretimeInfo?.config}
teyrchainCount={coretimeInfo.taskIds?.length || 0}
region={coretimeInfo?.region}
relayName={relayName}
saleInfo={coretimeInfo?.salesInfo}
status={coretimeInfo?.status}
/>
)}
{!!coretimeInfo &&
<TeyrchainsTable
coretimeInfo={coretimeInfo}
relayName={relayName}
/>
}
</main>
);
}
export default React.memo(Overview);
+160
View File
@@ -0,0 +1,160 @@
// Copyright 2017-2025 @pezkuwi/app-coretime authors & contributors
// SPDX-License-Identifier: Apache-2.0
import type { FlagColor } from '@pezkuwi/react-components/types';
import type { ChainWorkTaskInformation, LegacyLease } from '@pezkuwi/react-hooks/types';
import type { RelayName } from './types.js';
import React from 'react';
import { MarkWarning, ParaLink, styled, Tag } from '@pezkuwi/react-components';
import { ParaLinkType } from '@pezkuwi/react-components/ParaLink';
import { ChainRenewalStatus, CoreTimeTypes } from '@pezkuwi/react-hooks/constants';
import { BN, formatBalance, formatNumber } from '@pezkuwi/util';
import { coretimeTypeColours, estimateTime } from './utils/index.js';
import { useCoretimeContext } from './CoretimeContext.js';
interface Props {
id: number
chainRecord: ChainWorkTaskInformation
regionEnd: number
regionBegin: number
lastCommittedTimeslice: number
lease: LegacyLease | undefined
highlight?: boolean
relayName: RelayName
}
interface StyledCellProps {
$p: boolean;
$width?: string;
}
const StyledCell = styled.td<StyledCellProps>`
&& {
background-color: ${({ $p }) => ($p ? '#F9FAFB' : undefined)};
width: ${({ $width }) => $width};
}
height: 55px;
`;
const StyledMarkWarning = styled(MarkWarning)`
width: fit-content;
margin: 0;
display: inline-block;
vertical-align: middle;
&.mark {
margin: 0 0 0 1rem;
display: inline;
}
`;
const EXPIRES_IN_DAYS = 7;
function Row ({ chainRecord, highlight = false, id, lastCommittedTimeslice, lease, regionBegin, regionEnd, relayName }: Props): React.ReactElement<Props> {
// Group status checks
const { renewalStatus } = chainRecord;
const isRenewed = renewalStatus === ChainRenewalStatus.Renewed;
const isEligible = renewalStatus === ChainRenewalStatus.Eligible;
const chainRegionEnd = isRenewed ? regionEnd : regionBegin;
const renewalLink = isEligible && (
<a
href={`https://app.regionx.tech/renew?network=${relayName}&paraId=${id}&core=${chainRecord?.workload?.core}`}
rel='noopener noreferrer'
target='_blank'
>
Renew on RegionX
</a>
);
const renewalValue = isRenewed
? chainRecord.renewalStatusMessage
: (isEligible ? renewalLink : '-');
const targetTimeslice = lease?.until || chainRegionEnd;
const showEstimates = !!targetTimeslice && Object.values(CoreTimeTypes)[chainRecord.type] !== CoreTimeTypes.Reservation;
const { coretimeInfo, get } = useCoretimeContext();
const estimatedTime = showEstimates && get && coretimeInfo && estimateTime(targetTimeslice, get.blocks.relay(lastCommittedTimeslice), coretimeInfo?.constants?.relay);
const isWithinWeek = !!estimatedTime && new Date(estimatedTime.timestamp).getTime() - Date.now() < EXPIRES_IN_DAYS * 24 * 60 * 60 * 1000;
const isReservation = chainRecord.type === CoreTimeTypes.Reservation;
return (
<React.Fragment key={`${id}`}>
<StyledCell
$p={highlight}
$width='150px'
>{id}</StyledCell>
<StyledCell
$p={highlight}
$width='150px'
className='media--800'
>{<ParaLink id={new BN(id)} />}</StyledCell>
<StyledCell $p={highlight}>{chainRecord?.workload?.core}</StyledCell>
<StyledCell $p={highlight}>
<Tag
color={coretimeTypeColours[chainRecord.type] as FlagColor}
label={Object.values(CoreTimeTypes)[chainRecord.type]}
/>
</StyledCell>
<StyledCell
$p={highlight}
className='media--800'
>{showEstimates && chainRecord?.lastBlock && relayName &&
<a
href={`https://${relayName.split(' ')[0]}.subscan.io/block/${chainRecord?.lastBlock}`}
rel='noreferrer'
target='_blank'
>{formatNumber(chainRecord?.lastBlock)}
</a>}
</StyledCell>
<StyledCell
$p={highlight}
className='media--1000'
style={{ whiteSpace: 'nowrap' }}
>
<span>
{estimatedTime && estimatedTime?.formattedDate}
{!!isWithinWeek && !isReservation && chainRecord?.renewalStatus !== ChainRenewalStatus.Renewed && (
<StyledMarkWarning
content='Expires Soon'
/>
)}
</span>
</StyledCell>
<StyledCell
$p={highlight}
className='media--1200'
>{renewalValue}</StyledCell>
<StyledCell
$p={highlight}
className='media--1200'
>{chainRecord?.renewal ? formatBalance(chainRecord.renewal?.price.toString()) : ''}</StyledCell>
<StyledCell
$p={highlight}
className='media--800'
>{<div style={{ alignItems: 'center', columnGap: '12px', display: 'flex', flexDirection: 'row', justifyContent: 'left' }}>
<ParaLink
id={new BN(id)}
showLogo={false}
type={ParaLinkType.SUBSCAN}
/>
<div style={{ marginBottom: '2px' }}>
<ParaLink
id={new BN(id)}
showLogo={false}
type={ParaLinkType.HOME}
/>
</div>
</div>}
</StyledCell>
{highlight && <StyledCell $p={highlight} />}
</React.Fragment>
);
}
export default React.memo(Row);
@@ -0,0 +1,59 @@
// Copyright 2017-2025 @pezkuwi/app-coretime authors & contributors
// SPDX-License-Identifier: Apache-2.0
import type { PhaseConfig } from '../types.js';
import React, { useRef } from 'react';
import { styled, Table } from '@pezkuwi/react-components';
import { formatNumber } from '@pezkuwi/util';
import { useTranslation } from '../translate.js';
interface Props {
phaseInfo: PhaseConfig['config'][keyof PhaseConfig['config']]
}
const StyledTable = styled(Table)`
border-collapse: collapse;
tr:last-child {
border-bottom: 1px solid #e6e6e6;
}
`;
function SaleTable ({ phaseInfo }: Props): React.ReactElement<Props> {
const { t } = useTranslation();
const headerRef = useRef<([React.ReactNode?, string?, number?] | false)[]>([
[],
[t('Dates'), 'start media--800'],
[t('Blocks (relay)'), 'start'],
[t('Timeslices'), 'start']
]);
return (
<div style={{ maxWidth: '500px' }}>
<StyledTable
emptySpinner={false}
header={headerRef.current}
isSplit={false}
>
<tr>
<td style={{ width: '100px' }}>Start</td>
<td>{phaseInfo.start.date}</td>
<td>{formatNumber(phaseInfo.start.blocks.relay)}</td>
<td>{formatNumber(phaseInfo.start.ts)}</td>
</tr>
<tr>
<td style={{ width: '100px' }}>End</td>
<td>{phaseInfo.end.date}</td>
<td>{formatNumber(phaseInfo.end.blocks.relay)}</td>
<td>{formatNumber(phaseInfo.end.ts)}</td>
</tr>
</StyledTable>
</div>
);
}
export default React.memo(SaleTable);
@@ -0,0 +1,165 @@
// Copyright 2017-2025 @pezkuwi/app-coretime authors & contributors
// SPDX-License-Identifier: Apache-2.0
import type { RelayName, SaleParameters } from '../types.js';
import React, { useMemo } from 'react';
import { styled } from '@pezkuwi/react-components';
import { PhaseName } from '../constants.js';
import { useTranslation } from '../translate.js';
import PhaseTable from './PhaseTable.js';
import { SubscanModuleCallUrl } from './SubscanModuleCallUrl.js';
const ResponsiveContainer = styled.div`
display: flex;
flex-direction: row;
justify-content: flex-start;
gap: 10rem;
margin-top: 2rem;
@media (max-width: 1000px) {
flex-direction: column;
}
`;
const Title = styled.h3`
font-weight: bold;
margin-bottom: 1rem;
`;
const LinkWithLogo = ({ alt, href, logo }: { href: string, logo: string, alt: string }) => {
return (
<a
href={href}
rel='noopener noreferrer'
target='_blank'
>
<img
alt={alt}
height={25}
src={logo}
/>
</a>
);
};
const providers = {
lastic: {
alt: 'Lastic',
href: (chainName: string) => `https://www.lastic.xyz/${chainName}/bulkcore1`,
logo: 'https://www.lastic.xyz/_next/image?url=%2Fassets%2FImages%2FLogos%2Flastic-logo.png&w=384&q=100'
},
regionx: {
alt: 'RegionX',
href: (chainName: string) => `https://app.regionx.tech/?network=${chainName}`,
logo: 'https://app.regionx.tech/_next/image?url=%2F_next%2Fstatic%2Fmedia%2Flogo.8f0fd171.png&w=3840&q=75'
},
subscan: {
alt: 'Subscan',
href: (chainName: string) => `https://coretime-${chainName}.subscan.io/coretime_dashboard`,
logo: 'https://www.subscan.io/_next/image?url=%2Fwebsite%2Flogo-light.png&w=256&q=75'
}
};
const phases = {
[PhaseName.Renewals]: {
description: 'In this phase, core owners can renew existing cores at a fixed price to ensure continued operation in the next region. No new core purchases are permitted.',
name: 'Interlude/Renewals phase'
},
[PhaseName.PriceDiscovery]: {
description: 'The period during which cores are available for both purchase and renewal. The price decreases linearly over time.',
name: 'Price Discovery phase'
},
[PhaseName.FixedPrice]: {
description: 'The period during which cores are available for both purchase and renewal. The price remains fixed towards the end of the sales period.',
name: 'Fixed price phase'
}
};
const dotLakeUrl = 'https://data.parity.io/coretime';
const SaleDetailsView = ({ chosenSaleNumber, relayName, saleParams }: { saleParams: SaleParameters, chosenSaleNumber: number, relayName: RelayName }) => {
const { t } = useTranslation();
const subscanPriceGraphUrl = useMemo(() =>
`https://coretime-${relayName}.subscan.io/coretime_dashboard`
, [relayName]);
if (chosenSaleNumber === -1 || !saleParams) {
return null;
}
return (
<ResponsiveContainer>
<div>
<Title>Sale phases</Title>
<div style={{ display: 'grid', gap: '1rem', gridTemplateRows: '1fr 1fr 1fr', minWidth: '200px' }}>
{!saleParams?.phaseConfig &&
<div>
<p>{t(`This sale is of unusual length of ${saleParams.currentRegion.end.ts - saleParams.currentRegion.start.ts} timeslices, hence the regular phases are not applicable.`)}</p>
<p>{t(`Sale start timeslice: ${saleParams.currentRegion.start.ts}`)}</p>
<p>{t(`Sale end timeslice: ${saleParams.currentRegion.end.ts}`)}</p>
</div>
}
{saleParams?.phaseConfig && Object.entries(phases).map(([phase, { description, name }]) => (
<div key={phase}>
<h4>{t(name)}</h4>
<p style={{ maxWidth: '600px', opacity: '0.8' }}>{t(description)}</p>
{saleParams?.phaseConfig &&
<PhaseTable
phaseInfo={saleParams?.phaseConfig.config[phase as keyof typeof saleParams.phaseConfig.config]}
/>}
</div>
))}
</div>
</div>
<div>
<Title>{t('Region for sale ')}</Title>
<p style={{ maxWidth: '600px', opacity: '0.8' }}>{t('Region is an asset of Coretime. It signifies the upcoming sales period within which a core can be secured by purchasing coretime. Acquiring coretime grants access to a core for the duration of that specific region.')}</p>
{saleParams?.regionForSale && <PhaseTable phaseInfo={saleParams?.regionForSale} />}
<Title>{t('Subscan Links')}</Title>
<div style={{ display: 'flex', flexDirection: 'column', gap: '1rem' }}>
<a
href={subscanPriceGraphUrl}
rel='noopener noreferrer'
target='_blank'
>Sale Purchase Graph</a>
<SubscanModuleCallUrl
chainName={relayName}
chosenSaleNumber={chosenSaleNumber}
currentRegion={saleParams.currentRegion}
urlTitle='Sale Purchase Transactions'
/>
<SubscanModuleCallUrl
call={'renew'}
chainName={relayName}
chosenSaleNumber={chosenSaleNumber}
currentRegion={saleParams.currentRegion}
urlTitle='Sale Renewal Transactions'
/>
</div>
<Title>{t('DotLake Coretime Dashboard')}</Title>
<a
href={dotLakeUrl}
rel='noopener noreferrer'
target='_blank'
>Dot Lake</a>
<Title>{t('Coretime providers')}</Title>
<div style={{ display: 'flex', flexDirection: 'column', gap: '1rem' }}>
{Object.entries(providers).map(([provider, { alt, href, logo }]) => (
<LinkWithLogo
alt={alt}
href={href(relayName)}
key={provider}
logo={logo}
/>
))}
</div>
</div>
</ResponsiveContainer>
);
};
export default SaleDetailsView;
@@ -0,0 +1,30 @@
// Copyright 2017-2025 @pezkuwi/app-coretime authors & contributors
// SPDX-License-Identifier: Apache-2.0
import type { SaleParameters } from '../types.js';
import React, { useMemo } from 'react';
import { constructSubscanQuery } from '../utils/index.js';
function formatDate (input: string) {
const date = new Date(input + ' UTC');
return `${date.getFullYear()}-${date.getMonth() + 1}-${date.getDate()}`;
}
export const SubscanModuleCallUrl = ({ call = 'purchase', chainName, chosenSaleNumber, currentRegion, module = 'broker', urlTitle }: { chosenSaleNumber: number, currentRegion: SaleParameters['currentRegion'], chainName: string, urlTitle: string, module?: string, call?: string }) => {
const url = useMemo(() => constructSubscanQuery(formatDate(currentRegion.start.date), formatDate(currentRegion.end.date), chainName, module, call), [currentRegion, chainName, module, call]);
if (chosenSaleNumber === -1 || !currentRegion) {
return null;
}
return (
<a
href={url}
rel='noopener noreferrer'
target='_blank'
>{urlTitle}</a>
);
};
@@ -0,0 +1,88 @@
// Copyright 2017-2025 @pezkuwi/app-coretime authors & contributors
// SPDX-License-Identifier: Apache-2.0
import type { ApiPromise } from '@pezkuwi/api';
import type { BrokerStatus, CoreDescription, PalletBrokerConfigRecord, PalletBrokerSaleInfoRecord, RegionInfo } from '@pezkuwi/react-hooks/types';
import React from 'react';
import { CardSummary, SummaryBox } from '@pezkuwi/react-components';
import { BN, formatNumber } from '@pezkuwi/util';
import { useCoretimeContext } from '../CoretimeContext.js';
import { useTranslation } from '../translate.js';
import { getCurrentRegionStartEndTs } from '../utils/index.js';
interface Props {
api: ApiPromise | null,
coreDscriptors?: CoreDescription[];
saleInfo: PalletBrokerSaleInfoRecord
config: PalletBrokerConfigRecord,
region: RegionInfo[],
status: BrokerStatus,
saleNumber: number,
}
function Summary ({ config, saleInfo, saleNumber, status }: Props): React.ReactElement<Props> {
const { t } = useTranslation();
const { currentRegionEndTs, currentRegionStartTs } = getCurrentRegionStartEndTs(saleInfo, config.regionLength);
const { get, saleEndDate, saleStartDate } = useCoretimeContext();
return (
<SummaryBox>
<section>
{status &&
<CardSummary label={t('sale number')}>
<div>
{saleNumber > -1 ? saleNumber : '-'}
</div>
</CardSummary>
}
<CardSummary label={t('sold/offered')}>
{`${saleInfo?.coresSold} / ${saleInfo?.coresOffered}`}
</CardSummary>
<CardSummary label={t('sale end')}>
<div>{saleEndDate}</div>
</CardSummary>
<CardSummary label={t('last block')}>
<div>{get && formatNumber(get.blocks.relay(currentRegionEndTs))}</div>
</CardSummary>
<CardSummary label={t('last timeslice')}>
<div>{formatNumber(currentRegionEndTs)}</div>
</CardSummary>
{config && status &&
<CardSummary
className='media--800'
label={t('sale progress')}
progress={{
isBlurred: false,
total: new BN(config?.regionLength),
value: new BN(config?.regionLength - (currentRegionEndTs - status.lastTimeslice)),
withTime: false
}}
/>
}
</section>
<section className='media--1200'>
{status &&
(<CardSummary label={t('current region dates')}>
<div>
<div style={{ fontSize: '14px' }}>{saleStartDate}</div>
<div style={{ fontSize: '14px' }}>{saleEndDate}</div>
</div>
</CardSummary>)
}
{status &&
<CardSummary label={t('region ts')}>
<div>
<div style={{ fontSize: '14px' }}>{currentRegionStartTs}</div>
<div style={{ fontSize: '14px' }}>{currentRegionEndTs}</div>
</div>
</CardSummary>
}
</section>
</SummaryBox>
);
}
export default React.memo(Summary);
@@ -0,0 +1,61 @@
// Copyright 2017-2025 @pezkuwi/app-coretime authors & contributors
// SPDX-License-Identifier: Apache-2.0
import type { CoretimeInformation } from '@pezkuwi/react-hooks/types';
import type { BlockNumber } from '@pezkuwi/types/interfaces';
import React, { useMemo } from 'react';
import { styled } from '@pezkuwi/react-components';
import { useApi, useCall } from '@pezkuwi/react-hooks';
import { PhaseName } from '../../constants.js';
import { useTranslation } from '../../translate.js';
import { formatBNToBalance, getCorePriceAt } from '../../utils/sale.js';
import { WhiteBox } from '../../WhiteBox.js';
export const Cores = ({ color, phaseName, salesInfo }: { phaseName: string, salesInfo: CoretimeInformation['salesInfo'], color: string }) => {
const { t } = useTranslation();
const { apiCoretime } = useApi();
const bestNumberFinalized = useCall<BlockNumber>(apiCoretime?.derive.chain.bestNumberFinalized);
const coretimePrice = useMemo(() => bestNumberFinalized && salesInfo && getCorePriceAt(bestNumberFinalized.toNumber(), salesInfo), [salesInfo, bestNumberFinalized]);
const formattedCoretimePrice = useMemo(() => {
return !coretimePrice ? null : formatBNToBalance(coretimePrice);
}, [coretimePrice]);
const CoresWrapper = styled(WhiteBox)`
justify-self: flex-end;
@media (max-width: 1150px) {
width: 100%;
}
`;
return (
<CoresWrapper>
<p style={{ fontSize: '16px', fontWeight: 'bold' }}>Cores</p>
{phaseName === PhaseName.Renewals
? (
<h4>{t('Cores cannot be purchased now')}</h4>
)
: (
<div style={{ display: 'flex', flexDirection: 'column' }}>
{
<div style={{ display: 'flex', flexDirection: 'column', gap: '1rem' }}>
<div>
<p style={{ fontSize: '14px', marginBottom: '0.15rem', opacity: '0.8' }}>{t('current price')}</p>
<p style={{ color: `${color}`, fontSize: '20px' }}>{formattedCoretimePrice}</p>
</div>
<div>
<p style={{ fontSize: '14px', marginBottom: '0.15rem', opacity: '0.8' }}>{t('available cores')}</p>
<p style={{ fontSize: '20px' }}> {salesInfo.coresOffered - salesInfo.coresSold}</p>
</div>
</div>
}
</div>
)}
</CoresWrapper>
);
};
@@ -0,0 +1,42 @@
// Copyright 2017-2025 @pezkuwi/app-coretime authors & contributors
// SPDX-License-Identifier: Apache-2.0
import type { SaleParameters } from 'page-coretime/src/types.js';
import React from 'react';
import { styled } from '@pezkuwi/react-components';
import { formatNumber } from '@pezkuwi/util';
import { useTranslation } from '../../translate.js';
import { WhiteBox } from '../../WhiteBox.js';
const RegionWrapper = styled(WhiteBox)`
justify-self: center;
@media (min-width: 1150px) {
width: 100%;
}
`;
export const Region = ({ regionForSale }: { regionForSale: SaleParameters['regionForSale'] }) => {
const { t } = useTranslation();
return (
<RegionWrapper>
<p style={{ fontSize: '16px', fontWeight: 'bold' }}>{t('Region for sale')}</p>
{regionForSale &&
<div style={{ display: 'flex', flexDirection: 'column', gap: '1rem' }}>
<div>
<p style={{ fontSize: '14px', marginBottom: '0.15rem', opacity: '0.8' }}>period</p>
<p style={{ fontSize: '20px' }}>{regionForSale.start.date} - {regionForSale.end.date}</p>
</div>
<div style={{ marginTop: '0.5rem' }}>
<p style={{ fontSize: '14px', marginBottom: '0.15rem', opacity: '0.8' }}>{t('relay chain blocks')}</p>
<p style={{ fontSize: '20px' }}>{formatNumber(regionForSale.start.blocks.relay)} - {formatNumber(regionForSale.end.blocks.relay)}</p>
</div>
</div>}
</RegionWrapper>
);
};
@@ -0,0 +1,93 @@
// Copyright 2017-2025 @pezkuwi/app-coretime authors & contributors
// SPDX-License-Identifier: Apache-2.0
import type { SaleParameters } from 'page-coretime/src/types.js';
import type { ProgressBarSection } from '@pezkuwi/react-components/types';
import type { CoretimeInformation } from '@pezkuwi/react-hooks/types';
import React, { useMemo } from 'react';
import { CardSummary, ProgressBar, styled, SummaryBox } from '@pezkuwi/react-components';
import { formatNumber } from '@pezkuwi/util';
import { useTranslation } from '../../translate.js';
import { formatBNToBalance, getCorePriceAt, getSaleProgress } from '../../utils/sale.js';
import { WhiteBox } from '../../WhiteBox.js';
interface TimelineProps {
phaseName: string;
saleParams: SaleParameters;
coretimeInfo: {
salesInfo: CoretimeInformation['salesInfo'];
status: CoretimeInformation['status'];
};
color: string;
}
export const Timeline = ({ color, coretimeInfo: { salesInfo, status }, phaseName, saleParams }: TimelineProps) => {
const { t } = useTranslation();
const progressValues = useMemo(() => saleParams && salesInfo.regionBegin &&
getSaleProgress(
status.lastTimeslice,
saleParams.currentRegion.start.ts,
saleParams.interlude.ts,
saleParams.leadin.ts,
salesInfo.regionBegin),
[saleParams, status.lastTimeslice, salesInfo.regionBegin]);
const coretimePriceStart = useMemo(() => salesInfo && getCorePriceAt(salesInfo.saleStart, salesInfo), [salesInfo]);
const startPrice = useMemo(() => coretimePriceStart && formatBNToBalance(coretimePriceStart), [coretimePriceStart]);
const endPrice = useMemo(() => salesInfo?.endPrice && formatBNToBalance(salesInfo.endPrice), [salesInfo?.endPrice]);
return (
<TimelineWrapper>
<p style={{ fontSize: '16px', fontWeight: 'bold' }}>{t('Sale timeline')}</p>
<StyledSummaryBox>
<GridLayout>
{phaseName && <>
<CardSummary label='current phase'>{phaseName}</CardSummary>
<CardSummary label='current phase end'>{saleParams?.phaseConfig?.config[phaseName as keyof typeof saleParams.phaseConfig.config].end.date}</CardSummary>
<CardSummary label='last phase block'>{formatNumber(saleParams?.phaseConfig?.config[phaseName as keyof typeof saleParams.phaseConfig.config].end.blocks.relay)}</CardSummary>
</>}
<CardSummary label='start price'>{startPrice}</CardSummary>
<CardSummary label='fixed price'>{endPrice}</CardSummary>
</GridLayout>
</StyledSummaryBox>
<ProgressBar
color={color}
sections={progressValues as ProgressBarSection[] ?? []}
/>
</TimelineWrapper>
);
};
const TimelineWrapper = styled(WhiteBox)`
justify-self: flex-start;
@media (min-width: 769px) and (max-width: 1024px) {
width: 100%;
}
`;
const StyledSummaryBox = styled(SummaryBox)`
margin-top: 0;
`;
const GridLayout = styled.div`
display: grid;
grid-template-columns: repeat(3, 1fr);
column-gap: 0.5rem;
row-gap: 1rem;
width: 100%;
/* Override CardSummary styling to align text left and remove padding */
article {
justify-content: flex-start;
padding: 0;
> .ui--Labelled {
text-align: left;
}
}
`;
+145
View File
@@ -0,0 +1,145 @@
// Copyright 2017-2025 @pezkuwi/app-coretime authors & contributors
// SPDX-License-Identifier: Apache-2.0
import type { RelayName, SaleParameters } from '../types.js';
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { Dropdown, styled } from '@pezkuwi/react-components';
import { defaultHighlight } from '@pezkuwi/react-components/styles';
import { useApi } from '@pezkuwi/react-hooks';
import { useCoretimeContext } from '../CoretimeContext.js';
import { useTranslation } from '../translate.js';
import { getSaleParameters } from '../utils/sale.js';
import { Cores } from './boxes/Cores.js';
import { Region } from './boxes/Region.js';
import { Timeline } from './boxes/Timeline.js';
import SaleDetailsView from './SaleDetailsView.js';
import Summary from './Summary.js';
interface Props {
relayName: RelayName
}
const ResponsiveGrid = styled.div`
display: grid;
align-items: stretch;
gap: 2rem;
grid-template-rows: auto auto;
margin-top: 4rem;
@media (max-width: 768px) {
grid-template-columns: 1fr;
}
@media (min-width: 769px) and (max-width: 1150px) {
grid-template-columns: 1fr 1fr;
> *:nth-child(3) {
grid-column: 1 / -1;
}
}
@media (min-width: 1150px) {
grid-template-columns: 1fr 1fr 3fr;
}
`;
function Sale ({ relayName }: Props): React.ReactElement<Props> {
const { coretimeInfo } = useCoretimeContext();
const { api, apiEndpoint, isApiReady } = useApi();
const { t } = useTranslation();
const lastCommittedTimeslice = coretimeInfo?.status?.lastTimeslice;
const [chosenSaleNumber, setChosenSaleNumber] = useState<number>(-1);
const [saleParams, setSaleParams] = useState<SaleParameters | null>(null);
const [selectedSaleParams, setSelectedSaleParams] = useState<SaleParameters | null>(null);
const apiColor = apiEndpoint?.ui.color || defaultHighlight;
const saleNumberOptions = useMemo(() => [
{
text: t('Pick a sale number'),
value: -1
},
...Array.from({ length: saleParams?.saleNumber ?? 0 }, (_, i) => ({
text: `sale #${i + 1}`,
value: i
})).reverse()
], [saleParams?.saleNumber, t]);
useEffect(() => {
if (coretimeInfo && !saleParams) {
setSaleParams(getSaleParameters(
coretimeInfo,
relayName,
lastCommittedTimeslice ?? 0
));
}
}, [coretimeInfo, saleParams, lastCommittedTimeslice, relayName]);
const phaseName = useMemo(() => saleParams?.phaseConfig?.currentPhaseName, [saleParams]);
const onDropDownChange = useCallback((value: number) => {
setChosenSaleNumber(value);
if (value !== -1) {
if (!coretimeInfo) {
return;
}
setSelectedSaleParams(getSaleParameters(coretimeInfo, relayName, lastCommittedTimeslice ?? 0, value));
}
}, [coretimeInfo, relayName, lastCommittedTimeslice]);
return (
<div>
{coretimeInfo && !!saleParams?.saleNumber &&
<Summary
api={isApiReady ? api : null}
config={coretimeInfo?.config}
region={coretimeInfo?.region}
saleInfo={coretimeInfo?.salesInfo}
saleNumber={saleParams?.saleNumber}
status={coretimeInfo?.status}
/>}
<ResponsiveGrid>
{phaseName && coretimeInfo &&
<Cores
color={apiColor}
phaseName={phaseName}
salesInfo={coretimeInfo?.salesInfo}
/>}
{saleParams?.regionForSale && <Region regionForSale={saleParams.regionForSale} />}
{phaseName && coretimeInfo && saleParams &&
<Timeline
color={apiColor}
coretimeInfo={coretimeInfo}
phaseName={phaseName}
saleParams={saleParams}
/>}
<div style={{ borderRadius: '4px', gridColumn: '1 / -1', justifySelf: 'center', padding: '24px', width: '100%' }}>
<p style={{ fontSize: '16px', fontWeight: 'bold', marginBottom: '1rem' }}>{t('Sale information')}</p>
<div style={{ display: 'flex', flexDirection: 'column', gap: '1rem' }}>
<div style={{ maxWidth: '300px' }}>
<Dropdown
className='isSmall'
label={t('Pick a sale number')}
onChange={onDropDownChange}
options={saleNumberOptions}
value={chosenSaleNumber}
/>
</div>
{saleParams && selectedSaleParams &&
<SaleDetailsView
chosenSaleNumber={chosenSaleNumber}
relayName={relayName}
saleParams={selectedSaleParams}
/>}
</div>
</div>
</ResponsiveGrid>
</div>
);
}
export default Sale;
@@ -0,0 +1,73 @@
// Copyright 2017-2025 @pezkuwi/app-coretime authors & contributors
// SPDX-License-Identifier: Apache-2.0
import type { ChainInformation, ChainWorkTaskInformation } from '@pezkuwi/react-hooks/types';
import type { RelayName } from './types.js';
import React from 'react';
import { ExpandButton } from '@pezkuwi/react-components';
import { useToggle } from '@pezkuwi/react-hooks';
import Row from './Row.js';
interface Props {
chain: ChainInformation
regionEnd: number
regionBegin: number
lastCommittedTimeslice: number
relayName: RelayName
}
function TeyrchainTableRow ({ chain, lastCommittedTimeslice, regionBegin, regionEnd, relayName }: Props): React.ReactElement<Props> {
const [isExpanded, toggleIsExpanded] = useToggle(false);
const info = chain.workTaskInfo;
const firstRecord = chain.workTaskInfo[0];
const multiple = info.length > 1;
const expandedContent = info.slice(1);
if (!firstRecord) {
return <></>;
}
const renderRow = (record: ChainWorkTaskInformation, idx: number, highlight = false) =>
<>
<Row
chainRecord={record}
highlight={highlight}
id={chain.id}
lastCommittedTimeslice={lastCommittedTimeslice}
lease={chain.lease}
regionBegin={regionBegin}
regionEnd={regionEnd}
relayName={relayName}
/>
{idx === 0 && <td style={{ paddingRight: '2rem', textAlign: 'right', verticalAlign: 'top' }}>
{!!multiple &&
<ExpandButton
expanded={isExpanded}
onClick={toggleIsExpanded}
/>
}
</td>}
</>;
return (
<>
<tr
className={`isExpanded isFirst ${isExpanded ? '' : 'isLast'}`}
key={chain.id}
>
{renderRow(firstRecord, 0)}
</tr>
{isExpanded && expandedContent?.map((infoRow, idx) =>
<tr key={`${chain.id}${idx}`}>
{renderRow(infoRow, idx + 1, true)}
</tr>
)}
</>
);
}
export default React.memo(TeyrchainTableRow);
@@ -0,0 +1,83 @@
// Copyright 2017-2025 @pezkuwi/app-coretime authors & contributors
// SPDX-License-Identifier: Apache-2.0
import type { RelayName } from './types.js';
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { Table } from '@pezkuwi/react-components';
import { type CoretimeInformation } from '@pezkuwi/react-hooks/types';
import Filters from './Overview/Filters.js';
import TeyrchainTableRow from './TeyrchainTableRow.js';
import { useTranslation } from './translate.js';
interface Props {
coretimeInfo: CoretimeInformation
relayName: RelayName
}
function TeyrchainsTable ({ coretimeInfo, relayName }: Props): React.ReactElement<Props> {
const { t } = useTranslation();
const headerRef = useRef<([React.ReactNode?, string?, number?] | false)[]>([
[t('teyrchains'), 'start'],
[t('name'), 'start media--800'],
[t('core number'), 'start'],
[t('type'), 'start'],
[t('last block'), 'start media--800'],
[t('end date (approx)'), 'start media--1000'],
[t('renewal'), 'start media--1200'],
[t('renewal price'), 'start media--1200'],
[t('links'), 'start media--800'],
[t('other cores'), 'end']
]);
const [taskIds, setTaskIds] = useState<number[]>([]);
const onFilter = useCallback((filteredData: number[]) => {
setTaskIds(filteredData);
}, []);
useEffect(() => {
if (coretimeInfo?.taskIds) {
setTaskIds(coretimeInfo?.taskIds);
}
}, [coretimeInfo?.taskIds]);
return (
<>
<Filters
chainInfo={coretimeInfo?.chainInfo}
data={coretimeInfo?.taskIds}
onFilter={onFilter}
/>
<Table
emptySpinner={false}
header={headerRef.current}
isSplit={false}
>
{taskIds?.map((taskId: number) => {
const chain = coretimeInfo.chainInfo[taskId];
if (!chain) {
return null;
}
return (
<TeyrchainTableRow
chain={chain}
key={chain.id}
lastCommittedTimeslice={coretimeInfo.status.lastCommittedTimeslice}
regionBegin={coretimeInfo.salesInfo.regionBegin}
regionEnd={coretimeInfo.salesInfo.regionEnd}
relayName={relayName}
/>
);
})}
</Table>
</>
);
}
export default React.memo(TeyrchainsTable);
+15
View File
@@ -0,0 +1,15 @@
// Copyright 2017-2025 @pezkuwi/app-coretime authors & contributors
// SPDX-License-Identifier: Apache-2.0
import React, { type ReactNode } from 'react';
import { useTheme } from '@pezkuwi/react-hooks';
export const WhiteBox = ({ children, style }: { children: ReactNode, style?: React.CSSProperties }) => {
const { theme } = useTheme();
const backgroundColor = theme === 'dark' ? '#333' : 'white';
return <div style={{ backgroundColor, borderRadius: '4px', display: 'flex', flexDirection: 'column', justifyItems: 'center', minWidth: '250px', padding: '24px', ...style }}>
{children}
</div>;
};
+8
View File
@@ -0,0 +1,8 @@
// Copyright 2017-2025 @pezkuwi/app-coretime authors & contributors
// SPDX-License-Identifier: Apache-2.0
export const PhaseName = {
FixedPrice: 'Fixed Price',
PriceDiscovery: 'Price Discovery',
Renewals: 'Renewals'
} as const;
+34
View File
@@ -0,0 +1,34 @@
// Copyright 2017-2025 @pezkuwi/app-coretime authors & contributors
// SPDX-License-Identifier: Apache-2.0
import React from 'react';
import { useApi } from '@pezkuwi/react-hooks';
import { CoretimeProvider } from './CoretimeContext.js';
import CoretimePage from './CoretimePage.js';
interface Props {
basePath: string;
className?: string;
}
function CoretimeApp ({ basePath, className }: Props): React.ReactElement<Props> {
const { api, isApiReady } = useApi();
return (
<CoretimeProvider
api={api}
isApiReady={isApiReady}
>
<CoretimePage
api={api}
basePath={basePath}
className={className}
isApiReady={isApiReady}
/>
</CoretimeProvider>
);
}
export default React.memo(CoretimeApp);
+8
View File
@@ -0,0 +1,8 @@
// Copyright 2017-2025 @pezkuwi/app-coretime authors & contributors
// SPDX-License-Identifier: Apache-2.0
import { useTranslation as useTranslationBase } from 'react-i18next';
export function useTranslation (): { t: (key: string, options?: { replace: Record<string, unknown> }) => string } {
return useTranslationBase('app-coretime');
}
+119
View File
@@ -0,0 +1,119 @@
// Copyright 2017-2025 @pezkuwi/app-coretime authors & contributors
// SPDX-License-Identifier: Apache-2.0
import type { ChainInformation } from '@pezkuwi/react-hooks/types';
import type { PhaseName } from './constants.js';
export interface PhaseInfo {
name?: string;
start: {
date: string | null;
blocks: {
relay: number;
coretime: number;
};
ts: number;
}
end: {
date: string | null;
blocks: {
relay: number;
coretime: number;
};
ts: number;
}
}
type PhaseNameType = typeof PhaseName[keyof typeof PhaseName];
export interface PhaseConfig {
currentPhaseName: PhaseNameType;
config: Record<PhaseNameType, PhaseInfo >;
}
export interface PhaseProgress {
value: number;
total: number;
label: string;
}
export interface SaleParameters {
currentRegion: {
start: { date: string, ts: number; blocks: { coretime: number, relay: number } };
end: { date: string, ts: number; blocks: { coretime: number, relay: number } };
};
regionForSale: {
start: { date: string, ts: number; blocks: { coretime: number, relay: number } };
end: { date: string | null, ts: number; blocks: { coretime: number, relay: number } };
};
saleNumber: number;
interlude: { ts: number; blocks: number };
leadin: { ts: number; blocks: number };
phaseConfig: PhaseConfig | null;
}
export interface SaleDetails {
saleNumber: number;
relay: {
start: {
block: number;
ts: number;
},
end: {
block: number;
ts: number;
},
},
coretime: {
start: {
block: number;
},
end: {
block: number;
}
}
date: {
start: string | null;
end: string | null;
}
}
export interface RegionInfo {
regionBegin: number;
regionEnd: number;
}
export type RelayName = 'dicle' | 'pezkuwi' | 'paseo testnet' | 'zagros'
export interface GetResponse {
blocks: {
coretime: (ts: number) => number;
relay: (ts: number) => number;
};
timeslices: {
coretime: (blocks: number) => number;
relay: (blocks: number) => number;
};
}
export interface BaseFilterProps {
data: number[];
onFilter: (data: number[]) => void;
}
export interface ChainInfoFilterProps extends BaseFilterProps {
chainInfo: Record<number, ChainInformation>;
}
export type SortDirection = 'DESC' | 'ASC' | '';
export enum FilterType {
BLOCKS = 'blocks',
SEARCH = 'search',
TYPE = 'type'
}
export interface ActiveFilters {
search: number[];
type: number[];
}
+185
View File
@@ -0,0 +1,185 @@
// Copyright 2017-2025 @pezkuwi/app-coretime authors & contributors
// SPDX-License-Identifier: Apache-2.0
import type { ChainBlockConstants, ChainConstants, CoretimeInformation } from '@pezkuwi/react-hooks/types';
import type { GetResponse, RegionInfo, RelayName } from '../types.js';
import { CoreTimeTypes } from '@pezkuwi/react-hooks/constants';
import { BN } from '@pezkuwi/util';
type FirstCycleStartType = Record<
'block' | 'timeslice',
Record<
'coretime',
Record<RelayName, number>
>
>;
// Blocks on the Coretime Chain
export const FirstCycleStart: FirstCycleStartType = {
block: {
coretime: {
dicle: 53793,
'paseo testnet': 22316,
pezkuwi: 100988,
zagros: 7363
}
},
timeslice: {
coretime: {
dicle: 284920,
'paseo testnet': 38469,
pezkuwi: 282525,
zagros: 245402
}
}
};
export const coretimeTypeColours: Record<string, string> = {
[CoreTimeTypes.Reservation]: 'orange',
[CoreTimeTypes.Lease]: 'blue',
[CoreTimeTypes['Bulk Coretime']]: 'pink'
};
export function formatDate (date: Date, time = false) {
const day = date.getDate();
const month = date.toLocaleString('default', { month: 'short' });
const year = date.getFullYear();
if (time) {
const hours = date.getHours().toString().padStart(2, '0');
const minutes = date.getMinutes().toString().padStart(2, '0');
return `${day} ${month} ${year}, ${hours}:${minutes}`;
}
return `${day} ${month} ${year}`;
}
/**
* Gives you a date for the target timeslice
*
* Relay chain info:
* blockTime = 6000 ms
* BlocksPerTimeslice = 80
* Default Regoin = 5040 timeslices
*
* Calculation:
* TargetBlock = TargetTimeslice * BlocksPerTimeslice
* Block Time Difference = |TargetBlock - latest Block| * blockTime
*
* Estimate timestamp =
* if targetBlock is before the latestBlock
* now minus block time difference
* else
* now plus block time difference
*/
export const estimateTime = (
targetTimeslice: string | number,
latestBlock: number,
{ blocksPerTimeslice: blocksPerTs, blocktimeMs }: ChainBlockConstants
): { timestamp: number, formattedDate: string } | null => {
if (!latestBlock || !targetTimeslice) {
console.error('Invalid input: one or more inputs are missing');
return null;
}
try {
const now = new BN(Date.now());
const blockTime = new BN(blocktimeMs); // Average block time in milliseconds (6 seconds)
const blocksPerTimeslice = new BN(blocksPerTs);
const targetBlock = new BN(Number(targetTimeslice)).mul(blocksPerTimeslice);
const latestBlockBN = new BN(latestBlock);
const blockDifference = targetBlock.sub(latestBlockBN);
const timeDifference = blockDifference.mul(blockTime);
const estTimestamp = now.add(timeDifference);
return {
formattedDate: formatDate(new Date(estTimestamp.toNumber())),
timestamp: estTimestamp.toNumber()
};
} catch (error) {
console.error('Error in calculation:', error);
return null;
}
};
/**
* Factory function to create helper functions for converting timeslices to blocks and vice versa.
*
* @returns An object containing blocks and timeslices conversion functions.
*/
export const createGet = (constants: ChainConstants): GetResponse => ({
blocks: {
/**
* Convert timeslices to Coretime blocks.
*
* @param ts - Number of timeslices.
* @returns Number of Coretime blocks.
*/
coretime: (ts: number) => {
return ts * constants.coretime.blocksPerTimeslice;
},
/**
* Convert timeslices to Relay blocks.
*
* @param ts - Number of timeslices.
* @returns Number of Relay blocks.
*/
relay: (ts: number) => {
return ts * constants.relay.blocksPerTimeslice;
}
},
timeslices: {
/**
* Convert Coretime blocks to timeslices.
*
* @param blocks - Number of Coretime blocks.
* @returns Number of timeslices.
*/
coretime: (blocks: number) => {
return blocks / constants.coretime.blocksPerTimeslice;
},
/**
* Convert Relay blocks to timeslices.
*
* @param blocks - Number of Relay blocks.
* @returns Number of timeslices.
*/
relay: (blocks: number) => {
return blocks / constants.relay.blocksPerTimeslice;
}
}
});
/**
* Get the start and end of the current region
* broker.saleInfo call returns the start/end of the next region always
*
* The end of the current region is the start of the next region, which is returned by broker.saleInfo call
*
* @param saleInfo - The sale information
* @param config - The broker configuration
*
* @returns The start and end of the current region
*/
export const getCurrentRegionStartEndTs = (saleInfo: RegionInfo, regionLength: number) => {
return {
currentRegionEndTs: saleInfo.regionBegin,
currentRegionStartTs: saleInfo.regionBegin - regionLength
};
};
export const getAvailableNumberOfCores = (coretimeInfo: CoretimeInformation) =>
Number(coretimeInfo?.salesInfo?.coresOffered) - Number(coretimeInfo?.salesInfo.coresSold);
export const constructSubscanQuery = (dateStart: string, dateEnd: string, chainName: string, module = 'broker', call = 'purchase') => {
const page = 1;
const pageSize = 25;
const signed = 'all';
const baseURL = `https://coretime-${chainName}.subscan.io/extrinsic`;
return `${baseURL}?page=${page}&time_dimension=date&page_size=${pageSize}&module=${module}&signed=${signed}&call=${call}&date_start=${dateStart}&date_end=${dateEnd}`;
};
+292
View File
@@ -0,0 +1,292 @@
// Copyright 2017-2025 @pezkuwi/app-coretime authors & contributors
// SPDX-License-Identifier: Apache-2.0
import type { ChainConstants, PalletBrokerConfigRecord, PalletBrokerSaleInfoRecord } from '@pezkuwi/react-hooks/types';
import type { GetResponse, PhaseConfig, RegionInfo, RelayName, SaleParameters } from '../types.js';
import { type ProgressBarSection } from '@pezkuwi/react-components/types';
import { BN, formatBalance } from '@pezkuwi/util';
import { PhaseName } from '../constants.js';
import { createGet, estimateTime, FirstCycleStart, getCurrentRegionStartEndTs } from './index.js';
// We are scaling everything to avoid floating point precision issues.
const SCALE = new BN(10000);
/**
* Formats a BN value to a human-readable balance string with proper units
*
* @param num - The BN value to format
* @returns A formatted string with the balance value and unit
*/
export const formatBNToBalance = (num: BN) => formatBalance(num, { forceUnit: formatBalance.getDefaults().unit, withAll: true, withUnit: true });
export const leadinFactorAt = (scaledWhen: BN): BN => {
const scaledHalf = SCALE.div(new BN(2)); // 0.5 scaled to 10000
if (scaledWhen.lte(scaledHalf)) {
// First half of the graph, steeper slope
return SCALE.mul(new BN(100)).sub(scaledWhen.mul(new BN(180)));
} else {
// Second half of the graph, flatter slope
return SCALE.mul(new BN(19)).sub(scaledWhen.mul(new BN(18)));
}
};
export const getCorePriceAt = (blockNow: number | null, saleInfo: PalletBrokerSaleInfoRecord | undefined): BN => {
if (!saleInfo || !blockNow) {
return new BN(0);
}
const { endPrice, leadinLength, saleStart } = saleInfo;
// Explicit conversion to BN
const blockNowBn = new BN(blockNow);
const saleStartBn = new BN(saleStart);
const leadinLengthBn = new BN(leadinLength);
// Elapsed time since the start of the sale, constrained to not exceed the total lead-in period
const elapsedTimeSinceSaleStart = blockNowBn.sub(saleStartBn);
const cappedElapsedTime = elapsedTimeSinceSaleStart.lt(leadinLengthBn)
? elapsedTimeSinceSaleStart
: leadinLengthBn;
const scaledProgress = cappedElapsedTime.mul(new BN(10000)).div(leadinLengthBn);
/**
* Progress is a normalized value between 0 and 1, where:
*
* 0 means the sale just started.
* 1 means the sale is at the end of the lead-in period.
*
* We are scaling it to avoid floating point precision issues.
*/
const leadinFactor = leadinFactorAt(scaledProgress);
const scaledPrice = leadinFactor.mul(endPrice).div(SCALE);
return scaledPrice;
};
/**
* Get the current sale number
*
* @param currentRegionEnd - The end of the current region
* @param chainName - The name of the chain
* @param config - broker.configuration call response
*
* @returns The current sale number
*/
export const getCurrentSaleNumber = (
currentRegionEnd: number,
relayName: RelayName,
config: Pick<PalletBrokerConfigRecord, 'interludeLength' | 'leadinLength' | 'regionLength'>
): number => {
if (!relayName || !currentRegionEnd) {
return -1;
}
return Math.ceil((currentRegionEnd - FirstCycleStart.timeslice.coretime[relayName]) / config.regionLength);
};
export const determinePhaseName = (
lastCommittedTimeslice: number,
currentRegionStart: number,
interludeLengthTs: number,
leadInLengthTs: number): typeof PhaseName[keyof typeof PhaseName] => {
const progress = lastCommittedTimeslice - currentRegionStart;
if (progress < interludeLengthTs) {
return PhaseName.Renewals;
}
if (progress < interludeLengthTs + leadInLengthTs) {
return PhaseName.PriceDiscovery;
}
return PhaseName.FixedPrice;
};
export const getSaleProgress = (
lastCommittedTimeslice: number | undefined,
currentRegionStartTs: number,
interludeLengthTs: number,
leadInLengthTs: number,
regionBegin: number): ProgressBarSection[] => {
if (!lastCommittedTimeslice || !currentRegionStartTs || !interludeLengthTs || !leadInLengthTs || !regionBegin) {
return [];
}
const progress = lastCommittedTimeslice - currentRegionStartTs;
return [
{
label: PhaseName.Renewals,
total: interludeLengthTs,
value: Math.min(progress, interludeLengthTs)
},
{
label: PhaseName.PriceDiscovery,
total: leadInLengthTs,
value: Math.min(Math.max(progress - interludeLengthTs, 0), leadInLengthTs)
},
{
label: PhaseName.FixedPrice,
total: regionBegin - currentRegionStartTs - interludeLengthTs - leadInLengthTs,
value: Math.max(progress - interludeLengthTs - leadInLengthTs, 0)
}
];
};
const makeConfig = (startTs: number, endTs: number, get: GetResponse, getDate: (ts: number) => string | null, phaseName?: typeof PhaseName[keyof typeof PhaseName]) => {
return {
end: {
blocks: {
coretime: get.blocks.coretime(endTs),
relay: get.blocks.relay(endTs)
},
date: getDate(endTs),
ts: endTs
},
name: phaseName ?? '',
start: {
blocks: {
coretime: get.blocks.coretime(startTs),
relay: get.blocks.relay(startTs)
},
date: getDate(startTs),
ts: startTs
}
};
};
const getPhaseConfiguration = (
currentRegionStart: number,
regionLength: number,
interludeLengthTs: number,
leadInLengthTs: number,
lastCommittedTimeslice: number,
constants: ChainConstants
): PhaseConfig => {
const renewalsEndTs = currentRegionStart + interludeLengthTs;
const priceDiscoveryEndTs = renewalsEndTs + leadInLengthTs;
const fixedPriceLengthTs = regionLength - interludeLengthTs - leadInLengthTs;
const fixedPriceEndTs = priceDiscoveryEndTs + fixedPriceLengthTs;
const get = createGet(constants);
const getDate = (ts: number) => estimateTime(ts, get.blocks.relay(lastCommittedTimeslice), constants.relay)?.formattedDate ?? null;
return {
config: {
[PhaseName.FixedPrice]: makeConfig(priceDiscoveryEndTs, fixedPriceEndTs, get, getDate, PhaseName.FixedPrice),
[PhaseName.PriceDiscovery]: makeConfig(renewalsEndTs, priceDiscoveryEndTs, get, getDate, PhaseName.PriceDiscovery),
[PhaseName.Renewals]: makeConfig(currentRegionStart, renewalsEndTs, get, getDate, PhaseName.Renewals)
},
currentPhaseName: determinePhaseName(lastCommittedTimeslice, currentRegionStart, interludeLengthTs, leadInLengthTs)
};
};
export const getSaleParameters = (
{ config, constants, salesInfo }: {salesInfo: RegionInfo, config: Pick<PalletBrokerConfigRecord, 'interludeLength' | 'leadinLength' | 'regionLength'>, constants: ChainConstants},
relayName: RelayName,
lastCommittedTimeslice: number,
chosenSaleNumber = -1
): SaleParameters => {
const get = createGet(constants);
const interludeLengthTs = get.timeslices.coretime(config.interludeLength);
const leadInLengthTs = get.timeslices.coretime(config.leadinLength);
let { currentRegionEndTs, currentRegionStartTs } = getCurrentRegionStartEndTs(salesInfo, config.regionLength);
const getDate = (ts: number) => estimateTime(ts, get.blocks.relay(lastCommittedTimeslice), constants.relay)?.formattedDate ?? null;
const saleNumber = getCurrentSaleNumber(currentRegionEndTs, relayName, config);
let currentRegionInfo: SaleParameters['currentRegion'];
if (chosenSaleNumber !== -1) {
// A hack for Dicle as one of the sales had an unusual length
// checked against Subscan historical sales
if (relayName === 'dicle') {
const irregularRegionLength = 848;
if (chosenSaleNumber === 0) {
currentRegionStartTs = FirstCycleStart.timeslice.coretime[relayName];
currentRegionEndTs = currentRegionStartTs + config.regionLength;
} else if (chosenSaleNumber === 1) {
currentRegionStartTs = FirstCycleStart.timeslice.coretime[relayName] + config.regionLength;
// that particular sale #2 was only 848 blocks long
currentRegionEndTs = currentRegionStartTs + irregularRegionLength;
} else {
currentRegionStartTs = FirstCycleStart.timeslice.coretime[relayName] + config.regionLength * (chosenSaleNumber - 1) + irregularRegionLength;
currentRegionEndTs = currentRegionStartTs + config.regionLength;
}
} else {
currentRegionStartTs = FirstCycleStart.timeslice.coretime[relayName] + config.regionLength * chosenSaleNumber;
currentRegionEndTs = currentRegionStartTs + config.regionLength;
}
currentRegionInfo = {
end: {
blocks: {
// the coretime blocks cannot be calculated as historically the regions are not 201,600 blocks long, they deviate from 2,212 to 1,417 blocks
coretime: 0,
relay: get.blocks.relay(currentRegionEndTs)
},
date: getDate(currentRegionEndTs) ?? '',
ts: currentRegionEndTs
},
start: {
blocks: {
coretime: 0,
relay: get.blocks.relay(currentRegionStartTs)
},
date: getDate(currentRegionStartTs) ?? '',
ts: currentRegionStartTs
}
};
} else {
currentRegionInfo = makeConfig(currentRegionStartTs, currentRegionEndTs, get, getDate) as SaleParameters['currentRegion'];
}
let phaseConfig: PhaseConfig | null = null;
if (currentRegionEndTs - currentRegionStartTs === config.regionLength) {
phaseConfig = getPhaseConfiguration(
currentRegionStartTs,
config.regionLength,
interludeLengthTs,
leadInLengthTs,
lastCommittedTimeslice,
constants
);
}
return {
currentRegion: currentRegionInfo,
interlude: {
blocks: config.interludeLength,
ts: interludeLengthTs
},
leadin: {
blocks: config.leadinLength,
ts: leadInLengthTs
},
phaseConfig,
regionForSale: {
end: {
blocks: {
coretime: 0,
relay: currentRegionInfo.end.blocks.relay + get.blocks.relay(config.regionLength)
},
date: estimateTime(currentRegionInfo.end.ts + config.regionLength, get.blocks.relay(lastCommittedTimeslice), constants.relay)?.formattedDate ?? null,
ts: currentRegionInfo.end.ts + config.regionLength
},
start: {
blocks: {
coretime: 0,
relay: currentRegionInfo.end.blocks.relay
},
date: currentRegionInfo.end.date,
ts: currentRegionInfo.end.ts
}
},
saleNumber
};
};