├── .dockerignore ├── public └── favicon.ico ├── .gitignore ├── app ├── interfaces.ts ├── routes.ts ├── app.css ├── routes │ ├── home.tsx │ └── dashboard.tsx ├── root.tsx ├── Login │ └── login.tsx └── UserPage │ └── userPage.tsx ├── react-router.config.ts ├── vite.config.ts ├── Dockerfile ├── tsconfig.json ├── package.json ├── README.md └── README copy.md /.dockerignore: -------------------------------------------------------------------------------- 1 | .react-router 2 | build 3 | node_modules 4 | README.md -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Big-Silver/ortex-technical-test/main/public/favicon.ico -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | .env 3 | /node_modules/ 4 | 5 | # React Router 6 | /.react-router/ 7 | /build/ 8 | -------------------------------------------------------------------------------- /app/interfaces.ts: -------------------------------------------------------------------------------- 1 | interface IUser { 2 | id: number; 3 | email: string; 4 | password: string; 5 | } 6 | export { type IUser }; 7 | -------------------------------------------------------------------------------- /app/routes.ts: -------------------------------------------------------------------------------- 1 | import { type RouteConfig, index, route } from "@react-router/dev/routes"; 2 | 3 | export default [index("routes/home.tsx"), route("dashboard", "routes/dashboard.tsx"),] satisfies RouteConfig; 4 | -------------------------------------------------------------------------------- /react-router.config.ts: -------------------------------------------------------------------------------- 1 | import type { Config } from "@react-router/dev/config"; 2 | 3 | export default { 4 | // Config options... 5 | // Server-side render by default, to enable SPA mode set this to `false` 6 | ssr: true, 7 | } satisfies Config; 8 | -------------------------------------------------------------------------------- /vite.config.ts: -------------------------------------------------------------------------------- 1 | import { reactRouter } from "@react-router/dev/vite"; 2 | import tailwindcss from "@tailwindcss/vite"; 3 | import { defineConfig } from "vite"; 4 | import tsconfigPaths from "vite-tsconfig-paths"; 5 | 6 | export default defineConfig({ 7 | plugins: [tailwindcss(), reactRouter(), tsconfigPaths()], 8 | }); 9 | -------------------------------------------------------------------------------- /app/app.css: -------------------------------------------------------------------------------- 1 | @import "tailwindcss"; 2 | 3 | @theme { 4 | --font-sans: "Inter", ui-sans-serif, system-ui, sans-serif, 5 | "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji"; 6 | } 7 | 8 | html, 9 | body { 10 | @apply bg-white dark:bg-gray-950; 11 | 12 | @media (prefers-color-scheme: dark) { 13 | color-scheme: dark; 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /app/routes/home.tsx: -------------------------------------------------------------------------------- 1 | import type { Route } from "./+types/home"; 2 | import { Login } from "../Login/login"; 3 | 4 | export function meta({}: Route.MetaArgs) { 5 | return [ 6 | { title: "New React Router App" }, 7 | { name: "description", content: "Welcome to React Router!" }, 8 | ]; 9 | } 10 | 11 | export default function Home() { 12 | return ; 13 | } 14 | -------------------------------------------------------------------------------- /app/routes/dashboard.tsx: -------------------------------------------------------------------------------- 1 | import type { Route } from "./+types/home"; 2 | import { UserPage } from "../UserPage/userPage"; 3 | 4 | export function meta({ }: Route.MetaArgs) { 5 | return [ 6 | { title: "New React Router App" }, 7 | { name: "description", content: "Welcome to React Router!" }, 8 | ]; 9 | } 10 | 11 | export default function Dashboard() { 12 | return ; 13 | } -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:20-alpine AS development-dependencies-env 2 | COPY . /app 3 | WORKDIR /app 4 | RUN npm ci 5 | 6 | FROM node:20-alpine AS production-dependencies-env 7 | COPY ./package.json package-lock.json /app/ 8 | WORKDIR /app 9 | RUN npm ci --omit=dev 10 | 11 | FROM node:20-alpine AS build-env 12 | COPY . /app/ 13 | COPY --from=development-dependencies-env /app/node_modules /app/node_modules 14 | WORKDIR /app 15 | RUN npm run build 16 | 17 | FROM node:20-alpine 18 | COPY ./package.json package-lock.json /app/ 19 | COPY --from=production-dependencies-env /app/node_modules /app/node_modules 20 | COPY --from=build-env /app/build /app/build 21 | WORKDIR /app 22 | CMD ["npm", "run", "start"] -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "include": [ 3 | "**/*", 4 | "**/.server/**/*", 5 | "**/.client/**/*", 6 | ".react-router/types/**/*" 7 | ], 8 | "compilerOptions": { 9 | "lib": ["DOM", "DOM.Iterable", "ES2022"], 10 | "types": ["node", "vite/client"], 11 | "target": "ES2022", 12 | "module": "ES2022", 13 | "moduleResolution": "bundler", 14 | "jsx": "react-jsx", 15 | "rootDirs": [".", "./.react-router/types"], 16 | "baseUrl": ".", 17 | "paths": { 18 | "~/*": ["./app/*"] 19 | }, 20 | "esModuleInterop": true, 21 | "verbatimModuleSyntax": true, 22 | "noEmit": true, 23 | "resolveJsonModule": true, 24 | "skipLibCheck": true, 25 | "strict": true 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "ortex-technical-test", 3 | "private": true, 4 | "type": "module", 5 | "scripts": { 6 | "build": "react-router build", 7 | "dev": "react-router dev --host", 8 | "start": "react-router-serve ./build/server/index.js", 9 | "typecheck": "react-router typegen && tsc" 10 | }, 11 | "dependencies": { 12 | "@react-router/node": "7.10.1", 13 | "@react-router/serve": "7.10.1", 14 | "isbot": "^5.1.31", 15 | "react": "^19.2.3", 16 | "react-dom": "^19.2.3", 17 | "react-router": "7.10.1", 18 | "react-router-dom": "^7.10.1" 19 | }, 20 | "devDependencies": { 21 | "@react-router/dev": "7.10.1", 22 | "@tailwindcss/vite": "^4.1.13", 23 | "@types/node": "^22", 24 | "@types/react": "^19.2.7", 25 | "@types/react-dom": "^19.2.3", 26 | "tailwindcss": "^4.1.13", 27 | "typescript": "^5.9.2", 28 | "vite": "^7.1.7", 29 | "vite-tsconfig-paths": "^5.1.4" 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Ortex Technical Test 2 | 3 | A modern, production-ready template for building full-stack React applications using React Router. 4 | 5 | ## Getting Started 6 | 7 | ### Installation 8 | 9 | Install the dependencies: 10 | 11 | ```bash 12 | npm install 13 | ``` 14 | 15 | ### Development 16 | 17 | Start the development server with HMR: 18 | 19 | ```bash 20 | npm run dev 21 | ``` 22 | 23 | Your application will be available at `http://localhost:5173`. 24 | 25 | ## Building for Production 26 | 27 | Create a production build: 28 | 29 | ```bash 30 | npm run build 31 | ``` 32 | 33 | ## Deployment 34 | 35 | ### Docker Deployment 36 | 37 | To build and run using Docker: 38 | 39 | ```bash 40 | docker build -t ortex-technical-test . 41 | 42 | # Run the container 43 | docker run -p 3000:3000 ortex-technical-test 44 | ``` 45 | 46 | The containerized application can be deployed to any platform that supports Docker, including: 47 | 48 | - AWS ECS 49 | - Google Cloud Run 50 | - Azure Container Apps 51 | - Digital Ocean App Platform 52 | - Fly.io 53 | - Railway 54 | 55 | ### DIY Deployment 56 | 57 | If you're familiar with deploying Node applications, the built-in app server is production-ready. 58 | 59 | Make sure to deploy the output of `npm run build` 60 | 61 | ``` 62 | ├── package.json 63 | ├── package-lock.json (or pnpm-lock.yaml, or bun.lockb) 64 | ├── build/ 65 | │ ├── client/ # Static assets 66 | │ └── server/ # Server-side code 67 | ``` 68 | 69 | ## Styling 70 | 71 | This template comes with [Tailwind CSS](https://tailwindcss.com/) already configured for a simple default starting experience. You can use whatever CSS framework you prefer. 72 | 73 | --- 74 | 75 | Built with ❤️ using React Router. 76 | -------------------------------------------------------------------------------- /README copy.md: -------------------------------------------------------------------------------- 1 | # Ortex Technical Test 2 | 3 | A modern, production-ready template for building full-stack React applications using React Router. 4 | 5 | ## Getting Started 6 | 7 | ### Installation 8 | 9 | Install the dependencies: 10 | 11 | ```bash 12 | npm install 13 | ``` 14 | 15 | ### Development 16 | 17 | Start the development server with HMR: 18 | 19 | ```bash 20 | npm run dev 21 | ``` 22 | 23 | Your application will be available at `http://localhost:5173`. 24 | 25 | ## Building for Production 26 | 27 | Create a production build: 28 | 29 | ```bash 30 | npm run build 31 | ``` 32 | 33 | ## Deployment 34 | 35 | ### Docker Deployment 36 | 37 | To build and run using Docker: 38 | 39 | ```bash 40 | docker build -t ortex-technical-test . 41 | 42 | # Run the container 43 | docker run -p 3000:3000 ortex-technical-test 44 | ``` 45 | 46 | The containerized application can be deployed to any platform that supports Docker, including: 47 | 48 | - AWS ECS 49 | - Google Cloud Run 50 | - Azure Container Apps 51 | - Digital Ocean App Platform 52 | - Fly.io 53 | - Railway 54 | 55 | ### DIY Deployment 56 | 57 | If you're familiar with deploying Node applications, the built-in app server is production-ready. 58 | 59 | Make sure to deploy the output of `npm run build` 60 | 61 | ``` 62 | ├── package.json 63 | ├── package-lock.json (or pnpm-lock.yaml, or bun.lockb) 64 | ├── build/ 65 | │ ├── client/ # Static assets 66 | │ └── server/ # Server-side code 67 | ``` 68 | 69 | ## Styling 70 | 71 | This template comes with [Tailwind CSS](https://tailwindcss.com/) already configured for a simple default starting experience. You can use whatever CSS framework you prefer. 72 | 73 | --- 74 | 75 | Built with ❤️ using React Router. 76 | -------------------------------------------------------------------------------- /app/root.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | isRouteErrorResponse, 3 | Links, 4 | Meta, 5 | Outlet, 6 | Scripts, 7 | ScrollRestoration, 8 | } from "react-router"; 9 | 10 | import type { Route } from "./+types/root"; 11 | import "./app.css"; 12 | 13 | export const links: Route.LinksFunction = () => [ 14 | { rel: "preconnect", href: "https://fonts.googleapis.com" }, 15 | { 16 | rel: "preconnect", 17 | href: "https://fonts.gstatic.com", 18 | crossOrigin: "anonymous", 19 | }, 20 | { 21 | rel: "stylesheet", 22 | href: "https://fonts.googleapis.com/css2?family=Inter:ital,opsz,wght@0,14..32,100..900;1,14..32,100..900&display=swap", 23 | }, 24 | ]; 25 | 26 | export function Layout({ children }: { children: React.ReactNode }) { 27 | return ( 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | {children} 37 | 38 | 39 | 40 | 41 | ); 42 | } 43 | 44 | export default function App() { 45 | return ; 46 | } 47 | 48 | export function ErrorBoundary({ error }: Route.ErrorBoundaryProps) { 49 | let message = "Oops!"; 50 | let details = "An unexpected error occurred."; 51 | let stack: string | undefined; 52 | 53 | if (isRouteErrorResponse(error)) { 54 | message = error.status === 404 ? "404" : "Error"; 55 | details = 56 | error.status === 404 57 | ? "The requested page could not be found." 58 | : error.statusText || details; 59 | } else if (import.meta.env.DEV && error && error instanceof Error) { 60 | details = error.message; 61 | stack = error.stack; 62 | } 63 | 64 | return ( 65 |
66 |

