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 (
+
+
setExpanded()}>
+ {question}
+
+
+
+
+ {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 (
+
+ );
+}
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 (
+ <>
+
+
+
+ >
+ );
+}
+
+export const GiftPage = () => (
+ } />
+);
diff --git a/src/pages/Page.css b/src/pages/Page.css
new file mode 100644
index 0000000..dfefd7d
--- /dev/null
+++ b/src/pages/Page.css
@@ -0,0 +1,96 @@
+#root {
+ margin: 0 auto;
+ padding: 2rem;
+ text-align: center;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+}
+
+#version-footer {
+ font-family: "Wavehaus-Bold";
+ font-size: 14px;
+ letter-spacing: 0.82px;
+ padding-top: 4rem;
+
+ justify-self: flex-end;
+ align-self: flex-end;
+ text-align: right;
+}
+
+@media (min-width: 800px) {
+ #version-footer {
+ font-size: 16px;
+ }
+}
+
+@media (min-width: 1200px) {
+ #version-footer {
+ font-size: 18px;
+ }
+}
+
+.form {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ margin: 0rem 2rem;
+ width: 100%;
+ max-width: 32rem;
+}
+
+.form-section {
+ display: flex;
+ flex-direction: column;
+ align-items: left;
+ width: 100%;
+}
+
+.form-label {
+ margin: 0.5rem 0.5rem 0.25rem 0.1rem;
+ font-size: 1.25rem;
+ font-weight: bold;
+ font-family: Wavehaus-Bold;
+ text-align: left;
+ font-size: 1rem;
+}
+
+.form-input {
+ width: 100%;
+ padding: 0.5rem 1rem;
+ border: 1px solid var(--off-gray);
+ border-radius: 0.5rem;
+ font-size: 1.2rem;
+ font-family: Wavehaus-Semi;
+ box-sizing: border-box;
+ background-color: var(--dark-gray);
+}
+
+@media (prefers-color-scheme: light) {
+ .form-input {
+ background-color: var(--white);
+ }
+}
+
+.proceed-button {
+ margin: 2rem;
+ padding: 1rem 1.25rem;
+ border: none;
+ border-radius: 0.5rem;
+ background-color: var(--ardrive-red);
+ color: var(--white);
+ font-size: 1.25rem;
+ font-family: Wavehaus-Bold;
+ cursor: pointer;
+}
+
+.proceed-button:hover {
+ background-color: var(--ardrive-red);
+}
+
+.proceed-button:disabled {
+ background-color: var(--off-gray);
+ cursor: not-allowed;
+}
diff --git a/src/pages/Page.tsx b/src/pages/Page.tsx
new file mode 100644
index 0000000..49579ac
--- /dev/null
+++ b/src/pages/Page.tsx
@@ -0,0 +1,21 @@
+import { useErrorMessage } from "../hooks/useErrorMessage";
+import { LogoHeader } from "../components/LogoHeader";
+import "./Page.css";
+import { ErrMsgCallback } from "../types";
+import { Footer } from "../components/Footer";
+
+interface PageProps {
+ page: (errorCallback: ErrMsgCallback) => JSX.Element;
+}
+
+export function Page({ page }: PageProps) {
+ const [errorMessage, errorCallback] = useErrorMessage();
+
+ return (
+ <>
+
+ {page(errorCallback)}
+
+ >
+ );
+}
diff --git a/src/pages/RedeemPage.css b/src/pages/RedeemPage.css
new file mode 100644
index 0000000..24df845
--- /dev/null
+++ b/src/pages/RedeemPage.css
@@ -0,0 +1,9 @@
+.redeem-info {
+ text-align: left;
+}
+
+.redeem-info h2 {
+ font-family: Wavehaus-Extra;
+ font-size: 1.5rem;
+ margin: 1rem 0 0;
+}
diff --git a/src/pages/RedeemPage.tsx b/src/pages/RedeemPage.tsx
new file mode 100644
index 0000000..e8a2435
--- /dev/null
+++ b/src/pages/RedeemPage.tsx
@@ -0,0 +1,171 @@
+import { useEffect, useState } from "react";
+import { ErrMsgCallbackAsProps } from "../types";
+import { redeemGift } from "../utils/redeemGift";
+import { ardriveAppUrl } from "../constants";
+import { Page } from "./Page";
+import { useLocation } from "react-router-dom";
+import "./RedeemPage.css";
+
+function RedeemForm({ errorCallback }: ErrMsgCallbackAsProps) {
+ const [destinationAddress, setDestinationAddress] = useState("");
+ const [recipientEmail, setRecipientEmail] = useState("");
+ const [redemptionCode, setRedemptionCode] = useState("");
+
+ const location = useLocation();
+
+ useEffect(() => {
+ const urlParams = new URLSearchParams(location.search);
+
+ const redemptionCodeParam = urlParams.get("id");
+ const recipientEmailParam = urlParams.get("email");
+ const destinationAddressParam = urlParams.get("destinationAddress");
+
+ if (redemptionCodeParam) {
+ setRedemptionCode(redemptionCodeParam);
+ }
+ if (recipientEmailParam) {
+ setRecipientEmail(recipientEmailParam);
+ }
+ if (destinationAddressParam) {
+ setDestinationAddress(destinationAddressParam);
+ }
+ }, [location.search]);
+
+ const canSubmitForm =
+ !!destinationAddress && !!recipientEmail && !!redemptionCode;
+
+ const handleSubmit = (e: React.MouseEvent) => {
+ e.preventDefault();
+
+ if (!canSubmitForm) {
+ return;
+ }
+
+ redeemGift({ redemptionCode, destinationAddress, recipientEmail })
+ .then(() => {
+ // TODO: Success Modal or Page
+ console.log("Gift redeemed!");
+ alert("Gift redeemed, redirecting to ArDrive App!");
+
+ setTimeout(() => {
+ console.log("Redirecting to ArDrive app...");
+ window.location.href = ardriveAppUrl;
+ }, 2000);
+ })
+ .catch((err) => {
+ errorCallback(`Error redeeming gift: ${err.message}`);
+ });
+ };
+
+ return (
+ <>
+ Redeem Your Gift of Storage Credits
+
+
+
+
+ If you're new to ArDrive, here are a few resources to get you
+ started:
+
+
+
+
+
Get Started:
+
+
+
+ Step 1:{" "}
+
+ Get a Wallet
+
+
+
+
+ Step 2: Enter the wallet address here
+
+
+ Step 3: Enter your gift code and email address
+
+
+
+
+ Need help? Head to{" "}
+ Help Center
+
+
+
+ Wallet Address
+ {
+ setDestinationAddress(e.target.value);
+ }}
+ />
+
+
+
+ Gift Code
+ {
+ setRedemptionCode(e.target.value);
+ }}
+ />
+
+
+
+ Email Address
+ {
+ setRecipientEmail(e.target.value);
+ }}
+ />
+
+ handleSubmit(e)}
+ disabled={!canSubmitForm}
+ >
+ Redeem
+
+
+
+
+
+
+ If you do not have an Arweave wallet, you can create one in{" "}
+ ArDrive App .
+
+
+ >
+ );
+}
+
+export const RedeemPage = () => (
+ } />
+);
diff --git a/src/types.ts b/src/types.ts
new file mode 100644
index 0000000..5ca94c2
--- /dev/null
+++ b/src/types.ts
@@ -0,0 +1,4 @@
+export type ErrMsgCallback = (error: string) => void;
+export type ErrMsgCallbackAsProps = {
+ errorCallback: ErrMsgCallback;
+};
diff --git a/src/utils/getCheckoutSessionUrl.ts b/src/utils/getCheckoutSessionUrl.ts
new file mode 100644
index 0000000..e34247c
--- /dev/null
+++ b/src/utils/getCheckoutSessionUrl.ts
@@ -0,0 +1,25 @@
+import { paymentServiceUrl } from "../constants";
+
+export async function getCheckoutSessionUrl({
+ giftMessage,
+ recipientEmail,
+ usdAmount,
+}: {
+ usdAmount: number;
+ recipientEmail: string;
+ giftMessage?: string;
+}): Promise {
+ // TODO: support emails on turbo sdk
+ // return turboFactory.unauthenticated(turboConfig).createCheckoutSession({ amount: USD(usdAmount / 100), email: recipientEmail })
+ const response = await fetch(
+ `${paymentServiceUrl}/v1/top-up/checkout-session/${recipientEmail}/usd/${
+ usdAmount * 100
+ }?destinationAddressType=email${
+ giftMessage ? `&giftMessage=${giftMessage}` : ""
+ }`,
+ );
+ const data = await response.json();
+
+ // Send user to checkout session
+ return data.paymentSession.url;
+}
diff --git a/src/utils/redeemGift.ts b/src/utils/redeemGift.ts
new file mode 100644
index 0000000..f772f6b
--- /dev/null
+++ b/src/utils/redeemGift.ts
@@ -0,0 +1,19 @@
+import { paymentServiceUrl } from "../constants";
+
+export async function redeemGift({
+ destinationAddress,
+ recipientEmail,
+ redemptionCode,
+}: {
+ redemptionCode: string;
+ recipientEmail: string;
+ destinationAddress: string;
+}): Promise {
+ // TODO: Support redeeming gifts on turbo sdk
+ // return TurboFactory.unauthenticated(turboConfig)
+ // .redeemGift(redemptionCode, recipientEmail, destinationAddress)
+ const response = await fetch(
+ `${paymentServiceUrl}/v1/redeem/?email=${recipientEmail}&id=${redemptionCode}&destinationAddress=${destinationAddress}`,
+ );
+ if (!response.ok) throw new Error("Failed to redeem gift");
+}
diff --git a/yarn.lock b/yarn.lock
index 59929bd..fab5426 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -766,6 +766,11 @@
dependencies:
"@randlabs/communication-bridge" "1.0.1"
+"@remix-run/router@1.13.1":
+ version "1.13.1"
+ resolved "https://registry.yarnpkg.com/@remix-run/router/-/router-1.13.1.tgz#07e2a8006f23a3bc898b3f317e0a58cc8076b86e"
+ integrity sha512-so+DHzZKsoOcoXrILB4rqDkMDy7NLMErRdOxvzvOKb507YINKUP4Di+shbTZDhSE/pBZ+vr7XGIpcOO0VLSA+Q==
+
"@rollup/plugin-inject@^5.0.3":
version "5.0.5"
resolved "https://registry.yarnpkg.com/@rollup/plugin-inject/-/plugin-inject-5.0.5.tgz#616f3a73fe075765f91c5bec90176608bed277a3"
@@ -2777,6 +2782,21 @@ react-refresh@^0.14.0:
resolved "https://registry.yarnpkg.com/react-refresh/-/react-refresh-0.14.0.tgz#4e02825378a5f227079554d4284889354e5f553e"
integrity sha512-wViHqhAd8OHeLS/IRMJjTSDHF3U9eWi62F/MledQGPdJGDhodXJ9PBLNGr6WWL7qlH12Mt3TyTpbS+hGXMjCzQ==
+react-router-dom@^6.20.1:
+ version "6.20.1"
+ resolved "https://registry.yarnpkg.com/react-router-dom/-/react-router-dom-6.20.1.tgz#e34f8075b9304221420de3609e072bb349824984"
+ integrity sha512-npzfPWcxfQN35psS7rJgi/EW0Gx6EsNjfdJSAk73U/HqMEJZ2k/8puxfwHFgDQhBGmS3+sjnGbMdMSV45axPQw==
+ dependencies:
+ "@remix-run/router" "1.13.1"
+ react-router "6.20.1"
+
+react-router@6.20.1:
+ version "6.20.1"
+ resolved "https://registry.yarnpkg.com/react-router/-/react-router-6.20.1.tgz#e8cc326031d235aaeec405bb234af77cf0fe75ef"
+ integrity sha512-ccvLrB4QeT5DlaxSFFYi/KR8UMQ4fcD8zBcR71Zp1kaYTC5oJKYAp1cbavzGrogwxca+ubjkd7XjFZKBW8CxPA==
+ dependencies:
+ "@remix-run/router" "1.13.1"
+
react@^18.2.0:
version "18.2.0"
resolved "https://registry.yarnpkg.com/react/-/react-18.2.0.tgz#555bd98592883255fa00de14f1151a917b5d77d5"