Commit 06e5313b authored by Igor Stuev's avatar Igor Stuev Committed by GitHub

Neon: Solana transactions (#2483)

* Neon: Solana transactions

* add neon-devnet to reviews

* add env validation for external txs feature

* txs -> txns

* review fix
parent c8d6980b
...@@ -21,6 +21,7 @@ on: ...@@ -21,6 +21,7 @@ on:
- eth_sepolia - eth_sepolia
- eth_goerli - eth_goerli
- filecoin - filecoin
- neon_devnet
- optimism - optimism
- optimism_celestia - optimism_celestia
- optimism_sepolia - optimism_sepolia
......
...@@ -22,6 +22,7 @@ on: ...@@ -22,6 +22,7 @@ on:
- eth_goerli - eth_goerli
- filecoin - filecoin
- mekong - mekong
- neon_devnet
- optimism - optimism
- optimism_celestia - optimism_celestia
- optimism_sepolia - optimism_sepolia
......
import type { Feature } from './types';
import type { TxExternalTxsConfig } from 'types/client/externalTxsConfig';
import { getEnvValue, parseEnvJson } from '../utils';
const externalTransactionsConfig = parseEnvJson<TxExternalTxsConfig>(getEnvValue('NEXT_PUBLIC_TX_EXTERNAL_TRANSACTIONS_CONFIG'));
const title = 'External transactions';
const config: Feature<{ chainName: string; chainLogoUrl: string; explorerUrlTemplate: string }> = (() => {
if (externalTransactionsConfig) {
return Object.freeze({
title,
isEnabled: true,
chainName: externalTransactionsConfig.chain_name,
chainLogoUrl: externalTransactionsConfig.chain_logo_url,
explorerUrlTemplate: externalTransactionsConfig.explorer_url_template,
});
}
return Object.freeze({
title,
isEnabled: false,
});
})();
export default config;
...@@ -12,6 +12,7 @@ export { default as csvExport } from './csvExport'; ...@@ -12,6 +12,7 @@ export { default as csvExport } from './csvExport';
export { default as dataAvailability } from './dataAvailability'; export { default as dataAvailability } from './dataAvailability';
export { default as deFiDropdown } from './deFiDropdown'; export { default as deFiDropdown } from './deFiDropdown';
export { default as easterEggBadge } from './easterEggBadge'; export { default as easterEggBadge } from './easterEggBadge';
export { default as externalTxs } from './externalTxs';
export { default as faultProofSystem } from './faultProofSystem'; export { default as faultProofSystem } from './faultProofSystem';
export { default as gasTracker } from './gasTracker'; export { default as gasTracker } from './gasTracker';
export { default as getGasButton } from './getGasButton'; export { default as getGasButton } from './getGasButton';
......
# Set of ENVs for Neon Devnet network explorer
# https://neon-devnet.blockscout.com
# This is an auto-generated file. To update all values, run "yarn dev:preset:sync --name=neon_devnet"
# Local ENVs
NEXT_PUBLIC_APP_PROTOCOL=http
NEXT_PUBLIC_APP_HOST=localhost
NEXT_PUBLIC_APP_PORT=3000
NEXT_PUBLIC_APP_ENV=development
NEXT_PUBLIC_API_WEBSOCKET_PROTOCOL=ws
# Instance ENVs
NEXT_PUBLIC_ADMIN_SERVICE_API_HOST=https://admin-rs.services.blockscout.com
NEXT_PUBLIC_API_BASE_PATH=/
NEXT_PUBLIC_API_HOST=neon-devnet.blockscout.com
NEXT_PUBLIC_API_SPEC_URL=https://raw.githubusercontent.com/blockscout/blockscout-api-v2-swagger/main/swagger.yaml
NEXT_PUBLIC_CONTRACT_CODE_IDES=[{'title':'Remix IDE','url':'https://remix.ethereum.org/?address={hash}&blockscout={domain}','icon_url':'https://raw.githubusercontent.com/blockscout/frontend-configs/main/configs/ide-icons/remix.png'}]
NEXT_PUBLIC_CONTRACT_INFO_API_HOST=https://contracts-info.services.blockscout.com
NEXT_PUBLIC_FEATURED_NETWORKS=https://raw.githubusercontent.com/blockscout/frontend-configs/main/configs/featured-networks/neon-devnet.json
NEXT_PUBLIC_GAME_BADGE_CLAIM_LINK=https://badges.blockscout.com/mint/sherblockHolmesBadge
NEXT_PUBLIC_GRAPHIQL_TRANSACTION=0x0716b7a70a1c3b83f731084d7c1449148392512318c2ce0fd812d029204707b5
NEXT_PUBLIC_HOMEPAGE_CHARTS=['daily_txs']
NEXT_PUBLIC_HOMEPAGE_HERO_BANNER_CONFIG={'background':['linear-gradient(0, rgb(223, 66, 171), rgb(176, 40, 209))'],'text_color':['rgba(255, 255, 255, 1)']}
NEXT_PUBLIC_IS_ACCOUNT_SUPPORTED=true
NEXT_PUBLIC_IS_TESTNET=true
NEXT_PUBLIC_LOGOUT_URL=https://blockscout-neon.us.auth0.com/v2/logout
NEXT_PUBLIC_MARKETPLACE_ENABLED=false
NEXT_PUBLIC_METADATA_SERVICE_API_HOST=https://metadata.services.blockscout.com
NEXT_PUBLIC_NETWORK_CURRENCY_DECIMALS=18
NEXT_PUBLIC_NETWORK_CURRENCY_NAME=NEON
NEXT_PUBLIC_NETWORK_CURRENCY_SYMBOL=NEON
NEXT_PUBLIC_NETWORK_ICON=https://raw.githubusercontent.com/blockscout/frontend-configs/main/configs/network-icons/neon-short.svg
NEXT_PUBLIC_NETWORK_ID=245022926
NEXT_PUBLIC_NETWORK_LOGO=https://raw.githubusercontent.com/blockscout/frontend-configs/main/configs/network-logos/neon.svg
NEXT_PUBLIC_NETWORK_NAME=Neon Devnet
NEXT_PUBLIC_NETWORK_RPC_URL=https://devnet.neonevm.org
NEXT_PUBLIC_NETWORK_SHORT_NAME=Neon
NEXT_PUBLIC_OG_ENHANCED_DATA_ENABLED=true
NEXT_PUBLIC_OG_IMAGE_URL=https://raw.githubusercontent.com/blockscout/frontend-configs/main/configs/og-images/neon-devnet.png
NEXT_PUBLIC_STATS_API_HOST=https://stats-neon-devnet.k8s.blockscout.com
NEXT_PUBLIC_TRANSACTION_INTERPRETATION_PROVIDER=blockscout
NEXT_PUBLIC_VIEWS_CONTRACT_SOLIDITYSCAN_ENABLED=true
NEXT_PUBLIC_VISUALIZE_API_HOST=https://visualizer.services.blockscout.com
NEXT_PUBLIC_TX_EXTERNAL_TRANSACTIONS_CONFIG={'chain_name':'Solana','chain_logo_url':'https://raw.githubusercontent.com/blockscout/frontend-configs/refs/heads/main/configs/network-icons/solana.svg','explorer_url_template':'https://solscan.io/tx/{hash}'}
...@@ -43,6 +43,7 @@ import type { NftMarketplaceItem } from '../../../types/views/nft'; ...@@ -43,6 +43,7 @@ import type { NftMarketplaceItem } from '../../../types/views/nft';
import type { TxAdditionalFieldsId, TxFieldsId } from '../../../types/views/tx'; import type { TxAdditionalFieldsId, TxFieldsId } from '../../../types/views/tx';
import { TX_ADDITIONAL_FIELDS_IDS, TX_FIELDS_IDS } from '../../../types/views/tx'; import { TX_ADDITIONAL_FIELDS_IDS, TX_FIELDS_IDS } from '../../../types/views/tx';
import type { VerifiedContractsFilter } from '../../../types/api/contracts'; import type { VerifiedContractsFilter } from '../../../types/api/contracts';
import type { TxExternalTxsConfig } from '../../../types/client/externalTxsConfig';
import { replaceQuotes } from '../../../configs/app/utils'; import { replaceQuotes } from '../../../configs/app/utils';
import * as regexp from '../../../lib/regexp'; import * as regexp from '../../../lib/regexp';
...@@ -647,6 +648,12 @@ const multichainProviderConfigSchema: yup.ObjectSchema<MultichainProviderConfig> ...@@ -647,6 +648,12 @@ const multichainProviderConfigSchema: yup.ObjectSchema<MultichainProviderConfig>
dapp_id: yup.string(), dapp_id: yup.string(),
}); });
const externalTxsConfigSchema: yup.ObjectSchema<TxExternalTxsConfig> = yup.object({
chain_name: yup.string().required(),
chain_logo_url: yup.string().required(),
explorer_url_template: yup.string().required(),
});
const schema = yup const schema = yup
.object() .object()
.noUnknown(true, (params) => { .noUnknown(true, (params) => {
...@@ -996,6 +1003,19 @@ const schema = yup ...@@ -996,6 +1003,19 @@ const schema = yup
NEXT_PUBLIC_REWARDS_SERVICE_API_HOST: yup.string().test(urlTest), NEXT_PUBLIC_REWARDS_SERVICE_API_HOST: yup.string().test(urlTest),
NEXT_PUBLIC_XSTAR_SCORE_URL: yup.string().test(urlTest), NEXT_PUBLIC_XSTAR_SCORE_URL: yup.string().test(urlTest),
NEXT_PUBLIC_GAME_BADGE_CLAIM_LINK: yup.string().test(urlTest), NEXT_PUBLIC_GAME_BADGE_CLAIM_LINK: yup.string().test(urlTest),
NEXT_PUBLIC_TX_EXTERNAL_TRANSACTIONS_CONFIG: yup.mixed().test(
'shape',
'Invalid schema were provided for NEXT_PUBLIC_TX_EXTERNAL_TRANSACTIONS_CONFIG, it should have chain_name, chain_logo_url, and explorer_url_template',
(data) => {
const isUndefined = data === undefined;
const valueSchema = yup.object<TxExternalTxsConfig>().transform(replaceQuotes).json().shape({
chain_name: yup.string().required(),
chain_logo_url: yup.string().required(),
explorer_url_template: yup.string().required(),
});
return isUndefined || valueSchema.isValidSync(data);
}),
// 6. External services envs // 6. External services envs
NEXT_PUBLIC_WALLET_CONNECT_PROJECT_ID: yup.string(), NEXT_PUBLIC_WALLET_CONNECT_PROJECT_ID: yup.string(),
......
NEXT_PUBLIC_TX_EXTERNAL_TRANSACTIONS_CONFIG={'chain_name':'Solana','chain_logo_url':'https://raw.githubusercontent.com/blockscout/frontend-configs/refs/heads/main/configs/network-icons/solana.svg','explorer_url_template':'https://solscan.io/tx/{hash}'}
\ No newline at end of file
...@@ -604,6 +604,14 @@ This feature is **enabled by default** with the `['metamask']` value. To switch ...@@ -604,6 +604,14 @@ This feature is **enabled by default** with the `['metamask']` value. To switch
&nbsp; &nbsp;
### External transactions
| Variable | Type| Description | Compulsoriness | Default value | Example value | Version |
| --- | --- | --- | --- | --- | --- | --- |
| NEXT_PUBLIC_TX_EXTERNAL_TRANSACTIONS_CONFIG | `{ chain_name: string; chain_logo_url: string; explorer_url_template: string; }` | Configuration of the external transactions links that should be added to the transaction details. | - | - | `{ chain_name: 'ethereum', chain_logo_url: 'https://example.com/logo.png', explorer_url_template: 'https://explorer.com/tx/{hash}' }` | v1.38.0+ |
&nbsp;
### Verified tokens info ### Verified tokens info
| Variable | Type| Description | Compulsoriness | Default value | Example value | Version | | Variable | Type| Description | Compulsoriness | Default value | Example value | Version |
......
...@@ -518,6 +518,10 @@ export const RESOURCES = { ...@@ -518,6 +518,10 @@ export const RESOURCES = {
path: '/api/v2/transactions/:hash/summary', path: '/api/v2/transactions/:hash/summary',
pathParams: [ 'hash' as const ], pathParams: [ 'hash' as const ],
}, },
tx_external_transactions: {
path: '/api/v2/transactions/:hash/external-transactions',
pathParams: [ 'hash' as const ],
},
withdrawals: { withdrawals: {
path: '/api/v2/withdrawals', path: '/api/v2/withdrawals',
filterFields: [], filterFields: [],
...@@ -1331,6 +1335,7 @@ Q extends 'tx_raw_trace' ? RawTracesResponse : ...@@ -1331,6 +1335,7 @@ Q extends 'tx_raw_trace' ? RawTracesResponse :
Q extends 'tx_state_changes' ? TxStateChanges : Q extends 'tx_state_changes' ? TxStateChanges :
Q extends 'tx_blobs' ? TxBlobs : Q extends 'tx_blobs' ? TxBlobs :
Q extends 'tx_interpretation' ? TxInterpretationResponse : Q extends 'tx_interpretation' ? TxInterpretationResponse :
Q extends 'tx_external_transactions' ? Array<string> :
Q extends 'addresses' ? AddressesResponse : Q extends 'addresses' ? AddressesResponse :
Q extends 'addresses_metadata_search' ? AddressesMetadataSearchResult : Q extends 'addresses_metadata_search' ? AddressesMetadataSearchResult :
Q extends 'address' ? Address : Q extends 'address' ? Address :
......
...@@ -94,4 +94,7 @@ export const ENVS_MAP: Record<string, Array<[string, string]>> = { ...@@ -94,4 +94,7 @@ export const ENVS_MAP: Record<string, Array<[string, string]>> = {
[ 'NEXT_PUBLIC_ADDRESS_FORMAT', '["bech32","base16"]' ], [ 'NEXT_PUBLIC_ADDRESS_FORMAT', '["bech32","base16"]' ],
[ 'NEXT_PUBLIC_VIEWS_ADDRESS_BECH_32_PREFIX', 'tom' ], [ 'NEXT_PUBLIC_VIEWS_ADDRESS_BECH_32_PREFIX', 'tom' ],
], ],
externalTxs: [
[ 'NEXT_PUBLIC_TX_EXTERNAL_TRANSACTIONS_CONFIG', '{"chain_name": "Solana", "chain_logo_url": "http://example.url", "explorer_url_template": "https://scan.io/tx/{hash}"}' ],
],
}; };
...@@ -15,6 +15,7 @@ const PRESETS = { ...@@ -15,6 +15,7 @@ const PRESETS = {
filecoin: 'https://filecoin.blockscout.com', filecoin: 'https://filecoin.blockscout.com',
gnosis: 'https://gnosis.blockscout.com', gnosis: 'https://gnosis.blockscout.com',
mekong: 'https://mekong.blockscout.com', mekong: 'https://mekong.blockscout.com',
neon_devnet: 'https://neon-devnet.blockscout.com',
optimism: 'https://optimism.blockscout.com', optimism: 'https://optimism.blockscout.com',
optimism_celestia: 'https://opcelestia-raspberry.gelatoscout.com', optimism_celestia: 'https://opcelestia-raspberry.gelatoscout.com',
optimism_sepolia: 'https://optimism-sepolia.blockscout.com', optimism_sepolia: 'https://optimism-sepolia.blockscout.com',
......
export type TxExternalTxsConfig = {
chain_name: string;
chain_logo_url: string;
explorer_url_template: string;
};
import React from 'react';
import { ENVS_MAP } from 'playwright/fixtures/mockEnvs';
import { test, expect } from 'playwright/lib';
import TxExternalTxs from './TxExternalTxs';
const EXT_TX_HASH = '2uwpB95K9ae8yrpxxVXJ27ivvHXqrmy82jsamgNtdWJrYDGkCHsRwd2LKXubrQUzXMaojGxZmHZ85XVJN8EJ3LW8';
test('base view', async({ page, render, mockEnvs, mockAssetResponse }) => {
await mockEnvs(ENVS_MAP.externalTxs);
await mockAssetResponse('http://example.url', './playwright/mocks/image_s.jpg');
await render(<TxExternalTxs data={ Array(13).fill(EXT_TX_HASH) }/>);
await page.getByText('13 Solana txns').hover();
const popover = page.getByText('Solana transactions');
await expect(popover).toBeVisible();
await expect(page).toHaveScreenshot({ clip: { x: 0, y: 0, width: 500, height: 500 } });
});
import {
PopoverTrigger,
PopoverBody,
PopoverContent,
Flex,
Link,
Image,
} from '@chakra-ui/react';
import React from 'react';
import config from 'configs/app';
import Popover from 'ui/shared/chakra/Popover';
import TxEntity from 'ui/shared/entities/tx/TxEntity';
const externalTxFeature = config.features.externalTxs;
interface Props {
data: Array<string>;
}
const TxExternalTxs: React.FC<Props> = ({ data }) => {
if (!externalTxFeature.isEnabled) {
return null;
}
return (
<Popover placement="bottom-end" openDelay={ 300 } isLazy trigger="hover">
<PopoverTrigger>
<Link
_hover={{ textDecoration: 'none', color: 'link_hovered' }}
display="inline-flex"
alignItems="center"
gap={ 2 }
>
<Image src={ externalTxFeature.chainLogoUrl } alt={ externalTxFeature.chainName } width={ 5 } height={ 5 }/>
{ data.length } { externalTxFeature.chainName } txn{ data.length > 1 ? 's' : '' }
</Link>
</PopoverTrigger>
<PopoverContent border="1px solid" borderColor="divider" w={{ base: '300px', lg: '460px' }}>
<PopoverBody fontWeight={ 400 } fontSize="sm">
<Flex alignItems="center" gap={ 2 } fontSize="md" mb={ 3 }>
<Image src={ externalTxFeature.chainLogoUrl } alt={ externalTxFeature.chainName } width={ 5 } height={ 5 }/>
{ externalTxFeature.chainName } transaction{ data.length > 1 ? 's' : '' }
</Flex>
<Flex flexDirection="column" gap={ 2 } w="100%" maxHeight="460px" overflowY="auto">
{ data.map((txHash) => (
<TxEntity
key={ txHash }
hash={ txHash }
href={ externalTxFeature.explorerUrlTemplate.replace('{hash}', txHash) }
isExternal
/>
)) }
</Flex>
</PopoverBody>
</PopoverContent>
</Popover>
);
};
export default TxExternalTxs;
...@@ -126,3 +126,12 @@ test('arbitrum L1 status', async({ render, mockEnvs }) => { ...@@ -126,3 +126,12 @@ test('arbitrum L1 status', async({ render, mockEnvs }) => {
await expect(statusElement).toHaveScreenshot(); await expect(statusElement).toHaveScreenshot();
}); });
test('with external txs +@mobile', async({ render, mockEnvs, mockApiResponse, mockAssetResponse }) => {
await mockEnvs(ENVS_MAP.externalTxs);
await mockApiResponse('tx_external_transactions', [ 'tx1', 'tx2', 'tx3' ], { pathParams: { hash: txMock.base.hash } });
await mockAssetResponse('http://example.url', './playwright/mocks/image_s.jpg');
const component = await render(<TxInfo data={ txMock.base } isLoading={ false }/>);
await expect(component).toHaveScreenshot();
});
...@@ -22,7 +22,9 @@ import { ZKSYNC_L2_TX_BATCH_STATUSES } from 'types/api/zkSyncL2'; ...@@ -22,7 +22,9 @@ import { ZKSYNC_L2_TX_BATCH_STATUSES } from 'types/api/zkSyncL2';
import { route } from 'nextjs-routes'; import { route } from 'nextjs-routes';
import config from 'configs/app'; import config from 'configs/app';
import useApiQuery from 'lib/api/useApiQuery';
import { WEI, WEI_IN_GWEI } from 'lib/consts'; import { WEI, WEI_IN_GWEI } from 'lib/consts';
import useIsMobile from 'lib/hooks/useIsMobile';
import getNetworkValidatorTitle from 'lib/networks/getNetworkValidatorTitle'; import getNetworkValidatorTitle from 'lib/networks/getNetworkValidatorTitle';
import * as arbitrum from 'lib/rollups/arbitrum'; import * as arbitrum from 'lib/rollups/arbitrum';
import getConfirmationDuration from 'lib/tx/getConfirmationDuration'; import getConfirmationDuration from 'lib/tx/getConfirmationDuration';
...@@ -58,6 +60,7 @@ import TxDetailsTokenTransfers from 'ui/tx/details/TxDetailsTokenTransfers'; ...@@ -58,6 +60,7 @@ import TxDetailsTokenTransfers from 'ui/tx/details/TxDetailsTokenTransfers';
import TxDetailsWithdrawalStatusOptimistic from 'ui/tx/details/TxDetailsWithdrawalStatusOptimistic'; import TxDetailsWithdrawalStatusOptimistic from 'ui/tx/details/TxDetailsWithdrawalStatusOptimistic';
import TxRevertReason from 'ui/tx/details/TxRevertReason'; import TxRevertReason from 'ui/tx/details/TxRevertReason';
import TxAllowedPeekers from 'ui/tx/TxAllowedPeekers'; import TxAllowedPeekers from 'ui/tx/TxAllowedPeekers';
import TxExternalTxs from 'ui/tx/TxExternalTxs';
import TxSocketAlert from 'ui/tx/TxSocketAlert'; import TxSocketAlert from 'ui/tx/TxSocketAlert';
import ZkSyncL2TxnBatchHashesInfo from 'ui/txnBatches/zkSyncL2/ZkSyncL2TxnBatchHashesInfo'; import ZkSyncL2TxnBatchHashesInfo from 'ui/txnBatches/zkSyncL2/ZkSyncL2TxnBatchHashesInfo';
...@@ -72,9 +75,23 @@ interface Props { ...@@ -72,9 +75,23 @@ interface Props {
socketStatus?: 'close' | 'error'; socketStatus?: 'close' | 'error';
} }
const externalTxFeature = config.features.externalTxs;
const TxInfo = ({ data, isLoading, socketStatus }: Props) => { const TxInfo = ({ data, isLoading, socketStatus }: Props) => {
const [ isExpanded, setIsExpanded ] = React.useState(false); const [ isExpanded, setIsExpanded ] = React.useState(false);
const isMobile = useIsMobile();
const externalTxsQuery = useApiQuery('tx_external_transactions', {
pathParams: {
hash: data?.hash,
},
queryOptions: {
enabled: externalTxFeature.isEnabled,
placeholderData: [ '1', '2', '3' ],
},
});
const handleCutClick = React.useCallback(() => { const handleCutClick = React.useCallback(() => {
setIsExpanded((flag) => !flag); setIsExpanded((flag) => !flag);
scroller.scrollTo('TxInfo__cutLink', { scroller.scrollTo('TxInfo__cutLink', {
...@@ -147,18 +164,26 @@ const TxInfo = ({ data, isLoading, socketStatus }: Props) => { ...@@ -147,18 +164,26 @@ const TxInfo = ({ data, isLoading, socketStatus }: Props) => {
> >
Transaction hash Transaction hash
</DetailsInfoItem.Label> </DetailsInfoItem.Label>
<DetailsInfoItem.Value flexWrap="nowrap"> <DetailsInfoItem.Value>
{ data.status === null && <Spinner mr={ 2 } size="sm" flexShrink={ 0 }/> } <Flex flexWrap="nowrap" alignItems="center" overflow="hidden">
<Skeleton isLoaded={ !isLoading } overflow="hidden"> { data.status === null && <Spinner mr={ 2 } size="sm" flexShrink={ 0 }/> }
<HashStringShortenDynamic hash={ data.hash }/> <Skeleton isLoaded={ !isLoading } overflow="hidden">
</Skeleton> <HashStringShortenDynamic hash={ data.hash }/>
<CopyToClipboard text={ data.hash } isLoading={ isLoading }/> </Skeleton>
<CopyToClipboard text={ data.hash } isLoading={ isLoading }/>
{ config.features.metasuites.isEnabled && (
<> { config.features.metasuites.isEnabled && (
<TextSeparator color="gray.500" flexShrink={ 0 } display="none" id="meta-suites__tx-explorer-separator"/> <>
<Box display="none" flexShrink={ 0 } id="meta-suites__tx-explorer-link"/> <TextSeparator color="gray.500" flexShrink={ 0 } display="none" id="meta-suites__tx-explorer-separator"/>
</> <Box display="none" flexShrink={ 0 } id="meta-suites__tx-explorer-link"/>
</>
) }
</Flex>
{ config.features.externalTxs.isEnabled && externalTxsQuery.data && externalTxsQuery.data.length > 0 && (
<Skeleton isLoaded={ !isLoading && !externalTxsQuery.isPlaceholderData } display={{ base: 'block', lg: 'inline-flex' }} alignItems="center">
{ !isMobile && <TextSeparator color="gray.500" flexShrink={ 0 }/> }
<TxExternalTxs data={ externalTxsQuery.data }/>
</Skeleton>
) } ) }
</DetailsInfoItem.Value> </DetailsInfoItem.Value>
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment