├── .yarnrc.yml ├── src ├── app │ ├── favicon.ico │ ├── [lang] │ │ ├── components │ │ │ ├── NavBar │ │ │ │ ├── index.ts │ │ │ │ ├── NodeSelector │ │ │ │ │ ├── index.ts │ │ │ │ │ └── NodeMenuItem.tsx │ │ │ │ ├── LanguageSelector │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── LanguageMenuItem.tsx │ │ │ │ │ ├── LanguageSelector.test.tsx │ │ │ │ │ └── LanguageSelector.tsx │ │ │ │ ├── Settings │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── ConnectWalletConnected.test.tsx │ │ │ │ │ └── SettingsDialog.tsx │ │ │ │ ├── NavBar.tsx │ │ │ │ └── NavBar.test.tsx │ │ │ ├── PageTitleHeading │ │ │ │ ├── index.ts │ │ │ │ ├── PageTitleHeading.tsx │ │ │ │ ├── TxnPresetBadge.tsx │ │ │ │ └── PageTitleHeading.test.tsx │ │ │ ├── wallet │ │ │ │ └── index.ts │ │ │ ├── JotaiProvider.tsx │ │ │ ├── PageLoadingPlaceholder.tsx │ │ │ ├── DialogLoadingPlaceholder.tsx │ │ │ ├── form │ │ │ │ ├── FieldErrorMessage.tsx │ │ │ │ ├── index.ts │ │ │ │ ├── FieldTip.test.tsx │ │ │ │ ├── RadioButtonGroupField.tsx │ │ │ │ ├── FieldGroup.tsx │ │ │ │ ├── FieldGroup.test.tsx │ │ │ │ ├── FileField.tsx │ │ │ │ └── FieldTip.tsx │ │ │ ├── ToastProvider.tsx │ │ │ ├── index.ts │ │ │ ├── ToastViewport.tsx │ │ │ ├── ThemeChanger.tsx │ │ │ ├── BuilderSteps.tsx │ │ │ ├── ToastNotification.tsx │ │ │ └── Footer.tsx │ │ ├── txn │ │ │ ├── compose │ │ │ │ ├── components │ │ │ │ │ ├── fields │ │ │ │ │ │ ├── PaymentFields │ │ │ │ │ │ │ ├── index.ts │ │ │ │ │ │ │ ├── Amount.tsx │ │ │ │ │ │ │ └── Receiver.tsx │ │ │ │ │ │ ├── AssetFreezeFields │ │ │ │ │ │ │ ├── index.ts │ │ │ │ │ │ │ ├── Freeze.tsx │ │ │ │ │ │ │ └── TargetAddr.tsx │ │ │ │ │ │ ├── AssetTransferFields │ │ │ │ │ │ │ ├── index.ts │ │ │ │ │ │ │ ├── Amount.tsx │ │ │ │ │ │ │ ├── Receiver.tsx │ │ │ │ │ │ │ └── ClawbackTarget.tsx │ │ │ │ │ │ ├── AppCallFields │ │ │ │ │ │ │ ├── index.ts │ │ │ │ │ │ │ ├── OnComplete.tsx │ │ │ │ │ │ │ ├── AppProperties.tsx │ │ │ │ │ │ │ └── ExtraPages.tsx │ │ │ │ │ │ ├── GeneralFields │ │ │ │ │ │ │ ├── index.ts │ │ │ │ │ │ │ ├── LastValid.tsx │ │ │ │ │ │ │ ├── TxnType.tsx │ │ │ │ │ │ │ ├── ValidRounds.tsx │ │ │ │ │ │ │ └── FirstValid.tsx │ │ │ │ │ │ ├── LoadingPlaceholders │ │ │ │ │ │ │ ├── FullWidthField.tsx │ │ │ │ │ │ │ ├── LargeField.tsx │ │ │ │ │ │ │ ├── SmallField.tsx │ │ │ │ │ │ │ ├── ExtraSmallField.tsx │ │ │ │ │ │ │ ├── LargeAreaField.tsx │ │ │ │ │ │ │ ├── SwitchField.tsx │ │ │ │ │ │ │ ├── index.ts │ │ │ │ │ │ │ └── ArrayFieldGroup.tsx │ │ │ │ │ │ ├── KeyRegFields │ │ │ │ │ │ │ ├── index.ts │ │ │ │ │ │ │ ├── Nonparticipation.tsx │ │ │ │ │ │ │ └── SelectionKey.tsx │ │ │ │ │ │ └── AssetConfigFields │ │ │ │ │ │ │ ├── index.ts │ │ │ │ │ │ │ ├── DefaultFrozen.tsx │ │ │ │ │ │ │ ├── UnitName.tsx │ │ │ │ │ │ │ ├── AssetName.tsx │ │ │ │ │ │ │ └── URL.tsx │ │ │ │ │ └── wallet │ │ │ │ │ │ ├── WalletFieldWidget.tsx │ │ │ │ │ │ └── WalletConnected.test.tsx │ │ │ │ ├── page.test.tsx │ │ │ │ └── page.tsx │ │ │ ├── page.test.tsx │ │ │ ├── sign │ │ │ │ ├── components │ │ │ │ │ └── NextStepButton.tsx │ │ │ │ └── page.tsx │ │ │ ├── send │ │ │ │ └── page.tsx │ │ │ └── page.tsx │ │ ├── [...catchAll] │ │ │ └── page.ts │ │ ├── not-found.tsx │ │ ├── group │ │ │ ├── page.test.tsx │ │ │ ├── compose │ │ │ │ ├── page.tsx │ │ │ │ ├── page.test.tsx │ │ │ │ └── components │ │ │ │ │ └── GrpComposeList.tsx │ │ │ └── page.tsx │ │ ├── NotFoundBody.tsx │ │ └── privacy-policy │ │ │ └── page.test.tsx │ ├── apple-icon.png │ ├── lib │ │ ├── testing │ │ │ ├── test_compiled.teal │ │ │ ├── test_signed.txn.msgpack │ │ │ ├── test_unsigned.txn.msgpack │ │ │ ├── textcoderPolyfill.js │ │ │ ├── i18nextClientMock.ts │ │ │ └── duck_poem.txt │ │ ├── txn-data │ │ │ ├── index.ts │ │ │ ├── validation-rules.ts │ │ │ └── data-utils.ts │ │ ├── validation-set-locale.ts │ │ ├── wallet-utils.ts │ │ └── fonts.ts │ ├── i18n │ │ ├── locales │ │ │ ├── en │ │ │ │ ├── grp_presets.yml │ │ │ │ ├── grp_compose.yml │ │ │ │ ├── send_txn.yml │ │ │ │ ├── home.yml │ │ │ │ ├── common.yml │ │ │ │ └── privacy_policy.yml │ │ │ └── es │ │ │ │ ├── grp_presets.yml │ │ │ │ ├── grp_compose.yml │ │ │ │ ├── send_txn.yml │ │ │ │ ├── common.yml │ │ │ │ └── home.yml │ │ └── settings.ts │ ├── robots.ts │ ├── layout.tsx │ └── icon.svg └── e2e │ ├── shared │ ├── index.ts │ └── NavBarComponent.ts │ ├── pageModels │ ├── index.ts │ ├── SendTxnPage.ts │ ├── SignTxnPage.ts │ ├── TxnPresetsPage.ts │ ├── NotFoundPage.ts │ ├── ComposeTxnPage.ts │ ├── GroupComposePage.ts │ ├── PrivacyPolicyPage.ts │ └── HomePage.ts │ ├── send_txn.spec.ts │ ├── group_compose.spec.ts │ ├── privacy_policy.spec.ts │ ├── not_found.spec.ts │ └── txn_presets.spec.ts ├── public ├── assets │ ├── icon-192.png │ ├── icon-512.png │ ├── mstile-150x150.png │ ├── icon-192-maskable.png │ ├── icon-512-maskable.png │ └── silhouette-icon.svg ├── index.html └── browserconfig.xml ├── postcss.config.js ├── .swcrc ├── .gitattributes ├── docs └── README.md ├── compose.yml ├── lefthook.yml ├── .editorconfig ├── .env.local.example ├── .env.development ├── .env.production ├── tsconfig.json ├── .env.test ├── .gitignore ├── .dockerignore ├── jest.config.mjs ├── LICENSE.md ├── .github ├── workflows │ ├── release_standalone.yml │ └── release_container_image.yml ├── ISSUE_TEMPLATE │ ├── feature_request.yml │ └── bug_report.yml ├── PULL_REQUEST_TEMPLATE.md └── SECURITY.md ├── eslint.config.mjs ├── next.config.ts ├── playwright.config.js └── .env /.yarnrc.yml: -------------------------------------------------------------------------------- 1 | nodeLinker: node-modules 2 | -------------------------------------------------------------------------------- /src/app/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/No-Cash-7970/txnDuck/HEAD/src/app/favicon.ico -------------------------------------------------------------------------------- /src/app/[lang]/components/NavBar/index.ts: -------------------------------------------------------------------------------- 1 | import NavBar from './NavBar'; 2 | export default NavBar; 3 | -------------------------------------------------------------------------------- /src/app/apple-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/No-Cash-7970/txnDuck/HEAD/src/app/apple-icon.png -------------------------------------------------------------------------------- /public/assets/icon-192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/No-Cash-7970/txnDuck/HEAD/public/assets/icon-192.png -------------------------------------------------------------------------------- /public/assets/icon-512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/No-Cash-7970/txnDuck/HEAD/public/assets/icon-512.png -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | '@tailwindcss/postcss': {}, 4 | }, 5 | }; 6 | -------------------------------------------------------------------------------- /src/app/[lang]/components/NavBar/NodeSelector/index.ts: -------------------------------------------------------------------------------- 1 | export { default as NodeSelector } from './NodeSelector'; 2 | -------------------------------------------------------------------------------- /public/assets/mstile-150x150.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/No-Cash-7970/txnDuck/HEAD/public/assets/mstile-150x150.png -------------------------------------------------------------------------------- /public/assets/icon-192-maskable.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/No-Cash-7970/txnDuck/HEAD/public/assets/icon-192-maskable.png -------------------------------------------------------------------------------- /public/assets/icon-512-maskable.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/No-Cash-7970/txnDuck/HEAD/public/assets/icon-512-maskable.png -------------------------------------------------------------------------------- /src/app/[lang]/components/NavBar/LanguageSelector/index.ts: -------------------------------------------------------------------------------- 1 | export { default as LanguageSelector } from './LanguageSelector'; 2 | -------------------------------------------------------------------------------- /.swcrc: -------------------------------------------------------------------------------- 1 | { 2 | "jsc": { 3 | "experimental": { 4 | "plugins": [["@swc-jotai/react-refresh", {}]] 5 | } 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /src/app/lib/testing/test_compiled.teal: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/No-Cash-7970/txnDuck/HEAD/src/app/lib/testing/test_compiled.teal -------------------------------------------------------------------------------- /src/app/[lang]/components/PageTitleHeading/index.ts: -------------------------------------------------------------------------------- 1 | import PageTitleHeading from './PageTitleHeading'; 2 | export default PageTitleHeading; 3 | -------------------------------------------------------------------------------- /src/app/lib/testing/test_signed.txn.msgpack: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/No-Cash-7970/txnDuck/HEAD/src/app/lib/testing/test_signed.txn.msgpack -------------------------------------------------------------------------------- /src/app/lib/testing/test_unsigned.txn.msgpack: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/No-Cash-7970/txnDuck/HEAD/src/app/lib/testing/test_unsigned.txn.msgpack -------------------------------------------------------------------------------- /src/app/[lang]/components/NavBar/Settings/index.ts: -------------------------------------------------------------------------------- 1 | import SettingsDialog from './SettingsDialog'; 2 | const Settings = SettingsDialog; 3 | export default Settings; 4 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | # Set the default behavior 2 | * text eol=lf 3 | 4 | # Denote all files that are truly binary and should not be modified. 5 | *.png binary 6 | *.jpeg binary 7 | *.jpg binary 8 | -------------------------------------------------------------------------------- /docs/README.md: -------------------------------------------------------------------------------- 1 | # Extra Documentation 2 | 3 | - [Installation Guide](./installation.md) 4 | - [Developers Documentation](./developers.md) 5 | - [Making Shareable Links](./shareable_links.md) 6 | -------------------------------------------------------------------------------- /src/app/[lang]/txn/compose/components/fields/PaymentFields/index.ts: -------------------------------------------------------------------------------- 1 | export { default as Receiver } from './Receiver'; 2 | export { default as Amount } from './Amount'; 3 | export { default as CloseTo } from './CloseTo'; 4 | -------------------------------------------------------------------------------- /src/app/i18n/locales/en/grp_presets.yml: -------------------------------------------------------------------------------- 1 | title: Choose a Group Preset 2 | instruction: Compose a group faster by using one of the following presets. 3 | skip_btn: Or skip this and compose a group without using a preset 4 | -------------------------------------------------------------------------------- /src/app/[lang]/txn/compose/components/fields/AssetFreezeFields/index.ts: -------------------------------------------------------------------------------- 1 | export { default as AssetId } from './AssetId'; 2 | export { default as TargetAddr } from './TargetAddr'; 3 | export { default as Freeze } from './Freeze'; 4 | -------------------------------------------------------------------------------- /src/e2e/shared/index.ts: -------------------------------------------------------------------------------- 1 | /** @file Utilities, tests, etc. shared across multiple test suites */ 2 | 3 | export * from './LanguageSupport'; 4 | export * from './NavBarComponent'; 5 | export * as NodeTestResp from './AlgodMockResponses'; 6 | -------------------------------------------------------------------------------- /public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /src/app/i18n/locales/es/grp_presets.yml: -------------------------------------------------------------------------------- 1 | title: Eligir una preselección de grupo 2 | instruction: Componga un grupo más rápidamente utilizando uno de las siguientes preselecciones. 3 | skip_btn: O sáltese esto y componga un grupo sin utilizar una preselección 4 | -------------------------------------------------------------------------------- /compose.yml: -------------------------------------------------------------------------------- 1 | name: txnduck 2 | services: 3 | dev: 4 | container_name: txnduck-dev 5 | build: 6 | context: . 7 | target: dev-setup 8 | dockerfile: ./Containerfile 9 | volumes: 10 | - .:/app 11 | ports: 12 | - "3000:3000" 13 | -------------------------------------------------------------------------------- /src/app/robots.ts: -------------------------------------------------------------------------------- 1 | import { MetadataRoute } from 'next'; 2 | 3 | /** Generates a robots.txt file */ 4 | export default function robots(): MetadataRoute.Robots { 5 | return { 6 | rules: { 7 | userAgent: '*', 8 | allow: '/', 9 | }, 10 | }; 11 | } 12 | -------------------------------------------------------------------------------- /lefthook.yml: -------------------------------------------------------------------------------- 1 | pre-commit: 2 | follow: true 3 | commands: 4 | precommit-gulp: 5 | run: yarn gulp precommitHook 6 | exclude: 7 | - "*.md" 8 | commit-msg: 9 | follow: true 10 | commands: 11 | commitlint: 12 | run: yarn commitlint --edit 13 | -------------------------------------------------------------------------------- /src/app/[lang]/components/wallet/index.ts: -------------------------------------------------------------------------------- 1 | export {default as MagicAuthPrompt} from './MagicAuthPrompt'; 2 | export * from './MagicAuthPrompt'; 3 | export {default as WalletDialogContent} from './WalletDialogContent'; 4 | export {default as WalletProvider} from './WalletProvider'; 5 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # https://editorconfig.org 2 | root = true 3 | 4 | [*] 5 | charset = utf-8 6 | end_of_line = lf 7 | indent_size = 2 8 | indent_style = space 9 | insert_final_newline = true 10 | trim_trailing_whitespace = true 11 | 12 | [*.md] 13 | indent_size = 4 14 | trim_trailing_whitespace = false 15 | -------------------------------------------------------------------------------- /public/browserconfig.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | #332d2d 7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /src/e2e/pageModels/index.ts: -------------------------------------------------------------------------------- 1 | export * from './ComposeTxnPage'; 2 | export * from './GroupComposePage'; 3 | export * from './HomePage'; 4 | export * from './NotFoundPage'; 5 | export * from './PrivacyPolicyPage'; 6 | export * from './SendTxnPage'; 7 | export * from './SignTxnPage'; 8 | export * from './TxnPresetsPage'; 9 | -------------------------------------------------------------------------------- /src/app/[lang]/txn/compose/components/fields/AssetTransferFields/index.ts: -------------------------------------------------------------------------------- 1 | export { default as Receiver } from './Receiver'; 2 | export { default as AssetId } from './AssetId'; 3 | export { default as Amount } from './Amount'; 4 | export { default as ClawbackTarget } from './ClawbackTarget'; 5 | export { default as CloseTo } from './CloseTo'; 6 | -------------------------------------------------------------------------------- /src/app/[lang]/txn/compose/components/fields/AppCallFields/index.ts: -------------------------------------------------------------------------------- 1 | export { default as OnComplete } from './OnComplete'; 2 | export { default as AppId } from './AppId'; 3 | export { default as AppArgs } from './AppArgs'; 4 | export { default as AppProperties } from './AppProperties'; 5 | export { default as AppDependencies } from './AppDependencies'; 6 | -------------------------------------------------------------------------------- /src/app/lib/txn-data/index.ts: -------------------------------------------------------------------------------- 1 | export * as txnDataAtoms from './atoms'; 2 | export * from './constants'; 3 | export * from './data-utils'; 4 | export * from './processor'; 5 | export * from './stored'; 6 | export * from './types'; 7 | export * from './field-validation'; 8 | export * from './form-validation'; 9 | export * from './validation-rules'; 10 | -------------------------------------------------------------------------------- /src/app/[lang]/components/JotaiProvider.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { Provider } from 'jotai'; 4 | 5 | /** Wrapper for the Jotai provider to convert it to a client component so it is compatible with 6 | * Next.js server-side rendering (SSR) 7 | */ 8 | export default function JotaiProvider({ children }: { children: React.ReactNode }) { 9 | return {children}; 10 | }; 11 | -------------------------------------------------------------------------------- /src/app/[lang]/txn/compose/components/fields/GeneralFields/index.ts: -------------------------------------------------------------------------------- 1 | export { default as TxnType } from './TxnType'; 2 | export { default as Sender } from './Sender'; 3 | export { default as Fee } from './Fee'; 4 | export { default as Note } from './Note'; 5 | export { default as ValidRounds } from './ValidRounds'; 6 | export { default as Lease } from './Lease'; 7 | export { default as Rekey } from './Rekey'; 8 | -------------------------------------------------------------------------------- /.env.local.example: -------------------------------------------------------------------------------- 1 | # Example of a .env.local file, which overrides the defaults in the other .env 2 | # files. It is ignored by Git, so secrets should be stored in this file. 3 | # Environment variables not specified here inherit the values from the .env file. 4 | 5 | ##### App configuration ##### 6 | 7 | BASE_URL= 8 | NEXT_PUBLIC_DEFAULT_NETWORK=testnet 9 | NEXT_PUBLIC_WC_PROJECT_ID= 10 | NEXT_PUBLIC_MAGIC_API_KEY= 11 | -------------------------------------------------------------------------------- /src/app/[lang]/txn/compose/components/fields/LoadingPlaceholders/FullWidthField.tsx: -------------------------------------------------------------------------------- 1 | /** Placeholder for when a field with a "full" width is loading */ 2 | export default function FullWidthField({ containerClass }: { containerClass?: string }) { 3 | return ( 4 |
5 |
6 |
7 |
8 | ); 9 | } 10 | -------------------------------------------------------------------------------- /src/app/[lang]/txn/compose/components/fields/LoadingPlaceholders/LargeField.tsx: -------------------------------------------------------------------------------- 1 | /** Placeholder for when a field with a "large" width is loading */ 2 | export default function LargeField({ containerClass }: { containerClass?: string }) { 3 | return ( 4 |
5 |
6 |
7 |
8 | ); 9 | } 10 | -------------------------------------------------------------------------------- /src/app/[lang]/txn/compose/components/fields/LoadingPlaceholders/SmallField.tsx: -------------------------------------------------------------------------------- 1 | /** Placeholder for when a field with a "small" width is loading */ 2 | export default function SmallField({ containerClass }: { containerClass?: string }) { 3 | return ( 4 |
5 |
6 |
7 |
8 | ); 9 | } 10 | -------------------------------------------------------------------------------- /src/app/[lang]/txn/compose/components/fields/LoadingPlaceholders/ExtraSmallField.tsx: -------------------------------------------------------------------------------- 1 | /** Placeholder for when a field with an "extra small" width is loading */ 2 | export default function ExtraSmallField({ containerClass }: { containerClass?: string }) { 3 | return ( 4 |
5 |
6 |
7 |
8 | ); 9 | } 10 | -------------------------------------------------------------------------------- /src/app/[lang]/txn/compose/components/fields/LoadingPlaceholders/LargeAreaField.tsx: -------------------------------------------------------------------------------- 1 | /** Placeholder for when an area-type field with a "large" width is loading */ 2 | export default function LargeAreaField({ containerClass }: { containerClass?: string }) { 3 | return ( 4 |
5 |
6 |
7 |
8 | ); 9 | } 10 | -------------------------------------------------------------------------------- /src/app/[lang]/txn/compose/components/fields/LoadingPlaceholders/SwitchField.tsx: -------------------------------------------------------------------------------- 1 | /** Placeholder for when a switch-type field is loading */ 2 | export default function SwitchField({ containerClass }: { containerClass?: string }) { 3 | return ( 4 |
5 |
6 |
7 |
8 | ); 9 | } 10 | -------------------------------------------------------------------------------- /src/app/[lang]/components/PageLoadingPlaceholder.tsx: -------------------------------------------------------------------------------- 1 | /** Placeholder that indicates the page content is loading */ 2 | export default function PageLoadingPlaceholder() { 3 | return ( 4 |
5 | 6 | 7 | 8 |
9 | ); 10 | } 11 | -------------------------------------------------------------------------------- /src/app/[lang]/txn/compose/components/fields/KeyRegFields/index.ts: -------------------------------------------------------------------------------- 1 | export { default as VoteKey } from './VoteKey'; 2 | export { default as SelectionKey } from './SelectionKey'; 3 | export { default as StateProofKey } from './StateProofKey'; 4 | export { default as FirstVoteRound } from './FirstVoteRound'; 5 | export { default as LastVoteRound } from './LastVoteRound'; 6 | export { default as KeyDilution } from './KeyDilution'; 7 | export { default as Nonparticipation } from './Nonparticipation'; 8 | -------------------------------------------------------------------------------- /src/app/[lang]/components/DialogLoadingPlaceholder.tsx: -------------------------------------------------------------------------------- 1 | /** Placeholder that indicates the dialog content is loading */ 2 | export default function DialogLoadingPlaceholder() { 3 | return ( 4 |

5 | 6 | 7 | 8 |

9 | ); 10 | } 11 | -------------------------------------------------------------------------------- /src/app/layout.tsx: -------------------------------------------------------------------------------- 1 | import '@/app/globals.css'; 2 | import type { Metadata } from 'next'; 3 | import { getMetadataBase } from '@/app/lib/utils'; 4 | 5 | /** Generate the base metadata for the site. Parts may be overwritten by child pages. */ 6 | export async function generateMetadata(): Promise { 7 | return { metadataBase: new URL(getMetadataBase()) }; 8 | } 9 | 10 | export default function RootLayout({ children }: { children: React.ReactNode }) { 11 | return children; 12 | }; 13 | -------------------------------------------------------------------------------- /src/app/[lang]/txn/compose/components/fields/LoadingPlaceholders/index.ts: -------------------------------------------------------------------------------- 1 | export { default as ArrayFieldGroup } from './ArrayFieldGroup'; 2 | export { default as ExtraSmallField } from './ExtraSmallField'; 3 | export { default as FullWidthField } from './FullWidthField'; 4 | export { default as LargeAreaField } from './LargeAreaField'; 5 | export { default as LargeField } from './LargeField'; 6 | export { default as SmallField } from './SmallField'; 7 | export { default as SwitchField } from './SwitchField'; 8 | -------------------------------------------------------------------------------- /src/app/[lang]/[...catchAll]/page.ts: -------------------------------------------------------------------------------- 1 | import { notFound } from 'next/navigation'; 2 | 3 | /** For each supported language, make Next JS generate a static page for the language when building 4 | * the project. 5 | * @returns List of languages as parameters 6 | */ 7 | export function generateStaticParams() { 8 | return [{catchAll: ['catch-all']}]; 9 | } 10 | 11 | /** Catch-all route that directs to the 404 Not-Found page */ 12 | export default function CatchAll() { 13 | return notFound(); 14 | } 15 | -------------------------------------------------------------------------------- /src/app/i18n/locales/en/grp_compose.yml: -------------------------------------------------------------------------------- 1 | title: Compose Group 2 | grp_list_no_txn: No transactions 3 | add_slot_btn: Add transaction slot 4 | review_sign_btn: Review & sign group 5 | edit_txn_btn: Edit 6 | edit_txn_btn_title: "Edit transaction #{{index}}" 7 | remove_slot_btn: Remove 8 | remove_slot_btn_title: "Remove transaction #{{index}}" 9 | no_txn_in_slot: "No transaction for slot #{{index}}" 10 | move_slot_up_btn: "Move slot #{{index}} up one slot" 11 | move_slot_down_btn: "Move slot #{{index}} down one slot" 12 | -------------------------------------------------------------------------------- /src/app/[lang]/components/form/FieldErrorMessage.tsx: -------------------------------------------------------------------------------- 1 | import { IconExclamationCircle } from "@tabler/icons-react"; 2 | import { type TFunction } from "i18next"; 3 | 4 | export default function FieldErrorMessage( 5 | { t, i18nkey, dict }: 6 | { t: TFunction, i18nkey: string, dict?: {[k: string]: any} } 7 | ) { 8 | return ( 9 |
10 | 11 | {t(i18nkey, dict) as string} 12 |
13 | ); 14 | } 15 | -------------------------------------------------------------------------------- /src/app/[lang]/txn/compose/components/fields/LoadingPlaceholders/ArrayFieldGroup.tsx: -------------------------------------------------------------------------------- 1 | /** Placeholder for when an array field group is loading */ 2 | export default function ArrayFieldGroup({ containerClass }: { containerClass?: string }) { 3 | return ( 4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 | ); 12 | } 13 | -------------------------------------------------------------------------------- /src/app/i18n/locales/es/grp_compose.yml: -------------------------------------------------------------------------------- 1 | title: Componer el grupo 2 | grp_list_no_txn: No hay transacciones 3 | add_slot_btn: Añadir un espacio para la transacción 4 | review_sign_btn: Revisar y firmar el grupo 5 | edit_txn_btn: Editar 6 | edit_txn_btn_title: "Editar la transacción nº {{index}}" 7 | remove_slot_btn: Eliminar 8 | remove_slot_btn_title: "Eliminar la transacción nº {{index}}" 9 | no_txn_in_slot: "No hay transacción para el espacio nº {{index}}" 10 | move_slot_up_btn: "Mover el espacio nº {{index}} un espacio hasta arriba" 11 | move_slot_down_btn: "Mover el espacio nº {{index}} un espacio hasta abajo" 12 | -------------------------------------------------------------------------------- /src/app/lib/testing/textcoderPolyfill.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @file Sets up the polyfills for TextEncoder and TextDecoder because jest/js-dom does not include 3 | * them. This is for unit tests only. 4 | */ 5 | 6 | /* Polyfill for TextEncoder, TextDecoder and the Uint8Array they use */ 7 | import { TextEncoder, TextDecoder } from 'node:util'; 8 | window.TextEncoder = TextEncoder; 9 | window.TextDecoder = TextDecoder; 10 | // NOTE: For some reason, the Uint8Array class that the polyfills use is different from the actual 11 | // Uint8Array class, so polyfilling Uint8array is necessary too 12 | window.Uint8Array = (new TextEncoder).encode().constructor; 13 | -------------------------------------------------------------------------------- /src/app/lib/txn-data/validation-rules.ts: -------------------------------------------------------------------------------- 1 | /** @file Declaration and initialization of validation rules */ 2 | 3 | import { 4 | number as YupNumber, 5 | string as YupString, 6 | mixed as YupMixed, 7 | } from 'yup'; 8 | import { ADDRESS_LENGTH } from './constants'; 9 | import '@/app/lib/validation-set-locale'; // Run setup for the locales for Yup (`Yup.setLocale()`) 10 | 11 | /** Validation schema for wallet address */ 12 | export const addressSchema = YupString().trim().length(ADDRESS_LENGTH); 13 | /** Validation schemea for asset/application IDs */ 14 | export const idSchema = YupNumber().min(1); 15 | 16 | export {YupMixed, YupNumber, YupString}; 17 | -------------------------------------------------------------------------------- /.env.development: -------------------------------------------------------------------------------- 1 | # Default environment variables for the "development" environment (`next dev`) 2 | # Environment variables not specified here inherit the values from the .env file. 3 | 4 | ##### App configuration ##### 5 | 6 | BASE_URL= 7 | NEXT_PUBLIC_DEFAULT_NETWORK=testnet 8 | NEXT_PUBLIC_WC_PROJECT_ID= 9 | NEXT_PUBLIC_MAGIC_API_KEY= 10 | 11 | ##### Feature flags ##### 12 | 13 | DISABLE_PWA=true 14 | NEXT_PUBLIC_FEAT_MNEMONIC_WALLET=true 15 | NEXT_PUBLIC_FEAT_MNEMONIC_WALLET_PERSIST=true 16 | NEXT_PUBLIC_FEAT_TXN_GROUP_EXP=true 17 | 18 | ##### Debug, warning & error message settings ##### 19 | 20 | I18NEXT_DEBUG=true 21 | NEXT_PUBLIC_WALLET_DEBUG=true 22 | -------------------------------------------------------------------------------- /src/app/[lang]/components/form/index.ts: -------------------------------------------------------------------------------- 1 | export { default as CheckboxField } from './CheckboxField'; 2 | export { default as FileField } from './FileField'; 3 | export { default as FieldErrorMessage } from './FieldErrorMessage'; 4 | export { default as FieldGroup } from './FieldGroup'; 5 | export { default as NumberField } from './NumberField'; 6 | export { default as RadioButtonGroupField } from './RadioButtonGroupField'; 7 | export { default as SelectField } from './SelectField'; 8 | export { default as TextAreaField } from './TextAreaField'; 9 | export { default as TextField } from './TextField'; 10 | export { default as ToggleField } from './ToggleField'; 11 | -------------------------------------------------------------------------------- /src/app/[lang]/components/ToastProvider.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { Provider } from '@radix-ui/react-toast'; 4 | 5 | /** Wrapper for the Radix UI Toast Provider to convert it to a client component so it is compatible 6 | * with Next.js server-side rendering (SSR) 7 | */ 8 | export default function ToastProvider({ 9 | children, 10 | swipeDirection, 11 | label, 12 | }: { 13 | children: React.ReactNode, 14 | swipeDirection?: 'right' | 'left', 15 | label?: string, 16 | }) { 17 | return ( // The left swipe direction is usually for when the language is (right-to-left) RTL 18 | 19 | {children} 20 | 21 | ); 22 | }; 23 | -------------------------------------------------------------------------------- /src/app/[lang]/not-found.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { useEffect } from 'react'; 4 | import NotFoundBody from './NotFoundBody'; 5 | 6 | /** 404 Not Found page */ 7 | export default function NotFound() { 8 | useEffect(() => { 9 | const theme = localStorage.getItem('theme'); 10 | const htmlElem = document.querySelector('html'); 11 | 12 | if (theme) { 13 | htmlElem!.dataset.theme = theme; 14 | } else { 15 | // Unset the `data-theme` attribute if the theme is to be automatic 16 | delete htmlElem!.dataset.theme; 17 | } 18 | }); 19 | return ( 20 |
21 | 22 |
23 | ); 24 | } 25 | -------------------------------------------------------------------------------- /src/app/[lang]/txn/compose/components/fields/AssetConfigFields/index.ts: -------------------------------------------------------------------------------- 1 | export { default as AssetId } from './AssetId'; 2 | export { default as UnitName } from './UnitName'; 3 | export { default as AssetName } from './AssetName'; 4 | export { default as Total } from './Total'; 5 | export { default as DecimalPlaces } from './DecimalPlaces'; 6 | export { default as DefaultFrozen } from './DefaultFrozen'; 7 | export { default as URL } from './URL'; 8 | export { default as ManagerAddr } from './ManagerAddr'; 9 | export { default as FreezeAddr } from './FreezeAddr'; 10 | export { default as ClawbackAddr } from './ClawbackAddr'; 11 | export { default as ReserveAddr } from './ReserveAddr'; 12 | export { default as MetadataHash } from './MetadataHash'; 13 | -------------------------------------------------------------------------------- /.env.production: -------------------------------------------------------------------------------- 1 | # Default environment variables for the "production" environment (`next build` 2 | # and `next start`) 3 | # Environment variables not specified here inherit the values from the .env file. 4 | 5 | ##### App configuration ##### 6 | 7 | BASE_URL= 8 | NEXT_PUBLIC_DEFAULT_NETWORK=mainnet 9 | NEXT_PUBLIC_WC_PROJECT_ID= 10 | NEXT_PUBLIC_MAGIC_API_KEY= 11 | 12 | ##### Debug, warning & error message settings ##### 13 | 14 | # Ignore TypeScript errors when building 15 | IGNORE_TS_BUILD_ERRORS=true 16 | # If I18Next should be in debug mode 17 | I18NEXT_DEBUG=false 18 | # If the wallet connection library/libraries should be in debug logging mode 19 | NEXT_PUBLIC_WALLET_DEBUG=false 20 | # Suppress hydration warnings 21 | SUPPRESS_HYDRATION_WARNINGS=true 22 | -------------------------------------------------------------------------------- /src/app/lib/testing/i18nextClientMock.ts: -------------------------------------------------------------------------------- 1 | /** This mock makes sure any components using the translate hook can use it without a warning being 2 | * shown. 3 | * 4 | * From https://react.i18next.com/misc/testing 5 | * 6 | * NOTE: In test files, run `jest.mock` for this module before importing any modules that will use 7 | * this mock module. 8 | */ 9 | const i18nextClientMock = { 10 | useTranslation: () => { 11 | return { 12 | t: (str: string) => str, 13 | i18n: { 14 | changeLanguage: () => new Promise(() => {}), 15 | }, 16 | }; 17 | }, 18 | Trans: ({ i18nKey }: { i18nKey: string }) => i18nKey, 19 | initReactI18next: { 20 | type: '3rdParty', 21 | init: () => {}, 22 | } 23 | }; 24 | 25 | export default i18nextClientMock; 26 | -------------------------------------------------------------------------------- /src/app/[lang]/components/index.ts: -------------------------------------------------------------------------------- 1 | export {default as BuilderSteps} from './BuilderSteps'; 2 | export {default as DialogLoadingPlaceholder} from './DialogLoadingPlaceholder'; 3 | export {default as Footer} from './Footer'; 4 | export {default as JotaiProvider} from './JotaiProvider'; 5 | export {default as NavBar} from './NavBar'; 6 | export {default as PageLoadingPlaceholder} from './PageLoadingPlaceholder'; 7 | export {default as PageTitleHeading} from './PageTitleHeading'; 8 | export {default as ThemeChanger} from './ThemeChanger'; 9 | export {default as ToastNotification} from './ToastNotification'; 10 | export {default as ToastProvider} from './ToastProvider'; 11 | export {default as ToastViewport} from './ToastViewport'; 12 | export {default as WalletProvider} from './wallet/WalletProvider'; 13 | -------------------------------------------------------------------------------- /src/app/[lang]/components/ToastViewport.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { Viewport } from '@radix-ui/react-toast'; 4 | 5 | /** Wrapper for the Radix UI Toast Viewport to convert it to a client component so it is compatible 6 | * with Next.js server-side rendering (SSR) 7 | */ 8 | export default function ToastViewport({ 9 | toastPosition, 10 | label, 11 | }: { 12 | toastPosition?: 'right' | 'left', 13 | label?: string, 14 | }) { 15 | if (toastPosition === 'left') { // Usually for when the language is (right-to-left) RTL 16 | return ( 17 | 18 | ); 19 | } else { // Default 20 | return ( 21 | 22 | ); 23 | } 24 | }; 25 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "lib": ["dom", "dom.iterable", "esnext"], 5 | "allowJs": true, 6 | "skipLibCheck": true, 7 | "strict": true, 8 | "noEmit": true, 9 | "esModuleInterop": true, 10 | "module": "esnext", 11 | "moduleResolution": "bundler", 12 | "resolveJsonModule": true, 13 | "isolatedModules": true, 14 | "jsx": "react-jsx", 15 | "incremental": true, 16 | "plugins": [ 17 | { 18 | "name": "next" 19 | } 20 | ], 21 | "paths": { 22 | "@/*": ["./src/*"] 23 | } 24 | }, 25 | "include": [ 26 | "next-env.d.ts", 27 | "**/*.ts", 28 | "**/*.tsx", 29 | ".next/types/**/*.ts", 30 | ".next/dev/types/**/*.ts" 31 | ], 32 | "exclude": [ 33 | "node_modules", 34 | "build", 35 | "src/e2e" 36 | ] 37 | } 38 | -------------------------------------------------------------------------------- /src/app/[lang]/components/PageTitleHeading/PageTitleHeading.tsx: -------------------------------------------------------------------------------- 1 | import { Suspense } from "react"; 2 | import { TxnPresetBadge } from "./TxnPresetBadge"; 3 | 4 | type Props = { 5 | children?: React.ReactNode, 6 | /** Language (Not needed if `showTxnPreset` is `false`) */ 7 | lng?: string, 8 | /** If a badge for the transaction preset should be shown. Only used by a few pages. */ 9 | showTxnPreset?: boolean 10 | }; 11 | 12 | /** Top heading for the title of a page */ 13 | export default function PageTitleHeading({ children, lng = '', showTxnPreset = false }: Props) { 14 | return ( 15 |
16 | {showTxnPreset &&
17 | 18 |
} 19 |

