Commit a45aed6d authored by tom's avatar tom

csv export with old api

form fields

recaptcha v2

api integration

recaptcha reset

remove recaptcha key from git
parent 27974d19
......@@ -53,3 +53,4 @@ NEXT_PUBLIC_VISUALIZE_API_HOST=__PLACEHOLDER_FOR_NEXT_PUBLIC_VISUALIZE_API_HOST_
NEXT_PUBLIC_SENTRY_DSN=__PLACEHOLDER_FOR_NEXT_PUBLIC_SENTRY_DSN__
NEXT_PUBLIC_AUTH0_CLIENT_ID=__PLACEHOLDER_FOR_NEXT_PUBLIC_AUTH0_CLIENT_ID__
NEXT_PUBLIC_WALLET_CONNECT_PROJECT_ID=__PLACEHOLDER_FOR_NEXT_PUBLIC_WALLET_CONNECT_PROJECT_ID__
NEXT_PUBLIC_RE_CAPTCHA_APP_SITE_KEY=__PLACEHOLDER_FOR_NEXT_PUBLIC_RE_CAPTCHA_APP_SITE_KEY__
......@@ -125,8 +125,9 @@ The app instance could be customized by passing following variables to NodeJS en
| --- | --- | --- | --- |
| NEXT_PUBLIC_SENTRY_DSN | `string` *(optional)* | Client key for your Sentry.io app | `<secret>` |
| SENTRY_CSP_REPORT_URI | `string` *(optional)* | URL for sending CSP-reports to your Sentry.io app | `<secret>` |
| NEXT_PUBLIC_AUTH0_CLIENT_ID | `string` *(optional)* | Client id for [Auth0](https://auth0.com/) provider | `<secret>` |
| NEXT_PUBLIC_AUTH0_CLIENT_ID | `string` | Client id for [Auth0](https://auth0.com/) provider | `<secret>` |
| NEXT_PUBLIC_WALLET_CONNECT_PROJECT_ID | `string` | Project id for [WalletConnect](https://docs.walletconnect.com/2.0/web3modal/react/installation#obtain-project-id) integration | `<secret>` |
| NEXT_PUBLIC_RE_CAPTCHA_APP_SITE_KEY | `string` | Site key for [reCAPTCHA](https://developers.google.com/recaptcha) service | `<secret>` |
### Marketplace app configuration properties
......
......@@ -122,6 +122,9 @@ const config = Object.freeze({
walletConnect: {
projectId: getEnvValue(process.env.NEXT_PUBLIC_WALLET_CONNECT_PROJECT_ID),
},
reCaptcha: {
siteKey: getEnvValue(process.env.NEXT_PUBLIC_RE_CAPTCHA_APP_SITE_KEY) || '',
},
});
export default config;
......@@ -13,4 +13,4 @@ NEXT_PUBLIC_FOOTER_TWITTER_LINK=https://www.twitter.com/blockscoutcom
# api config
NEXT_PUBLIC_API_HOST=blockscout.com
NEXT_PUBLIC_STATS_API_HOST=https://stats-test.aws-k8s.blockscout.com
NEXT_PUBLIC_VISUALIZE_API_HOST=http://94.131.100.174:8050
NEXT_PUBLIC_VISUALIZE_API_HOST=https://visualizer.aws-k8s.blockscout.com
......@@ -13,10 +13,9 @@ export default function fetchFactory(
// first arg can be only a string
// FIXME migrate to RequestInfo later if needed
return function fetch(url: string, init?: RequestInit): Promise<Response> {
const incomingContentType = _req.headers['content-type'];
const headers = {
accept: 'application/json',
'content-type': incomingContentType?.match(/^multipart\/form-data/) ? incomingContentType : 'application/json',
accept: _req.headers['accept'] || 'application/json',
'content-type': _req.headers['content-type'] || 'application/json',
cookie: `${ cookies.NAMES.API_TOKEN }=${ _req.cookies[cookies.NAMES.API_TOKEN] }`,
};
......
......@@ -286,6 +286,15 @@ export const RESOURCES = {
old_api: {
path: '/api',
},
csv_export_txs: {
path: '/transactions-csv',
},
csv_export_internal_txs: {
path: '/internal-transactions-csv',
},
csv_export_token_transfers: {
path: '/token-transfers-csv',
},
};
export type ResourceName = keyof typeof RESOURCES;
......
......@@ -97,6 +97,11 @@ function makePolicyMap() {
'servedbyadbutler.com',
'\'sha256-wMOeDjJaOTjCfNjluteV+tSqHW547T89sgxd8W6tQJM=\'',
'\'sha256-FcyIn1h7zra8TVnnRhYrwrplxJW7dpD5TV7kP2AG/kI=\'',
// reCAPTCHA from google
'https://www.google.com/recaptcha/api.js',
'https://www.gstatic.com',
'\'sha256-FDyPg8CqqIpPAfGVKx1YeKduyLs0ghNYWII21wL+7HM=\'',
],
'style-src': [
......@@ -147,7 +152,7 @@ function makePolicyMap() {
KEY_WORDS.DATA,
// google fonts
'*.gstatic.com',
'fonts.gstatic.com',
'fonts.googleapis.com',
],
......@@ -168,6 +173,11 @@ function makePolicyMap() {
// ad
'request-global.czilladx.com',
// reCAPTCHA from google
// 'https://www.google.com/',
'https://www.google.com/recaptcha/api2/anchor',
'https://www.google.com/recaptcha/api2/bframe',
],
...(REPORT_URI ? {
......
export default function downloadBlob(blob: Blob, filename: string) {
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.setAttribute('href', url);
link.setAttribute('download', filename);
link.click();
link.remove();
URL.revokeObjectURL(url);
}
import { unparse } from 'papaparse';
import downloadBlob from 'lib/downloadBlob';
export default function saveAsCSV(headerRows: Array<string>, dataRows: Array<Array<string>>, filename: string) {
const csv = unparse([
headerRows,
......@@ -8,12 +10,5 @@ export default function saveAsCSV(headerRows: Array<string>, dataRows: Array<Arr
const blob = new Blob([ csv ], { type: 'text/csv;charset=utf-8;' });
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.setAttribute('href', url);
link.setAttribute('download', filename);
link.click();
link.remove();
URL.revokeObjectURL(url);
downloadBlob(blob, filename);
}
import type { NextPage } from 'next';
import Head from 'next/head';
import React from 'react';
import getNetworkTitle from 'lib/networks/getNetworkTitle';
import CsvExport from 'ui/pages/CsvExport';
const CsvExportPage: NextPage = () => {
const title = getNetworkTitle();
return (
<>
<Head>
<title>{ title }</title>
</Head>
<CsvExport/>
</>
);
};
export default CsvExportPage;
export { getServerSideProps } from 'lib/next/getServerSideProps';
export type CsvExportType = 'transactions' | 'internal-transactions' | 'token-transfers';
import { chakra, Icon, Link, Tooltip, Hide } from '@chakra-ui/react';
import { chakra, Icon, Tooltip, Hide } from '@chakra-ui/react';
import React from 'react';
import type { CsvExportType } from 'types/client/address';
import svgFileIcon from 'icons/files/csv.svg';
import useIsMobile from 'lib/hooks/useIsMobile';
import link from 'lib/link/link';
import LinkInternal from 'ui/shared/LinkInternal';
interface Props {
address: string;
type: 'transactions' | 'internal-transactions' | 'token-transfers';
type: CsvExportType;
className?: string;
}
......@@ -16,7 +19,7 @@ const AddressCsvExportLink = ({ className, address, type }: Props) => {
return (
<Tooltip isDisabled={ !isMobile } label="Download CSV">
<Link
<LinkInternal
className={ className }
display="inline-flex"
alignItems="center"
......@@ -24,7 +27,7 @@ const AddressCsvExportLink = ({ className, address, type }: Props) => {
>
<Icon as={ svgFileIcon } boxSize={{ base: '30px', lg: 6 }}/>
<Hide ssr={ false } below="lg"><chakra.span ml={ 1 }>Download CSV</chakra.span></Hide>
</Link>
</LinkInternal>
</Tooltip>
);
};
......
import { Button, chakra, Flex } from '@chakra-ui/react';
import React from 'react';
import type { SubmitHandler } from 'react-hook-form';
import { useForm, FormProvider } from 'react-hook-form';
import type { FormFields } from './types';
import buildUrl from 'lib/api/buildUrl';
import type { ResourceName } from 'lib/api/resources';
import dayjs from 'lib/date/dayjs';
import downloadBlob from 'lib/downloadBlob';
import useToast from 'lib/hooks/useToast';
import CsvExportFormField from './CsvExportFormField';
import CsvExportFormReCaptcha from './CsvExportFormReCaptcha';
interface Props {
hash: string;
resource: ResourceName;
fileNameTemplate: string;
}
const CsvExportForm = ({ hash, resource, fileNameTemplate }: Props) => {
const formApi = useForm<FormFields>({
mode: 'onBlur',
defaultValues: {
from: dayjs().subtract(1, 'day').format('YYYY-MM-DD'),
to: dayjs().format('YYYY-MM-DD'),
},
});
const { handleSubmit, formState } = formApi;
const toast = useToast();
const onFormSubmit: SubmitHandler<FormFields> = React.useCallback(async(data) => {
try {
const url = buildUrl(resource, undefined, {
address_id: hash,
from_period: data.from,
to_period: data.to,
recaptcha_response: data.reCaptcha,
});
const response = await fetch(url, {
headers: {
'content-type': 'application/octet-stream',
},
});
if (!response.ok) {
throw new Error();
}
const blob = await response.blob();
downloadBlob(blob, `${ fileNameTemplate }_${ hash }_${ data.from }_${ data.to }.csv`);
} catch (error) {
toast({
position: 'top-right',
title: 'Error',
description: (error as Error)?.message || 'Something went wrong. Try again later.',
status: 'error',
variant: 'subtle',
isClosable: true,
});
}
}, [ fileNameTemplate, hash, resource, toast ]);
return (
<FormProvider { ...formApi }>
<chakra.form
noValidate
onSubmit={ handleSubmit(onFormSubmit) }
>
<Flex columnGap={ 5 } rowGap={ 3 } flexDir={{ base: 'column', lg: 'row' }} alignItems={{ base: 'flex-start', lg: 'center' }}>
<CsvExportFormField name="from" formApi={ formApi }/>
<CsvExportFormField name="to" formApi={ formApi }/>
<CsvExportFormReCaptcha formApi={ formApi }/>
</Flex>
<Button
variant="solid"
size="lg"
type="submit"
mt={ 8 }
isLoading={ formState.isSubmitting }
loadingText="Download"
isDisabled={ !formState.isValid }
>
Download
</Button>
</chakra.form>
</FormProvider>
);
};
export default React.memo(CsvExportForm);
import { FormControl, Input } from '@chakra-ui/react';
import _capitalize from 'lodash/capitalize';
import React from 'react';
import type { ControllerRenderProps, UseFormReturn } from 'react-hook-form';
import { Controller } from 'react-hook-form';
import type { FormFields } from './types';
import dayjs from 'lib/date/dayjs';
import InputPlaceholder from 'ui/shared/InputPlaceholder';
interface Props {
formApi: UseFormReturn<FormFields>;
name: 'from' | 'to';
}
const CsvExportFormField = ({ formApi, name }: Props) => {
const { formState, control, getValues, trigger } = formApi;
const renderControl = React.useCallback(({ field }: {field: ControllerRenderProps<FormFields, 'from' | 'to'>}) => {
const error = field.name in formState.errors ? formState.errors[field.name] : undefined;
return (
<FormControl variant="floating" id={ field.name } isRequired size={{ base: 'md', lg: 'lg' }} maxW={{ base: 'auto', lg: '220px' }}>
<Input
{ ...field }
required
isInvalid={ Boolean(error) }
type="date"
isDisabled={ formState.isSubmitting }
autoComplete="off"
/>
<InputPlaceholder text={ _capitalize(field.name) } error={ error }/>
</FormControl>
);
}, [ formState.errors, formState.isSubmitting ]);
const validate = React.useCallback((newValue: string) => {
if (name === 'from') {
const toValue = getValues('to');
if (toValue && dayjs(newValue) > dayjs(toValue)) {
return 'Incorrect date';
}
if (formState.errors.to) {
trigger('to');
}
} else {
const fromValue = getValues('from');
if (fromValue && dayjs(fromValue) > dayjs(newValue)) {
return 'Incorrect date';
}
if (formState.errors.from) {
trigger('from');
}
}
}, [ formState.errors.from, formState.errors.to, getValues, name, trigger ]);
return (
<Controller
name={ name }
control={ control }
render={ renderControl }
rules={{ required: true, validate }}
/>
);
};
export default React.memo(CsvExportFormField);
import React from 'react';
import ReCaptcha from 'react-google-recaptcha';
import type { UseFormReturn } from 'react-hook-form';
import type { FormFields } from './types';
import appConfig from 'configs/app/config';
interface Props {
formApi: UseFormReturn<FormFields>;
}
const CsvExportFormReCaptcha = ({ formApi }: Props) => {
const ref = React.useRef<ReCaptcha>(null);
React.useEffect(() => {
formApi.register('reCaptcha', { required: true, shouldUnregister: true });
return () => {
formApi.unregister('reCaptcha');
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
React.useEffect(() => {
ref.current?.reset();
formApi.trigger('reCaptcha');
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [ formApi.formState.submitCount ]);
const handleReCaptchaChange = React.useCallback((token: string | null) => {
if (token) {
formApi.clearErrors('reCaptcha');
formApi.setValue('reCaptcha', token, { shouldValidate: true });
}
}, [ formApi ]);
const handleReCaptchaExpire = React.useCallback(() => {
formApi.resetField('reCaptcha');
formApi.setError('reCaptcha', { type: 'required' });
}, [ formApi ]);
return (
<ReCaptcha
ref={ ref }
sitekey={ appConfig.reCaptcha.siteKey }
onChange={ handleReCaptchaChange }
onExpired={ handleReCaptchaExpire }
/>
);
};
export default CsvExportFormReCaptcha;
export interface FormFields {
from: string;
to: string;
reCaptcha: string;
}
......@@ -6,7 +6,6 @@ import type { SmartContractVerificationConfigRaw, SmartContractVerificationMetho
import useApiQuery from 'lib/api/useApiQuery';
import { useAppContext } from 'lib/appContext';
import isBrowser from 'lib/isBrowser';
import link from 'lib/link/link';
import ContractVerificationForm from 'ui/contractVerification/ContractVerificationForm';
import { isValidVerificationMethod, sortVerificationMethods } from 'ui/contractVerification/utils';
......@@ -21,9 +20,7 @@ import PageTitle from 'ui/shared/Page/PageTitle';
const ContractVerification = () => {
const appProps = useAppContext();
const isInBrowser = isBrowser();
const referrer = isInBrowser ? window.document.referrer : appProps.referrer;
const hasGoBackLink = referrer && referrer.includes('/address');
const hasGoBackLink = appProps.referrer && appProps.referrer.includes('/address');
const router = useRouter();
const hash = router.query.id?.toString();
......@@ -91,7 +88,7 @@ const ContractVerification = () => {
<Page>
<PageTitle
text="New smart contract verification"
backLinkUrl={ hasGoBackLink ? referrer : undefined }
backLinkUrl={ hasGoBackLink ? appProps.referrer : undefined }
backLinkLabel="Back to contract"
/>
{ hash && (
......
import { Flex } from '@chakra-ui/react';
import { useRouter } from 'next/router';
import React from 'react';
import type { CsvExportType } from 'types/client/address';
import type { ResourceName } from 'lib/api/resources';
import useApiQuery from 'lib/api/useApiQuery';
import { useAppContext } from 'lib/appContext';
import useIsMobile from 'lib/hooks/useIsMobile';
import CsvExportForm from 'ui/csvExport/CsvExportForm';
import Address from 'ui/shared/address/Address';
import AddressIcon from 'ui/shared/address/AddressIcon';
import AddressLink from 'ui/shared/address/AddressLink';
import ContentLoader from 'ui/shared/ContentLoader';
import DataFetchAlert from 'ui/shared/DataFetchAlert';
import Page from 'ui/shared/Page/Page';
import PageTitle from 'ui/shared/Page/PageTitle';
interface ExportTypeEntity {
text: string;
resource: ResourceName;
fileNameTemplate: string;
}
const EXPORT_TYPES: Record<CsvExportType, ExportTypeEntity> = {
transactions: {
text: 'transactions',
resource: 'csv_export_txs',
fileNameTemplate: 'transactions',
},
'internal-transactions': {
text: 'internal transactions',
resource: 'csv_export_internal_txs',
fileNameTemplate: 'internal_transactions',
},
'token-transfers': {
text: 'token transfers',
resource: 'csv_export_token_transfers',
fileNameTemplate: 'token_transfers',
},
};
const isCorrectExportType = (type: string): type is CsvExportType => Object.keys(EXPORT_TYPES).includes(type);
const CsvExport = () => {
const router = useRouter();
const isMobile = useIsMobile();
const appProps = useAppContext();
const addressHash = router.query.address?.toString() || '';
const exportType = router.query.type?.toString() || '';
const hasGoBackLink = appProps.referrer && appProps.referrer.includes('/address');
const addressQuery = useApiQuery('address', {
pathParams: { id: addressHash },
queryOptions: {
enabled: Boolean(addressHash),
},
});
if (!isCorrectExportType(exportType) || !addressHash || addressQuery.error?.status === 400) {
throw Error('Not found', { cause: { status: 404 } });
}
const content = (() => {
if (addressQuery.isError) {
return <DataFetchAlert/>;
}
if (addressQuery.isLoading) {
return <ContentLoader/>;
}
return <CsvExportForm hash={ addressHash } resource={ EXPORT_TYPES[exportType].resource } fileNameTemplate={ EXPORT_TYPES[exportType].fileNameTemplate }/>;
})();
return (
<Page>
<PageTitle
text="Export data to CSV file"
backLinkUrl={ hasGoBackLink ? appProps.referrer : undefined }
backLinkLabel="Back to address"
/>
<Flex mb={ 10 } whiteSpace="pre-wrap" flexWrap="wrap">
<span>Export { EXPORT_TYPES[exportType].text } for address </span>
<Address>
<AddressIcon address={{ hash: addressHash, is_contract: true, implementation_name: null }}/>
<AddressLink hash={ addressHash } type="address" ml={ 2 } truncation={ isMobile ? 'constant' : 'dynamic' }/>
</Address>
<span> to CSV file</span>
</Flex>
{ content }
</Page>
);
};
export default CsvExport;
......@@ -2,6 +2,7 @@ import { Flex } from '@chakra-ui/react';
import { useRouter } from 'next/router';
import React from 'react';
import { useAppContext } from 'lib/appContext';
import useIsMobile from 'lib/hooks/useIsMobile';
import Address from 'ui/shared/address/Address';
import AddressIcon from 'ui/shared/address/AddressIcon';
......@@ -13,12 +14,18 @@ import Sol2UmlDiagram from 'ui/sol2uml/Sol2UmlDiagram';
const Sol2Uml = () => {
const router = useRouter();
const isMobile = useIsMobile();
const appProps = useAppContext();
const addressHash = router.query.address?.toString() || '';
const hasGoBackLink = appProps.referrer && appProps.referrer.includes('/address');
return (
<Page>
<PageTitle text="Solidity UML diagram"/>
<PageTitle
text="Solidity UML diagram"
backLinkUrl={ hasGoBackLink ? appProps.referrer : undefined }
backLinkLabel="Back to address"
/>
<Flex mb={ 10 }>
<span>For contract</span>
<Address ml={ 3 }>
......
......@@ -4042,6 +4042,13 @@
dependencies:
"@types/react" "*"
"@types/react-google-recaptcha@^2.1.5":
version "2.1.5"
resolved "https://registry.yarnpkg.com/@types/react-google-recaptcha/-/react-google-recaptcha-2.1.5.tgz#af157dc2e4bde3355f9b815a64f90e85cfa9df8b"
integrity sha512-iWTjmVttlNgp0teyh7eBXqNOQzVq2RWNiFROWjraOptRnb1OcHJehQnji0sjqIRAk9K0z8stjyhU+OLpPb0N6w==
dependencies:
"@types/react" "*"
"@types/react-scroll@^1.8.4":
version "1.8.4"
resolved "https://registry.yarnpkg.com/@types/react-scroll/-/react-scroll-1.8.4.tgz#2b6258fb34104d3fcc7a22e8eceaadc669ba3ad1"
......@@ -7618,7 +7625,7 @@ hmac-drbg@^1.0.1:
minimalistic-assert "^1.0.0"
minimalistic-crypto-utils "^1.0.1"
hoist-non-react-statics@^3.3.1, hoist-non-react-statics@^3.3.2:
hoist-non-react-statics@^3.3.0, hoist-non-react-statics@^3.3.1, hoist-non-react-statics@^3.3.2:
version "3.3.2"
resolved "https://registry.yarnpkg.com/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz#ece0acaf71d62c2969c2ec59feff42a4b1a85b45"
integrity sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==
......@@ -9630,7 +9637,7 @@ prompts@^2.0.1:
kleur "^3.0.3"
sisteransi "^1.0.5"
prop-types@^15.6.0, prop-types@^15.6.2, prop-types@^15.7.2, prop-types@^15.8.1:
prop-types@^15.5.0, prop-types@^15.6.0, prop-types@^15.6.2, prop-types@^15.7.2, prop-types@^15.8.1:
version "15.8.1"
resolved "https://registry.yarnpkg.com/prop-types/-/prop-types-15.8.1.tgz#67d87bf1a694f48435cf332c24af10214a3140b5"
integrity sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==
......@@ -9754,6 +9761,14 @@ react-ace@^10.1.0:
lodash.isequal "^4.5.0"
prop-types "^15.7.2"
react-async-script@^1.1.1:
version "1.2.0"
resolved "https://registry.yarnpkg.com/react-async-script/-/react-async-script-1.2.0.tgz#ab9412a26f0b83f5e2e00de1d2befc9400834b21"
integrity sha512-bCpkbm9JiAuMGhkqoAiC0lLkb40DJ0HOEJIku+9JDjxX3Rcs+ztEOG13wbrOskt3n2DTrjshhaQ/iay+SnGg5Q==
dependencies:
hoist-non-react-statics "^3.3.0"
prop-types "^15.5.0"
react-clientside-effect@^1.2.6:
version "1.2.6"
resolved "https://registry.yarnpkg.com/react-clientside-effect/-/react-clientside-effect-1.2.6.tgz#29f9b14e944a376b03fb650eed2a754dd128ea3a"
......@@ -9786,6 +9801,14 @@ react-focus-lock@^2.9.1:
use-callback-ref "^1.3.0"
use-sidecar "^1.1.2"
react-google-recaptcha@^2.1.0:
version "2.1.0"
resolved "https://registry.yarnpkg.com/react-google-recaptcha/-/react-google-recaptcha-2.1.0.tgz#9f6f4954ce49c1dedabc2c532347321d892d0a16"
integrity sha512-K9jr7e0CWFigi8KxC3WPvNqZZ47df2RrMAta6KmRoE4RUi7Ys6NmNjytpXpg4HI/svmQJLKR+PncEPaNJ98DqQ==
dependencies:
prop-types "^15.5.0"
react-async-script "^1.1.1"
react-hook-form@^7.33.1:
version "7.37.0"
resolved "https://registry.yarnpkg.com/react-hook-form/-/react-hook-form-7.37.0.tgz#4d1738f092d3d8a3ade34ee892d97350b1032b19"
......
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