app: Add modals and begin implementing donations

This patch adds a mechanism to open a modal, and roughs out a donation
flow using the 'braintree' payments provider.
This commit is contained in:
rektdeckard
2021-06-20 20:04:40 -04:00
parent a885931831
commit 8a7921d082
12 changed files with 568 additions and 17 deletions

View File

@@ -21,6 +21,10 @@
"repository": "github:phosphor-icons/phosphor-home",
"private": true,
"dependencies": {
"@types/braintree-web": "^3.75.3",
"@types/braintree-web-drop-in": "^1.22.3",
"braintree-web": "^3.78.2",
"braintree-web-drop-in": "^1.30.1",
"file-saver": "^2.0.2",
"framer-motion": "^3.10.0",
"fuse.js": "^6.4.1",

View File

@@ -49,6 +49,7 @@ button {
justify-content: flex-start;
}
input.main-button,
button.main-button {
height: 64px;
padding: 0 48px 0 40px;
@@ -72,19 +73,29 @@ button.main-button {
margin: 0 24px 24px 0;
}
input.main-button:active,
button.main-button:active {
transform: translate(4px, 4px);
box-shadow: 0 0 0 0 black;
}
input.main-button:focus,
button.main-button:focus {
outline: none;
}
input.main-button:disabled,
button.main-button:disabled {
border: 2px solid gray;
box-shadow: 4px 4px 0 0 gray;
cursor: not-allowed;
}
/* button.main-button:not(:last-child) {
margin: 0 24px 24px 0;
} */
input.main-button,
button.main-button svg {
margin-right: 12px;
}
@@ -114,3 +125,55 @@ a.main-link:after {
a.main-link:hover:after {
width: 0%;
}
button.text-button {
display: flex;
align-items: center;
padding: 8px;
background-color: transparent;
font-size: 16px;
cursor: pointer;
border-radius: 8px;
}
button.text-button svg {
margin: 8px;
}
.radio-group {
display: flex;
flex-flow: row wrap;
justify-content: space-around;
row-gap: 16px;
}
.radio-button input[type="radio"] {
display: none;
}
.radio-button label {
display: inline-block;
padding: 8px;
min-width: 50px;
background-color: white;
border-radius: 8px;
border: 2px solid black;
font-weight: bold;
cursor: pointer;
text-align: center;
user-select: none;
}
.radio-button label:hover {
background-color: #E0E0E0;
}
.radio-button input[type="radio"]:checked + label {
color: white;
background-color: black;
}
.radio-button label input {
margin-left: 8px;
}

View File

@@ -2,6 +2,7 @@ import React, { Suspense } from "react";
import "./App.css";
import Header from "../Header/Header";
import Modal from "../Modal/Modal";
import Toolbar from "../Toolbar/Toolbar";
import IconGrid from "../IconGrid/IconGrid";
import Footer from "../Footer/Footer";
@@ -10,6 +11,7 @@ import Notice from "../Notice/Notice";
import useIconParameters from "../../hooks/useIconParameters";
const errorFallback = <Notice message="Search error" />;
const paymentFallback = <Notice message="Could not connect to payments" />;
const waitingFallback = <Notice type="none" message="" />;
const App: React.FC<any> = () => {
@@ -26,6 +28,9 @@ const App: React.FC<any> = () => {
</Suspense>
</ErrorBoundary>
</main>
<Suspense fallback={paymentFallback}>
<Modal />
</Suspense>
<Footer />
</React.StrictMode>
);

View File

@@ -1,14 +1,20 @@
import React from "react";
import { OutboundLink } from "react-ga";
import { useSetRecoilState } from "recoil";
import { ArrowElbowDownRight } from "phosphor-react";
import { iconCount } from "../../lib/icons";
import { modalOpenAtom } from "../../state/atoms";
import "./Links.css";
interface LinksProps {}
const Links: React.FC<LinksProps> = () => {
const setModalOpen = useSetRecoilState(modalOpenAtom);
const openDonationModal = () => setTimeout(() => setModalOpen(true), 1000);
return (
<div className="links">
<div>
@@ -19,6 +25,7 @@ const Links: React.FC<LinksProps> = () => {
eventLabel="Download all"
download
type="application/zip"
onClick={openDonationModal}
>
Download all ({iconCount})
</OutboundLink>
@@ -66,7 +73,11 @@ const Links: React.FC<LinksProps> = () => {
</div> */}
<div>
<ArrowElbowDownRight size={24} />
<a className="nav-link" href="https://paypal.me/minoraxis">
<a
className="nav-link"
href="https://paypal.me/minoraxis"
onClick={openDonationModal}
>
Donate on PayPal
</a>
</div>

View File

@@ -0,0 +1,68 @@
import React, { useState } from "react";
import { useSetRecoilState } from "recoil";
import { X } from "phosphor-react";
import { modalOpenAtom } from "../../state/atoms";
import DonationStepMethod from "./DonationStepMethod";
import DonationStepDropin from "./DonationStepDropin";
import DonationStepThanks from "./DonationStepThanks";
const routes = [DonationStepMethod, DonationStepDropin, DonationStepThanks];
export interface StepProps {
previousStep: () => void;
nextStep: () => void;
close: () => void;
}
interface StepperProps {
routes: Array<React.FC<StepProps>>;
routeProps: any;
close: () => void;
}
const Stepper: React.FC<StepperProps> = ({ routes, routeProps, close }) => {
const [currentStep, setCurrentStep] = useState<number>(0);
const previousStep = () => {
if (currentStep <= 0) return;
setTimeout(() => setCurrentStep((c) => c - 1), 500);
};
const nextStep = () => {
if (currentStep >= routes.length - 1) return;
setTimeout(() => setCurrentStep((c) => c + 1), 500);
};
const Component = routes[currentStep];
return (
<>
<Component
previousStep={previousStep}
nextStep={nextStep}
close={close}
{...routeProps}
/>
</>
);
};
const DonationModal: React.FC<{}> = () => {
const setModalOpen = useSetRecoilState(modalOpenAtom);
const close = () => setModalOpen(false);
return (
<div className="modal-content">
<button
className="modal-close-button"
onClick={() => setModalOpen(false)}
>
<X size={32} />
</button>
<Stepper routes={routes} routeProps={{}} close={close} />
</div>
);
};
export default DonationModal;

View File

@@ -0,0 +1,80 @@
import React, { useRef, useEffect } from "react";
import { client, hostedFields, Client, HostedFields } from "braintree-web";
const BT_STYLES = {
":focus": {
color: "blue",
},
".valid": {
color: "green",
},
".invalid": {
color: "red",
},
};
const BT_PAYMENT_FIELDS = {
number: {
container: "#card-number",
placeholder: "4111 1111 1111 1111",
},
cvv: {
container: "#cvv",
placeholder: "123",
},
expirationDate: {
container: "#expiration-date",
placeholder: "10/2022",
},
};
const PaymentModal: React.FC<{}> = () => {
const bt = useRef<Client>();
const form = useRef<HostedFields>();
const handleSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
if (!form.current || !bt.current) return;
const response = await form.current.tokenize();
console.log({ response });
};
useEffect(() => {
const initializeBt = async () => {
try {
bt.current = await client.create({
authorization: "sandbox_246jdjxq_8h7hm5rvngkykjds",
});
form.current = await hostedFields.create({
client: bt.current!!,
fields: BT_PAYMENT_FIELDS,
styles: BT_STYLES,
});
} catch (err) {
console.error(err);
}
};
initializeBt();
}, []);
return (
<form action="/" method="post" onSubmit={handleSubmit}>
<label htmlFor="card-number">Card Number</label>
<div id="card-number"></div>
<label htmlFor="cvv">CVV</label>
<div id="cvv"></div>
<label htmlFor="expiration-date">Expiration Date</label>
<div id="expiration-date"></div>
<div className="step-button-container">
<input type="submit" value="Pay" className="main-button" disabled />
</div>
</form>
);
};
export default PaymentModal;

View File

@@ -0,0 +1,85 @@
import React, { useRef, useState, useEffect } from "react";
import dropin, { Dropin } from "braintree-web-drop-in";
import { StepProps } from "./DonationModal";
const BT_PAYMENT_FIELDS = {
number: {
placeholder: "4111 1111 1111 1111",
},
cvv: {
placeholder: "123",
},
expirationDate: {
placeholder: "10/22",
},
cardholderName: {
placeholder: "Person McFace",
},
};
const PaymentModal: React.FC<StepProps> = ({ previousStep }) => {
const instance = useRef<Dropin>();
const [isValid, setIsValid] = useState<boolean>(false);
const submit = async () => {
if (!instance.current) return;
const payload = await instance.current.requestPaymentMethod();
console.log({ payload });
};
useEffect(() => {
const initializePayments = async () => {
try {
instance.current = await dropin.create({
authorization: "sandbox_246jdjxq_8h7hm5rvngkykjds",
container: "#braintree-dropin",
card: {
cardholderName: {
required: true,
},
overrides: {
fields: BT_PAYMENT_FIELDS,
},
},
paypal: {
flow: "checkout",
amount: "10.00",
currency: "USD",
commit: true,
},
paypalCredit: {
flow: "checkout",
amount: "10.00",
currency: "USD",
commit: true,
},
venmo: { allowNewBrowserTab: false },
});
instance.current.on("paymentMethodRequestable", () => setIsValid(true));
instance.current.on("noPaymentMethodRequestable", () =>
setIsValid(false)
);
} catch (err) {
console.error(err);
}
};
initializePayments();
}, []);
return (
<>
<div id="braintree-dropin"></div>
<div className="step-button-container">
<button className="main-button" onClick={previousStep}>
Back
</button>
<button className="main-button" onClick={submit} disabled={!isValid}>
Submit
</button>
</div>
</>
);
};
export default PaymentModal;

View File

@@ -0,0 +1,131 @@
import React, { useState } from "react";
import { StepProps } from "./DonationModal";
enum DonationAmount {
FIVE_DOLLARS = 5,
TEN_DOLLARS = 10,
TWENTY_DOLLARS = 20,
FIFTY_DOLLARS = 50,
ONE_HUNDRED_DOLLARS = 100,
CUSTOM = -1,
}
const DonationStepMethod: React.FC<StepProps> = ({
previousStep,
nextStep,
close,
}) => {
const [donationType, setDonationType] = useState<DonationAmount>();
const [donationAmount, setDonationAmount] = useState<number>(0);
const onDonationChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setDonationType(+e.target.value as DonationAmount);
if (!(+e.target.value === DonationAmount.CUSTOM))
setDonationAmount(+e.target.value);
};
void previousStep;
return (
<>
<h3>
Thanks for using Phosphor! Would you like to make a donation to help
support our work?
</h3>
<div className="radio-group">
<div className="radio-button">
<input
type="radio"
id="donate-5"
name="donation-type"
value={DonationAmount.FIVE_DOLLARS}
checked={donationType === DonationAmount.FIVE_DOLLARS}
onChange={onDonationChange}
/>
<label htmlFor="donate-5">$5</label>
</div>
<div className="radio-button">
<input
type="radio"
id="donate-10"
name="donation-type"
value={DonationAmount.TEN_DOLLARS}
checked={donationType === DonationAmount.TEN_DOLLARS}
onChange={onDonationChange}
/>
<label htmlFor="donate-10">$10</label>
</div>
<div className="radio-button">
<input
type="radio"
id="donate-20"
name="donation-type"
value={DonationAmount.TWENTY_DOLLARS}
checked={donationType === DonationAmount.TWENTY_DOLLARS}
onChange={onDonationChange}
/>
<label htmlFor="donate-20">$20</label>
</div>
<div className="radio-button">
<input
type="radio"
id="donate-50"
name="donation-type"
value={DonationAmount.FIFTY_DOLLARS}
checked={donationType === DonationAmount.FIFTY_DOLLARS}
onChange={onDonationChange}
/>
<label htmlFor="donate-50">$50</label>
</div>
<div className="radio-button">
<input
type="radio"
id="donate-100"
name="donation-type"
value={DonationAmount.ONE_HUNDRED_DOLLARS}
checked={donationType === DonationAmount.ONE_HUNDRED_DOLLARS}
onChange={onDonationChange}
/>
<label htmlFor="donate-100">$100</label>
</div>
<div className="radio-button">
<input
type="radio"
id="donate-custom"
name="donation-type"
value={DonationAmount.CUSTOM}
checked={donationType === DonationAmount.CUSTOM}
onChange={onDonationChange}
/>
<label htmlFor="donate-custom">
Other: $
<input
type="number"
min={2}
max={1000000}
id="donate-custom"
value={donationAmount}
disabled={donationType !== DonationAmount.CUSTOM}
onChange={(e) => setDonationAmount(+e.target.value)}
/>
</label>
</div>
</div>
<div className="step-button-container">
<button className="main-button" onClick={close}>
No thanks
</button>
<button
className="main-button"
onClick={nextStep}
disabled={!(donationType && donationAmount)}
>
Next
</button>
</div>
</>
);
};
export default DonationStepMethod;

View File

@@ -0,0 +1,7 @@
import React from "react";
const DonationStepThanks: React.FC<{}> = () => {
return null;
};
export default DonationStepThanks;

View File

@@ -0,0 +1,67 @@
.modal-container {
position: fixed;
display: grid;
place-items: center;
inset: 0;
background-color: rgba(0, 0, 0, 0.4);
z-index: 10;
}
.modal-content {
position: relative;
display: flex;
flex-direction: column;
width: 400px;
min-height: 600px;
/* background-color: #ffe8dc; */
background-color: white;
border-radius: 8px;
border: 2px solid black;
padding: 24px;
}
@media screen and (max-width: 480px) {
.modal-content {
min-width: 90%;
min-height: 100%;
border: none;
border-radius: 0;
}
}
.step-button-container {
flex: 1;
display: flex;
flex-direction: row;
align-items: flex-end;
justify-content: center;
}
button.modal-close-button {
position: absolute;
top: 24px;
right: 24px;
background: transparent;
padding: 0;
cursor: pointer;
box-sizing: content-box;
z-index: 1;
}
.payment-methods {
display: grid;
gap: 16px;
grid-template-rows: repeat(2, 1fr);
grid-template-columns: repeat(2, 1fr);
}
.payment-methods button {
margin: 0;
padding: 0;
justify-content: center;
height: 96px;
}
.payment-methods button > svg {
margin: 0;
}

View File

@@ -0,0 +1,25 @@
import React from "react";
import { useRecoilState } from "recoil";
import { useLockBodyScroll } from "react-use";
import { useHotkeys } from "react-hotkeys-hook";
import { modalOpenAtom } from "../../state/atoms";
import DonationModal from "./DonationModal";
import "./Modal.css";
const Modal: React.FC<{}> = () => {
const [isModalOpen, setModalOpen] = useRecoilState(modalOpenAtom);
useHotkeys("esc", () => isModalOpen && setModalOpen(false), [isModalOpen]);
useLockBodyScroll(isModalOpen);
if (!isModalOpen) return null;
return (
<div className="modal-container">
<DonationModal />
</div>
);
};
export default Modal;

View File

@@ -25,3 +25,8 @@ export const iconPreviewOpenAtom = atom<string | false>({
key: "iconPreviewOpenAtom",
default: false,
});
export const modalOpenAtom = atom<boolean>({
key: "modalOpenAtom",
default: true,
});