├── .github └── workflows │ ├── nextjs-app-ci.yml │ ├── nextjs-pages-ci.yml │ └── react-vite-ci.yml ├── .gitignore ├── .husky └── pre-commit ├── LICENSE ├── README.md ├── apps ├── nextjs-app │ ├── .env.example │ ├── .env.example-e2e │ ├── .eslintrc.cjs │ ├── .gitignore │ ├── .prettierignore │ ├── .prettierrc │ ├── .storybook │ │ ├── main.ts │ │ └── preview.tsx │ ├── .vscode │ │ ├── extensions.json │ │ └── settings.json │ ├── README.md │ ├── __mocks__ │ │ ├── vitest-env.d.ts │ │ └── zustand.ts │ ├── e2e │ │ ├── .eslintrc.cjs │ │ └── tests │ │ │ ├── auth.setup.ts │ │ │ ├── profile.spec.ts │ │ │ └── smoke.spec.ts │ ├── generators │ │ └── component │ │ │ ├── component.stories.tsx.hbs │ │ │ ├── component.tsx.hbs │ │ │ ├── index.cjs │ │ │ └── index.ts.hbs │ ├── index.html │ ├── lint-staged.config.mjs │ ├── mock-server.ts │ ├── next-env.d.ts │ ├── next.config.mjs │ ├── package.json │ ├── playwright.config.ts │ ├── plopfile.cjs │ ├── postcss.config.cjs │ ├── public │ │ ├── _redirects │ │ ├── favicon.ico │ │ ├── logo.svg │ │ ├── logo192.png │ │ ├── logo512.png │ │ ├── mockServiceWorker.js │ │ └── robots.txt │ ├── src │ │ ├── app │ │ │ ├── app │ │ │ │ ├── _components │ │ │ │ │ ├── dashboard-info.tsx │ │ │ │ │ └── dashboard-layout.tsx │ │ │ │ ├── discussions │ │ │ │ │ ├── [discussionId] │ │ │ │ │ │ ├── __tests__ │ │ │ │ │ │ │ └── discussion.test.tsx │ │ │ │ │ │ ├── _components │ │ │ │ │ │ │ └── discussion.tsx │ │ │ │ │ │ └── page.tsx │ │ │ │ │ ├── __tests__ │ │ │ │ │ │ └── discussions.test.tsx │ │ │ │ │ ├── _components │ │ │ │ │ │ └── discussions.tsx │ │ │ │ │ └── page.tsx │ │ │ │ ├── layout.tsx │ │ │ │ ├── page.tsx │ │ │ │ ├── profile │ │ │ │ │ ├── _components │ │ │ │ │ │ └── profile.tsx │ │ │ │ │ └── page.tsx │ │ │ │ └── users │ │ │ │ │ ├── _components │ │ │ │ │ ├── admin-guard.tsx │ │ │ │ │ └── users.tsx │ │ │ │ │ └── page.tsx │ │ │ ├── auth │ │ │ │ ├── _components │ │ │ │ │ └── auth-layout.tsx │ │ │ │ ├── layout.tsx │ │ │ │ ├── login │ │ │ │ │ └── page.tsx │ │ │ │ └── register │ │ │ │ │ └── page.tsx │ │ │ ├── layout.tsx │ │ │ ├── not-found.tsx │ │ │ ├── page.tsx │ │ │ ├── provider.tsx │ │ │ └── public │ │ │ │ └── discussions │ │ │ │ └── [discussionId] │ │ │ │ └── page.tsx │ │ ├── components │ │ │ ├── errors │ │ │ │ └── main.tsx │ │ │ ├── layouts │ │ │ │ └── content-layout.tsx │ │ │ └── ui │ │ │ │ ├── button │ │ │ │ ├── button.stories.tsx │ │ │ │ ├── button.tsx │ │ │ │ └── index.ts │ │ │ │ ├── dialog │ │ │ │ ├── __tests__ │ │ │ │ │ └── dialog.test.tsx │ │ │ │ ├── confirmation-dialog │ │ │ │ │ ├── __tests__ │ │ │ │ │ │ └── confirmation-dialog.test.tsx │ │ │ │ │ ├── confirmation-dialog.stories.tsx │ │ │ │ │ ├── confirmation-dialog.tsx │ │ │ │ │ └── index.ts │ │ │ │ ├── dialog.stories.tsx │ │ │ │ ├── dialog.tsx │ │ │ │ └── index.ts │ │ │ │ ├── drawer │ │ │ │ ├── __tests__ │ │ │ │ │ └── drawer.test.tsx │ │ │ │ ├── drawer.stories.tsx │ │ │ │ ├── drawer.tsx │ │ │ │ └── index.ts │ │ │ │ ├── dropdown │ │ │ │ ├── dropdown.stories.tsx │ │ │ │ ├── dropdown.tsx │ │ │ │ └── index.ts │ │ │ │ ├── form │ │ │ │ ├── __tests__ │ │ │ │ │ └── form.test.tsx │ │ │ │ ├── error.tsx │ │ │ │ ├── field-wrapper.tsx │ │ │ │ ├── form-drawer.tsx │ │ │ │ ├── form.stories.tsx │ │ │ │ ├── form.tsx │ │ │ │ ├── index.ts │ │ │ │ ├── input.tsx │ │ │ │ ├── label.tsx │ │ │ │ ├── select.tsx │ │ │ │ ├── switch.tsx │ │ │ │ └── textarea.tsx │ │ │ │ ├── link │ │ │ │ ├── index.ts │ │ │ │ ├── link.stories.tsx │ │ │ │ └── link.tsx │ │ │ │ ├── md-preview │ │ │ │ ├── index.ts │ │ │ │ ├── md-preview.stories.tsx │ │ │ │ └── md-preview.tsx │ │ │ │ ├── notifications │ │ │ │ ├── __tests__ │ │ │ │ │ └── notifications.test.ts │ │ │ │ ├── index.ts │ │ │ │ ├── notification.stories.tsx │ │ │ │ ├── notification.tsx │ │ │ │ ├── notifications-store.ts │ │ │ │ └── notifications.tsx │ │ │ │ ├── spinner │ │ │ │ ├── index.ts │ │ │ │ ├── spinner.stories.tsx │ │ │ │ └── spinner.tsx │ │ │ │ └── table │ │ │ │ ├── index.ts │ │ │ │ ├── pagination.tsx │ │ │ │ ├── table.stories.tsx │ │ │ │ └── table.tsx │ │ ├── config │ │ │ ├── env.ts │ │ │ └── paths.ts │ │ ├── features │ │ │ ├── auth │ │ │ │ └── components │ │ │ │ │ ├── __tests__ │ │ │ │ │ ├── login-form.test.tsx │ │ │ │ │ └── register-form.test.tsx │ │ │ │ │ ├── login-form.tsx │ │ │ │ │ └── register-form.tsx │ │ │ ├── comments │ │ │ │ ├── api │ │ │ │ │ ├── create-comment.ts │ │ │ │ │ ├── delete-comment.ts │ │ │ │ │ └── get-comments.ts │ │ │ │ └── components │ │ │ │ │ ├── comments-list.tsx │ │ │ │ │ ├── comments.tsx │ │ │ │ │ ├── create-comment.tsx │ │ │ │ │ └── delete-comment.tsx │ │ │ ├── discussions │ │ │ │ ├── api │ │ │ │ │ ├── create-discussion.ts │ │ │ │ │ ├── delete-discussion.ts │ │ │ │ │ ├── get-discussion.ts │ │ │ │ │ ├── get-discussions.ts │ │ │ │ │ └── update-discussion.ts │ │ │ │ └── components │ │ │ │ │ ├── create-discussion.tsx │ │ │ │ │ ├── delete-discussion.tsx │ │ │ │ │ ├── discussion-view.tsx │ │ │ │ │ ├── discussions-list.tsx │ │ │ │ │ └── update-discussion.tsx │ │ │ ├── teams │ │ │ │ └── api │ │ │ │ │ └── get-teams.ts │ │ │ └── users │ │ │ │ ├── api │ │ │ │ ├── delete-user.ts │ │ │ │ ├── get-users.ts │ │ │ │ └── update-profile.ts │ │ │ │ └── components │ │ │ │ ├── delete-user.tsx │ │ │ │ ├── update-profile.tsx │ │ │ │ └── users-list.tsx │ │ ├── hooks │ │ │ ├── __tests__ │ │ │ │ └── use-disclosure.test.ts │ │ │ └── use-disclosure.ts │ │ ├── lib │ │ │ ├── __tests__ │ │ │ │ └── authorization.test.tsx │ │ │ ├── api-client.ts │ │ │ ├── auth.tsx │ │ │ ├── authorization.ts │ │ │ └── react-query.ts │ │ ├── styles │ │ │ └── globals.css │ │ ├── testing │ │ │ ├── data-generators.ts │ │ │ ├── mocks │ │ │ │ ├── browser.ts │ │ │ │ ├── db.ts │ │ │ │ ├── handlers │ │ │ │ │ ├── auth.ts │ │ │ │ │ ├── comments.ts │ │ │ │ │ ├── discussions.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── teams.ts │ │ │ │ │ └── users.ts │ │ │ │ ├── index.ts │ │ │ │ ├── server.ts │ │ │ │ └── utils.ts │ │ │ ├── setup-tests.ts │ │ │ └── test-utils.tsx │ │ ├── types │ │ │ └── api.ts │ │ └── utils │ │ │ ├── auth.ts │ │ │ ├── cn.ts │ │ │ └── format.ts │ ├── tailwind.config.cjs │ ├── tsconfig.json │ ├── vitest.config.ts │ └── yarn.lock ├── nextjs-pages │ ├── .env.example │ ├── .env.example-e2e │ ├── .eslintrc.cjs │ ├── .gitignore │ ├── .prettierignore │ ├── .prettierrc │ ├── .storybook │ │ ├── main.ts │ │ └── preview.tsx │ ├── .vscode │ │ ├── extensions.json │ │ └── settings.json │ ├── README.md │ ├── __mocks__ │ │ ├── vitest-env.d.ts │ │ └── zustand.ts │ ├── e2e │ │ ├── .eslintrc.cjs │ │ └── tests │ │ │ ├── auth.setup.ts │ │ │ ├── profile.spec.ts │ │ │ └── smoke.spec.ts │ ├── generators │ │ └── component │ │ │ ├── component.stories.tsx.hbs │ │ │ ├── component.tsx.hbs │ │ │ ├── index.cjs │ │ │ └── index.ts.hbs │ ├── lint-staged.config.mjs │ ├── mock-server.ts │ ├── next-env.d.ts │ ├── next.config.mjs │ ├── package.json │ ├── playwright.config.ts │ ├── plopfile.cjs │ ├── postcss.config.cjs │ ├── public │ │ ├── _redirects │ │ ├── favicon.ico │ │ ├── logo.svg │ │ ├── logo192.png │ │ ├── logo512.png │ │ ├── mockServiceWorker.js │ │ └── robots.txt │ ├── src │ │ ├── app │ │ │ ├── pages │ │ │ │ ├── app │ │ │ │ │ ├── dashboard.tsx │ │ │ │ │ ├── discussions │ │ │ │ │ │ ├── __tests__ │ │ │ │ │ │ │ ├── discussion.test.tsx │ │ │ │ │ │ │ └── discussions.test.tsx │ │ │ │ │ │ ├── discussion.tsx │ │ │ │ │ │ └── discussions.tsx │ │ │ │ │ ├── profile.tsx │ │ │ │ │ └── users.tsx │ │ │ │ └── auth │ │ │ │ │ ├── login.tsx │ │ │ │ │ └── register.tsx │ │ │ └── provider.tsx │ │ ├── components │ │ │ ├── errors │ │ │ │ └── main.tsx │ │ │ ├── layouts │ │ │ │ ├── auth-layout.tsx │ │ │ │ ├── content-layout.tsx │ │ │ │ ├── dashboard-layout.tsx │ │ │ │ └── index.ts │ │ │ ├── seo │ │ │ │ ├── head.tsx │ │ │ │ └── index.ts │ │ │ └── ui │ │ │ │ ├── button │ │ │ │ ├── button.stories.tsx │ │ │ │ ├── button.tsx │ │ │ │ └── index.ts │ │ │ │ ├── dialog │ │ │ │ ├── __tests__ │ │ │ │ │ └── dialog.test.tsx │ │ │ │ ├── confirmation-dialog │ │ │ │ │ ├── __tests__ │ │ │ │ │ │ └── confirmation-dialog.test.tsx │ │ │ │ │ ├── confirmation-dialog.stories.tsx │ │ │ │ │ ├── confirmation-dialog.tsx │ │ │ │ │ └── index.ts │ │ │ │ ├── dialog.stories.tsx │ │ │ │ ├── dialog.tsx │ │ │ │ └── index.ts │ │ │ │ ├── drawer │ │ │ │ ├── __tests__ │ │ │ │ │ └── drawer.test.tsx │ │ │ │ ├── drawer.stories.tsx │ │ │ │ ├── drawer.tsx │ │ │ │ └── index.ts │ │ │ │ ├── dropdown │ │ │ │ ├── dropdown.stories.tsx │ │ │ │ ├── dropdown.tsx │ │ │ │ └── index.ts │ │ │ │ ├── form │ │ │ │ ├── __tests__ │ │ │ │ │ └── form.test.tsx │ │ │ │ ├── error.tsx │ │ │ │ ├── field-wrapper.tsx │ │ │ │ ├── form-drawer.tsx │ │ │ │ ├── form.stories.tsx │ │ │ │ ├── form.tsx │ │ │ │ ├── index.ts │ │ │ │ ├── input.tsx │ │ │ │ ├── label.tsx │ │ │ │ ├── select.tsx │ │ │ │ ├── switch.tsx │ │ │ │ └── textarea.tsx │ │ │ │ ├── link │ │ │ │ ├── index.ts │ │ │ │ ├── link.stories.tsx │ │ │ │ └── link.tsx │ │ │ │ ├── md-preview │ │ │ │ ├── index.ts │ │ │ │ ├── md-preview.stories.tsx │ │ │ │ └── md-preview.tsx │ │ │ │ ├── notifications │ │ │ │ ├── __tests__ │ │ │ │ │ └── notifications.test.ts │ │ │ │ ├── index.ts │ │ │ │ ├── notification.stories.tsx │ │ │ │ ├── notification.tsx │ │ │ │ ├── notifications-store.ts │ │ │ │ └── notifications.tsx │ │ │ │ ├── spinner │ │ │ │ ├── index.ts │ │ │ │ ├── spinner.stories.tsx │ │ │ │ └── spinner.tsx │ │ │ │ └── table │ │ │ │ ├── index.ts │ │ │ │ ├── pagination.tsx │ │ │ │ ├── table.stories.tsx │ │ │ │ └── table.tsx │ │ ├── config │ │ │ ├── env.ts │ │ │ └── paths.ts │ │ ├── features │ │ │ ├── auth │ │ │ │ └── components │ │ │ │ │ ├── __tests__ │ │ │ │ │ ├── login-form.test.tsx │ │ │ │ │ └── register-form.test.tsx │ │ │ │ │ ├── login-form.tsx │ │ │ │ │ └── register-form.tsx │ │ │ ├── comments │ │ │ │ ├── api │ │ │ │ │ ├── create-comment.ts │ │ │ │ │ ├── delete-comment.ts │ │ │ │ │ └── get-comments.ts │ │ │ │ └── components │ │ │ │ │ ├── comments-list.tsx │ │ │ │ │ ├── comments.tsx │ │ │ │ │ ├── create-comment.tsx │ │ │ │ │ └── delete-comment.tsx │ │ │ ├── discussions │ │ │ │ ├── api │ │ │ │ │ ├── create-discussion.ts │ │ │ │ │ ├── delete-discussion.ts │ │ │ │ │ ├── get-discussion.ts │ │ │ │ │ ├── get-discussions.ts │ │ │ │ │ └── update-discussion.ts │ │ │ │ └── components │ │ │ │ │ ├── create-discussion.tsx │ │ │ │ │ ├── delete-discussion.tsx │ │ │ │ │ ├── discussion-view.tsx │ │ │ │ │ ├── discussions-list.tsx │ │ │ │ │ └── update-discussion.tsx │ │ │ ├── teams │ │ │ │ └── api │ │ │ │ │ └── get-teams.ts │ │ │ └── users │ │ │ │ ├── api │ │ │ │ ├── delete-user.ts │ │ │ │ ├── get-users.ts │ │ │ │ └── update-profile.ts │ │ │ │ └── components │ │ │ │ ├── delete-user.tsx │ │ │ │ ├── update-profile.tsx │ │ │ │ └── users-list.tsx │ │ ├── hooks │ │ │ ├── __tests__ │ │ │ │ └── use-disclosure.test.ts │ │ │ └── use-disclosure.ts │ │ ├── lib │ │ │ ├── __tests__ │ │ │ │ └── authorization.test.tsx │ │ │ ├── api-client.ts │ │ │ ├── auth.tsx │ │ │ ├── authorization.tsx │ │ │ └── react-query.ts │ │ ├── pages │ │ │ ├── 404.tsx │ │ │ ├── _app.tsx │ │ │ ├── app │ │ │ │ ├── discussions │ │ │ │ │ ├── [discussionId].tsx │ │ │ │ │ └── index.tsx │ │ │ │ ├── index.tsx │ │ │ │ ├── profile.tsx │ │ │ │ └── users.tsx │ │ │ ├── auth │ │ │ │ ├── login.tsx │ │ │ │ └── register.tsx │ │ │ ├── index.tsx │ │ │ └── public │ │ │ │ └── discussions │ │ │ │ └── [discussionId].tsx │ │ ├── styles │ │ │ └── globals.css │ │ ├── testing │ │ │ ├── data-generators.ts │ │ │ ├── mocks │ │ │ │ ├── browser.ts │ │ │ │ ├── db.ts │ │ │ │ ├── handlers │ │ │ │ │ ├── auth.ts │ │ │ │ │ ├── comments.ts │ │ │ │ │ ├── discussions.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── teams.ts │ │ │ │ │ └── users.ts │ │ │ │ ├── index.ts │ │ │ │ ├── server.ts │ │ │ │ └── utils.ts │ │ │ ├── setup-tests.ts │ │ │ └── test-utils.tsx │ │ ├── types │ │ │ └── api.ts │ │ └── utils │ │ │ ├── cn.ts │ │ │ └── format.ts │ ├── tailwind.config.cjs │ ├── tsconfig.json │ ├── vitest.config.ts │ └── yarn.lock └── react-vite │ ├── .env.example │ ├── .env.example-e2e │ ├── .eslintrc.cjs │ ├── .gitignore │ ├── .prettierignore │ ├── .prettierrc │ ├── .storybook │ ├── main.ts │ └── preview.tsx │ ├── .vscode │ ├── extensions.json │ └── settings.json │ ├── README.md │ ├── __mocks__ │ ├── vitest-env.d.ts │ └── zustand.ts │ ├── e2e │ ├── .eslintrc.cjs │ └── tests │ │ ├── auth.setup.ts │ │ ├── profile.spec.ts │ │ └── smoke.spec.ts │ ├── generators │ └── component │ │ ├── component.stories.tsx.hbs │ │ ├── component.tsx.hbs │ │ ├── index.cjs │ │ └── index.ts.hbs │ ├── index.html │ ├── mock-server.ts │ ├── package.json │ ├── playwright.config.ts │ ├── plopfile.cjs │ ├── postcss.config.cjs │ ├── public │ ├── _redirects │ ├── favicon.ico │ ├── logo192.png │ ├── logo512.png │ ├── mockServiceWorker.js │ └── robots.txt │ ├── src │ ├── app │ │ ├── index.tsx │ │ ├── provider.tsx │ │ ├── router.tsx │ │ └── routes │ │ │ ├── app │ │ │ ├── dashboard.tsx │ │ │ ├── discussions │ │ │ │ ├── __tests__ │ │ │ │ │ ├── discussion.test.tsx │ │ │ │ │ └── discussions.test.tsx │ │ │ │ ├── discussion.tsx │ │ │ │ └── discussions.tsx │ │ │ ├── profile.tsx │ │ │ ├── root.tsx │ │ │ └── users.tsx │ │ │ ├── auth │ │ │ ├── login.tsx │ │ │ └── register.tsx │ │ │ ├── landing.tsx │ │ │ └── not-found.tsx │ ├── assets │ │ └── logo.svg │ ├── components │ │ ├── errors │ │ │ └── main.tsx │ │ ├── layouts │ │ │ ├── auth-layout.tsx │ │ │ ├── content-layout.tsx │ │ │ ├── dashboard-layout.tsx │ │ │ └── index.ts │ │ ├── seo │ │ │ ├── __tests__ │ │ │ │ └── head.test.tsx │ │ │ ├── head.tsx │ │ │ └── index.ts │ │ └── ui │ │ │ ├── button │ │ │ ├── button.stories.tsx │ │ │ ├── button.tsx │ │ │ └── index.ts │ │ │ ├── dialog │ │ │ ├── __tests__ │ │ │ │ └── dialog.test.tsx │ │ │ ├── confirmation-dialog │ │ │ │ ├── __tests__ │ │ │ │ │ └── confirmation-dialog.test.tsx │ │ │ │ ├── confirmation-dialog.stories.tsx │ │ │ │ ├── confirmation-dialog.tsx │ │ │ │ └── index.ts │ │ │ ├── dialog.stories.tsx │ │ │ ├── dialog.tsx │ │ │ └── index.ts │ │ │ ├── drawer │ │ │ ├── __tests__ │ │ │ │ └── drawer.test.tsx │ │ │ ├── drawer.stories.tsx │ │ │ ├── drawer.tsx │ │ │ └── index.ts │ │ │ ├── dropdown │ │ │ ├── dropdown.stories.tsx │ │ │ ├── dropdown.tsx │ │ │ └── index.ts │ │ │ ├── form │ │ │ ├── __tests__ │ │ │ │ └── form.test.tsx │ │ │ ├── error.tsx │ │ │ ├── field-wrapper.tsx │ │ │ ├── form-drawer.tsx │ │ │ ├── form.stories.tsx │ │ │ ├── form.tsx │ │ │ ├── index.ts │ │ │ ├── input.tsx │ │ │ ├── label.tsx │ │ │ ├── select.tsx │ │ │ ├── switch.tsx │ │ │ └── textarea.tsx │ │ │ ├── link │ │ │ ├── index.ts │ │ │ ├── link.stories.tsx │ │ │ └── link.tsx │ │ │ ├── md-preview │ │ │ ├── index.ts │ │ │ ├── md-preview.stories.tsx │ │ │ └── md-preview.tsx │ │ │ ├── notifications │ │ │ ├── __tests__ │ │ │ │ └── notifications.test.ts │ │ │ ├── index.ts │ │ │ ├── notification.stories.tsx │ │ │ ├── notification.tsx │ │ │ ├── notifications-store.ts │ │ │ └── notifications.tsx │ │ │ ├── spinner │ │ │ ├── index.ts │ │ │ ├── spinner.stories.tsx │ │ │ └── spinner.tsx │ │ │ └── table │ │ │ ├── index.ts │ │ │ ├── pagination.tsx │ │ │ ├── table.stories.tsx │ │ │ └── table.tsx │ ├── config │ │ ├── env.ts │ │ └── paths.ts │ ├── features │ │ ├── auth │ │ │ └── components │ │ │ │ ├── __tests__ │ │ │ │ ├── login-form.test.tsx │ │ │ │ └── register-form.test.tsx │ │ │ │ ├── login-form.tsx │ │ │ │ └── register-form.tsx │ │ ├── comments │ │ │ ├── api │ │ │ │ ├── create-comment.ts │ │ │ │ ├── delete-comment.ts │ │ │ │ └── get-comments.ts │ │ │ └── components │ │ │ │ ├── comments-list.tsx │ │ │ │ ├── comments.tsx │ │ │ │ ├── create-comment.tsx │ │ │ │ └── delete-comment.tsx │ │ ├── discussions │ │ │ ├── api │ │ │ │ ├── create-discussion.ts │ │ │ │ ├── delete-discussion.ts │ │ │ │ ├── get-discussion.ts │ │ │ │ ├── get-discussions.ts │ │ │ │ └── update-discussion.ts │ │ │ └── components │ │ │ │ ├── create-discussion.tsx │ │ │ │ ├── delete-discussion.tsx │ │ │ │ ├── discussion-view.tsx │ │ │ │ ├── discussions-list.tsx │ │ │ │ └── update-discussion.tsx │ │ ├── teams │ │ │ └── api │ │ │ │ └── get-teams.ts │ │ └── users │ │ │ ├── api │ │ │ ├── delete-user.ts │ │ │ ├── get-users.ts │ │ │ └── update-profile.ts │ │ │ └── components │ │ │ ├── delete-user.tsx │ │ │ ├── update-profile.tsx │ │ │ └── users-list.tsx │ ├── hooks │ │ ├── __tests__ │ │ │ └── use-disclosure.test.ts │ │ └── use-disclosure.ts │ ├── index.css │ ├── lib │ │ ├── __tests__ │ │ │ └── authorization.test.tsx │ │ ├── api-client.ts │ │ ├── auth.tsx │ │ ├── authorization.tsx │ │ └── react-query.ts │ ├── main.tsx │ ├── testing │ │ ├── data-generators.ts │ │ ├── mocks │ │ │ ├── browser.ts │ │ │ ├── db.ts │ │ │ ├── handlers │ │ │ │ ├── auth.ts │ │ │ │ ├── comments.ts │ │ │ │ ├── discussions.ts │ │ │ │ ├── index.ts │ │ │ │ ├── teams.ts │ │ │ │ └── users.ts │ │ │ ├── index.ts │ │ │ ├── server.ts │ │ │ └── utils.ts │ │ ├── setup-tests.ts │ │ └── test-utils.tsx │ ├── types │ │ └── api.ts │ ├── utils │ │ ├── cn.ts │ │ └── format.ts │ └── vite-env.d.ts │ ├── tailwind.config.cjs │ ├── tsconfig.json │ ├── vite-env.d.ts │ ├── vite.config.ts │ └── yarn.lock ├── docs ├── additional-resources.md ├── api-layer.md ├── application-overview.md ├── assets │ └── unidirectional-codebase.png ├── components-and-styling.md ├── deployment.md ├── error-handling.md ├── performance.md ├── project-standards.md ├── project-structure.md ├── security.md ├── state-management.md └── testing.md └── package.json /.gitignore: -------------------------------------------------------------------------------- 1 | # dependencies 2 | /node_modules 3 | /.pnp 4 | .pnp.js 5 | 6 | # misc 7 | .DS_Store -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | yarn --cwd apps/nextjs-app lint-staged && yarn --cwd apps/nextjs-pages lint-staged && yarn --cwd apps/react-vite lint-staged -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Alan Alickovic 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /apps/nextjs-app/.env.example: -------------------------------------------------------------------------------- 1 | NEXT_PUBLIC_API_URL=http://localhost:8080/api 2 | NEXT_PUBLIC_ENABLE_API_MOCKING=false 3 | NEXT_PUBLIC_MOCK_API_PORT=8080 4 | NEXT_PUBLIC_URL=http://localhost:3000 -------------------------------------------------------------------------------- /apps/nextjs-app/.env.example-e2e: -------------------------------------------------------------------------------- 1 | NEXT_PUBLIC_API_URL=http://localhost:8080/api 2 | NEXT_PUBLIC_ENABLE_API_MOCKING=false 3 | NEXT_PUBLIC_MOCK_API_PORT=8080 4 | NEXT_PUBLIC_URL=http://localhost:3000 -------------------------------------------------------------------------------- /apps/nextjs-app/.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 | /.next 42 | /.vite 43 | tsconfig.tsbuildinfo -------------------------------------------------------------------------------- /apps/nextjs-app/.prettierignore: -------------------------------------------------------------------------------- 1 | *.hbs -------------------------------------------------------------------------------- /apps/nextjs-app/.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true, 3 | "trailingComma": "all", 4 | "printWidth": 80, 5 | "tabWidth": 2, 6 | "useTabs": false 7 | } 8 | -------------------------------------------------------------------------------- /apps/nextjs-app/.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: '@storybook/nextjs', 14 | docs: { 15 | autodocs: 'tag', 16 | }, 17 | typescript: { 18 | reactDocgen: 'react-docgen-typescript', 19 | }, 20 | }; 21 | -------------------------------------------------------------------------------- /apps/nextjs-app/.storybook/preview.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import '../src/styles/globals.css'; 3 | 4 | export const parameters = { 5 | actions: { argTypesRegex: '^on[A-Z].*' }, 6 | }; 7 | 8 | export const decorators = [(Story) => ]; 9 | -------------------------------------------------------------------------------- /apps/nextjs-app/.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/nextjs-app/.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "editor.formatOnSave": true, 3 | "editor.codeActionsOnSave": { 4 | "source.fixAll.eslint": "explicit" 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /apps/nextjs-app/README.md: -------------------------------------------------------------------------------- 1 | # Next.js App 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/nextjs-app 16 | cp .env.example .env 17 | yarn install 18 | ``` 19 | 20 | #### `yarn run-mock-server` 21 | 22 | Make sure to start the mock server before running the app. 23 | The mock server runs on [http://localhost:8080/api](http://localhost:8080/api). 24 | 25 | ##### `yarn dev` 26 | 27 | Runs the app in the development mode.\ 28 | Open [http://localhost:3000](http://localhost:3000) to view it in the browser. 29 | -------------------------------------------------------------------------------- /apps/nextjs-app/__mocks__/vitest-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | /// 3 | -------------------------------------------------------------------------------- /apps/nextjs-app/e2e/.eslintrc.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | parser: '@typescript-eslint/parser', 4 | extends: 'plugin:playwright/recommended', 5 | }; 6 | -------------------------------------------------------------------------------- /apps/nextjs-app/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/nextjs-app/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/nextjs-app/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/nextjs-app/generators/component/index.ts.hbs: -------------------------------------------------------------------------------- 1 | export * from './{{ kebabCase name }}'; 2 | -------------------------------------------------------------------------------- /apps/nextjs-app/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | Bulletproof React 12 | 13 | 14 | 15 |
16 | 17 | 18 | 19 | -------------------------------------------------------------------------------- /apps/nextjs-app/lint-staged.config.mjs: -------------------------------------------------------------------------------- 1 | import path from 'path'; 2 | 3 | const buildEslintCommand = (filenames) => { 4 | return `next lint --fix --file ${filenames 5 | .filter((f) => f.includes('/src/')) 6 | .map((f) => path.relative(process.cwd(), f)) 7 | .join(' --file ')}`; 8 | }; 9 | 10 | const config = { 11 | '*.{ts,tsx}': [buildEslintCommand, "bash -c 'yarn check-types'"], 12 | }; 13 | 14 | export default config; 15 | -------------------------------------------------------------------------------- /apps/nextjs-app/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 { initializeDb } from './src/testing/mocks/db'; 7 | import { handlers } from './src/testing/mocks/handlers'; 8 | 9 | const app = express(); 10 | 11 | app.use( 12 | cors({ 13 | origin: process.env.NEXT_PUBLIC_URL, 14 | credentials: true, 15 | }), 16 | ); 17 | 18 | app.use(express.json()); 19 | app.use( 20 | logger({ 21 | level: 'info', 22 | redact: ['req.headers', 'res.headers'], 23 | transport: { 24 | target: 'pino-pretty', 25 | options: { 26 | colorize: true, 27 | translateTime: true, 28 | }, 29 | }, 30 | }), 31 | ); 32 | app.use(createMiddleware(...handlers)); 33 | 34 | initializeDb().then(() => { 35 | console.log('Mock DB initialized'); 36 | app.listen(process.env.NEXT_PUBLIC_MOCK_API_PORT, () => { 37 | console.log( 38 | `Mock API server started at http://localhost:${process.env.NEXT_PUBLIC_MOCK_API_PORT}`, 39 | ); 40 | }); 41 | }); 42 | -------------------------------------------------------------------------------- /apps/nextjs-app/next-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | /// 3 | 4 | // NOTE: This file should not be edited 5 | // see https://nextjs.org/docs/basic-features/typescript for more information. 6 | -------------------------------------------------------------------------------- /apps/nextjs-app/next.config.mjs: -------------------------------------------------------------------------------- 1 | /** @type {import('next').NextConfig} */ 2 | const nextConfig = { 3 | reactStrictMode: true, 4 | }; 5 | 6 | export default nextConfig; 7 | -------------------------------------------------------------------------------- /apps/nextjs-app/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/nextjs-app/postcss.config.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | }; 7 | -------------------------------------------------------------------------------- /apps/nextjs-app/public/_redirects: -------------------------------------------------------------------------------- 1 | /* /index.html 200 -------------------------------------------------------------------------------- /apps/nextjs-app/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alan2207/bulletproof-react/49c4249fd68ef2196151ef34cc2c68cb4fe81dc1/apps/nextjs-app/public/favicon.ico -------------------------------------------------------------------------------- /apps/nextjs-app/public/logo192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alan2207/bulletproof-react/49c4249fd68ef2196151ef34cc2c68cb4fe81dc1/apps/nextjs-app/public/logo192.png -------------------------------------------------------------------------------- /apps/nextjs-app/public/logo512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alan2207/bulletproof-react/49c4249fd68ef2196151ef34cc2c68cb4fe81dc1/apps/nextjs-app/public/logo512.png -------------------------------------------------------------------------------- /apps/nextjs-app/public/robots.txt: -------------------------------------------------------------------------------- 1 | # https://www.robotstxt.org/robotstxt.html 2 | User-agent: * 3 | Disallow: 4 | -------------------------------------------------------------------------------- /apps/nextjs-app/src/app/app/_components/dashboard-info.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { useUser } from '@/lib/auth'; 4 | 5 | export const DashboardInfo = () => { 6 | const user = useUser(); 7 | 8 | return ( 9 | <> 10 |

