diff --git a/apps/faucet/src/App/App.components.ts b/apps/faucet/src/App/App.components.ts index ea4de2696..e47bf5acb 100644 --- a/apps/faucet/src/App/App.components.ts +++ b/apps/faucet/src/App/App.components.ts @@ -140,3 +140,50 @@ export const ContentContainer = styled.div` padding: 0 16px; } `; + +export const InputContainer = styled.div` + margin: 12px 0; +`; + +export const ButtonContainer = styled.div` + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + margin: 13px 0 0 0; +`; + +export const SettingsButtonContainer = styled.div` + display: flex; + align-items: center; + justify-content: flex-end; + width: 100%; +`; + +export const SettingsButton = styled.button` + & > svg { + width: 20px; + height: 20px; + color: ${(props) => props.theme.colors.primary.main20}; + } +`; + +export const SettingsContainer = styled.div` + flex-direction: column; + justify-content: start; + align-items: center; + box-sizing: border-box; + background-color: ${(props) => + getColor(ComponentColor.BackgroundColor, props.theme)}; + border: 1px solid ${(props) => props.theme.colors.primary.main20}; + border-radius: ${(props) => props.theme.borderRadius.mainContainer}; + transition: background-color 0.3s linear; +`; + +export const SettingsFormContainer = styled.form` + flex-direction: column; + justify-content: start; + align-items: center; + padding: 32px 40px; + width: 500px; +`; diff --git a/apps/faucet/src/App/App.tsx b/apps/faucet/src/App/App.tsx index 8c3b473d7..5314cd835 100644 --- a/apps/faucet/src/App/App.tsx +++ b/apps/faucet/src/App/App.tsx @@ -1,20 +1,21 @@ import React, { createContext, useCallback, useEffect, useState } from "react"; +import { GoGear } from "react-icons/go"; import { ThemeProvider } from "styled-components"; -import { ActionButton, Alert, Heading } from "@namada/components"; +import { ActionButton, Alert, Modal } from "@namada/components"; import { Namada } from "@namada/integrations"; import { ColorMode, getTheme } from "@namada/utils"; import { AppContainer, BackgroundImage, - Banner, - BannerContents, BottomSection, ContentContainer, FaucetContainer, GlobalStyles, InfoContainer, + SettingsButton, + SettingsButtonContainer, TopSection, } from "App/App.components"; import { FaucetForm } from "App/Faucet"; @@ -22,28 +23,28 @@ import { FaucetForm } from "App/Faucet"; import { chains } from "@namada/chains"; import { useUntil } from "@namada/hooks"; import { Account } from "@namada/types"; -import { API } from "utils"; +import { API, toNam } from "utils"; import dotsBackground from "../../public/bg-dots.svg"; -import { CallToActionCard } from "./CallToActionCard"; -import { CardsContainer } from "./Card.components"; -import { Faq } from "./Faq"; +import { + AppBanner, + AppHeader, + CallToActionCard, + CardsContainer, + Faq, +} from "./Common"; +import { SettingsForm } from "./SettingsForm"; const DEFAULT_URL = "http://localhost:5000"; -const DEFAULT_ENDPOINT = "/api/v1/faucet"; -const DEFAULT_FAUCET_LIMIT = "1000"; +const DEFAULT_LIMIT = 1_000_000_000; const { NAMADA_INTERFACE_FAUCET_API_URL: faucetApiUrl = DEFAULT_URL, - NAMADA_INTERFACE_FAUCET_API_ENDPOINT: faucetApiEndpoint = DEFAULT_ENDPOINT, - NAMADA_INTERFACE_FAUCET_LIMIT: faucetLimit = DEFAULT_FAUCET_LIMIT, NAMADA_INTERFACE_PROXY: isProxied, NAMADA_INTERFACE_PROXY_PORT: proxyPort = 9000, } = process.env; -const apiUrl = isProxied ? `http://localhost:${proxyPort}/proxy` : faucetApiUrl; -const url = `${apiUrl}${faucetApiEndpoint}`; -const api = new API(url); -const limit = parseInt(faucetLimit); +const baseUrl = + isProxied ? `http://localhost:${proxyPort}/proxy` : faucetApiUrl; const runFullNodeUrl = "https://docs.namada.net/operators/ledger"; const becomeBuilderUrl = "https://docs.namada.net/integrating-with-namada"; @@ -52,13 +53,18 @@ type Settings = { tokens?: Record; startsAt: number; startsAtText?: string; + withdrawLimit: number; }; -type AppContext = Settings & { - limit: number; - url: string; +type AppContext = { + baseUrl: string; settingsError?: string; api: API; + isTestnetLive: boolean; + settings: Settings; + setApi: (api: API) => void; + setUrl: (url: string) => void; + setIsModalOpen: (value: boolean) => void; }; const START_TIME_UTC = 1702918800; @@ -74,17 +80,7 @@ const START_TIME_TEXT = new Date(START_TIME_UTC * 1000).toLocaleString( } ); -const defaults = { - startsAt: START_TIME_UTC, - startsAtText: `${START_TIME_TEXT} UTC`, -}; - -export const AppContext = createContext({ - ...defaults, - limit, - url, - api, -}); +export const AppContext = createContext(null); enum ExtensionAttachStatus { PendingDetection, @@ -104,8 +100,13 @@ export const App: React.FC = () => { const [colorMode, _] = useState(initialColorMode); const [isTestnetLive, setIsTestnetLive] = useState(true); const [settings, setSettings] = useState({ - ...defaults, + startsAt: START_TIME_UTC, + startsAtText: `${START_TIME_TEXT} UTC`, + withdrawLimit: toNam(DEFAULT_LIMIT), }); + const [url, setUrl] = useState(localStorage.getItem("baseUrl") || baseUrl); + const [api, setApi] = useState(new API(url)); + const [isModalOpen, setIsModalOpen] = useState(false); const [settingsError, setSettingsError] = useState(); const theme = getTheme(colorMode); @@ -124,6 +125,10 @@ export const App: React.FC = () => { ); useEffect(() => { + // Sync url to localStorage + localStorage.setItem("baseUrl", url); + const api = new API(url); + setApi(api); const { startsAt } = settings; const now = new Date(); const nowUTC = Date.UTC( @@ -141,26 +146,28 @@ export const App: React.FC = () => { // Fetch settings from faucet API (async () => { try { - const { difficulty, tokens_alias_to_address: tokens } = await api - .settings() - .catch((e) => { - const message = e.errors?.message; - setSettingsError( - `Error requesting settings: ${message?.join(" ")}` - ); - throw new Error(e); - }); + const { + difficulty, + tokens_alias_to_address: tokens, + withdraw_limit: withdrawLimit = DEFAULT_LIMIT, + } = await api.settings().catch((e) => { + const message = e.errors?.message; + setSettingsError(`Error requesting settings: ${message?.join(" ")}`); + throw new Error(e); + }); // Append difficulty level and tokens to settings setSettings({ ...settings, difficulty, tokens, + withdrawLimit: toNam(withdrawLimit), }); + setSettingsError(undefined); } catch (e) { setSettingsError(`Failed to load settings! ${e}`); } })(); - }, []); + }, [url]); const handleConnectExtensionClick = useCallback(async (): Promise => { if (integration) { @@ -186,43 +193,53 @@ export const App: React.FC = () => { return ( - {!isTestnetLive && settings?.startsAtText && ( - - - Testnet will go live {settings.startsAtText}! Faucet is disabled - until then. - - - )} + + + setIsModalOpen(true)} + title="Settings" + > + + + + - - Namada Faucet - + - {extensionAttachStatus === - ExtensionAttachStatus.PendingDetection && ( + {settingsError && ( - Detecting extension... + {settingsError} )} + + {extensionAttachStatus === + ExtensionAttachStatus.PendingDetection && ( + + Detecting extension... + + )} {extensionAttachStatus === ExtensionAttachStatus.NotInstalled && ( You must download the extension! )} + {isExtensionConnected && ( { )} + {isModalOpen && ( + setIsModalOpen(false)}> + + + )} { + const { isTestnetLive, settings } = useContext(AppContext)!; + return ( + <> + {!isTestnetLive && settings?.startsAtText && ( + + + Testnet will go live {settings.startsAtText}! Faucet is disabled + until then. + + + )} + + ); +}; diff --git a/apps/faucet/src/App/Common/AppHeader.components.ts b/apps/faucet/src/App/Common/AppHeader.components.ts new file mode 100644 index 000000000..a3fae7d74 --- /dev/null +++ b/apps/faucet/src/App/Common/AppHeader.components.ts @@ -0,0 +1,7 @@ +import styled from "styled-components"; + +export const AppHeaderContainer = styled.div` + display: flex; + align-items: center; + justify-content: center; +`; diff --git a/apps/faucet/src/App/Common/AppHeader.tsx b/apps/faucet/src/App/Common/AppHeader.tsx new file mode 100644 index 000000000..95ec56c21 --- /dev/null +++ b/apps/faucet/src/App/Common/AppHeader.tsx @@ -0,0 +1,13 @@ +import { Heading } from "@namada/components"; +import React from "react"; +import { AppHeaderContainer } from "./AppHeader.components"; + +export const AppHeader: React.FC = () => { + return ( + + + Namada Faucet + + + ); +}; diff --git a/apps/faucet/src/App/Common/Banner.components.ts b/apps/faucet/src/App/Common/Banner.components.ts new file mode 100644 index 000000000..f1a85b7a3 --- /dev/null +++ b/apps/faucet/src/App/Common/Banner.components.ts @@ -0,0 +1,21 @@ +import styled from "styled-components"; + +export const Banner = styled.div` + width: 100%; + display: flex; + justify-content: center; + align-items: center; + background-color: ${(props) => props.theme.colors.utility3.highAttention}; + color: ${(props) => props.theme.colors.primary.main20}; + font-size: 13px; + font-weight: bold; +`; + +export const BannerContents = styled.div` + display: flex; + width: 100%; + align-items: center; + max-width: 762px; + padding: 8px 0; + margin: 0 20px; +`; diff --git a/apps/faucet/src/App/CallToActionCard.tsx b/apps/faucet/src/App/Common/CallToActionCard.tsx similarity index 88% rename from apps/faucet/src/App/CallToActionCard.tsx rename to apps/faucet/src/App/Common/CallToActionCard.tsx index 1f9e807ff..725ee8f30 100644 --- a/apps/faucet/src/App/CallToActionCard.tsx +++ b/apps/faucet/src/App/Common/CallToActionCard.tsx @@ -1,6 +1,6 @@ import React, { useState } from "react"; -import InclineArrowBlack from "../../public/incline-arrow-black.svg"; -import InclineArrowYellow from "../../public/incline-arrow-yellow.svg"; +import InclineArrowBlack from "../../../public/incline-arrow-black.svg"; +import InclineArrowYellow from "../../../public/incline-arrow-yellow.svg"; import { BottomBorder, CallToActionContainer, diff --git a/apps/faucet/src/App/Card.components.tsx b/apps/faucet/src/App/Common/Card.components.tsx similarity index 100% rename from apps/faucet/src/App/Card.components.tsx rename to apps/faucet/src/App/Common/Card.components.tsx diff --git a/apps/faucet/src/App/Faq.components.tsx b/apps/faucet/src/App/Common/Faq.components.tsx similarity index 100% rename from apps/faucet/src/App/Faq.components.tsx rename to apps/faucet/src/App/Common/Faq.components.tsx diff --git a/apps/faucet/src/App/Faq.tsx b/apps/faucet/src/App/Common/Faq.tsx similarity index 100% rename from apps/faucet/src/App/Faq.tsx rename to apps/faucet/src/App/Common/Faq.tsx diff --git a/apps/faucet/src/App/FaqDropdown.tsx b/apps/faucet/src/App/Common/FaqDropdown.tsx similarity index 93% rename from apps/faucet/src/App/FaqDropdown.tsx rename to apps/faucet/src/App/Common/FaqDropdown.tsx index a9d7a149f..02f557268 100644 --- a/apps/faucet/src/App/FaqDropdown.tsx +++ b/apps/faucet/src/App/Common/FaqDropdown.tsx @@ -1,5 +1,5 @@ import React, { useState } from "react"; -import plusIcon from "../../public/plus-icon.svg"; +import plusIcon from "../../../public/plus-icon.svg"; import { DropDownTitle, DropDownTitleText, diff --git a/apps/faucet/src/App/Common/index.ts b/apps/faucet/src/App/Common/index.ts new file mode 100644 index 000000000..448948934 --- /dev/null +++ b/apps/faucet/src/App/Common/index.ts @@ -0,0 +1,6 @@ +export * from "./AppBanner"; +export * from "./AppHeader"; +export * from "./CallToActionCard"; +export * from "./Card.components"; +export * from "./Faq"; +export * from "./FaqDropdown"; diff --git a/apps/faucet/src/App/Faucet.components.ts b/apps/faucet/src/App/Faucet.components.ts index ba8e812b8..c4d20fef9 100644 --- a/apps/faucet/src/App/Faucet.components.ts +++ b/apps/faucet/src/App/Faucet.components.ts @@ -1,6 +1,6 @@ import styled from "styled-components"; -export const FaucetFormContainer = styled.div` +export const FaucetFormContainer = styled.form` flex-direction: column; justify-content: start; align-items: center; @@ -11,18 +11,6 @@ export const FaucetFormContainer = styled.div` } `; -export const InputContainer = styled.div` - margin: 12px 0; -`; - -export const ButtonContainer = styled.div` - display: flex; - flex-direction: column; - justify-content: center; - align-items: center; - margin: 13px 0 0 0; -`; - export const PreFormatted = styled.pre` font-size: 12px; background-color: ${(props) => props.theme.colors.utility2.main20}; diff --git a/apps/faucet/src/App/Faucet.tsx b/apps/faucet/src/App/Faucet.tsx index 2b8e6dcc5..051c62685 100644 --- a/apps/faucet/src/App/Faucet.tsx +++ b/apps/faucet/src/App/Faucet.tsx @@ -15,12 +15,14 @@ import { bech32mValidation, shortenAddress } from "@namada/utils"; import { TransferResponse, computePowSolution } from "../utils"; import { AppContext } from "./App"; -import { InfoContainer } from "./App.components"; import { ButtonContainer, + InfoContainer, + InputContainer, +} from "./App.components"; +import { FaucetFormContainer, FormStatus, - InputContainer, PreFormatted, } from "./Faucet.components"; @@ -43,8 +45,10 @@ export const FaucetForm: React.FC = ({ integration, isTestnetLive, }) => { - const { api, difficulty, settingsError, limit, tokens } = - useContext(AppContext); + const { + api, + settings: { difficulty, tokens, withdrawLimit }, + } = useContext(AppContext)!; const accountLookup = accounts.reduce( (acc, account) => { @@ -75,7 +79,7 @@ export const FaucetForm: React.FC = ({ const isFormValid: boolean = Boolean(tokenAddress) && Boolean(amount) && - (amount || 0) <= limit && + (amount || 0) <= withdrawLimit && Boolean(account) && status !== Status.Pending && typeof difficulty !== "undefined" && @@ -174,7 +178,6 @@ export const FaucetForm: React.FC = ({ return ( - {settingsError && {settingsError}} {accounts.length > 0 ? { + setApiUrl(e.target.value); + validateUrl(e.target.value); + }} + /> + + + handleSetUrl(apiUrl)} + disabled={!isFormValid || apiUrl === baseUrl} + > + Update URL + + + + + ); +}; diff --git a/apps/faucet/src/utils/api.ts b/apps/faucet/src/utils/api.ts index 41550a51e..48a200dd9 100644 --- a/apps/faucet/src/utils/api.ts +++ b/apps/faucet/src/utils/api.ts @@ -6,8 +6,12 @@ import { TransferResponse, } from "./types"; +export const { + NAMADA_INTERFACE_FAUCET_API_ENDPOINT: endpoint = "/api/v1/faucet", +} = process.env; + export class API { - constructor(protected readonly url: string) {} + constructor(public readonly baseUrl: string) { } /** * Wrapper for fetch requests to handle ReadableStream response when errors are received from API @@ -21,7 +25,7 @@ export class API { endpoint: string, options: RequestInit = { method: "GET" } ): Promise { - return await fetch(new URL(`${this.url}${endpoint}`), { + return await fetch(this.endpoint(endpoint), { ...options, }) .then((response) => { @@ -84,4 +88,8 @@ export class API { }, }); } + + private endpoint(path?: string): URL { + return new URL(`${this.baseUrl}${endpoint}${path ? path : ""}`); + } } diff --git a/apps/faucet/src/utils/helpers.ts b/apps/faucet/src/utils/helpers.ts new file mode 100644 index 000000000..c87d8d9f2 --- /dev/null +++ b/apps/faucet/src/utils/helpers.ts @@ -0,0 +1,6 @@ +/** + * Take a value in namnam, return NAM + */ +export const toNam = (amount: number): number => { + return amount / 1_000_000; +}; diff --git a/apps/faucet/src/utils/index.ts b/apps/faucet/src/utils/index.ts index 17f61411c..a9333307e 100644 --- a/apps/faucet/src/utils/index.ts +++ b/apps/faucet/src/utils/index.ts @@ -1,3 +1,4 @@ export * from "./api"; +export * from "./helpers"; export * from "./pow"; export * from "./types"; diff --git a/apps/faucet/src/utils/types.ts b/apps/faucet/src/utils/types.ts index 325aaef3c..2f93c03ea 100644 --- a/apps/faucet/src/utils/types.ts +++ b/apps/faucet/src/utils/types.ts @@ -8,6 +8,7 @@ export type SettingsResponse = { chain_id: string; start_at: number; tokens_alias_to_address: Record; + withdraw_limit: number; }; export type TransferDetails = {