diff --git a/package.json b/package.json index 5da3d7e20..897904740 100644 --- a/package.json +++ b/package.json @@ -61,6 +61,7 @@ "@noble/hashes": "^1.3.2", "@rainbow-me/rainbowkit": "2.1.2", "@sentry/nextjs": "7.43.x", + "@splidejs/react-splide": "^0.7.12", "@svgr/webpack": "^8.1.0", "@tanstack/query-persist-client-core": "5.22.2", "@tanstack/query-sync-storage-persister": "5.22.2", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1e8c72c86..094aaeed1 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -56,6 +56,9 @@ importers: '@sentry/nextjs': specifier: 7.43.x version: 7.43.0(encoding@0.1.13)(next@13.5.6(@babel/core@7.24.6)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1)(webpack@5.91.0(esbuild@0.17.19)) + '@splidejs/react-splide': + specifier: ^0.7.12 + version: 0.7.12 '@svgr/webpack': specifier: ^8.1.0 version: 8.1.0(typescript@5.4.5) @@ -293,7 +296,7 @@ importers: version: 0.3.9 eslint-plugin-import: specifier: ^2.28.1 - version: 2.29.1(@typescript-eslint/parser@6.21.0(eslint@8.50.0)(typescript@5.4.5))(eslint-import-resolver-typescript@3.6.1(@typescript-eslint/parser@6.21.0(eslint@8.50.0)(typescript@5.4.5))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.29.1(@typescript-eslint/parser@6.21.0(eslint@8.50.0)(typescript@5.4.5))(eslint@8.50.0))(eslint@8.50.0))(eslint@8.50.0) + version: 2.29.1(@typescript-eslint/parser@6.21.0(eslint@8.50.0)(typescript@5.4.5))(eslint-import-resolver-typescript@3.6.1)(eslint@8.50.0) eslint-plugin-jsx-a11y: specifier: ^6.7.1 version: 6.8.0(eslint@8.50.0) @@ -2947,6 +2950,12 @@ packages: '@socket.io/component-emitter@3.1.2': resolution: {integrity: sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA==} + '@splidejs/react-splide@0.7.12': + resolution: {integrity: sha512-UfXH+j47jsMc4x5HA/aOwuuHPqn6y9+ZTNYPWDRD8iLKvIVMZlzq2unjUEvyDAU+TTVPZOXkG2Ojeoz0P4AkZw==} + + '@splidejs/splide@4.1.4': + resolution: {integrity: sha512-5I30evTJcAJQXt6vJ26g2xEkG+l1nXcpEw4xpKh0/FWQ8ozmAeTbtniVtVmz2sH1Es3vgfC4SS8B2X4o5JMptA==} + '@stablelib/aead@1.0.1': resolution: {integrity: sha512-q39ik6sxGHewqtO0nP4BuSe3db5G1fEJE8ukvngS2gLkBXyy6E7pLubhbYgnkDFv6V8cWaxcE4Xn0t6LWcJkyg==} @@ -13629,6 +13638,12 @@ snapshots: '@socket.io/component-emitter@3.1.2': {} + '@splidejs/react-splide@0.7.12': + dependencies: + '@splidejs/splide': 4.1.4 + + '@splidejs/splide@4.1.4': {} + '@stablelib/aead@1.0.1': {} '@stablelib/binary@1.0.1': @@ -16637,7 +16652,7 @@ snapshots: dependencies: confusing-browser-globals: 1.0.11 eslint: 8.50.0 - eslint-plugin-import: 2.29.1(@typescript-eslint/parser@6.21.0(eslint@8.50.0)(typescript@5.4.5))(eslint-import-resolver-typescript@3.6.1(@typescript-eslint/parser@6.21.0(eslint@8.50.0)(typescript@5.4.5))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.29.1(@typescript-eslint/parser@6.21.0(eslint@8.50.0)(typescript@5.4.5))(eslint@8.50.0))(eslint@8.50.0))(eslint@8.50.0) + eslint-plugin-import: 2.29.1(@typescript-eslint/parser@6.21.0(eslint@8.50.0)(typescript@5.4.5))(eslint-import-resolver-typescript@3.6.1)(eslint@8.50.0) object.assign: 4.1.5 object.entries: 1.1.8 semver: 6.3.1 @@ -16648,13 +16663,13 @@ snapshots: '@typescript-eslint/parser': 6.21.0(eslint@8.50.0)(typescript@5.4.5) eslint: 8.50.0 eslint-config-airbnb-base: 15.0.0(eslint-plugin-import@2.29.1(@typescript-eslint/parser@6.21.0(eslint@8.50.0)(typescript@5.4.5))(eslint@8.50.0))(eslint@8.50.0) - eslint-plugin-import: 2.29.1(@typescript-eslint/parser@6.21.0(eslint@8.50.0)(typescript@5.4.5))(eslint-import-resolver-typescript@3.6.1(@typescript-eslint/parser@6.21.0(eslint@8.50.0)(typescript@5.4.5))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.29.1(@typescript-eslint/parser@6.21.0(eslint@8.50.0)(typescript@5.4.5))(eslint@8.50.0))(eslint@8.50.0))(eslint@8.50.0) + eslint-plugin-import: 2.29.1(@typescript-eslint/parser@6.21.0(eslint@8.50.0)(typescript@5.4.5))(eslint-import-resolver-typescript@3.6.1)(eslint@8.50.0) eslint-config-airbnb@19.0.4(eslint-plugin-import@2.29.1(@typescript-eslint/parser@6.21.0(eslint@8.50.0)(typescript@5.4.5))(eslint@8.50.0))(eslint-plugin-jsx-a11y@6.8.0(eslint@8.50.0))(eslint-plugin-react-hooks@4.6.2(eslint@8.50.0))(eslint-plugin-react@7.34.1(eslint@8.50.0))(eslint@8.50.0): dependencies: eslint: 8.50.0 eslint-config-airbnb-base: 15.0.0(eslint-plugin-import@2.29.1(@typescript-eslint/parser@6.21.0(eslint@8.50.0)(typescript@5.4.5))(eslint@8.50.0))(eslint@8.50.0) - eslint-plugin-import: 2.29.1(@typescript-eslint/parser@6.21.0(eslint@8.50.0)(typescript@5.4.5))(eslint-import-resolver-typescript@3.6.1(@typescript-eslint/parser@6.21.0(eslint@8.50.0)(typescript@5.4.5))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.29.1(@typescript-eslint/parser@6.21.0(eslint@8.50.0)(typescript@5.4.5))(eslint@8.50.0))(eslint@8.50.0))(eslint@8.50.0) + eslint-plugin-import: 2.29.1(@typescript-eslint/parser@6.21.0(eslint@8.50.0)(typescript@5.4.5))(eslint-import-resolver-typescript@3.6.1)(eslint@8.50.0) eslint-plugin-jsx-a11y: 6.8.0(eslint@8.50.0) eslint-plugin-react: 7.34.1(eslint@8.50.0) eslint-plugin-react-hooks: 4.6.2(eslint@8.50.0) @@ -16668,8 +16683,8 @@ snapshots: '@typescript-eslint/parser': 6.21.0(eslint@8.50.0)(typescript@5.4.5) eslint: 8.50.0 eslint-import-resolver-node: 0.3.9 - eslint-import-resolver-typescript: 3.6.1(@typescript-eslint/parser@6.21.0(eslint@8.50.0)(typescript@5.4.5))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.29.1(@typescript-eslint/parser@6.21.0(eslint@8.50.0)(typescript@5.4.5))(eslint@8.50.0))(eslint@8.50.0) - eslint-plugin-import: 2.29.1(@typescript-eslint/parser@6.21.0(eslint@8.50.0)(typescript@5.4.5))(eslint-import-resolver-typescript@3.6.1(@typescript-eslint/parser@6.21.0(eslint@8.50.0)(typescript@5.4.5))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.29.1(@typescript-eslint/parser@6.21.0(eslint@8.50.0)(typescript@5.4.5))(eslint@8.50.0))(eslint@8.50.0))(eslint@8.50.0) + eslint-import-resolver-typescript: 3.6.1(@typescript-eslint/parser@6.21.0(eslint@8.50.0)(typescript@5.4.5))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.29.1)(eslint@8.50.0) + eslint-plugin-import: 2.29.1(@typescript-eslint/parser@6.21.0(eslint@8.50.0)(typescript@5.4.5))(eslint-import-resolver-typescript@3.6.1)(eslint@8.50.0) eslint-plugin-jsx-a11y: 6.8.0(eslint@8.50.0) eslint-plugin-react: 7.34.1(eslint@8.50.0) eslint-plugin-react-hooks: 4.6.2(eslint@8.50.0) @@ -16691,13 +16706,13 @@ snapshots: transitivePeerDependencies: - supports-color - eslint-import-resolver-typescript@3.6.1(@typescript-eslint/parser@6.21.0(eslint@8.50.0)(typescript@5.4.5))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.29.1(@typescript-eslint/parser@6.21.0(eslint@8.50.0)(typescript@5.4.5))(eslint@8.50.0))(eslint@8.50.0): + eslint-import-resolver-typescript@3.6.1(@typescript-eslint/parser@6.21.0(eslint@8.50.0)(typescript@5.4.5))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.29.1)(eslint@8.50.0): dependencies: debug: 4.3.4(supports-color@5.5.0) enhanced-resolve: 5.16.1 eslint: 8.50.0 - eslint-module-utils: 2.8.1(@typescript-eslint/parser@6.21.0(eslint@8.50.0)(typescript@5.4.5))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.1(@typescript-eslint/parser@6.21.0(eslint@8.50.0)(typescript@5.4.5))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.29.1(@typescript-eslint/parser@6.21.0(eslint@8.50.0)(typescript@5.4.5))(eslint@8.50.0))(eslint@8.50.0))(eslint@8.50.0) - eslint-plugin-import: 2.29.1(@typescript-eslint/parser@6.21.0(eslint@8.50.0)(typescript@5.4.5))(eslint-import-resolver-typescript@3.6.1(@typescript-eslint/parser@6.21.0(eslint@8.50.0)(typescript@5.4.5))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.29.1(@typescript-eslint/parser@6.21.0(eslint@8.50.0)(typescript@5.4.5))(eslint@8.50.0))(eslint@8.50.0))(eslint@8.50.0) + eslint-module-utils: 2.8.1(@typescript-eslint/parser@6.21.0(eslint@8.50.0)(typescript@5.4.5))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.1(@typescript-eslint/parser@6.21.0(eslint@8.50.0)(typescript@5.4.5))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.29.1)(eslint@8.50.0))(eslint@8.50.0) + eslint-plugin-import: 2.29.1(@typescript-eslint/parser@6.21.0(eslint@8.50.0)(typescript@5.4.5))(eslint-import-resolver-typescript@3.6.1)(eslint@8.50.0) fast-glob: 3.3.2 get-tsconfig: 4.7.5 is-core-module: 2.13.1 @@ -16708,18 +16723,18 @@ snapshots: - eslint-import-resolver-webpack - supports-color - eslint-module-utils@2.8.1(@typescript-eslint/parser@6.21.0(eslint@8.50.0)(typescript@5.4.5))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.1(@typescript-eslint/parser@6.21.0(eslint@8.50.0)(typescript@5.4.5))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.29.1(@typescript-eslint/parser@6.21.0(eslint@8.50.0)(typescript@5.4.5))(eslint@8.50.0))(eslint@8.50.0))(eslint@8.50.0): + eslint-module-utils@2.8.1(@typescript-eslint/parser@6.21.0(eslint@8.50.0)(typescript@5.4.5))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.1(@typescript-eslint/parser@6.21.0(eslint@8.50.0)(typescript@5.4.5))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.29.1)(eslint@8.50.0))(eslint@8.50.0): dependencies: debug: 3.2.7 optionalDependencies: '@typescript-eslint/parser': 6.21.0(eslint@8.50.0)(typescript@5.4.5) eslint: 8.50.0 eslint-import-resolver-node: 0.3.9 - eslint-import-resolver-typescript: 3.6.1(@typescript-eslint/parser@6.21.0(eslint@8.50.0)(typescript@5.4.5))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.29.1(@typescript-eslint/parser@6.21.0(eslint@8.50.0)(typescript@5.4.5))(eslint@8.50.0))(eslint@8.50.0) + eslint-import-resolver-typescript: 3.6.1(@typescript-eslint/parser@6.21.0(eslint@8.50.0)(typescript@5.4.5))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.29.1)(eslint@8.50.0) transitivePeerDependencies: - supports-color - eslint-plugin-import@2.29.1(@typescript-eslint/parser@6.21.0(eslint@8.50.0)(typescript@5.4.5))(eslint-import-resolver-typescript@3.6.1(@typescript-eslint/parser@6.21.0(eslint@8.50.0)(typescript@5.4.5))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.29.1(@typescript-eslint/parser@6.21.0(eslint@8.50.0)(typescript@5.4.5))(eslint@8.50.0))(eslint@8.50.0))(eslint@8.50.0): + eslint-plugin-import@2.29.1(@typescript-eslint/parser@6.21.0(eslint@8.50.0)(typescript@5.4.5))(eslint-import-resolver-typescript@3.6.1)(eslint@8.50.0): dependencies: array-includes: 3.1.8 array.prototype.findlastindex: 1.2.5 @@ -16729,7 +16744,7 @@ snapshots: doctrine: 2.1.0 eslint: 8.50.0 eslint-import-resolver-node: 0.3.9 - eslint-module-utils: 2.8.1(@typescript-eslint/parser@6.21.0(eslint@8.50.0)(typescript@5.4.5))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.1(@typescript-eslint/parser@6.21.0(eslint@8.50.0)(typescript@5.4.5))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.29.1(@typescript-eslint/parser@6.21.0(eslint@8.50.0)(typescript@5.4.5))(eslint@8.50.0))(eslint@8.50.0))(eslint@8.50.0) + eslint-module-utils: 2.8.1(@typescript-eslint/parser@6.21.0(eslint@8.50.0)(typescript@5.4.5))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.1(@typescript-eslint/parser@6.21.0(eslint@8.50.0)(typescript@5.4.5))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.29.1)(eslint@8.50.0))(eslint@8.50.0) hasown: 2.0.2 is-core-module: 2.13.1 is-glob: 4.0.3 diff --git a/public/confetti.png b/public/confetti.png new file mode 100644 index 000000000..eab8f092f Binary files /dev/null and b/public/confetti.png differ diff --git a/src/assets/DAO.svg b/src/assets/DAO.svg new file mode 100644 index 000000000..2d33444de --- /dev/null +++ b/src/assets/DAO.svg @@ -0,0 +1,10 @@ + + + + + + + + + + \ No newline at end of file diff --git a/src/assets/social/SocialX.svg b/src/assets/social/SocialX.svg index 6a7b7dfe7..134d9024f 100644 --- a/src/assets/social/SocialX.svg +++ b/src/assets/social/SocialX.svg @@ -1,3 +1,3 @@ - + diff --git a/src/components/@atoms/Calendar/Calendar.tsx b/src/components/@atoms/Calendar/Calendar.tsx index 37b7db57c..d012e4fdf 100644 --- a/src/components/@atoms/Calendar/Calendar.tsx +++ b/src/components/@atoms/Calendar/Calendar.tsx @@ -4,7 +4,7 @@ import styled, { css } from 'styled-components' import CalendarSVG from '@app/assets/Calendar.svg' import { useDefaultRef } from '@app/hooks/useDefaultRef' import { useBreakpoint } from '@app/utils/BreakpointProvider' -import { secondsToDate, secondsToDateInput } from '@app/utils/date' +import { dateToDateInput, secondsToDate, secondsToDateInput } from '@app/utils/date' import { formatExpiry } from '@app/utils/utils' const Label = styled.label<{ $highlighted?: boolean }>( @@ -66,10 +66,10 @@ const LabelInput = styled.input( type InputProps = InputHTMLAttributes type Props = { highlighted?: boolean - value: number + value: number | Date unit?: string name?: string - min?: number + min?: number | Date } & Omit export const Calendar = forwardRef( @@ -78,8 +78,8 @@ export const Calendar = forwardRef( ref: ForwardedRef, ) => { const inputRef = useDefaultRef(ref) - const [minDuratiion] = useState(min ?? value) - const minDate = secondsToDate(minDuratiion) + const [minDuration] = useState(min ?? value) + const minDate = typeof minDuration === 'number' ? secondsToDate(minDuration) : minDuration const breakpoint = useBreakpoint() @@ -91,8 +91,12 @@ export const Calendar = forwardRef( type="date" {...props} ref={inputRef} - value={secondsToDateInput(value)} - min={secondsToDateInput(minDuratiion)} + value={typeof value === 'number' ? secondsToDateInput(value) : dateToDateInput(value)} + min={ + typeof minDuration === 'number' + ? secondsToDateInput(minDuration) + : dateToDateInput(minDuration) + } onFocus={(e) => { e.target.select() }} @@ -117,7 +121,9 @@ export const Calendar = forwardRef( onClick={() => inputRef.current!.showPicker()} /> - {formatExpiry(secondsToDate(value), { short: !breakpoint.sm })} + {formatExpiry(typeof value === 'number' ? secondsToDate(value) : value, { + short: !breakpoint.sm, + })} diff --git a/src/components/pages/migrate/Carousel.tsx b/src/components/pages/migrate/Carousel.tsx new file mode 100644 index 000000000..26fc3ac64 --- /dev/null +++ b/src/components/pages/migrate/Carousel.tsx @@ -0,0 +1,21 @@ +import { Splide, SplideSlide } from '@splidejs/react-splide' +import { ReactNode } from 'react' + +export const Carousel = ({ children }: { children: ReactNode[] }) => { + return ( + + {children.map((child, i) => ( + // eslint-disable-next-line react/no-array-index-key + {child} + ))} + + ) +} diff --git a/src/components/pages/migrate/EligibleForTokens.tsx b/src/components/pages/migrate/EligibleForTokens.tsx new file mode 100644 index 000000000..6af934334 --- /dev/null +++ b/src/components/pages/migrate/EligibleForTokens.tsx @@ -0,0 +1,79 @@ +import Link from 'next/link' +import { useTranslation } from 'react-i18next' +import styled, { css } from 'styled-components' + +import { InfoCircleSVG, OutlinkSVG, Typography } from '@ensdomains/thorin' + +import { REBATE_DATE } from '@app/utils/constants' + +const EligibleForTokensContainer = styled.div( + ({ theme }) => css` + padding: ${theme.space['4']}; + gap: ${theme.space['2']}; + display: flex; + flex-direction: column; + align-items: flex-start; + justify-content: center; + width: ${theme.space.full}; + border-radius: ${theme.radii['2xLarge']}; + background: ${theme.colors.greenSurface}; + + a { + display: flex; + flex-direction: row; + align-items: center; + gap: ${theme.space['2']}; + color: ${theme.colors.greenPrimary}; + } + `, +) + +const InelegibleForTokensContainer = styled.div( + ({ theme }) => css` + display: flex; + flex-direction: row; + background: ${theme.colors.greenSurface}; + padding: ${theme.space['4']}; + gap: ${theme.space['4']}; + border-radius: ${theme.radii.large}; + align-items: center; + width: 100%; + svg { + color: ${theme.colors.greenDim}; + } + `, +) + +export const EligibleForTokens = ({ + amount, + extendedDate, +}: { + amount: number + extendedDate?: Date +}) => { + const { t } = useTranslation('common') + + if (!extendedDate) return null + + if (extendedDate < REBATE_DATE) { + return ( + + + names expiring in 2031 smth smth + + ) + } + + return ( + + Eligible for {amount} $ENS + something something + + + {t('action.learnMore')} + + + + + ) +} diff --git a/src/components/pages/migrate/MigrationNamesList.tsx b/src/components/pages/migrate/MigrationNamesList.tsx new file mode 100644 index 000000000..331476fb6 --- /dev/null +++ b/src/components/pages/migrate/MigrationNamesList.tsx @@ -0,0 +1,250 @@ +/* eslint-disable @next/next/no-img-element */ +import { ReactNode } from 'react' +import { TFunction, useTranslation } from 'react-i18next' +import styled, { css } from 'styled-components' +import { Address } from 'viem' +import { useAccount, useEnsAvatar } from 'wagmi' + +import { NameWithRelation } from '@ensdomains/ensjs/subgraph' +import { Button, CheckCircleSVG, Colors, DisabledSVG, PlusCircleSVG, Tag } from '@ensdomains/thorin' + +import { useTransactionFlow } from '@app/transaction-flow/TransactionFlowProvider' +import { ensAvatarConfig } from '@app/utils/query/ipfsGateway' +import { formatDurationOfDates } from '@app/utils/utils' + +type Tab = 'eligible' | 'ineligible' | 'approved' | 'claimed' + +const icons: Record = { + eligible: , + ineligible: , + approved: , + claimed: , +} + +const colors: Record = { + eligible: { + fg: 'bluePrimary', + bg: 'blueSurface', + hover: 'blueLight', + }, + ineligible: { + fg: 'redPrimary', + bg: 'redSurface', + hover: 'redLight', + }, + approved: { + fg: 'greenPrimary', + bg: 'greenSurface', + hover: 'greenLight', + }, + claimed: { + fg: 'greenPrimary', + bg: 'greenSurface', + hover: 'greenLight', + }, +} + +const Container = styled.div( + ({ theme }) => css` + display: flex; + flex-direction: column; + gap: ${theme.space['2']}; + `, +) + +const TabsContainer = styled.div( + ({ theme }) => css` + display: flex; + flex-direction: row; + gap: ${theme.space['4']}; + padding: ${theme.space['0.5']}; + border-radius: ${theme.radii['2xLarge']}; + background-color: ${theme.colors.background}; + border: 4px solid ${theme.colors.background}; + `, +) + +const TabButton = styled.button<{ $isActive?: boolean; tab: NameListTab }>( + ({ theme, $isActive, tab }) => css` + width: ${theme.space.full}; + padding: 0 ${theme.space['4']}; + height: ${theme.space['12']}; + border-radius: ${theme.radii.large}; + color: ${$isActive ? theme.colors[colors[tab].fg] : theme.colors.textTertiary}; + background-color: ${$isActive ? theme.colors[colors[tab].bg] : theme.colors.background}; + cursor: pointer; + display: flex; + flex-direction: row; + align-items: center; + justify-content: center; + font-weight: ${theme.fontWeights.bold}; + gap: ${theme.space['2']}; + + transition-property: background-color; + transition-duration: ${theme.transitionDuration['150']}; + transition-timing-function: ${theme.transitionTimingFunction.inOut}; + + &:hover { + background-color: ${$isActive + ? theme.colors[colors[tab].hover] + : theme.colors[colors[tab].bg]}; + } + `, +) + +const NamesGrid = styled.div( + ({ theme }) => css` + display: grid; + grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); + gap: ${theme.space['2']}; + `, +) + +const NameCard = styled.div( + ({ theme }) => css` + background: ${theme.colors.background}; + border-radius: ${theme.radii['2xLarge']}; + padding: ${theme.space['8']}; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + text-align: center; + + & > span:nth-of-type(1) { + font-weight: ${theme.fontWeights.bold}; + } + & > span:nth-of-type(2) { + color: ${theme.colors.textTertiary}; + font-size: ${theme.fontSizes.small}; + } + & > span[data-owner='not-owner'] { + color: ${theme.colors.redPrimary}; + font-size: ${theme.fontSizes.small}; + margin-top: ${theme.space['2']}; + } + & > button { + margin-top: ${theme.space['6']}; + } + + & > img { + border-radius: ${theme.radii.full}; + } + `, +) + +const nameListTabs = ['eligible', 'ineligible', 'approved', 'claimed'] as const + +export type NameListTab = (typeof nameListTabs)[number] + +const TagListContainer = styled.div( + ({ theme }) => css` + display: flex; + flex-direction: row; + gap: ${theme.space['2']}; + margin-top: ${theme.space['2']}; + `, +) + +const TagList = ({ name, address }: { name: NameWithRelation; address: Address }) => { + const tags: ReactNode[] = [] + if (name.registrant === address || name.wrappedOwner === address) tags.push(Owner) + if (name.owner === address) tags.push(Manager) + return {tags.map((tag) => tag)} +} + +const ExtendableNameButton = ({ name, t }: { name: NameWithRelation; t: TFunction }) => { + const { usePreparedDataInput } = useTransactionFlow() + const showExtendNamesInput = usePreparedDataInput('ExtendNames') + + return ( + + ) +} + +const MigrationName = ({ + name, + t, + address, + mode, +}: { + name: NameWithRelation + t: TFunction + address?: Address + mode: 'migration' | 'extension' +}) => { + const now = new Date() + const { data: avatar } = useEnsAvatar({ ...ensAvatarConfig, name: name.name! }) + + const expiresIn = formatDurationOfDates({ + startDate: now, + endDate: new Date(name.expiryDate?.date!), + t, + }).split(', ')[0] + + if (name.registrant === address || name.wrappedOwner === address) { + return ( + + {avatar && } + {name.truncatedName} + Expires in {expiresIn} + {mode === 'extension' ? ( + <> + + + + ) : null} + + ) + } + return ( + + {avatar && } + {name.truncatedName} + Expires in {expiresIn} + Not owner + + ) +} + +export const MigrationNamesList = ({ + activeTab, + setTab, + names, + tabs, + mode, +}: { + activeTab: T + names: NameWithRelation[] + setTab: (tab: T) => void + tabs: T[] + mode: 'migration' | 'extension' +}) => { + const { t } = useTranslation(['migrate', 'common']) + const { address } = useAccount() + + if (!tabs.length) return null + + return ( + + + {tabs.map((tab) => ( + setTab(tab)}> + {icons[tab]} {t(`migration-list.${tab}`)} + + ))} + + + {names.map((name) => ( + + ))} + + + ) +} diff --git a/src/components/pages/migrate/MigrationSection.tsx b/src/components/pages/migrate/MigrationSection.tsx new file mode 100644 index 000000000..3762c9bdb --- /dev/null +++ b/src/components/pages/migrate/MigrationSection.tsx @@ -0,0 +1,27 @@ +import styled, { css } from 'styled-components' + +export const MigrationSection = styled.div( + ({ theme }) => css` + display: flex; + flex-direction: column; + gap: ${theme.space['6']}; + h3 { + text-align: center; + } + & > div { + display: grid; + grid-template-columns: repeat(1, 1fr); + gap: ${theme.space['4']}; + } + & > div > div { + width: 100%; + display: flex; + align-items: center; + } + @media (min-width: 480px) { + & > div { + grid-template-columns: repeat(2, 1fr); + } + } + `, +) diff --git a/src/components/pages/migrate/MigrationTab.tsx b/src/components/pages/migrate/MigrationTab.tsx new file mode 100644 index 000000000..c05132e18 --- /dev/null +++ b/src/components/pages/migrate/MigrationTab.tsx @@ -0,0 +1,676 @@ +import { useConnectModal } from '@rainbow-me/rainbowkit' +import Head from 'next/head' +import { useState } from 'react' +import { TFunction } from 'react-i18next' +import styled, { css } from 'styled-components' +import { match } from 'ts-pattern' +import { Address } from 'viem' +import { useAccount } from 'wagmi' + +import { NameWithRelation } from '@ensdomains/ensjs/subgraph' +import { + Banner, + Button, + Card, + FastForwardSVG, + GasPumpSVG, + KeySVG, + RightArrowSVG, + Typography, + UpCircleSVG, + WalletSVG, +} from '@ensdomains/thorin' + +import { Carousel } from '@app/components/pages/migrate/Carousel' +import { useNamesForAddress } from '@app/hooks/ensjs/subgraph/useNamesForAddress' +import { useApprovedNamesForMigration } from '@app/hooks/migration/useApprovedNamesForMigration' +import { makeIntroItem } from '@app/transaction-flow/intro' +import { createTransactionItem, TransactionData } from '@app/transaction-flow/transaction' +import { useTransactionFlow } from '@app/transaction-flow/TransactionFlowProvider' +import { REBATE_DATE } from '@app/utils/constants' + +import { MigrationNamesList, NameListTab } from './MigrationNamesList' +import { MigrationSection } from './MigrationSection' + +const Header = styled.header<{ $isLanding?: boolean }>( + ({ theme, $isLanding }) => css` + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: ${theme.space['4']}; + gap: ${theme.space['4']}; + min-height: ${$isLanding ? '530px' : 'unset'}; + `, +) + +const ButtonContainer = styled.div( + ({ theme }) => css` + display: flex; + gap: ${theme.space['2']}; + flex-direction: column; + + @media (min-width: 360px) { + flex-direction: row; + } + `, +) + +const Caption = styled(Typography)` + text-align: center; + max-width: 538px; +` + +const ContainerWithCenteredButton = styled.div( + ({ theme }) => css` + button span { + display: flex; + flex-direction: row; + align-items: center; + gap: ${theme.space['2']}; + } + `, +) + +const CenteredCard = styled(Card)` + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; +` + +const CardWithEmoji = styled(CenteredCard)` + padding-top: 83px; + position: relative; + grid-column: 1 / -1; + + & > img { + position: absolute; + top: -72px; + } +` + +const GridOneToThree = styled.div( + ({ theme }) => css` + display: grid; + grid-template-rows: auto; + gap: ${theme.space['4']}; + text-align: center; + grid-template-columns: 1fr; + + @media (min-width: 640px) { + grid-template-columns: repeat(3, 1fr); + } + `, +) + +const Heading = styled.h1<{ $fontSize?: number }>( + ({ theme, $fontSize = 52 }) => css` + font-size: ${$fontSize}px; + font-weight: 850; + color: ${theme.colors.textPrimary}; + text-align: center; + line-height: 104%; + + @media (min-width: 480px) { + font-size: 60px; + } + @media (min-width: 640px) { + font-size: 76px; + } + `, +) + +const GradientText = styled.span` + font-weight: inherit; + font-size: inherit; + line-height: inherit; + background: linear-gradient(90deg, #199c75 2.87%, #9b9ba7 97.95%); + background-clip: text; + -webkit-text-fill-color: transparent; +` + +const CardHeader = styled.h3( + ({ theme }) => css` + display: flex; + flex-direction: column; + font-size: ${theme.fontSizes.extraLarge}; + color: ${theme.colors.greenDim}; + font-weight: ${theme.fontWeights.bold}; + gap: ${theme.space['2']}; + align-items: center; + `, +) + +const AnnouncementSlide = ({ title, text }: { title: string; text: string }) => ( + + {text} + +) + +const SlideshowContainer = styled.div( + ({ theme }) => css` + display: flex; + flex-direction: column; + gap: ${theme.space['6']}; + h3 { + text-align: center; + } + `, +) + +const AllNamesAreApprovedBanner = styled.div( + ({ theme }) => css` + display: flex; + flex-direction: column; + flex: 1 0 0; + align-items: center; + border-radius: ${theme.radii['4xLarge']}; + gap: ${theme.space['2']}; + padding: ${theme.space['4']} ${theme.space['6']}; + background: ${theme.colors.greenSurface}; + border: 1px solid ${theme.colors.greenPrimary}; + `, +) + +const RebatesMigrationSection = styled(MigrationSection)` + & > div > div:nth-child(3) { + grid-column: 1 / -1; + grid-row: 2; + } +` + +export const migrationTabs = ['ensv2', 'migrations', 'extension'] as const + +export type MigrationTabType = (typeof migrationTabs)[number] + +const LandingTab = ({ + t, + isConnected, + openConnectModal, + setTab, + allNamesAreApproved, + eligibleToApprove, +}: { + t: TFunction + isConnected: boolean + openConnectModal?: () => void + setTab: (tab: MigrationTabType) => void + allNamesAreApproved: boolean + eligibleToApprove: boolean +}) => { + return ( + <> +
+ + {t('title.landing')} + + {t('title.landing')} + {/** @ts-expect-error styled-components don't know how to inherit types */} + {t('caption.ensv2')} + + + + +
+ + + 🎉 + + {t('accessible.title')} + + {t('accessible.caption')} + + + + + + {t('accessible.gas.title')} + + {t('accessible.gas.text')} + + + + + {t('accessible.control.title')} + + {t('accessible.control.text')} + + + + + {t('accessible.multichain.title')} + + {t('accessible.multichain.text')} + + + + + {t('get-started.title')} + + + + + + {t('get-started.upgrade.title')} + + {t('get-started.upgrade.caption')} + + + + + + {t('get-started.extend.title')} + + {t('get-started.extend.caption')} + + + + + + + {t('announcement.title')} + + + + + + + + + ) +} + +type MigrationHelperTab = Exclude + +const filterNamesForMigration = (names: NameWithRelation[]) => { + const eligibleNames: NameWithRelation[] = [] + const inelegibleNames: NameWithRelation[] = [] + + for (const name of names.filter( + ({ expiryDate }) => expiryDate?.date && expiryDate?.date > new Date(), + )) { + if (name.relation.wrappedOwner || name.relation.registrant) { + eligibleNames.push(name) + } else { + inelegibleNames.push(name) + } + } + return { eligibleNames, inelegibleNames } +} + +const filterTabs = ({ + approvedNames = [], + eligibleNames = [], + inelegibleNames = [], + claimedNames = [], +}: Partial<{ + approvedNames: NameWithRelation[] + eligibleNames: NameWithRelation[] + inelegibleNames: NameWithRelation[] + claimedNames: NameWithRelation[] +}>) => { + const tabs: T[] = [] + + if (eligibleNames.length) { + tabs.push('eligible' as T) + } + + if (inelegibleNames.length) { + tabs.push('ineligible' as T) + } + + if (approvedNames.length) { + tabs.push('approved' as T) + } + + if (claimedNames.length) { + tabs.push('claimed' as T) + } + + return tabs +} + +const MigrationsTab = ({ + t, + isConnected, + openConnectModal, + address, + setTab, + eligibleNames, + inelegibleNames, + approvedNames, + allNamesAreApproved, +}: { + t: TFunction + isConnected: boolean + openConnectModal?: () => void + address?: Address + setTab: (tab: MigrationTabType) => void + eligibleNames: NameWithRelation[] + inelegibleNames: NameWithRelation[] + approvedNames: NameWithRelation[] + allNamesAreApproved: boolean +}) => { + const tabs = filterTabs({ approvedNames, eligibleNames, inelegibleNames }) + + const { createTransactionFlow } = useTransactionFlow() + const [activeNameListTab, setNameListTab] = useState(tabs[0]) + + const names: Record = { + eligible: eligibleNames, + ineligible: inelegibleNames, + approved: approvedNames, + } + + return ( + <> +
+ + {t('title.migration')} + + {t('title.migration')} + {/** @ts-expect-error styled-components don't know how to inherit types */} + {t('caption.migration')} + + {match({ isConnected, allNamesAreApproved }) + .with({ isConnected: true, allNamesAreApproved: false }, () => ( + + )) + .with({ isConnected: true, allNamesAreApproved: true }, () => null) + .with({ isConnected: false }, () => ( + + )) + .exhaustive()} + + + +
+ {allNamesAreApproved ? ( + {t('banner.all-approved')} + ) : null} + {isConnected ? ( + + ) : null} + + + {t('approval-benefits.title')} + +
+ + + + {t('approval-benefits.migration.title')} + + {t('approval-benefits.migration.caption')} + + + + + {t('approval-benefits.no-gas-cost.title')} + + {t('approval-benefits.no-gas-cost.caption')} + + + + + {t('approval-benefits.claim-rebates.title')} + + {t('approval-benefits.claim-rebates.caption')} + + + + +
+
+ + ) +} + +type ExtensionTabType = Exclude + +const ExtensionTab = ({ + t, + isConnected, + address, + allNamesAreApproved, + setTab, +}: { + t: TFunction + isConnected: boolean + address?: Address + allNamesAreApproved: boolean + setTab: (tab: MigrationTabType) => void +}) => { + const { infiniteData } = useNamesForAddress({ + address, + pageSize: 20, + filter: { owner: false, wrappedOwner: true, registrant: true, resolvedAddress: false }, + enabled: allNamesAreApproved, + }) + + const allNames = infiniteData.filter((name) => name.parentName === 'eth' && name.expiryDate) + + const [activeTab, setNameListTab] = useState('eligible') + + const claimedNames = allNames.filter( + (name) => name.expiryDate && name.expiryDate.date > REBATE_DATE, + ) + + const eligibleNames = allNames.filter((name) => !claimedNames.includes(name)) + + const { openConnectModal } = useConnectModal() + + const names: Record = { + claimed: claimedNames, + eligible: eligibleNames, + } + + const tabs = filterTabs({ claimedNames, eligibleNames }) + + const { usePreparedDataInput } = useTransactionFlow() + const showExtendNamesInput = usePreparedDataInput('BulkRenewal') + + return ( + <> +
+ + + {t('title.extension.part1')} {t('title.extension.part2')} + + + + {t('title.extension.part1')} {t('title.extension.part2')} + + bloop + + {match({ isConnected, allNamesAreApproved }) + .with({ isConnected: true, allNamesAreApproved: true }, () => ( + + )) + .with({ isConnected: true, allNamesAreApproved: false }, () => ( + + )) + .with({ isConnected: false }, () => ( + + )) + .exhaustive()} + + +
+ {isConnected ? ( + + ) : null} + + ) +} + +export const MigrationTab = ({ + tab, + setTab, + t, +}: { + tab: MigrationTabType + setTab: (tab: MigrationTabType) => void + t: TFunction +}) => { + const { isConnected, address } = useAccount() + const { openConnectModal } = useConnectModal() + + const { infiniteData } = useNamesForAddress({ + address, + pageSize: 100, + filter: { registrant: true, owner: true, resolvedAddress: true, wrappedOwner: true }, + }) + + const names = infiniteData.filter((name) => name.parentName === 'eth') + + const { eligibleNames: initialEligibleNames, inelegibleNames } = filterNamesForMigration(names) + + const approvedNames = useApprovedNamesForMigration({ + names: initialEligibleNames, + owner: address, + }) + + const eligibleNames = initialEligibleNames.filter((name) => !approvedNames.includes(name)) + + const allNamesAreApproved = approvedNames.length !== 0 && approvedNames.length === names.length + + const eligibleToApprove = eligibleNames.length !== 0 + + return match(tab) + .with('ensv2', () => ( + + )) + .with('migrations', () => ( + + )) + .with('extension', () => ( + + )) + .exhaustive() +} diff --git a/src/hooks/migration/useApprovedNamesForMigration.ts b/src/hooks/migration/useApprovedNamesForMigration.ts new file mode 100644 index 000000000..9540428ab --- /dev/null +++ b/src/hooks/migration/useApprovedNamesForMigration.ts @@ -0,0 +1,32 @@ +import { Address, erc721Abi } from 'viem' +import { usePublicClient, useReadContracts } from 'wagmi' + +import { getChainContractAddress } from '@ensdomains/ensjs/contracts' +import { NameWithRelation } from '@ensdomains/ensjs/subgraph' + +import { migrationHelperContract } from '@app/migration/migrationHelper' + +export const useApprovedNamesForMigration = ({ + names, + owner, +}: { + names: NameWithRelation[] + owner?: Address +}): NameWithRelation[] => { + const client = usePublicClient() + + const { data } = useReadContracts({ + contracts: names.map((name) => ({ + address: getChainContractAddress({ + client, + contract: name.wrappedOwner ? 'ensNameWrapper' : 'ensBaseRegistrarImplementation', + }), + abi: erc721Abi, + functionName: 'isApprovedForAll', + args: [owner, migrationHelperContract[client.chain.id]], + })), + multicallAddress: getChainContractAddress({ client, contract: 'multicall3' }), + }) + + return names.filter((d, i) => Boolean(data?.[i].result)) +} diff --git a/src/migration/migrationHelper.ts b/src/migration/migrationHelper.ts new file mode 100644 index 000000000..4d62f698b --- /dev/null +++ b/src/migration/migrationHelper.ts @@ -0,0 +1,9 @@ +import { goerli, holesky, localhost, mainnet, sepolia } from 'viem/chains' + +export const migrationHelperContract = { + [mainnet.id]: '0xnot-deployed-yet', + [localhost.id]: '0xnot-deployed-yet', + [goerli.id]: '0xwillnotdeploy', + [holesky.id]: '0x76aafA281Ed5155f83926a12ACB92e237e322A8C', + [sepolia.id]: '0xf9c8c83adda8d52d9284cdbef23da10b5f9869bf', +} as const diff --git a/src/pages/_app.tsx b/src/pages/_app.tsx index 3c5d9dfb9..294ed4587 100644 --- a/src/pages/_app.tsx +++ b/src/pages/_app.tsx @@ -1,6 +1,7 @@ import { lightTheme, RainbowKitProvider, Theme } from '@rainbow-me/rainbowkit' import '@rainbow-me/rainbowkit/styles.css' +import '@splidejs/react-splide/css' import { NextPage } from 'next' import type { AppProps } from 'next/app' diff --git a/src/pages/migrate.tsx b/src/pages/migrate.tsx new file mode 100644 index 000000000..ad75e278c --- /dev/null +++ b/src/pages/migrate.tsx @@ -0,0 +1,159 @@ +/* eslint-disable @next/next/no-img-element */ +import { useTranslation } from 'react-i18next' +import styled, { css } from 'styled-components' + +import { + Button, + Card, + InfoCircleSVG, + QuestionBubbleSVG, + QuestionCircleSVG, + RightArrowSVG, + SpannerAltSVG, + Typography, +} from '@ensdomains/thorin' + +import { MigrationSection } from '@app/components/pages/migrate/MigrationSection' +import { + MigrationTab, + migrationTabs, + MigrationTabType, +} from '@app/components/pages/migrate/MigrationTab' +import { useQueryParameterState } from '@app/hooks/useQueryParameterState' + +import DAOSVG from '../assets/DAO.svg' +import SocialX from '../assets/social/SocialX.svg' + +const Main = styled.main( + ({ theme }) => css` + display: flex; + flex-direction: column; + gap: ${theme.space['16']}; + `, +) + +const PartnershipAnnouncement = styled.div( + ({ theme }) => css` + width: ${theme.space.full}; + padding: ${theme.space['4']}; + background-color: ${theme.colors.backgroundPrimary}; + border-radius: ${theme.radii['4xLarge']}; + font-size: ${theme.fontSizes.body}; + font-weight: ${theme.fontWeights.bold}; + display: flex; + justify-content: space-between; + & > a { + color: ${theme.colors.greenDim}; + cursor: pointer; + } + & > a:hover { + color: ${theme.colors.green}; + } + @media (min-width: 640px) { + border-radius: ${theme.radii['3xLarge']}; + } + `, +) + +const TopNav = styled.div( + ({ theme }) => css` + display: flex; + flex-direction: column; + gap: ${theme.space['6']}; + align-items: center; + `, +) + +const TabManager = styled.div( + ({ theme }) => css` + display: flex; + flex-direction: row; + gap: ${theme.space['4']}; + + overflow-x: auto; + width: ${theme.space.full}; + + @media (min-width: 480px) { + width: auto; + overflow-x: none; + } + `, +) + +const Footer = styled(MigrationSection)( + ({ theme }) => css` + span { + display: flex; + flex-direction: row; + align-items: center; + gap: ${theme.space['2']}; + color: ${theme.colors.green}; + } + `, +) + +export default function Page() { + const { t } = useTranslation('migrate') + + const [currentTab, setTab] = useQueryParameterState('tab', 'ensv2') + + return ( + <> +
+ + + {t('partnership.text')} + + {t('partnership.watch')} + + + + {migrationTabs.map((tab) => ( + + ))} + + + + + +
+ + {t('footer.title')} + +
+ + + {t('footer.learn.faq')} + + + {t('footer.learn.plan')} + + + {t('footer.learn.base')} + + + + + {t('footer.support.ticket')} + + + {t('footer.support.twitter')} + + + {t('footer.support.dao')} + + +
+
+
+ + ) +} diff --git a/src/transaction-flow/input/BulkRenewal/BulkRenewal-flow.tsx b/src/transaction-flow/input/BulkRenewal/BulkRenewal-flow.tsx new file mode 100644 index 000000000..e330142ad --- /dev/null +++ b/src/transaction-flow/input/BulkRenewal/BulkRenewal-flow.tsx @@ -0,0 +1,251 @@ +import { useState } from 'react' +import { useTranslation } from 'react-i18next' +import styled, { css } from 'styled-components' +import { useAccount, useBalance, useClient, useEstimateGas, useReadContract } from 'wagmi' + +import { NameWithRelation } from '@ensdomains/ensjs/subgraph' +import { Dialog, Heading, Helper, OutlinkSVG, Typography } from '@ensdomains/thorin' + +import { CacheableComponent } from '@app/components/@atoms/CacheableComponent' +import { Calendar } from '@app/components/@atoms/Calendar/Calendar' +import { Invoice, InvoiceItem } from '@app/components/@atoms/Invoice/Invoice' +import { PlusMinusControl } from '@app/components/@atoms/PlusMinusControl/PlusMinusControl' +import { EligibleForTokens } from '@app/components/pages/migrate/EligibleForTokens' +import { useEstimateGasWithStateOverride } from '@app/hooks/chain/useEstimateGasWithStateOverride' +import { createTransactionItem } from '@app/transaction-flow/transaction' +import { bulkRenewalContract } from '@app/transaction-flow/transaction/bulkRenew' +import { CURRENCY_FLUCTUATION_BUFFER_PERCENTAGE, REBATE_DATE } from '@app/utils/constants' +import useUserConfig from '@app/utils/useUserConfig' +import { formatDurationOfDates } from '@app/utils/utils' + +export type Props = { data: { names: NameWithRelation[] } } + +const abi = [ + { + inputs: [ + { + internalType: 'string[]', + name: 'names', + type: 'string[]', + }, + { + internalType: 'uint256', + name: 'targetExpiry', + type: 'uint256', + }, + ], + name: 'getTargetExpiryPriceData', + outputs: [ + { + internalType: 'uint256', + name: 'total', + type: 'uint256', + }, + { + internalType: 'uint256[]', + name: 'durations', + type: 'uint256[]', + }, + { + internalType: 'uint256[]', + name: 'prices', + type: 'uint256[]', + }, + ], + stateMutability: 'view', + type: 'function', + }, + { + inputs: [ + { + internalType: 'string', + name: 'name', + type: 'string', + }, + ], + name: 'NameAvailable', + type: 'error', + }, + { + inputs: [ + { + internalType: 'string', + name: 'name', + type: 'string', + }, + ], + name: 'NameBeyondWantedExpiryDate', + type: 'error', + }, + { + inputs: [ + { + internalType: 'string', + name: 'name', + type: 'string', + }, + ], + name: 'NameMismatchedPrice', + type: 'error', + }, +] as const + +const CalendarContainer = styled.div( + ({ theme }) => css` + display: flex; + flex-direction: column; + gap: ${theme.space['2']}; + align-items: center; + width: ${theme.space.full}; + `, +) + +const YearsViewSwitch = styled.button( + ({ theme }) => css` + color: ${theme.colors.bluePrimary}; + cursor: pointer; + font-size: ${theme.fontSizes.small}; + font-weight: ${theme.fontWeights.bold}; + `, +) + +const GasEstimationCacheableComponent = styled(CacheableComponent)( + ({ theme }) => css` + width: 100%; + gap: ${theme.space['4']}; + display: flex; + flex-direction: column; + `, +) + +const BulkRenewalFlow = ({ data }: Props) => { + const [date, setDate] = useState(REBATE_DATE) + const [durationType, setDurationType] = useState<'years' | 'date'>('date') + + const client = useClient() + + const { address } = useAccount() + + const { data: balance } = useBalance({ + address, + }) + + const dateAsBigInt = BigInt(date.getTime() / 1000) + + const { + data: expiryData, + error, + status, + } = useReadContract({ + abi, + address: bulkRenewalContract[client.chain.id!]!, + functionName: 'getTargetExpiryPriceData', + args: [data.names.map((name) => name.labelName!), dateAsBigInt], + }) + + const [total, durations, prices] = expiryData! as [bigint, bigint[], bigint[]] + + const { + data: { gasEstimate: estimatedGasLimit, gasCost: transactionFee }, + error: estimateGasLimitError, + isLoading: isEstimateGasLoading, + gasPrice, + } = useEstimateGasWithStateOverride({ + transactions: [ + { + name: 'bulkRenew', + data: { + names: data.names.map((name) => name.labelhash), + durations, + prices, + }, + stateOverride: [{ address: address! }], + }, + ], + }) + + const { t } = useTranslation(['transactionFlow', 'common']) + + const now = new Date() + + const { userConfig, setCurrency } = useUserConfig() + const currencyDisplay = userConfig.currency === 'fiat' ? userConfig.fiat : 'eth' + + const items: InvoiceItem[] = [ + { + label: t('input.extendNames.invoice.extension', { + time: formatDurationOfDates({ startDate: now, endDate: date, t }), + }), + value: dateAsBigInt, + bufferPercentage: CURRENCY_FLUCTUATION_BUFFER_PERCENTAGE, + }, + { + label: t('input.extendNames.invoice.transaction'), + value: transactionFee, + bufferPercentage: CURRENCY_FLUCTUATION_BUFFER_PERCENTAGE, + }, + ] + + return ( + <> + + + + {durationType === 'date' ? ( + { + const { valueAsDate } = e.currentTarget + if (valueAsDate) { + setDate(valueAsDate) + } + }} + highlighted + min={REBATE_DATE} + /> + ) : ( + { + const newYears = parseInt(e.target.value) + + if (!Number.isNaN(newYears)) + setDate(new Date(now.getFullYear() + newYears, date.getMonth(), date.getDate())) + }} + /> + )} + + {formatDurationOfDates({ + startDate: now, + endDate: date, + postFix: ' extension. ', + t, + })} + setDurationType(durationType === 'years' ? 'date' : 'years')} + > + {t(`calendar.pick_by_${durationType === 'date' ? 'years' : 'date'}`, { + ns: 'common', + })} + + + + + {error ? {error.message} : null} + + + {(!!estimateGasLimitError || + (!!estimatedGasLimit && !!balance?.value && balance.value < estimatedGasLimit)) && ( + {t('input.extendNames.gasLimitError')} + )} + + + + ) +} + +export default BulkRenewalFlow diff --git a/src/transaction-flow/input/ExtendNames/ExtendNames-flow.tsx b/src/transaction-flow/input/ExtendNames/ExtendNames-flow.tsx index 50343fdf2..43eab41b4 100644 --- a/src/transaction-flow/input/ExtendNames/ExtendNames-flow.tsx +++ b/src/transaction-flow/input/ExtendNames/ExtendNames-flow.tsx @@ -14,6 +14,7 @@ import { Invoice, InvoiceItem } from '@app/components/@atoms/Invoice/Invoice' import { PlusMinusControl } from '@app/components/@atoms/PlusMinusControl/PlusMinusControl' import { StyledName } from '@app/components/@atoms/StyledName/StyledName' import { DateSelection } from '@app/components/@molecules/DateSelection/DateSelection' +import { EligibleForTokens } from '@app/components/pages/migrate/EligibleForTokens' import { useEstimateGasWithStateOverride } from '@app/hooks/chain/useEstimateGasWithStateOverride' import { useExpiry } from '@app/hooks/ensjs/public/useExpiry' import { usePrice } from '@app/hooks/ensjs/public/usePrice' @@ -366,6 +367,7 @@ const ExtendNames = ({ data: { names, isSelf }, dispatch, onDismiss }: Props) => {t('input.extendNames.gasLimitError')} )} + ))} diff --git a/src/transaction-flow/input/index.tsx b/src/transaction-flow/input/index.tsx index 33ce04860..a3cb9df03 100644 --- a/src/transaction-flow/input/index.tsx +++ b/src/transaction-flow/input/index.tsx @@ -5,6 +5,7 @@ import DynamicLoadingContext from '@app/components/@molecules/TransactionDialogM import TransactionLoader from '../TransactionLoader' import type { Props as AdvancedEditorProps } from './AdvancedEditor/AdvancedEditor-flow' +import type { Props as BulkRenewalProps } from './BulkRenewal/BulkRenewal-flow' import type { Props as CreateSubnameProps } from './CreateSubname-flow' import type { Props as DeleteEmancipatedSubnameWarningProps } from './DeleteEmancipatedSubnameWarning/DeleteEmancipatedSubnameWarning-flow' import type { Props as DeleteSubnameNotParentWarningProps } from './DeleteSubnameNotParentWarning/DeleteSubnameNotParentWarning-flow' @@ -69,6 +70,8 @@ const SyncManager = dynamicHelper('SyncManager/SyncManager') const UnknownLabels = dynamicHelper('UnknownLabels/UnknownLabels') const VerifyProfile = dynamicHelper('VerifyProfile/VerifyProfile') +const BulkRenewal = dynamicHelper('BulkRenewal/BulkRenewal') + export const DataInputComponents = { AdvancedEditor, CreateSubname, @@ -86,6 +89,7 @@ export const DataInputComponents = { SyncManager, UnknownLabels, VerifyProfile, + BulkRenewal, } export type DataInputName = keyof typeof DataInputComponents diff --git a/src/transaction-flow/transaction/approveNameWrapperForMigration.ts b/src/transaction-flow/transaction/approveNameWrapperForMigration.ts new file mode 100644 index 000000000..fb2b6e582 --- /dev/null +++ b/src/transaction-flow/transaction/approveNameWrapperForMigration.ts @@ -0,0 +1,47 @@ +import type { TFunction } from 'react-i18next' +import { Address, encodeFunctionData } from 'viem' + +import { + getChainContractAddress, + registrySetApprovalForAllSnippet, +} from '@ensdomains/ensjs/contracts' + +import { migrationHelperContract } from '@app/migration/migrationHelper' +import { Transaction, TransactionDisplayItem, TransactionFunctionParameters } from '@app/types' + +type Data = { address: Address } + +const displayItems = ( + { address }: Data, + t: TFunction<'translation', undefined>, +): TransactionDisplayItem[] => [ + { + label: 'address', + value: address, + type: 'address', + }, + { + label: 'action', + value: t('transaction.description.approveNameWrapper'), + }, + { + label: 'info', + value: t('transaction.info.approveNameWrapper'), + }, +] + +const transaction = async ({ client }: TransactionFunctionParameters) => { + return { + to: getChainContractAddress({ + client, + contract: 'ensNameWrapper', + }), + data: encodeFunctionData({ + abi: registrySetApprovalForAllSnippet, + functionName: 'setApprovalForAll', + args: [migrationHelperContract[client.chain.id], true], + }), + } +} + +export default { displayItems, transaction } satisfies Transaction diff --git a/src/transaction-flow/transaction/approveRegistrarForMigration.ts b/src/transaction-flow/transaction/approveRegistrarForMigration.ts new file mode 100644 index 000000000..a1ddf3626 --- /dev/null +++ b/src/transaction-flow/transaction/approveRegistrarForMigration.ts @@ -0,0 +1,48 @@ +import { TFunction } from 'react-i18next' +import { Address } from 'viem/accounts' +import { encodeFunctionData } from 'viem/utils' + +import { + getChainContractAddress, + registrySetApprovalForAllSnippet, +} from '@ensdomains/ensjs/contracts' + +import { migrationHelperContract } from '@app/migration/migrationHelper' +import type { Transaction, TransactionDisplayItem, TransactionFunctionParameters } from '@app/types' + +type Data = { address: Address } + +const displayItems = ( + { address }: Data, + t: TFunction<'translation', undefined>, +): TransactionDisplayItem[] => [ + { + label: 'address', + value: address, + type: 'address', + }, + { + label: 'action', + value: t('transaction.description.approveEthRegistrar'), + }, + { + label: 'info', + value: t('transaction.info.approveEthRegistrar'), + }, +] + +const transaction = async ({ client }: TransactionFunctionParameters) => { + return { + to: getChainContractAddress({ + client, + contract: 'ensBaseRegistrarImplementation', + }), + data: encodeFunctionData({ + abi: registrySetApprovalForAllSnippet, + functionName: 'setApprovalForAll', + args: [migrationHelperContract[client.chain.id], true], + }), + } +} + +export default { displayItems, transaction } satisfies Transaction diff --git a/src/transaction-flow/transaction/bulkRenew.ts b/src/transaction-flow/transaction/bulkRenew.ts new file mode 100644 index 000000000..46d0bbdd0 --- /dev/null +++ b/src/transaction-flow/transaction/bulkRenew.ts @@ -0,0 +1,102 @@ +import { TFunction } from 'react-i18next' +import { goerli, holesky, localhost, mainnet, sepolia } from 'viem/chains' +import { encodeFunctionData } from 'viem/utils' + +import type { Transaction, TransactionDisplayItem, TransactionFunctionParameters } from '@app/types' + +type Data = { names: `0x${string}`[]; durations: bigint[]; prices: bigint[] } + +export const targetExpiryAbi = [ + { + inputs: [ + { + internalType: 'string[]', + name: 'names', + type: 'string[]', + }, + { + internalType: 'uint256', + name: 'targetExpiry', + type: 'uint256', + }, + ], + name: 'getTargetExpiryPriceData', + outputs: [ + { + internalType: 'uint256', + name: 'total', + type: 'uint256', + }, + { + internalType: 'uint256[]', + name: 'durations', + type: 'uint256[]', + }, + { + internalType: 'uint256[]', + name: 'prices', + type: 'uint256[]', + }, + ], + stateMutability: 'view', + type: 'function', + }, + { + inputs: [ + { + internalType: 'string[]', + name: 'names', + type: 'string[]', + }, + { + internalType: 'uint256[]', + name: 'durations', + type: 'uint256[]', + }, + { + internalType: 'uint256[]', + name: 'prices', + type: 'uint256[]', + }, + ], + name: 'renewAllWithTargetExpiry', + outputs: [], + stateMutability: 'payable', + type: 'function', + }, +] as const + +export const bulkRenewalContract = { + [mainnet.id]: '0xnotdeployedyet', + [goerli.id]: '0xdeprecated', + [localhost.id]: '0xnotdeployedyet', + [holesky.id]: '0x3dCE478E4C880E96Ad3BF022acae38bef43F13eB', + [sepolia.id]: '0x0E714019e4BC65164d29960805259C1fA70E508a', +} as const + +const displayItems = ( + { names }: Data, + t: TFunction<'translation', undefined>, +): TransactionDisplayItem[] => [ + { + label: 'names', + value: names.map((name) => `0x${name}`).join(', '), + }, + { + label: 'action', + value: t('transaction.info.fuses.CAN_EXTEND_EXPIRY'), + }, +] + +const transaction = async ({ client, data }: TransactionFunctionParameters) => { + return { + to: bulkRenewalContract[client.chain.id], + data: encodeFunctionData({ + abi: targetExpiryAbi, + functionName: 'renewAllWithTargetExpiry', + args: [data.names, data.durations, data.prices], + }), + } +} + +export default { displayItems, transaction } satisfies Transaction diff --git a/src/transaction-flow/transaction/index.ts b/src/transaction-flow/transaction/index.ts index 55211a5e5..6a9dda0ac 100644 --- a/src/transaction-flow/transaction/index.ts +++ b/src/transaction-flow/transaction/index.ts @@ -1,5 +1,8 @@ import approveDnsRegistrar from './approveDnsRegistrar' import approveNameWrapper from './approveNameWrapper' +import approveNameWrapperForMigration from './approveNameWrapperForMigration' +import approveRegistrarForMigration from './approveRegistrarForMigration' +import bulkRenew from './bulkRenew' import burnFuses from './burnFuses' import changePermissions from './changePermissions' import claimDnsName from './claimDnsName' @@ -31,8 +34,11 @@ import wrapName from './wrapName' export const transactions = { approveDnsRegistrar, + approveNameWrapperForMigration, + approveRegistrarForMigration, approveNameWrapper, burnFuses, + bulkRenew, changePermissions, claimDnsName, commitName, diff --git a/src/utils/constants.ts b/src/utils/constants.ts index 994abc021..1c5dd45ba 100644 --- a/src/utils/constants.ts +++ b/src/utils/constants.ts @@ -26,3 +26,5 @@ export const IS_DEV_ENVIRONMENT = process.env.NEXT_PUBLIC_PROVIDER export const INVALID_NAME = '[Invalid ENS Name]' + +export const REBATE_DATE = new Date(2030, 11, 31, 0, 0)