From f38a33b74e329272e4000558371b6671b3d04c98 Mon Sep 17 00:00:00 2001 From: Morgan Liggett <91029038+morgan-lig@users.noreply.github.com> Date: Tue, 20 Aug 2024 11:27:05 -0600 Subject: [PATCH 01/12] Initial commit --- README.md | 81 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 81 insertions(+) create mode 100644 README.md diff --git a/README.md b/README.md new file mode 100644 index 0000000..4f2c8f4 --- /dev/null +++ b/README.md @@ -0,0 +1,81 @@ +# Exodus Full-Stack Code Challenge + +## Project + +The purpose of this project is to create a small custodial wallet app that demos an end-to-end purchase of bitcoin with fiat. + +Work on this challenge as you would work as a member of a development team. Consider the other devs that will review your code as your teammates in this project. + +Please **do not** work more than 8 hours on this without checking in with us to see if you're on the right path. We don't want to waste your time. + +### Signup Flow + +- The user can sign up for an account with an email and password (no need to verify their email) +- During sign-up, the user must also provide some basic personal information (e.g., their name) +- The user should be able to authenticate using the provided email address +- The user must then link their bank account via [Plaid](https://plaid.com) (in sandbox mode) +- The backend generates a bitcoin address for the user (each user has a single address) + +### Purchase Flow + +- An authenticated user that has linked their bank account should be allowed to purchase bitcoin (you will need to [set up a regtest bitcoin node](https://developer.bitcoin.org/examples/testing.html#regtest-mode) locally) +- The user should receive regtest bitcoin via a bitcoin transaction and see their balance update +- Do NOT worry about charging the user’s fake bank account, just connecting it is enough for the purpose of this exercise + +### Bonus Track + +_Note: [code quality](#code-guidelines) in the required features is more important than completing the bonus track!_ + +- Passwordless authentication using [passkeys](https://passkeys.dev) instead of email and password +- The user should NOT be able to purchase more bitcoin than what they can purchase with their current bank account balance (essentially, do a balance check before allowing them to purchase) +- The user can see their bank accounts with their corresponding balance, and chose which one to debit for the bitcoin purchase +- The user is automatically logged in after creating their account + +## Rules + +- Create either a web app with React or a mobile app with React Native +- Use [Plaid](https://plaid.com) to allow the user to connect their bank account +- Use Node.js for any backend development +- You can use ANY open source npm library + +## Deployment + +You will get bonus points if you deploy your working project to an infrastructure provider of your choice. We recommend either [Heroku](https://www.heroku.com), [Netlify](https://www.netlify.com), or [Vercel](https://vercel.com). If you need to, you can mirror this repo in a **private** repo in your GitHub account, so that you can connect and authorize these services there. + +If you don't have time to deploy, please record a quick 1-5 minute demo on `localhost` of the flows you were able to implement. + +## Resources + +- If you're not familiar with regtest, here’s a good [intro](https://bitcoin.stackexchange.com/questions/70156/difference-between-regtest-and-testnet) +- If you've never set up your own bitcoin regtest node, here’s a good [tutorial](https://gist.github.com/olistic/2d5e303da5eb9ff4fa5f794b39171842) for setting it up + +## Code Guidelines + +Please refer to this [document](https://github.com/ExodusMovementInterviews/exodus-hiring-code-guidelines) for things we'd like to see (and will be looking for) in your code. + +__Please add project setup instructions in README.md.__ + +## FAQs + +### Can I code the solution in Typescript? +No, please use only JavaScript. Although we use TypeScript for some projects internally, code challenges in JavaScript give us more flexibility when it comes to reviewing your code. + +### Can I start right away? +Whatever works best for you. However, we don’t expect you to start until you have received your BTC payment. + +### I have already spent 8 hours working on the code challenge, and it’s still a work in progress. What should I do? +1. Commit and push your work as it will help us know if you are on the right path +2. Reach out to our recruitment team (or the one who sent you the code challenge) and let them know about it +3. Our recruiter will get back to you at the earliest - letting you know whether adding more efforts to the code challenge is worth your time + +### Can I add unit tests to my solution? +Yes, bonus points for these. + +### I am stuck. What should I do? +Please reach out to our recruiter, and we will try our best to help you. + +If you still have questions, don't hesitate to ask your recruiter! + +### If I'm using pull requests, should I merge them to master? + +Please do. From 830efeec91250ff0fb8414fd1acaf2eceef31cb1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Qwerty=20=28V=C3=ADt=C4=9Bzslav=20Ackermann=20Ferko=29?= Date: Thu, 29 Aug 2024 12:22:27 +0200 Subject: [PATCH 02/12] Implement custom cursors --- .prettierignore | 2 + package.json | 4 +- src/components/Atoms/cursors.tsx | 76 ++++++++++++++++++++++++++++++++ src/components/Atoms/index.ts | 1 + src/hooks/index.ts | 1 + src/hooks/useCursor.tsx | 65 +++++++++++++++++++++++++++ src/pages/_app.tsx | 6 +++ src/styles/globals.css | 5 +++ 8 files changed, 158 insertions(+), 2 deletions(-) create mode 100644 .prettierignore create mode 100644 src/components/Atoms/cursors.tsx create mode 100644 src/hooks/index.ts create mode 100644 src/hooks/useCursor.tsx diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 0000000..b6271b6 --- /dev/null +++ b/.prettierignore @@ -0,0 +1,2 @@ +# files with special formatting +src/hooks/useCursor.tsx diff --git a/package.json b/package.json index a91e276..2031b03 100644 --- a/package.json +++ b/package.json @@ -13,8 +13,8 @@ "start": "next start", "lint": "next lint", "lint:fix": "next lint --fix", - "format": "prettier --check --ignore-path .gitignore .", - "format:fix": "prettier --write --ignore-path .gitignore .", + "format": "prettier --check --ignore-path .gitignore --ignore-path .prettierignore .", + "format:fix": "pnpm format --write", "fix": "pnpm format:fix && pnpm lint:fix", "types": "tsc --noEmit --incremental", "types:watch": "tsc --noEmit --incremental --watch" diff --git a/src/components/Atoms/cursors.tsx b/src/components/Atoms/cursors.tsx new file mode 100644 index 0000000..3ef594c --- /dev/null +++ b/src/components/Atoms/cursors.tsx @@ -0,0 +1,76 @@ +/* Icons https://lucide.dev/icons/ */ +/* Brand icons https://simpleicons.org/ */ +/* SVGs https://thenounproject.com/ */ + +import React, { type RefObject } from 'react' +import { twMerge } from 'tailwind-merge' + +interface CursorProps { + id?: string + className?: string + forwardRef?: RefObject +} + +export const HandClickCursor: React.FC = ({ className, forwardRef, ...props }) => ( + + + + + + + + + + +) + +export const HandCursor: React.FC = ({ className, forwardRef, ...props }) => ( + + + +) + +export const Cursor: React.FC = ({ className, forwardRef, ...props }) => ( + + + +) diff --git a/src/components/Atoms/index.ts b/src/components/Atoms/index.ts index 826dac4..a689776 100644 --- a/src/components/Atoms/index.ts +++ b/src/components/Atoms/index.ts @@ -1 +1,2 @@ export * from './icons' +export * from './cursors' diff --git a/src/hooks/index.ts b/src/hooks/index.ts new file mode 100644 index 0000000..0cdf562 --- /dev/null +++ b/src/hooks/index.ts @@ -0,0 +1 @@ +export { useCursor } from './useCursor' diff --git a/src/hooks/useCursor.tsx b/src/hooks/useCursor.tsx new file mode 100644 index 0000000..3aa960b --- /dev/null +++ b/src/hooks/useCursor.tsx @@ -0,0 +1,65 @@ +import { useEffect, useRef } from 'react' + +import { Cursor, HandCursor, HandClickCursor } from '@/components' + +/** + * Move a container with svgs to current mouse position to simulate custom cursors. + * + * Note: Cursors must have correct size and be aligned to the tip of the actual cursor, including correct rotation. + * To enable default cursor see globals.css `cursor: none`. + * + * TODO: disable on mobile + */ + +export function useCursor() { + const cursorsContainer = useRef(null) + + /* Position the container on "mousemove" event. */ + + useEffect(() => { + let activeCursor: 0 | 1 | 2 = 0 + + const listener = (event: MouseEvent) => { + const { x, y } = { x: event.clientX, y: event.clientY } + + const path = event.composedPath?.() ?? (event as MouseEvent & { path: Node[] }).path + + if (path.some((item) => (item as HTMLElement).classList?.contains('cursor-pointer'))) activeCursor = 1 + else if (path.some((item) => (item as HTMLElement).classList?.contains('cursor-click'))) activeCursor = 2 + else activeCursor = 0 + + if (cursorsContainer.current) { + cursorsContainer.current.style.transform = `translate(${x}px, ${y}px)` + cursorsContainer.current.dataset.cursor = activeCursor.toString() + } + } + + window.addEventListener('mousemove', listener) + return () => void window.removeEventListener('mousemove', listener) + }, []) + + /* Hide the cursors when leaving the page with "mouseleave" event. */ + + useEffect(() => { + const listener = function (event: MouseEvent) { + if ( + /**/event.clientY <= 0 + || event.clientX <= 0 + || event.clientX >= window.innerWidth + || event.clientY >= window.innerHeight + ) + if (cursorsContainer.current) cursorsContainer.current.dataset.cursor = '' + } + + document.addEventListener('mouseleave', listener) + return () => void document.removeEventListener('mouseleave', listener) + }, []) + + return ( +
+ + + +
+ ) +} diff --git a/src/pages/_app.tsx b/src/pages/_app.tsx index f892ccf..296d4f6 100644 --- a/src/pages/_app.tsx +++ b/src/pages/_app.tsx @@ -4,11 +4,14 @@ import type { AppProps } from 'next/app' import { Inter } from 'next/font/google' import Head from 'next/head' +import { useCursor } from '@/hooks' import { Navigation } from '@/components' const inter = Inter({ subsets: ['latin'] }) export default function App({ Component, pageProps }: AppProps) { + const cursors = useCursor() + useLayoutEffect(() => { document.getElementById('RootElement')?.classList.add('bg' + (((Math.random() * 7) | 0) + 1)) }, []) @@ -21,6 +24,9 @@ export default function App({ Component, pageProps }: AppProps) { + + {cursors} + ) diff --git a/src/styles/globals.css b/src/styles/globals.css index 17d00a3..e136fd5 100644 --- a/src/styles/globals.css +++ b/src/styles/globals.css @@ -26,6 +26,11 @@ --background-opacity: 0.4; } +body, +body * { + cursor: none !important; +} + body { color: rgb(var(--foreground-rgb)); background-color: rgb(var(--background-color)); From 10238fdbb64de7415b275b6924d29dda9d72d240 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Qwerty=20=28V=C3=ADt=C4=9Bzslav=20Ackermann=20Ferko=29?= Date: Thu, 29 Aug 2024 12:57:27 +0200 Subject: [PATCH 03/12] Fix issue with eslint-typescript --- .eslintrc.js | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/.eslintrc.js b/.eslintrc.js index af5be83..7fbdcb7 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -1,6 +1,16 @@ module.exports = { + root: true, extends: ['next', 'next/core-web-vitals', 'prettier'], plugins: ['@typescript-eslint'], + settings: { + next: { + rootDir: '.', + }, + }, + parserOptions: { + project: ['tsconfig.json'], + }, + ignorePatterns: ['.eslintrc.js'], rules: { 'comma-dangle': ['error', 'always-multiline'], 'no-shadow': 'warn', From 6bba73700dcfcf601920dbcc377a4c278697b46e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Qwerty=20=28V=C3=ADt=C4=9Bzslav=20Ackermann=20Ferko=29?= Date: Thu, 29 Aug 2024 14:20:57 +0200 Subject: [PATCH 04/12] Add SignUp page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Implement Form and tweak styles - Add Block and Input components - Change Navigation options - Add useToggle and useFormState hooks - Add new cursor-text - Add global types - Add promise utils - Prettier 💩 --- .prettierignore | 3 + src/components/Atoms/Block.tsx | 56 ++++++ src/components/Atoms/Input.tsx | 216 ++++++++++++++++++++++++ src/components/Atoms/cursors.tsx | 24 +++ src/components/Atoms/index.ts | 1 + src/components/Organisms/Navigation.tsx | 91 ++++++---- src/global.d.ts | 55 ++++++ src/hooks/index.ts | 2 + src/hooks/useCursor.tsx | 10 +- src/hooks/useFormState.ts | 154 +++++++++++++++++ src/hooks/useToggle.ts | 64 +++++++ src/pages/index.tsx | 6 +- src/pages/signup.tsx | 86 ++++++++++ src/styles/globals.css | 10 ++ src/utils/index.ts | 4 + 15 files changed, 743 insertions(+), 39 deletions(-) create mode 100644 src/components/Atoms/Block.tsx create mode 100644 src/components/Atoms/Input.tsx create mode 100644 src/global.d.ts create mode 100644 src/hooks/useFormState.ts create mode 100644 src/hooks/useToggle.ts create mode 100644 src/pages/signup.tsx create mode 100644 src/utils/index.ts diff --git a/.prettierignore b/.prettierignore index b6271b6..7d513a8 100644 --- a/.prettierignore +++ b/.prettierignore @@ -1,2 +1,5 @@ # files with special formatting src/hooks/useCursor.tsx +src/hooks/useFormState.ts +src/hooks/useToggle.ts +src/global.d.ts diff --git a/src/components/Atoms/Block.tsx b/src/components/Atoms/Block.tsx new file mode 100644 index 0000000..e27071b --- /dev/null +++ b/src/components/Atoms/Block.tsx @@ -0,0 +1,56 @@ +import { type CSSProperties } from 'react' +import { twMerge } from 'tailwind-merge' + +export type BlockProps = { + id?: string + borderStyle?: 'solid' | 'gradient' + className?: string + onClick?: React.MouseEventHandler +} + +/** + * This Block component adds standard padding and rounded borders. + */ +export const Block: React.FC> = ({ + id = 'Block', + className: className_ = '', + borderStyle = 'solid', + children, + onClick, +}) => { + const className = [classNames.base, 'rounded-md', classNames.borderStyle[borderStyle]].join(' ') + + return ( +
+ {children} +
+ ) +} + +const classNames = { + base: [ + 'w-[600px] mx-auto', + 'p-6 md:p-8', + 'flex flex-col', + 'border border-stone-600 shadow-lg bg-stone-950', + '[--block-bg-color:theme("colors.stone.950")]', + ].join(' '), + borderStyle: { + solid: '', + gradient: '!border-transparent [background:padding-box_var(--block-bg-content),border-box_var(--block-bg-border)]', + }, + radius: { + sm: 'rounded-sm', + md: 'rounded-md', + }, +} as const diff --git a/src/components/Atoms/Input.tsx b/src/components/Atoms/Input.tsx new file mode 100644 index 0000000..1d651f1 --- /dev/null +++ b/src/components/Atoms/Input.tsx @@ -0,0 +1,216 @@ +import { useMemo, type HTMLInputTypeAttribute } from 'react' +import { twMerge } from 'tailwind-merge' + +//@ts-ignore +export const Input: FormInput> = ({ + id, + name, + value = '', + label = '', + labelVariant = 'caption', + rightLabel = '', + align = 'left', + type = 'text', + placeholder = '', + error = '', + autoFocus = false, + min, + max, + maxLength, + maxDecimalLength = 0, + rows, + readOnly = false, + disabled, + className = '', + onChange, + onBlur, + onMouseEnter, + onMouseLeave, +}) => { + const inputProps = { + id, + name, + autoFocus, + placeholder: String(placeholder || ''), + maxLength, + readOnly, + disabled, + onBlur, + onMouseEnter, + onMouseLeave, + min, + max, + } + + const isTextArea = type === 'textarea' && rows !== undefined + + const inputWrapperClassName = useMemo( + () => + twMerge( + 'w-full flex gap-4', + !readOnly && getBorderStyles(!isTextArea ? 'input' : 'textarea'), + !readOnly && + 'hover:border-stone-400 focus-within:border-stone-500 border-stone-600 disabled:border-stone-800:disabled', + ), + [readOnly, isTextArea], + ) + + const inputClassName = useMemo( + () => + twMerge( + textAlignments[align], + 'bg-transparent outline-none w-full', + readOnly && '!min-h-fit', + 'pb-2', + // padding + getSpacingStyles(!isTextArea ? 'input' : 'textarea', readOnly), + // text style + 'font-extralight tracking-body', + 'text-stone-200 disabled:text-stone-400', + // placeholder text style + 'placeholder:text-stone-400 placeholder:disabled:text-stone-500', + ), + [align, isTextArea, readOnly], + ) + + const handleChange = (event: React.ChangeEvent) => { + if (type === 'number') { + const decimalDigits = event.target.value?.split('.') || [] + if (maxDecimalLength !== 0 && (decimalDigits?.[1]?.length ?? 0) > maxDecimalLength) { + return + } + } + onChange?.(event) + } + + const suffixSection = useMemo(() => { + if (!rightLabel) return <> + + switch (typeof rightLabel) { + case 'string': + case 'number': + return
{rightLabel}
+ default: + return rightLabel + } + }, [rightLabel]) + + return ( +
+ {label &&
{label}
} +
+ {!isTextArea ? ( + + ) : ( +