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 ( + + ); +}; + +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 ( +
+ + + {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 ( + + ); +} 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. +

+
+
+ closed captioning image +
+
+
+ + + ); } 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 + } }