11 | Welcome {`${user.data?.firstName} ${user.data?.lastName}`} 12 |

13 |

14 | Your role is : {user.data?.role} 15 |

16 |

In this application you can:

17 | {user.data?.role === 'USER' && ( 18 |
    19 |
  • Create comments in discussions
  • 20 |
  • Delete own comments
  • 21 |
22 | )} 23 | {user.data?.role === 'ADMIN' && ( 24 |
    25 |
  • Create discussions
  • 26 |
  • Edit discussions
  • 27 |
  • Delete discussions
  • 28 |
  • Comment on discussions
  • 29 |
  • Delete all comments
  • 30 |
31 | )} 32 | 33 | ); 34 | }; 35 | -------------------------------------------------------------------------------- /apps/nextjs-app/src/app/app/discussions/[discussionId]/_components/discussion.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { ErrorBoundary } from 'react-error-boundary'; 4 | 5 | import { ContentLayout } from '@/components/layouts/content-layout'; 6 | import { Comments } from '@/features/comments/components/comments'; 7 | import { useDiscussion } from '@/features/discussions/api/get-discussion'; 8 | import { DiscussionView } from '@/features/discussions/components/discussion-view'; 9 | 10 | export const Discussion = ({ discussionId }: { discussionId: string }) => { 11 | const discussion = useDiscussion({ discussionId }); 12 | 13 | return ( 14 | 15 | 16 |
17 | Failed to load comments. Try to refresh the page.
20 | } 21 | > 22 | 23 | 24 | 25 |
26 | ); 27 | }; 28 | -------------------------------------------------------------------------------- /apps/nextjs-app/src/app/app/discussions/_components/discussions.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { useQueryClient } from '@tanstack/react-query'; 4 | 5 | import { ContentLayout } from '@/components/layouts/content-layout'; 6 | import { getInfiniteCommentsQueryOptions } from '@/features/comments/api/get-comments'; 7 | import { CreateDiscussion } from '@/features/discussions/components/create-discussion'; 8 | import { DiscussionsList } from '@/features/discussions/components/discussions-list'; 9 | 10 | export const Discussions = () => { 11 | const queryClient = useQueryClient(); 12 | 13 | return ( 14 | 15 |
16 | 17 |
18 |
19 | { 21 | // Prefetch the comments data when the user hovers over the link in the list 22 | queryClient.prefetchInfiniteQuery( 23 | getInfiniteCommentsQueryOptions(id), 24 | ); 25 | }} 26 | /> 27 |
28 |
29 | ); 30 | }; 31 | -------------------------------------------------------------------------------- /apps/nextjs-app/src/app/app/discussions/page.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | dehydrate, 3 | HydrationBoundary, 4 | QueryClient, 5 | } from '@tanstack/react-query'; 6 | 7 | import { getDiscussionsQueryOptions } from '@/features/discussions/api/get-discussions'; 8 | 9 | import { Discussions } from './_components/discussions'; 10 | 11 | export const metadata = { 12 | title: 'Discussions', 13 | description: 'Discussions', 14 | }; 15 | 16 | const DiscussionsPage = async ({ 17 | searchParams, 18 | }: { 19 | searchParams: { page: string | null }; 20 | }) => { 21 | const queryClient = new QueryClient(); 22 | 23 | await queryClient.prefetchQuery( 24 | getDiscussionsQueryOptions({ 25 | page: searchParams.page ? Number(searchParams.page) : 1, 26 | }), 27 | ); 28 | 29 | const dehydratedState = dehydrate(queryClient); 30 | 31 | return ( 32 | 33 | 34 | 35 | ); 36 | }; 37 | 38 | export default DiscussionsPage; 39 | -------------------------------------------------------------------------------- /apps/nextjs-app/src/app/app/layout.tsx: -------------------------------------------------------------------------------- 1 | import { ReactNode } from 'react'; 2 | 3 | import { DashboardLayout } from './_components/dashboard-layout'; 4 | 5 | export const metadata = { 6 | title: 'Dashboard', 7 | description: 'Dashboard', 8 | }; 9 | 10 | const AppLayout = ({ children }: { children: ReactNode }) => { 11 | return {children}; 12 | }; 13 | 14 | export default AppLayout; 15 | -------------------------------------------------------------------------------- /apps/nextjs-app/src/app/app/page.tsx: -------------------------------------------------------------------------------- 1 | import { DashboardInfo } from './_components/dashboard-info'; 2 | 3 | export const metadata = { 4 | title: 'Dashboard', 5 | description: 'Dashboard', 6 | }; 7 | 8 | const DashboardPage = async () => { 9 | return ; 10 | }; 11 | 12 | export default DashboardPage; 13 | -------------------------------------------------------------------------------- /apps/nextjs-app/src/app/app/profile/page.tsx: -------------------------------------------------------------------------------- 1 | import { Profile } from './_components/profile'; 2 | 3 | export const metadata = { 4 | title: 'Profile', 5 | description: 'Profile', 6 | }; 7 | 8 | const ProfilePage = () => { 9 | return ; 10 | }; 11 | 12 | export default ProfilePage; 13 | -------------------------------------------------------------------------------- /apps/nextjs-app/src/app/app/users/_components/admin-guard.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { Spinner } from '@/components/ui/spinner'; 4 | import { useUser } from '@/lib/auth'; 5 | import { canViewUsers } from '@/lib/authorization'; 6 | 7 | export const AdminGuard = ({ children }: { children: React.ReactNode }) => { 8 | const user = useUser(); 9 | 10 | if (!user?.data) { 11 | return ; 12 | } 13 | 14 | if (!canViewUsers(user?.data)) { 15 | return
Only admin can view this.
; 16 | } 17 | 18 | return children; 19 | }; 20 | -------------------------------------------------------------------------------- /apps/nextjs-app/src/app/app/users/_components/users.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | dehydrate, 3 | HydrationBoundary, 4 | QueryClient, 5 | } from '@tanstack/react-query'; 6 | 7 | import { getUsersQueryOptions } from '@/features/users/api/get-users'; 8 | import { UsersList } from '@/features/users/components/users-list'; 9 | 10 | export const Users = async () => { 11 | const queryClient = new QueryClient(); 12 | 13 | await queryClient.prefetchQuery(getUsersQueryOptions()); 14 | 15 | const dehydratedState = dehydrate(queryClient); 16 | 17 | return ( 18 | 19 | 20 | 21 | ); 22 | }; 23 | -------------------------------------------------------------------------------- /apps/nextjs-app/src/app/app/users/page.tsx: -------------------------------------------------------------------------------- 1 | import { ContentLayout } from '@/components/layouts/content-layout'; 2 | 3 | import { AdminGuard } from './_components/admin-guard'; 4 | import { Users } from './_components/users'; 5 | 6 | export const metadata = { 7 | title: 'Users', 8 | description: 'Users', 9 | }; 10 | 11 | const UsersPage = () => { 12 | return ( 13 | 14 | 15 | 16 | 17 | 18 | ); 19 | }; 20 | 21 | export default UsersPage; 22 | -------------------------------------------------------------------------------- /apps/nextjs-app/src/app/auth/layout.tsx: -------------------------------------------------------------------------------- 1 | import { ReactNode, Suspense } from 'react'; 2 | import { ErrorBoundary } from 'react-error-boundary'; 3 | 4 | import { Spinner } from '@/components/ui/spinner'; 5 | 6 | import { AuthLayout as AuthLayoutComponent } from './_components/auth-layout'; 7 | 8 | export const metadata = { 9 | title: 'Bulletproof React', 10 | description: 'Welcome to Bulletproof React', 11 | }; 12 | 13 | const AuthLayout = ({ children }: { children: ReactNode }) => { 14 | return ( 15 | 18 | 19 | 20 | } 21 | > 22 | Something went wrong!}> 23 | {children} 24 | 25 | 26 | ); 27 | }; 28 | 29 | export default AuthLayout; 30 | -------------------------------------------------------------------------------- /apps/nextjs-app/src/app/auth/login/page.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { useRouter, useSearchParams } from 'next/navigation'; 4 | 5 | import { paths } from '@/config/paths'; 6 | import { LoginForm } from '@/features/auth/components/login-form'; 7 | 8 | const LoginPage = () => { 9 | const router = useRouter(); 10 | const searchParams = useSearchParams(); 11 | const redirectTo = searchParams?.get('redirectTo'); 12 | 13 | return ( 14 | 16 | router.replace( 17 | `${redirectTo ? `${decodeURIComponent(redirectTo)}` : paths.app.dashboard.getHref()}`, 18 | ) 19 | } 20 | /> 21 | ); 22 | }; 23 | 24 | export default LoginPage; 25 | -------------------------------------------------------------------------------- /apps/nextjs-app/src/app/auth/register/page.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { useRouter, useSearchParams } from 'next/navigation'; 4 | import { useState } from 'react'; 5 | 6 | import { paths } from '@/config/paths'; 7 | import { RegisterForm } from '@/features/auth/components/register-form'; 8 | import { useTeams } from '@/features/teams/api/get-teams'; 9 | 10 | const RegisterPage = () => { 11 | const router = useRouter(); 12 | 13 | const searchParams = useSearchParams(); 14 | const redirectTo = searchParams?.get('redirectTo'); 15 | 16 | const [chooseTeam, setChooseTeam] = useState(false); 17 | 18 | const teamsQuery = useTeams({ 19 | queryConfig: { 20 | enabled: chooseTeam, 21 | }, 22 | }); 23 | 24 | return ( 25 | 27 | router.replace( 28 | `${redirectTo ? `${decodeURIComponent(redirectTo)}` : paths.app.dashboard.getHref()}`, 29 | ) 30 | } 31 | chooseTeam={chooseTeam} 32 | setChooseTeam={() => setChooseTeam(!chooseTeam)} 33 | teams={teamsQuery.data?.data} 34 | /> 35 | ); 36 | }; 37 | 38 | export default RegisterPage; 39 | -------------------------------------------------------------------------------- /apps/nextjs-app/src/app/layout.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | dehydrate, 3 | HydrationBoundary, 4 | QueryClient, 5 | } from '@tanstack/react-query'; 6 | import { ReactNode } from 'react'; 7 | 8 | import { AppProvider } from '@/app/provider'; 9 | import { getUserQueryOptions } from '@/lib/auth'; 10 | 11 | import '@/styles/globals.css'; 12 | 13 | export const metadata = { 14 | title: 'Bulletproof React', 15 | description: 'Showcasing Best Practices For Building React Applications', 16 | }; 17 | 18 | const RootLayout = async ({ children }: { children: ReactNode }) => { 19 | const queryClient = new QueryClient(); 20 | 21 | await queryClient.prefetchQuery(getUserQueryOptions()); 22 | 23 | const dehydratedState = dehydrate(queryClient); 24 | 25 | return ( 26 | 27 | 28 | 29 | 30 | {children} 31 | 32 | 33 | 34 | 35 | ); 36 | }; 37 | 38 | export default RootLayout; 39 | 40 | // We are not prerendering anything because the app is highly dynamic 41 | // and the data depends on the user so we need to send cookies with each request 42 | export const dynamic = 'force-dynamic'; 43 | -------------------------------------------------------------------------------- /apps/nextjs-app/src/app/not-found.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-app/src/app/provider.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; 4 | import { ReactQueryDevtools } from '@tanstack/react-query-devtools'; 5 | import * as React from 'react'; 6 | import { ErrorBoundary } from 'react-error-boundary'; 7 | 8 | import { MainErrorFallback } from '@/components/errors/main'; 9 | import { Notifications } from '@/components/ui/notifications'; 10 | import { queryConfig } from '@/lib/react-query'; 11 | 12 | type AppProviderProps = { 13 | children: React.ReactNode; 14 | }; 15 | 16 | export const AppProvider = ({ children }: AppProviderProps) => { 17 | const [queryClient] = React.useState( 18 | () => 19 | new QueryClient({ 20 | defaultOptions: queryConfig, 21 | }), 22 | ); 23 | 24 | return ( 25 | 26 | 27 | {process.env.DEV && } 28 | 29 | {children} 30 | 31 | 32 | ); 33 | }; 34 | -------------------------------------------------------------------------------- /apps/nextjs-app/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 | 16 |
17 | ); 18 | }; 19 | -------------------------------------------------------------------------------- /apps/nextjs-app/src/components/layouts/content-layout.tsx: -------------------------------------------------------------------------------- 1 | import { ReactNode } from 'react'; 2 | 3 | type ContentLayoutProps = { 4 | children: ReactNode; 5 | title?: string; 6 | }; 7 | 8 | export const ContentLayout = ({ children, title = '' }: ContentLayoutProps) => { 9 | return ( 10 |
11 |
12 |

{title}

13 |
14 |
15 | {children} 16 |
17 |
18 | ); 19 | }; 20 | -------------------------------------------------------------------------------- /apps/nextjs-app/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/nextjs-app/src/components/ui/button/index.ts: -------------------------------------------------------------------------------- 1 | export * from './button'; 2 | -------------------------------------------------------------------------------- /apps/nextjs-app/src/components/ui/dialog/confirmation-dialog/__tests__/confirmation-dialog.test.tsx: -------------------------------------------------------------------------------- 1 | import { Button } from '@/components/ui/button'; 2 | import { rtlRender, screen, userEvent, waitFor } from '@/testing/test-utils'; 3 | 4 | import { ConfirmationDialog } from '../confirmation-dialog'; 5 | 6 | test('should handle confirmation flow', async () => { 7 | const titleText = 'Are you sure?'; 8 | const bodyText = 'Are you sure you want to delete this item?'; 9 | const confirmationButtonText = 'Confirm'; 10 | const openButtonText = 'Open'; 11 | 12 | await rtlRender( 13 | {confirmationButtonText}} 18 | triggerButton={} 19 | />, 20 | ); 21 | 22 | expect(screen.queryByText(titleText)).not.toBeInTheDocument(); 23 | 24 | await userEvent.click(screen.getByRole('button', { name: openButtonText })); 25 | 26 | expect(await screen.findByText(titleText)).toBeInTheDocument(); 27 | 28 | expect(screen.getByText(bodyText)).toBeInTheDocument(); 29 | 30 | await userEvent.click(screen.getByRole('button', { name: 'Cancel' })); 31 | 32 | await waitFor(() => 33 | expect(screen.queryByText(titleText)).not.toBeInTheDocument(), 34 | ); 35 | 36 | expect(screen.queryByText(bodyText)).not.toBeInTheDocument(); 37 | }); 38 | -------------------------------------------------------------------------------- /apps/nextjs-app/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: , 21 | triggerButton: , 22 | }, 23 | }; 24 | 25 | export const Info: Story = { 26 | args: { 27 | icon: 'info', 28 | title: 'Confirmation', 29 | body: 'Hello World', 30 | confirmButton: , 31 | triggerButton: , 32 | }, 33 | }; 34 | -------------------------------------------------------------------------------- /apps/nextjs-app/src/components/ui/dialog/confirmation-dialog/index.ts: -------------------------------------------------------------------------------- 1 | export * from './confirmation-dialog'; 2 | -------------------------------------------------------------------------------- /apps/nextjs-app/src/components/ui/dialog/index.ts: -------------------------------------------------------------------------------- 1 | export * from './dialog'; 2 | export * from './confirmation-dialog'; 3 | -------------------------------------------------------------------------------- /apps/nextjs-app/src/components/ui/drawer/index.ts: -------------------------------------------------------------------------------- 1 | export * from './drawer'; 2 | -------------------------------------------------------------------------------- /apps/nextjs-app/src/components/ui/dropdown/index.ts: -------------------------------------------------------------------------------- 1 | export * from './dropdown'; 2 | -------------------------------------------------------------------------------- /apps/nextjs-app/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/nextjs-app/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 | 27 | 28 |
29 | ); 30 | }; 31 | -------------------------------------------------------------------------------- /apps/nextjs-app/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/nextjs-app/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/nextjs-app/src/components/ui/form/label.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import * as LabelPrimitive from '@radix-ui/react-label'; 4 | import { cva, type VariantProps } from 'class-variance-authority'; 5 | import * as React from 'react'; 6 | 7 | import { cn } from '@/utils/cn'; 8 | 9 | const labelVariants = cva( 10 | 'text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70', 11 | ); 12 | 13 | const Label = React.forwardRef< 14 | React.ElementRef, 15 | React.ComponentPropsWithoutRef & 16 | VariantProps 17 | >(({ className, ...props }, ref) => ( 18 | 23 | )); 24 | Label.displayName = LabelPrimitive.Root.displayName; 25 | 26 | export { Label }; 27 | -------------------------------------------------------------------------------- /apps/nextjs-app/src/components/ui/form/select.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import * as React from 'react'; 4 | import { UseFormRegisterReturn } from 'react-hook-form'; 5 | 6 | import { cn } from '@/utils/cn'; 7 | 8 | import { FieldWrapper, FieldWrapperPassThroughProps } from './field-wrapper'; 9 | 10 | type Option = { 11 | label: React.ReactNode; 12 | value: string | number | string[]; 13 | }; 14 | 15 | type SelectFieldProps = FieldWrapperPassThroughProps & { 16 | options: Option[]; 17 | className?: string; 18 | defaultValue?: string; 19 | registration: Partial; 20 | }; 21 | 22 | export const Select = (props: SelectFieldProps) => { 23 | const { label, options, error, className, defaultValue, registration } = 24 | props; 25 | return ( 26 | 27 | 41 | 42 | ); 43 | }; 44 | -------------------------------------------------------------------------------- /apps/nextjs-app/src/components/ui/form/switch.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import * as SwitchPrimitives from '@radix-ui/react-switch'; 4 | import * as React from 'react'; 5 | 6 | import { cn } from '@/utils/cn'; 7 | 8 | const Switch = React.forwardRef< 9 | React.ElementRef, 10 | React.ComponentPropsWithoutRef 11 | >(({ className, ...props }, ref) => ( 12 | 20 | 25 | 26 | )); 27 | Switch.displayName = SwitchPrimitives.Root.displayName; 28 | 29 | export { Switch }; 30 | -------------------------------------------------------------------------------- /apps/nextjs-app/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 |