├── .eslintrc
├── .gitignore
├── .node-version
├── README.md
├── app
├── components
│ ├── About.tsx
│ ├── Chart.tsx
│ ├── CompForm.tsx
│ ├── CompModal.tsx
│ ├── CompTable.tsx
│ ├── FormField.tsx
│ ├── Logo.tsx
│ ├── Modal.tsx
│ └── Navigation.tsx
├── entry.client.tsx
├── entry.server.tsx
├── global.css
├── lib
│ ├── comp.ts
│ └── formProps.ts
├── root.tsx
└── routes
│ ├── api
│ ├── search.tsx
│ └── stock.tsx
│ └── index.tsx
├── package-lock.json
├── package.json
├── public
├── _headers
└── favicon
│ ├── android-chrome-192x192.png
│ ├── android-chrome-512x512.png
│ ├── apple-touch-icon.png
│ ├── browserconfig.xml
│ ├── favicon-16x16.png
│ ├── favicon-32x32.png
│ ├── favicon.ico
│ ├── mstile-144x144.png
│ ├── mstile-150x150.png
│ ├── mstile-310x150.png
│ ├── mstile-310x310.png
│ ├── mstile-70x70.png
│ ├── safari-pinned-tab.svg
│ └── site.webmanifest
├── remix.config.js
├── remix.env.d.ts
├── scripts
└── dev-wrangler.mjs
├── server.js
├── tailwind.config.js
└── tsconfig.json
/.eslintrc:
--------------------------------------------------------------------------------
1 | {
2 | "extends": ["@remix-run/eslint-config"]
3 | }
4 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | node_modules
2 |
3 | /.cache
4 | /functions/\[\[path\]\].js
5 | /functions/\[\[path\]\].js.map
6 | /public/build
7 | .env
8 |
9 | /app/tailwind.css
10 |
--------------------------------------------------------------------------------
/.node-version:
--------------------------------------------------------------------------------
1 | 16.17.0
2 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Total Compensation Calculator
2 |
3 | [🚀 Live Site](https://tc.kyh.io)
4 |
5 | > Understand your total compensation under current market conditions.
6 |
7 | 
8 |
9 | The term Total Compensation captures all the different ways you are financially compensated by your employer: base salary, bonus, equity, benefits, etc.
10 |
11 | This calculator normalizes all these different forms of compensation into dollar values (from private or public companies) so you can estimate the final amount you are paid.
12 |
13 | ## Stack
14 |
15 | This project uses the following libraries and services:
16 |
17 | - Framework - [Remix](https://remix.run)
18 | - Styling - [Tailwind](https://tailwindcss.com)
19 | - API - [IEX](https://iexcloud.io/)
20 | - Hosting - [Cloudflare Pages](https://pages.cloudflare.com/)
21 |
22 | ## Development
23 |
24 | ```sh
25 | npm i
26 | # start the remix dev server and wrangler
27 | npm run dev
28 | ```
29 |
30 | Open up [http://127.0.0.1:8788](http://127.0.0.1:8788) and you should be ready to go!
31 |
--------------------------------------------------------------------------------
/app/components/About.tsx:
--------------------------------------------------------------------------------
1 | import { useState } from "react";
2 | import { Portal } from "react-portal";
3 | import type {
4 | CallBackProps,
5 | Step,
6 | Placement,
7 | TooltipRenderProps,
8 | } from "react-joyride";
9 | import Joyride, { ACTIONS, EVENTS, STATUS } from "react-joyride";
10 |
11 | const defaultStepProps = {
12 | disableBeacon: true,
13 | placement: "right" as Placement,
14 | floaterProps: {
15 | disableAnimation: true,
16 | },
17 | };
18 |
19 | export const defaultSteps: Step[] = [
20 | {
21 | ...defaultStepProps,
22 | target: ".title-section",
23 | content: (
24 | <>
25 |
26 | The term Total Compensation captures all the
27 | different ways you are financially compensated by your employer: base
28 | salary, bonus, equity, benefits, etc.
29 |
30 |
31 | This calculator normalizes all these different forms of compensation
32 | into dollar values (from private or public companies) so you can
33 | estimate the final amount you are paid.
34 |
35 | >
36 | ),
37 | },
38 | {
39 | ...defaultStepProps,
40 | target: ".cash-section",
41 | content: (
42 | <>
43 |
44 | Cash compensation is the simplest category to understand because it's
45 | what gets directly deposited into your bank account.
46 |
47 |
48 | Types of cash compensation:
49 |
50 |
51 |
52 | Base Salary - amount of money you receive just for
53 | being employed (regardless of the performance of the company or your
54 | performance)
55 |
56 |
57 | Bonuses - a single lump sum of cash (sometimes it's
58 | a yearly bonus, other times it could be a one time bonus at certain
59 | milestones)
60 |
61 |
62 | >
63 | ),
64 | },
65 | {
66 | ...defaultStepProps,
67 | target: ".equity-section",
68 | content: (
69 | <>
70 |
71 | Equity compensation is more complex, you only recieve during certain
72 | periods and it's difficult to get the exact dollar value of your
73 | equity.
74 |
75 |
76 | Types of equity compensation:
77 |
78 |
79 |
80 | ISO - your typical startup equity package consists
81 | of stock options which translate to stocks once you buy them for a
82 | certain strike price
83 |
84 |
85 | RSU - these are just like any other shares of
86 | company stock once they are vested
87 |
88 |
89 | >
90 | ),
91 | },
92 | {
93 | ...defaultStepProps,
94 | target: ".equity-value-section",
95 | content: (
96 | <>
97 |
98 | Estimating the value of your equity is the hard part. Investors often
99 | look at value from multiple dimensions. To keep things simple, we
100 | offer 2 different approaches.
101 |
102 |
103 | Estimating equity value:
104 |
105 |
106 |
107 | Growth based - At high-growth startup companies it
108 | may be easier to think of your stock value as an N multiple after 4
109 | years. Often, VCs expect a 10x return on their investment
110 |
111 |
112 | Revenue based - If you know the revenue of your
113 | company, you can estimate the value of your equity by comparing it
114 | against the revenue multiple of an equivalent public company
115 |
116 |
117 | >
118 | ),
119 | },
120 | {
121 | ...defaultStepProps,
122 | target: ".estimate-modal-button",
123 | content: (
124 | <>
125 |
126 | If you don't know what numbers to use, we can offer reasonable
127 | defaults for you by looking at competitors.
128 |
129 | >
130 | ),
131 | },
132 | ];
133 |
134 | export const useAbout = () => {
135 | const [run, setRun] = useState(false);
136 | const [stepIndex, setStepIndex] = useState(0);
137 | const [steps] = useState(defaultSteps);
138 |
139 | const handleJoyrideCallback = ({
140 | action,
141 | index,
142 | type,
143 | status,
144 | }: CallBackProps) => {
145 | if (
146 | action === ACTIONS.CLOSE ||
147 | ([STATUS.FINISHED, STATUS.SKIPPED] as string[]).includes(status)
148 | ) {
149 | setRun(false);
150 | setStepIndex(0);
151 | window.scrollTo({
152 | top: 0,
153 | left: 0,
154 | behavior: "smooth",
155 | });
156 | } else if (
157 | ([EVENTS.STEP_AFTER, EVENTS.TARGET_NOT_FOUND] as string[]).includes(type)
158 | ) {
159 | const stepIndex = index + (action === ACTIONS.PREV ? -1 : 1);
160 | setStepIndex(stepIndex);
161 | }
162 | };
163 |
164 | return {
165 | run,
166 | setRun,
167 | stepIndex,
168 | setStepIndex,
169 | steps,
170 | handleJoyrideCallback,
171 | };
172 | };
173 |
174 | const Tooltip = ({
175 | index,
176 | step,
177 | backProps,
178 | primaryProps,
179 | tooltipProps,
180 | isLastStep,
181 | }: TooltipRenderProps) => (
182 |
186 | {step.title && (
187 |
188 | {step.title}
189 |
190 | )}
191 | {step.content}
192 |
223 |
224 | );
225 |
226 | type Props = ReturnType;
227 |
228 | export const About = ({
229 | run,
230 | stepIndex,
231 | steps,
232 | handleJoyrideCallback,
233 | }: Props) => (
234 |
235 |
248 |
249 | );
250 |
--------------------------------------------------------------------------------
/app/components/Chart.tsx:
--------------------------------------------------------------------------------
1 | import type { SeriesPoint } from "@visx/shape/lib/types";
2 | import { BarStack } from "@visx/shape";
3 | import { Group } from "@visx/group";
4 | import { Grid } from "@visx/grid";
5 | import { AxisBottom } from "@visx/axis";
6 | import { scaleBand, scaleLinear, scaleOrdinal } from "@visx/scale";
7 | import { useTooltip, useTooltipInPortal, defaultStyles } from "@visx/tooltip";
8 | import { localPoint } from "@visx/event";
9 | import NumberFormat from "react-number-format";
10 |
11 | type CompTypes = "base" | "bonus" | "stock";
12 |
13 | type Comp = {
14 | year: string;
15 | base: number;
16 | bonus: number;
17 | stock: number;
18 | };
19 |
20 | type Data = Comp[];
21 |
22 | type TooltipData = {
23 | bar: SeriesPoint;
24 | key: CompTypes;
25 | index: number;
26 | height: number;
27 | width: number;
28 | x: number;
29 | y: number;
30 | color: string;
31 | };
32 |
33 | export type BarStackProps = {
34 | data: Data;
35 | width: number;
36 | height: number;
37 | margin?: { top: number; right: number; bottom: number; left: number };
38 | };
39 |
40 | const base = "#059669";
41 | const bonus = "#0ea5e9";
42 | const stock = "#f59e0b";
43 | const defaultMargin = { top: 0, right: 0, bottom: 0, left: 0 };
44 | const tooltipStyles = {
45 | ...defaultStyles,
46 | minWidth: 60,
47 | backgroundColor: "rgba(0,0,0,0.8)",
48 | borderRadius: "0.5rem",
49 | };
50 |
51 | let tooltipTimeout: number;
52 |
53 | export default function Chart({
54 | data,
55 | width,
56 | height,
57 | margin = defaultMargin,
58 | }: BarStackProps) {
59 | const {
60 | tooltipOpen,
61 | tooltipLeft,
62 | tooltipTop,
63 | tooltipData,
64 | hideTooltip,
65 | showTooltip,
66 | } = useTooltip();
67 |
68 | const { containerRef, TooltipInPortal } = useTooltipInPortal({
69 | // TooltipInPortal is rendered in a separate child of and positioned
70 | // with page coordinates which should be updated on scroll. consider using
71 | // Tooltip or TooltipWithBounds if you don't need to render inside a Portal
72 | scroll: true,
73 | });
74 |
75 | const keys = Object.keys(data[0]).filter((d) => d !== "year") as CompTypes[];
76 |
77 | const totals = data.reduce((all, current) => {
78 | const tc = keys.reduce((d, k) => {
79 | d += Number(current[k]);
80 | return d;
81 | }, 0);
82 | all.push(tc);
83 | return all;
84 | }, [] as number[]);
85 |
86 | const xScale = scaleBand({
87 | domain: data.map((d) => d.year),
88 | padding: 0.2,
89 | });
90 |
91 | const yScale = scaleLinear({
92 | domain: [0, Math.max(...totals)],
93 | nice: true,
94 | });
95 |
96 | const colorScale = scaleOrdinal({
97 | domain: keys,
98 | range: [base, bonus, stock],
99 | });
100 |
101 | // bounds
102 | const xMax = width;
103 | const yMax = height - margin.top - 100;
104 |
105 | xScale.rangeRound([0, xMax]);
106 | yScale.range([yMax, 0]);
107 |
108 | return (
109 |
110 |
111 |
121 |
122 | d.year}
126 | xScale={xScale}
127 | yScale={yScale}
128 | color={colorScale}
129 | >
130 | {(barStacks) =>
131 | barStacks.map((barStack) =>
132 | barStack.bars.map((bar) => (
133 | {
141 | tooltipTimeout = window.setTimeout(() => {
142 | hideTooltip();
143 | }, 300);
144 | }}
145 | onMouseMove={(event) => {
146 | if (tooltipTimeout) clearTimeout(tooltipTimeout);
147 | const eventSvgCoords = localPoint(event);
148 | const left = bar.x + bar.width / 2;
149 | showTooltip({
150 | tooltipData: bar,
151 | tooltipTop: eventSvgCoords?.y,
152 | tooltipLeft: left,
153 | });
154 | }}
155 | />
156 | ))
157 | )
158 | }
159 |
160 |
161 | `Year ${d}`}
165 | stroke="#64748b"
166 | tickStroke="#64748b"
167 | tickLabelProps={() => ({
168 | fill: "#64748b",
169 | fontSize: 10,
170 | textAnchor: "middle",
171 | })}
172 | />
173 |
174 | {tooltipOpen && tooltipData && (
175 |
180 |
181 | Year {tooltipData.bar.data["year"]}
182 |
183 |
184 | {tooltipData.key} compensation
185 |
186 |
194 |
195 | )}
196 |
197 | );
198 | }
199 |
--------------------------------------------------------------------------------
/app/components/CompForm.tsx:
--------------------------------------------------------------------------------
1 | import { useState, useEffect } from "react";
2 | import { RadioGroup, Listbox } from "@headlessui/react";
3 | import NumberFormat from "react-number-format";
4 | import { FormField } from "~/components/FormField";
5 | import { CompModal } from "~/components/CompModal";
6 | import { useModal } from "~/components/Modal";
7 | import type { CompHooksType } from "~/lib/comp";
8 | import {
9 | currencyInputFormatProps,
10 | staticInputFormatProps,
11 | } from "~/lib/formProps";
12 |
13 | type Props = {
14 | comp: CompHooksType;
15 | };
16 |
17 | export const CompForm = ({ comp }: Props) => {
18 | const [shouldUpdate, setShouldUpdate] = useState(false);
19 | const modalProps = useModal();
20 |
21 | useEffect(() => {
22 | if (shouldUpdate) {
23 | comp.updateData();
24 | setShouldUpdate(false);
25 | }
26 | }, [comp, shouldUpdate]);
27 |
28 | return (
29 | <>
30 |
31 |
32 | Cash Compensation
33 |
34 |
40 | comp.setBase(value)}
44 | onBlur={() => setShouldUpdate(true)}
45 | />
46 |
47 |
53 | comp.setSignOnBonus(value)}
57 | onBlur={() => setShouldUpdate(true)}
58 | />
59 |
60 |
66 | comp.setTargetBonus(value)}
70 | onBlur={() => setShouldUpdate(true)}
71 | />
72 |
73 |
74 |
75 |
76 |
77 | Equity Compensation
78 | {
82 | comp.setShareType(shareType);
83 | comp.setIso("");
84 | comp.setStrikePrice("");
85 | comp.setRsu("");
86 | comp.setExpectedGrowthMultiple("");
87 | }}
88 | onBlur={() => setShouldUpdate(true)}
89 | >
90 |
91 | {({ checked }) => (
92 |
97 | ISO
98 |
99 | )}
100 |
101 |
102 | {({ checked }) => (
103 |
108 | RSU
109 |
110 | )}
111 |
112 |
113 |
114 | {comp.shareType === "iso" && (
115 |
116 |
122 | comp.setIso(value)}
126 | onBlur={() => setShouldUpdate(true)}
127 | />
128 |
129 |
135 | comp.setStrikePrice(value)}
139 | onBlur={() => setShouldUpdate(true)}
140 | />
141 |
142 |
143 | )}
144 | {comp.shareType === "rsu" && (
145 |
146 |
151 | comp.setRsu(value)}
155 | onBlur={() => setShouldUpdate(true)}
156 | />
157 |
158 |
159 | )}
160 |
161 |
162 |
163 |
164 |
Estimate Equity Value
165 |
modalProps.openModal()}
169 | >
170 | Find out for me
171 |
181 |
182 |
183 |
184 |
185 |
186 |
187 | {
190 | comp.setShareCalcType(shareCalcType);
191 | comp.setPreferredSharePrice("");
192 | comp.setExpectedGrowthMultiple("");
193 | comp.setSharesOutstanding("");
194 | comp.setExpectedRevenue("");
195 | comp.setRevenueMultiple("");
196 | }}
197 | >
198 |
199 |
200 | {comp.shareCalcType === "current"
201 | ? "Growth Based"
202 | : comp.shareCalcType === "revenue"
203 | ? "Revenue Based"
204 | : null}
205 |
206 |
207 |
210 | `cursor-pointer select-none relative py-2 px-4 ${
211 | active ? "text-emerald-100 bg-emerald-900" : ""
212 | }`
213 | }
214 | >
215 | Growth Based
216 |
217 |
220 | `cursor-pointer select-none relative py-2 px-4 ${
221 | active ? "text-emerald-100 bg-emerald-900" : ""
222 | }`
223 | }
224 | >
225 | Revenue Based
226 |
227 |
228 |
229 |
230 |
231 | {comp.shareCalcType === "current" && (
232 |
233 |
243 |
247 | comp.setPreferredSharePrice(value)
248 | }
249 | onBlur={() => setShouldUpdate(true)}
250 | />
251 |
252 |
262 |
268 | comp.setExpectedGrowthMultiple(value)
269 | }
270 | onBlur={() => setShouldUpdate(true)}
271 | />
272 |
273 |
274 | )}
275 | {comp.shareCalcType === "revenue" && (
276 |
277 |
283 |
287 | comp.setSharesOutstanding(value)
288 | }
289 | onBlur={() => setShouldUpdate(true)}
290 | />
291 |
292 |
298 | comp.setExpectedRevenue(value)}
302 | onBlur={() => setShouldUpdate(true)}
303 | />
304 |
305 |
311 | comp.setRevenueMultiple(value)}
315 | onBlur={() => setShouldUpdate(true)}
316 | />
317 |
318 |
319 | )}
320 |
321 |
322 |
323 | >
324 | );
325 | };
326 |
--------------------------------------------------------------------------------
/app/components/CompModal.tsx:
--------------------------------------------------------------------------------
1 | import { useState } from "react";
2 | import { useFetcher } from "@remix-run/react";
3 | import Select, { components } from "react-select";
4 | import NumberFormat from "react-number-format";
5 | import { useDebouncedCallback } from "use-debounce";
6 | import { Modal } from "~/components/Modal";
7 | import { FormField } from "~/components/FormField";
8 | import {
9 | currencyTextFormatProps,
10 | staticTextFormatProps,
11 | } from "~/lib/formProps";
12 |
13 | import type { OptionProps, MultiValue } from "react-select";
14 | import type { Props as ModalProps } from "~/components/Modal";
15 | import type { CompHooksType } from "~/lib/comp";
16 |
17 | type Props = { setShouldUpdate: (t: boolean) => void } & CompHooksType &
18 | Omit;
19 |
20 | const Option = ({ children, ...rest }: OptionProps) => {
21 | return (
22 |
23 |
24 | {rest.data.symbol}
25 |
26 | {children}
27 |
28 | );
29 | };
30 |
31 | export const CompModal = ({
32 | isOpen,
33 | closeModal,
34 | openModal,
35 | shareType,
36 | shareCalcType,
37 | setExpectedGrowthMultiple,
38 | setPreferredSharePrice,
39 | setSharesOutstanding,
40 | setExpectedRevenue,
41 | setRevenueMultiple,
42 | setShouldUpdate,
43 | }: Props) => {
44 | const [view, setView] = useState("estimate");
45 | const search = useFetcher();
46 | const companies = useFetcher();
47 |
48 | const loadOptions = useDebouncedCallback((value) => {
49 | search.load(`/api/search?q=${value}`);
50 | }, 300);
51 |
52 | const loadCompaniesData = (selected: MultiValue) => {
53 | const query = selected.map((s) => s.symbol).join(",");
54 | companies.load(`/api/stock?s=${query}`);
55 | };
56 |
57 | const handleClose = () => {
58 | loadCompaniesData([]);
59 | closeModal();
60 | };
61 |
62 | const handleUse = (c: any) => {
63 | if (isoCurrent) {
64 | setExpectedGrowthMultiple(c.year5ChangePercent.toFixed(2));
65 | }
66 | if (rsuCurrent) {
67 | setPreferredSharePrice((c.marketcap / c.sharesOutstanding).toFixed(2));
68 | setExpectedGrowthMultiple(((c.year5ChangePercent / 5) * 100).toFixed(2));
69 | }
70 | if (isoRevenue || rsuRevenue) {
71 | setSharesOutstanding(c.sharesOutstanding.toString());
72 | setExpectedRevenue(c.revenue.toString());
73 | setRevenueMultiple(c.revenuePerShare.toString());
74 | }
75 | setShouldUpdate(true);
76 | handleClose();
77 | };
78 |
79 | const isoCurrent = shareType === "iso" && shareCalcType == "current";
80 | const rsuCurrent = shareType === "rsu" && shareCalcType == "current";
81 | const isoRevenue = shareType === "iso" && shareCalcType == "revenue";
82 | const rsuRevenue = shareType === "rsu" && shareCalcType == "revenue";
83 |
84 | return (
85 |
91 |
92 | {view === "estimate" ? "Estimate Equity Value" : "Terminology"}
93 |
94 |
95 | setView("estimate")}
101 | >
102 | Estimate Equity Value
103 |
112 |
113 |
114 |
115 |
116 |
117 |
118 |
119 |
120 |
121 |
122 |
123 | setView("terminology")}
129 | >
130 | Terminology
131 |
140 |
141 |
142 |
143 |
144 |
145 |
146 |
147 | }
148 | >
149 | {view === "terminology" && (
150 |
151 | {isoCurrent && (
152 |
153 |
Preferred Stock Price
154 |
155 | The preferred stock price is the price at which investors
156 | currently pay for shares of the company. You can ask your
157 | recruiter what the current price is.
158 |
159 |
160 | )}
161 | {rsuCurrent && (
162 |
163 |
Current Market Price
164 |
165 | This is the stock price at which the company is currently
166 | trading
167 |
168 |
169 | )}
170 | {(isoRevenue || rsuRevenue) && (
171 |
172 |
Shares Outstanding
173 |
174 | The shares outstanding is the number of shares that the company
175 | has available in the market.
176 |
177 |
178 | )}
179 |
180 | {isoCurrent && (
181 | <>
182 |
183 | Expected Growth over 4 years
184 |
185 |
186 | Depending on the stage of the company expected growth can
187 | vary. Investors typically expect a 10x return on what they put
188 | in.
189 |
190 | >
191 | )}
192 | {rsuCurrent && (
193 | <>
194 |
195 | Expected Market Growth
196 |
197 |
198 | How much do you expect the stock price to change every year?
199 | Anualized growth over the last 4 years is a good estimate.
200 |
201 | >
202 | )}
203 | {(isoRevenue || rsuRevenue) && (
204 | <>
205 |
206 | Expected Company Revenue
207 |
208 |
209 | How much do you expect the company to make every year? Divide
210 | this number by the number of shares outstanding to get the
211 | revenue multiple.
212 |
213 |
214 | Revenue Multiple
215 |
216 |
217 | The revenue multiple is the ratio of the company's revenue
218 | relative to its stock price. You can use your competitors
219 | revenue multiple to estimate what your share value would be.
220 |
221 | >
222 | )}
223 |
224 |
225 | )}
226 | {view === "estimate" && (
227 |
228 |
229 |
230 | Estimate reasonable numbers for your equity value by looking at
231 | competitors:
232 |
233 |
234 |
240 | null }}
244 | isMulti
245 | isLoading={search.state !== "idle"}
246 | defaultValue={[]}
247 | onInputChange={loadOptions}
248 | options={search.data}
249 | onChange={loadCompaniesData}
250 | isSearchable={
251 | !companies.data ||
252 | (companies.data && companies.data.length < 3)
253 | }
254 | openMenuOnFocus={false}
255 | openMenuOnClick={false}
256 | />
257 |
258 |
259 |
260 | {companies.data && companies.data.length ? (
261 |
262 |
263 |
264 |
265 | Company
266 |
267 | {isoCurrent && (
268 |
269 | Growth over last 4 years
270 |
271 | )}
272 | {rsuCurrent && (
273 | <>
274 |
278 | Current Market Value
279 |
280 |
284 | Average Growth per year
285 |
286 | >
287 | )}
288 | {(isoRevenue || rsuRevenue) && (
289 | <>
290 |
294 | Shares Outstanding
295 |
296 |
300 | Revenue
301 |
302 |
306 | Revenue Multiple
307 |
308 | >
309 | )}
310 |
311 |
312 |
313 |
314 | {companies.data.map((c: any) => (
315 |
316 | {c.companyName}
317 | {isoCurrent && (
318 |
319 |
325 |
326 | )}
327 | {rsuCurrent && (
328 | <>
329 |
330 |
334 |
335 |
336 |
342 |
343 | >
344 | )}
345 | {(isoRevenue || rsuRevenue) && (
346 | <>
347 |
348 |
354 |
355 |
356 |
360 |
361 |
362 |
368 |
369 | >
370 | )}
371 |
372 | handleUse(c)}
376 | >
377 | Use
378 |
379 |
380 |
381 | ))}
382 |
383 |
384 | ) : (
385 |
386 |
Add a company above
387 |
388 | )}
389 |
390 | )}
391 |
392 | );
393 | };
394 |
--------------------------------------------------------------------------------
/app/components/CompTable.tsx:
--------------------------------------------------------------------------------
1 | import type { BaseDataType } from "~/lib/comp";
2 | import NumberFormat from "react-number-format";
3 | import { currencyTextFormatProps } from "~/lib/formProps";
4 |
5 | type Props = {
6 | data: BaseDataType;
7 | };
8 |
9 | export const CompTable = ({ data }: Props) => (
10 |
11 |
12 |
13 |
14 | Year
15 |
16 |
17 | Base
18 |
19 |
20 | Bonus
21 |
22 |
23 | Stock
24 |
25 |
26 | Total
27 |
28 |
29 |
30 |
31 | {data.map((c) => (
32 |
33 | {c.year}
34 |
35 |
36 |
37 |
38 |
39 |
40 |
41 |
42 |
43 |
44 |
48 |
49 |
50 | ))}
51 |
52 |
53 | );
54 |
--------------------------------------------------------------------------------
/app/components/FormField.tsx:
--------------------------------------------------------------------------------
1 | import { cloneElement } from "react";
2 |
3 | type Props = {
4 | label: string;
5 | name: string;
6 | className?: string;
7 | placeholder?: string;
8 | children?: any;
9 | };
10 |
11 | export const FormField = ({
12 | label,
13 | name,
14 | className = "",
15 | placeholder,
16 | children,
17 | }: Props) => {
18 | const fieldProps = {
19 | id: name,
20 | type: "text",
21 | className:
22 | "block w-full border-0 p-0 text-emerald-500 placeholder-slate-500 bg-transparent focus:ring-0",
23 | name,
24 | placeholder,
25 | };
26 |
27 | const field = children ? (
28 | cloneElement(children, fieldProps)
29 | ) : (
30 |
31 | );
32 |
33 | return (
34 |
37 |
41 | {label}
42 |
43 | {field}
44 |
45 | );
46 | };
47 |
--------------------------------------------------------------------------------
/app/components/Logo.tsx:
--------------------------------------------------------------------------------
1 | export const Logo = () => (
2 |
3 |
8 |
9 | );
10 |
--------------------------------------------------------------------------------
/app/components/Modal.tsx:
--------------------------------------------------------------------------------
1 | import { Dialog, Transition } from "@headlessui/react";
2 | import { Fragment, useState } from "react";
3 |
4 | export const useModal = () => {
5 | const [isOpen, setIsOpen] = useState(false);
6 |
7 | const closeModal = () => {
8 | setIsOpen(false);
9 | };
10 |
11 | const openModal = () => {
12 | setIsOpen(true);
13 | };
14 |
15 | return { isOpen, closeModal, openModal };
16 | };
17 |
18 | export type Props = {
19 | title?: React.ReactNode;
20 | children: React.ReactNode;
21 | } & ReturnType;
22 |
23 | export const Modal = ({ isOpen, closeModal, title, children }: Props) => {
24 | return (
25 |
26 |
27 |
36 |
37 |
38 |
39 |
40 |
41 |
50 |
51 | {title && (
52 |
56 | {title}
57 |
58 | )}
59 | {children}
60 |
61 |
62 |
63 |
64 |
65 |
66 | );
67 | };
68 |
--------------------------------------------------------------------------------
/app/components/Navigation.tsx:
--------------------------------------------------------------------------------
1 | import { Logo } from "~/components/Logo";
2 | import { About, useAbout } from "~/components/About";
3 |
4 | export const Navigation = () => {
5 | const aboutProps = useAbout();
6 |
7 | return (
8 |
9 |
10 | Logo
11 |
12 |
13 |
38 |
39 |
40 | );
41 | };
42 |
--------------------------------------------------------------------------------
/app/entry.client.tsx:
--------------------------------------------------------------------------------
1 | import { hydrate } from "react-dom";
2 | import { RemixBrowser } from "@remix-run/react";
3 |
4 | hydrate( , document);
5 |
--------------------------------------------------------------------------------
/app/entry.server.tsx:
--------------------------------------------------------------------------------
1 | import { renderToString } from "react-dom/server";
2 | import type { EntryContext } from "@remix-run/cloudflare";
3 | import { RemixServer } from "@remix-run/react";
4 |
5 | export default function handleRequest(
6 | request: Request,
7 | responseStatusCode: number,
8 | responseHeaders: Headers,
9 | remixContext: EntryContext
10 | ) {
11 | let markup = renderToString(
12 |
13 | );
14 |
15 | responseHeaders.set("Content-Type", "text/html");
16 |
17 | return new Response("" + markup, {
18 | status: responseStatusCode,
19 | headers: responseHeaders,
20 | });
21 | }
22 |
--------------------------------------------------------------------------------
/app/global.css:
--------------------------------------------------------------------------------
1 | div.react-select__control,
2 | div.react-select__control:hover,
3 | div.react-select__control.react-select__control--is-focused {
4 | background-color: transparent;
5 | border: none;
6 | box-shadow: none;
7 | }
8 |
9 | div.react-select__value-container {
10 | padding: 0;
11 | }
12 |
13 | div.react-select__input-container {
14 | color: #10b981;
15 | margin: 0;
16 | padding: 0;
17 | }
18 |
19 | input.react-select__input:focus {
20 | box-shadow: none;
21 | }
22 |
23 | div.react-select__value-container {
24 | gap: 2px;
25 | }
26 |
27 | div.react-select__multi-value {
28 | background-color: #1e293b;
29 | }
30 |
31 | div.react-select__multi-value__label {
32 | color: #cbd5e1;
33 | }
34 |
35 | div.react-select__multi-value__remove:hover {
36 | background-color: transparent;
37 | }
38 |
39 | div.react-select__menu {
40 | margin: 0;
41 | background-color: #1e293b;
42 | border: 1px solid #475569;
43 | }
44 |
45 | .react-select__option.react-select__option--is-focused {
46 | background-color: #0f172a;
47 | }
48 |
--------------------------------------------------------------------------------
/app/lib/comp.ts:
--------------------------------------------------------------------------------
1 | import { useState } from "react";
2 |
3 | const baseData = [
4 | {
5 | year: "1",
6 | base: 0,
7 | bonus: 0,
8 | stock: 0,
9 | },
10 | {
11 | year: "2",
12 | base: 0,
13 | bonus: 0,
14 | stock: 0,
15 | },
16 | {
17 | year: "3",
18 | base: 0,
19 | bonus: 0,
20 | stock: 0,
21 | },
22 | {
23 | year: "4",
24 | base: 0,
25 | bonus: 0,
26 | stock: 0,
27 | },
28 | ];
29 |
30 | export type BaseDataType = typeof baseData;
31 |
32 | const calculateBase = (base = "0") => {
33 | return parseFloat(base || "0");
34 | };
35 |
36 | const calculateBonus = (year = "1", signOnBonus = "0", targetBonus = "0") => {
37 | if (year === "1")
38 | return parseFloat(signOnBonus || "0") + parseFloat(targetBonus || "0");
39 | return parseFloat(targetBonus || "0");
40 | };
41 |
42 | const calculateStocks = (shares = "0", strikePrice = "0", shareValue = "0") => {
43 | const total =
44 | parseFloat(shares || "0") * parseFloat(shareValue || "0") -
45 | parseFloat(shares || "0") * parseFloat(strikePrice || "0");
46 | return total > 0 ? total : 0;
47 | };
48 |
49 | const compoundInterest = (
50 | principle: number = 0,
51 | rate: number = 0,
52 | time: number = 1,
53 | n: number = 1
54 | ) => {
55 | const amount = principle * Math.pow(1 + rate / n, n * time);
56 | const interest = amount - principle;
57 | return interest;
58 | };
59 |
60 | const calculateShareValueFromMultiple = (
61 | preferredSharePrice = "0",
62 | multiple = "0",
63 | calcTotal = true,
64 | year = "1"
65 | ) => {
66 | const price = parseFloat(preferredSharePrice || "0");
67 | const rate = parseFloat(multiple || "0");
68 |
69 | if (calcTotal) return (price * rate || 1).toFixed(2);
70 |
71 | const interest = compoundInterest(price, rate / 100, parseInt(year));
72 | return (price + interest).toFixed(2);
73 | };
74 |
75 | const calculateShareValueFromRevenue = (
76 | sharesOutstanding = "0",
77 | expectedRevenue = "0",
78 | revenueMultiple = "0"
79 | ) => {
80 | const valuation =
81 | parseFloat(expectedRevenue || "0") * parseFloat(revenueMultiple || "0");
82 | const shareValue = valuation / parseInt(sharesOutstanding || "1");
83 |
84 | return shareValue.toFixed(2);
85 | };
86 |
87 | export const useCompHooks = () => {
88 | const [data, setData] = useState(baseData);
89 |
90 | // cash comp
91 | const [base, setBase] = useState("");
92 | const [signOnBonus, setSignOnBonus] = useState("");
93 | const [targetBonus, setTargetBonus] = useState("");
94 |
95 | // stock info
96 | const [shareType, setShareType] = useState("iso");
97 | const [iso, setIso] = useState("");
98 | const [strikePrice, setStrikePrice] = useState("");
99 | const [rsu, setRsu] = useState("");
100 |
101 | // stock comp
102 | const [shareCalcType, setShareCalcType] = useState("current");
103 | // common
104 | const [preferredSharePrice, setPreferredSharePrice] = useState("");
105 | const [expectedGrowthMultiple, setExpectedGrowthMultiple] = useState("");
106 | // revenue
107 | const [sharesOutstanding, setSharesOutstanding] = useState("");
108 | const [expectedRevenue, setExpectedRevenue] = useState("");
109 | const [revenueMultiple, setRevenueMultiple] = useState("");
110 |
111 | const updateData = () => {
112 | setData((prevData) =>
113 | prevData.map((d) => {
114 | const sv =
115 | shareCalcType === "current"
116 | ? calculateShareValueFromMultiple(
117 | preferredSharePrice,
118 | expectedGrowthMultiple,
119 | shareType === "iso",
120 | d.year
121 | )
122 | : calculateShareValueFromRevenue(
123 | sharesOutstanding,
124 | expectedRevenue,
125 | revenueMultiple
126 | );
127 |
128 | return {
129 | ...d,
130 | base: calculateBase(base),
131 | bonus: calculateBonus(d.year, signOnBonus, targetBonus),
132 | stock:
133 | shareType === "iso"
134 | ? calculateStocks(iso, strikePrice, sv)
135 | : calculateStocks(rsu, "0", sv),
136 | };
137 | })
138 | );
139 | };
140 |
141 | return {
142 | data,
143 | updateData,
144 | base,
145 | setBase,
146 | signOnBonus,
147 | setSignOnBonus,
148 | targetBonus,
149 | setTargetBonus,
150 |
151 | shareType,
152 | setShareType,
153 |
154 | iso,
155 | setIso,
156 | rsu,
157 | setRsu,
158 | strikePrice,
159 | setStrikePrice,
160 |
161 | shareCalcType,
162 | setShareCalcType,
163 |
164 | preferredSharePrice,
165 | setPreferredSharePrice,
166 | expectedGrowthMultiple,
167 | setExpectedGrowthMultiple,
168 |
169 | sharesOutstanding,
170 | setSharesOutstanding,
171 | expectedRevenue,
172 | setExpectedRevenue,
173 | revenueMultiple,
174 | setRevenueMultiple,
175 | };
176 | };
177 |
178 | export type CompHooksType = ReturnType;
179 |
--------------------------------------------------------------------------------
/app/lib/formProps.ts:
--------------------------------------------------------------------------------
1 | export const staticInputFormatProps = {
2 | displayType: "input" as "input",
3 | thousandSeparator: true,
4 | isNumericString: true,
5 | allowNegative: false,
6 | };
7 |
8 | export const staticTextFormatProps = {
9 | decimalSeparator: ".",
10 | displayType: "text" as "text",
11 | thousandSeparator: true,
12 | isNumericString: true,
13 | allowNegative: false,
14 | decimalScale: 2,
15 | fixedDecimalScale: true,
16 | };
17 |
18 | export const currencyInputFormatProps = {
19 | prefix: "$",
20 | decimalSeparator: ".",
21 | displayType: "input" as "input",
22 | thousandSeparator: true,
23 | isNumericString: true,
24 | allowNegative: false,
25 | };
26 |
27 | export const currencyTextFormatProps = {
28 | prefix: "$",
29 | decimalSeparator: ".",
30 | displayType: "text" as "text",
31 | thousandSeparator: true,
32 | isNumericString: true,
33 | allowNegative: false,
34 | decimalScale: 2,
35 | fixedDecimalScale: true,
36 | };
37 |
--------------------------------------------------------------------------------
/app/root.tsx:
--------------------------------------------------------------------------------
1 | import type { LinksFunction, MetaFunction } from "@remix-run/cloudflare";
2 | import {
3 | Links,
4 | LiveReload,
5 | Meta,
6 | Outlet,
7 | Scripts,
8 | ScrollRestoration,
9 | } from "@remix-run/react";
10 | import styles from "./tailwind.css";
11 | import global from "./global.css";
12 |
13 | export const meta: MetaFunction = () => ({
14 | title: "Total Compensation Calculator",
15 | charset: "utf-8",
16 | viewport: "width=device-width,initial-scale=1",
17 | "msapplication-TileColor": "#da532c",
18 | "msapplication-config": "/favicon/browserconfig.xml",
19 | "theme-color": "#ffffff",
20 | });
21 |
22 | export const links: LinksFunction = () => [
23 | { rel: "stylesheet", href: styles },
24 | { rel: "stylesheet", href: global },
25 | {
26 | rel: "apple-touch-icon",
27 | sizes: "180x180",
28 | href: "/favicon/apple-touch-icon.png",
29 | },
30 | {
31 | rel: "icon",
32 | type: "image/png",
33 | sizes: "32x32",
34 | href: "/favicon/favicon-32x32.png",
35 | },
36 | {
37 | rel: "icon",
38 | type: "image/png",
39 | sizes: "16x16",
40 | href: "/favicon/favicon-16x16.png",
41 | },
42 | { rel: "manifest", href: "/favicon/site.webmanifest" },
43 | {
44 | rel: "mask-icon",
45 | href: "/favicon/safari-pinned-tab.svg",
46 | color: "#555555",
47 | },
48 | { rel: "shortcut icon", href: "/favicon/favicon.ico" },
49 | { rel: "canonical", href: "https://tc.kyh.io" },
50 | ];
51 |
52 | export default function App() {
53 | return (
54 |
55 |
56 |
57 |
58 |
59 |
60 |
61 |
62 |
63 |
64 |
69 |
70 |
71 | );
72 | }
73 |
--------------------------------------------------------------------------------
/app/routes/api/search.tsx:
--------------------------------------------------------------------------------
1 | import { json } from "@remix-run/cloudflare";
2 | import type { LoaderFunction } from "@remix-run/cloudflare";
3 |
4 | export const loader: LoaderFunction = async ({ request, context }) => {
5 | const baseUrl = context.env.IEX_URL;
6 | const iexToken = context.env.IEX_PUBLISHABLE_KEY;
7 | const proxyToken = context.env.PROXY_API_KEY;
8 |
9 | const url = new URL(request.url);
10 | const fragment = url.searchParams.get("q");
11 |
12 | if (!fragment) return json([]);
13 |
14 | const response = await fetch(
15 | `${baseUrl}/search/${fragment}?token=${iexToken}`,
16 | {
17 | headers: { "proxy-apiKey": proxyToken },
18 | }
19 | );
20 | const data: Record[] = await response.json();
21 |
22 | const formatted = data.map((d) => ({ ...d, label: d.name, value: d.symbol }));
23 |
24 | return json(formatted);
25 | };
26 |
--------------------------------------------------------------------------------
/app/routes/api/stock.tsx:
--------------------------------------------------------------------------------
1 | import { json } from "@remix-run/cloudflare";
2 | import type { LoaderFunction } from "@remix-run/cloudflare";
3 |
4 | export const loader: LoaderFunction = async ({ request, context }) => {
5 | const baseUrl = context.env.IEX_URL;
6 | const iexToken = context.env.IEX_PUBLISHABLE_KEY;
7 | const proxyToken = context.env.PROXY_API_KEY;
8 |
9 | const url = new URL(request.url);
10 | const fragment = url.searchParams.get("s");
11 |
12 | if (!fragment) return json([]);
13 |
14 | const queries = fragment.split(",");
15 | const promises = queries.map((q) =>
16 | fetch(`${baseUrl}/stock/${q}/advanced-stats?token=${iexToken}`, {
17 | headers: { "proxy-apiKey": proxyToken },
18 | })
19 | .then((r) => r.json())
20 | .catch(() => ({}))
21 | );
22 |
23 | const data: unknown[] = await Promise.all(promises);
24 |
25 | return json(data);
26 | };
27 |
--------------------------------------------------------------------------------
/app/routes/index.tsx:
--------------------------------------------------------------------------------
1 | import NumberFormat from "react-number-format";
2 | import ParentSize from "@visx/responsive/lib/components/ParentSize";
3 | import { Navigation } from "~/components/Navigation";
4 | import { CompTable } from "~/components/CompTable";
5 | import { CompForm } from "~/components/CompForm";
6 | import Chart from "~/components/Chart";
7 | import { useCompHooks } from "~/lib/comp";
8 | import { currencyTextFormatProps } from "~/lib/formProps";
9 |
10 | export default function Index() {
11 | const comp = useCompHooks();
12 | const totalTc = comp.data.reduce(
13 | (acc, curr) => acc + curr.base + curr.bonus + curr.stock,
14 | 0
15 | );
16 | const avgTc = totalTc / comp.data.length;
17 |
18 | return (
19 | <>
20 |
21 |
22 |
23 |
24 |
25 | A layman's Total Compensation Calculator
26 |
27 |
28 | Understand your total compensation under current market
29 | conditions.
30 |
31 |
32 |
33 |
34 |
35 |
36 |
41 |
42 | Estimated Total Compensation
43 |
44 |
45 |
46 |
51 | {!!avgTc && (
52 | (per year)
53 | )}
54 |
55 |
56 |
60 | {({ width }) => (
61 |
62 | )}
63 |
64 |
65 |
66 |
67 |
68 |
69 |
74 |
81 |
82 | >
83 | );
84 | }
85 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "tc",
3 | "private": true,
4 | "description": "A laymans total compensation calculator",
5 | "license": "MIT",
6 | "sideEffects": false,
7 | "scripts": {
8 | "build": "npm run build:css && npm run build:remix",
9 | "build:css": "npm run generate:css -- --minify",
10 | "build:remix": "remix build",
11 | "dev": "remix build && run-p dev:*",
12 | "dev:css": "npm run generate:css -- --watch",
13 | "dev:remix": "remix watch",
14 | "dev:wrangler": "cross-env NODE_ENV=development node ./scripts/dev-wrangler.mjs",
15 | "generate:css": "npx tailwindcss -o ./app/tailwind.css",
16 | "start": "cross-env NODE_ENV=production npm run dev:wrangler"
17 | },
18 | "dependencies": {
19 | "@headlessui/react": "^1.5.0",
20 | "@remix-run/cloudflare": "^1.3.4",
21 | "@remix-run/cloudflare-pages": "^1.3.4",
22 | "@remix-run/react": "^1.3.4",
23 | "@visx/axis": "^2.6.0",
24 | "@visx/event": "^2.6.0",
25 | "@visx/grid": "^2.6.0",
26 | "@visx/group": "^2.1.0",
27 | "@visx/responsive": "^2.8.0",
28 | "@visx/scale": "^2.2.2",
29 | "@visx/shape": "^2.4.0",
30 | "@visx/tooltip": "^2.8.0",
31 | "cross-env": "^7.0.3",
32 | "react": "^18.1.0",
33 | "react-dom": "^18.1.0",
34 | "react-joyride": "^2.4.0",
35 | "react-number-format": "^4.9.1",
36 | "react-portal": "^4.2.2",
37 | "react-select": "^5.3.2",
38 | "use-debounce": "^8.0.1"
39 | },
40 | "devDependencies": {
41 | "@cloudflare/workers-types": "^3.10.0",
42 | "@remix-run/dev": "^1.3.4",
43 | "@remix-run/eslint-config": "^1.3.4",
44 | "@tailwindcss/forms": "^0.5.0",
45 | "@types/react": "^18.0.9",
46 | "@types/react-dom": "^18.0.4",
47 | "@types/react-portal": "^4.0.4",
48 | "eslint": "^8.15.0",
49 | "npm-run-all": "^4.1.5",
50 | "tailwindcss": "^3.0.23",
51 | "typescript": "^4.5.5",
52 | "wrangler": "beta"
53 | },
54 | "engines": {
55 | "node": ">=14"
56 | }
57 | }
58 |
--------------------------------------------------------------------------------
/public/_headers:
--------------------------------------------------------------------------------
1 | /build/*
2 | Cache-Control: public, max-age=31536000, s-maxage=31536000
3 |
--------------------------------------------------------------------------------
/public/favicon/android-chrome-192x192.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/kyh/tc/cd96d50dd71c06908853f171a1c8e72cafd596b0/public/favicon/android-chrome-192x192.png
--------------------------------------------------------------------------------
/public/favicon/android-chrome-512x512.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/kyh/tc/cd96d50dd71c06908853f171a1c8e72cafd596b0/public/favicon/android-chrome-512x512.png
--------------------------------------------------------------------------------
/public/favicon/apple-touch-icon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/kyh/tc/cd96d50dd71c06908853f171a1c8e72cafd596b0/public/favicon/apple-touch-icon.png
--------------------------------------------------------------------------------
/public/favicon/browserconfig.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 | #da532c
7 |
8 |
9 |
10 |
--------------------------------------------------------------------------------
/public/favicon/favicon-16x16.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/kyh/tc/cd96d50dd71c06908853f171a1c8e72cafd596b0/public/favicon/favicon-16x16.png
--------------------------------------------------------------------------------
/public/favicon/favicon-32x32.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/kyh/tc/cd96d50dd71c06908853f171a1c8e72cafd596b0/public/favicon/favicon-32x32.png
--------------------------------------------------------------------------------
/public/favicon/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/kyh/tc/cd96d50dd71c06908853f171a1c8e72cafd596b0/public/favicon/favicon.ico
--------------------------------------------------------------------------------
/public/favicon/mstile-144x144.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/kyh/tc/cd96d50dd71c06908853f171a1c8e72cafd596b0/public/favicon/mstile-144x144.png
--------------------------------------------------------------------------------
/public/favicon/mstile-150x150.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/kyh/tc/cd96d50dd71c06908853f171a1c8e72cafd596b0/public/favicon/mstile-150x150.png
--------------------------------------------------------------------------------
/public/favicon/mstile-310x150.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/kyh/tc/cd96d50dd71c06908853f171a1c8e72cafd596b0/public/favicon/mstile-310x150.png
--------------------------------------------------------------------------------
/public/favicon/mstile-310x310.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/kyh/tc/cd96d50dd71c06908853f171a1c8e72cafd596b0/public/favicon/mstile-310x310.png
--------------------------------------------------------------------------------
/public/favicon/mstile-70x70.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/kyh/tc/cd96d50dd71c06908853f171a1c8e72cafd596b0/public/favicon/mstile-70x70.png
--------------------------------------------------------------------------------
/public/favicon/safari-pinned-tab.svg:
--------------------------------------------------------------------------------
1 |
2 |
4 |
7 |
8 | Created by potrace 1.14, written by Peter Selinger 2001-2017
9 |
10 |
12 |
31 |
32 |
33 |
--------------------------------------------------------------------------------
/public/favicon/site.webmanifest:
--------------------------------------------------------------------------------
1 | {
2 | "name": "",
3 | "short_name": "",
4 | "icons": [
5 | {
6 | "src": "/favicon/android-chrome-192x192.png",
7 | "sizes": "192x192",
8 | "type": "image/png"
9 | },
10 | {
11 | "src": "/favicon/android-chrome-512x512.png",
12 | "sizes": "512x512",
13 | "type": "image/png"
14 | }
15 | ],
16 | "theme_color": "#ffffff",
17 | "background_color": "#ffffff",
18 | "display": "standalone"
19 | }
20 |
--------------------------------------------------------------------------------
/remix.config.js:
--------------------------------------------------------------------------------
1 | /**
2 | * @type {import('@remix-run/dev').AppConfig}
3 | */
4 | module.exports = {
5 | serverBuildTarget: "cloudflare-pages",
6 | server: "./server.js",
7 | devServerBroadcastDelay: 1000,
8 | ignoredRouteFiles: [".*"],
9 | // appDirectory: "app",
10 | // assetsBuildDirectory: "public/build",
11 | // serverBuildPath: "functions/[[path]].js",
12 | // publicPath: "/build/",
13 | };
14 |
--------------------------------------------------------------------------------
/remix.env.d.ts:
--------------------------------------------------------------------------------
1 | ///
2 | ///
3 | ///
4 |
--------------------------------------------------------------------------------
/scripts/dev-wrangler.mjs:
--------------------------------------------------------------------------------
1 | import "dotenv/config";
2 | import { spawn } from "child_process";
3 |
4 | const child = spawn(
5 | "wrangler",
6 | [
7 | "pages",
8 | "dev",
9 | "./public",
10 | "--kv",
11 | "PROXY",
12 | "--binding",
13 | ...Object.keys(process.env).map((k) => `${k}=${process.env[k]}`),
14 | ],
15 | { stdio: "inherit" }
16 | );
17 |
18 | process.on("SIGTERM", () => {
19 | child.kill();
20 | });
21 |
--------------------------------------------------------------------------------
/server.js:
--------------------------------------------------------------------------------
1 | import { createPagesFunctionHandler } from "@remix-run/cloudflare-pages";
2 | import * as build from "@remix-run/dev/server-build";
3 |
4 | const handleRequest = createPagesFunctionHandler({
5 | build,
6 | mode: process.env.NODE_ENV,
7 | getLoadContext: (context) => context,
8 | });
9 |
10 | export function onRequest(context) {
11 | return handleRequest(context);
12 | }
13 |
--------------------------------------------------------------------------------
/tailwind.config.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | content: ["./app/**/*.{ts,tsx,jsx,js}"],
3 | darkMode: "class",
4 | theme: {
5 | extend: {},
6 | },
7 | plugins: [require("@tailwindcss/forms")],
8 | };
9 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "include": ["remix.env.d.ts", "**/*.ts", "**/*.tsx"],
3 | "compilerOptions": {
4 | "lib": ["DOM", "DOM.Iterable", "ES2019"],
5 | "isolatedModules": true,
6 | "esModuleInterop": true,
7 | "jsx": "react-jsx",
8 | "moduleResolution": "node",
9 | "resolveJsonModule": true,
10 | "target": "ES2019",
11 | "strict": true,
12 | "baseUrl": ".",
13 | "paths": {
14 | "~/*": ["./app/*"]
15 | },
16 | "noEmit": true,
17 | "forceConsistentCasingInFileNames": true,
18 | "allowJs": true
19 | }
20 | }
21 |
--------------------------------------------------------------------------------