[0]
26 | >;
27 |
--------------------------------------------------------------------------------
/apps/nextjs-pages/src/pages/404.tsx:
--------------------------------------------------------------------------------
1 | import { Link } from '@/components/ui/link';
2 | import { paths } from '@/config/paths';
3 |
4 | const NotFoundPage = () => {
5 | return (
6 |
7 |
404 - Not Found
8 |
Sorry, the page you are looking for does not exist.
9 |
10 | Go to Home
11 |
12 |
13 | );
14 | };
15 |
16 | export default NotFoundPage;
17 |
--------------------------------------------------------------------------------
/apps/nextjs-pages/src/pages/_app.tsx:
--------------------------------------------------------------------------------
1 | import { NextPage } from 'next';
2 | import type { AppProps } from 'next/app';
3 | import { ReactElement, ReactNode } from 'react';
4 |
5 | import { AppProvider } from '@/app/provider';
6 |
7 | import '@/styles/globals.css';
8 |
9 | // eslint-disable-next-line @typescript-eslint/ban-types
10 | export type NextPageWithLayout = NextPage
& {
11 | getLayout?: (page: ReactElement) => ReactNode;
12 | };
13 |
14 | type AppPropsWithLayout = AppProps & {
15 | Component: NextPageWithLayout;
16 | };
17 |
18 | export default function App({ Component, pageProps }: AppPropsWithLayout) {
19 | const getLayout = Component.getLayout ?? ((page) => page);
20 | return {getLayout( )} ;
21 | }
22 |
--------------------------------------------------------------------------------
/apps/nextjs-pages/src/pages/app/discussions/[discussionId].tsx:
--------------------------------------------------------------------------------
1 | export { DiscussionPage as default } from '@/app/pages/app/discussions/discussion';
2 |
--------------------------------------------------------------------------------
/apps/nextjs-pages/src/pages/app/discussions/index.tsx:
--------------------------------------------------------------------------------
1 | export { DiscussionsPage as default } from '@/app/pages/app/discussions/discussions';
2 |
--------------------------------------------------------------------------------
/apps/nextjs-pages/src/pages/app/index.tsx:
--------------------------------------------------------------------------------
1 | export { DashboardPage as default } from '@/app/pages/app/dashboard';
2 |
--------------------------------------------------------------------------------
/apps/nextjs-pages/src/pages/app/profile.tsx:
--------------------------------------------------------------------------------
1 | export { ProfilePage as default } from '@/app/pages/app/profile';
2 |
--------------------------------------------------------------------------------
/apps/nextjs-pages/src/pages/app/users.tsx:
--------------------------------------------------------------------------------
1 | export { UsersPage as default } from '@/app/pages/app/users';
2 |
--------------------------------------------------------------------------------
/apps/nextjs-pages/src/pages/auth/login.tsx:
--------------------------------------------------------------------------------
1 | export { LoginPage as default } from '@/app/pages/auth/login';
2 |
--------------------------------------------------------------------------------
/apps/nextjs-pages/src/pages/auth/register.tsx:
--------------------------------------------------------------------------------
1 | export { RegisterPage as default } from '@/app/pages/auth/register';
2 |
--------------------------------------------------------------------------------
/apps/nextjs-pages/src/pages/public/discussions/[discussionId].tsx:
--------------------------------------------------------------------------------
1 | export {
2 | getServerSideProps,
3 | PublicDiscussionPage as default,
4 | } from '@/app/pages/app/discussions/discussion';
5 |
--------------------------------------------------------------------------------
/apps/nextjs-pages/src/testing/mocks/browser.ts:
--------------------------------------------------------------------------------
1 | import { setupWorker } from 'msw/browser';
2 |
3 | import { handlers } from './handlers';
4 |
5 | export const worker = setupWorker(...handlers);
6 |
--------------------------------------------------------------------------------
/apps/nextjs-pages/src/testing/mocks/handlers/index.ts:
--------------------------------------------------------------------------------
1 | import { HttpResponse, http } from 'msw';
2 |
3 | import { env } from '@/config/env';
4 |
5 | import { networkDelay } from '../utils';
6 |
7 | import { authHandlers } from './auth';
8 | import { commentsHandlers } from './comments';
9 | import { discussionsHandlers } from './discussions';
10 | import { teamsHandlers } from './teams';
11 | import { usersHandlers } from './users';
12 |
13 | export const handlers = [
14 | ...authHandlers,
15 | ...commentsHandlers,
16 | ...discussionsHandlers,
17 | ...teamsHandlers,
18 | ...usersHandlers,
19 | http.get(`${env.API_URL}/healthcheck`, async () => {
20 | await networkDelay();
21 | return HttpResponse.json({ ok: true });
22 | }),
23 | ];
24 |
--------------------------------------------------------------------------------
/apps/nextjs-pages/src/testing/mocks/handlers/teams.ts:
--------------------------------------------------------------------------------
1 | import { HttpResponse, http } from 'msw';
2 |
3 | import { env } from '@/config/env';
4 |
5 | import { db } from '../db';
6 | import { networkDelay } from '../utils';
7 |
8 | export const teamsHandlers = [
9 | http.get(`${env.API_URL}/teams`, async () => {
10 | await networkDelay();
11 |
12 | try {
13 | const result = db.team.getAll();
14 | return HttpResponse.json({ data: result });
15 | } catch (error: any) {
16 | return HttpResponse.json(
17 | { message: error?.message || 'Server Error' },
18 | { status: 500 },
19 | );
20 | }
21 | }),
22 | ];
23 |
--------------------------------------------------------------------------------
/apps/nextjs-pages/src/testing/mocks/index.ts:
--------------------------------------------------------------------------------
1 | import { env } from '@/config/env';
2 |
3 | export const enableMocking = async () => {
4 | if (env.ENABLE_API_MOCKING) {
5 | const { worker } = await import('./browser');
6 | const { initializeDb } = await import('./db');
7 | await initializeDb();
8 | return worker.start();
9 | }
10 | };
11 |
--------------------------------------------------------------------------------
/apps/nextjs-pages/src/testing/mocks/server.ts:
--------------------------------------------------------------------------------
1 | import { setupServer } from 'msw/node';
2 |
3 | import { handlers } from './handlers';
4 |
5 | export const server = setupServer(...handlers);
6 |
--------------------------------------------------------------------------------
/apps/nextjs-pages/src/testing/setup-tests.ts:
--------------------------------------------------------------------------------
1 | import '@testing-library/jest-dom/vitest';
2 |
3 | import { initializeDb, resetDb } from '@/testing/mocks/db';
4 | import { server } from '@/testing/mocks/server';
5 |
6 | vi.mock('zustand');
7 |
8 | beforeAll(() => {
9 | server.listen({ onUnhandledRequest: 'error' });
10 | vi.mock('next/router', () => require('next-router-mock'));
11 | });
12 | afterAll(() => server.close());
13 | beforeEach(() => {
14 | const ResizeObserverMock = vi.fn(() => ({
15 | observe: vi.fn(),
16 | unobserve: vi.fn(),
17 | disconnect: vi.fn(),
18 | }));
19 |
20 | vi.stubGlobal('ResizeObserver', ResizeObserverMock);
21 |
22 | window.btoa = (str: string) => Buffer.from(str, 'binary').toString('base64');
23 | window.atob = (str: string) => Buffer.from(str, 'base64').toString('binary');
24 |
25 | initializeDb();
26 | });
27 | afterEach(() => {
28 | server.resetHandlers();
29 | resetDb();
30 | });
31 |
--------------------------------------------------------------------------------
/apps/nextjs-pages/src/types/api.ts:
--------------------------------------------------------------------------------
1 | // let's imagine this file is autogenerated from the backend
2 | // ideally, we want to keep these api related types in sync
3 | // with the backend instead of manually writing them out
4 |
5 | export type BaseEntity = {
6 | id: string;
7 | createdAt: number;
8 | };
9 |
10 | export type Entity = {
11 | [K in keyof T]: T[K];
12 | } & BaseEntity;
13 |
14 | export type Meta = {
15 | page: number;
16 | total: number;
17 | totalPages: number;
18 | };
19 |
20 | export type User = Entity<{
21 | firstName: string;
22 | lastName: string;
23 | email: string;
24 | role: 'ADMIN' | 'USER';
25 | teamId: string;
26 | bio: string;
27 | }>;
28 |
29 | export type AuthResponse = {
30 | jwt: string;
31 | user: User;
32 | };
33 |
34 | export type Team = Entity<{
35 | name: string;
36 | description: string;
37 | }>;
38 |
39 | export type Discussion = Entity<{
40 | title: string;
41 | body: string;
42 | teamId: string;
43 | author: User;
44 | public: boolean;
45 | }>;
46 |
47 | export type Comment = Entity<{
48 | body: string;
49 | discussionId: string;
50 | author: User;
51 | }>;
52 |
--------------------------------------------------------------------------------
/apps/nextjs-pages/src/utils/cn.ts:
--------------------------------------------------------------------------------
1 | import { type ClassValue, clsx } from 'clsx';
2 | import { twMerge } from 'tailwind-merge';
3 |
4 | export function cn(...inputs: ClassValue[]) {
5 | return twMerge(clsx(inputs));
6 | }
7 |
--------------------------------------------------------------------------------
/apps/nextjs-pages/src/utils/format.ts:
--------------------------------------------------------------------------------
1 | import { default as dayjs } from 'dayjs';
2 |
3 | export const formatDate = (date: number) =>
4 | dayjs(date).format('MMMM D, YYYY h:mm A');
5 |
--------------------------------------------------------------------------------
/apps/nextjs-pages/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "lib": ["dom", "dom.iterable", "esnext"],
4 | "allowJs": true,
5 | "skipLibCheck": true,
6 | "strict": true,
7 | "noEmit": true,
8 | "esModuleInterop": true,
9 | "module": "esnext",
10 | "target": "esnext",
11 | "moduleResolution": "node",
12 | "resolveJsonModule": true,
13 | "isolatedModules": true,
14 | "jsx": "preserve",
15 | "incremental": true,
16 | "paths": {
17 | "@/*": ["./src/*"]
18 | },
19 | "plugins": [
20 | {
21 | "name": "next"
22 | }
23 | ]
24 | },
25 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
26 | "exclude": ["node_modules"]
27 | }
28 |
--------------------------------------------------------------------------------
/apps/nextjs-pages/vitest.config.ts:
--------------------------------------------------------------------------------
1 | ///
2 |
3 | import react from '@vitejs/plugin-react';
4 | import viteTsconfigPaths from 'vite-tsconfig-paths';
5 | import { defineConfig } from 'vitest/config';
6 |
7 | export default defineConfig({
8 | base: './',
9 | plugins: [react(), viteTsconfigPaths()],
10 | test: {
11 | globals: true,
12 | environment: 'jsdom',
13 | setupFiles: './src/testing/setup-tests.ts',
14 | exclude: ['**/node_modules/**', '**/e2e/**'],
15 | coverage: {
16 | include: ['src/**'],
17 | },
18 | },
19 | });
20 |
--------------------------------------------------------------------------------
/apps/react-vite/.env.example:
--------------------------------------------------------------------------------
1 | VITE_APP_API_URL=https://api.bulletproofapp.com
2 | VITE_APP_ENABLE_API_MOCKING=true
--------------------------------------------------------------------------------
/apps/react-vite/.env.example-e2e:
--------------------------------------------------------------------------------
1 | VITE_APP_API_URL=http://localhost:8080/api
2 | VITE_APP_ENABLE_API_MOCKING=false
3 | VITE_APP_MOCK_API_PORT=8080
4 | VITE_APP_URL=http://localhost:3000
--------------------------------------------------------------------------------
/apps/react-vite/.gitignore:
--------------------------------------------------------------------------------
1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
2 |
3 | # dependencies
4 | /node_modules
5 | /.pnp
6 | .pnp.js
7 |
8 | # testing
9 | /coverage
10 | /test-results/
11 | /playwright-report/
12 | /blob-report/
13 | /playwright/.cache/
14 | /e2e/.auth/
15 |
16 | # storybook
17 | migration-storybook.log
18 | storybook.log
19 | storybook-static
20 |
21 |
22 | # production
23 | /dist
24 |
25 | # misc
26 | .DS_Store
27 | .env
28 | .env.local
29 | .env.development.local
30 | .env.test.local
31 | .env.production.local
32 |
33 | npm-debug.log*
34 | yarn-debug.log*
35 | yarn-error.log*
36 |
37 |
38 | # local
39 | mocked-db.json
40 |
41 |
--------------------------------------------------------------------------------
/apps/react-vite/.prettierignore:
--------------------------------------------------------------------------------
1 | *.hbs
--------------------------------------------------------------------------------
/apps/react-vite/.prettierrc:
--------------------------------------------------------------------------------
1 | {
2 | "singleQuote": true,
3 | "trailingComma": "all",
4 | "printWidth": 80,
5 | "tabWidth": 2,
6 | "useTabs": false
7 | }
8 |
--------------------------------------------------------------------------------
/apps/react-vite/.storybook/main.ts:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | stories: ['../src/**/*.stories.mdx', '../src/**/*.stories.@(js|jsx|ts|tsx)'],
3 |
4 | addons: [
5 | '@storybook/addon-actions',
6 | '@storybook/addon-links',
7 | '@storybook/node-logger',
8 | '@storybook/addon-essentials',
9 | '@storybook/addon-interactions',
10 | '@storybook/addon-docs',
11 | '@storybook/addon-a11y',
12 | ],
13 | framework: {
14 | name: '@storybook/react-vite',
15 | options: {},
16 | },
17 | docs: {
18 | autodocs: 'tag',
19 | },
20 | typescript: {
21 | reactDocgen: 'react-docgen-typescript',
22 | },
23 | };
24 |
--------------------------------------------------------------------------------
/apps/react-vite/.storybook/preview.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { BrowserRouter as Router } from 'react-router';
3 | import '../src/index.css';
4 |
5 | export const parameters = {
6 | actions: { argTypesRegex: '^on[A-Z].*' },
7 | };
8 |
9 | export const decorators = [
10 | (Story) => (
11 |
12 |
13 |
14 | ),
15 | ];
16 |
--------------------------------------------------------------------------------
/apps/react-vite/.vscode/extensions.json:
--------------------------------------------------------------------------------
1 | {
2 | "recommendations": [
3 | "dbaeumer.vscode-eslint",
4 | "esbenp.prettier-vscode",
5 | "dsznajder.es7-react-js-snippets",
6 | "mariusalchimavicius.json-to-ts",
7 | "bradlc.vscode-tailwindcss"
8 | ]
9 | }
10 |
--------------------------------------------------------------------------------
/apps/react-vite/.vscode/settings.json:
--------------------------------------------------------------------------------
1 | {
2 | "editor.formatOnSave": true,
3 | "editor.codeActionsOnSave": {
4 | "source.fixAll.eslint": "explicit"
5 | }
6 | }
7 |
--------------------------------------------------------------------------------
/apps/react-vite/README.md:
--------------------------------------------------------------------------------
1 | # React Vite Application
2 |
3 | ## Get Started
4 |
5 | Prerequisites:
6 |
7 | - Node 20+
8 | - Yarn 1.22+
9 |
10 | To set up the app execute the following commands.
11 |
12 | ```bash
13 | git clone https://github.com/alan2207/bulletproof-react.git
14 | cd bulletproof-react
15 | cd apps/react-vite
16 | cp .env.example .env
17 | yarn install
18 | ```
19 |
20 | ##### `yarn dev`
21 |
22 | Runs the app in the development mode.\
23 | Open [http://localhost:3000](http://localhost:3000) to view it in the browser.
24 |
25 | ##### `yarn build`
26 |
27 | Builds the app for production to the `dist` folder.\
28 | It correctly bundles React in production mode and optimizes the build for the best performance.
29 |
30 | See the section about [deployment](https://vitejs.dev/guide/static-deploy) for more information.
31 |
--------------------------------------------------------------------------------
/apps/react-vite/__mocks__/vitest-env.d.ts:
--------------------------------------------------------------------------------
1 | ///
2 | ///
3 |
--------------------------------------------------------------------------------
/apps/react-vite/e2e/.eslintrc.cjs:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | root: true,
3 | parser: '@typescript-eslint/parser',
4 | extends: 'plugin:playwright/recommended',
5 | };
6 |
--------------------------------------------------------------------------------
/apps/react-vite/e2e/tests/profile.spec.ts:
--------------------------------------------------------------------------------
1 | import { test, expect } from '@playwright/test';
2 |
3 | test('profile', async ({ page }) => {
4 | // update user:
5 | await page.goto('/app');
6 | await page.getByRole('button', { name: 'Open user menu' }).click();
7 | await page.getByRole('menuitem', { name: 'Your Profile' }).click();
8 | await page.getByRole('button', { name: 'Update Profile' }).click();
9 | await page.getByLabel('Bio').click();
10 | await page.getByLabel('Bio').fill('My bio');
11 | await page.getByRole('button', { name: 'Submit' }).click();
12 | await page
13 | .getByLabel('Profile Updated')
14 | .getByRole('button', { name: 'Close' })
15 | .click();
16 | await expect(page.getByText('My bio')).toBeVisible();
17 | });
18 |
--------------------------------------------------------------------------------
/apps/react-vite/generators/component/component.stories.tsx.hbs:
--------------------------------------------------------------------------------
1 | import { Meta, StoryObj } from '@storybook/react';
2 |
3 | import { {{ properCase name }} } from './{{ kebabCase name }}';
4 |
5 | const meta: Meta = {
6 | component: {{ properCase name }},
7 | };
8 |
9 | export default meta;
10 |
11 | type Story = StoryObj;
12 |
13 | export const Default: Story = {
14 | args: {}
15 | };
16 |
--------------------------------------------------------------------------------
/apps/react-vite/generators/component/component.tsx.hbs:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 |
3 | export type {{properCase name}}Props = {};
4 |
5 | export const {{properCase name}} = (props: {{properCase name}}Props) => {
6 | return (
7 |
8 | {{properCase name}}
9 |
10 | );
11 | };
--------------------------------------------------------------------------------
/apps/react-vite/generators/component/index.ts.hbs:
--------------------------------------------------------------------------------
1 | export * from './{{ kebabCase name }}';
2 |
--------------------------------------------------------------------------------
/apps/react-vite/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 | Bulletproof React
12 |
13 |
14 | You need to enable JavaScript to run this app.
15 |
16 |
17 |
18 |
19 |
--------------------------------------------------------------------------------
/apps/react-vite/mock-server.ts:
--------------------------------------------------------------------------------
1 | import { createMiddleware } from '@mswjs/http-middleware';
2 | import cors from 'cors';
3 | import express from 'express';
4 | import logger from 'pino-http';
5 |
6 | import { env } from './src/config/env';
7 | import { initializeDb } from './src/testing/mocks/db';
8 | import { handlers } from './src/testing/mocks/handlers';
9 |
10 | const app = express();
11 |
12 | app.use(
13 | cors({
14 | origin: env.APP_URL,
15 | credentials: true,
16 | }),
17 | );
18 |
19 | app.use(express.json());
20 | app.use(logger());
21 | app.use(createMiddleware(...handlers));
22 |
23 | initializeDb().then(() => {
24 | console.log('Mock DB initialized');
25 | app.listen(env.APP_MOCK_API_PORT, () => {
26 | console.log(
27 | `Mock API server started at http://localhost:${env.APP_MOCK_API_PORT}`,
28 | );
29 | });
30 | });
31 |
--------------------------------------------------------------------------------
/apps/react-vite/plopfile.cjs:
--------------------------------------------------------------------------------
1 | const componentGenerator = require('./generators/component/index');
2 |
3 | /**
4 | *
5 | * @param {import('plop').NodePlopAPI} plop
6 | */
7 | module.exports = function (plop) {
8 | plop.setGenerator('component', componentGenerator);
9 | };
10 |
--------------------------------------------------------------------------------
/apps/react-vite/postcss.config.cjs:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | plugins: {
3 | tailwindcss: {},
4 | autoprefixer: {},
5 | },
6 | };
7 |
--------------------------------------------------------------------------------
/apps/react-vite/public/_redirects:
--------------------------------------------------------------------------------
1 | /* /index.html 200
--------------------------------------------------------------------------------
/apps/react-vite/public/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/alan2207/bulletproof-react/49c4249fd68ef2196151ef34cc2c68cb4fe81dc1/apps/react-vite/public/favicon.ico
--------------------------------------------------------------------------------
/apps/react-vite/public/logo192.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/alan2207/bulletproof-react/49c4249fd68ef2196151ef34cc2c68cb4fe81dc1/apps/react-vite/public/logo192.png
--------------------------------------------------------------------------------
/apps/react-vite/public/logo512.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/alan2207/bulletproof-react/49c4249fd68ef2196151ef34cc2c68cb4fe81dc1/apps/react-vite/public/logo512.png
--------------------------------------------------------------------------------
/apps/react-vite/public/robots.txt:
--------------------------------------------------------------------------------
1 | # https://www.robotstxt.org/robotstxt.html
2 | User-agent: *
3 | Disallow:
4 |
--------------------------------------------------------------------------------
/apps/react-vite/src/app/index.tsx:
--------------------------------------------------------------------------------
1 | import { AppProvider } from './provider';
2 | import { AppRouter } from './router';
3 |
4 | export const App = () => {
5 | return (
6 |
7 |
8 |
9 | );
10 | };
11 |
--------------------------------------------------------------------------------
/apps/react-vite/src/app/routes/app/dashboard.tsx:
--------------------------------------------------------------------------------
1 | import { ContentLayout } from '@/components/layouts';
2 | import { useUser } from '@/lib/auth';
3 | import { ROLES } from '@/lib/authorization';
4 |
5 | const DashboardRoute = () => {
6 | const user = useUser();
7 | return (
8 |
9 |
10 | Welcome {`${user.data?.firstName} ${user.data?.lastName}`}
11 |
12 |
13 | Your role is : {user.data?.role}
14 |
15 | In this application you can:
16 | {user.data?.role === ROLES.USER && (
17 |
18 | Create comments in discussions
19 | Delete own comments
20 |
21 | )}
22 | {user.data?.role === ROLES.ADMIN && (
23 |
24 | Create discussions
25 | Edit discussions
26 | Delete discussions
27 | Comment on discussions
28 | Delete all comments
29 |
30 | )}
31 |
32 | );
33 | };
34 |
35 | export default DashboardRoute;
36 |
--------------------------------------------------------------------------------
/apps/react-vite/src/app/routes/app/root.tsx:
--------------------------------------------------------------------------------
1 | import { Outlet } from 'react-router';
2 |
3 | import { DashboardLayout } from '@/components/layouts';
4 |
5 | export const ErrorBoundary = () => {
6 | return Something went wrong!
;
7 | };
8 |
9 | const AppRoot = () => {
10 | return (
11 |
12 |
13 |
14 | );
15 | };
16 |
17 | export default AppRoot;
18 |
--------------------------------------------------------------------------------
/apps/react-vite/src/app/routes/app/users.tsx:
--------------------------------------------------------------------------------
1 | import { QueryClient } from '@tanstack/react-query';
2 |
3 | import { ContentLayout } from '@/components/layouts';
4 | import { getUsersQueryOptions } from '@/features/users/api/get-users';
5 | import { UsersList } from '@/features/users/components/users-list';
6 | import { Authorization, ROLES } from '@/lib/authorization';
7 |
8 | export const clientLoader = (queryClient: QueryClient) => async () => {
9 | const query = getUsersQueryOptions();
10 |
11 | return (
12 | queryClient.getQueryData(query.queryKey) ??
13 | (await queryClient.fetchQuery(query))
14 | );
15 | };
16 |
17 | const UsersRoute = () => {
18 | return (
19 |
20 | Only admin can view this.}
22 | allowedRoles={[ROLES.ADMIN]}
23 | >
24 |
25 |
26 |
27 | );
28 | };
29 |
30 | export default UsersRoute;
31 |
--------------------------------------------------------------------------------
/apps/react-vite/src/app/routes/auth/login.tsx:
--------------------------------------------------------------------------------
1 | import { useNavigate, useSearchParams } from 'react-router';
2 |
3 | import { AuthLayout } from '@/components/layouts/auth-layout';
4 | import { paths } from '@/config/paths';
5 | import { LoginForm } from '@/features/auth/components/login-form';
6 |
7 | const LoginRoute = () => {
8 | const navigate = useNavigate();
9 | const [searchParams] = useSearchParams();
10 | const redirectTo = searchParams.get('redirectTo');
11 |
12 | return (
13 |
14 | {
16 | navigate(
17 | `${redirectTo ? `${redirectTo}` : paths.app.dashboard.getHref()}`,
18 | {
19 | replace: true,
20 | },
21 | );
22 | }}
23 | />
24 |
25 | );
26 | };
27 |
28 | export default LoginRoute;
29 |
--------------------------------------------------------------------------------
/apps/react-vite/src/app/routes/auth/register.tsx:
--------------------------------------------------------------------------------
1 | import { useState } from 'react';
2 | import { useNavigate, useSearchParams } from 'react-router';
3 |
4 | import { AuthLayout } from '@/components/layouts/auth-layout';
5 | import { paths } from '@/config/paths';
6 | import { RegisterForm } from '@/features/auth/components/register-form';
7 | import { useTeams } from '@/features/teams/api/get-teams';
8 |
9 | const RegisterRoute = () => {
10 | const navigate = useNavigate();
11 | const [searchParams] = useSearchParams();
12 | const redirectTo = searchParams.get('redirectTo');
13 | const [chooseTeam, setChooseTeam] = useState(false);
14 |
15 | const teamsQuery = useTeams({
16 | queryConfig: {
17 | enabled: chooseTeam,
18 | },
19 | });
20 |
21 | return (
22 |
23 | {
25 | navigate(
26 | `${redirectTo ? `${redirectTo}` : paths.app.dashboard.getHref()}`,
27 | {
28 | replace: true,
29 | },
30 | );
31 | }}
32 | chooseTeam={chooseTeam}
33 | setChooseTeam={() => setChooseTeam(!chooseTeam)}
34 | teams={teamsQuery.data?.data}
35 | />
36 |
37 | );
38 | };
39 |
40 | export default RegisterRoute;
41 |
--------------------------------------------------------------------------------
/apps/react-vite/src/app/routes/not-found.tsx:
--------------------------------------------------------------------------------
1 | import { Link } from '@/components/ui/link';
2 | import { paths } from '@/config/paths';
3 |
4 | const NotFoundRoute = () => {
5 | return (
6 |
7 |
404 - Not Found
8 |
Sorry, the page you are looking for does not exist.
9 |
10 | Go to Home
11 |
12 |
13 | );
14 | };
15 |
16 | export default NotFoundRoute;
17 |
--------------------------------------------------------------------------------
/apps/react-vite/src/components/errors/main.tsx:
--------------------------------------------------------------------------------
1 | import { Button } from '../ui/button';
2 |
3 | export const MainErrorFallback = () => {
4 | return (
5 |
9 |
Ooops, something went wrong :(
10 | window.location.assign(window.location.origin)}
13 | >
14 | Refresh
15 |
16 |
17 | );
18 | };
19 |
--------------------------------------------------------------------------------
/apps/react-vite/src/components/layouts/content-layout.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 |
3 | import { Head } from '../seo';
4 |
5 | type ContentLayoutProps = {
6 | children: React.ReactNode;
7 | title: string;
8 | };
9 |
10 | export const ContentLayout = ({ children, title }: ContentLayoutProps) => {
11 | return (
12 | <>
13 |
14 |
15 |
16 |
{title}
17 |
18 |
19 | {children}
20 |
21 |
22 | >
23 | );
24 | };
25 |
--------------------------------------------------------------------------------
/apps/react-vite/src/components/layouts/index.ts:
--------------------------------------------------------------------------------
1 | export * from './content-layout';
2 | export * from './dashboard-layout';
3 |
--------------------------------------------------------------------------------
/apps/react-vite/src/components/seo/__tests__/head.test.tsx:
--------------------------------------------------------------------------------
1 | import { render, waitFor } from '@/testing/test-utils';
2 |
3 | import { Head } from '../head';
4 |
5 | test('should add proper page title and meta description', async () => {
6 | const title = 'Hello World';
7 | const titleSuffix = ' | Bulletproof React';
8 | const description = 'This is a description';
9 |
10 | render();
11 | await waitFor(() => expect(document.title).toEqual(title + titleSuffix));
12 |
13 | const metaDescription = document.querySelector("meta[name='description']");
14 |
15 | expect(metaDescription?.getAttribute('content')).toEqual(description);
16 | });
17 |
--------------------------------------------------------------------------------
/apps/react-vite/src/components/seo/head.tsx:
--------------------------------------------------------------------------------
1 | import { Helmet, HelmetData } from 'react-helmet-async';
2 |
3 | type HeadProps = {
4 | title?: string;
5 | description?: string;
6 | };
7 |
8 | const helmetData = new HelmetData({});
9 |
10 | export const Head = ({ title = '', description = '' }: HeadProps = {}) => {
11 | return (
12 |
17 |
18 |
19 | );
20 | };
21 |
--------------------------------------------------------------------------------
/apps/react-vite/src/components/seo/index.ts:
--------------------------------------------------------------------------------
1 | export * from './head';
2 |
--------------------------------------------------------------------------------
/apps/react-vite/src/components/ui/button/button.stories.tsx:
--------------------------------------------------------------------------------
1 | import { Meta, StoryObj } from '@storybook/react';
2 |
3 | import { Button } from './button';
4 |
5 | const meta: Meta = {
6 | component: Button,
7 | };
8 |
9 | export default meta;
10 | type Story = StoryObj;
11 |
12 | export const Default: Story = {
13 | args: {
14 | children: 'Button',
15 | variant: 'default',
16 | },
17 | };
18 |
--------------------------------------------------------------------------------
/apps/react-vite/src/components/ui/button/index.ts:
--------------------------------------------------------------------------------
1 | export * from './button';
2 |
--------------------------------------------------------------------------------
/apps/react-vite/src/components/ui/dialog/confirmation-dialog/confirmation-dialog.stories.tsx:
--------------------------------------------------------------------------------
1 | import { Meta, StoryObj } from '@storybook/react';
2 |
3 | import { Button } from '@/components/ui/button';
4 |
5 | import { ConfirmationDialog } from './confirmation-dialog';
6 |
7 | const meta: Meta = {
8 | component: ConfirmationDialog,
9 | };
10 |
11 | export default meta;
12 |
13 | type Story = StoryObj;
14 |
15 | export const Danger: Story = {
16 | args: {
17 | icon: 'danger',
18 | title: 'Confirmation',
19 | body: 'Hello World',
20 | confirmButton: Confirm ,
21 | triggerButton: Open ,
22 | },
23 | };
24 |
25 | export const Info: Story = {
26 | args: {
27 | icon: 'info',
28 | title: 'Confirmation',
29 | body: 'Hello World',
30 | confirmButton: Confirm ,
31 | triggerButton: Open ,
32 | },
33 | };
34 |
--------------------------------------------------------------------------------
/apps/react-vite/src/components/ui/dialog/confirmation-dialog/index.ts:
--------------------------------------------------------------------------------
1 | export * from './confirmation-dialog';
2 |
--------------------------------------------------------------------------------
/apps/react-vite/src/components/ui/dialog/index.ts:
--------------------------------------------------------------------------------
1 | export * from './dialog';
2 | export * from './confirmation-dialog';
3 |
--------------------------------------------------------------------------------
/apps/react-vite/src/components/ui/drawer/index.ts:
--------------------------------------------------------------------------------
1 | export * from './drawer';
2 |
--------------------------------------------------------------------------------
/apps/react-vite/src/components/ui/dropdown/index.ts:
--------------------------------------------------------------------------------
1 | export * from './dropdown';
2 |
--------------------------------------------------------------------------------
/apps/react-vite/src/components/ui/form/error.tsx:
--------------------------------------------------------------------------------
1 | export type ErrorProps = {
2 | errorMessage?: string | null;
3 | };
4 |
5 | export const Error = ({ errorMessage }: ErrorProps) => {
6 | if (!errorMessage) return null;
7 |
8 | return (
9 |
14 | {errorMessage}
15 |
16 | );
17 | };
18 |
--------------------------------------------------------------------------------
/apps/react-vite/src/components/ui/form/field-wrapper.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 | import { type FieldError } from 'react-hook-form';
3 |
4 | import { Error } from './error';
5 | import { Label } from './label';
6 |
7 | type FieldWrapperProps = {
8 | label?: string;
9 | className?: string;
10 | children: React.ReactNode;
11 | error?: FieldError | undefined;
12 | };
13 |
14 | export type FieldWrapperPassThroughProps = Omit<
15 | FieldWrapperProps,
16 | 'className' | 'children'
17 | >;
18 |
19 | export const FieldWrapper = (props: FieldWrapperProps) => {
20 | const { label, error, children } = props;
21 | return (
22 |
23 |
24 | {label}
25 | {children}
26 |
27 |
28 |
29 | );
30 | };
31 |
--------------------------------------------------------------------------------
/apps/react-vite/src/components/ui/form/index.ts:
--------------------------------------------------------------------------------
1 | export * from './form';
2 | export * from './input';
3 | export * from './select';
4 | export * from './textarea';
5 | export * from './form-drawer';
6 | export * from './label';
7 | export * from './switch';
8 |
--------------------------------------------------------------------------------
/apps/react-vite/src/components/ui/form/input.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 | import { type UseFormRegisterReturn } from 'react-hook-form';
3 |
4 | import { cn } from '@/utils/cn';
5 |
6 | import { FieldWrapper, FieldWrapperPassThroughProps } from './field-wrapper';
7 |
8 | export type InputProps = React.InputHTMLAttributes &
9 | FieldWrapperPassThroughProps & {
10 | className?: string;
11 | registration: Partial;
12 | };
13 |
14 | const Input = React.forwardRef(
15 | ({ className, type, label, error, registration, ...props }, ref) => {
16 | return (
17 |
18 |
28 |
29 | );
30 | },
31 | );
32 | Input.displayName = 'Input';
33 |
34 | export { Input };
35 |
--------------------------------------------------------------------------------
/apps/react-vite/src/components/ui/form/label.tsx:
--------------------------------------------------------------------------------
1 | import * as LabelPrimitive from '@radix-ui/react-label';
2 | import { cva, type VariantProps } from 'class-variance-authority';
3 | import * as React from 'react';
4 |
5 | import { cn } from '@/utils/cn';
6 |
7 | const labelVariants = cva(
8 | 'text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70',
9 | );
10 |
11 | const Label = React.forwardRef<
12 | React.ElementRef,
13 | React.ComponentPropsWithoutRef &
14 | VariantProps
15 | >(({ className, ...props }, ref) => (
16 |
21 | ));
22 | Label.displayName = LabelPrimitive.Root.displayName;
23 |
24 | export { Label };
25 |
--------------------------------------------------------------------------------
/apps/react-vite/src/components/ui/form/select.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 | import { UseFormRegisterReturn } from 'react-hook-form';
3 |
4 | import { cn } from '@/utils/cn';
5 |
6 | import { FieldWrapper, FieldWrapperPassThroughProps } from './field-wrapper';
7 |
8 | type Option = {
9 | label: React.ReactNode;
10 | value: string | number | string[];
11 | };
12 |
13 | type SelectFieldProps = FieldWrapperPassThroughProps & {
14 | options: Option[];
15 | className?: string;
16 | defaultValue?: string;
17 | registration: Partial;
18 | };
19 |
20 | export const Select = (props: SelectFieldProps) => {
21 | const { label, options, error, className, defaultValue, registration } =
22 | props;
23 | return (
24 |
25 |
33 | {options.map(({ label, value }) => (
34 |
35 | {label}
36 |
37 | ))}
38 |
39 |
40 | );
41 | };
42 |
--------------------------------------------------------------------------------
/apps/react-vite/src/components/ui/form/switch.tsx:
--------------------------------------------------------------------------------
1 | import * as SwitchPrimitives from '@radix-ui/react-switch';
2 | import * as React from 'react';
3 |
4 | import { cn } from '@/utils/cn';
5 |
6 | const Switch = React.forwardRef<
7 | React.ElementRef,
8 | React.ComponentPropsWithoutRef
9 | >(({ className, ...props }, ref) => (
10 |
18 |
23 |
24 | ));
25 | Switch.displayName = SwitchPrimitives.Root.displayName;
26 |
27 | export { Switch };
28 |
--------------------------------------------------------------------------------
/apps/react-vite/src/components/ui/form/textarea.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 | import { UseFormRegisterReturn } from 'react-hook-form';
3 |
4 | import { cn } from '@/utils/cn';
5 |
6 | import { FieldWrapper, FieldWrapperPassThroughProps } from './field-wrapper';
7 |
8 | export type TextareaProps = React.TextareaHTMLAttributes &
9 | FieldWrapperPassThroughProps & {
10 | className?: string;
11 | registration: Partial;
12 | };
13 |
14 | const Textarea = React.forwardRef(
15 | ({ className, label, error, registration, ...props }, ref) => {
16 | return (
17 |
18 |
27 |
28 | );
29 | },
30 | );
31 | Textarea.displayName = 'Textarea';
32 |
33 | export { Textarea };
34 |
--------------------------------------------------------------------------------
/apps/react-vite/src/components/ui/link/index.ts:
--------------------------------------------------------------------------------
1 | export * from './link';
2 |
--------------------------------------------------------------------------------
/apps/react-vite/src/components/ui/link/link.stories.tsx:
--------------------------------------------------------------------------------
1 | import { Meta, StoryObj } from '@storybook/react';
2 |
3 | import { Link } from './link';
4 |
5 | const meta: Meta = {
6 | component: Link,
7 | };
8 |
9 | export default meta;
10 |
11 | type Story = StoryObj;
12 |
13 | export const Default: Story = {
14 | args: {
15 | children: 'Link',
16 | to: '/',
17 | },
18 | };
19 |
--------------------------------------------------------------------------------
/apps/react-vite/src/components/ui/link/link.tsx:
--------------------------------------------------------------------------------
1 | import { Link as RouterLink, LinkProps } from 'react-router';
2 |
3 | import { cn } from '@/utils/cn';
4 |
5 | export const Link = ({ className, children, ...props }: LinkProps) => {
6 | return (
7 |
11 | {children}
12 |
13 | );
14 | };
15 |
--------------------------------------------------------------------------------
/apps/react-vite/src/components/ui/md-preview/index.ts:
--------------------------------------------------------------------------------
1 | export * from './md-preview';
2 |
--------------------------------------------------------------------------------
/apps/react-vite/src/components/ui/md-preview/md-preview.stories.tsx:
--------------------------------------------------------------------------------
1 | import { Meta, StoryObj } from '@storybook/react';
2 |
3 | import { MDPreview } from './md-preview';
4 |
5 | const meta: Meta = {
6 | component: MDPreview,
7 | };
8 |
9 | export default meta;
10 |
11 | type Story = StoryObj;
12 |
13 | export const Default: Story = {
14 | args: {
15 | value: `## Hello World!`,
16 | },
17 | };
18 |
--------------------------------------------------------------------------------
/apps/react-vite/src/components/ui/md-preview/md-preview.tsx:
--------------------------------------------------------------------------------
1 | import createDOMPurify from 'dompurify';
2 | import { parse } from 'marked';
3 |
4 | const DOMPurify = createDOMPurify(window);
5 |
6 | export type MDPreviewProps = {
7 | value: string;
8 | };
9 |
10 | export const MDPreview = ({ value = '' }: MDPreviewProps) => {
11 | return (
12 |
18 | );
19 | };
20 |
--------------------------------------------------------------------------------
/apps/react-vite/src/components/ui/notifications/__tests__/notifications.test.ts:
--------------------------------------------------------------------------------
1 | import { renderHook, act } from '@testing-library/react';
2 |
3 | import { useNotifications, Notification } from '../notifications-store';
4 |
5 | test('should add and remove notifications', () => {
6 | const { result } = renderHook(() => useNotifications());
7 |
8 | expect(result.current.notifications.length).toBe(0);
9 |
10 | const notification: Notification = {
11 | id: '123',
12 | title: 'Hello World',
13 | type: 'info',
14 | message: 'This is a notification',
15 | };
16 |
17 | act(() => {
18 | result.current.addNotification(notification);
19 | });
20 |
21 | expect(result.current.notifications).toContainEqual(notification);
22 |
23 | act(() => {
24 | result.current.dismissNotification(notification.id);
25 | });
26 |
27 | expect(result.current.notifications).not.toContainEqual(notification);
28 | });
29 |
--------------------------------------------------------------------------------
/apps/react-vite/src/components/ui/notifications/index.ts:
--------------------------------------------------------------------------------
1 | export * from './notifications';
2 | export * from './notifications-store';
3 |
--------------------------------------------------------------------------------
/apps/react-vite/src/components/ui/notifications/notifications-store.ts:
--------------------------------------------------------------------------------
1 | import { nanoid } from 'nanoid';
2 | import { create } from 'zustand';
3 |
4 | export type Notification = {
5 | id: string;
6 | type: 'info' | 'warning' | 'success' | 'error';
7 | title: string;
8 | message?: string;
9 | };
10 |
11 | type NotificationsStore = {
12 | notifications: Notification[];
13 | addNotification: (notification: Omit) => void;
14 | dismissNotification: (id: string) => void;
15 | };
16 |
17 | export const useNotifications = create((set) => ({
18 | notifications: [],
19 | addNotification: (notification) =>
20 | set((state) => ({
21 | notifications: [
22 | ...state.notifications,
23 | { id: nanoid(), ...notification },
24 | ],
25 | })),
26 | dismissNotification: (id) =>
27 | set((state) => ({
28 | notifications: state.notifications.filter(
29 | (notification) => notification.id !== id,
30 | ),
31 | })),
32 | }));
33 |
--------------------------------------------------------------------------------
/apps/react-vite/src/components/ui/notifications/notifications.tsx:
--------------------------------------------------------------------------------
1 | import { Notification } from './notification';
2 | import { useNotifications } from './notifications-store';
3 |
4 | export const Notifications = () => {
5 | const { notifications, dismissNotification } = useNotifications();
6 |
7 | return (
8 |
12 | {notifications.map((notification) => (
13 |
18 | ))}
19 |
20 | );
21 | };
22 |
--------------------------------------------------------------------------------
/apps/react-vite/src/components/ui/spinner/index.ts:
--------------------------------------------------------------------------------
1 | export * from './spinner';
2 |
--------------------------------------------------------------------------------
/apps/react-vite/src/components/ui/spinner/spinner.stories.tsx:
--------------------------------------------------------------------------------
1 | import { Meta, StoryObj } from '@storybook/react';
2 |
3 | import { Spinner } from './spinner';
4 |
5 | const meta: Meta = {
6 | component: Spinner,
7 | };
8 |
9 | export default meta;
10 |
11 | type Story = StoryObj;
12 |
13 | export const Default: Story = {
14 | args: {
15 | size: 'md',
16 | },
17 | };
18 |
--------------------------------------------------------------------------------
/apps/react-vite/src/components/ui/spinner/spinner.tsx:
--------------------------------------------------------------------------------
1 | import { cn } from '@/utils/cn';
2 |
3 | const sizes = {
4 | sm: 'h-4 w-4',
5 | md: 'h-8 w-8',
6 | lg: 'h-16 w-16',
7 | xl: 'h-24 w-24',
8 | };
9 |
10 | const variants = {
11 | light: 'text-white',
12 | primary: 'text-slate-600',
13 | };
14 |
15 | export type SpinnerProps = {
16 | size?: keyof typeof sizes;
17 | variant?: keyof typeof variants;
18 | className?: string;
19 | };
20 |
21 | export const Spinner = ({
22 | size = 'md',
23 | variant = 'primary',
24 | className = '',
25 | }: SpinnerProps) => {
26 | return (
27 | <>
28 |
45 |
46 |
47 | Loading
48 | >
49 | );
50 | };
51 |
--------------------------------------------------------------------------------
/apps/react-vite/src/components/ui/table/index.ts:
--------------------------------------------------------------------------------
1 | export * from './table';
2 |
--------------------------------------------------------------------------------
/apps/react-vite/src/components/ui/table/table.stories.tsx:
--------------------------------------------------------------------------------
1 | import { Meta, StoryObj } from '@storybook/react';
2 |
3 | import { Table } from './table';
4 |
5 | const meta: Meta = {
6 | component: Table,
7 | };
8 |
9 | export default meta;
10 |
11 | type User = {
12 | id: string;
13 | createdAt: number;
14 | name: string;
15 | title: string;
16 | role: string;
17 | email: string;
18 | };
19 |
20 | type Story = StoryObj>;
21 |
22 | const data: User[] = [
23 | {
24 | id: '1',
25 | createdAt: Date.now(),
26 | name: 'Jane Cooper',
27 | title: 'Regional Paradigm Technician',
28 | role: 'Admin',
29 | email: 'jane.cooper@example.com',
30 | },
31 | {
32 | id: '2',
33 | createdAt: Date.now(),
34 | name: 'Cody Fisher',
35 | title: 'Product Directives Officer',
36 | role: 'Owner',
37 | email: 'cody.fisher@example.com',
38 | },
39 | ];
40 |
41 | export const Default: Story = {
42 | args: {
43 | data,
44 | columns: [
45 | {
46 | title: 'Name',
47 | field: 'name',
48 | },
49 | {
50 | title: 'Title',
51 | field: 'title',
52 | },
53 | {
54 | title: 'Role',
55 | field: 'role',
56 | },
57 | {
58 | title: 'Email',
59 | field: 'email',
60 | },
61 | ],
62 | },
63 | };
64 |
--------------------------------------------------------------------------------
/apps/react-vite/src/config/env.ts:
--------------------------------------------------------------------------------
1 | import * as z from 'zod';
2 |
3 | const createEnv = () => {
4 | const EnvSchema = z.object({
5 | API_URL: z.string(),
6 | ENABLE_API_MOCKING: z
7 | .string()
8 | .refine((s) => s === 'true' || s === 'false')
9 | .transform((s) => s === 'true')
10 | .optional(),
11 | APP_URL: z.string().optional().default('http://localhost:3000'),
12 | APP_MOCK_API_PORT: z.string().optional().default('8080'),
13 | });
14 |
15 | const envVars = Object.entries(import.meta.env).reduce<
16 | Record
17 | >((acc, curr) => {
18 | const [key, value] = curr;
19 | if (key.startsWith('VITE_APP_')) {
20 | acc[key.replace('VITE_APP_', '')] = value;
21 | }
22 | return acc;
23 | }, {});
24 |
25 | const parsedEnv = EnvSchema.safeParse(envVars);
26 |
27 | if (!parsedEnv.success) {
28 | throw new Error(
29 | `Invalid env provided.
30 | The following variables are missing or invalid:
31 | ${Object.entries(parsedEnv.error.flatten().fieldErrors)
32 | .map(([k, v]) => `- ${k}: ${v}`)
33 | .join('\n')}
34 | `,
35 | );
36 | }
37 |
38 | return parsedEnv.data;
39 | };
40 |
41 | export const env = createEnv();
42 |
--------------------------------------------------------------------------------
/apps/react-vite/src/config/paths.ts:
--------------------------------------------------------------------------------
1 | export const paths = {
2 | home: {
3 | path: '/',
4 | getHref: () => '/',
5 | },
6 |
7 | auth: {
8 | register: {
9 | path: '/auth/register',
10 | getHref: (redirectTo?: string | null | undefined) =>
11 | `/auth/register${redirectTo ? `?redirectTo=${encodeURIComponent(redirectTo)}` : ''}`,
12 | },
13 | login: {
14 | path: '/auth/login',
15 | getHref: (redirectTo?: string | null | undefined) =>
16 | `/auth/login${redirectTo ? `?redirectTo=${encodeURIComponent(redirectTo)}` : ''}`,
17 | },
18 | },
19 |
20 | app: {
21 | root: {
22 | path: '/app',
23 | getHref: () => '/app',
24 | },
25 | dashboard: {
26 | path: '',
27 | getHref: () => '/app',
28 | },
29 | discussions: {
30 | path: 'discussions',
31 | getHref: () => '/app/discussions',
32 | },
33 | discussion: {
34 | path: 'discussions/:discussionId',
35 | getHref: (id: string) => `/app/discussions/${id}`,
36 | },
37 | users: {
38 | path: 'users',
39 | getHref: () => '/app/users',
40 | },
41 | profile: {
42 | path: 'profile',
43 | getHref: () => '/app/profile',
44 | },
45 | },
46 | } as const;
47 |
--------------------------------------------------------------------------------
/apps/react-vite/src/features/auth/components/__tests__/login-form.test.tsx:
--------------------------------------------------------------------------------
1 | import {
2 | createUser,
3 | renderApp,
4 | screen,
5 | userEvent,
6 | waitFor,
7 | } from '@/testing/test-utils';
8 |
9 | import { LoginForm } from '../login-form';
10 |
11 | test('should login new user and call onSuccess cb which should navigate the user to the app', async () => {
12 | const newUser = await createUser({ teamId: undefined });
13 |
14 | const onSuccess = vi.fn();
15 |
16 | await renderApp( , { user: null });
17 |
18 | await userEvent.type(screen.getByLabelText(/email address/i), newUser.email);
19 | await userEvent.type(screen.getByLabelText(/password/i), newUser.password);
20 |
21 | await userEvent.click(screen.getByRole('button', { name: /log in/i }));
22 |
23 | await waitFor(() => expect(onSuccess).toHaveBeenCalledTimes(1));
24 | });
25 |
--------------------------------------------------------------------------------
/apps/react-vite/src/features/auth/components/__tests__/register-form.test.tsx:
--------------------------------------------------------------------------------
1 | import { createUser } from '@/testing/data-generators';
2 | import { renderApp, screen, userEvent, waitFor } from '@/testing/test-utils';
3 |
4 | import { RegisterForm } from '../register-form';
5 |
6 | test('should register new user and call onSuccess cb which should navigate the user to the app', async () => {
7 | const newUser = createUser({});
8 |
9 | const onSuccess = vi.fn();
10 |
11 | await renderApp(
12 | {}}
16 | teams={[]}
17 | />,
18 | { user: null },
19 | );
20 |
21 | await userEvent.type(screen.getByLabelText(/first name/i), newUser.firstName);
22 | await userEvent.type(screen.getByLabelText(/last name/i), newUser.lastName);
23 | await userEvent.type(screen.getByLabelText(/email address/i), newUser.email);
24 | await userEvent.type(screen.getByLabelText(/password/i), newUser.password);
25 | await userEvent.type(screen.getByLabelText(/team name/i), newUser.teamName);
26 |
27 | await userEvent.click(screen.getByRole('button', { name: /register/i }));
28 |
29 | await waitFor(() => expect(onSuccess).toHaveBeenCalledTimes(1));
30 | });
31 |
--------------------------------------------------------------------------------
/apps/react-vite/src/features/comments/api/delete-comment.ts:
--------------------------------------------------------------------------------
1 | import { useMutation, useQueryClient } from '@tanstack/react-query';
2 |
3 | import { api } from '@/lib/api-client';
4 | import { MutationConfig } from '@/lib/react-query';
5 |
6 | import { getInfiniteCommentsQueryOptions } from './get-comments';
7 |
8 | export const deleteComment = ({ commentId }: { commentId: string }) => {
9 | return api.delete(`/comments/${commentId}`);
10 | };
11 |
12 | type UseDeleteCommentOptions = {
13 | discussionId: string;
14 | mutationConfig?: MutationConfig;
15 | };
16 |
17 | export const useDeleteComment = ({
18 | mutationConfig,
19 | discussionId,
20 | }: UseDeleteCommentOptions) => {
21 | const queryClient = useQueryClient();
22 |
23 | const { onSuccess, ...restConfig } = mutationConfig || {};
24 |
25 | return useMutation({
26 | onSuccess: (...args) => {
27 | queryClient.invalidateQueries({
28 | queryKey: getInfiniteCommentsQueryOptions(discussionId).queryKey,
29 | });
30 | onSuccess?.(...args);
31 | },
32 | ...restConfig,
33 | mutationFn: deleteComment,
34 | });
35 | };
36 |
--------------------------------------------------------------------------------
/apps/react-vite/src/features/comments/components/comments.tsx:
--------------------------------------------------------------------------------
1 | import { CommentsList } from './comments-list';
2 | import { CreateComment } from './create-comment';
3 |
4 | type CommentsProps = {
5 | discussionId: string;
6 | };
7 |
8 | export const Comments = ({ discussionId }: CommentsProps) => {
9 | return (
10 |
11 |
12 |
Comments:
13 |
14 |
15 |
16 |
17 | );
18 | };
19 |
--------------------------------------------------------------------------------
/apps/react-vite/src/features/discussions/api/delete-discussion.ts:
--------------------------------------------------------------------------------
1 | import { useMutation, useQueryClient } from '@tanstack/react-query';
2 |
3 | import { api } from '@/lib/api-client';
4 | import { MutationConfig } from '@/lib/react-query';
5 |
6 | import { getDiscussionsQueryOptions } from './get-discussions';
7 |
8 | export const deleteDiscussion = ({
9 | discussionId,
10 | }: {
11 | discussionId: string;
12 | }) => {
13 | return api.delete(`/discussions/${discussionId}`);
14 | };
15 |
16 | type UseDeleteDiscussionOptions = {
17 | mutationConfig?: MutationConfig;
18 | };
19 |
20 | export const useDeleteDiscussion = ({
21 | mutationConfig,
22 | }: UseDeleteDiscussionOptions = {}) => {
23 | const queryClient = useQueryClient();
24 |
25 | const { onSuccess, ...restConfig } = mutationConfig || {};
26 |
27 | return useMutation({
28 | onSuccess: (...args) => {
29 | queryClient.invalidateQueries({
30 | queryKey: getDiscussionsQueryOptions().queryKey,
31 | });
32 | onSuccess?.(...args);
33 | },
34 | ...restConfig,
35 | mutationFn: deleteDiscussion,
36 | });
37 | };
38 |
--------------------------------------------------------------------------------
/apps/react-vite/src/features/discussions/api/get-discussion.ts:
--------------------------------------------------------------------------------
1 | import { useQuery, queryOptions } from '@tanstack/react-query';
2 |
3 | import { api } from '@/lib/api-client';
4 | import { QueryConfig } from '@/lib/react-query';
5 | import { Discussion } from '@/types/api';
6 |
7 | export const getDiscussion = ({
8 | discussionId,
9 | }: {
10 | discussionId: string;
11 | }): Promise<{ data: Discussion }> => {
12 | return api.get(`/discussions/${discussionId}`);
13 | };
14 |
15 | export const getDiscussionQueryOptions = (discussionId: string) => {
16 | return queryOptions({
17 | queryKey: ['discussions', discussionId],
18 | queryFn: () => getDiscussion({ discussionId }),
19 | });
20 | };
21 |
22 | type UseDiscussionOptions = {
23 | discussionId: string;
24 | queryConfig?: QueryConfig;
25 | };
26 |
27 | export const useDiscussion = ({
28 | discussionId,
29 | queryConfig,
30 | }: UseDiscussionOptions) => {
31 | return useQuery({
32 | ...getDiscussionQueryOptions(discussionId),
33 | ...queryConfig,
34 | });
35 | };
36 |
--------------------------------------------------------------------------------
/apps/react-vite/src/features/discussions/api/get-discussions.ts:
--------------------------------------------------------------------------------
1 | import { queryOptions, useQuery } from '@tanstack/react-query';
2 |
3 | import { api } from '@/lib/api-client';
4 | import { QueryConfig } from '@/lib/react-query';
5 | import { Discussion, Meta } from '@/types/api';
6 |
7 | export const getDiscussions = (
8 | page = 1,
9 | ): Promise<{
10 | data: Discussion[];
11 | meta: Meta;
12 | }> => {
13 | return api.get(`/discussions`, {
14 | params: {
15 | page,
16 | },
17 | });
18 | };
19 |
20 | export const getDiscussionsQueryOptions = ({
21 | page,
22 | }: { page?: number } = {}) => {
23 | return queryOptions({
24 | queryKey: page ? ['discussions', { page }] : ['discussions'],
25 | queryFn: () => getDiscussions(page),
26 | });
27 | };
28 |
29 | type UseDiscussionsOptions = {
30 | page?: number;
31 | queryConfig?: QueryConfig;
32 | };
33 |
34 | export const useDiscussions = ({
35 | queryConfig,
36 | page,
37 | }: UseDiscussionsOptions) => {
38 | return useQuery({
39 | ...getDiscussionsQueryOptions({ page }),
40 | ...queryConfig,
41 | });
42 | };
43 |
--------------------------------------------------------------------------------
/apps/react-vite/src/features/teams/api/get-teams.ts:
--------------------------------------------------------------------------------
1 | import { queryOptions, useQuery } from '@tanstack/react-query';
2 |
3 | import { api } from '@/lib/api-client';
4 | import { QueryConfig } from '@/lib/react-query';
5 | import { Team } from '@/types/api';
6 |
7 | export const getTeams = (): Promise<{ data: Team[] }> => {
8 | return api.get('/teams');
9 | };
10 |
11 | export const getTeamsQueryOptions = () => {
12 | return queryOptions({
13 | queryKey: ['teams'],
14 | queryFn: () => getTeams(),
15 | });
16 | };
17 |
18 | type UseTeamsOptions = {
19 | queryConfig?: QueryConfig;
20 | };
21 |
22 | export const useTeams = ({ queryConfig = {} }: UseTeamsOptions = {}) => {
23 | return useQuery({
24 | ...getTeamsQueryOptions(),
25 | ...queryConfig,
26 | });
27 | };
28 |
--------------------------------------------------------------------------------
/apps/react-vite/src/features/users/api/delete-user.ts:
--------------------------------------------------------------------------------
1 | import { useMutation, useQueryClient } from '@tanstack/react-query';
2 |
3 | import { api } from '@/lib/api-client';
4 | import { MutationConfig } from '@/lib/react-query';
5 |
6 | import { getUsersQueryOptions } from './get-users';
7 |
8 | export type DeleteUserDTO = {
9 | userId: string;
10 | };
11 |
12 | export const deleteUser = ({ userId }: DeleteUserDTO) => {
13 | return api.delete(`/users/${userId}`);
14 | };
15 |
16 | type UseDeleteUserOptions = {
17 | mutationConfig?: MutationConfig;
18 | };
19 |
20 | export const useDeleteUser = ({
21 | mutationConfig,
22 | }: UseDeleteUserOptions = {}) => {
23 | const queryClient = useQueryClient();
24 |
25 | const { onSuccess, ...restConfig } = mutationConfig || {};
26 |
27 | return useMutation({
28 | onSuccess: (...args) => {
29 | queryClient.invalidateQueries({
30 | queryKey: getUsersQueryOptions().queryKey,
31 | });
32 | onSuccess?.(...args);
33 | },
34 | ...restConfig,
35 | mutationFn: deleteUser,
36 | });
37 | };
38 |
--------------------------------------------------------------------------------
/apps/react-vite/src/features/users/api/get-users.ts:
--------------------------------------------------------------------------------
1 | import { queryOptions, useQuery } from '@tanstack/react-query';
2 |
3 | import { api } from '@/lib/api-client';
4 | import { QueryConfig } from '@/lib/react-query';
5 | import { User } from '@/types/api';
6 |
7 | export const getUsers = (): Promise<{ data: User[] }> => {
8 | return api.get(`/users`);
9 | };
10 |
11 | export const getUsersQueryOptions = () => {
12 | return queryOptions({
13 | queryKey: ['users'],
14 | queryFn: getUsers,
15 | });
16 | };
17 |
18 | type UseUsersOptions = {
19 | queryConfig?: QueryConfig;
20 | };
21 |
22 | export const useUsers = ({ queryConfig }: UseUsersOptions = {}) => {
23 | return useQuery({
24 | ...getUsersQueryOptions(),
25 | ...queryConfig,
26 | });
27 | };
28 |
--------------------------------------------------------------------------------
/apps/react-vite/src/features/users/api/update-profile.ts:
--------------------------------------------------------------------------------
1 | import { useMutation } from '@tanstack/react-query';
2 | import { z } from 'zod';
3 |
4 | import { api } from '@/lib/api-client';
5 | import { useUser } from '@/lib/auth';
6 | import { MutationConfig } from '@/lib/react-query';
7 |
8 | export const updateProfileInputSchema = z.object({
9 | email: z.string().min(1, 'Required').email('Invalid email'),
10 | firstName: z.string().min(1, 'Required'),
11 | lastName: z.string().min(1, 'Required'),
12 | bio: z.string(),
13 | });
14 |
15 | export type UpdateProfileInput = z.infer;
16 |
17 | export const updateProfile = ({ data }: { data: UpdateProfileInput }) => {
18 | return api.patch(`/users/profile`, data);
19 | };
20 |
21 | type UseUpdateProfileOptions = {
22 | mutationConfig?: MutationConfig;
23 | };
24 |
25 | export const useUpdateProfile = ({
26 | mutationConfig,
27 | }: UseUpdateProfileOptions = {}) => {
28 | const { refetch: refetchUser } = useUser();
29 |
30 | const { onSuccess, ...restConfig } = mutationConfig || {};
31 |
32 | return useMutation({
33 | onSuccess: (...args) => {
34 | refetchUser();
35 | onSuccess?.(...args);
36 | },
37 | ...restConfig,
38 | mutationFn: updateProfile,
39 | });
40 | };
41 |
--------------------------------------------------------------------------------
/apps/react-vite/src/hooks/use-disclosure.ts:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 |
3 | export const useDisclosure = (initial = false) => {
4 | const [isOpen, setIsOpen] = React.useState(initial);
5 |
6 | const open = React.useCallback(() => setIsOpen(true), []);
7 | const close = React.useCallback(() => setIsOpen(false), []);
8 | const toggle = React.useCallback(() => setIsOpen((state) => !state), []);
9 |
10 | return { isOpen, open, close, toggle };
11 | };
12 |
--------------------------------------------------------------------------------
/apps/react-vite/src/lib/api-client.ts:
--------------------------------------------------------------------------------
1 | import Axios, { InternalAxiosRequestConfig } from 'axios';
2 |
3 | import { useNotifications } from '@/components/ui/notifications';
4 | import { env } from '@/config/env';
5 | import { paths } from '@/config/paths';
6 |
7 | function authRequestInterceptor(config: InternalAxiosRequestConfig) {
8 | if (config.headers) {
9 | config.headers.Accept = 'application/json';
10 | }
11 |
12 | config.withCredentials = true;
13 | return config;
14 | }
15 |
16 | export const api = Axios.create({
17 | baseURL: env.API_URL,
18 | });
19 |
20 | api.interceptors.request.use(authRequestInterceptor);
21 | api.interceptors.response.use(
22 | (response) => {
23 | return response.data;
24 | },
25 | (error) => {
26 | const message = error.response?.data?.message || error.message;
27 | useNotifications.getState().addNotification({
28 | type: 'error',
29 | title: 'Error',
30 | message,
31 | });
32 |
33 | if (error.response?.status === 401) {
34 | const searchParams = new URLSearchParams();
35 | const redirectTo =
36 | searchParams.get('redirectTo') || window.location.pathname;
37 | window.location.href = paths.auth.login.getHref(redirectTo);
38 | }
39 |
40 | return Promise.reject(error);
41 | },
42 | );
43 |
--------------------------------------------------------------------------------
/apps/react-vite/src/lib/react-query.ts:
--------------------------------------------------------------------------------
1 | import { UseMutationOptions, DefaultOptions } from '@tanstack/react-query';
2 |
3 | export const queryConfig = {
4 | queries: {
5 | // throwOnError: true,
6 | refetchOnWindowFocus: false,
7 | retry: false,
8 | staleTime: 1000 * 60,
9 | },
10 | } satisfies DefaultOptions;
11 |
12 | export type ApiFnReturnType Promise> =
13 | Awaited>;
14 |
15 | export type QueryConfig any> = Omit<
16 | ReturnType,
17 | 'queryKey' | 'queryFn'
18 | >;
19 |
20 | export type MutationConfig<
21 | MutationFnType extends (...args: any) => Promise,
22 | > = UseMutationOptions<
23 | ApiFnReturnType,
24 | Error,
25 | Parameters[0]
26 | >;
27 |
--------------------------------------------------------------------------------
/apps/react-vite/src/main.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 | import { createRoot } from 'react-dom/client';
3 |
4 | import './index.css';
5 | import { App } from './app';
6 | import { enableMocking } from './testing/mocks';
7 |
8 | const root = document.getElementById('root');
9 | if (!root) throw new Error('No root element found');
10 |
11 | enableMocking().then(() => {
12 | createRoot(root).render(
13 |
14 |
15 | ,
16 | );
17 | });
18 |
--------------------------------------------------------------------------------
/apps/react-vite/src/testing/mocks/browser.ts:
--------------------------------------------------------------------------------
1 | import { setupWorker } from 'msw/browser';
2 |
3 | import { handlers } from './handlers';
4 |
5 | export const worker = setupWorker(...handlers);
6 |
--------------------------------------------------------------------------------
/apps/react-vite/src/testing/mocks/handlers/index.ts:
--------------------------------------------------------------------------------
1 | import { HttpResponse, http } from 'msw';
2 |
3 | import { env } from '@/config/env';
4 |
5 | import { networkDelay } from '../utils';
6 |
7 | import { authHandlers } from './auth';
8 | import { commentsHandlers } from './comments';
9 | import { discussionsHandlers } from './discussions';
10 | import { teamsHandlers } from './teams';
11 | import { usersHandlers } from './users';
12 |
13 | export const handlers = [
14 | ...authHandlers,
15 | ...commentsHandlers,
16 | ...discussionsHandlers,
17 | ...teamsHandlers,
18 | ...usersHandlers,
19 | http.get(`${env.API_URL}/healthcheck`, async () => {
20 | await networkDelay();
21 | return HttpResponse.json({ ok: true });
22 | }),
23 | ];
24 |
--------------------------------------------------------------------------------
/apps/react-vite/src/testing/mocks/handlers/teams.ts:
--------------------------------------------------------------------------------
1 | import { HttpResponse, http } from 'msw';
2 |
3 | import { env } from '@/config/env';
4 |
5 | import { db } from '../db';
6 | import { networkDelay } from '../utils';
7 |
8 | export const teamsHandlers = [
9 | http.get(`${env.API_URL}/teams`, async () => {
10 | await networkDelay();
11 |
12 | try {
13 | const result = db.team.getAll();
14 | return HttpResponse.json({ data: result });
15 | } catch (error: any) {
16 | return HttpResponse.json(
17 | { message: error?.message || 'Server Error' },
18 | { status: 500 },
19 | );
20 | }
21 | }),
22 | ];
23 |
--------------------------------------------------------------------------------
/apps/react-vite/src/testing/mocks/index.ts:
--------------------------------------------------------------------------------
1 | import { env } from '@/config/env';
2 |
3 | export const enableMocking = async () => {
4 | if (env.ENABLE_API_MOCKING) {
5 | const { worker } = await import('./browser');
6 | const { initializeDb } = await import('./db');
7 | await initializeDb();
8 | return worker.start();
9 | }
10 | };
11 |
--------------------------------------------------------------------------------
/apps/react-vite/src/testing/mocks/server.ts:
--------------------------------------------------------------------------------
1 | import { setupServer } from 'msw/node';
2 |
3 | import { handlers } from './handlers';
4 |
5 | export const server = setupServer(...handlers);
6 |
--------------------------------------------------------------------------------
/apps/react-vite/src/testing/setup-tests.ts:
--------------------------------------------------------------------------------
1 | import '@testing-library/jest-dom/vitest';
2 |
3 | import { initializeDb, resetDb } from '@/testing/mocks/db';
4 | import { server } from '@/testing/mocks/server';
5 |
6 | vi.mock('zustand');
7 |
8 | beforeAll(() => server.listen({ onUnhandledRequest: 'error' }));
9 | afterAll(() => server.close());
10 | beforeEach(() => {
11 | const ResizeObserverMock = vi.fn(() => ({
12 | observe: vi.fn(),
13 | unobserve: vi.fn(),
14 | disconnect: vi.fn(),
15 | }));
16 |
17 | vi.stubGlobal('ResizeObserver', ResizeObserverMock);
18 |
19 | window.btoa = (str: string) => Buffer.from(str, 'binary').toString('base64');
20 | window.atob = (str: string) => Buffer.from(str, 'base64').toString('binary');
21 |
22 | initializeDb();
23 | });
24 | afterEach(() => {
25 | server.resetHandlers();
26 | resetDb();
27 | });
28 |
--------------------------------------------------------------------------------
/apps/react-vite/src/types/api.ts:
--------------------------------------------------------------------------------
1 | // let's imagine this file is autogenerated from the backend
2 | // ideally, we want to keep these api related types in sync
3 | // with the backend instead of manually writing them out
4 |
5 | export type BaseEntity = {
6 | id: string;
7 | createdAt: number;
8 | };
9 |
10 | export type Entity = {
11 | [K in keyof T]: T[K];
12 | } & BaseEntity;
13 |
14 | export type Meta = {
15 | page: number;
16 | total: number;
17 | totalPages: number;
18 | };
19 |
20 | export type User = Entity<{
21 | firstName: string;
22 | lastName: string;
23 | email: string;
24 | role: 'ADMIN' | 'USER';
25 | teamId: string;
26 | bio: string;
27 | }>;
28 |
29 | export type AuthResponse = {
30 | jwt: string;
31 | user: User;
32 | };
33 |
34 | export type Team = Entity<{
35 | name: string;
36 | description: string;
37 | }>;
38 |
39 | export type Discussion = Entity<{
40 | title: string;
41 | body: string;
42 | teamId: string;
43 | author: User;
44 | }>;
45 |
46 | export type Comment = Entity<{
47 | body: string;
48 | discussionId: string;
49 | author: User;
50 | }>;
51 |
--------------------------------------------------------------------------------
/apps/react-vite/src/utils/cn.ts:
--------------------------------------------------------------------------------
1 | import { type ClassValue, clsx } from 'clsx';
2 | import { twMerge } from 'tailwind-merge';
3 |
4 | export function cn(...inputs: ClassValue[]) {
5 | return twMerge(clsx(inputs));
6 | }
7 |
--------------------------------------------------------------------------------
/apps/react-vite/src/utils/format.ts:
--------------------------------------------------------------------------------
1 | import { default as dayjs } from 'dayjs';
2 |
3 | export const formatDate = (date: number) =>
4 | dayjs(date).format('MMMM D, YYYY h:mm A');
5 |
--------------------------------------------------------------------------------
/apps/react-vite/src/vite-env.d.ts:
--------------------------------------------------------------------------------
1 | ///
2 |
--------------------------------------------------------------------------------
/apps/react-vite/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "ESNext",
4 | "lib": ["dom", "dom.iterable", "esnext"],
5 | "allowJs": true,
6 | "skipLibCheck": true,
7 | "esModuleInterop": true,
8 | "allowSyntheticDefaultImports": true,
9 | "strict": true,
10 | "forceConsistentCasingInFileNames": true,
11 | "noFallthroughCasesInSwitch": true,
12 | "module": "esnext",
13 | "moduleResolution": "bundler",
14 | "resolveJsonModule": true,
15 | "noEmit": true,
16 | "jsx": "react-jsx",
17 | "types": ["vite/client", "vitest/globals"],
18 | "isolatedModules": true,
19 | "baseUrl": ".",
20 | "paths": {
21 | "@/*": ["./src/*"]
22 | }
23 | },
24 | "include": ["src"],
25 | "exclude": ["node_modules"]
26 | }
27 |
--------------------------------------------------------------------------------
/apps/react-vite/vite-env.d.ts:
--------------------------------------------------------------------------------
1 | ///
2 |
--------------------------------------------------------------------------------
/apps/react-vite/vite.config.ts:
--------------------------------------------------------------------------------
1 | ///
2 | ///
3 |
4 | import react from '@vitejs/plugin-react';
5 | import { defineConfig } from 'vite';
6 | import viteTsconfigPaths from 'vite-tsconfig-paths';
7 |
8 | export default defineConfig({
9 | base: './',
10 | plugins: [react(), viteTsconfigPaths()],
11 | server: {
12 | port: 3000,
13 | },
14 | preview: {
15 | port: 3000,
16 | },
17 | test: {
18 | globals: true,
19 | environment: 'jsdom',
20 | setupFiles: './src/testing/setup-tests.ts',
21 | exclude: ['**/node_modules/**', '**/e2e/**'],
22 | coverage: {
23 | include: ['src/**'],
24 | },
25 | },
26 | optimizeDeps: { exclude: ['fsevents'] },
27 | build: {
28 | rollupOptions: {
29 | external: ['fs/promises'],
30 | output: {
31 | experimentalMinChunkSize: 3500,
32 | },
33 | },
34 | },
35 | });
36 |
--------------------------------------------------------------------------------
/docs/additional-resources.md:
--------------------------------------------------------------------------------
1 | # 📚 Additional Resources
2 |
3 | ## React
4 |
5 | - [Official Documentation](https://react.dev/)
6 | - [Tao Of React](https://alexkondov.com/tao-of-react/)
7 | - [React Handbook](https://reacthandbook.dev/)
8 | - [React Philosophies](https://github.com/mithi/react-philosophies)
9 | - [React Patterns](https://reactpatterns.com/)
10 | - [React Typescript Cheatsheet](https://react-typescript-cheatsheet.netlify.app/)
11 |
12 | ## JavaScript
13 |
14 | - [You Dont Know JS](https://github.com/getify/You-Dont-Know-JS)
15 | - [JavaScript Info](https://javascript.info/)
16 | - [33 Concepts Every JavaScript Developer Should Know](https://github.com/leonardomso/33-js-concepts#8-iife-modules-and-namespaces)
17 | - [JavaScript to Know for React](https://kentcdodds.com/blog/javascript-to-know-for-react)
18 |
19 | ## Best Practices
20 |
21 | - [patterns.dev](https://www.patterns.dev/)
22 | - [Naming Cheatsheet](https://github.com/kettanaito/naming-cheatsheet)
23 | - [Clean Code Javascript](https://github.com/ryanmcdermott/clean-code-javascript)
24 |
--------------------------------------------------------------------------------
/docs/application-overview.md:
--------------------------------------------------------------------------------
1 | # 💻 Application Overview
2 |
3 | The application is relatively simple. Users can create teams where other users can join, and they start discussions on different topics between each other.
4 |
5 | A team is created during the registration if the user didn't choose to join an existing team and the user becomes the admin of it.
6 |
7 | [Demo](https://bulletproof-react-app.netlify.app)
8 |
9 | ## Data model
10 |
11 | The application contains the following models:
12 |
13 | - User - can have one of these roles:
14 |
15 | - `ADMIN` can:
16 | - create/edit/delete discussions
17 | - create/delete all comments
18 | - delete users
19 | - edit own profile
20 | - `USER` - can:
21 | - edit own profile
22 | - create/delete own comments
23 |
24 | - Team: represents a team that has 1 admin and many users that can participate in discussions between each other.
25 |
26 | - Discussion: represents discussions created by team members.
27 |
28 | - Comment: represents all the messages in a discussion.
29 |
30 | ## Get Started
31 |
32 | To get started, check the README.md file in the application you want to run.
33 |
34 | - [React Vite](../apps/react-vite/README.md)
35 | - [Next.js App Router](../apps/nextjs-app/README.md)
36 | - [Next.js Pages](../apps/nextjs-pages/README.md)
37 |
--------------------------------------------------------------------------------
/docs/assets/unidirectional-codebase.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/alan2207/bulletproof-react/49c4249fd68ef2196151ef34cc2c68cb4fe81dc1/docs/assets/unidirectional-codebase.png
--------------------------------------------------------------------------------
/docs/deployment.md:
--------------------------------------------------------------------------------
1 | # 🌐 Deployment
2 |
3 | Deploy and serve your applications and assets over a CDN for best delivery and performance. Good options for that are:
4 |
5 | - [Vercel](https://vercel.com/)
6 | - [Netlify](https://www.netlify.com/)
7 | - [AWS](https://aws.amazon.com/cloudfront/)
8 | - [CloudFlare](https://www.cloudflare.com/en-gb/cdn/)
9 |
--------------------------------------------------------------------------------
/docs/error-handling.md:
--------------------------------------------------------------------------------
1 | # ⚠️ Error Handling
2 |
3 | ### API Errors
4 |
5 | Implement an interceptor to manage errors effectively. This interceptor can be utilized to trigger notification toasts informing users of errors, log out unauthorized users, or send requests to refresh tokens to maintain secure and seamless application operation.
6 |
7 | [API Errors Notification Example Code](../apps/react-vite/src/lib/api-client.ts)
8 |
9 | ### In App Errors
10 |
11 | Utilize error boundaries in React to handle errors within specific parts of your application. Instead of having only one error boundary for the entire app, consider placing multiple error boundaries in different areas. This way, if an error occurs, it can be contained and managed locally without disrupting the entire application's functionality, ensuring a smoother user experience.
12 |
13 | [Error Boundary Example Code](../apps/react-vite/src/app/routes/app/discussions/discussion.tsx)
14 |
15 | ### Error Tracking
16 |
17 | You should track any errors that occur in production. Although it's possible to implement your own solution, it is a better idea to use tools like [Sentry](https://sentry.io/). It will report any issue that breaks the app. You will also be able to see on which platform, browser, etc. did it occur. Make sure to upload source maps to sentry to see where in your source code did the error happen.
18 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "bulletproof-react",
3 | "private": true,
4 | "version": "1.0.0",
5 | "description": "A simple, scalable, and powerful architecture for building production ready React applications.",
6 | "scripts": {
7 | "prepare": "cd ./apps/nextjs-app && yarn && cd ../nextjs-pages && yarn && cd ../react-vite && yarn"
8 | },
9 | "author": "alan2207",
10 | "license": "MIT"
11 | }
12 |
--------------------------------------------------------------------------------