diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 0000000..6f5d1d9 --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,24 @@ +name: Build and Test + +on: [push] + +jobs: + build: + runs-on: ubuntu-latest + strategy: + matrix: + node_version: [18.x, 20.x] + command: ["lint", "format", "build", "test"] + steps: + - uses: actions/checkout@v3 + + - name: Set Up node + uses: actions/setup-node@v3 + with: + node-version: ${{ matrix.node_version }} + cache: "yarn" + + - name: Install dependencies + run: yarn --immutable --immutable-cache + + - run: yarn ${{ matrix.command }} diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml new file mode 100644 index 0000000..536e215 --- /dev/null +++ b/.github/workflows/deploy.yml @@ -0,0 +1,33 @@ +name: Deploy to GitHub Pages + +on: + push: + branches: + - prod + +jobs: + deploy: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + + - name: Set Up node + uses: actions/setup-node@v3 + with: + node-version: 20.x + cache: "yarn" + + - name: Install dependencies + run: yarn --immutable --immutable-cache + + - name: Build + run: yarn build + + - name: Copy index.html to 404.html + run: cp ./dist/index.html ./dist/404.html + + - name: Deploy gh-pages if on prod + uses: JamesIves/github-pages-deploy-action@4.1.4 + with: + branch: gh-pages + folder: dist diff --git a/index.html b/index.html index bbd4d2b..ebfe82d 100644 --- a/index.html +++ b/index.html @@ -1,43 +1,48 @@ - + - - - - - - - - - - - ArDrive Turbo App - - -
- - + + + + + + + + + + + + ArDrive Gift App + + +
+ + diff --git a/package.json b/package.json index 8b8f297..4664ff3 100644 --- a/package.json +++ b/package.json @@ -3,6 +3,7 @@ "version": "1.0.0", "type": "module", "description": "ArDrive Turbo App", + "homepage": "./", "license": "AGPL-3.0-or-later", "author": { "name": "Permanent Data Solutions Inc", @@ -14,12 +15,14 @@ "build": "tsc && vite build", "lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0", "format": "prettier --write \"src/**/*.{js,jsx,ts,tsx}\"", - "preview": "vite preview" + "preview": "vite preview", + "test": "echo \"TODO: add tests\" && exit 0" }, "dependencies": { "@ardrive/turbo-sdk": "^1.1.0", "react": "^18.2.0", - "react-dom": "^18.2.0" + "react-dom": "^18.2.0", + "react-router-dom": "^6.20.1" }, "devDependencies": { "@types/react": "^18.2.37", diff --git a/src/Router.tsx b/src/Router.tsx new file mode 100644 index 0000000..5379e89 --- /dev/null +++ b/src/Router.tsx @@ -0,0 +1,22 @@ +import { BrowserRouter, Route, Routes } from "react-router-dom"; +import { GiftPage } from "./pages/GiftPage"; +import { RedeemPage } from "./pages/RedeemPage"; + +export function Router() { + return ( + + + } /> + } /> + + } + /> + 404

} /> +
+
+ ); +} diff --git a/src/components/ArDriveLogo.tsx b/src/components/ArDriveLogo.tsx new file mode 100644 index 0000000..4fb09f5 --- /dev/null +++ b/src/components/ArDriveLogo.tsx @@ -0,0 +1,192 @@ +import { useIsDarkMode } from "../hooks/useIsDarkMode"; + +export function ArDriveLogo() { + const isDarkMode = useIsDarkMode(); + + return ( + + {isDarkMode ? : } + + ); +} + +function ArDriveLogoLight() { + return ( + + + + + + + + + + + + + + + + + + ); +} + +function ArDriveLogoDark() { + return ( + + + + + + + + + + + + + + + + + + + + + + + + + ); +} diff --git a/src/components/Expandable.css b/src/components/Expandable.css new file mode 100644 index 0000000..0c0c3be --- /dev/null +++ b/src/components/Expandable.css @@ -0,0 +1,80 @@ +.expandable { + padding: 1.5rem 0; + font-family: "Wavehaus-Book"; +} + +@media (min-width: 640px) { + .expandable { + font-size: 18px; + } +} + +@media (min-width: 800px) { + .expandable { + font-size: 20px; + } +} + +@media (min-width: 1200px) { + .expandable { + font-size: 24px; + } +} +.expandable h3 { + font-size: inherit; +} + +.expandable:not(:last-of-type) { + border-bottom: 0.25px; + border-color: var(--off-gray); + border-bottom-style: solid; +} + +.expandable button { + background-color: inherit; + border: none; + cursor: pointer; + display: flex; + width: 100%; + font-size: inherit; + + color: var(--text-white); + + padding: 0; + justify-content: space-between; + width: 100%; + align-items: center; + text-align: left; + letter-spacing: 1.09px; + font-family: Wavehaus-Book; +} + +@media (prefers-color-scheme: light) { + .expandable button { + color: var(--black); + } +} + +.expandable div { + padding-left: 1rem; + height: 1rem; + width: 1rem; + display: flex; + justify-content: center; +} + +.expandable p { + padding-top: 1rem; +} + +.expandable strong { + font-family: "Wavehaus-Bold"; +} + +.expandable.expanded button { + font-family: Wavehaus-Semi; +} + +.expandable.expanded div { + transform: rotate(180deg); +} diff --git a/src/components/Expandable.tsx b/src/components/Expandable.tsx new file mode 100644 index 0000000..9b72059 --- /dev/null +++ b/src/components/Expandable.tsx @@ -0,0 +1,30 @@ +import { JSX } from "react"; +import * as React from "react"; +import UpArrowIcon from "./icons/UpArrowIcon"; +import "./Expandable.css"; + +interface ExpandableProps { + question: string; + answer: React.ReactNode; + expanded: boolean; + setExpanded: () => void; +} + +export default function Expandable({ + question, + answer, + expanded, + setExpanded, +}: ExpandableProps): JSX.Element { + return ( +
+ + {expanded && <>{answer}} +
+ ); +} diff --git a/src/components/Faq.content.tsx b/src/components/Faq.content.tsx new file mode 100644 index 0000000..f0f2f03 --- /dev/null +++ b/src/components/Faq.content.tsx @@ -0,0 +1,92 @@ +import * as React from "react"; +export const faqQuestionsAnswers: Array<{ + question: string; + answer: React.JSX.Element; +}> = [ + { + question: "How much storage do I need?", + answer: ( + <> +

+ A little bit of money can go a long way in data storage. As you can + see, a small amount of USD can purchase storage for thousands of + documents or hundreds of photos or songs. +

+

+ Files of the same type (docs, jpegs, or mp3) will vary in sizes, but + in general: 1 document 0.31 MB (320KB); 1 HD Photo 2.5 MB; 3.5 minute + Song 3.5 MB; 1 minute HD video 100 MB. +

+ + ), + }, + { + question: "How are fees calculated?", + answer: ( + <> +

+ The file size determines the fee to upload data to the network. The + larger the file the higher the price will be. +

+

+ Note: All file sizes are represented using binary units of measurement + (i.e. 1 MB = 1024 KB). +

+ + ), + }, + + { + question: "What are Credits?", + answer: ( + <> +

+ Credits offers users the ability to pay via credit card instead of + using the AR Token. Credits represent a 1:1 value with the AR Token, + but are used solely to pay for uploads with ArDrive. +

+

+ Learn more about + Credits. +

+ + ), + }, + { + question: "Is the price of data consistent?", + answer: ( + <> +

+ Not exactly. The Arweave network protocol is always adjusting the data + price to maintain sustainable, perpetual storage. The data price is + lowered as the network increases its overall capacity. +

+

+ This can result in a fluctuating data price. However, for most files + these fluctuations would only increase or decrease costs by fractions + of a cent. +

+ + ), + }, + { + question: "How do I know how big a file is?", + answer: ( +

+ In ArDrive, before any files are uploaded to the network, we calculate + their size and estimate the price to upload. Before it is paid for and + stored permanently, you must approve the upload. This ensures you are + only uploading the right data for the right price. +

+ ), + }, + { + question: "How do I use a credit card?", + answer: ( +

+ You can purchase Credits with your credit card in the web app from the + profile menu. +

+ ), + }, +]; diff --git a/src/components/Faq.css b/src/components/Faq.css new file mode 100644 index 0000000..5694b45 --- /dev/null +++ b/src/components/Faq.css @@ -0,0 +1,29 @@ +.faq { + letter-spacing: 1.09px; + width: 100%; + padding-top: 4rem; + max-width: 40rem; +} + +@media (min-width: 640px) { + .faq { + font-size: 18px; + } +} + +@media (min-width: 800px) { + .faq { + font-size: 20px; + } +} + +@media (min-width: 1200px) { + .faq { + font-size: 24px; + } +} + +.faq h2 { + font-family: Wavehaus-Extra; + padding-bottom: 1rem; +} diff --git a/src/components/Faq.tsx b/src/components/Faq.tsx new file mode 100644 index 0000000..8beee12 --- /dev/null +++ b/src/components/Faq.tsx @@ -0,0 +1,25 @@ +import { JSX, useState } from "react"; +import Expandable from "./Expandable"; +import { faqQuestionsAnswers } from "./Faq.content"; +import "./Faq.css"; + +export default function Faq(): JSX.Element { + const [expanded, setExpanded] = useState(undefined); + + return ( +
+

FAQs

+ {faqQuestionsAnswers.map((qa, index) => ( + + index === expanded ? setExpanded(undefined) : setExpanded(index) + } + > + ))} +
+ ); +} diff --git a/src/components/Footer.tsx b/src/components/Footer.tsx new file mode 100644 index 0000000..db126b5 --- /dev/null +++ b/src/components/Footer.tsx @@ -0,0 +1,9 @@ +import { termsOfServiceUrl } from "../constants"; + +export function Footer() { + return ( + + Terms | v{import.meta.env.PACKAGE_VERSION} + + ); +} diff --git a/src/components/LogoHeader.tsx b/src/components/LogoHeader.tsx new file mode 100644 index 0000000..fb5c2db --- /dev/null +++ b/src/components/LogoHeader.tsx @@ -0,0 +1,17 @@ +import { ArDriveLogo } from "./ArDriveLogo"; + +interface LogoHeaderProps { + errorMessage?: string; +} +export function LogoHeader({ errorMessage }: LogoHeaderProps) { + return ( + <> + + {errorMessage && ( +
+ {errorMessage} +
+ )} + + ); +} diff --git a/src/components/icons/UpArrowIcon.tsx b/src/components/icons/UpArrowIcon.tsx new file mode 100644 index 0000000..fe28715 --- /dev/null +++ b/src/components/icons/UpArrowIcon.tsx @@ -0,0 +1,20 @@ +import { JSX } from "react"; + +export default function UpArrowIcon(): JSX.Element { + return ( + + ); +} diff --git a/src/constants.ts b/src/constants.ts new file mode 100644 index 0000000..9226982 --- /dev/null +++ b/src/constants.ts @@ -0,0 +1,12 @@ +export const paymentServiceUrl = + import.meta.env.PROD === true + ? "https://payment.ardrive.dev" // TODO: change to https://payment.ardrive.io when gifting is in prod + : "http://localhost:3000"; +export const termsOfServiceUrl = "https://ardrive.io/tos-and-privacy/"; +export const defaultUSDAmount = 10.0; +export const turboConfig = { + paymentServiceConfig: { url: paymentServiceUrl }, +}; +export const wincPerCredit = 1_000_000_000_000; +export const defaultDebounceMs = 500; +export const ardriveAppUrl = "https://app.ardrive.io"; diff --git a/src/hooks/useCreditsForFiat.ts b/src/hooks/useCreditsForFiat.ts new file mode 100644 index 0000000..6d6e723 --- /dev/null +++ b/src/hooks/useCreditsForFiat.ts @@ -0,0 +1,32 @@ +import { TurboFactory, USD } from "@ardrive/turbo-sdk"; +import { useState, useRef, useEffect } from "react"; +import { turboConfig, wincPerCredit } from "../constants"; + +export function useCreditsForFiat( + debouncedUsdAmount: number, + errorCallback: (message: string) => void, +): [number | undefined, number | undefined] { + const [winc, setWinc] = useState(undefined); + const usdWhenCreditsWereLastUpdatedRef = useRef( + undefined, + ); + + // Get credits for USD amount when USD amount has stopped debouncing + useEffect(() => { + TurboFactory.unauthenticated(turboConfig) + .getWincForFiat({ amount: USD(debouncedUsdAmount), promoCodes: [] }) + .then(({ winc }) => { + usdWhenCreditsWereLastUpdatedRef.current = debouncedUsdAmount; + setWinc(winc); + }) + .catch((err) => { + console.error(err); + errorCallback(`Error getting credits for USD amount: ${err.message}`); + }); + }, [debouncedUsdAmount, errorCallback]); + + return [ + winc ? +winc / wincPerCredit : undefined, + usdWhenCreditsWereLastUpdatedRef.current, + ]; +} diff --git a/src/hooks/useDebounce.ts b/src/hooks/useDebounce.ts new file mode 100644 index 0000000..8823a04 --- /dev/null +++ b/src/hooks/useDebounce.ts @@ -0,0 +1,21 @@ +import { useEffect, useState } from "react"; +import { defaultDebounceMs } from "../constants"; + +export function useDebounce(value: T, delay?: number): T { + const [debouncedValue, setDebouncedValue] = useState(value); + + useEffect(() => { + const timer = setTimeout( + () => setDebouncedValue(value), + delay || defaultDebounceMs, + ); + + return () => { + clearTimeout(timer); + }; + }, [value, delay]); + + return debouncedValue; +} + +export default useDebounce; diff --git a/src/hooks/useErrorMessage.ts b/src/hooks/useErrorMessage.ts new file mode 100644 index 0000000..fbc3d14 --- /dev/null +++ b/src/hooks/useErrorMessage.ts @@ -0,0 +1,24 @@ +import { useEffect, useState } from "react"; + +const errorMessageTimeout = 5_000; // 5 seconds + +export function useErrorMessage(): [ + string | undefined, + (message: string | undefined) => void, +] { + const [errorMessage, setErrorMessage] = useState( + undefined, + ); + useEffect(() => { + if (!errorMessage) return; + + console.error(errorMessage); + + const timeout = setTimeout(() => { + setErrorMessage(undefined); + }, errorMessageTimeout); + return () => clearTimeout(timeout); + }, [errorMessage]); + + return [errorMessage, setErrorMessage]; +} diff --git a/src/hooks/useIsDarkMode.ts b/src/hooks/useIsDarkMode.ts new file mode 100644 index 0000000..f8bf37d --- /dev/null +++ b/src/hooks/useIsDarkMode.ts @@ -0,0 +1,25 @@ +import { useState, useEffect } from "react"; + +export function useIsDarkMode(): boolean { + const [isDarkMode, setIsDarkMode] = useState(() => { + // Check the current theme using matchMedia + return window.matchMedia("(prefers-color-scheme: dark)").matches; + }); + + useEffect(() => { + // Update the theme on changes + const mediaQueryList = window.matchMedia("(prefers-color-scheme: dark)"); + const handleChange = (e: MediaQueryListEvent) => { + setIsDarkMode(e.matches); + }; + + mediaQueryList.addEventListener("change", handleChange); + + // Cleanup event listener on component unmount + return () => { + mediaQueryList.removeEventListener("change", handleChange); + }; + }, []); + + return isDarkMode; +} diff --git a/src/hooks/useWincForOneGiB.ts b/src/hooks/useWincForOneGiB.ts new file mode 100644 index 0000000..3e0586e --- /dev/null +++ b/src/hooks/useWincForOneGiB.ts @@ -0,0 +1,20 @@ +import { TurboFactory } from "@ardrive/turbo-sdk"; +import { useState, useEffect } from "react"; +import { turboConfig } from "../constants"; + +export function useWincForOneGiB() { + const [wincForOneGiB, setWincForOneGiB] = useState( + undefined, + ); + + // On first render, get winc for 1 GiB for conversions + useEffect(() => { + TurboFactory.unauthenticated(turboConfig) + .getFiatRates() + .then(({ winc }) => { + setWincForOneGiB(winc); + }); + }, []); + + return wincForOneGiB; +} diff --git a/src/index.css b/src/index.css new file mode 100644 index 0000000..cdd05fa --- /dev/null +++ b/src/index.css @@ -0,0 +1,83 @@ +:root { + font-family: Wavehaus-Book, sans-serif; + line-height: 1.5; + font-weight: 400; + + color-scheme: light dark; + color: var(--text-white); + background-color: var(--black); + + font-synthesis: none; + text-rendering: optimizeLegibility; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + + --ardrive-red: #d31721; + --text-white: #a6a6a6; + --white: #fafafa; + --black: #0d0d0d; + --dark-gray: #171717; + --gray: #7d7d7d; + --off-gray: #787878; + + --mobile: 640px; + --tablet: 800px; + --desktop: 1200px; + + font-size: 0.75rem; +} + +@media (min-width: 640px) { + :root { + font-size: 0.85rem; + } +} + +@media (min-width: 800px) { + :root { + font-size: 1.1rem; + } +} + +@media (min-width: 1200px) { + :root { + font-size: 1.25rem; + } +} + +/* Firefox */ +input[type="number"] { + -webkit-appearance: textfield; + -moz-appearance: textfield; + appearance: textfield; +} + +@media (prefers-color-scheme: light) { + :root { + color: var(--black); + background-color: var(--white); + } +} + +.ardrive-logo:hover { + filter: drop-shadow(0 0 2em var(--ardrive-red)); +} + +.alert { + margin: 1rem 0; + padding: 1rem 2rem; + border-radius: 0.5rem; + font-size: 1rem; + font-family: Wavehaus-Extra; + color: var(--ardrive-red); +} + +h1 { + font-family: Wavehaus-Extra; + font-size: 2.15rem; + margin: 3rem 2rem 0rem 2rem; +} + +p { + text-align: left; +} diff --git a/src/main.tsx b/src/main.tsx index f25366e..fad9c77 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -1,10 +1,10 @@ import React from "react"; import ReactDOM from "react-dom/client"; -import App from "./App.tsx"; import "./index.css"; +import { Router } from "./Router.tsx"; ReactDOM.createRoot(document.getElementById("root")!).render( - + , ); diff --git a/src/pages/GiftPage.content.ts b/src/pages/GiftPage.content.ts new file mode 100644 index 0000000..bc74ab7 --- /dev/null +++ b/src/pages/GiftPage.content.ts @@ -0,0 +1,9 @@ +export const GiftPageContent = { + header: "Gift Storage Credits", + headerParagraph: + "Credits can be redeemed for a secure and permanent place to store precious memories such as photos, videos, docs and more.", + recipientParagraph: + "Your recipient will receive an email with instructions on how to redeem and use their gift of permanent storage.", + recipientParagraph2: + "If you'd prefer them to receive the email at another time, please enter your own email address and plan to forward the email when you'd like the recipient to receive your gift.", +}; diff --git a/src/pages/GiftPage.css b/src/pages/GiftPage.css new file mode 100644 index 0000000..7a27b52 --- /dev/null +++ b/src/pages/GiftPage.css @@ -0,0 +1,160 @@ +/* + If the input is not focused, and the placeholder is not shown, and the input is invalid, + then change the border color to red to indicate invalid email address. +*/ +#recipient-email:not(:focus):not(:placeholder-shown):invalid { + border-color: var(--ardrive-red); +} + +.suggested-amount-buttons { + display: flex; + flex-direction: row; + justify-content: left; + margin: 0rem 0; + width: 100%; +} + +.suggested-amount-button { + margin-left: 0.5rem; + padding: 0.5rem 1rem; + border: 1px solid var(--off-gray); + border-radius: 0.5rem; + font-size: 1.2rem; + font-family: Wavehaus-Semi; + background-color: var(--dark-gray); + color: var(--white); + cursor: pointer; + flex-grow: 1; +} +.suggested-amount-button:first-child { + margin-left: 0rem; +} + +.suggested-amount-button:hover { + background-color: var(--gray); + color: var(--black); +} + +@media (prefers-color-scheme: light) { + .suggested-amount-button { + background-color: var(--white); + color: var(--black); + } + + .suggested-amount-button:hover { + background-color: var(--gray); + color: var(--black); + } +} + +#usd-form-input { + display: flex; + width: 100%; + background-color: var(--dark-gray); + width: 100%; + border: 1px solid var(--off-gray); + border-radius: 0.5rem; +} + +#dollar-sign { + font-family: Wavehaus-Semi; + font-size: 1.2rem; + padding: 0.5rem 1rem; + border: none; +} + +#usd-input { + font-family: Wavehaus-Semi; + font-size: 1.2rem; + width: 100%; + margin: 0.5rem 0.5rem; + border: none; + background-color: var(--dark-gray); +} + +.form-input:focus, +#usd-input:focus, +#usd-form-input:focus-within { + outline: none; + border-color: var(--white); + border-width: 0.1rem; +} + +#conversions { + margin: 2rem 0 2rem 0; + font-family: Wavehaus-Semi; + font-size: 1.5rem; +} + +.conversion-amount { + font-family: Wavehaus-Semi; + font-size: 1.75rem; + margin: 0.25rem; +} + +#gift-message { + min-height: 10rem; + resize: none; +} + +.terms-and-conditions { + display: flex; + + margin: 1rem 0; + font-size: 1rem; + font-family: Wavehaus-Semi; +} + +.terms-and-conditions a { + color: var(--ardrive-red); + font-family: Wavehaus-Bold; +} + +.terms-and-conditions a:hover { + color: var(--ardrive-red); + + text-decoration: underline; +} + +a { + color: var(--ardrive-red); + text-decoration: inherit; +} +a:hover { + text-decoration: underline; +} + +input[type="checkbox"] { + width: 1.5rem; + height: 1.5rem; + border-radius: 0.25rem; + outline: none; + border: 0.1rem solid var(--gray); + /* don't resize this when mobile squishes screen */ + flex-shrink: 0; +} +input[type="checkbox"]:checked { + background-color: var(--ardrive-red); +} + +input[type="checkbox"]:hover { + cursor: pointer; +} + +#terms-and-conditions-checkbox { + margin: 0 0.75rem; +} + +@media (prefers-color-scheme: light) { + .form-input, + #usd-input, + #usd-form-input { + background-color: var(--white); + } + + .form-input:focus, + #usd-input:focus, + #usd-form-input:focus-within { + border-color: var(--black); + } +} diff --git a/src/pages/GiftPage.tsx b/src/pages/GiftPage.tsx new file mode 100644 index 0000000..5530f37 --- /dev/null +++ b/src/pages/GiftPage.tsx @@ -0,0 +1,272 @@ +import { useState, useRef, useEffect } from "react"; +import { + defaultUSDAmount, + termsOfServiceUrl, + wincPerCredit, +} from "../constants"; +import useDebounce from "../hooks/useDebounce"; +import "./GiftPage.css"; +import { useWincForOneGiB } from "../hooks/useWincForOneGiB"; +import { useCreditsForFiat } from "../hooks/useCreditsForFiat"; +import { getCheckoutSessionUrl } from "../utils/getCheckoutSessionUrl"; +import { Page } from "./Page"; +import { ErrMsgCallbackAsProps } from "../types"; +import Faq from "../components/Faq"; +import { useLocation } from "react-router"; + +import { GiftPageContent } from "./GiftPage.content"; + +const maxUSDAmount = 10000; +const minUSDAmount = 5; + +function GiftForm({ errorCallback }: ErrMsgCallbackAsProps) { + const location = useLocation(); + + const [usdAmount, setUsdAmount] = useState(defaultUSDAmount); + const [recipientEmail, setRecipientEmail] = useState(""); + const [giftMessage, setGiftMessage] = useState(""); + + useEffect(() => { + const urlParams = new URLSearchParams(location.search); + + const amountParam = urlParams.get("amount"); + const recipientEmailParam = urlParams.get("email"); + const giftMessageParam = urlParams.get("giftMessage"); + + if (amountParam) { + setUsdAmount(+amountParam / 100); + } + if (recipientEmailParam) { + setRecipientEmail(recipientEmailParam); + } + if (giftMessageParam) { + setGiftMessage(giftMessageParam); + } + }, [location.search]); + + const [isTermsAccepted, setTermsAccepted] = useState(false); + + const handleUSDChange = (e: React.ChangeEvent) => { + const amount = +e.target.value; + if (amount > maxUSDAmount) { + setUsdAmount(maxUSDAmount); + return; + } + if (amount < 0) { + setUsdAmount(0); + return; + } + setUsdAmount(+amount.toFixed(2)); + }; + + const wincForOneGiB = useWincForOneGiB(); + const debouncedUsdAmount = useDebounce(usdAmount); + const [credits, usdWhenCreditsWereLastUpdatedRef] = useCreditsForFiat( + debouncedUsdAmount, + errorCallback, + ); + + const recipientEmailRef = useRef(null); + const isEmailHtmlElementValid = recipientEmailRef.current?.checkValidity(); + + const canSubmitForm = + !!credits && + !!recipientEmail && + !!isTermsAccepted && + isEmailHtmlElementValid; + + const handleSubmit = (e: React.MouseEvent) => { + e.preventDefault(); + + if (!canSubmitForm) { + return; + } + + if (usdAmount < minUSDAmount) { + return; + } + + const giftMessage = ( + document.getElementById("gift-message") as HTMLInputElement + ).value; + + getCheckoutSessionUrl({ + usdAmount, + recipientEmail, + giftMessage, + }) + .then((url) => { + window.location.href = url; + }) + .catch((e) => { + errorCallback(`Error getting checkout session URL: ${e}`); + }); + }; + + const displayConversion = + !!credits && + !!wincForOneGiB && + usdWhenCreditsWereLastUpdatedRef && + usdWhenCreditsWereLastUpdatedRef >= minUSDAmount; + + return ( + <> +
+

{GiftPageContent.header}

+

{GiftPageContent.headerParagraph}

+
+ +
+ + + +
+ + +
+ {"$".toLocaleUpperCase()} + +
+
+ + {usdAmount < minUSDAmount ? ( +
+ Minimum USD amount is{" "} + {minUSDAmount.toLocaleString("en-US", { + style: "currency", + currency: "USD", + })} +
+ ) : ( + displayConversion && ( +
+ {wincForOneGiB && ( +
+ {"$".toLocaleUpperCase()} + + {usdWhenCreditsWereLastUpdatedRef} + {" "} + ≈{" "} + + {credits.toFixed(4)} + + Credits ≈{" "} + + {( + Number(credits * wincPerCredit) / Number(wincForOneGiB) + ).toFixed(2)} + + GiB +
+ )} +
+ ) + )} + +
+ + + { + setRecipientEmail(e.target.value); + }} + /> + +

{GiftPageContent.recipientParagraph}

+

{GiftPageContent.recipientParagraph2}

+
+ +
+ +