├── .env ├── .env.example ├── .gitignore ├── README.md ├── package-lock.json ├── package.json ├── postcss.config.js ├── public ├── _redirects ├── favicon.ico ├── index.html ├── loading.gif ├── logo192.png ├── logo512.png ├── manifest.json └── robots.txt ├── src ├── app.tsx ├── components.tsx ├── index.css ├── index.tsx ├── react-app-env.d.ts ├── routes │ ├── accounts.tsx │ ├── dashboard.tsx │ ├── expenses.tsx │ ├── index.tsx │ ├── reports.tsx │ ├── sales.tsx │ └── sales │ │ ├── customers.tsx │ │ ├── deposits.tsx │ │ ├── index.tsx │ │ ├── invoices.tsx │ │ ├── invoices │ │ ├── $invoiceId.tsx │ │ └── index.tsx │ │ └── subscriptions.tsx ├── types.d.ts └── utils.tsx ├── tailwind.config.js └── tsconfig.json /.env: -------------------------------------------------------------------------------- 1 | REACT_APP_API_URL=https://fakebooks-remix.fly.dev/api/ -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | # create a .env.local and paste this into it 2 | # then make sure you're running the remix version of this project locally 3 | # to handle these requests 4 | REACT_APP_API_URL=http://localhost:3000/api/ 5 | -------------------------------------------------------------------------------- /.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.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # production 12 | /build 13 | 14 | # misc 15 | .DS_Store 16 | .env.local 17 | .env.development.local 18 | .env.test.local 19 | .env.production.local 20 | 21 | npm-debug.log* 22 | yarn-debug.log* 23 | yarn-error.log* 24 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # CRA Fakebooks App 2 | 3 | This is a (very) simple implementation of the fakebooks mock app demonstrated on [remix.run](https://remix.run). The backend is served by [the Remix version of this app](https://github.com/kentcdodds/fakebooks-remix). There is no database, but there is an arbitrary delay of 40-100ms whenever accessing "invoice data" to simulate querying a real database. 4 | 5 | This is intended to be used as an example of a fairly standard client-rendered app as a comparison to a Remix app. The sister repo to this one can be found at [kentcdodds/fakebooks-remix](https://github.com/kentcdodds/fakebooks-remix). 6 | 7 | - [Fakebooks Remix Production Deploy](https://fakebooks-remix.fly.dev/sales/invoices) - Deployed on Fly in the Dallas Region 8 | - [Fakebooks CRA Production Deploy](https://fakebooks-cra.netlify.app/sales/invoices) - Deployed on Netlify's global CDN 9 | 10 | The main objective of this comparison (currently) is to demonstrate the UX and DX difference between the two approaches in regards to data loading. Test out the initial page load as well as switching between different pages. 11 | 12 | Please do dig into the code and compare the level of complexity. Keep in mind that a CRA app is only half the story. You need a backend. This Remix app's backend is used to handle that for the CRA version. 13 | 14 | Another thing to keep in mind is that this app doesn't handle mutations (yet?). Adding mutation support would drastically complicate the CRA implementation, but would be a pretty simple thing to handle for Remix. 15 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "fakebooks", 3 | "version": "0.1.0", 4 | "private": true, 5 | "dependencies": { 6 | "@types/node": "^16.11.27", 7 | "@types/react": "^18.0.6", 8 | "@types/react-dom": "^18.0.2", 9 | "react": "^18.0.0", 10 | "react-dom": "^18.0.0", 11 | "react-router-dom": "^6.3.0", 12 | "react-scripts": "5.0.1", 13 | "typescript": "^4.6.3" 14 | }, 15 | "scripts": { 16 | "start": "PORT=3001 react-scripts start", 17 | "build": "react-scripts build" 18 | }, 19 | "eslintConfig": { 20 | "extends": [ 21 | "react-app" 22 | ] 23 | }, 24 | "browserslist": { 25 | "production": [ 26 | ">0.2%", 27 | "not dead", 28 | "not op_mini all" 29 | ], 30 | "development": [ 31 | "last 1 chrome version", 32 | "last 1 firefox version", 33 | "last 1 safari version" 34 | ] 35 | }, 36 | "devDependencies": { 37 | "autoprefixer": "^10.4.4", 38 | "cross-env": "^7.0.3", 39 | "postcss": "^8.4.12", 40 | "tailwindcss": "^3.0.24" 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | } 7 | -------------------------------------------------------------------------------- /public/_redirects: -------------------------------------------------------------------------------- 1 | /* /index.html 200 2 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kentcdodds/fakebooks-cra/922760f272e0ad170d1b38d205143c7c73563091/public/favicon.ico -------------------------------------------------------------------------------- /public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 11 | 12 | 13 | 14 | 33 | Fakebooks CRA 34 | 35 | 36 | 37 |
38 |
39 | 40 |
41 |
42 | 43 | 44 | -------------------------------------------------------------------------------- /public/loading.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kentcdodds/fakebooks-cra/922760f272e0ad170d1b38d205143c7c73563091/public/loading.gif -------------------------------------------------------------------------------- /public/logo192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kentcdodds/fakebooks-cra/922760f272e0ad170d1b38d205143c7c73563091/public/logo192.png -------------------------------------------------------------------------------- /public/logo512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kentcdodds/fakebooks-cra/922760f272e0ad170d1b38d205143c7c73563091/public/logo512.png -------------------------------------------------------------------------------- /public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "React App", 3 | "name": "Create React App Sample", 4 | "icons": [ 5 | { 6 | "src": "favicon.ico", 7 | "sizes": "64x64 32x32 24x24 16x16", 8 | "type": "image/x-icon" 9 | }, 10 | { 11 | "src": "logo192.png", 12 | "type": "image/png", 13 | "sizes": "192x192" 14 | }, 15 | { 16 | "src": "logo512.png", 17 | "type": "image/png", 18 | "sizes": "512x512" 19 | } 20 | ], 21 | "start_url": ".", 22 | "display": "standalone", 23 | "theme_color": "#000000", 24 | "background_color": "#ffffff" 25 | } 26 | -------------------------------------------------------------------------------- /public/robots.txt: -------------------------------------------------------------------------------- 1 | # https://www.robotstxt.org/robotstxt.html 2 | User-agent: * 3 | Disallow: 4 | -------------------------------------------------------------------------------- /src/app.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { Link, NavLink, Route, Routes } from "react-router-dom"; 3 | import { Spinner } from "utils"; 4 | 5 | const Index = React.lazy(() => import("./routes/index")); 6 | const Dashboard = React.lazy(() => import("./routes/dashboard")); 7 | const Accounts = React.lazy(() => import("./routes/accounts")); 8 | const Expenses = React.lazy(() => import("./routes/expenses")); 9 | const Reports = React.lazy(() => import("./routes/reports")); 10 | const Sales = React.lazy(() => import("./routes/sales")); 11 | const SalesIndex = React.lazy(() => import("./routes/sales/index")); 12 | const Subscriptions = React.lazy(() => import("./routes/sales/subscriptions")); 13 | const Customers = React.lazy(() => import("./routes/sales/customers")); 14 | const Deposits = React.lazy(() => import("./routes/sales/deposits")); 15 | const Invoices = React.lazy(() => import("./routes/sales/invoices")); 16 | const InvoicesIndex = React.lazy(() => import("./routes/sales/invoices/index")); 17 | const Invoice = React.lazy(() => import("./routes/sales/invoices/$invoiceId")); 18 | 19 | const Spinnit = ({ children }: { children: React.ReactNode }) => ( 20 | }> 21 | {children} 22 | 23 | ); 24 | 25 | const SmallSpinnit = ({ children }: { children: React.ReactNode }) => ( 26 | }> 27 | {children} 28 | 29 | ); 30 | 31 | export default function App() { 32 | return ( 33 | 34 | 35 | 36 | 37 | 41 | 42 | 43 | } 44 | /> 45 | 49 | 50 | 51 | } 52 | /> 53 | 57 | 58 | 59 | } 60 | /> 61 | 65 | 66 | 67 | } 68 | /> 69 | 73 | 74 | 75 | } 76 | /> 77 | 81 | 82 | 83 | } 84 | > 85 | 89 | 90 | 91 | } 92 | /> 93 | 97 | 98 | 99 | } 100 | /> 101 | 105 | 106 | 107 | } 108 | /> 109 | 113 | 114 | 115 | } 116 | > 117 | 121 | 122 | 123 | } 124 | /> 125 | 129 | 130 | 131 | } 132 | /> 133 | 134 | 138 | 139 | 140 | } 141 | /> 142 | 143 | 144 | 145 | 146 | 147 | ); 148 | } 149 | 150 | function Fakebooks({ children }: { children: React.ReactNode }) { 151 | return ( 152 |
153 |
154 |
155 | 156 | 157 |
158 |
Fakebooks
159 | 160 |
161 |
162 | Dashboard 163 | Accounts 164 | Sales 165 | Expenses 166 | Reports 167 | 171 | GitHub ↗️ 172 | 173 |
174 |
175 |
176 |
{children}
177 |
178 | ); 179 | } 180 | 181 | function NavItem({ 182 | to, 183 | children, 184 | }: { 185 | to: string; 186 | children: React.ReactNode; 187 | className?: string; 188 | }) { 189 | return ( 190 | 193 | `my-1 py-1 px-2 pr-16 text-[length:14px] ${ 194 | isActive ? "rounded-md bg-gray-100" : "" 195 | }` 196 | } 197 | > 198 | {children} 199 | 200 | ); 201 | } 202 | 203 | function FakebooksLogo({ className }: { className: string }) { 204 | return ( 205 | 211 | 217 | 218 | ); 219 | } 220 | -------------------------------------------------------------------------------- /src/components.tsx: -------------------------------------------------------------------------------- 1 | export function LabelText({ children }: { children: React.ReactNode }) { 2 | return ( 3 |
4 | {children} 5 |
6 | ); 7 | } 8 | -------------------------------------------------------------------------------- /src/index.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | html, 6 | body, 7 | #root { 8 | height: 100%; 9 | } 10 | -------------------------------------------------------------------------------- /src/index.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import ReactDOM from "react-dom/client"; 3 | import { BrowserRouter } from "react-router-dom"; 4 | import "./index.css"; 5 | import App from "./app"; 6 | import { ErrorBoundary } from "utils"; 7 | 8 | const root = ReactDOM.createRoot( 9 | document.getElementById("root") as HTMLElement 10 | ); 11 | root.render( 12 |
Well rats...
}> 13 | 14 | 15 | 16 | 17 | 18 |
19 | ); 20 | -------------------------------------------------------------------------------- /src/react-app-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /src/routes/accounts.tsx: -------------------------------------------------------------------------------- 1 | export default function AccountsRoute() { 2 | return
Hope you have tons of accounts I guess.
; 3 | } 4 | -------------------------------------------------------------------------------- /src/routes/dashboard.tsx: -------------------------------------------------------------------------------- 1 | export default function DashboardRoute() { 2 | return
Look at all these graphs!
; 3 | } 4 | -------------------------------------------------------------------------------- /src/routes/expenses.tsx: -------------------------------------------------------------------------------- 1 | export default function ExpensesRoute() { 2 | return
Hope you don't have a lot of these...
; 3 | } 4 | -------------------------------------------------------------------------------- /src/routes/index.tsx: -------------------------------------------------------------------------------- 1 | import { Link } from "react-router-dom"; 2 | 3 | export default function Index() { 4 | return ( 5 |
6 | Go to the{" "} 7 | 8 | sales 9 | {" "} 10 | page... 11 |
12 | ); 13 | } 14 | -------------------------------------------------------------------------------- /src/routes/reports.tsx: -------------------------------------------------------------------------------- 1 | export default function ReportsRoute() { 2 | return
Reports yo.
; 3 | } 4 | -------------------------------------------------------------------------------- /src/routes/sales.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { NavLink, Outlet, useMatch } from "react-router-dom"; 3 | import { InvoiceListItem } from "types"; 4 | import { Spinner, useAsync } from "../utils"; 5 | 6 | type LoaderData = { 7 | invoiceListItems: Array; 8 | experiments: Record; 9 | }; 10 | 11 | const linkClassName = ({ isActive }: { isActive: boolean }) => 12 | isActive ? "font-bold text-black" : ""; 13 | 14 | export default function SalesRouteLoader() { 15 | const { run, status, error, data } = useAsync(); 16 | React.useEffect(() => { 17 | run( 18 | Promise.all([ 19 | fetch(`${process.env.REACT_APP_API_URL}v1/invoice`).then((r) => 20 | r.json() 21 | ), 22 | fetch(`${process.env.REACT_APP_API_URL}v1/experiments`).then((r) => 23 | r.json() 24 | ), 25 | ]).then(([invoice, experiments]) => { 26 | return { 27 | ...invoice, 28 | ...experiments, 29 | }; 30 | }) 31 | ); 32 | }, [run]); 33 | switch (status) { 34 | case "idle": 35 | case "pending": { 36 | return ( 37 |
38 |
Sales
39 |
40 |
41 |
42 |   43 |
44 |
45 |   46 |
47 |
48 |   49 |
50 |
51 |
52 |
53 | 54 |
55 |
56 | ); 57 | } 58 | case "rejected": { 59 | throw error; 60 | } 61 | case "resolved": { 62 | if (!data) { 63 | throw new Error("The API forgot to give us something..."); 64 | } 65 | return ; 66 | } 67 | } 68 | } 69 | 70 | function SalesRoute({ data }: { data: LoaderData }) { 71 | const indexMatches = Boolean(useMatch("/sales")); 72 | const invoiceMatches = Boolean(useMatch("/sales/invoices/*")); 73 | const firstInvoiceId = data.invoiceListItems?.[0].id; 74 | return ( 75 |
76 |
Sales
77 |
78 |
79 | 80 | Overview 81 | 82 | 83 | Subscriptions 84 | 85 | 89 | Invoices 90 | 91 | {data.experiments.customers ? ( 92 | 93 | Customers 94 | 95 | ) : null} 96 | 97 | Deposits 98 | 99 |
100 |
101 | 102 |
103 | ); 104 | } 105 | -------------------------------------------------------------------------------- /src/routes/sales/customers.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { useAsync } from "utils"; 3 | 4 | type LoaderData = { experiments: Record }; 5 | 6 | export default function CustomersRouteLoader() { 7 | const { run, status, error, data } = useAsync(); 8 | React.useEffect(() => { 9 | run( 10 | fetch(`${process.env.REACT_APP_API_URL}v1/experiments`).then((r) => 11 | r.json() 12 | ) 13 | ); 14 | }, [run]); 15 | switch (status) { 16 | case "idle": 17 | case "pending": { 18 | return ; 19 | } 20 | case "rejected": { 21 | throw error; 22 | } 23 | case "resolved": { 24 | if (!data?.experiments.customers) { 25 | return
Not found 404
; 26 | } 27 | return ; 28 | } 29 | } 30 | } 31 | 32 | function CustomersRoute() { 33 | return
We love our customers. Because money.
; 34 | } 35 | -------------------------------------------------------------------------------- /src/routes/sales/deposits.tsx: -------------------------------------------------------------------------------- 1 | export default function Deposits() { 2 | return
This is the part where we can see money
; 3 | } 4 | -------------------------------------------------------------------------------- /src/routes/sales/index.tsx: -------------------------------------------------------------------------------- 1 | export default function SalesOverview() { 2 | return
Overview
; 3 | } 4 | -------------------------------------------------------------------------------- /src/routes/sales/invoices.tsx: -------------------------------------------------------------------------------- 1 | import { Outlet, useOutletContext } from "react-router-dom"; 2 | import { NavLink } from "react-router-dom"; 3 | import { LabelText } from "components"; 4 | import { InvoiceListItem } from "types"; 5 | 6 | function useSalesContext() { 7 | return useOutletContext() as { invoiceListItems: Array }; 8 | } 9 | 10 | export default function InvoicesRoute() { 11 | const salesContext = useSalesContext(); 12 | const { invoiceListItems } = salesContext; 13 | const overdueAmount = invoiceListItems.reduce( 14 | (sum, li) => sum + (li.dueStatus === "overdue" ? li.total : 0), 15 | 0 16 | ); 17 | const dueSoonAmount = invoiceListItems.reduce( 18 | (sum, li) => sum + (li.dueStatus === "due" ? li.total : 0), 19 | 0 20 | ); 21 | const hundo = dueSoonAmount + overdueAmount; 22 | const dueSoonPercent = Math.floor((dueSoonAmount / hundo) * 100); 23 | return ( 24 |
25 |
26 | 27 |
28 |
29 |
33 |
34 | 35 |
36 |
37 | Invoice List 38 |
39 | 40 | 41 | 42 |
43 | ); 44 | } 45 | 46 | function InvoicesInfo({ 47 | label, 48 | amount, 49 | right, 50 | }: { 51 | label: string; 52 | amount: number; 53 | right?: boolean; 54 | }) { 55 | return ( 56 |
57 | {label} 58 |
59 | ${amount.toLocaleString()} 60 |
61 |
62 | ); 63 | } 64 | 65 | function InvoiceList({ children }: { children: React.ReactNode }) { 66 | const { invoiceListItems } = useSalesContext(); 67 | return ( 68 |
69 |
70 | {invoiceListItems.map((invoice, index) => ( 71 | 75 | "block border-b border-gray-50 py-3 px-4 hover:bg-gray-50" + 76 | " " + 77 | (isActive ? "bg-gray-50" : "") 78 | } 79 | > 80 |
81 |
{invoice.name}
82 |
${invoice.total.toLocaleString()}
83 |
84 |
85 |
{invoice.number}
86 |
97 | {invoice.dueDisplay} 98 |
99 |
100 |
101 | ))} 102 |
103 |
{children}
104 |
105 | ); 106 | } 107 | -------------------------------------------------------------------------------- /src/routes/sales/invoices/$invoiceId.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { useParams } from "react-router-dom"; 3 | import { LabelText } from "components"; 4 | import { Invoice } from "types"; 5 | import { getInvoiceDue, Spinner, useAsync } from "utils"; 6 | 7 | type LoaderData = { 8 | invoice: Invoice; 9 | }; 10 | 11 | export default function InvoiceRouteLoader() { 12 | const params = useParams(); 13 | const { run, status, error, data } = useAsync(); 14 | React.useEffect(() => { 15 | run( 16 | fetch( 17 | `${process.env.REACT_APP_API_URL}v1/invoice/${params.invoiceId}` 18 | ).then((r) => r.json()) 19 | ); 20 | }, [run, params.invoiceId]); 21 | switch (status) { 22 | case "idle": 23 | case "pending": { 24 | return ( 25 |
26 | 27 |
28 | ); 29 | } 30 | case "rejected": { 31 | throw error; 32 | } 33 | case "resolved": { 34 | if (!data) { 35 | throw new Error("The API forgot to give us something..."); 36 | } 37 | return ; 38 | } 39 | } 40 | } 41 | 42 | function InvoiceRoute({ data }: { data: LoaderData }) { 43 | const totalDue = data.invoice.lineItems.reduce( 44 | (total, item) => total + item.amount, 45 | 0 46 | ); 47 | const dueDisplay = getInvoiceDue(data.invoice); 48 | const invoiceDateDisplay = new Date( 49 | data.invoice.invoiceDate 50 | ).toLocaleDateString(); 51 | 52 | return ( 53 |
54 |
55 | {data.invoice.name} 56 |
57 |
58 | ${totalDue.toLocaleString()} 59 |
60 | 61 | {dueDisplay} • Invoiced {invoiceDateDisplay} 62 | 63 |
64 | {data.invoice.lineItems.map((item) => ( 65 | 66 | ))} 67 | sum + li.amount, 0)} 71 | /> 72 |
73 | ); 74 | } 75 | 76 | export function ErrorBoundary({ error }: { error: Error }) { 77 | console.error(error); 78 | 79 | return ( 80 |
81 |
82 |
Oh snap!
83 |
84 | There was a problem loading this invoice 85 |
86 |
87 |
88 | ); 89 | } 90 | 91 | function LineItem({ 92 | label, 93 | amount, 94 | bold, 95 | }: { 96 | label: string; 97 | amount: number; 98 | bold?: boolean; 99 | }) { 100 | return ( 101 |
108 |
{label}
109 |
${amount.toLocaleString()}
110 |
111 | ); 112 | } 113 | -------------------------------------------------------------------------------- /src/routes/sales/invoices/index.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { useNavigate, useOutletContext } from "react-router-dom"; 3 | import { InvoiceListItem } from "types"; 4 | 5 | export default function InvoiceIndex() { 6 | const salesContext = useOutletContext() as { 7 | invoiceListItems: Array; 8 | }; 9 | const navigate = useNavigate(); 10 | 11 | const hasListItems = salesContext.invoiceListItems.length > 0; 12 | const [firstListItem] = salesContext.invoiceListItems; 13 | const mounted = React.useRef(false); 14 | 15 | React.useEffect(() => { 16 | if (mounted.current) return; 17 | mounted.current = true; 18 | if (hasListItems) { 19 | navigate(firstListItem.id); 20 | } 21 | }, [hasListItems, firstListItem, navigate]); 22 | 23 | if (hasListItems) { 24 | return
You shouldn't see this...
; 25 | } else { 26 | return
You don't have any invoices 😭
; 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/routes/sales/subscriptions.tsx: -------------------------------------------------------------------------------- 1 | export default function Subscriptions() { 2 | return
Woo. Subs. Money.
; 3 | } 4 | -------------------------------------------------------------------------------- /src/types.d.ts: -------------------------------------------------------------------------------- 1 | export type InvoiceListItem = { 2 | id: string; 3 | total: number; 4 | number: number; 5 | dueDisplay: string; 6 | dueStatus: string; 7 | name: string; 8 | }; 9 | export type Invoice = { 10 | id: string; 11 | name: string; 12 | number: number; 13 | invoiceDate: string; 14 | dueDate: string; 15 | paid: boolean; 16 | lineItems: Array<{ 17 | id: string; 18 | label: string; 19 | amount: number; 20 | }>; 21 | }; 22 | 23 | export type DueStatus = "overdue" | "due" | "paid"; 24 | -------------------------------------------------------------------------------- /src/utils.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { DueStatus, Invoice } from "./types"; 3 | 4 | type AsyncState = 5 | | { 6 | status: "idle"; 7 | data?: null; 8 | error?: null; 9 | promise?: null; 10 | } 11 | | { 12 | status: "pending"; 13 | data?: null; 14 | error?: null; 15 | promise: Promise; 16 | } 17 | | { 18 | status: "resolved"; 19 | data: DataType; 20 | error: null; 21 | promise: null; 22 | } 23 | | { 24 | status: "rejected"; 25 | data: null; 26 | error: Error; 27 | promise: null; 28 | }; 29 | 30 | type AsyncAction = 31 | | { type: "reset" } 32 | | { type: "pending"; promise: Promise } 33 | | { type: "resolved"; data: DataType; promise?: Promise } 34 | | { type: "rejected"; error: Error; promise?: Promise }; 35 | 36 | function asyncReducer( 37 | state: AsyncState, 38 | action: AsyncAction 39 | ): AsyncState { 40 | switch (action.type) { 41 | case "pending": { 42 | return { 43 | status: "pending", 44 | data: null, 45 | error: null, 46 | promise: action.promise, 47 | }; 48 | } 49 | case "resolved": { 50 | if (action.promise && action.promise !== state.promise) return state; 51 | return { 52 | status: "resolved", 53 | data: action.data, 54 | error: null, 55 | promise: null, 56 | }; 57 | } 58 | case "rejected": { 59 | if (action.promise && action.promise !== state.promise) return state; 60 | return { 61 | status: "rejected", 62 | data: null, 63 | error: action.error, 64 | promise: null, 65 | }; 66 | } 67 | default: { 68 | throw new Error(`Unhandled action type: ${action.type}`); 69 | } 70 | } 71 | } 72 | 73 | export function useAsync() { 74 | const [state, dispatch] = React.useReducer< 75 | React.Reducer, AsyncAction> 76 | >(asyncReducer, { 77 | status: "idle", 78 | data: null, 79 | error: null, 80 | }); 81 | 82 | const { data, error, status } = state; 83 | 84 | const run = React.useCallback((promise: Promise) => { 85 | dispatch({ type: "pending", promise }); 86 | promise.then( 87 | (data) => { 88 | dispatch({ type: "resolved", data, promise }); 89 | }, 90 | (error) => { 91 | dispatch({ type: "rejected", error, promise }); 92 | } 93 | ); 94 | }, []); 95 | 96 | const setData = React.useCallback( 97 | (data: DataType) => dispatch({ type: "resolved", data }), 98 | [dispatch] 99 | ); 100 | const setError = React.useCallback( 101 | (error: Error) => dispatch({ type: "rejected", error }), 102 | [dispatch] 103 | ); 104 | 105 | return { 106 | setData, 107 | setError, 108 | error, 109 | status, 110 | data, 111 | run, 112 | }; 113 | } 114 | 115 | export const getDueStatus = (invoice: Invoice): DueStatus => { 116 | const days = Math.ceil( 117 | (new Date(invoice.dueDate).getTime() - asUTC(new Date()).getTime()) / 118 | (1000 * 60 * 60 * 24) 119 | ); 120 | 121 | return invoice.paid ? "paid" : days < 0 ? "overdue" : "due"; 122 | }; 123 | 124 | export function getInvoiceDue(invoice: Invoice) { 125 | const days = Math.ceil( 126 | (new Date(invoice.dueDate).getTime() - asUTC(new Date()).getTime()) / 127 | (1000 * 60 * 60 * 24) 128 | ); 129 | 130 | return invoice.paid 131 | ? "Paid" 132 | : days < 0 133 | ? "Overdue" 134 | : days === 0 135 | ? "Due Today" 136 | : `Due in ${days} Days`; 137 | } 138 | 139 | function asUTC(date: Date) { 140 | return new Date(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate()); 141 | } 142 | 143 | type ErrorBoundaryProps = { 144 | children: React.ReactNode; 145 | FallbackComponent: React.FunctionComponent<{ error: Error }>; 146 | }; 147 | type ErrorBoundaryState = { 148 | error: null | Error; 149 | }; 150 | 151 | export class ErrorBoundary extends React.Component< 152 | ErrorBoundaryProps, 153 | ErrorBoundaryState 154 | > { 155 | state: ErrorBoundaryState = { error: null }; 156 | static getDerivedStateFromError(error: Error) { 157 | return { error }; 158 | } 159 | render() { 160 | const { error } = this.state; 161 | if (error) { 162 | return ; 163 | } 164 | 165 | return this.props.children; 166 | } 167 | } 168 | 169 | export function Spinner({ className }: { className?: string }) { 170 | return ( 171 |
177 | 182 |
183 | ); 184 | } 185 | -------------------------------------------------------------------------------- /tailwind.config.js: -------------------------------------------------------------------------------- 1 | const defaultTheme = require("tailwindcss/defaultTheme"); 2 | 3 | module.exports = { 4 | content: ["./src/**/*.{js,jsx,ts,tsx}"], 5 | plugins: [], 6 | theme: { 7 | fontFamily: { 8 | ...defaultTheme.fontFamily, 9 | display: ['"Founders Grotesk", "Arial Black", sans-serif'], 10 | sans: ["Inter", ...defaultTheme.fontFamily.sans], 11 | mono: ["Source Code Pro", ...defaultTheme.fontFamily.mono], 12 | "jet-mono": ["JetBrains Mono", ...defaultTheme.fontFamily.mono], 13 | }, 14 | fontSize: { 15 | // names come from the figma file 16 | // desktop paragraph small -> d-p-s 17 | "d-p-sm": ["16px", "24px"], 18 | "d-p-lg": ["20px", "32px"], 19 | "d-h3": ["30px", "32px"], 20 | "d-h2": ["45px", "48px"], 21 | "d-h1": ["64px", "72px"], 22 | "d-j": ["72px", "64px"], 23 | 24 | // mobile paragraph small -> d-m-s 25 | "m-p-sm": ["14px", "24px"], 26 | "m-p-lg": ["18px", "32px"], 27 | "m-h3": ["20px", "24px"], 28 | "m-h2": ["24px", "32px"], 29 | "m-h1": ["32px", "32px"], 30 | "m-j": ["40px", "48px"], 31 | 32 | eyebrow: ["16px", "24px"], 33 | }, 34 | container: { 35 | center: true, 36 | padding: { 37 | DEFAULT: "24px", 38 | sm: "24px", 39 | md: "32px", 40 | lg: "40px", 41 | }, 42 | }, 43 | extend: { 44 | colors: { 45 | current: "currentColor", 46 | gray: { 47 | 50: "#f8fbfc", 48 | 100: "#eef2f8", 49 | 200: "#d0d0d0", 50 | 300: "#b7bcbe", 51 | 400: "#828282", 52 | 500: "#6a726d", 53 | 600: "#3f3f3f", 54 | 700: "#292929", 55 | 800: "#1e1e1e", 56 | 900: "#121212", 57 | }, 58 | red: { 59 | 50: "#fdfcfb", 60 | 100: "#fcf0ed", 61 | 200: "#f9ccdb", 62 | 300: "#f09eb7", 63 | 400: "#ee6e90", 64 | 500: "#f44250", 65 | brand: "#f44250", 66 | 600: "#d03150", 67 | 700: "#aa253a", 68 | 800: "#7d1a26", 69 | 900: "#4d1014", 70 | }, 71 | yellow: { 72 | 50: "#faf9f0", 73 | 100: "#f8ef9f", 74 | 200: "#fecc1b", 75 | brand: "#fecc1b", 76 | 300: "#d3be33", 77 | 400: "#a69719", 78 | 500: "#837a0b", 79 | 600: "#686207", 80 | 700: "#514a07", 81 | 800: "#373307", 82 | 900: "#271f06", 83 | }, 84 | green: { 85 | 50: "#f2f6f1", 86 | 100: "#e0f0de", 87 | 200: "#b6e7b5", 88 | 300: "#6bd968", 89 | brand: "#6bd968", 90 | 400: "#33ad4e", 91 | 500: "#22942c", 92 | 600: "#1e7e1f", 93 | 700: "#1b611b", 94 | 800: "#144317", 95 | 900: "#0f2913", 96 | }, 97 | aqua: { 98 | 50: "#ecf4f3", 99 | 100: "#c9eff0", 100 | 200: "#3defe9", 101 | brand: "#3defe9", 102 | 300: "#54cfb7", 103 | 400: "#1db28b", 104 | 500: "#149963", 105 | 600: "#13844c", 106 | 700: "#13663e", 107 | 800: "#0f4630", 108 | 900: "#0b2c25", 109 | }, 110 | blue: { 111 | 50: "#DAEEFF", 112 | 100: "#AAD6FF", 113 | 200: "#7FBFFF", 114 | 300: "#59A8FF", 115 | 400: "#3992ff", 116 | brand: "#3992ff", 117 | 500: "#287BD9", 118 | 600: "#1A65B3", 119 | 700: "#0F4F8C", 120 | 800: "#073966", 121 | 900: "#022340", 122 | }, 123 | pink: { 124 | 50: "#fcfbfb", 125 | 100: "#f9eef5", 126 | 200: "#f4caec", 127 | 300: "#e79fd7", 128 | 400: "#e571be", 129 | 500: "#d83bd2", 130 | brand: "#d83bd2", 131 | 600: "#c1338b", 132 | 700: "#9a2769", 133 | 800: "#701c45", 134 | 900: "#441325", 135 | }, 136 | }, 137 | }, 138 | }, 139 | }; 140 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "lib": ["dom", "dom.iterable", "esnext"], 5 | "allowJs": true, 6 | "skipLibCheck": true, 7 | "esModuleInterop": true, 8 | "allowSyntheticDefaultImports": true, 9 | "strict": true, 10 | "forceConsistentCasingInFileNames": true, 11 | "noFallthroughCasesInSwitch": true, 12 | "module": "esnext", 13 | "moduleResolution": "node", 14 | "resolveJsonModule": true, 15 | "isolatedModules": true, 16 | "noEmit": true, 17 | "jsx": "react-jsx", 18 | "baseUrl": "./src" 19 | }, 20 | "include": ["src"] 21 | } 22 | --------------------------------------------------------------------------------