{message}

67 |

{details}

68 | {stack && ( 69 |
70 |           {stack}
71 |         
72 | )} 73 |
74 | ); 75 | } 76 | -------------------------------------------------------------------------------- /app/Login/login.tsx: -------------------------------------------------------------------------------- 1 | import { useState } from "react"; 2 | import { useNavigate } from "react-router-dom"; 3 | 4 | export function Login() { 5 | const [email, setEmail] = useState(""); 6 | const [password, setPassword] = useState(""); 7 | const navigate = useNavigate(); 8 | 9 | const handleSubmit = (e: any) => { 10 | e.preventDefault(); 11 | 12 | // Save credentials to browser cache (localStorage) 13 | localStorage.setItem( 14 | "ortex_user", 15 | JSON.stringify({ id: 1,email, password }) 16 | ); 17 | navigate("/dashboard"); 18 | }; 19 | 20 | return ( 21 |
22 |
23 |

ORTEX

24 | 25 | 26 |
27 |
28 | 29 | setEmail(e.target.value)} 34 | className="mt-1 w-full rounded-lg border px-3 py-2 focus:outline-none focus:ring-2 focus:ring-indigo-500" 35 | /> 36 |
37 | 38 |
39 | 40 | setPassword(e.target.value)} 45 | className="mt-1 w-full rounded-lg border px-3 py-2 focus:outline-none focus:ring-2 focus:ring-indigo-500" 46 | /> 47 |
48 | 49 | 55 |
56 |
57 |
58 | ); 59 | } -------------------------------------------------------------------------------- /app/UserPage/userPage.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from "react"; 2 | import { useNavigate } from "react-router-dom"; 3 | import { type IUser } from "~/interfaces"; 4 | 5 | export function UserPage() { 6 | const [users, setUsers] = useState([] as IUser[]); 7 | const [showReset, setShowReset] = useState(false); 8 | const [selectedUser, setSelectedUser] = useState(null as IUser | null); 9 | const [currentPassword, setCurrentPassword] = useState(""); 10 | const [newPassword, setNewPassword] = useState(""); 11 | const [errorMessage, setErrorMessage] = useState(""); 12 | const [price, setPrice] = useState(null); 13 | const [timestamp, setTimestamp] = useState(null as Date | null); 14 | const navigate = useNavigate(); 15 | 16 | useEffect(() => { 17 | // Check if user is logged in 18 | const storedUser = localStorage.getItem("ortex_user"); 19 | if (!storedUser) { 20 | navigate("/"); 21 | return; 22 | } 23 | 24 | const userObj = JSON.parse(storedUser) as IUser; 25 | setUsers([{ ...userObj }]); 26 | 27 | const ws = new WebSocket("ws://stream.tradingeconomics.com/?client=guest:guest"); 28 | ws.onopen = () => { 29 | ws.send(JSON.stringify({ topic: "subscribe", to: "EURUSD:CUR" })); 30 | }; 31 | ws.onmessage = (event) => { 32 | const data = JSON.parse(event.data); 33 | if (data.price && data.dt) { 34 | setPrice(data.price); 35 | setTimestamp(new Date(data.dt)); 36 | } 37 | }; 38 | 39 | return () => ws.close(); 40 | }, [navigate]); 41 | 42 | const handleResetClick = (user: IUser) => { 43 | setSelectedUser(user); 44 | setShowReset(true); 45 | }; 46 | 47 | const handleModalClose = () => { 48 | setShowReset(false); 49 | setSelectedUser(null); 50 | }; 51 | 52 | const handlePasswordReset = () => { 53 | if (selectedUser) { 54 | if (currentPassword !== selectedUser.password) { 55 | setErrorMessage("Original password is incorrect."); 56 | return; 57 | } 58 | 59 | const updatedUsers = users.map(u => 60 | u.id === selectedUser.id ? { ...u, password: newPassword } : u 61 | ); 62 | setUsers(updatedUsers); 63 | 64 | const storedUser = localStorage.getItem("ortex_user"); 65 | if (storedUser) { 66 | const currentUser = JSON.parse(storedUser) as IUser; 67 | if (currentUser.id === selectedUser.id) { 68 | localStorage.setItem("ortex_user", JSON.stringify({ ...currentUser, password: newPassword })); 69 | } 70 | } 71 | handleModalClose(); 72 | } 73 | }; 74 | 75 | const handleLogout = () => { 76 | localStorage.removeItem("ortex_user"); 77 | navigate("/"); 78 | }; 79 | 80 | return ( 81 |
82 |
83 |
{/* placeholder to balance header */} 84 |
85 |

EUR / USD

86 |

{price ?? "Loading..."}

87 | {timestamp &&

Updated: {timestamp.toLocaleString()}

} 88 |
89 | 95 |
96 | 97 |
98 |

Users

99 | 100 | 101 | 102 | 103 | 104 | 105 | 106 | 107 | 108 | {users.map((user) => ( 109 | 110 | 111 | 112 | 120 | 121 | ))} 122 | 123 |
IDEmailActions
{user.id}{user.email} 113 | 119 |
124 |
125 | {showReset && ( 126 |
127 |
128 |

Reset Password

129 |

Reset password for: {selectedUser?.email}

130 | {errorMessage &&

{errorMessage}

} 131 | setCurrentPassword(e.target.value)} 135 | placeholder="Original Password" 136 | className="w-full border p-2 rounded mb-2" 137 | /> 138 | setNewPassword(e.target.value)} 142 | placeholder="New Password" 143 | className="w-full border p-2 rounded mb-4" 144 | /> 145 |
146 | 152 | 158 |
159 |
160 |
161 | )} 162 |
163 | ); 164 | } 165 | --------------------------------------------------------------------------------