diff --git a/app/components/calculator.tsx b/app/components/calculator.tsx
new file mode 100644
index 0000000..dc094ed
--- /dev/null
+++ b/app/components/calculator.tsx
@@ -0,0 +1,217 @@
+import { ValidatedForm } from "remix-validated-form";
+import { withZod } from "@remix-validated-form/with-zod";
+import { z } from "zod";
+import { useIsSubmitting } from "remix-validated-form";
+import { useField } from "remix-validated-form";
+import { useState, useEffect } from "react";
+
+/*------------------------------------------
+ HELPER FUNCTIONS
+ ------------------------------------------ */
+// NOTE: I decided to try out remix-validated-form because of it's simplicity and ease for validation. I originally used 'useActionData();' to do server-side validation, but ran into some issues due to returning a JSON response instead of a redirect, which caused the app to stay on the action's route.
+
+const validator = withZod(
+ z.object({
+ name: z.string().min(1, { message: "First name is required" }),
+ email: z.string().min(1, { message: "Email is required" }).email("Must be a valid email"),
+ averageProgramsPerMonth: z.string().min(1, { message: "Average Number of Programs Per Month is required" }),
+ averageLengthOfProgramsInHours: z
+ .string()
+ .min(1, { message: "Average Length Of Programs In Hours is required" }),
+ })
+);
+
+/*------------------------------------------
+ JSX ELEMENTS
+ ------------------------------------------ */
+const SubmitButton = () => {
+ const isSubmitting = useIsSubmitting();
+ return (
+
+ {isSubmitting ? "CALCULATING..." : "CALCULATE"}
+
+ );
+};
+
+type TextInputProps = {
+ name: string;
+ label: string;
+ type: string;
+};
+
+// NOTE: remix-validated-form helped me to reduce my code quite a bit by being able to reuse a single component for both inputs and error handling. It was easy to customize the styles in one place as well.
+const TextInput = ({ name, label, type }: TextInputProps) => {
+ const { error, getInputProps } = useField(name);
+ const [borderColor, setBorderColor] = useState("medium-grey");
+
+ useEffect(() => {
+ error ? setBorderColor("red-500") : setBorderColor("medium-grey");
+ }, [error]);
+
+ return (
+
+
+ {label}
+
+
+ {error &&
*{error}
}
+
+ );
+};
+
+export default function Calculator() {
+ // NOTE: I wasn't sure if you wanted the calculation to occur in the backend or frontend. I chose the frontend since it wasn't a complex function, and didn't slow down the the user's experience.
+ const [yearlyCaptionMins, setYearlyCaptionMins] = useState(0);
+
+ const calculateCaptions = (form) => {
+ const averageProgramsPerMonth = form.averageProgramsPerMonth;
+ const averageLengthOfProgramsInHours = form.averageLengthOfProgramsInHours;
+ setYearlyCaptionMins(averageProgramsPerMonth * averageLengthOfProgramsInHours * 60);
+ };
+
+ const CalculatorForm = () => {
+ return (
+
+
+
+
+
+
+
+
+
+
Do You Need Translations?
+
+
+
+
+
+
+
+
+
+ );
+ };
+ const CalculatorResults = () => {
+ return (
+ <>
+ You will need approximately
+
+
+
+ Of Closed Captioning Minutes for 1 year
+ >
+ );
+ };
+
+ return (
+
+ {yearlyCaptionMins === 0 ? : }
+
+ );
+}
diff --git a/app/components/navigation.tsx b/app/components/navigation.tsx
new file mode 100644
index 0000000..71744be
--- /dev/null
+++ b/app/components/navigation.tsx
@@ -0,0 +1,88 @@
+import logo from "../../public/cablecast-logo.png";
+import { useState } from "react";
+
+export default function Navigation() {
+ const [navbar, setNavbar] = useState(false);
+ return (
+
+
+
+
setNavbar(!navbar)}>
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/app/root.tsx b/app/root.tsx
index 81181cc..58c58e8 100644
--- a/app/root.tsx
+++ b/app/root.tsx
@@ -1,35 +1,30 @@
import type { LinksFunction } from "@remix-run/node";
-import {
- Links,
- LiveReload,
- Meta,
- Outlet,
- Scripts,
- ScrollRestoration,
-} from "@remix-run/react";
+import { Links, LiveReload, Meta, Outlet, Scripts, ScrollRestoration } from "@remix-run/react";
import stylesheet from "~/tailwind.css";
-
export const links: LinksFunction = () => [
- { rel: "stylesheet", href: stylesheet },
+ { rel: "stylesheet", href: stylesheet },
+ { rel: "preconnect", href: "https://fonts.googleapis.com" },
+ { rel: "preconnect", href: "https://fonts.gstatic.com" },
+ { rel: "stylesheet", href: "https://fonts.googleapis.com/css2?family=Nunito+Sans:wght@300&display=swap" },
];
export default function App() {
- return (
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- );
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
}
diff --git a/app/routes/_index.tsx b/app/routes/_index.tsx
index 64163f8..b3f37da 100644
--- a/app/routes/_index.tsx
+++ b/app/routes/_index.tsx
@@ -1,23 +1,41 @@
import type { V2_MetaFunction } from "@remix-run/node";
+import ccImage from "../../public/cc-img.png";
+import Navigation from "../components/navigation";
+import Calculator from "../components/calculator";
export const meta: V2_MetaFunction = () => {
- return [
- { title: "New Remix App" },
- { name: "description", content: "Welcome to Remix!" },
- ];
+ return [
+ { title: "Captioning Calculator" },
+ {
+ name: "description",
+ content:
+ "A prototype of a closed caption calculator that can be used to capture leads for a Closed Captioning Project",
+ },
+ ];
};
export default function Index() {
- return (
-
-
Hello world!
- {/*
- // Use the form below if you wan to go remix style, or just use fetch in an event handler.
- */}
-
-
- );
+ return (
+ <>
+
+
+
CAPTIONING CALCULATOR
+
+
+
+ How many captioning minutes will you need?
+
+
+ The Cablecast Captioning Calculator is a tool to understand how many cablecast Captioning
+ Minutes you will need for a year of programming.
+
+
+
+
+
+
+
+
+ >
+ );
}
diff --git a/app/routes/api.leads.tsx b/app/routes/api.leads.tsx
index 4fa1b24..14ec039 100644
--- a/app/routes/api.leads.tsx
+++ b/app/routes/api.leads.tsx
@@ -9,16 +9,7 @@ interface Lead {
needsTranslations: boolean;
}
-export async function action( { request }: ActionArgs) {
-
- try {
- const requestJson = request.clone();
- const jsonData = await requestJson.json();
- return await handleJsonBody(jsonData);
- } catch {
- // Nothing todo here. Just try form data next.
- }
-
+export async function action({ request }: ActionArgs) {
try {
const requestFormData = request.clone();
const formData = await requestFormData.formData();
@@ -27,40 +18,33 @@ export async function action( { request }: ActionArgs) {
// Nothing todo here. We'll error before
}
- throw json({ message: "Unsupported content type. Supported values are application/json and application/x-www-form-urlencoded."}, {status: 401});
-}
-
-async function handleJsonBody(jsonData: any) {
- const lead: Lead = {
- name: jsonData.name,
- email: jsonData.email,
- averageProgramsPerMonth: jsonData.averageProgramsPerMonth,
- averageLengthOfProgramsInHours: jsonData.averageLengthOfProgramsInHours,
- needsTranslations: jsonData.needsTranslations
- };
- saveLead(lead);
- return json({json: 'ok', lead})
+ throw json(
+ {
+ message:
+ "Unsupported content type. Supported values are application/json and application/x-www-form-urlencoded.",
+ },
+ { status: 401 }
+ );
}
async function handleFormData(formData: FormData) {
- const name = formData.get('name') as string;
- const email = formData.get('email') as string;
- const averageProgramsPerMonth = Number(formData.get('averageProgramsPerMonth')) ?? 0;
- const averageLengthOfProgramsInHours = Number(formData.get('averageLengthOfProgramsInHours')) ?? 0;
- const needsTranslations = formData.get('needsTranslations') == "YES";
+ const name = formData.get("name") as string;
+ const email = formData.get("email") as string;
+ const averageProgramsPerMonth = Number(formData.get("averageProgramsPerMonth")) ?? 0;
+ const averageLengthOfProgramsInHours = Number(formData.get("averageLengthOfProgramsInHours")) ?? 0;
+ const needsTranslations = formData.get("needsTranslations") == "on";
const lead: Lead = {
name,
email,
averageProgramsPerMonth,
averageLengthOfProgramsInHours,
- needsTranslations
+ needsTranslations,
};
- await saveLead(lead);
- return redirect('/');
+ await saveLead(lead);
+ return redirect("/");
}
async function saveLead(lead: Lead) {
console.log(lead);
}
-
diff --git a/app/tailwind.css b/app/tailwind.css
index bd6213e..2e8dba0 100644
--- a/app/tailwind.css
+++ b/app/tailwind.css
@@ -1,3 +1,9 @@
@tailwind base;
@tailwind components;
-@tailwind utilities;
\ No newline at end of file
+@tailwind utilities;
+
+@layer base {
+ html {
+ font-family: "Nunito Sans", sans-serif;
+ }
+}
diff --git a/package-lock.json b/package-lock.json
index ff8abab..dcd0258 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,5 +1,5 @@
{
- "name": "web-dev-hw",
+ "name": "captioning-calculator",
"lockfileVersion": 3,
"requires": true,
"packages": {
@@ -9,9 +9,11 @@
"@remix-run/node": "^1.17.1",
"@remix-run/react": "^1.17.1",
"@remix-run/serve": "^1.17.1",
+ "@remix-validated-form/with-zod": "^2.0.6",
"isbot": "^3.6.8",
"react": "^18.2.0",
- "react-dom": "^18.2.0"
+ "react-dom": "^18.2.0",
+ "remix-validated-form": "^5.0.2"
},
"devDependencies": {
"@remix-run/dev": "^1.17.1",
@@ -3012,6 +3014,15 @@
"web-streams-polyfill": "^3.1.1"
}
},
+ "node_modules/@remix-validated-form/with-zod": {
+ "version": "2.0.6",
+ "resolved": "https://registry.npmjs.org/@remix-validated-form/with-zod/-/with-zod-2.0.6.tgz",
+ "integrity": "sha512-i8H0PPFSSKIMGPVO/8cUMO1QoGa2bBQZb6RH3DoXGVE1heu52d1vwrFVsYYQB8Vc8lp5BGQk1kbxZuN9RzH1OA==",
+ "peerDependencies": {
+ "remix-validated-form": "^4.x || ^5.x",
+ "zod": "^3.11.x"
+ }
+ },
"node_modules/@rollup/pluginutils": {
"version": "4.2.1",
"resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-4.2.1.tgz",
@@ -7488,6 +7499,15 @@
"node": ">= 4"
}
},
+ "node_modules/immer": {
+ "version": "9.0.21",
+ "resolved": "https://registry.npmjs.org/immer/-/immer-9.0.21.tgz",
+ "integrity": "sha512-bc4NBHqOqSfRW7POMkHd51LvClaeMXpm8dx0e8oE2GORbq5aRK7Bxl4FyzVLdGtLmvLKL7BTDBG5ACQm4HWjTA==",
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/immer"
+ }
+ },
"node_modules/import-fresh": {
"version": "3.3.0",
"resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.0.tgz",
@@ -8369,6 +8389,11 @@
"integrity": "sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==",
"dev": true
},
+ "node_modules/lodash.get": {
+ "version": "4.4.2",
+ "resolved": "https://registry.npmjs.org/lodash.get/-/lodash.get-4.4.2.tgz",
+ "integrity": "sha512-z+Uw/vLuy6gQe8cfaFWD7p0wVv8fJl3mbzXh33RS+0oW2wvUqiRXiQ69gLWSLpgB5/6sU+r6BlQR0MBILadqTQ=="
+ },
"node_modules/lodash.merge": {
"version": "4.6.2",
"resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz",
@@ -9589,7 +9614,6 @@
"version": "3.3.6",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.6.tgz",
"integrity": "sha512-BGcqMMJuToF7i1rt+2PWSNVnWIkGCU78jBG3RxO/bZlnZPK2Cmi2QaffxGO/2RvWi9sL+FAiRiXMgsyxQ1DIDA==",
- "dev": true,
"funding": [
{
"type": "github",
@@ -10993,6 +11017,29 @@
"url": "https://opencollective.com/unified"
}
},
+ "node_modules/remeda": {
+ "version": "1.23.0",
+ "resolved": "https://registry.npmjs.org/remeda/-/remeda-1.23.0.tgz",
+ "integrity": "sha512-1y0jygsAc3opoFQW5BtA/QYOboai0u5IwdvwtbRAd1eJ2D9NmvZpDfV819LdSmrIQ0LONbp/dE9Uo/rGxUshPw=="
+ },
+ "node_modules/remix-validated-form": {
+ "version": "5.0.2",
+ "resolved": "https://registry.npmjs.org/remix-validated-form/-/remix-validated-form-5.0.2.tgz",
+ "integrity": "sha512-jM3uuvCP6AO9G117MAEGgTb3x/aOy5qVrOM85XdDhWnpJFt7WC6u1gBQ/tSd1UBCTxDSFwU6TZPCJex73D9rjQ==",
+ "dependencies": {
+ "immer": "^9.0.12",
+ "lodash.get": "^4.4.2",
+ "nanoid": "3.3.6",
+ "remeda": "^1.2.0",
+ "tiny-invariant": "^1.2.0",
+ "zustand": "^4.3.0"
+ },
+ "peerDependencies": {
+ "@remix-run/react": ">= 1.15.0",
+ "@remix-run/server-runtime": "1.x",
+ "react": "^17.0.2 || ^18.0.0"
+ }
+ },
"node_modules/require-like": {
"version": "0.1.2",
"resolved": "https://registry.npmjs.org/require-like/-/require-like-0.1.2.tgz",
@@ -12068,6 +12115,11 @@
"safe-buffer": "~5.1.0"
}
},
+ "node_modules/tiny-invariant": {
+ "version": "1.3.1",
+ "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.1.tgz",
+ "integrity": "sha512-AD5ih2NlSssTCwsMznbvwMZpJ1cbhkGd2uueNxzv2jDlEeZdU04JQfRnggJQ8DrcVBGjAsCKwFBbDlVNtEMlzw=="
+ },
"node_modules/titleize": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/titleize/-/titleize-3.0.0.tgz",
@@ -12546,6 +12598,14 @@
"punycode": "^2.1.0"
}
},
+ "node_modules/use-sync-external-store": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.2.0.tgz",
+ "integrity": "sha512-eEgnFxGQ1Ife9bzYs6VLi8/4X6CObHMw9Qr9tPY43iKwsPw8xE8+EFsf/2cFZ5S3esXgpWgtSCtLNS41F+sKPA==",
+ "peerDependencies": {
+ "react": "^16.8.0 || ^17.0.0 || ^18.0.0"
+ }
+ },
"node_modules/util": {
"version": "0.12.5",
"resolved": "https://registry.npmjs.org/util/-/util-0.12.5.tgz",
@@ -12993,6 +13053,38 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
+ "node_modules/zod": {
+ "version": "3.21.4",
+ "resolved": "https://registry.npmjs.org/zod/-/zod-3.21.4.tgz",
+ "integrity": "sha512-m46AKbrzKVzOzs/DZgVnG5H55N1sv1M8qZU3A8RIKbs3mrACDNeIOeilDymVb2HdmP8uwshOCF4uJ8uM9rCqJw==",
+ "peer": true,
+ "funding": {
+ "url": "https://github.com/sponsors/colinhacks"
+ }
+ },
+ "node_modules/zustand": {
+ "version": "4.3.8",
+ "resolved": "https://registry.npmjs.org/zustand/-/zustand-4.3.8.tgz",
+ "integrity": "sha512-4h28KCkHg5ii/wcFFJ5Fp+k1J3gJoasaIbppdgZFO4BPJnsNxL0mQXBSFgOgAdCdBj35aDTPvdAJReTMntFPGg==",
+ "dependencies": {
+ "use-sync-external-store": "1.2.0"
+ },
+ "engines": {
+ "node": ">=12.7.0"
+ },
+ "peerDependencies": {
+ "immer": ">=9.0",
+ "react": ">=16.8"
+ },
+ "peerDependenciesMeta": {
+ "immer": {
+ "optional": true
+ },
+ "react": {
+ "optional": true
+ }
+ }
+ },
"node_modules/zwitch": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/zwitch/-/zwitch-2.0.4.tgz",
diff --git a/package.json b/package.json
index 376c099..4866b68 100644
--- a/package.json
+++ b/package.json
@@ -13,9 +13,11 @@
"@remix-run/node": "^1.17.1",
"@remix-run/react": "^1.17.1",
"@remix-run/serve": "^1.17.1",
+ "@remix-validated-form/with-zod": "^2.0.6",
"isbot": "^3.6.8",
"react": "^18.2.0",
- "react-dom": "^18.2.0"
+ "react-dom": "^18.2.0",
+ "remix-validated-form": "^5.0.2"
},
"devDependencies": {
"@remix-run/dev": "^1.17.1",
diff --git a/public/cablecast-logo.png b/public/cablecast-logo.png
new file mode 100644
index 0000000..4c61601
Binary files /dev/null and b/public/cablecast-logo.png differ
diff --git a/public/cc-img.png b/public/cc-img.png
new file mode 100644
index 0000000..eee29fa
Binary files /dev/null and b/public/cc-img.png differ
diff --git a/public/favicon.ico b/public/favicon.ico
index 8830cf6..8a70edd 100644
Binary files a/public/favicon.ico and b/public/favicon.ico differ
diff --git a/tailwind.config.ts b/tailwind.config.ts
index 3a1b56f..734e4af 100644
--- a/tailwind.config.ts
+++ b/tailwind.config.ts
@@ -1,10 +1,17 @@
-import type { Config } from 'tailwindcss'
+import type { Config } from "tailwindcss";
export default {
- content: ['./app/**/*.{js,jsx,ts,tsx}'],
- theme: {
- extend: {},
- },
- plugins: [],
-} satisfies Config
-
+ content: ["./app/**/*.{js,jsx,ts,tsx}"],
+ theme: {
+ extend: {
+ colors: {
+ "dark-grey": "#535B6F",
+ "cta-blue": "#2F87CA",
+ "logo-green": "#26A35C",
+ "light-grey": "#EDEEF1",
+ "medium-grey": "#8E98A9",
+ },
+ },
+ },
+ plugins: [],
+} satisfies Config;
diff --git a/tsconfig.json b/tsconfig.json
index 20f8a38..ecae7d6 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -1,22 +1,23 @@
{
- "include": ["remix.env.d.ts", "**/*.ts", "**/*.tsx"],
- "compilerOptions": {
- "lib": ["DOM", "DOM.Iterable", "ES2019"],
- "isolatedModules": true,
- "esModuleInterop": true,
- "jsx": "react-jsx",
- "moduleResolution": "node",
- "resolveJsonModule": true,
- "target": "ES2019",
- "strict": true,
- "allowJs": true,
- "forceConsistentCasingInFileNames": true,
- "baseUrl": ".",
- "paths": {
- "~/*": ["./app/*"]
- },
+ "include": ["remix.env.d.ts", "**/*.ts", "**/*.tsx"],
+ "compilerOptions": {
+ "lib": ["DOM", "DOM.Iterable", "ES2019"],
+ "noImplicitAny": false,
+ "isolatedModules": true,
+ "esModuleInterop": true,
+ "jsx": "react-jsx",
+ "moduleResolution": "node",
+ "resolveJsonModule": true,
+ "target": "ES2019",
+ "strict": true,
+ "allowJs": true,
+ "forceConsistentCasingInFileNames": true,
+ "baseUrl": ".",
+ "paths": {
+ "~/*": ["./app/*"]
+ },
- // Remix takes care of building everything in `remix build`.
- "noEmit": true
- }
+ // Remix takes care of building everything in `remix build`.
+ "noEmit": true
+ }
}