{children}

20 |
21 | ); 22 | } 23 | -------------------------------------------------------------------------------- /src/app/lib/validation-set-locale.ts: -------------------------------------------------------------------------------- 1 | import { setLocale } from 'yup'; 2 | import { ValidationMessage } from './utils'; 3 | 4 | // Run function to set how validation messages are formatted 5 | setLocale({ 6 | // use constant translation keys for messages without values 7 | mixed: { 8 | required: (): ValidationMessage => ({key: 'form.error.required'}), 9 | }, 10 | string: { 11 | length: ({length}): ValidationMessage => ( 12 | {key: 'form.error.string.length', dict: {count: length}} 13 | ), 14 | max: ({max}): ValidationMessage => ({key: 'form.error.string.max', dict: {count: max}}), 15 | email: (): ValidationMessage => ({key: 'form.error.string.email'}), 16 | }, 17 | number: { 18 | min: ({min}): ValidationMessage => ({key: 'form.error.number.min', dict: {min}}), 19 | max: ({max}): ValidationMessage => ({key: 'form.error.number.max', dict: {max}}), 20 | } 21 | }); 22 | -------------------------------------------------------------------------------- /src/app/[lang]/components/form/FieldTip.test.tsx: -------------------------------------------------------------------------------- 1 | import '@testing-library/jest-dom'; 2 | import { render, screen } from '@testing-library/react'; 3 | import userEvent from '@testing-library/user-event'; 4 | import FieldTip from './FieldTip'; 5 | 6 | // This solution to the "ResizeObserver is not defined" error caused by Radix UI Popover (only in 7 | // Jest) found at 8 | // https://greenonsoftware.com/articles/testing/testing-and-mocking-resize-observer-in-java-script/ 9 | global.ResizeObserver = class MockedResizeObserver { 10 | observe = jest.fn(); 11 | unobserve = jest.fn(); 12 | disconnect = jest.fn(); 13 | }; 14 | 15 | describe('Form Components - FieldTip', () => { 16 | 17 | it('shows tooltip when button is clicked', async () => { 18 | render(); 19 | await userEvent.click(screen.getByRole('button')); 20 | expect(screen.getByText(/foo/)).toBeInTheDocument(); 21 | }); 22 | 23 | }); 24 | -------------------------------------------------------------------------------- /src/app/[lang]/txn/compose/components/wallet/WalletFieldWidget.tsx: -------------------------------------------------------------------------------- 1 | import { type TFunction } from "i18next"; 2 | import { useAtomValue } from "jotai"; 3 | import { useEffect } from "react"; 4 | import { WalletProvider } from "@/app/[lang]/components"; 5 | import { isWalletConnectedAtom } from "@/app/lib/wallet-utils"; 6 | import ConnectWallet from "./ConnectWallet"; 7 | 8 | /** Component that handles the "connect wallet" button below certain form fields */ 9 | export default function ConnectWalletFieldWidget({ t, setvalfn }:{ 10 | t: TFunction, 11 | setvalfn: (v: any) => void, 12 | }) { 13 | const isWalletConnected = useAtomValue(isWalletConnectedAtom); 14 | 15 | // Trigger rerender when wallet connection status has changed 16 | useEffect(() => {}, [isWalletConnected]); 17 | 18 | return ( 19 | 20 | 21 | 22 | ); 23 | } 24 | -------------------------------------------------------------------------------- /.env.test: -------------------------------------------------------------------------------- 1 | # Default environment variables for the "development" environment (`next dev`) 2 | # Environment variables not specified here inherit the values from the .env file. 3 | 4 | ##### App configuration ##### 5 | 6 | # Base URL for the site. If blank or omitted, Next.js's default is used. 7 | BASE_URL= 8 | # Default node network 9 | # Possible values: mainnet, testnet, betanet, fnet, voimain, localnet, custom 10 | NEXT_PUBLIC_DEFAULT_NETWORK=testnet 11 | # WalletConnect project ID 12 | # If the project ID is not set, WalletConnect support will not be enabled. 13 | NEXT_PUBLIC_WC_PROJECT_ID= 14 | # Magic publishable API key 15 | # If the API key is not set, Magic support will not be enabled. 16 | NEXT_PUBLIC_MAGIC_API_KEY= 17 | 18 | ##### Feature flags ##### 19 | 20 | NEXT_PUBLIC_FEAT_MNEMONIC_WALLET=true 21 | NEXT_PUBLIC_FEAT_MNEMONIC_WALLET_PERSIST=true 22 | NEXT_PUBLIC_FEAT_TXN_GROUP_EXP=true 23 | 24 | ##### Debug, warning & error message settings ##### 25 | 26 | IGNORE_TS_BUILD_ERRORS=true 27 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.* 7 | .yarn 8 | !.yarn/patches 9 | !.yarn/plugins 10 | !.yarn/releases 11 | !.yarn/sdks 12 | !.yarn/versions 13 | 14 | # testing 15 | /coverage 16 | 17 | # next.js 18 | /.next/ 19 | /out/ 20 | 21 | # production 22 | /build 23 | 24 | # misc 25 | .DS_Store 26 | *.pem 27 | .vscode 28 | 29 | # debug 30 | npm-debug.log* 31 | yarn-debug.log* 32 | yarn-error.log* 33 | 34 | # local env files 35 | .env*.local 36 | 37 | # vercel 38 | .vercel 39 | 40 | # typescript 41 | *.tsbuildinfo 42 | next-env.d.ts 43 | /test-results/ 44 | 45 | # Playwright 46 | /playwright-report/ 47 | /playwright/.cache/ 48 | 49 | # compiled translation files 50 | src/app/i18n/locales/.dist/ 51 | 52 | # Auto Generated PWA (Progressive Web App) files 53 | /public/sw.js 54 | /public/workbox-*.js 55 | /public/worker-*.js 56 | /public/sw.js.map 57 | /public/workbox-*.js.map 58 | /public/worker-*.js.map 59 | /public/**/*.webmanifest 60 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | # See https://docs.docker.com/build/concepts/context/#dockerignore-files for more about ignoring files. 2 | # This should be very similar to the .gitignore file 3 | 4 | # dependencies 5 | /node_modules 6 | /.pnp 7 | .pnp.* 8 | .yarn 9 | !.yarn/patches 10 | !.yarn/plugins 11 | !.yarn/releases 12 | !.yarn/sdks 13 | !.yarn/versions 14 | 15 | # testing 16 | /coverage 17 | 18 | # next.js 19 | /.next/ 20 | /out/ 21 | 22 | # production 23 | /build 24 | 25 | # misc 26 | .DS_Store 27 | *.pem 28 | 29 | # debug 30 | npm-debug.log* 31 | yarn-debug.log* 32 | yarn-error.log* 33 | 34 | # vercel 35 | .vercel 36 | 37 | # typescript 38 | *.tsbuildinfo 39 | next-env.d.ts 40 | /test-results/ 41 | 42 | # Playwright 43 | /playwright-report/ 44 | /playwright/.cache/ 45 | 46 | # compiled translation files 47 | src/app/i18n/locales/.dist/ 48 | 49 | # Auto Generated PWA (Progressive Web App) files 50 | /public/sw.js 51 | /public/workbox-*.js 52 | /public/worker-*.js 53 | /public/sw.js.map 54 | /public/workbox-*.js.map 55 | /public/worker-*.js.map 56 | /public/**/*.webmanifest 57 | -------------------------------------------------------------------------------- /src/e2e/send_txn.spec.ts: -------------------------------------------------------------------------------- 1 | import { test as base, expect } from '@playwright/test'; 2 | import { LanguageSupport, NavBarComponent as NavBar } from './shared'; 3 | import { SendTxnPage } from './pageModels/SendTxnPage'; 4 | 5 | // Extend basic test by providing a "sendTxnPage" fixture. 6 | // Code adapted from https://playwright.dev/docs/pom 7 | const test = base.extend<{ sendTxnPage: SendTxnPage }>({ 8 | sendTxnPage: async ({ page }, use) => { 9 | // Set up the fixture. 10 | const sendTxnPage = new SendTxnPage(page); 11 | await sendTxnPage.goto(); 12 | // Use the fixture value in the test. 13 | await use(sendTxnPage); 14 | }, 15 | }); 16 | 17 | test.describe('Send Transaction Page', () => { 18 | 19 | test.describe('Language Support', () => { 20 | (new LanguageSupport({ 21 | en: { body: /Send/, title: /Send/ }, 22 | es: { body: /Enviar/, title: /Enviar/ }, 23 | })).check(test, SendTxnPage.url); 24 | }); 25 | 26 | test.describe('Nav Bar', () => { 27 | NavBar.check(test, SendTxnPage.getFullUrl()); 28 | }); 29 | 30 | }); 31 | -------------------------------------------------------------------------------- /jest.config.mjs: -------------------------------------------------------------------------------- 1 | import nextJest from 'next/jest.js'; 2 | 3 | const createJestConfig = nextJest({ 4 | // Provide the path to your Next.js app to load next.config.js and .env files in your test 5 | // environment 6 | dir: './', 7 | }); 8 | 9 | // Add any custom config to be passed to Jest 10 | /** @type {import('jest').Config} */ 11 | const config = { 12 | roots: ['src'], 13 | 14 | /* 15 | * Add more setup options before each test is run 16 | */ 17 | 18 | // Setup files to run before the test framework is installed in the environment 19 | setupFiles: ['/src/app/lib/testing/textcoderPolyfill.js'], 20 | // Setup files to run after the test framework has been installed in the environment. 21 | // setupFilesAfterEnv: ['/jest.setup.js'], 22 | 23 | testEnvironment: 'jest-environment-jsdom', 24 | testPathIgnorePatterns: ['/node_modules/', '/src/e2e'], 25 | resetMocks: true, 26 | }; 27 | 28 | // createJestConfig is exported this way to ensure that next/jest can load the Next.js config which 29 | // is async 30 | export default createJestConfig(config); 31 | -------------------------------------------------------------------------------- /src/app/[lang]/components/NavBar/LanguageSelector/LanguageMenuItem.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { usePathname, useSearchParams } from 'next/navigation'; 4 | import * as DropdownMenu from '@radix-ui/react-dropdown-menu'; 5 | import { supportedLangs } from '@/app/i18n/settings'; 6 | 7 | type Props = { 8 | /** Language of the page */ 9 | page?: string 10 | /** Language the link is for */ 11 | link?: string 12 | }; 13 | 14 | /** Item (language) in the language menu */ 15 | export default function LanguageMenuItem({ page='', link='' }: Props) { 16 | const currentURLPath = usePathname(); 17 | const currentURLParams = useSearchParams(); 18 | return ( 19 | 20 |
  • 21 | 26 | {supportedLangs[link].listName} 27 | 28 |
  • 29 |
    30 | ); 31 | } 32 | -------------------------------------------------------------------------------- /src/e2e/group_compose.spec.ts: -------------------------------------------------------------------------------- 1 | import { test as base, expect } from '@playwright/test'; 2 | import { LanguageSupport, NavBarComponent as NavBar } from './shared'; 3 | import { GroupComposePage } from './pageModels'; 4 | 5 | // Extend basic test by providing a "grpComposePage" fixture. 6 | // Code adapted from https://playwright.dev/docs/pom 7 | const test = base.extend<{ grpComposePage: GroupComposePage }>({ 8 | grpComposePage: async ({ page }, use) => { 9 | // Set up the fixture. 10 | const grpComposePage = new GroupComposePage(page); 11 | await grpComposePage.goto(); 12 | // Use the fixture value in the test. 13 | await use(grpComposePage); 14 | }, 15 | }); 16 | 17 | test.describe('Transaction Group Compose Page', () => { 18 | 19 | test.describe('Language Support', () => { 20 | (new LanguageSupport({ 21 | en: { body: /Compose/, title: /Compose/ }, 22 | es: { body: /Componer/, title: /Componer/ }, 23 | })).check(test, GroupComposePage.url); 24 | }); 25 | 26 | test.describe('Nav Bar', () => { 27 | NavBar.check(test, GroupComposePage.getFullUrl()); 28 | }); 29 | 30 | }); 31 | -------------------------------------------------------------------------------- /src/app/[lang]/components/NavBar/NavBar.tsx: -------------------------------------------------------------------------------- 1 | import { use } from 'react'; 2 | import { useTranslation } from '@/app/i18n'; 3 | import Settings from './Settings'; 4 | import { NodeSelector } from './NodeSelector'; 5 | import { LanguageSelector } from './LanguageSelector'; 6 | 7 | type Props = { 8 | /** Language */ 9 | lng?: string 10 | }; 11 | 12 | /** Navigation bar that serves as a header for every page */ 13 | export default function NavBar({ lng }: Props) { 14 | const { t } = use(useTranslation(lng || '', ['app', 'common'])); 15 | 16 | return ( 17 | 31 | ); 32 | } 33 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | # MIT License 2 | 3 | Copyright © 2023 No-Cash-7970 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /src/e2e/privacy_policy.spec.ts: -------------------------------------------------------------------------------- 1 | import { test as base, expect } from '@playwright/test'; 2 | import { LanguageSupport, NavBarComponent as NavBar } from './shared'; 3 | import { PrivacyPolicyPage } from './pageModels/PrivacyPolicyPage'; 4 | 5 | // Extend basic test by providing a "privacyPolicyPage" fixture. 6 | // Code adapted from https://playwright.dev/docs/pom 7 | const test = base.extend<{ privacyPolicyPage: PrivacyPolicyPage }>({ 8 | privacyPolicyPage: async ({ page }, use) => { 9 | // Set up the fixture. 10 | const privacyPolicyPage = new PrivacyPolicyPage(page); 11 | await privacyPolicyPage.goto(); 12 | // Use the fixture value in the test. 13 | await use(privacyPolicyPage); 14 | }, 15 | }); 16 | 17 | test.describe('Privacy Policy Page', () => { 18 | 19 | test.describe('Language Support', () => { 20 | (new LanguageSupport({ 21 | en: { body: /Privacy/, title: /Privacy/ }, 22 | es: { body: /privacidad/, title: /privacidad/ }, 23 | })).check(test, PrivacyPolicyPage.url); 24 | }); 25 | 26 | test.describe('Nav Bar', () => { 27 | NavBar.check(test, PrivacyPolicyPage.getFullUrl()); 28 | }); 29 | 30 | }); 31 | -------------------------------------------------------------------------------- /src/e2e/pageModels/SendTxnPage.ts: -------------------------------------------------------------------------------- 1 | import { type Page as PageFixture, type Locator } from '@playwright/test'; 2 | 3 | export class SendTxnPage { 4 | /** Page fixture from Playwright */ 5 | readonly page: PageFixture; 6 | /** URL without the language prefix */ 7 | static readonly url = '/txn/send'; 8 | /** Main section of the page */ 9 | readonly main: Locator; 10 | 11 | /** 12 | * @param page Page fixture from Playwright 13 | */ 14 | constructor(page: PageFixture) { 15 | this.page = page; 16 | this.main = page.getByRole('main'); 17 | } 18 | 19 | /** Get the URL with language prefix. 20 | * @param lang The language prefix. Must be an ISO 639-1 code 21 | * @returns The URL with the language prefix 22 | */ 23 | static getFullUrl(lang = 'en') { 24 | return '/' + lang + SendTxnPage.url; 25 | } 26 | 27 | /** Go to the page 28 | * @param lang The language prefix of the page to go to. 29 | * @param query The query parameter string with the question mark at the beginning 30 | * (e.g. "?a=1&b=2") 31 | */ 32 | async goto(lang = 'en', query = '') { 33 | return await this.page.goto(SendTxnPage.getFullUrl(lang) + query); 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/e2e/pageModels/SignTxnPage.ts: -------------------------------------------------------------------------------- 1 | import { type Page as PageFixture, type Locator } from '@playwright/test'; 2 | 3 | export class SignTxnPage { 4 | /** Page fixture from Playwright */ 5 | readonly page: PageFixture; 6 | /** URL without the language prefix */ 7 | static readonly url = '/txn/sign'; 8 | /** Main section of the page */ 9 | readonly main: Locator; 10 | 11 | /** 12 | * @param page Page fixture from Playwright 13 | */ 14 | constructor(page: PageFixture) { 15 | this.page = page; 16 | this.main = page.getByRole('main'); 17 | } 18 | 19 | /** Get the URL with language prefix. 20 | * @param lang The language prefix. Must be an ISO 639-1 code 21 | * @returns The URL with the language prefix 22 | */ 23 | static getFullUrl(lang = 'en') { 24 | return '/' + lang + SignTxnPage.url; 25 | } 26 | 27 | /** Go to the page 28 | * @param lang The language prefix of the page to go to. 29 | * @param query The query parameter string with the question mark at the beginning 30 | * (e.g. "?a=1&b=2") 31 | */ 32 | async goto(lang = 'en', query = '') { 33 | return await this.page.goto(SignTxnPage.getFullUrl(lang) + query); 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/e2e/pageModels/TxnPresetsPage.ts: -------------------------------------------------------------------------------- 1 | import { type Page as PageFixture, type Locator } from '@playwright/test'; 2 | 3 | export class TxnPresetsPage { 4 | /** Page fixture from Playwright */ 5 | readonly page: PageFixture; 6 | /** URL without the language prefix */ 7 | static readonly url = '/txn'; 8 | /** Main section of the page */ 9 | readonly main: Locator; 10 | 11 | /** 12 | * @param page Page fixture from Playwright 13 | */ 14 | constructor(page: PageFixture) { 15 | this.page = page; 16 | this.main = page.getByRole('main'); 17 | } 18 | 19 | /** Get the URL with language prefix. 20 | * @param lang The language prefix. Must be an ISO 639-1 code 21 | * @returns The URL with the language prefix 22 | */ 23 | static getFullUrl(lang = 'en') { 24 | return '/' + lang + TxnPresetsPage.url; 25 | } 26 | 27 | /** Go to the page 28 | * @param lang The language prefix of the page to go to. 29 | * @param query The query parameter string with the question mark at the beginning 30 | * (e.g. "?a=1&b=2") 31 | */ 32 | async goto(lang = 'en', query = '') { 33 | return await this.page.goto(TxnPresetsPage.getFullUrl(lang) + query); 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/e2e/pageModels/NotFoundPage.ts: -------------------------------------------------------------------------------- 1 | import { type Page as PageFixture, type Locator } from '@playwright/test'; 2 | 3 | export class NotFoundPage { 4 | /** Page fixture from Playwright */ 5 | readonly page: PageFixture; 6 | /** URL without the language prefix */ 7 | static readonly url = '/doesnt-exist'; 8 | /** Main section of the page */ 9 | readonly main: Locator; 10 | 11 | /** 12 | * @param page Page fixture from Playwright 13 | */ 14 | constructor(page: PageFixture) { 15 | this.page = page; 16 | this.main = page.getByRole('main'); 17 | } 18 | 19 | /** Get the URL with language prefix. 20 | * @param lang The language prefix. Must be an ISO 639-1 code 21 | * @returns The URL with the language prefix 22 | */ 23 | static getFullUrl(lang = 'en') { 24 | return '/' + lang + NotFoundPage.url; 25 | } 26 | 27 | /** Go to the page 28 | * @param lang The language prefix of the page to go to. 29 | * @param query The query parameter string with the question mark at the beginning 30 | * (e.g. "?a=1&b=2") 31 | */ 32 | async goto(lang = 'en', query = '') { 33 | return await this.page.goto(NotFoundPage.getFullUrl(lang) + query); 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/e2e/pageModels/ComposeTxnPage.ts: -------------------------------------------------------------------------------- 1 | import { type Page as PageFixture, type Locator } from '@playwright/test'; 2 | 3 | export class ComposeTxnPage { 4 | /** Page fixture from Playwright */ 5 | readonly page: PageFixture; 6 | /** URL without the language prefix */ 7 | static readonly url = '/txn/compose'; 8 | /** Main section of the page */ 9 | readonly main: Locator; 10 | 11 | /** 12 | * @param page Page fixture from Playwright 13 | */ 14 | constructor(page: PageFixture) { 15 | this.page = page; 16 | this.main = page.getByRole('main'); 17 | } 18 | 19 | /** Get the URL with language prefix. 20 | * @param lang The language prefix. Must be an ISO 639-1 code 21 | * @returns The URL with the language prefix 22 | */ 23 | static getFullUrl(lang = 'en') { 24 | return '/' + lang + ComposeTxnPage.url; 25 | } 26 | 27 | /** Go to the page 28 | * @param lang The language prefix of the page to go to. 29 | * @param query The query parameter string with the question mark at the beginning 30 | * (e.g. "?a=1&b=2") 31 | */ 32 | async goto(lang = 'en', query = '') { 33 | return await this.page.goto(ComposeTxnPage.getFullUrl(lang) + query); 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/app/[lang]/txn/page.test.tsx: -------------------------------------------------------------------------------- 1 | import '@testing-library/jest-dom'; 2 | import { render, screen } from '@testing-library/react'; 3 | import i18nextClientMock from '@/app/lib/testing/i18nextClientMock'; 4 | 5 | // Mock react `use` function before modules that use it are imported 6 | jest.mock('react', () => ({ 7 | ...jest.requireActual('react'), 8 | use: () => ({ t: (key: string) => key }), 9 | })); 10 | 11 | // Mock i18next before modules that use it are imported because it is used by a child component 12 | jest.mock('react-i18next', () => i18nextClientMock); 13 | 14 | // Mock navigation hooks 15 | jest.mock('next/navigation', () => ({ 16 | useSearchParams: () => ({ 17 | get: () => null 18 | }), 19 | })); 20 | 21 | // Mock the wallet provider 22 | jest.mock('../components/wallet/WalletProvider.tsx', () => 'div'); 23 | 24 | import TxnPresetsPage from './page'; 25 | 26 | describe('Transaction Presets Page', () => { 27 | 28 | it('has page title heading', () => { 29 | const pageParam = new Promise(resolve => { resolve({lang: ''}); }); 30 | render(); 31 | expect(screen.getByRole('heading', { level: 1 })).not.toBeEmptyDOMElement(); 32 | }); 33 | 34 | }); 35 | -------------------------------------------------------------------------------- /src/app/[lang]/group/page.test.tsx: -------------------------------------------------------------------------------- 1 | import '@testing-library/jest-dom'; 2 | import { render, screen } from '@testing-library/react'; 3 | import i18nextClientMock from '@/app/lib/testing/i18nextClientMock'; 4 | 5 | // Mock react `use` function before modules that use it are imported 6 | jest.mock('react', () => ({ 7 | ...jest.requireActual('react'), 8 | use: () => ({ t: (key: string) => key }), 9 | })); 10 | 11 | // Mock i18next before modules that use it are imported because it is used by a child component 12 | jest.mock('react-i18next', () => i18nextClientMock); 13 | 14 | // Mock navigation hooks 15 | jest.mock('next/navigation', () => ({ 16 | useSearchParams: () => ({ 17 | get: () => null 18 | }), 19 | })); 20 | 21 | // Mock the wallet provider 22 | jest.mock('../components/wallet/WalletProvider.tsx', () => 'div'); 23 | 24 | import GroupPresetsPage from './page'; 25 | 26 | describe('Transaction Presets Page', () => { 27 | 28 | it('has page title heading', () => { 29 | const pageParam = new Promise(resolve => { resolve({lang: ''}); }); 30 | render(); 31 | expect(screen.getByRole('heading', { level: 1 })).not.toBeEmptyDOMElement(); 32 | }); 33 | 34 | }); 35 | -------------------------------------------------------------------------------- /src/e2e/pageModels/GroupComposePage.ts: -------------------------------------------------------------------------------- 1 | import { type Page as PageFixture, type Locator } from '@playwright/test'; 2 | 3 | export class GroupComposePage { 4 | /** Page fixture from Playwright */ 5 | readonly page: PageFixture; 6 | /** URL without the language prefix */ 7 | static readonly url = '/group/compose'; 8 | /** Main section of the page */ 9 | readonly main: Locator; 10 | 11 | /** 12 | * @param page Page fixture from Playwright 13 | */ 14 | constructor(page: PageFixture) { 15 | this.page = page; 16 | this.main = page.getByRole('main'); 17 | } 18 | 19 | /** Get the URL with language prefix. 20 | * @param lang The language prefix. Must be an ISO 639-1 code 21 | * @returns The URL with the language prefix 22 | */ 23 | static getFullUrl(lang = 'en') { 24 | return '/' + lang + GroupComposePage.url; 25 | } 26 | 27 | /** Go to the page 28 | * @param lang The language prefix of the page to go to. 29 | * @param query The query parameter string with the question mark at the beginning 30 | * (e.g. "?a=1&b=2") 31 | */ 32 | async goto(lang = 'en', query = '') { 33 | return await this.page.goto(GroupComposePage.getFullUrl(lang) + query); 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/app/[lang]/txn/compose/components/fields/AssetConfigFields/DefaultFrozen.tsx: -------------------------------------------------------------------------------- 1 | import { type TFunction } from 'i18next'; 2 | import { useAtomValue } from 'jotai'; 3 | import { ToggleField } from '@/app/[lang]/components/form'; 4 | import { assetConfigFormControlAtom, tipBtnClass, tipContentClass } from '@/app/lib/txn-data'; 5 | 6 | export default function DefaultFrozen({ t }: { t: TFunction }) { 7 | const form = useAtomValue(assetConfigFormControlAtom); 8 | // If creation transaction 9 | return (!form.values.caid && 10 | { 26 | form.setTouched('apar_df', true); 27 | form.handleOnChange('apar_df')(e.target.checked); 28 | }} 29 | /> 30 | ); 31 | } 32 | -------------------------------------------------------------------------------- /src/e2e/pageModels/PrivacyPolicyPage.ts: -------------------------------------------------------------------------------- 1 | import { type Page as PageFixture, type Locator } from '@playwright/test'; 2 | 3 | export class PrivacyPolicyPage { 4 | /** Page fixture from Playwright */ 5 | readonly page: PageFixture; 6 | /** URL without the language prefix */ 7 | static readonly url = '/privacy-policy'; 8 | /** Main section of the page */ 9 | readonly main: Locator; 10 | 11 | /** 12 | * @param page Page fixture from Playwright 13 | */ 14 | constructor(page: PageFixture) { 15 | this.page = page; 16 | this.main = page.getByRole('main'); 17 | } 18 | 19 | /** Get the URL with language prefix. 20 | * @param lang The language prefix. Must be an ISO 639-1 code 21 | * @returns The URL with the language prefix 22 | */ 23 | static getFullUrl(lang = 'en') { 24 | return '/' + lang + PrivacyPolicyPage.url; 25 | } 26 | 27 | /** Go to the page 28 | * @param lang The language prefix of the page to go to. 29 | * @param query The query parameter string with the question mark at the beginning 30 | * (e.g. "?a=1&b=2") 31 | */ 32 | async goto(lang = 'en', query = '') { 33 | return await this.page.goto(PrivacyPolicyPage.getFullUrl(lang) + query); 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/app/[lang]/txn/sign/components/NextStepButton.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import Link from 'next/link'; 4 | import { useSearchParams } from 'next/navigation'; 5 | import { IconArrowRight, IconArrowLeft } from '@tabler/icons-react'; 6 | import { useAtomValue } from 'jotai'; 7 | import { useTranslation } from '@/app/i18n/client'; 8 | import { storedSignedTxnAtom } from '@/app/lib/txn-data'; 9 | 10 | type Props = { 11 | /** Language */ 12 | lng?: string 13 | }; 14 | 15 | /** Button for going to the next step in the "sign transaction" page */ 16 | export default function NextStepButton({ lng }: Props) { 17 | const { t } = useTranslation(lng || '', 'sign_txn'); 18 | const storedSignedTxn = useAtomValue(storedSignedTxnAtom); 19 | const currentURLParams = useSearchParams(); 20 | return ( 21 | 26 | {t('send_txn_btn')} 27 | 28 | 29 | 30 | ); 31 | } 32 | -------------------------------------------------------------------------------- /.github/workflows/release_standalone.yml: -------------------------------------------------------------------------------- 1 | name: Release - Standalone 2 | on: 3 | release: 4 | types: [released] 5 | workflow_dispatch: null 6 | permissions: 7 | contents: write 8 | jobs: 9 | build: 10 | timeout-minutes: 30 11 | runs-on: ubuntu-latest 12 | steps: 13 | - name: Checkout code 14 | uses: actions/checkout@v4 15 | - name: Install Node 16 | uses: actions/setup-node@v4 17 | with: 18 | node-version: 22 19 | - name: Enable Corepack for Yarn 20 | run: corepack enable 21 | - name: Set up Node cache # Set up Node cache by installing again 22 | uses: actions/setup-node@v4 23 | with: 24 | node-version: 22 25 | cache: yarn 26 | - name: Install dependencies 27 | run: yarn 28 | - name: Build standalone zip file 29 | run: yarn build:standalone:zip 30 | env: 31 | NEXT_PUBLIC_WC_PROJECT_ID: ${{vars.NEXT_PUBLIC_WC_PROJECT_ID}} 32 | NEXT_PUBLIC_MAGIC_API_KEY: ${{vars.NEXT_PUBLIC_MAGIC_API_KEY}} 33 | - name: Upload standalone to latest release 34 | uses: softprops/action-gh-release@v2 35 | with: 36 | fail_on_unmatched_files: true 37 | files: build/*.zip 38 | -------------------------------------------------------------------------------- /src/app/[lang]/components/PageTitleHeading/TxnPresetBadge.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { useTranslation } from "@/app/i18n/client"; 4 | import { useSearchParams } from "next/navigation"; 5 | import { nodeConfigAtom } from "@/app/lib/node-config"; 6 | import { useAtomValue } from "jotai"; 7 | import { importParamName } from "@/app/lib/utils"; 8 | import { Preset } from "@/app/lib/txn-data"; 9 | 10 | type Props = { 11 | /** Language */ 12 | lng?: string 13 | }; 14 | 15 | /** Badge that shows the transaction preset being used. Usually in the page title heading */ 16 | export function TxnPresetBadge({ lng }: Props) { 17 | const { t } = useTranslation(lng || '', ['txn_presets', 'common']); 18 | const nodeConfig = useAtomValue(nodeConfigAtom); 19 | const currentURLParams = useSearchParams(); 20 | const txnPreset = currentURLParams.get(Preset.ParamName); 21 | const isImporting = currentURLParams.get(importParamName) !== null; 22 | return (<> 23 | {!isImporting && !!txnPreset && 24 | 25 | {t( 26 | [txnPreset + '.heading', 'invalid_preset'], 27 | {coinName: nodeConfig.coinName ?? t('algo_other')} 28 | )} 29 | 30 | } 31 | ); 32 | } 33 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.yml: -------------------------------------------------------------------------------- 1 | name: ✨ Feature Request 2 | description: Suggest an idea for this project 3 | labels: [enhancement] 4 | body: 5 | - type: textarea 6 | id: problem 7 | attributes: 8 | label: Is your feature request related to a problem? Please describe. 9 | description: A clear and concise description of what the problem is. 10 | placeholder: Ex. "I'm always frustrated when..." 11 | validations: 12 | required: true 13 | - type: textarea 14 | id: solution 15 | attributes: 16 | label: Describe the solution you'd like 17 | description: A clear and concise description of what you want to happen. 18 | validations: 19 | required: true 20 | - type: textarea 21 | id: alternatives 22 | attributes: 23 | label: Describe alternatives you've considered 24 | description: A clear and concise description of any alternative solutions or features you've considered. 25 | - type: textarea 26 | id: additional-context 27 | attributes: 28 | label: Additional context 29 | description: | 30 | Add any other context or screenshots about the feature request here. 31 | 32 | Tip: You can attach images by clicking this area to highlight it and then dragging files in. 33 | -------------------------------------------------------------------------------- /src/app/[lang]/NotFoundBody.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { useTranslation } from '@/app/i18n/client'; 4 | import Image from 'next/image'; 5 | 6 | /** The core content of the Not-Found page */ 7 | export default function NotFoundBody() { 8 | // Attempt to get the current language stored in localStorage because the Not-Found page cannot 9 | // use the "lang" path parameter. We also cannot get the value from the cookie because that would 10 | // ruin the ability of this app to be statically exported. This leaves us with getting the current 11 | // language from localStorage on the client side as our only option. 12 | const { t } = useTranslation('', 'app'); 13 | return (<> 14 |
    15 |
    16 | {t('page_not_found.quack')} 17 |
    18 |
    19 | {t('page_not_found.duck_img_alt')} 20 |
    21 |
    22 |
    23 | {t('page_not_found.title')} 24 |
    25 |

    {t('page_not_found.details')}

    26 | ); 27 | } 28 | -------------------------------------------------------------------------------- /src/app/[lang]/components/NavBar/Settings/ConnectWalletConnected.test.tsx: -------------------------------------------------------------------------------- 1 | import '@testing-library/jest-dom'; 2 | import { render, screen } from '@testing-library/react'; 3 | import userEvent from '@testing-library/user-event'; 4 | import { type TFunction } from 'i18next'; 5 | import i18nextClientMock from '@/app/lib/testing/i18nextClientMock'; 6 | import { fooDisconnectFn, useWalletConnectedMock } from '@/app/lib/testing/useWalletMock'; 7 | 8 | // Mock i18next before modules that use it are imported 9 | jest.mock('react-i18next', () => i18nextClientMock); 10 | // Mock use-wallet 11 | jest.mock('@txnlab/use-wallet-react', () => useWalletConnectedMock); 12 | 13 | import ConnectWallet from './ConnectWallet'; 14 | 15 | describe('Wallet Connect (in Settings) (Connected wallet)', () => { 16 | const t = i18nextClientMock.useTranslation().t as TFunction; 17 | 18 | it('has "disconnect" button', () => { 19 | render(); 20 | expect(screen.getByRole('button')).toHaveTextContent('wallet.disconnect'); 21 | expect(screen.getByText('wallet.is_connected')).toBeInTheDocument(); 22 | }); 23 | 24 | it('disconnects wallet when "disconnect" button is clicked', async () => { 25 | render(); 26 | await userEvent.click(screen.getByRole('button')); 27 | expect(fooDisconnectFn).toHaveBeenCalledTimes(1); 28 | }); 29 | 30 | }); 31 | -------------------------------------------------------------------------------- /src/app/[lang]/txn/compose/components/fields/AssetFreezeFields/Freeze.tsx: -------------------------------------------------------------------------------- 1 | import { useSearchParams } from 'next/navigation'; 2 | import { type TFunction } from 'i18next'; 3 | import { useAtomValue } from 'jotai'; 4 | import { ToggleField } from '@/app/[lang]/components/form'; 5 | import { 6 | Preset, 7 | assetFreezeFormControlAtom, 8 | tipBtnClass, 9 | tipContentClass, 10 | } from '@/app/lib/txn-data'; 11 | 12 | export default function Freeze({ t }: { t: TFunction }) { 13 | const preset = useSearchParams().get(Preset.ParamName); 14 | const form = useAtomValue(assetFreezeFormControlAtom); 15 | // If creation transaction 16 | return ( 17 | { 34 | form.setTouched('afrz', true); 35 | form.handleOnChange('afrz')(e.target.checked); 36 | }} 37 | /> 38 | ); 39 | } 40 | -------------------------------------------------------------------------------- /src/app/lib/wallet-utils.ts: -------------------------------------------------------------------------------- 1 | /** @file Collection of constants and functions for managing wallet connections */ 2 | 3 | import { WalletId } from '@txnlab/use-wallet-react'; 4 | import { atom } from 'jotai'; 5 | import { atomWithValidate } from 'jotai-form'; 6 | import { string } from 'yup'; 7 | import '@/app/lib/validation-set-locale'; // Run setup for the locales for Yup (`Yup.setLocale()`) 8 | 9 | /** The type of wallet for each supported wallet provider */ 10 | export const walletTypes: {[id: string]: string} = { 11 | [WalletId.PERA]: 'mobile_web', 12 | [WalletId.DEFLY]: 'mobile', 13 | [WalletId.DEFLY_WEB]: 'browser_extension', 14 | [WalletId.EXODUS]: 'browser_extension', 15 | [WalletId.W3_WALLET]: 'mobile', 16 | // [WalletId.DAFFI]: 'mobile', 17 | [WalletId.LUTE]: 'web', 18 | [WalletId.KMD]: 'cli', 19 | [WalletId.KIBISIS]: 'browser_extension', 20 | [WalletId.WALLETCONNECT]: 'protocol', 21 | [WalletId.MAGIC]: 'waas', 22 | [WalletId.BIATEC]: 'web', 23 | [WalletId.MNEMONIC]: 'mnemonic', 24 | }; 25 | 26 | /** Validation atom that contains the Magic email address */ 27 | export const magicEmailAtom = atomWithValidate('', { 28 | validate: v => { 29 | string().email().required().validateSync(v); 30 | return v; 31 | } 32 | }); 33 | 34 | /** Atom used for detecting and syncing changes in wallet connection status across components */ 35 | export const isWalletConnectedAtom = atom(false); 36 | -------------------------------------------------------------------------------- /src/app/lib/fonts.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Noto_Sans, 3 | Noto_Sans_Display, 4 | Noto_Sans_Mono, 5 | // Noto_Color_Emoji, 6 | } from 'next/font/google'; 7 | 8 | export const notoSans = Noto_Sans({ 9 | style: ['normal', 'italic'], 10 | weight: ['400', '700'], 11 | variable: '--font-noto-sans', 12 | subsets: [ 13 | // 'cyrillic', 14 | // 'cyrillic-ext', 15 | // 'devanagari', 16 | // 'greek', 17 | // 'greek-ext', 18 | 'latin', 19 | 'latin-ext', 20 | // 'vietnamese', 21 | ], 22 | display: 'swap', 23 | }); 24 | 25 | export const notoSansDisplay = Noto_Sans_Display({ 26 | variable: '--font-noto-sans-display', 27 | subsets: [ 28 | // 'cyrillic', 29 | // 'cyrillic-ext', 30 | // 'greek', 31 | // 'greek-ext', 32 | 'latin', 33 | 'latin-ext', 34 | // 'vietnamese', 35 | ], 36 | display: 'swap', 37 | }); 38 | 39 | export const notoSansMono = Noto_Sans_Mono({ 40 | variable: '--font-noto-sans-mono', 41 | subsets: [ 42 | // 'cyrillic', 43 | // 'cyrillic-ext', 44 | // 'greek', 45 | // 'greek-ext', 46 | 'latin', 47 | 'latin-ext', 48 | // 'vietnamese', 49 | ], 50 | display: 'swap', 51 | }); 52 | 53 | // export const notoColorEmoji = Noto_Color_Emoji({ 54 | // weight: ['400'], 55 | // variable: '--font-noto-color-emoji', 56 | // subsets: [ 'emoji' ], 57 | // display: 'swap', // Decreases perceived load time by reducing the "First Contentful Paint" time 58 | // }); 59 | -------------------------------------------------------------------------------- /src/e2e/not_found.spec.ts: -------------------------------------------------------------------------------- 1 | import { test as base, expect } from '@playwright/test'; 2 | import { LanguageSupport, NavBarComponent as NavBar } from './shared'; 3 | import { NotFoundPage } from './pageModels/NotFoundPage'; 4 | 5 | // Extend basic test by providing a "notFoundPage" fixture. 6 | // Code adapted from https://playwright.dev/docs/pom 7 | const test = base.extend<{ notFoundPage: NotFoundPage }>({ 8 | notFoundPage: async ({ page }, use) => { 9 | // Set up the fixture. 10 | const notFoundPage = new NotFoundPage(page); 11 | await notFoundPage.goto(); 12 | // Use the fixture value in the test. 13 | await use(notFoundPage); 14 | }, 15 | }); 16 | 17 | test.describe('Not Found Page', () => { 18 | 19 | test.describe('Language Support', () => { 20 | (new LanguageSupport({ 21 | // The new behavior of Next.js is to have no title. It is not certain if this is the intended 22 | // behavior or not, but it is fine for this web app. 23 | en: { body: /Page Not Found/, title: new RegExp('') }, 24 | es: { body: /Página no encontrada/, title: new RegExp('') }, 25 | 26 | // These old tests for the page title broke when upgrading to Next 15.2.4 27 | // en: { body: /Page Not Found/, title: /Transaction/ }, 28 | // es: { body: /Página no encontrada/, title: /transacciones/ }, 29 | })).check(test, NotFoundPage.url); 30 | }); 31 | 32 | test.describe('Nav Bar', () => { 33 | NavBar.check(test, NotFoundPage.getFullUrl()); 34 | }); 35 | 36 | }); 37 | -------------------------------------------------------------------------------- /src/e2e/shared/NavBarComponent.ts: -------------------------------------------------------------------------------- 1 | import { 2 | expect, 3 | type Page, 4 | type TestType, 5 | type PlaywrightTestArgs, 6 | type PlaywrightTestOptions, 7 | type PlaywrightWorkerArgs, 8 | type PlaywrightWorkerOptions 9 | } from '@playwright/test'; 10 | import { HomePage } from '../pageModels/HomePage'; 11 | 12 | export class NavBarComponent { 13 | /** Checks if the navigation bar has a link to the home page, which is usually the site name. 14 | * @param page Playwright Page fixture 15 | * @param lang Language prefix for the page 16 | */ 17 | static async hasHomeLink(page: Page, lang = 'en'): Promise { 18 | await page.getByRole('navigation').getByRole('link').click(); 19 | await expect(page).toHaveURL(HomePage.getFullUrl(lang)); 20 | } 21 | 22 | /** Runs all the tests for the navigation bar in the page with the given page URL 23 | * @param test Playwright "test" object 24 | * @param pageFullUrl URL of the page WITH the language prefix 25 | * @param lang Language prefix for the page 26 | */ 27 | static check( 28 | test: TestType< 29 | PlaywrightTestArgs & PlaywrightTestOptions, 30 | PlaywrightWorkerArgs & PlaywrightWorkerOptions 31 | >, 32 | pageFullUrl: string, 33 | lang = 'en', 34 | ): void { 35 | test.beforeEach(async ({ page }) => { 36 | await page.goto(pageFullUrl); 37 | }); 38 | 39 | test('has link to home page', async({ page }) => { 40 | await page.goto(pageFullUrl); 41 | await this.hasHomeLink(page, lang); 42 | }); 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /src/app/i18n/settings.ts: -------------------------------------------------------------------------------- 1 | type LanguageData = { 2 | // The key must be an ISO 639-1 language code 3 | [lang: string]: { 4 | /** Full name used to display the currently selected language. */ 5 | name: string, 6 | /** Name used in list of supported languages. Usually the same as the full name. */ 7 | listName: string, 8 | } 9 | }; 10 | 11 | /** Collection of supported languages */ 12 | export const supportedLangs: LanguageData = { 13 | en: { 14 | name: 'English', 15 | listName: 'English (US)', 16 | }, 17 | es: { 18 | name: 'Español', 19 | listName: 'Español', 20 | }, 21 | // ADD DATA FOR NEW LANGUAGE HERE 22 | }; 23 | 24 | /* 25 | * NOTE: This code was copied (with a few modifications) from 26 | * https://github.com/i18next/next-13-app-dir-i18next-example-ts/blob/main/app/i18n/settings.ts 27 | */ 28 | 29 | export const fallbackLng: string = 'en'; 30 | export const defaultNS: string = 'common'; 31 | 32 | export type i18nOptions = { 33 | debug?: boolean, 34 | supportedLngs: string[], 35 | fallbackLng: string, 36 | lng: string, 37 | fallbackNS: string, 38 | defaultNS: string, 39 | ns: string | string[], 40 | }; 41 | 42 | export function getOptions (lng = fallbackLng, ns: string | string[] = defaultNS): i18nOptions { 43 | return { 44 | debug: process.env.I18NEXT_DEBUG?.toLowerCase() === 'true', 45 | supportedLngs: Object.keys(supportedLangs), 46 | // preload: languages, 47 | fallbackLng, 48 | lng, 49 | fallbackNS: defaultNS, 50 | defaultNS, 51 | ns, 52 | }; 53 | } 54 | -------------------------------------------------------------------------------- /src/app/[lang]/txn/send/page.tsx: -------------------------------------------------------------------------------- 1 | import { Suspense, use } from 'react'; 2 | import { type Metadata } from 'next'; 3 | import { BuilderSteps, PageLoadingPlaceholder, PageTitleHeading } from '@/app/[lang]/components'; 4 | import { generateLangAltsMetadata, useTranslation } from '@/app/i18n'; 5 | import SendTxn from './components/SendTxn'; 6 | 7 | export async function generateMetadata( 8 | props: { params: Promise<{ lang: string }> } 9 | ): Promise { 10 | const params = await props.params; 11 | // eslint-disable-next-line react-hooks/rules-of-hooks 12 | const { t } = await useTranslation(params.lang, ['send_txn', 'app']); 13 | const path = '/txn/send'; 14 | return { 15 | title: t('page_title', {page: t('title'), site: t('site_name')}), 16 | alternates: { 17 | canonical: `/${params.lang}${path}`, 18 | languages: generateLangAltsMetadata(path) 19 | }, 20 | }; 21 | } 22 | 23 | /** Make Next JS generate at static version of this page */ 24 | export function generateStaticParams() { return ['send']; } 25 | 26 | /** Send Transaction page */ 27 | export default function SendTxnPage(props: { params: Promise<{ lang: string }> }) { 28 | const { lang } = use(props.params); 29 | const { t } = use(useTranslation(lang, ['send_txn', 'common'])); 30 | return ( 31 |
    32 | 33 | {t('title')} 34 | }> 35 | 36 | 37 |
    38 | ); 39 | } 40 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | ## Description 4 | 5 | 6 | ## Motivation and Context 7 | 8 | 9 | 10 | ## How Has This Been Tested? 11 | 12 | 13 | 14 | 15 | ## Screenshots (if appropriate): 16 | 17 | ## Types of changes 18 | 19 | - [ ] Bug fix (non-breaking change which fixes an issue) 20 | - [ ] New feature (non-breaking change which adds functionality) 21 | - [ ] Breaking change (fix or feature that would cause existing functionality to change) 22 | 23 | ## Checklist: 24 | 25 | 26 | - [ ] My code follows the code style of this project. 27 | - [ ] My change requires a change to the **documentation**. 28 | - [ ] I have updated the **documentation** accordingly. 29 | - [ ] My change requires a change to the **translations**. 30 | - [ ] I have updated the **translations** accordingly. 31 | - [ ] I have read the [Contributing Guidelines](CONTRIBUTING.md). 32 | - [ ] I have added tests to cover my changes. 33 | - [ ] All new and existing tests passed. 34 | -------------------------------------------------------------------------------- /src/app/[lang]/components/NavBar/LanguageSelector/LanguageSelector.test.tsx: -------------------------------------------------------------------------------- 1 | import '@testing-library/jest-dom'; 2 | import { render, screen } from '@testing-library/react'; 3 | import userEvent from '@testing-library/user-event'; 4 | import i18nextClientMock from '@/app/lib/testing/i18nextClientMock'; 5 | 6 | // Mock i18next before modules that use it are imported 7 | jest.mock('react-i18next', () => i18nextClientMock); 8 | 9 | // Mock navigation hooks 10 | jest.mock('next/navigation', () => ({ 11 | usePathname: () => '/current/url/of/page', 12 | useSearchParams: () => ({toString: () => 'q=yes'}) 13 | })); 14 | 15 | // Mock I18n settings 16 | jest.mock('../../../../../app/i18n/settings', () => ({ 17 | supportedLangs: { 18 | foo: { 19 | name: 'Foo Language', 20 | listName: 'Foo Language (Machine-translated)', 21 | }, 22 | bar: { 23 | name: 'Bar Language', 24 | listName: 'Bar Language (42)', 25 | } 26 | } 27 | })); 28 | 29 | import LanguageSelector from './LanguageSelector'; 30 | 31 | describe('Language Selector', () => { 32 | 33 | it('displays current language', () => { 34 | render(); 35 | expect(screen.getByRole('button')).toHaveTextContent(/Foo Language/); 36 | expect(screen.getByRole('button')).toHaveTextContent(/FOO/); 37 | }); 38 | 39 | it('displays list of languages in menu', async () => { 40 | render(); 41 | 42 | await userEvent.click(screen.getByRole('button')); 43 | 44 | expect(screen.getByText('Foo Language (Machine-translated)')).toBeInTheDocument(); 45 | expect(screen.getByText('Bar Language (42)')).toBeInTheDocument(); 46 | }); 47 | 48 | }); 49 | -------------------------------------------------------------------------------- /src/app/[lang]/txn/compose/components/wallet/WalletConnected.test.tsx: -------------------------------------------------------------------------------- 1 | import '@testing-library/jest-dom'; 2 | import { render, screen } from '@testing-library/react'; 3 | import userEvent from '@testing-library/user-event'; 4 | import { TFunction } from 'i18next'; 5 | import i18nextClientMock from '@/app/lib/testing/i18nextClientMock'; 6 | import { fooDisconnectFn, useWalletConnectedMock } from '@/app/lib/testing/useWalletMock'; 7 | 8 | // Mock i18next before modules that use it are imported 9 | jest.mock('react-i18next', () => i18nextClientMock); 10 | // Mock use-wallet 11 | jest.mock('@txnlab/use-wallet-react', () => useWalletConnectedMock); 12 | 13 | import ConnectWallet from './ConnectWallet'; 14 | 15 | describe('Wallet Connect (in Settings) (Connected wallet)', () => { 16 | const t = i18nextClientMock.useTranslation().t as TFunction; 17 | 18 | it('has "disconnect" button', () => { 19 | render( {}} />); 20 | expect(screen.getByText('app:wallet.disconnect')).toHaveRole('button'); 21 | }); 22 | 23 | it('disconnects wallet when "disconnect" button is clicked', async () => { 24 | render( {}} />); 25 | await userEvent.click(screen.getByText('app:wallet.disconnect')); 26 | expect(fooDisconnectFn).toHaveBeenCalledTimes(1); 27 | }); 28 | 29 | it('calls function to set field when "use connected account" button clicked', async () => { 30 | const setValFnMock = jest.fn(); 31 | render(); 32 | await userEvent.click(screen.getByText('app:wallet.set_field_to_connected_addr')); 33 | expect(setValFnMock).toHaveBeenCalledTimes(1); 34 | }); 35 | 36 | }); 37 | -------------------------------------------------------------------------------- /src/app/[lang]/components/NavBar/LanguageSelector/LanguageSelector.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import * as DropdownMenu from '@radix-ui/react-dropdown-menu'; 4 | import { supportedLangs } from '@/app/i18n/settings'; 5 | import { IconLanguage } from '@tabler/icons-react'; 6 | import LanguageMenuItem from './LanguageMenuItem'; 7 | 8 | type Props = { 9 | /** Language */ 10 | lng?: string 11 | }; 12 | 13 | /** Language selection button and menu */ 14 | export default function LanguageSelector({ lng }: Props) { 15 | return ( 16 | 17 | 18 | 25 | 26 | 27 | 28 |
      34 | {Object.keys(supportedLangs).map((l: string) => ( 35 | 36 | ))} 37 |
    38 |
    39 |
    40 |
    41 | ); 42 | } 43 | -------------------------------------------------------------------------------- /src/e2e/pageModels/HomePage.ts: -------------------------------------------------------------------------------- 1 | import { type Page as PageFixture, type Locator } from '@playwright/test'; 2 | 3 | export class HomePage { 4 | /** Page fixture from Playwright */ 5 | readonly page: PageFixture; 6 | /** URL without the language prefix */ 7 | static readonly url = ''; 8 | /** The "start" button link that directs the user to use the app. */ 9 | readonly startBtn: Locator; 10 | /** The "compose transaction" button that directs user to compose a transaction */ 11 | readonly composeTxnBtn: Locator; 12 | /** The "sign transaction" button that directs user to compose a transaction */ 13 | readonly signTxnBtn: Locator; 14 | /** The "send transaction" button that directs user to compose a transaction */ 15 | readonly sendTxnBtn: Locator; 16 | 17 | /** 18 | * @param page Page fixture from Playwright 19 | */ 20 | constructor(page: PageFixture) { 21 | this.page = page; 22 | this.startBtn = page.getByTestId('startBtn'); 23 | this.composeTxnBtn = page.getByTestId('composeTxnBtn'); 24 | this.signTxnBtn = page.getByTestId('signTxnBtn'); 25 | this.sendTxnBtn = page.getByTestId('sendTxnBtn'); 26 | } 27 | 28 | /** Get the URL with language prefix. 29 | * @param lang The language prefix. Must be an ISO 639-1 code 30 | * @returns The URL with the language prefix 31 | */ 32 | static getFullUrl(lang = 'en') { 33 | return '/' + lang + HomePage.url; 34 | } 35 | 36 | /** Go to the page 37 | * @param lang The language prefix of the page to go to. 38 | * @param query The query parameter string with the question mark at the beginning 39 | * (e.g. "?a=1&b=2") 40 | */ 41 | async goto(lang = 'en', query = '') { 42 | return await this.page.goto(HomePage.getFullUrl(lang) + query); 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /.github/workflows/release_container_image.yml: -------------------------------------------------------------------------------- 1 | # See https://docs.github.com/en/actions/use-cases-and-examples/publishing-packages/publishing-docker-images#publishing-images-to-github-packages 2 | name: Release - Container image 3 | on: 4 | release: 5 | types: [released] 6 | workflow_dispatch: null 7 | env: 8 | REGISTRY: ghcr.io 9 | IMAGE_NAME: ${{ github.repository }} 10 | jobs: 11 | push_to_registries: 12 | name: Push container image to multiple registries 13 | runs-on: ubuntu-latest 14 | permissions: 15 | packages: write 16 | contents: read 17 | attestations: write 18 | id-token: write 19 | steps: 20 | - name: Check out the repo 21 | uses: actions/checkout@v4 22 | - name: Log in to the container registry 23 | uses: docker/login-action@v3 24 | with: 25 | registry: ${{ env.REGISTRY }} 26 | username: ${{ github.actor }} 27 | password: ${{ secrets.GITHUB_TOKEN }} 28 | - name: Extract metadata (tags, labels) for Docker 29 | id: meta 30 | uses: docker/metadata-action@v5 31 | with: 32 | images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} 33 | - name: Build and push container image 34 | id: push 35 | uses: docker/build-push-action@v6 36 | with: 37 | context: . 38 | file: ./Containerfile 39 | push: true 40 | tags: ${{ steps.meta.outputs.tags }} 41 | labels: ${{ steps.meta.outputs.labels }} 42 | - name: Generate artifact attestation 43 | uses: actions/attest-build-provenance@v1 44 | with: 45 | subject-name: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME}} 46 | subject-digest: ${{ steps.push.outputs.digest }} 47 | push-to-registry: true 48 | -------------------------------------------------------------------------------- /src/app/lib/testing/duck_poem.txt: -------------------------------------------------------------------------------- 1 | The Duck That Lost His Quack 2 | 3 | A Duck woke up late one day last week, 4 | And all he could do was to squeak. 5 | He looked everywhere and listened to different things, 6 | Even heard sounds all around, from pings to zings. 7 | 8 | For example, he tried many gates, stairs, and barn doors, 9 | Then went and stepped on cracks in nearby creaky floors. 10 | He visited several witches, doctors and some were both, 11 | They prescribed everything from lemons to ginger troth. 12 | 13 | In his travels, he came across a quaint woodshop, 14 | Being so tired, he sat down with a solid plop. 15 | A carpenter saw that the Duck was so very sad, 16 | From behind the counter, he came to help the lad. 17 | 18 | After hearing of the tale of a missing sound, 19 | The carpenter leapt up with a double bound. 20 | He said, “From within is where it comes, 21 | Not outside, as most would sum.” 22 | 23 | “I have made many instruments for music, 24 | And what you need is something acoustic.” 25 | He brought out a short board with a nail, 26 | Then attached several metal strings to a pail. 27 | 28 | The carpenter said, “Play away and listen to the sounds in your head.” 29 | The Duck strummed everything from Enya to the Grateful Dead. 30 | After a fashion, the Duck was soon lost in the tunes, 31 | And started to dance and sing like a midnight Lune. 32 | 33 | Who knew that this Duck had a knack, 34 | And in the middle of it all started to quack. 35 | So you see, it’s not external to what you seek, 36 | In many cases, its internal and who you meet. 37 | 38 | 39 | 40 | Written by Michael Eastman, 8-25-2015, 41 | 42 | This, after listening to Bubbles the Mouse speak, 43 | And hearing a long story composed of squeaks. 44 | 45 | https://www.poetrysoup.com/poem/the_duck_that_lost_his_quack_704294 46 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.yml: -------------------------------------------------------------------------------- 1 | name: 🐛 Bug Report 2 | description: File a bug report 3 | labels: [bug] 4 | body: 5 | - type: textarea 6 | id: describe-bug 7 | attributes: 8 | label: Describe the bug 9 | description: A clear and concise description of what the bug is. 10 | validations: 11 | required: true 12 | - type: textarea 13 | id: reproduce-steps 14 | attributes: 15 | label: To reproduce 16 | description: Steps to reproduce the behavior. 17 | placeholder: | 18 | 1. Go to '...' 19 | 2. Click on '....' 20 | 3. Scroll down to '....' 21 | 4. See error 22 | - type: textarea 23 | id: expected-behavior 24 | attributes: 25 | label: Expected behavior 26 | description: A clear and concise description of what you expected to happen. 27 | - type: dropdown 28 | id: website 29 | attributes: 30 | label: Which website are you seeing the problem on? 31 | multiple: true 32 | options: 33 | - txnduck.vercel.app (Production) 34 | - txnduck-preview.vercel.app (Preview) 35 | - Local installation 36 | - Other 37 | - type: dropdown 38 | id: browsers 39 | attributes: 40 | label: What browsers are you seeing the problem on? 41 | multiple: true 42 | options: 43 | - Firefox 44 | - Chrome 45 | - Safari 46 | - Microsoft Edge 47 | - Other 48 | - type: textarea 49 | id: additional-context 50 | attributes: 51 | label: Additional context 52 | description: | 53 | Links? References? Anything that will give us more context about the issue you are encountering! 54 | 55 | Tip: You can attach images or log files by clicking this area to highlight it and then dragging files in. 56 | -------------------------------------------------------------------------------- /src/app/[lang]/components/ThemeChanger.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { useAtom } from "jotai"; 4 | import { RESET } from "jotai/utils"; 5 | import { RadioButtonGroupField } from "@/app/[lang]/components/form"; 6 | import { useTranslation } from "@/app/i18n/client"; 7 | import { themeAtom, Themes } from "@/app/lib/app-settings"; 8 | import { applyTheme } from "@/app/lib/utils"; 9 | 10 | type Props = { 11 | /** Language */ 12 | lng?: string, 13 | /** Function for notifying the user. If undefined, the user is not notified. */ 14 | notify?: () => void, 15 | /** Classes to add to the container */ 16 | containerClass?: string, 17 | /** Classes to add to the label */ 18 | labelClass?: string, 19 | /** Classes to add to the element for the text content of the label */ 20 | labelTextClass?: string, 21 | }; 22 | 23 | export default function ThemeChanger(props: Props) { 24 | const { t } = useTranslation(props.lng || '', ['app', 'common']); 25 | const [theme, setTheme] = useAtom(themeAtom); 26 | return ( 27 | applyTheme( 41 | e.target.value as Themes, 42 | theme => setTheme(theme === '' ? RESET : theme), 43 | props.notify 44 | )} 45 | /> 46 | ); 47 | } 48 | -------------------------------------------------------------------------------- /src/app/[lang]/txn/compose/components/fields/AssetConfigFields/UnitName.tsx: -------------------------------------------------------------------------------- 1 | import { type TFunction } from 'i18next'; 2 | import { useAtomValue } from 'jotai'; 3 | import { FieldErrorMessage, TextField } from '@/app/[lang]/components/form'; 4 | import { 5 | UNIT_NAME_MAX_LENGTH, 6 | assetConfigFormControlAtom, 7 | showFormErrorsAtom, 8 | tipBtnClass, 9 | tipContentClass, 10 | } from '@/app/lib/txn-data'; 11 | 12 | export default function UnitName({ t }: { t: TFunction }) { 13 | const form = useAtomValue(assetConfigFormControlAtom); 14 | const showFormErrors = useAtomValue(showFormErrorsAtom); 15 | // If creation transaction 16 | return (!form.values.caid && <> 17 | form.handleOnChange('apar_un')(e.target.value)} 36 | onFocus={form.handleOnFocus('apar_un')} 37 | onBlur={form.handleOnBlur('apar_un')} 38 | /> 39 | {(showFormErrors || form.touched.apar_un) && form.fieldErrors.apar_un && 40 | 44 | } 45 | ); 46 | } 47 | -------------------------------------------------------------------------------- /src/app/[lang]/txn/compose/components/fields/AssetConfigFields/AssetName.tsx: -------------------------------------------------------------------------------- 1 | import { type TFunction } from 'i18next'; 2 | import { useAtomValue } from 'jotai'; 3 | import { FieldErrorMessage, TextField } from '@/app/[lang]/components/form'; 4 | import { 5 | ASSET_NAME_MAX_LENGTH, 6 | assetConfigFormControlAtom, 7 | showFormErrorsAtom, 8 | tipBtnClass, 9 | tipContentClass, 10 | } from '@/app/lib/txn-data'; 11 | 12 | export default function AssetName({ t }: { t: TFunction }) { 13 | const form = useAtomValue(assetConfigFormControlAtom); 14 | const showFormErrors = useAtomValue(showFormErrorsAtom); 15 | // If creation transaction 16 | return (!form.values.caid && <> 17 | form.handleOnChange('apar_an')(e.target.value)} 36 | onFocus={form.handleOnFocus('apar_an')} 37 | onBlur={form.handleOnBlur('apar_an')} 38 | /> 39 | {(showFormErrors || form.touched.apar_an) && form.fieldErrors.apar_an && 40 | 44 | } 45 | ); 46 | } 47 | -------------------------------------------------------------------------------- /src/app/[lang]/txn/compose/components/fields/KeyRegFields/Nonparticipation.tsx: -------------------------------------------------------------------------------- 1 | import { useSearchParams } from 'next/navigation'; 2 | import { type TFunction } from 'i18next'; 3 | import { useAtomValue } from 'jotai'; 4 | import { IconAlertTriangle } from '@tabler/icons-react'; 5 | import { ToggleField } from '@/app/[lang]/components/form'; 6 | import { 7 | Preset, 8 | keyRegFormControlAtom, 9 | tipBtnClass, 10 | tipContentClass, 11 | } from '@/app/lib/txn-data'; 12 | 13 | export default function Nonparticipation({ t }: { t: TFunction }) { 14 | const form = useAtomValue(keyRegFormControlAtom); 15 | const preset = useSearchParams().get(Preset.ParamName); 16 | return (!( 17 | form.values.votekey || form.values.selkey || form.values.sprfkey 18 | || form.values.votefst || form.values.votelst || form.values.votekd 19 | ) && <> 20 | { 37 | form.setTouched('nonpart', true); 38 | form.handleOnChange('nonpart')(e.target.checked); 39 | }} 40 | /> 41 | {!!form.values.nonpart 42 | ?
    43 | 44 | {t('fields.nonpart.warning')} 45 |
    46 | : undefined 47 | } 48 | ); 49 | } 50 | -------------------------------------------------------------------------------- /src/app/[lang]/txn/compose/components/fields/AssetConfigFields/URL.tsx: -------------------------------------------------------------------------------- 1 | import { type TFunction } from 'i18next'; 2 | import { useAtomValue } from 'jotai'; 3 | import { FieldErrorMessage, TextField } from '@/app/[lang]/components/form'; 4 | import { 5 | URL_MAX_LENGTH, 6 | assetConfigFormControlAtom, 7 | showFormErrorsAtom, 8 | tipBtnClass, 9 | tipContentClass, 10 | } from '@/app/lib/txn-data'; 11 | 12 | export default function URL({ t }: { t: TFunction }) { 13 | const form = useAtomValue(assetConfigFormControlAtom); 14 | const showFormErrors = useAtomValue(showFormErrorsAtom); 15 | // If creation transaction 16 | return (!form.values.caid && <> 17 | form.handleOnChange('apar_au')(e.target.value)} 37 | onFocus={form.handleOnFocus('apar_au')} 38 | onBlur={form.handleOnBlur('apar_au')} 39 | inputMode='url' 40 | /> 41 | {(showFormErrors || form.touched.apar_au) && form.fieldErrors.apar_au && 42 | 46 | } 47 | ); 48 | } 49 | -------------------------------------------------------------------------------- /src/app/[lang]/group/compose/page.tsx: -------------------------------------------------------------------------------- 1 | import { use } from 'react'; 2 | import { type Metadata } from 'next'; 3 | import { 4 | IconTrafficCone 5 | } from '@tabler/icons-react'; 6 | import { BuilderSteps, PageTitleHeading } from '@/app/[lang]/components'; 7 | import { generateLangAltsMetadata, useTranslation } from '@/app/i18n'; 8 | import GrpComposeList from './components/GrpComposeList'; 9 | 10 | export async function generateMetadata( 11 | props: { params: Promise<{ lang: string }> } 12 | ): Promise { 13 | const params = await props.params; 14 | // eslint-disable-next-line react-hooks/rules-of-hooks 15 | const { t } = await useTranslation(params.lang, ['grp_compose', 'app']); 16 | const path = '/group/compose'; 17 | return { 18 | title: t('page_title', {page: t('title'), site: t('site_name')}), 19 | alternates: { 20 | canonical: `/${params.lang}${path}`, 21 | languages: generateLangAltsMetadata(path) 22 | }, 23 | }; 24 | } 25 | 26 | /** Make Next JS generate at static version of this page */ 27 | export function generateStaticParams() { return ['group_compose']; } 28 | 29 | /** Choose Transaction Group Presets page */ 30 | export default function GroupComposePage(props: { params: Promise<{ lang: string }> }) { 31 | const { lang } = use(props.params); 32 | const { t } = use(useTranslation(lang, ['grp_compose', 'app'])); 33 | return ( 34 |
    35 |
    36 | 37 |

    {t('page_under_construction')}

    38 |
    39 | 40 | {t('title')} 41 | 42 |
    43 | ); 44 | } 45 | -------------------------------------------------------------------------------- /src/app/[lang]/components/NavBar/NodeSelector/NodeMenuItem.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { usePathname, useRouter, useSearchParams } from 'next/navigation'; 4 | import * as DropdownMenu from '@radix-ui/react-dropdown-menu'; 5 | import { useSetAtom } from 'jotai'; 6 | import * as NodeConfigLib from '@/app/lib/node-config'; 7 | 8 | type Props = { 9 | /** The node configuration data to be applied and stored when the menu item is clicked */ 10 | config: NodeConfigLib.NodeConfig 11 | /** Contents (icon & name) of the menu item */ 12 | children: React.ReactNode 13 | }; 14 | 15 | /** Item (network) in the node selector menu */ 16 | export default function NodeMenuItem({ config, children }: Props) { 17 | const router = useRouter(); 18 | const setNodeConfig = useSetAtom(NodeConfigLib.nodeConfigAtom); 19 | const pathName = usePathname(); 20 | const currentURLParams = useSearchParams(); 21 | 22 | /** Set the node configuration to the given configuration and apply the change 23 | * @param newConfig The new node configuration to apply 24 | */ 25 | const updateNodeConfig = (newConfig: NodeConfigLib.NodeConfig) => { 26 | setNodeConfig(newConfig); 27 | 28 | // The new node configuration isn't used unless the wallet provider is reloaded, which happens 29 | // when the page is refreshed. Also, remove the network specified in the URL, if present. 30 | const newURLParams = new URLSearchParams(currentURLParams.toString()); 31 | 32 | if (newURLParams.size) { // The current URL has query parameters 33 | newURLParams.delete(NodeConfigLib.networkURLParamName); 34 | router.push(`${pathName}?${newURLParams}`); 35 | } 36 | }; 37 | 38 | return ( 39 | 40 |
  • updateNodeConfig(config)}> 41 | {children} 42 |
  • 43 |
    44 | ); 45 | } 46 | -------------------------------------------------------------------------------- /src/app/i18n/locales/en/send_txn.yml: -------------------------------------------------------------------------------- 1 | title: Send Transaction 2 | txn_confirm_wait: Waiting for transaction to be confirmed… 3 | click_for_details: Click “{{details_name}}” below for more information. 4 | fail: 5 | heading: Transaction failed. 6 | rejected_msg: >- 7 | The node rejected the transaction. It is most likely that at least one of the transaction fields 8 | contains an error. 9 | http_400_msg: >- 10 | The transaction failed to be confirmed. It is most likely that at least one of the transaction 11 | fields contains an error. 12 | http_401_msg: The transaction could not be sent. The API token for the Algorand node is invalid. 13 | http_500_msg: The transaction could not be sent. The Algorand node is malfunctioning. 14 | http_503_msg: The transaction could not be sent. The Algorand node is not available at the moment. 15 | http_unknown_msg: The transaction could not be sent. 16 | unknown_msg: The transaction could not be confirmed. 17 | details: Error details 18 | warn: 19 | heading: Transaction may have failed. 20 | not_confirmed_msg: >- 21 | The transaction was not confirmed after {{count, number}} round(s). However, if the 22 | transaction’s last valid round is still in the future, the transaction may 23 | still be confirmed later. 24 | success: 25 | heading: Transaction confirmed! 26 | msg: "Transaction ID: {{txn_id}}" 27 | details: More information 28 | done_btn: Done! 29 | wait_longer_btn: Wait longer 30 | retry_btn: Retry 31 | quit_btn: Quit 32 | compose_txn_btn: Edit transaction fields 33 | sign_txn_btn: Sign transaction again 34 | make_new_txn_btn: Make another transaction 35 | import_txn: 36 | label: Import signed transaction file 37 | overwrite_warning: >- 38 | You have a saved incomplete transaction. If you import a transaction from a file, the saved 39 | transaction will get overwritten. 40 | cancel: Cancel import 41 | -------------------------------------------------------------------------------- /src/app/lib/txn-data/data-utils.ts: -------------------------------------------------------------------------------- 1 | /** @file Useful utilities for managing transaction data */ 2 | 3 | import { Algodv2 } from "algosdk"; 4 | import { NodeConfig } from "@/app/lib/node-config"; 5 | import { DEFAULT_NODE_CONFIG } from "@/app/lib/node-config"; 6 | import { RetrievedAssetInfo } from "./types"; 7 | 8 | /** Get information about the asset with the given ID 9 | * @param assetId ID of the asset of which to get the information 10 | * @param nodeConfig Configuration for the node used query the chain to get the asset information 11 | * @param callback Function executed when information retrieval has succeeded or failed. If the 12 | * information retrieval succeeded, the asset information is passed into the 13 | * callback as an argument. If the information retrieval failed, `undefined` is 14 | * passed into the callback as an argument. 15 | */ 16 | export const getAssetInfo = async ( 17 | assetId?: number, 18 | nodeConfig: NodeConfig = DEFAULT_NODE_CONFIG, 19 | callback: (info?: RetrievedAssetInfo) => void = ()=>{}, 20 | ) => { 21 | if (assetId) { 22 | try { 23 | const algod = new Algodv2( 24 | nodeConfig.nodeToken ?? '', 25 | nodeConfig.nodeServer, 26 | nodeConfig.nodePort, 27 | nodeConfig.nodeHeaders 28 | ); 29 | const assetInfo = await algod.getAssetByID(assetId).do(); 30 | callback({ 31 | id: assetInfo.index.toString(), 32 | name: assetInfo.params.name, 33 | unitName: assetInfo.params.unitName, 34 | total: assetInfo.params.total.toString(), 35 | decimals: assetInfo.params.decimals, 36 | manager: assetInfo.params.manager, 37 | freeze: assetInfo.params.freeze, 38 | clawback: assetInfo.params.clawback, 39 | reserve: assetInfo.params.reserve, 40 | }); 41 | } catch (error) { 42 | console.error(error); 43 | callback(undefined); 44 | } 45 | } 46 | }; 47 | -------------------------------------------------------------------------------- /src/app/[lang]/txn/compose/components/fields/PaymentFields/Amount.tsx: -------------------------------------------------------------------------------- 1 | import { type TFunction } from 'i18next'; 2 | import { useAtomValue } from 'jotai'; 3 | import { FieldErrorMessage, NumberField } from '@/app/[lang]/components/form'; 4 | import { 5 | paymentFormControlAtom, 6 | showFormErrorsAtom, 7 | tipBtnClass, 8 | tipContentClass 9 | } from '@/app/lib/txn-data'; 10 | import { nodeConfigAtom } from '@/app/lib/node-config'; 11 | 12 | export default function Amount({ t }: { t: TFunction }) { 13 | const form = useAtomValue(paymentFormControlAtom); 14 | const showFormErrors = useAtomValue(showFormErrorsAtom); 15 | const nodeConfig = useAtomValue(nodeConfigAtom); 16 | return (<> 17 | 39 | form.handleOnChange('amt')(e.target.value === '' ? undefined : parseFloat(e.target.value)) 40 | } 41 | onFocus={form.handleOnFocus('amt')} 42 | onBlur={form.handleOnBlur('amt')} 43 | /> 44 | {(showFormErrors || form.touched.amt) && form.fieldErrors.amt && 45 | 49 | } 50 | ); 51 | } 52 | -------------------------------------------------------------------------------- /src/app/[lang]/txn/sign/page.tsx: -------------------------------------------------------------------------------- 1 | import { Suspense, use } from 'react'; 2 | import { type Metadata } from 'next'; 3 | import { 4 | BuilderSteps, 5 | PageLoadingPlaceholder, 6 | PageTitleHeading, 7 | WalletProvider 8 | } from '@/app/[lang]/components'; 9 | import { generateLangAltsMetadata, useTranslation } from '@/app/i18n'; 10 | import TxnDataTable from './components/TxnDataTable'; 11 | import SignTxn from './components/SignTxn'; 12 | import TxnImport from './components/TxnImport'; 13 | 14 | export async function generateMetadata( 15 | props: { params: Promise<{ lang: string }> } 16 | ): Promise { 17 | const params = await props.params; 18 | // eslint-disable-next-line react-hooks/rules-of-hooks 19 | const { t } = await useTranslation(params.lang, ['sign_txn', 'app']); 20 | const path = '/txn/sign'; 21 | return { 22 | title: t('page_title', {page: t('title'), site: t('site_name')}), 23 | alternates: { 24 | canonical: `/${params.lang}${path}`, 25 | languages: generateLangAltsMetadata(path) 26 | }, 27 | }; 28 | } 29 | 30 | /** Make Next JS generate at static version of this page */ 31 | export function generateStaticParams() { return ['sign']; } 32 | 33 | /** Sign Transaction page */ 34 | export default function SignTxnPage(props: { params: Promise<{ lang: string }> }) { 35 | const { lang } = use(props.params); 36 | const { t } = use(useTranslation(lang, ['sign_txn', 'app'])); 37 | return ( 38 |
    39 | 40 | {t('title')} 41 | 42 | }> 43 | 44 | 45 | 46 |
    47 | ); 48 | } 49 | -------------------------------------------------------------------------------- /src/app/[lang]/components/BuilderSteps.tsx: -------------------------------------------------------------------------------- 1 | import { use } from 'react'; 2 | import Link from 'next/link'; 3 | import { useTranslation } from '@/app/i18n'; 4 | 5 | type Props = { 6 | /** Language */ 7 | lng?: string, 8 | /** Name of the current step */ 9 | current?: 'compose' | 'sign' | 'send' | 'done', 10 | /** Color of current and completed steps */ 11 | color?: 'primary' | 'secondary' | 'accent', 12 | }; 13 | 14 | /** Roadmap display for showing the steps of building a transaction */ 15 | export default function BuilderSteps({ lng, current, color = 'primary'}: Props) { 16 | const { t } = use(useTranslation(lng || '', 'app')); 17 | 18 | return ( 19 |
      20 |
    • 25 | {current === 'compose' 26 | ? t('builder_steps.compose') 27 | : 28 | {t('builder_steps.compose')} 29 | 30 | } 31 |
    • 32 |
    • 36 | {current === 'sign' 37 | ? t('builder_steps.sign') 38 | : 39 | {t('builder_steps.sign')} 40 | 41 | } 42 |
    • 43 |
    • 44 | {current === 'send' 45 | ? t('builder_steps.send') 46 | : 47 | {t('builder_steps.send')} 48 | 49 | } 50 |
    • 51 |
    52 | ); 53 | } 54 | -------------------------------------------------------------------------------- /src/app/i18n/locales/en/home.yml: -------------------------------------------------------------------------------- 1 | # Home page 2 | 3 | hero: # The attention-grabber that is first thing the user sees 4 | main_paragraph: Build almost any kind of Algorand transaction. 5 | start_button: Build a new transaction! 6 | what_is_this: 7 | heading: What is this? 8 | paragraph: >- 9 | A free and open source tool for easily building Algorand transactions. 10 | how_it_works: 11 | heading: How does it work? 12 | compose: 13 | heading: 1. Compose the transaction. 14 | paragraph: >- 15 | Begin creating a new transaction by entering the transaction information. Use one of the 16 | transaction presets to make this step faster and easier. 17 | button: Compose a new transaction 18 | sign: 19 | heading: 2. Sign the transaction. 20 | paragraph: >- 21 | Every transaction must be signed before it is sent. Connect your wallet and sign the 22 | transaction. If you saved a transaction as a file, you can import it to sign it. 23 | button: Import & sign a transaction 24 | send: 25 | heading: 3. Send the transaction. 26 | paragraph: >- 27 | Send the signed transaction. A successful transaction is usually confirmed and final in less 28 | than 4 seconds. If you saved a signed transaction as a file, you can import it to send it. 29 | button: Import & send a signed transaction 30 | uses: 31 | heading: What can I use this tool for? 32 | simple_things: 33 | heading: Simple things 34 | list: 35 | 0: Send Algos 36 | 1: Opt-in to a token or <1>ASA 37 | 2: Transfer an <1>NFT 38 | 3: And more 39 | complex_things: 40 | heading: Advanced or complex things 41 | list: 42 | 0: Mark an account as “online” to start participating consensus 43 | 1: Update an application (smart contract) 44 | 2: Revoke (Claw back) an asset 45 | 3: And more 46 | dangerous_things: 47 | heading: Dangerous things 48 | list: 49 | 0: Rekey an account 50 | 1: Close an account 51 | 2: And more 52 | -------------------------------------------------------------------------------- /src/app/icon.svg: -------------------------------------------------------------------------------- 1 | 9 | 10 | -------------------------------------------------------------------------------- /src/app/[lang]/components/NavBar/NavBar.test.tsx: -------------------------------------------------------------------------------- 1 | import '@testing-library/jest-dom'; 2 | import { render, screen } from '@testing-library/react'; 3 | import i18nextClientMock from '@/app/lib/testing/i18nextClientMock'; 4 | // This must be imported after the mock classes are imported 5 | import { JotaiProvider } from '@/app/[lang]/components'; 6 | 7 | // Mock react `use` function before modules that use it are imported 8 | jest.mock('react', () => ({ 9 | ...jest.requireActual('react'), 10 | use: () => ({ t: (key: string) => key }), 11 | })); 12 | 13 | // Mock useRouter because it is used by child components 14 | jest.mock('next/navigation', () => ({ 15 | useRouter: () => ({ push: jest.fn() }), 16 | usePathname: () => '/current/url/of/page', 17 | useSearchParams: () => ({ 18 | toString: () => 'q=yes', 19 | get: () => null, 20 | }), 21 | })); 22 | 23 | // Mock i18next because it is used by child components 24 | jest.mock('react-i18next', () => i18nextClientMock); 25 | 26 | // Mock the wallet provider 27 | jest.mock('../wallet/WalletProvider.tsx', () => 'div'); 28 | 29 | import NavBar from './NavBar'; 30 | 31 | describe('Nav Bar Component', () => { 32 | 33 | it('has site name', async () => { 34 | render(); 35 | expect(await screen.findByText('site_name_pt1')).toBeInTheDocument(); 36 | expect(screen.getByText('site_name_pt2')).toBeInTheDocument(); 37 | }); 38 | 39 | it('has node selector button', async () => { 40 | render(); 41 | expect(await screen.findByTitle('node_selector.choose_node')).toBeInTheDocument(); 42 | }); 43 | 44 | it('has language selector button', async () => { 45 | render(); 46 | expect(await screen.findByTestId('lang-btn')).toBeInTheDocument(); 47 | }); 48 | 49 | it('has settings button', async () => { 50 | render(); 51 | expect(await screen.findByTitle('settings.heading')).toBeInTheDocument(); 52 | }); 53 | 54 | }); 55 | -------------------------------------------------------------------------------- /src/app/[lang]/components/PageTitleHeading/PageTitleHeading.test.tsx: -------------------------------------------------------------------------------- 1 | import '@testing-library/jest-dom'; 2 | import { render, screen } from '@testing-library/react'; 3 | import i18nextClientMock from '@/app/lib/testing/i18nextClientMock'; 4 | 5 | // Mock i18next before modules that use it are imported 6 | jest.mock('react-i18next', () => i18nextClientMock); 7 | 8 | // Mock navigation hooks 9 | const mockGetSearchParam = jest.fn(); 10 | jest.mock('next/navigation', () => ({ 11 | useSearchParams: () => ({ 12 | get: mockGetSearchParam //(paramName: string) => (paramName === 'preset' ? 'foo' : null) 13 | }) 14 | })); 15 | 16 | import PageTitleHeading from './PageTitleHeading'; 17 | 18 | describe('PageTitleHeading Component', () => { 19 | beforeEach(() => { 20 | mockGetSearchParam.mockImplementation((param: string) => { 21 | if (param === 'preset') return 'foo'; 22 | if (param === 'import') return null; 23 | }); 24 | }); 25 | 26 | it('has heading', () => { 27 | render(Hello!); 28 | expect(screen.getByRole('heading')).toHaveTextContent('Hello!'); 29 | }); 30 | 31 | it('has badge with preset name if `showTxnPreset` is true', async() => { 32 | render(); 33 | expect(await screen.findByText(/foo\.heading/)).toBeInTheDocument(); 34 | }); 35 | 36 | it('does not have badge with preset name if `showTxnPreset` is false', () => { 37 | render(); 38 | expect(screen.queryByText(/foo\.heading/)).not.toBeInTheDocument(); 39 | }); 40 | 41 | it('does not have badge if the "import" parameter is present in the URL', () => { 42 | mockGetSearchParam.mockImplementation((param: string) => { 43 | if (param === 'preset') return 'foo'; 44 | if (param === 'import') return ''; 45 | }); 46 | render(); 47 | expect(screen.queryByText(/foo\.heading/)).not.toBeInTheDocument(); 48 | }); 49 | 50 | }); 51 | -------------------------------------------------------------------------------- /src/app/i18n/locales/en/common.yml: -------------------------------------------------------------------------------- 1 | # Common words and phrases used throughout the app 2 | 3 | home: Home # Home page name 4 | algo_std_asset: Algorand Standard Asset 5 | nonfungible_token: Non-Fungible Token 6 | close: Close # For a button to close dialog/modal 7 | none: None # Often used when displaying that some field was not given or was empty 8 | empty: Empty # Often used when displaying that some field was given but contains an "empty" value 9 | number_value: "{{value, number}}" # When displaying a number without any other text 10 | asset_amount: "{{amount, number}} {{asset}}" # For displaying amount of an asset 11 | loading: Loading… 12 | cancel: Cancel # Often used for cancel buttons 13 | 14 | # Unit name of the token for Algorand 15 | # More information about specifying singular and plural versions of a word: 16 | # https://www.i18next.com/translation-function/plurals#singular-plural 17 | algo_one: Algo # 1 Algo 18 | algo_other: Algos # Multiple Algos 19 | 20 | unit_one: unit # 1 unit 21 | unit_other: units # Multiple units 22 | 23 | # These labels are used to help screen readers 24 | # More information: https://www.radix-ui.com/primitives/docs/components/toast#api-reference 25 | toast: # Toast = an in-app notification 26 | provider_label: Notification # Should be singular 27 | viewport_label: Notifications ({hotkey}) # Should be plural, `{hotkey}` should not be translated 28 | 29 | form: # For forms 30 | required: Required # When a field is required 31 | error: 32 | required: This is required. 33 | string: 34 | length: This must be {{count, number}} characters long. 35 | length_bytes: This must be {{count, number}} bytes long. 36 | max: This must be at most {{count, number}} characters long. 37 | max_bytes: This must be at most {{count, number}} bytes long. 38 | email: This must be a valid email address. 39 | number: 40 | invalid: This is an invalid number. 41 | min: This must be at least {{min, number}}. 42 | max: This must be at most {{max, number}}. 43 | email_placeholder: someone@example.com 44 | -------------------------------------------------------------------------------- /src/app/[lang]/txn/compose/page.test.tsx: -------------------------------------------------------------------------------- 1 | import '@testing-library/jest-dom'; 2 | import { render, screen } from '@testing-library/react'; 3 | import i18nextClientMock from '@/app/lib/testing/i18nextClientMock'; 4 | import { useWalletUnconnectedMock } from '@/app/lib/testing/useWalletMock'; 5 | 6 | // Mock react `use` function before modules that use it are imported 7 | jest.mock('react', () => ({ 8 | ...jest.requireActual('react'), 9 | use: () => ({ t: (key: string) => key }), 10 | })); 11 | 12 | // Mock i18next before modules that use it are imported because it is used by a child component 13 | jest.mock('react-i18next', () => i18nextClientMock); 14 | 15 | // Mock navigation hooks because they are used by a child components 16 | jest.mock('next/navigation', () => ({ 17 | useRouter: () => ({ push: jest.fn() }), 18 | useSearchParams: () => ({get: () => 'foo'}) 19 | })); 20 | 21 | 22 | // Mock use-wallet before modules that use it are imported 23 | jest.mock('@txnlab/use-wallet-react', () => useWalletUnconnectedMock); 24 | // Mock the wallet provider 25 | jest.mock('../../components/wallet/WalletProvider.tsx', () => 'div'); 26 | 27 | import ComposeTxnPage from './page'; 28 | 29 | describe('Compose Transaction Page', () => { 30 | 31 | it('has builder steps', async () => { 32 | const pageParam = new Promise(resolve => { resolve({lang: ''}); }); 33 | render(); 34 | expect(await screen.findByText(/builder_steps\.compose/)).toBeInTheDocument(); 35 | }); 36 | 37 | it('has page title heading', async () => { 38 | const pageParam = new Promise(resolve => { resolve({lang: ''}); }); 39 | render(); 40 | expect(await screen.findByRole('heading', { level: 1 })).not.toBeEmptyDOMElement(); 41 | }); 42 | 43 | it('has form', async () => { 44 | const pageParam = new Promise(resolve => { resolve({lang: ''}); }); 45 | render(); 46 | expect(await screen.findByRole('form')).toBeInTheDocument(); 47 | }); 48 | 49 | }); 50 | -------------------------------------------------------------------------------- /src/app/i18n/locales/es/send_txn.yml: -------------------------------------------------------------------------------- 1 | title: Enviar la transacción 2 | txn_confirm_wait: A la espera de que se confirme la transacción… 3 | click_for_details: Haga clic en «{{details_name}}» para obtener más información. 4 | fail: 5 | heading: La transacción ha fallado. 6 | rejected_msg: >- 7 | El nodo ha rechazado la transacción. Lo más probable es que al menos uno de los datos de la 8 | transacción contenga un error. 9 | http_400_msg: >- 10 | No se ha podido confirmar la transacción. Lo más probable es que al menos uno de los datos de la 11 | transacción contenga un error. 12 | http_401_msg: >- 13 | No se ha podido enviar la transacción. El token de API para el nodo Algorand no es válido. 14 | http_500_msg: No se ha podido enviar la transacción. El nodo Algorand no funciona correctamente. 15 | http_503_msg: >- 16 | No se ha podido enviar la transacción. El nodo Algorand no está disponible en este momento. 17 | http_unknown_msg: No se ha podido enviar la transacción. 18 | unknown_msg: La transacción no ha podido confirmarse. 19 | details: Detalles del error 20 | warn: 21 | heading: La transacción puede haber fallado. 22 | not_confirmed_msg: >- 23 | La transacción no se confirmó después de {{count}} ronda(s). Sin embargo, si la última 24 | ronda válida de la transacción aún está en el futuro, la transacción aún puede 25 | confirmarse más tarde. 26 | success: 27 | heading: ¡Transacción confirmada! 28 | msg: "ID de la transacción: {{txn_id}}" 29 | details: Más información 30 | done_btn: ¡Hecho! 31 | wait_longer_btn: Esperar más 32 | retry_btn: Reintentar 33 | quit_btn: Abandonar 34 | compose_txn_btn: Editar los datos de la transacción 35 | sign_txn_btn: Firmar la transacción de nuevo 36 | make_new_txn_btn: Hacer otra transacción 37 | import_txn: 38 | label: Importar archivo de transacción firmado 39 | overwrite_warning: >- 40 | Usted tiene una transacción incompleta guardada. Si importa una transacción desde un archivo, la 41 | transacción guardada se sobrescribirá. 42 | cancel: Cancelar importación 43 | -------------------------------------------------------------------------------- /src/app/i18n/locales/es/common.yml: -------------------------------------------------------------------------------- 1 | # Common words and phrases used throughout the app 2 | 3 | home: Inicio # Home page name 4 | algo_std_asset: Algorand Standard Asset (El activo estándar de Algorand) 5 | nonfungible_token: Non-Fungible Token (La ficha no fungible) 6 | close: Cerrar # For a button to close dialog/modal 7 | none: Nada # Often used when displaying that some field was not given or was empty 8 | empty: Vacío # Often used when displaying that some field was given but contains an "empty" value 9 | number_value: "{{value, number}}" # When displaying a number without any other text 10 | asset_amount: "{{amount, number}} {{asset}}" # For displaying amount of an asset 11 | loading: Cargando… 12 | cancel: Cancelar # Often used for cancel buttons 13 | 14 | # Unit name of the token for Algorand 15 | # More information about specifying singular and plural versions of a word: 16 | # https://www.i18next.com/translation-function/plurals#singular-plural 17 | algo_one: Algo # 1 Algo 18 | algo_other: Algos # Multiple Algos 19 | 20 | unit_one: unidad # 1 unit 21 | unit_other: unidades # Multiple units 22 | 23 | # These labels are used to help screen readers 24 | # More information: https://www.radix-ui.com/primitives/docs/components/toast#api-reference 25 | toast: # Toast = an in-app notification 26 | provider_label: Notificación # Should be singular 27 | viewport_label: Notificaciones ({hotkey}) # Should be plural, `{hotkey}` should not be translated 28 | 29 | form: # For forms 30 | required: Obligatorio # When a field is required 31 | error: 32 | required: Esto es obligatorio. 33 | string: 34 | length: Debe tener {{count, number}} caracteres. 35 | length_bytes: Debe tener {{count, number}} bytes. 36 | max: Debe tener como máximo {{count, number}} caracteres. 37 | max_bytes: Debe tener como máximo {{count, number}} bytes. 38 | email: Debe ser una dirección de correo electrónico válida. 39 | number: 40 | invalid: Esto es un número inválido. 41 | min: Debe ser al menos {{min, number}}. 42 | max: Debe ser como máximo {{max, number}}. 43 | email_placeholder: alguien@ejemplo.com 44 | -------------------------------------------------------------------------------- /src/app/[lang]/privacy-policy/page.test.tsx: -------------------------------------------------------------------------------- 1 | import '@testing-library/jest-dom'; 2 | import { render, screen } from '@testing-library/react'; 3 | import i18nextClientMock from '@/app/lib/testing/i18nextClientMock'; 4 | 5 | // Mock react `use` function before modules that use it are imported 6 | jest.mock('react', () => ({ 7 | ...jest.requireActual('react'), 8 | use: () => ({ t: (key: string) => key }), 9 | })); 10 | 11 | // Mock i18next before modules that use it are imported because it is used by a child component 12 | jest.mock('react-i18next', () => i18nextClientMock); 13 | 14 | // Mock the wallet provider 15 | jest.mock('../components/wallet/WalletProvider.tsx', () => 'div'); 16 | 17 | import PrivacyPolicyPage from './page'; 18 | 19 | describe('Privacy Policy Page', () => { 20 | 21 | it('has page title heading', () => { 22 | const pageParam = new Promise(resolve => { resolve({lang: ''}); }); 23 | render(); 24 | expect(screen.getByRole('heading', { level: 1 })).not.toBeEmptyDOMElement(); 25 | }); 26 | 27 | it('shows information about Magic when it is enabled', () => { 28 | process.env.NEXT_PUBLIC_MAGIC_API_KEY = 'Some API Key'; 29 | const pageParam = new Promise(resolve => { resolve({lang: ''}); }); 30 | render(); 31 | 32 | expect(screen.getByText('personal_info.details_magic')).toBeInTheDocument(); 33 | expect(screen.queryByText('personal_info.details')).not.toBeInTheDocument(); 34 | expect(screen.getByText('magic_auth.heading')).toBeInTheDocument(); 35 | }); 36 | 37 | it('does not show information about Magic when it is disabled', () => { 38 | process.env.NEXT_PUBLIC_MAGIC_API_KEY = ''; 39 | const pageParam = new Promise(resolve => { resolve({lang: ''}); }); 40 | render(); 41 | 42 | expect(screen.getByText('personal_info.details')).toBeInTheDocument(); 43 | expect(screen.queryByText('personal_info.details_magic')).not.toBeInTheDocument(); 44 | expect(screen.queryByText('magic_auth.heading')).not.toBeInTheDocument(); 45 | }); 46 | 47 | }); 48 | -------------------------------------------------------------------------------- /src/app/[lang]/txn/compose/components/fields/AssetFreezeFields/TargetAddr.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect } from 'react'; 2 | import { useSearchParams } from 'next/navigation'; 3 | import { type TFunction } from 'i18next'; 4 | import { useAtomValue, useSetAtom } from 'jotai'; 5 | import { FieldErrorMessage, TextField } from '@/app/[lang]/components/form'; 6 | import { 7 | ADDRESS_LENGTH, 8 | Preset, 9 | assetFreezeFormControlAtom, 10 | presetAtom, 11 | showFormErrorsAtom, 12 | tipBtnClass, 13 | tipContentClass, 14 | } from '@/app/lib/txn-data'; 15 | 16 | export default function TargetAddr({ t }: { t: TFunction }) { 17 | const form = useAtomValue(assetFreezeFormControlAtom); 18 | const preset = useSearchParams().get(Preset.ParamName); 19 | const setPresetAtom = useSetAtom(presetAtom); 20 | const showFormErrors = useAtomValue(showFormErrorsAtom); 21 | 22 | // eslint-disable-next-line react-hooks/exhaustive-deps 23 | useEffect(() => setPresetAtom(preset), [preset]); 24 | 25 | return (<> 26 | form.handleOnChange('fadd')(e.target.value)} 47 | onFocus={form.handleOnFocus('fadd')} 48 | onBlur={form.handleOnBlur('fadd')} 49 | /> 50 | {(showFormErrors || form.touched.fadd) && form.fieldErrors.fadd && 51 | 55 | } 56 | ); 57 | } 58 | -------------------------------------------------------------------------------- /public/assets/silhouette-icon.svg: -------------------------------------------------------------------------------- 1 | 2 | 4 | 7 | 8 | Created by potrace 1.14, written by Peter Selinger 2001-2017 9 | 10 | 12 | 33 | 34 | 35 | -------------------------------------------------------------------------------- /src/app/i18n/locales/es/home.yml: -------------------------------------------------------------------------------- 1 | # Home page 2 | 3 | hero: # The attention-grabber that is first thing the user sees 4 | main_paragraph: Cree casi cualquier tipo de transacción de Algorand. 5 | start_button: ¡Cree una nueva transacción! 6 | what_is_this: 7 | heading: ¿Qué es esto? 8 | paragraph: >- 9 | Una herramienta gratuita y de código abierto para crear fácilmente transacciones Algorand. 10 | how_it_works: 11 | heading: ¿Cómo funciona? 12 | compose: 13 | heading: 1. Componga la transacción. 14 | paragraph: >- 15 | Comience a crear una nueva transacción introduciendo la información de la transacción. 16 | Utilice una de las preselecciones de transacciones para que este paso sea más rápido y 17 | sencillo. 18 | button: Componer una nueva transacción 19 | sign: 20 | heading: 2. Firme la transacción. 21 | paragraph: >- 22 | Cada transacción debe ser firmada antes de ser enviada. Conecte su billetera y firme la 23 | transacción. Si usted guardó una transacción como un archivo, puede importarla para 24 | firmarla. 25 | button: Importar y firmar una transacción 26 | send: 27 | heading: 3. Envíe la transacción. 28 | paragraph: >- 29 | Envíe la transacción firmada. Una transacción exitosa se confirma y finaliza normalmente en 30 | menos de 4 segundos. Si usted guardó una transacción firmada como archivo, puede importarla 31 | para enviarla. 32 | button: Importar y enviar una transacción firmada 33 | uses: 34 | heading: ¿Para qué puedo utilizar esta herramienta? 35 | simple_things: 36 | heading: Cosas sencillas 37 | list: 38 | 0: Enviar Algos 39 | 1: Optar a un token o <1>ASA 40 | 2: Transferir un <1>NFT 41 | 3: Y más 42 | complex_things: 43 | heading: Cosas avanzadas o complejas 44 | list: 45 | 0: Marcar una cuenta como «en línea» to start participating consensus 46 | 1: Actualizar una aplicación (smart contract) 47 | 2: Recuperar (Claw back) un activo 48 | 3: Y más 49 | dangerous_things: 50 | heading: Cosas peligrosas 51 | list: 52 | 0: Recodificar una cuenta 53 | 1: Cerrar una cuenta 54 | 2: Y más 55 | -------------------------------------------------------------------------------- /src/app/[lang]/txn/compose/components/fields/GeneralFields/LastValid.tsx: -------------------------------------------------------------------------------- 1 | import { type TFunction } from 'i18next'; 2 | import { useAtomValue } from 'jotai'; 3 | import { FieldErrorMessage, NumberField } from '@/app/[lang]/components/form'; 4 | import { 5 | generalFormControlAtom, 6 | showFormErrorsAtom, 7 | tipContentClass, 8 | tipBtnClass, 9 | lvConditionalRequireAtom, 10 | } from '@/app/lib/txn-data'; 11 | 12 | export default function LastValid({ t }: { t: TFunction }) { 13 | const form = useAtomValue(generalFormControlAtom); 14 | const lvCondReqGroup = useAtomValue(lvConditionalRequireAtom); 15 | const showFormErrors = useAtomValue(showFormErrorsAtom); 16 | return (<> 17 | 39 | form.handleOnChange('lv')(e.target.value === '' ? undefined : parseInt(e.target.value)) 40 | } 41 | onFocus={form.handleOnFocus('lv')} 42 | onBlur={form.handleOnBlur('lv')} 43 | /> 44 | {(showFormErrors || form.touched.lv) && form.fieldErrors.lv && 45 | 49 | } 50 | {(showFormErrors || form.touched.lv) && !lvCondReqGroup.isValid 51 | && lvCondReqGroup.error && 52 | 56 | } 57 | ); 58 | } 59 | -------------------------------------------------------------------------------- /src/app/[lang]/components/form/RadioButtonGroupField.tsx: -------------------------------------------------------------------------------- 1 | import FieldTip from './FieldTip'; 2 | import type { RadioButtonGroupFieldProps } from './types'; 3 | 4 | /** Radio button group form field */ 5 | export default function SelectField({ 6 | required = false, 7 | optionClass = '', 8 | label = '', 9 | labelClass = '', 10 | labelTextClass = '', 11 | containerId = undefined, 12 | containerClass = '', 13 | requiredText = '', 14 | helpMsg = '', 15 | options = [], 16 | name ='', 17 | defaultValue = undefined, 18 | disabled = false, 19 | value = undefined, 20 | onChange = undefined, 21 | onFocus = undefined, 22 | onBlur = undefined, 23 | tip = undefined, 24 | }: RadioButtonGroupFieldProps) { 25 | return ( 26 |
    27 | 28 | 29 | {label} 30 | {required && *} 31 | {tip && } 32 | 33 | 34 |
    35 | { 36 | options.map((option) => { 37 | return ( 38 | 54 | ); 55 | }) 56 | } 57 |
    58 | {helpMsg && 59 |

    60 | {helpMsg} 61 |

    62 | } 63 |
    64 | ); 65 | } 66 | -------------------------------------------------------------------------------- /src/app/[lang]/txn/page.tsx: -------------------------------------------------------------------------------- 1 | import { Suspense, use } from 'react'; 2 | import { type Metadata } from 'next'; 3 | import Link from 'next/link'; 4 | import { IconArrowBigRightLinesFilled, IconArrowBigLeftLinesFilled } from '@tabler/icons-react'; 5 | import { PageLoadingPlaceholder, PageTitleHeading } from '@/app/[lang]/components'; 6 | import { generateLangAltsMetadata, useTranslation } from '@/app/i18n'; 7 | import TxnPresetsList from './TxnPresetsList'; 8 | 9 | export async function generateMetadata( 10 | props: { params: Promise<{ lang: string }> } 11 | ): Promise { 12 | const params = await props.params; 13 | // eslint-disable-next-line react-hooks/rules-of-hooks 14 | const { t } = await useTranslation(params.lang, ['txn_presets', 'app']); 15 | const path = '/txn'; 16 | return { 17 | title: t('page_title', {page: t('title'), site: t('site_name')}), 18 | alternates: { 19 | canonical: `/${params.lang}${path}`, 20 | languages: generateLangAltsMetadata(path) 21 | }, 22 | }; 23 | } 24 | 25 | /** Make Next JS generate at static version of this page */ 26 | export function generateStaticParams() { return ['txn']; } 27 | 28 | /** Choose Transaction Presets page */ 29 | export default function TxnPresetsPage(props: { params: Promise<{ lang: string }> }) { 30 | const { lang } = use(props.params); 31 | const { t } = use(useTranslation(lang, 'txn_presets')); 32 | return ( 33 |
    34 | {t('title')} 35 |

    {t('instruction')}

    36 |
    37 | 43 | 44 | 45 | {t('skip_btn')} 46 | 47 |
    48 | }> 49 |
    50 | ); 51 | } 52 | -------------------------------------------------------------------------------- /src/app/[lang]/txn/compose/components/fields/PaymentFields/Receiver.tsx: -------------------------------------------------------------------------------- 1 | import { useSearchParams } from 'next/navigation'; 2 | import { type TFunction } from 'i18next'; 3 | import { useAtomValue } from 'jotai'; 4 | import { FieldErrorMessage, TextField } from '@/app/[lang]/components/form'; 5 | import { 6 | ADDRESS_LENGTH, 7 | paymentFormControlAtom, 8 | showFormErrorsAtom, 9 | tipBtnClass, 10 | tipContentClass 11 | } from '@/app/lib/txn-data'; 12 | import ConnectWalletFieldWidget from '../../wallet/WalletFieldWidget'; 13 | 14 | export default function Receiver({ t }: { t: TFunction }) { 15 | const form = useAtomValue(paymentFormControlAtom); 16 | const showFormErrors = useAtomValue(showFormErrorsAtom); 17 | const searchParams = useSearchParams(); 18 | return (<> 19 | form.handleOnChange('rcv')(e.target.value)} 40 | onFocus={form.handleOnFocus('rcv')} 41 | onBlur={form.handleOnBlur('rcv')} 42 | /> 43 | {(showFormErrors || form.touched.rcv) && form.fieldErrors.rcv && 44 | 48 | } 49 | {/* Show wallet widget when either 50 | * (1) the `rcv` query parameter is NOT set 51 | * (2) or the `rcv` query parameter is set AND the field has been touched 52 | */} 53 | {(!searchParams.get('rcv') || form.touched.rcv) && 54 | 55 | } 56 | ); 57 | } 58 | -------------------------------------------------------------------------------- /src/app/[lang]/components/ToastNotification.tsx: -------------------------------------------------------------------------------- 1 | import { dir } from 'i18next'; 2 | import * as Toast from '@radix-ui/react-toast'; 3 | import { IconSettingsCheck, IconX } from '@tabler/icons-react'; 4 | import { useTranslation } from '@/app/i18n/client'; 5 | 6 | interface Props { 7 | /** Language */ 8 | lng?: string; 9 | /** Message the toast notification should contain */ 10 | message?: string; 11 | /** The controlled open state of the toast notification. Must be used in conjunction with 12 | * `onOpenChange`. 13 | */ 14 | open?: boolean; 15 | /** Event handler called when the open state of the dialog changes. */ 16 | onOpenChange?(open: boolean): void 17 | }; 18 | 19 | /** Toast (notification) for when a setting or multiple settings are updated and saved */ 20 | export default function ToastNotification({ lng, message, open, onOpenChange }: Props) { 21 | const { t } = useTranslation(lng || '', ['app', 'common']); 22 | const langDir = dir(lng); 23 | return ( 24 | 42 | 43 | {message} 44 | 45 | 53 | 54 | 55 | ); 56 | } 57 | -------------------------------------------------------------------------------- /src/app/[lang]/group/page.tsx: -------------------------------------------------------------------------------- 1 | import { use } from 'react'; 2 | import { type Metadata } from 'next'; 3 | import Link from 'next/link'; 4 | import { 5 | IconArrowBigRightLinesFilled, 6 | IconArrowBigLeftLinesFilled, 7 | IconTrafficCone 8 | } from '@tabler/icons-react'; 9 | import { PageTitleHeading } from '@/app/[lang]/components'; 10 | import { generateLangAltsMetadata, useTranslation } from '@/app/i18n'; 11 | 12 | export async function generateMetadata( 13 | props: { params: Promise<{ lang: string }> } 14 | ): Promise { 15 | const params = await props.params; 16 | // eslint-disable-next-line react-hooks/rules-of-hooks 17 | const { t } = await useTranslation(params.lang, ['grp_presets', 'app']); 18 | const path = '/group'; 19 | return { 20 | title: t('page_title', {page: t('title'), site: t('site_name')}), 21 | alternates: { 22 | canonical: `/${params.lang}${path}`, 23 | languages: generateLangAltsMetadata(path) 24 | }, 25 | }; 26 | } 27 | 28 | /** Make Next JS generate at static version of this page */ 29 | export function generateStaticParams() { return ['group']; } 30 | 31 | /** Choose Transaction Group Presets page */ 32 | export default function GroupPresetsPage(props: { params: Promise<{ lang: string }> }) { 33 | const { lang } = use(props.params); 34 | const { t } = use(useTranslation(lang, ['grp_presets', 'app'])); 35 | return ( 36 |
    37 |
    38 | 39 |

    {t('page_under_construction')}

    40 |
    41 | {t('title')} 42 |

    {t('instruction')}

    43 |
    44 | 50 | 51 | 52 | {t('skip_btn')} 53 | 54 |
    55 |
    56 | ); 57 | } 58 | -------------------------------------------------------------------------------- /eslint.config.mjs: -------------------------------------------------------------------------------- 1 | import { dirname } from "path"; 2 | import { fileURLToPath } from "url"; 3 | import { FlatCompat } from "@eslint/eslintrc"; 4 | import testingLibrary from "eslint-plugin-testing-library"; 5 | import stylistic from "@stylistic/eslint-plugin"; 6 | import { defineConfig, globalIgnores } from "eslint/config"; 7 | import nextCoreWebVitals from "eslint-config-next/core-web-vitals"; 8 | import nextTypescript from "eslint-config-next/typescript"; 9 | import { includeIgnoreFile } from "@eslint/compat"; 10 | import yamlparser from "yaml-eslint-parser"; 11 | 12 | const __filename = fileURLToPath(import.meta.url); 13 | const __dirname = dirname(__filename); 14 | 15 | const compat = new FlatCompat({ 16 | baseDirectory: __dirname, 17 | }); 18 | const gitignorePath = fileURLToPath(new URL(".gitignore", import.meta.url)); 19 | 20 | const eslintConfig = defineConfig([ 21 | includeIgnoreFile(gitignorePath, "Imported .gitignore patterns"), 22 | globalIgnores(["public/**/*"], "Ignore public directory"), 23 | ...nextCoreWebVitals, 24 | ...nextTypescript, 25 | { 26 | plugins: { '@stylistic': stylistic }, 27 | rules: { 28 | '@stylistic/semi': 'error', 29 | '@stylistic/max-len': ["warn", { code: 100 }], 30 | '@typescript-eslint/no-explicit-any': 'off' 31 | }, 32 | }, { 33 | files: [ 34 | "**/__tests__/**/*.[jt]s?(x)", 35 | "**/?(*.)+(test).[jt]s?(x)", 36 | ], 37 | ...testingLibrary.configs['flat/react'], 38 | rules: { 39 | "testing-library/no-await-sync-events": ["error", { eventModules: ["fire-event"] }], 40 | "testing-library/await-async-events": ["error", { eventModule: "userEvent" }], 41 | } 42 | }, { 43 | files: ["**/?(*.)+(spec).[jt]s"], 44 | rules: { 45 | "@typescript-eslint/no-unused-vars": ["off", { 46 | argsIgnorePattern: "(Page)$" 47 | }], 48 | "react-hooks/rules-of-hooks": "off", 49 | } 50 | }, { 51 | files: ["**/*.yaml", "**/*.yml"], 52 | extends: [...compat.extends("plugin:yml/standard")], 53 | languageOptions: { 54 | parser: yamlparser, 55 | }, 56 | rules: { 57 | '@stylistic/max-len': 'off' 58 | }, 59 | }, { 60 | ignores: ["node_modules/**", ".next/**", "out/**", "build/**", "next-env.d.ts"] 61 | } 62 | ]); 63 | 64 | export default eslintConfig; 65 | -------------------------------------------------------------------------------- /src/app/[lang]/group/compose/page.test.tsx: -------------------------------------------------------------------------------- 1 | import '@testing-library/jest-dom'; 2 | import { render, screen } from '@testing-library/react'; 3 | import i18nextClientMock from '@/app/lib/testing/i18nextClientMock'; 4 | import { useWalletUnconnectedMock } from '@/app/lib/testing/useWalletMock'; 5 | 6 | // Mock react `use` function before modules that use it are imported 7 | jest.mock('react', () => ({ 8 | ...jest.requireActual('react'), 9 | use: () => ({ t: (key: string) => key }), 10 | })); 11 | 12 | // Mock i18next before modules that use it are imported because it is used by a child component 13 | jest.mock('react-i18next', () => i18nextClientMock); 14 | 15 | // Mock navigation hooks because they are used by a child components 16 | jest.mock('next/navigation', () => ({ 17 | useRouter: () => ({ push: jest.fn() }), 18 | useSearchParams: () => ({get: () => 'foo'}) 19 | })); 20 | 21 | // Mock use-wallet before modules that use it are imported 22 | jest.mock('@txnlab/use-wallet-react', () => useWalletUnconnectedMock); 23 | // Mock the wallet provider 24 | jest.mock('../../components/wallet/WalletProvider.tsx', () => 'div'); 25 | 26 | import GroupComposePage from './page'; 27 | 28 | // Mock navigation hooks because they are used by a child components 29 | const paramsMock = {get: jest.fn()}; 30 | jest.mock('next/navigation', () => ({ 31 | useSearchParams: () => paramsMock, 32 | })); 33 | 34 | describe('Group Transactions Compose Page', () => { 35 | 36 | it('has builder steps', async () => { 37 | const pageParam = new Promise(resolve => { resolve({lang: ''}); }); 38 | render(); 39 | expect(await screen.findByText(/builder_steps\.compose/)).toBeInTheDocument(); 40 | }); 41 | 42 | it('has page title heading', async () => { 43 | const pageParam = new Promise(resolve => { resolve({lang: ''}); }); 44 | render(); 45 | expect(await screen.findByRole('heading', { level: 1 })).not.toBeEmptyDOMElement(); 46 | }); 47 | 48 | it('has transaction group list', async () => { 49 | const pageParam = new Promise(resolve => { resolve({lang: ''}); }); 50 | render(); 51 | expect(await screen.findByText(/grp_list_no_txn/)).toBeInTheDocument(); 52 | }); 53 | 54 | }); 55 | -------------------------------------------------------------------------------- /src/app/[lang]/group/compose/components/GrpComposeList.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { useTranslation } from "@/app/i18n/client"; 4 | import { MAX_GRP_TXNS, storedTxnGrpKeysAtom } from "@/app/lib/txn-data"; 5 | import { useAtom } from "jotai"; 6 | import GrpComposeListSlot from "./GrpComposeListSlot"; 7 | import { IconPlus } from "@tabler/icons-react"; 8 | import Link from "next/link"; 9 | import { useState } from "react"; 10 | 11 | type Props = { 12 | /** Language */ 13 | lng?: string 14 | }; 15 | 16 | /** A list that contains the group transactions */ 17 | export default function GrpComposeList({ lng }: Props) { 18 | const { t } = useTranslation(lng || '', ['grp_compose']); 19 | const [grpList, setGrpList] = useAtom(storedTxnGrpKeysAtom); 20 | const [emptySlotExists, setEmptySlotExists] = useState(false); 21 | 22 | /** Adds slot to the transaction group list */ 23 | function addTxnSlot() { 24 | if (grpList.length < MAX_GRP_TXNS) { 25 | setGrpList([...grpList, '']); 26 | } 27 | } 28 | 29 | return <> 30 |
      31 | {grpList.length 32 | ? grpList.map((storageKey, i) => { 33 | if (!emptySlotExists && grpList[i] === '') setEmptySlotExists(true); 34 | return ; 35 | }) 36 | :
    1. {t('grp_list_no_txn')}
    2. 37 | } 38 |
    39 | 46 | {/*
    */} 47 | {/* TODO: Calculate pooled transaction fee and give error when min fee isn't covered */} 48 | {/*

    */} 49 | {/* Current total fee: {0} Algos */} 50 | {/*

    */} 51 | {/*

    */} 52 | {/* Minimum total fee: {(grpList.length * MIN_TX_FEE) / 1_000_000} Algos */} 53 | {/*

    */} 54 | {/*
    */} 55 | 59 | {t('review_sign_btn')} 60 | 61 | ; 62 | } 63 | -------------------------------------------------------------------------------- /.github/SECURITY.md: -------------------------------------------------------------------------------- 1 | # Security 2 | 3 | We take security seriously. If you believe you have found a security 4 | vulnerability, please report it to us as described below. 5 | 6 | ## Reporting Security Issues 7 | 8 | **:warning: Please do not report security vulnerabilities through public GitHub 9 | issues.** 10 | 11 | Instead, please report them by sending an email to 12 | 13 | txnDuck+security@proton.me. 14 | If possible, encrypt your message with our PGP key: 15 | 16 | ```text 17 | -----BEGIN PGP PUBLIC KEY BLOCK----- 18 | 19 | xjMEZLz4wxYJKwYBBAHaRw8BAQdA88cCrkXE+qu7TE6ZiCK0Sqr4Dg8gJmUf 20 | dxEUfFVKoBzNJXR4bkR1Y2tAcHJvdG9uLm1lIDx0eG5EdWNrQHByb3Rvbi5t 21 | ZT7CjAQQFgoAPgWCZLz4wwQLCQcICZCtnW0m0MIOwgMVCAoEFgACAQIZAQKb 22 | AwIeARYhBBpnHNtL4ywJaWVHWK2dbSbQwg7CAAC0swEA+Ajb6exAVZmwWDwc 23 | XNHoPRx14IuErkhMRv6lM0Im/EoA/2gcecvvum3Od/6uSA6xi2+MVgrNeJfn 24 | /l/4BgOqZWsIzjgEZLz4wxIKKwYBBAGXVQEFAQEHQGcbKVt4U2IreRmgWFM1 25 | xdS/WbbgQKykFN/FfKgmPp5vAwEIB8J4BBgWCAAqBYJkvPjDCZCtnW0m0MIO 26 | wgKbDBYhBBpnHNtL4ywJaWVHWK2dbSbQwg7CAAB3aAD/RgbsNKmivIMZIrqj 27 | +K2p9pd9gEVHpVFwbNJjX0t372YBAJ2NQ6ld5/dPR3KJUEIwihgjh6AbVBkV 28 | SO+h6QpTmbIP 29 | =aHey 30 | -----END PGP PUBLIC KEY BLOCK----- 31 | ``` 32 | 33 | Please include the requested information listed below (as much as you can 34 | provide) to help us better understand the nature and scope of the possible 35 | issue: 36 | 37 | * Type of issue (e.g. buffer overflow, SQL injection, cross-site scripting, 38 | etc.) 39 | * Full paths of source file(s) related to the manifestation of the issue 40 | * The location of the affected source code (tag/branch/commit or direct URL) 41 | * Any special configuration required to reproduce the issue 42 | * Step-by-step instructions to reproduce the issue 43 | * Proof-of-concept or exploit code (if possible) 44 | * Impact of the issue, including how an attacker might exploit the issue 45 | 46 | This information will help us triage your report more quickly. 47 | 48 | ## Preferred Languages 49 | 50 | We prefer all communications to be in English. 51 | 52 | ## Attribution 53 | 54 | This security policy is based on [Microsoft's security policy for its 55 | repositories on GitHub](https://github.com/microsoft/repo-templates/blob/main/shared/SECURITY.md). 56 | -------------------------------------------------------------------------------- /src/app/[lang]/txn/compose/components/fields/AssetTransferFields/Amount.tsx: -------------------------------------------------------------------------------- 1 | import { type TFunction } from 'i18next'; 2 | import { useAtomValue } from 'jotai'; 3 | import { FieldErrorMessage, TextField } from '@/app/[lang]/components/form'; 4 | import { 5 | aamtConditionalMaxAtom, 6 | assetTransferFormControlAtom, 7 | showFormErrorsAtom, 8 | tipBtnClass, 9 | tipContentClass, 10 | txnDataAtoms, 11 | } from '@/app/lib/txn-data'; 12 | 13 | export default function Amount({ t }: { t: TFunction }) { 14 | const form = useAtomValue(assetTransferFormControlAtom); 15 | const aamtCondMax = useAtomValue(aamtConditionalMaxAtom); 16 | const showFormErrors = useAtomValue(showFormErrorsAtom); 17 | const retrievedAssetInfo = useAtomValue(txnDataAtoms.retrievedAssetInfo); 18 | return (<> 19 | form.handleOnChange('aamt')(e.target.value)} 41 | onFocus={form.handleOnFocus('aamt')} 42 | onBlur={form.handleOnBlur('aamt')} 43 | inputMode='numeric' 44 | /> 45 | {(showFormErrors || form.touched.aamt) && form.fieldErrors.aamt && 46 | 50 | } 51 | {(showFormErrors || form.touched.aamt) && !form.fieldErrors.aamt 52 | && !aamtCondMax.isValid && aamtCondMax.error && 53 | 57 | } 58 | ); 59 | } 60 | -------------------------------------------------------------------------------- /src/app/[lang]/txn/compose/components/fields/AssetTransferFields/Receiver.tsx: -------------------------------------------------------------------------------- 1 | import { useSearchParams } from 'next/navigation'; 2 | import { type TFunction } from 'i18next'; 3 | import { useAtomValue } from 'jotai'; 4 | import { FieldErrorMessage, TextField } from '@/app/[lang]/components/form'; 5 | import { 6 | ADDRESS_LENGTH, 7 | assetTransferFormControlAtom, 8 | showFormErrorsAtom, 9 | tipBtnClass, 10 | tipContentClass, 11 | } from '@/app/lib/txn-data'; 12 | import ConnectWalletFieldWidget from '../../wallet/WalletFieldWidget'; 13 | 14 | export default function Receiver({ t }: { t: TFunction }) { 15 | const form = useAtomValue(assetTransferFormControlAtom); 16 | const showFormErrors = useAtomValue(showFormErrorsAtom); 17 | const searchParams = useSearchParams(); 18 | return (<> 19 | form.handleOnChange('arcv')(e.target.value)} 40 | onFocus={form.handleOnFocus('arcv')} 41 | onBlur={form.handleOnBlur('arcv')} 42 | /> 43 | {(showFormErrors || form.touched.arcv) && form.fieldErrors.arcv && 44 | 48 | } 49 | {/* Show wallet widget when either 50 | * (1) the `arcv` query parameter is NOT set, 51 | * (2) the `xaid` query parameter is NOT set, 52 | * (3) or the `arcv` query parameter is set AND the field has been touched 53 | */} 54 | {(!searchParams.get('arcv') || !searchParams.get('xaid') || form.touched.arcv) && 55 | 56 | } 57 | ); 58 | } 59 | -------------------------------------------------------------------------------- /src/app/[lang]/txn/compose/page.tsx: -------------------------------------------------------------------------------- 1 | import { use } from 'react'; 2 | import { type Metadata } from 'next'; 3 | import dynamic from 'next/dynamic'; 4 | import { BuilderSteps, PageTitleHeading, WalletProvider } from '@/app/[lang]/components'; 5 | import { generateLangAltsMetadata, useTranslation } from '@/app/i18n'; 6 | import { 7 | ExtraSmallField, 8 | FullWidthField, 9 | LargeAreaField, 10 | LargeField, 11 | SwitchField 12 | } from './components/fields/LoadingPlaceholders'; 13 | 14 | const ComposeForm = dynamic(() => import('./components/ComposeForm'), { 15 | loading: () => 16 |
    17 |
    18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 |
    26 | }); 27 | 28 | export async function generateMetadata( 29 | props: { params: Promise<{ lang: string }> } 30 | ): Promise { 31 | const params = await props.params; 32 | // eslint-disable-next-line react-hooks/rules-of-hooks 33 | const { t } = await useTranslation(params.lang, ['compose_txn', 'app']); 34 | const path = '/txn/compose'; 35 | return { 36 | title: t('page_title', {page: t('title'), site: t('site_name')}), 37 | alternates: { 38 | canonical: `/${params.lang}${path}`, 39 | languages: generateLangAltsMetadata(path) 40 | }, 41 | }; 42 | } 43 | 44 | /** Make Next JS generate at static version of this page */ 45 | export function generateStaticParams() { return ['compose']; } 46 | 47 | /** Compose Transaction page */ 48 | export default function ComposeTxnPage(props: { params: Promise<{ lang: string }> }) { 49 | const { lang } = use(props.params); 50 | const { t } = use(useTranslation(lang, ['compose_txn', 'common'])); 51 | return ( 52 |
    53 | 54 | {t('title')} 55 | 56 |
    57 | ); 58 | } 59 | -------------------------------------------------------------------------------- /src/app/[lang]/components/form/FieldGroup.tsx: -------------------------------------------------------------------------------- 1 | import FieldTip from './FieldTip'; 2 | import type { FieldGroupProps } from './types'; 3 | 4 | /** Grouping for form fields with an optional heading. */ 5 | export default function FieldGroup({ 6 | children, 7 | heading = '', 8 | headingLevel = 2, 9 | headingClass = '', 10 | headingId = '', 11 | containerId = undefined, 12 | containerClass = '', 13 | disabled = false, 14 | tip = undefined, 15 | }: FieldGroupProps) { 16 | return ( 17 |
    18 | {/* Putting a heading inside a legend is acceptable: "By adding a heading screen reader users 19 | * can navigate to the heading and the legend text is also included as part of navigable 20 | * document structure." 21 | * Source: https://www.tpgi.com/fieldsets-legends-and-screen-readers-again/ 22 | */} 23 | {heading && 24 | {headingLevel === 1 && 25 |

    26 | {heading} 27 | {tip && } 28 |

    29 | } 30 | {headingLevel === 2 && 31 |

    32 | {heading} 33 | {tip && } 34 |

    35 | } 36 | {headingLevel === 3 && 37 |

    38 | {heading} 39 | {tip && } 40 |

    41 | } 42 | {headingLevel === 4 && 43 |

    44 | {heading} 45 | {tip && } 46 |

    47 | } 48 | {headingLevel === 5 && 49 |
    50 | {heading} 51 | {tip && } 52 |
    53 | } 54 | {headingLevel === 6 && 55 |
    56 | {heading} 57 | {tip && } 58 |
    59 | } 60 |
    } 61 | 62 | {children} 63 |
    64 | ); 65 | } 66 | -------------------------------------------------------------------------------- /src/app/[lang]/txn/compose/components/fields/AppCallFields/OnComplete.tsx: -------------------------------------------------------------------------------- 1 | import { useSearchParams } from 'next/navigation'; 2 | import { type TFunction } from 'i18next'; 3 | import { useAtomValue } from 'jotai'; 4 | import { OnApplicationComplete } from 'algosdk'; 5 | import { FieldErrorMessage, SelectField } from '@/app/[lang]/components/form'; 6 | import { 7 | Preset, 8 | applFormControlAtom, 9 | showFormErrorsAtom, 10 | tipBtnClass, 11 | tipContentClass 12 | } from '@/app/lib/txn-data'; 13 | 14 | export default function OnComplete({ t }: { t: TFunction }) { 15 | const form = useAtomValue(applFormControlAtom); 16 | const preset = useSearchParams().get(Preset.ParamName); 17 | const showFormErrors = useAtomValue(showFormErrorsAtom); 18 | return (<> 19 | form.handleOnChange('apan')(e.target.value)} 46 | onFocus={form.handleOnFocus('apan')} 47 | onBlur={form.handleOnBlur('apan')} 48 | /> 49 | {form.touched.apan && form.fieldErrors.apan && 50 | 54 | } 55 | ); 56 | } 57 | -------------------------------------------------------------------------------- /src/app/[lang]/txn/compose/components/fields/GeneralFields/TxnType.tsx: -------------------------------------------------------------------------------- 1 | import { useSearchParams } from 'next/navigation'; 2 | import { type TFunction } from 'i18next'; 3 | import { useAtomValue } from 'jotai'; 4 | import { FieldErrorMessage, SelectField } from '@/app/[lang]/components/form'; 5 | import { 6 | Preset, 7 | generalFormControlAtom, 8 | showFormErrorsAtom, 9 | tipContentClass, 10 | tipBtnClass, 11 | } from '@/app/lib/txn-data'; 12 | import { TransactionType } from 'algosdk'; 13 | 14 | export default function TxnType({ t }: { t: TFunction }) { 15 | const form = useAtomValue(generalFormControlAtom); 16 | const preset = useSearchParams().get(Preset.ParamName); 17 | const showFormErrors = useAtomValue(showFormErrorsAtom); 18 | return (<> 19 | form.handleOnChange('txnType')(e.target.value)} 47 | onFocus={form.handleOnFocus('txnType')} 48 | onBlur={form.handleOnBlur('txnType')} 49 | /> 50 | {(showFormErrors || form.touched.txnType) && form.fieldErrors.txnType && 51 | 55 | } 56 | ); 57 | } 58 | -------------------------------------------------------------------------------- /src/e2e/txn_presets.spec.ts: -------------------------------------------------------------------------------- 1 | import { test as base, expect } from '@playwright/test'; 2 | import { LanguageSupport, NavBarComponent as NavBar } from './shared'; 3 | import { TxnPresetsPage } from './pageModels/TxnPresetsPage'; 4 | 5 | // Extend basic test by providing a "txnPresetsPage" fixture. 6 | // Code adapted from https://playwright.dev/docs/pom 7 | const test = base.extend<{ txnPresetsPage: TxnPresetsPage }>({ 8 | txnPresetsPage: async ({ page }, use) => { 9 | // Set up the fixture. 10 | const txnPresetsPage = new TxnPresetsPage(page); 11 | await txnPresetsPage.goto(); 12 | // Use the fixture value in the test. 13 | await use(txnPresetsPage); 14 | }, 15 | }); 16 | 17 | test.describe('Transaction Presets Page', () => { 18 | 19 | test.describe('Language Support', () => { 20 | (new LanguageSupport({ 21 | en: { body: /preset/, title: /Transaction/ }, 22 | es: { body: /preselección/, title: /transacción/ }, 23 | })).check(test, TxnPresetsPage.url); 24 | }); 25 | 26 | test.describe('Nav Bar', () => { 27 | NavBar.check(test, TxnPresetsPage.getFullUrl()); 28 | }); 29 | 30 | test.describe('With URL Parameters', () => { 31 | 32 | test('overrides the preset specified in URL in links to presets', async ({ page }) => { 33 | await (new TxnPresetsPage(page)).goto('en', '?preset=foo'); 34 | // Only check one of the links instead of all 20+ links 35 | await expect(page.getByRole('link', { name: 'Transfer Algos' })) 36 | .toHaveAttribute('href', '/en/txn/compose?preset=transfer'); 37 | }); 38 | 39 | test('overrides the preset specified with other URL parameters in URL in links to presets', 40 | async ({ page }) => { 41 | await (new TxnPresetsPage(page)).goto('en', '?preset=foo&a=b'); 42 | // Only check one of the links instead of all 20+ links 43 | await expect(page.getByRole('link', { name: 'Transfer Algos' })) 44 | .toHaveAttribute('href', '/en/txn/compose?a=b&preset=transfer'); 45 | }); 46 | 47 | test('includes current URL parameters (without preset) specified in links to presets', 48 | async ({ page }) => { 49 | await (new TxnPresetsPage(page)).goto('en', '?a=b&c=d'); 50 | // Only check one of the links instead of all 20+ links 51 | await expect(page.getByRole('link', { name: 'Transfer Algos' })) 52 | .toHaveAttribute('href', '/en/txn/compose?a=b&c=d&preset=transfer'); 53 | }); 54 | 55 | }); 56 | 57 | }); 58 | -------------------------------------------------------------------------------- /src/app/[lang]/components/form/FieldGroup.test.tsx: -------------------------------------------------------------------------------- 1 | import '@testing-library/jest-dom'; 2 | import { render, screen } from '@testing-library/react'; 3 | import FieldGroup from './FieldGroup'; 4 | 5 | describe('Form Components - FieldGroup', () => { 6 | it('has children', () => { 7 | render(foo); 8 | expect(screen.getByText(/foo/)).toBeInTheDocument(); 9 | }); 10 | 11 | it('has heading', () => { 12 | render(); 13 | expect(screen.getByRole('heading')).toHaveTextContent(/Foo Group/); 14 | }); 15 | 16 | it('has heading with level specified in `headingLevel` property', () => { 17 | render(); 18 | expect(screen.getByRole('heading', { level: 5 })).toHaveTextContent(/Foo Group/); 19 | }); 20 | 21 | it('has heading with class specified in `headingClass` property', () => { 22 | render(); 23 | expect(screen.getByRole('heading')).toHaveClass('foo-class'); 24 | }); 25 | 26 | it('has heading with `id` specified in `headingId` property', () => { 27 | render(); 28 | expect(screen.getByRole('heading')).toHaveAttribute('id', 'foo-id'); 29 | }); 30 | 31 | it('has container with ID specified in `containerId` property', () => { 32 | render(); 33 | expect(screen.getByRole('group')).toHaveAttribute('id', 'foo'); 34 | }); 35 | 36 | it('has container with class(es) specified in `containerClass` property', () => { 37 | render(); 38 | expect(screen.getByRole('group')).toHaveClass('foo'); 39 | }); 40 | 41 | it('disables the input if `disabled` is true', () => { 42 | render(); 43 | expect(screen.getByRole('group')).toBeDisabled(); 44 | }); 45 | 46 | it('enables the input if `disabled` is false', () => { 47 | render(); 48 | expect(screen.getByRole('group')).not.toBeDisabled(); 49 | }); 50 | 51 | it('has field tip button in heading when `tip` and a heading are specified', () => { 52 | render(); 53 | expect(screen.getByTitle('Foo tip')).toBeInTheDocument(); 54 | }); 55 | 56 | }); 57 | -------------------------------------------------------------------------------- /next.config.ts: -------------------------------------------------------------------------------- 1 | import type { NextConfig } from 'next'; 2 | import withPWAInit from "@ducanh2912/next-pwa"; 3 | 4 | const nextConfig: NextConfig = { 5 | poweredByHeader: false, 6 | serverExternalPackages: ['thread-stream'], 7 | turbopack: {}, 8 | webpack: (config, { isServer }) => { 9 | // Add use-wallet dependency modules that cause "not found" errors. Also see 10 | // https://github.com/WalletConnect/walletconnect-monorepo/issues/1908#issuecomment-1487801131 11 | config.externals.push('pino-pretty', 'lokijs', 'encoding'); 12 | config.resolve.fallback = { fs: false }; 13 | 14 | /** 15 | * Provide fallbacks for optional wallet dependencies. 16 | * This allows the app to build and run without these packages installed, enabling users to 17 | * include only the wallet packages they need. Each package is set to 'false', which means 18 | * Webpack will provide an empty module if the package is not found, preventing build errors for 19 | * unused wallets. 20 | */ 21 | if (!isServer) { 22 | config.resolve.fallback = { 23 | ...config.resolve.fallback, 24 | '@agoralabs-sh/avm-web-provider': false, 25 | '@blockshake/defly-connect': false, 26 | '@magic-ext/algorand': false, 27 | '@perawallet/connect': false, 28 | '@perawallet/connect-beta': false, 29 | '@walletconnect/modal': false, 30 | '@walletconnect/sign-client': false, 31 | 'lute-connect': false, 32 | 'magic-sdk': false, 33 | '@algorandfoundation/liquid-auth-use-wallet-client': false, 34 | }; 35 | } 36 | 37 | return config; 38 | }, 39 | typescript: { 40 | // !! WARN !! 41 | // Dangerously allow production builds to successfully complete even if your project has type 42 | // errors. 43 | // !! WARN !! 44 | ignoreBuildErrors: process.env.IGNORE_TS_BUILD_ERRORS?.toLowerCase() === 'true', 45 | }, 46 | }; 47 | 48 | if (process.env.STATIC_BUILD?.toLowerCase() === 'true') { 49 | nextConfig.output = 'export'; 50 | } 51 | 52 | if (process.env.STANDALONE_BUILD?.toLowerCase() === 'true') { 53 | nextConfig.output = 'standalone'; 54 | } 55 | 56 | // Create configuration for next-pwa plugin 57 | const withPWA = withPWAInit({ 58 | dest: 'public', 59 | disable: process.env.DISABLE_PWA === 'true', 60 | // register: true, 61 | // scope: '/app', 62 | // sw: 'service-worker.js', 63 | //... 64 | }); 65 | 66 | // Export the combined Next.js and PWA configuration 67 | module.exports = withPWA(nextConfig); 68 | -------------------------------------------------------------------------------- /src/app/[lang]/txn/compose/components/fields/GeneralFields/ValidRounds.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect } from 'react'; 2 | import { useSearchParams } from 'next/navigation'; 3 | import { type TFunction } from 'i18next'; 4 | import { useAtomValue, useSetAtom } from 'jotai'; 5 | import { FieldGroup, ToggleField } from '@/app/[lang]/components/form'; 6 | import { 7 | generalFormControlAtom, getStoredTxnDataAtom, tipBtnClass, tipContentClass, txnDataAtoms, 8 | } from '@/app/lib/txn-data'; 9 | import { defaultUseSugRounds as defaultUseSugRoundsAtom } from '@/app/lib/app-settings'; 10 | import FirstValid from './FirstValid'; 11 | import LastValid from './LastValid'; 12 | 13 | export default function ValidRounds({ t }: { t: TFunction }) { 14 | const form = useAtomValue(generalFormControlAtom); 15 | return ( 16 | 17 | 18 | {!form.values.useSugRounds && <> 19 | 20 | 21 | } 22 | 23 | ); 24 | } 25 | 26 | export function UseSugRoundsInput({ t }: { t: TFunction }) { 27 | const form = useAtomValue(generalFormControlAtom); 28 | 29 | const currentURLParams = useSearchParams(); 30 | const storedTxnDataAtom = getStoredTxnDataAtom(currentURLParams); 31 | const storedTxnData = useAtomValue(storedTxnDataAtom); 32 | 33 | const defaultUseSugRounds = useAtomValue(defaultUseSugRoundsAtom); 34 | const setUseSugRounds = useSetAtom(txnDataAtoms.useSugRounds); 35 | 36 | useEffect(() => { 37 | if (storedTxnData?.useSugRounds === undefined && !form.touched.useSugRounds) { 38 | setUseSugRounds(defaultUseSugRounds); 39 | } 40 | // eslint-disable-next-line react-hooks/exhaustive-deps 41 | },[defaultUseSugRounds, storedTxnData]); 42 | 43 | return ( 44 | { 60 | form.setTouched('useSugRounds', true); 61 | form.handleOnChange('useSugRounds')(e.target.checked); 62 | }} 63 | /> 64 | ); 65 | } 66 | -------------------------------------------------------------------------------- /src/app/[lang]/components/form/FileField.tsx: -------------------------------------------------------------------------------- 1 | import FieldTip from './FieldTip'; 2 | import type { FileFieldProps } from './types'; 3 | 4 | /** File form field. Includes a `