├── .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 |
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 |
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 |
--------------------------------------------------------------------------------