initial commit
114
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,114 @@
|
||||||
|
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||||
|
|
||||||
|
# dependencies
|
||||||
|
node_modules/
|
||||||
|
.pnp/
|
||||||
|
.pnp.js
|
||||||
|
.yarn/install-state.gz
|
||||||
|
.yarn/cache
|
||||||
|
.yarn/unplugged
|
||||||
|
.yarn/build-state.yml
|
||||||
|
.yarn/releases
|
||||||
|
.pnpm-store/
|
||||||
|
|
||||||
|
# testing
|
||||||
|
/coverage
|
||||||
|
/playwright-report
|
||||||
|
/test-results
|
||||||
|
/cypress/videos/
|
||||||
|
/cypress/screenshots/
|
||||||
|
.nyc_output
|
||||||
|
|
||||||
|
# next.js
|
||||||
|
.next/
|
||||||
|
out/
|
||||||
|
build/
|
||||||
|
dist/
|
||||||
|
.next-env.d.ts
|
||||||
|
|
||||||
|
# misc
|
||||||
|
.DS_Store
|
||||||
|
*.pem
|
||||||
|
Thumbs.db
|
||||||
|
desktop.ini
|
||||||
|
.directory
|
||||||
|
|
||||||
|
# debug
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
.pnpm-debug.log*
|
||||||
|
debug.log
|
||||||
|
|
||||||
|
# local env files
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
|
|
||||||
|
# vercel
|
||||||
|
.vercel
|
||||||
|
|
||||||
|
# typescript
|
||||||
|
*.tsbuildinfo
|
||||||
|
next-env.d.ts
|
||||||
|
|
||||||
|
# IDE
|
||||||
|
.idea/
|
||||||
|
.vscode/
|
||||||
|
*.sublime-project
|
||||||
|
*.sublime-workspace
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
.vs/
|
||||||
|
*.code-workspace
|
||||||
|
.project
|
||||||
|
.settings/
|
||||||
|
.classpath
|
||||||
|
|
||||||
|
# PWA files
|
||||||
|
**/public/sw.js
|
||||||
|
**/public/workbox-*.js
|
||||||
|
**/public/worker-*.js
|
||||||
|
**/public/sw.js.map
|
||||||
|
**/public/workbox-*.js.map
|
||||||
|
**/public/worker-*.js.map
|
||||||
|
**/public/fallback-*.js
|
||||||
|
|
||||||
|
# Sentry
|
||||||
|
.sentryclirc
|
||||||
|
.sentry-native/
|
||||||
|
|
||||||
|
# Storybook
|
||||||
|
storybook-static/
|
||||||
|
*.stories.d.ts
|
||||||
|
|
||||||
|
# Cache and Temp
|
||||||
|
.eslintcache
|
||||||
|
.stylelintcache
|
||||||
|
.cache/
|
||||||
|
.temp/
|
||||||
|
tmp/
|
||||||
|
temp/
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
logs/
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
|
||||||
|
# Runtime data
|
||||||
|
pids/
|
||||||
|
*.pid
|
||||||
|
*.seed
|
||||||
|
*.pid.lock
|
||||||
|
|
||||||
|
# Build tools
|
||||||
|
.rollup.cache/
|
||||||
|
tsconfig.tsbuildinfo
|
||||||
|
|
||||||
|
# OS generated files
|
||||||
|
._*
|
||||||
|
.Spotlight-V100
|
||||||
|
.Trashes
|
||||||
|
ehthumbs.db
|
||||||
2
.husky/commit-msg
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
#!/bin/sh
|
||||||
|
cd "$(dirname "$0")/.." && npx --no -- commitlint --edit $1
|
||||||
3
.husky/pre-commit
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
#!/bin/sh
|
||||||
|
# Disable concurent to run `check-types` after ESLint in lint-staged
|
||||||
|
cd "$(dirname "$0")/.." && npx --no lint-staged --concurrent false
|
||||||
21
.storybook/main.ts
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
import type { StorybookConfig } from '@storybook/nextjs';
|
||||||
|
|
||||||
|
const config: StorybookConfig = {
|
||||||
|
stories: ['../src/**/*.mdx', '../src/**/*.stories.@(js|jsx|mjs|ts|tsx)'],
|
||||||
|
addons: [
|
||||||
|
'@storybook/addon-onboarding',
|
||||||
|
'@storybook/addon-links',
|
||||||
|
'@storybook/addon-essentials',
|
||||||
|
'@storybook/addon-interactions',
|
||||||
|
],
|
||||||
|
framework: {
|
||||||
|
name: '@storybook/nextjs',
|
||||||
|
options: {},
|
||||||
|
},
|
||||||
|
staticDirs: ['../public'],
|
||||||
|
core: {
|
||||||
|
disableTelemetry: true,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default config;
|
||||||
19
.storybook/preview.ts
Normal file
|
|
@ -0,0 +1,19 @@
|
||||||
|
import '../src/styles/global.css';
|
||||||
|
|
||||||
|
import type { Preview } from '@storybook/react';
|
||||||
|
|
||||||
|
const preview: Preview = {
|
||||||
|
parameters: {
|
||||||
|
controls: {
|
||||||
|
matchers: {
|
||||||
|
color: /(background|color)$/i,
|
||||||
|
date: /Date$/i,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
nextjs: {
|
||||||
|
appDirectory: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default preview;
|
||||||
102
CHANGELOG.md
Normal file
|
|
@ -0,0 +1,102 @@
|
||||||
|
## [1.7.3](https://github.com/ixartz/SaaS-Boilerplate/compare/v1.7.2...v1.7.3) (2024-11-07)
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* chnage dashboard index message button in french translation ([2f1dca8](https://github.com/ixartz/SaaS-Boilerplate/commit/2f1dca84cb05af52a959dd9630769ed661d8c69b))
|
||||||
|
* remove update deps github workflow, add separator in dashboard header ([fcf0fb4](https://github.com/ixartz/SaaS-Boilerplate/commit/fcf0fb48304ce45f6ceefa7d7eae11692655c749))
|
||||||
|
|
||||||
|
## [1.7.2](https://github.com/ixartz/SaaS-Boilerplate/compare/v1.7.1...v1.7.2) (2024-10-17)
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* hide text in logo used in dashboard and add spacing for sign in button used in navbar ([a0eeda1](https://github.com/ixartz/SaaS-Boilerplate/commit/a0eeda12251551fd6a8e50222f46f3d47f0daad7))
|
||||||
|
* in dashboard, make the logo smaller, display without text ([f780727](https://github.com/ixartz/SaaS-Boilerplate/commit/f780727659fa58bbe6e4250dd63b2819369b7308))
|
||||||
|
* remove hydration error and unify with pro version 1.6.1 ([ea2d02b](https://github.com/ixartz/SaaS-Boilerplate/commit/ea2d02bd52de34c6cd2390d160ffe7f14319d5c3))
|
||||||
|
|
||||||
|
## [1.7.1](https://github.com/ixartz/SaaS-Boilerplate/compare/v1.7.0...v1.7.1) (2024-10-04)
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* update logicalId in checkly configuration ([6e7a479](https://github.com/ixartz/SaaS-Boilerplate/commit/6e7a4795bff0b92d3681fadc36256aa957eb2613))
|
||||||
|
|
||||||
|
# [1.7.0](https://github.com/ixartz/SaaS-Boilerplate/compare/v1.6.1...v1.7.0) (2024-10-04)
|
||||||
|
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
* update de Next.js Boilerplate v3.58.1 ([16aea65](https://github.com/ixartz/SaaS-Boilerplate/commit/16aea651ef93ed627e3bf310412cfd3651aeb3e4))
|
||||||
|
|
||||||
|
## [1.6.1](https://github.com/ixartz/SaaS-Boilerplate/compare/v1.6.0...v1.6.1) (2024-08-31)
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* add demo banner at the top of the landing page ([09bf8c8](https://github.com/ixartz/SaaS-Boilerplate/commit/09bf8c8aba06eba1405fb0c20aeec23dfb732bb7))
|
||||||
|
* issue to build Next.js with Node.js 22.7, use 22.6 instead ([4acaef9](https://github.com/ixartz/SaaS-Boilerplate/commit/4acaef95edec3cd72a35405969ece9d55a2bb641))
|
||||||
|
|
||||||
|
# [1.6.0](https://github.com/ixartz/SaaS-Boilerplate/compare/v1.5.0...v1.6.0) (2024-07-26)
|
||||||
|
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
* update to Next.js Boilerpalte v3.54 ([ae80843](https://github.com/ixartz/SaaS-Boilerplate/commit/ae808433e50d6889559fff382d4b9c595d34e04f))
|
||||||
|
|
||||||
|
# [1.5.0](https://github.com/ixartz/SaaS-Boilerplate/compare/v1.4.0...v1.5.0) (2024-06-05)
|
||||||
|
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
* update to Drizzle Kit 0.22, Storybook 8, migrate to vitest ([c2f19cd](https://github.com/ixartz/SaaS-Boilerplate/commit/c2f19cd8e9dc983e0ad799da2474610b57b88f50))
|
||||||
|
|
||||||
|
# [1.4.0](https://github.com/ixartz/SaaS-Boilerplate/compare/v1.3.0...v1.4.0) (2024-05-17)
|
||||||
|
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
* vscode jest open test result view on test fails and add unauthenticatedUrl in clerk middleware ([3cfcb6b](https://github.com/ixartz/SaaS-Boilerplate/commit/3cfcb6b00d91dabcb00cbf8eb2d8be6533ff672e))
|
||||||
|
|
||||||
|
# [1.3.0](https://github.com/ixartz/SaaS-Boilerplate/compare/v1.2.1...v1.3.0) (2024-05-16)
|
||||||
|
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
* add custom framework for i18n-ally and replace deprecated Jest VSCode configuration ([a9889dc](https://github.com/ixartz/SaaS-Boilerplate/commit/a9889dc129aeeba8801f4f47e54d46e9515e6a29))
|
||||||
|
* create dashboard header component ([f3dc1da](https://github.com/ixartz/SaaS-Boilerplate/commit/f3dc1da451ab8dce90d111fe4bbc8d4bc99e4b01))
|
||||||
|
* don't redirect to organization-selection if the user is already on this page ([87da997](https://github.com/ixartz/SaaS-Boilerplate/commit/87da997b853fd9dcb7992107d2cb206817258910))
|
||||||
|
* make the landing page responsive and works on mobile ([27e908a](https://github.com/ixartz/SaaS-Boilerplate/commit/27e908a735ea13845a6cc42acc12e6cae3232b9b))
|
||||||
|
* make user dashboard responsive ([f88c9dd](https://github.com/ixartz/SaaS-Boilerplate/commit/f88c9dd5ac51339d37d1d010e5b16c7776c73b8d))
|
||||||
|
* migreate Env.mjs file to Env.ts ([2e6ff12](https://github.com/ixartz/SaaS-Boilerplate/commit/2e6ff124dcc10a3c12cac672cbb82ec4000dc60c))
|
||||||
|
* remove next-sitemap and use the native Next.js sitemap/robots.txt ([75c9751](https://github.com/ixartz/SaaS-Boilerplate/commit/75c9751d607b8a6a269d08667f7d9900797ff38a))
|
||||||
|
* upgrade to Clerk v5 and use Clerk's Core 2 ([a92cef0](https://github.com/ixartz/SaaS-Boilerplate/commit/a92cef026b5c85a703f707aabf42d28a16f07054))
|
||||||
|
* use Node.js version 20 and 22 in GitHub Actions ([226b5e9](https://github.com/ixartz/SaaS-Boilerplate/commit/226b5e970f46bfcd384ca60cd63ebb15516eca21))
|
||||||
|
|
||||||
|
## [1.2.1](https://github.com/ixartz/SaaS-Boilerplate/compare/v1.2.0...v1.2.1) (2024-03-30)
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* redirect user to the landing page after signing out ([6e9f383](https://github.com/ixartz/SaaS-Boilerplate/commit/6e9f3839daaab56dd3cf3e57287ea0f3862b8588))
|
||||||
|
|
||||||
|
# [1.2.0](https://github.com/ixartz/SaaS-Boilerplate/compare/v1.1.0...v1.2.0) (2024-03-29)
|
||||||
|
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
* add link to the GitHub repository ([ed42176](https://github.com/ixartz/SaaS-Boilerplate/commit/ed42176bdc2776cacc2c939bac45914a1ede8e51))
|
||||||
|
|
||||||
|
# [1.1.0](https://github.com/ixartz/SaaS-Boilerplate/compare/v1.0.0...v1.1.0) (2024-03-29)
|
||||||
|
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
* launching SaaS boilerplate for helping developers to build SaaS quickly ([7f24661](https://github.com/ixartz/SaaS-Boilerplate/commit/7f246618791e3a731347dffc694a52fa90b1152a))
|
||||||
|
|
||||||
|
# 1.0.0 (2024-03-29)
|
||||||
|
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
* initial commit ([d58e1d9](https://github.com/ixartz/SaaS-Boilerplate/commit/d58e1d97e11baa0a756bd038332eb84daf5a8327))
|
||||||
21
LICENSE
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2024 Remi W.
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
495
README.md
Normal file
|
|
@ -0,0 +1,495 @@
|
||||||
|
# Free and Open Source SaaS Boilerplate with Tailwind CSS and Shadcn UI
|
||||||
|
|
||||||
|
<p align="center">
|
||||||
|
<a href="https://react-saas.com"><img height="300" src="public/assets/images/nextjs-starter-banner.png?raw=true" alt="Next.js SaaS Template"></a>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
🚀 **SaaS Boilerplate** is a powerful and fully customizable template to kickstart your SaaS applications. Built with **Next.js** and **Tailwind CSS**, and the modular UI components of **Shadcn UI**. This **Next.js SaaS Template** helps you to quickly build and launch SaaS with minimal effort.
|
||||||
|
|
||||||
|
Packed with essential features like built-in **Authentication**, **Multi-Tenancy** with Team support, **Role & Permission**, Database, I18n (internationalization), Landing Page, User Dashboard, Form handling, SEO optimization, Logging, Error reporting with [Sentry](https://sentry.io/for/nextjs/?utm_source=github&utm_medium=paid-community&utm_campaign=general-fy25q1-nextjs&utm_content=github-banner-nextjsboilerplate-logo), Testing, Deployment, Monitoring, and **User Impersonation**, this SaaS template provides everything you need to get started.
|
||||||
|
|
||||||
|
Designed with developers in mind, this **Next.js Starter Kit** uses TypeScript for type safety and integrates ESLint to maintain code quality, along with Prettier for consistent code formatting. The testing suite combines Vitest and React Testing Library for robust unit testing, while Playwright handles integration and E2E testing. Continuous integration and deployment are managed via GitHub Actions. For user management, authentication is handled by [Clerk](https://go.clerk.com/zGlzydF). For database operations, it uses Drizzle ORM for type-safe database management across popular databases like PostgreSQL, SQLite, and MySQL.
|
||||||
|
|
||||||
|
Whether you're building a new SaaS app or looking for a flexible, **production-ready SaaS template**, this boilerplate has you covered. This free, open-source starter kit has everything you need to accelerate your development and scale your product with ease.
|
||||||
|
|
||||||
|
Clone this project and use it to create your own SaaS. You can check the live demo at [SaaS Boilerplate](https://react-saas.com), which is a demo with a working authentication and multi-tenancy system.
|
||||||
|
|
||||||
|
## Sponsors
|
||||||
|
|
||||||
|
<table width="100%">
|
||||||
|
<tr height="187px">
|
||||||
|
<td align="center" width="33%">
|
||||||
|
<a href="https://go.clerk.com/zGlzydF">
|
||||||
|
<picture>
|
||||||
|
<source media="(prefers-color-scheme: dark)" srcset="https://github.com/ixartz/SaaS-Boilerplate/assets/1328388/6fb61971-3bf1-4580-98a0-10bd3f1040a2">
|
||||||
|
<source media="(prefers-color-scheme: light)" srcset="https://github.com/ixartz/SaaS-Boilerplate/assets/1328388/f80a8bb5-66da-4772-ad36-5fabc5b02c60">
|
||||||
|
<img alt="Clerk – Authentication & User Management for Next.js" src="https://github.com/ixartz/SaaS-Boilerplate/assets/1328388/f80a8bb5-66da-4772-ad36-5fabc5b02c60">
|
||||||
|
</picture>
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
<td align="center" width="33%">
|
||||||
|
<a href="https://l.crowdin.com/next-js">
|
||||||
|
<picture>
|
||||||
|
<source media="(prefers-color-scheme: dark)" srcset="public/assets/images/crowdin-white.png?raw=true">
|
||||||
|
<source media="(prefers-color-scheme: light)" srcset="public/assets/images/crowdin-dark.png?raw=true">
|
||||||
|
<img alt="Crowdin" src="public/assets/images/crowdin-dark.png?raw=true">
|
||||||
|
</picture>
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
<td align="center" width="33%">
|
||||||
|
<a href="https://sentry.io/for/nextjs/?utm_source=github&utm_medium=paid-community&utm_campaign=general-fy25q1-nextjs&utm_content=github-banner-nextjsboilerplate-logo">
|
||||||
|
<picture>
|
||||||
|
<source media="(prefers-color-scheme: dark)" srcset="public/assets/images/sentry-white.png?raw=true">
|
||||||
|
<source media="(prefers-color-scheme: light)" srcset="public/assets/images/sentry-dark.png?raw=true">
|
||||||
|
<img alt="Sentry" src="public/assets/images/sentry-dark.png?raw=true">
|
||||||
|
</picture>
|
||||||
|
</a>
|
||||||
|
<a href="https://about.codecov.io/codecov-free-trial/?utm_source=github&utm_medium=paid-community&utm_campaign=general-fy25q1-nextjs&utm_content=github-banner-nextjsboilerplate-logo">
|
||||||
|
<picture>
|
||||||
|
<source media="(prefers-color-scheme: dark)" srcset="public/assets/images/codecov-white.svg?raw=true">
|
||||||
|
<source media="(prefers-color-scheme: light)" srcset="public/assets/images/codecov-dark.svg?raw=true">
|
||||||
|
<img alt="Codecov" src="public/assets/images/codecov-dark.svg?raw=true">
|
||||||
|
</picture>
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr height="187px">
|
||||||
|
<td align="center" style=width="33%">
|
||||||
|
<a href="https://nextjs-boilerplate.com/pro-saas-starter-kit">
|
||||||
|
<img src="public/assets/images/nextjs-boilerplate-saas.png?raw=true" alt="Next.js SaaS Boilerplate with React" />
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr height="187px">
|
||||||
|
<td align="center" width="33%">
|
||||||
|
<a href="mailto:contact@creativedesignsguru.com">
|
||||||
|
Add your logo here
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
### Demo
|
||||||
|
|
||||||
|
**Live demo: [SaaS Boilerplate](https://react-saas.com)**
|
||||||
|
|
||||||
|
| Landing Page | User Dashboard |
|
||||||
|
| --- | --- |
|
||||||
|
| [](https://react-saas.com) | [](https://react-saas.com/dashboard) |
|
||||||
|
|
||||||
|
| Team Management | User Profile |
|
||||||
|
| --- | --- |
|
||||||
|
| [](https://react-saas.com/dashboard/organization-profile/organization-members) | [](https://react-saas.com/dashboard/user-profile) |
|
||||||
|
|
||||||
|
| Sign Up | Sign In |
|
||||||
|
| --- | --- |
|
||||||
|
| [](https://react-saas.com/sign-up) | [](https://react-saas.com/sign-in) |
|
||||||
|
|
||||||
|
| Landing Page with Dark Mode (Pro Version) | User Dashboard with Dark Mode (Pro Version) |
|
||||||
|
| --- | --- |
|
||||||
|
| [](https://pro-demo.nextjs-boilerplate.com) | [](https://pro-demo.nextjs-boilerplate.com/dashboard) |
|
||||||
|
|
||||||
|
| User Dashboard with Sidebar (Pro Version) |
|
||||||
|
| --- |
|
||||||
|
| [](https://pro-demo.nextjs-boilerplate.com) |
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
Developer experience first, extremely flexible code structure and only keep what you need:
|
||||||
|
|
||||||
|
- ⚡ [Next.js](https://nextjs.org) with App Router support
|
||||||
|
- 🔥 Type checking [TypeScript](https://www.typescriptlang.org)
|
||||||
|
- 💎 Integrate with [Tailwind CSS](https://tailwindcss.com) and Shadcn UI
|
||||||
|
- ✅ Strict Mode for TypeScript and [React 18](https://react.dev)
|
||||||
|
- 🔒 Authentication with [Clerk](https://go.clerk.com/zGlzydF): Sign up, Sign in, Sign out, Forgot password, Reset password, and more.
|
||||||
|
- 👤 Passwordless Authentication with Magic Links, Multi-Factor Auth (MFA), Social Auth (Google, Facebook, Twitter, GitHub, Apple, and more), Passwordless login with Passkeys, User Impersonation
|
||||||
|
- 👥 Multi-tenancy & team support: create, switch, update organization and invite team members
|
||||||
|
- 📝 Role-based access control and permissions
|
||||||
|
- 👤 Multi-Factor Auth (MFA), Social Auth (Google, Facebook, Twitter, GitHub, Apple, and more), User Impersonation
|
||||||
|
- 📦 Type-safe ORM with DrizzleORM, compatible with PostgreSQL, SQLite, and MySQL
|
||||||
|
- 💽 Offline and local development database with PGlite
|
||||||
|
- 🌐 Multi-language (i18n) with [next-intl](https://next-intl-docs.vercel.app/) and [Crowdin](https://l.crowdin.com/next-js)
|
||||||
|
- ♻️ Type-safe environment variables with T3 Env
|
||||||
|
- ⌨️ Form with [React Hook Form](https://react-hook-form.com)
|
||||||
|
- 🔴 Validation library with [Zod](https://zod.dev)
|
||||||
|
- 📏 Linter with [ESLint](https://eslint.org) (default NextJS, NextJS Core Web Vitals, Tailwind CSS and Antfu configuration)
|
||||||
|
- 💖 Code Formatter with [Prettier](https://prettier.io)
|
||||||
|
- 🦊 Husky for Git Hooks
|
||||||
|
- 🚫 Lint-staged for running linters on Git staged files
|
||||||
|
- 🚓 Lint git commit with Commitlint
|
||||||
|
- 📓 Write standard compliant commit messages with Commitizen
|
||||||
|
- 🦺 Unit Testing with [Vitest](https://vitest.dev) and React Testing Library
|
||||||
|
- 🧪 Integration and E2E Testing with [Playwright](https://playwright.dev)
|
||||||
|
- 👷 Run tests on pull requests with GitHub Actions
|
||||||
|
- 🎉 [Storybook](https://storybook.js.org) for UI development
|
||||||
|
- 🚨 Error Monitoring with [Sentry](https://sentry.io/for/nextjs/?utm_source=github&utm_medium=paid-community&utm_campaign=general-fy25q1-nextjs&utm_content=github-banner-nextjsboilerplate-logo)
|
||||||
|
- ☂️ Code coverage with [Codecov](https://about.codecov.io/codecov-free-trial/?utm_source=github&utm_medium=paid-community&utm_campaign=general-fy25q1-nextjs&utm_content=github-banner-nextjsboilerplate-logo)
|
||||||
|
- 📝 Logging with [Pino.js](https://getpino.io) and Log Management with [Better Stack](https://betterstack.com/?utm_source=github&utm_medium=sponsorship&utm_campaign=next-js-boilerplate)
|
||||||
|
- 🖥️ Monitoring as Code with [Checkly](https://www.checklyhq.com/?utm_source=github&utm_medium=sponsorship&utm_campaign=next-js-boilerplate)
|
||||||
|
- 🎁 Automatic changelog generation with Semantic Release
|
||||||
|
- 🔍 Visual testing with Percy (Optional)
|
||||||
|
- 💡 Absolute Imports using `@` prefix
|
||||||
|
- 🗂 VSCode configuration: Debug, Settings, Tasks and Extensions
|
||||||
|
- 🤖 SEO metadata, JSON-LD and Open Graph tags
|
||||||
|
- 🗺️ Sitemap.xml and robots.txt
|
||||||
|
- ⌘ Database exploration with Drizzle Studio and CLI migration tool with Drizzle Kit
|
||||||
|
- ⚙️ [Bundler Analyzer](https://www.npmjs.com/package/@next/bundle-analyzer)
|
||||||
|
- 🌈 Include a FREE minimalist theme
|
||||||
|
- 💯 Maximize lighthouse score
|
||||||
|
|
||||||
|
Built-in feature from Next.js:
|
||||||
|
|
||||||
|
- ☕ Minify HTML & CSS
|
||||||
|
- 💨 Live reload
|
||||||
|
- ✅ Cache busting
|
||||||
|
|
||||||
|
### Philosophy
|
||||||
|
|
||||||
|
- Nothing is hidden from you, allowing you to make any necessary adjustments to suit your requirements and preferences.
|
||||||
|
- Dependencies are updated every month
|
||||||
|
- Start for free without upfront costs
|
||||||
|
- Easy to customize
|
||||||
|
- Minimal code
|
||||||
|
- SEO-friendly
|
||||||
|
- Everything you need to build a SaaS
|
||||||
|
- 🚀 Production-ready
|
||||||
|
|
||||||
|
### Requirements
|
||||||
|
|
||||||
|
- Node.js 20+ and npm
|
||||||
|
|
||||||
|
### Getting started
|
||||||
|
|
||||||
|
Run the following command on your local environment:
|
||||||
|
|
||||||
|
```shell
|
||||||
|
git clone --depth=1 https://github.com/ixartz/SaaS-Boilerplate.git my-project-name
|
||||||
|
cd my-project-name
|
||||||
|
npm install
|
||||||
|
```
|
||||||
|
|
||||||
|
For your information, all dependencies are updated every month.
|
||||||
|
|
||||||
|
Then, you can run the project locally in development mode with live reload by executing:
|
||||||
|
|
||||||
|
```shell
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
Open http://localhost:3000 with your favorite browser to see your project.
|
||||||
|
|
||||||
|
### Set up authentication
|
||||||
|
|
||||||
|
Create a Clerk account at [Clerk.com](https://go.clerk.com/zGlzydF) and create a new application in the Clerk Dashboard. Then, copy the values of `NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY` and `CLERK_SECRET_KEY` into the `.env.local` file (which is not tracked by Git):
|
||||||
|
|
||||||
|
```shell
|
||||||
|
NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY=your_clerk_pub_key
|
||||||
|
CLERK_SECRET_KEY=your_clerk_secret_key
|
||||||
|
```
|
||||||
|
|
||||||
|
In your Clerk Dashboard, you also need to `Enable Organization` by navigating to `Organization management` > `Settings` > `Enable organization`.
|
||||||
|
|
||||||
|
Now, you have a fully working authentication system with Next.js: Sign up, Sign in, Sign out, Forgot password, Reset password, Update profile, Update password, Update email, Delete account, and more.
|
||||||
|
|
||||||
|
### Set up remote database
|
||||||
|
|
||||||
|
The project uses DrizzleORM, a type-safe ORM that is compatible with PostgreSQL, SQLite, and MySQL databases. By default, the project is set up to work seamlessly with PostgreSQL and you can easily choose any PostgreSQL database provider.
|
||||||
|
|
||||||
|
### Translation (i18n) setup
|
||||||
|
|
||||||
|
For translation, the project uses `next-intl` combined with [Crowdin](https://l.crowdin.com/next-js). As a developer, you only need to take care of the English (or another default language) version. Translations for other languages are automatically generated and handled by Crowdin. You can use Crowdin to collaborate with your translation team or translate the messages yourself with the help of machine translation.
|
||||||
|
|
||||||
|
To set up translation (i18n), create an account at [Crowdin.com](https://l.crowdin.com/next-js) and create a new project. In the newly created project, you will be able to find the project ID. You will also need to create a new Personal Access Token by going to Account Settings > API. Then, in your GitHub Actions, you need to define the following environment variables: `CROWDIN_PROJECT_ID` and `CROWDIN_PERSONAL_TOKEN`.
|
||||||
|
|
||||||
|
After defining the environment variables in your GitHub Actions, your localization files will be synchronized with Crowdin every time you push a new commit to the `main` branch.
|
||||||
|
|
||||||
|
### Project structure
|
||||||
|
|
||||||
|
```shell
|
||||||
|
.
|
||||||
|
├── README.md # README file
|
||||||
|
├── .github # GitHub folder
|
||||||
|
├── .husky # Husky configuration
|
||||||
|
├── .storybook # Storybook folder
|
||||||
|
├── .vscode # VSCode configuration
|
||||||
|
├── migrations # Database migrations
|
||||||
|
├── public # Public assets folder
|
||||||
|
├── scripts # Scripts folder
|
||||||
|
├── src
|
||||||
|
│ ├── app # Next JS App (App Router)
|
||||||
|
│ ├── components # Reusable components
|
||||||
|
│ ├── features # Components specific to a feature
|
||||||
|
│ ├── libs # 3rd party libraries configuration
|
||||||
|
│ ├── locales # Locales folder (i18n messages)
|
||||||
|
│ ├── models # Database models
|
||||||
|
│ ├── styles # Styles folder
|
||||||
|
│ ├── templates # Templates folder
|
||||||
|
│ ├── types # Type definitions
|
||||||
|
│ └── utils # Utilities folder
|
||||||
|
├── tests
|
||||||
|
│ ├── e2e # E2E tests, also includes Monitoring as Code
|
||||||
|
│ └── integration # Integration tests
|
||||||
|
├── tailwind.config.js # Tailwind CSS configuration
|
||||||
|
└── tsconfig.json # TypeScript configuration
|
||||||
|
```
|
||||||
|
|
||||||
|
### Customization
|
||||||
|
|
||||||
|
You can easily configure Next.js SaaS Boilerplate by searching the entire project for `FIXME:` to make quick customization. Here are some of the most important files to customize:
|
||||||
|
|
||||||
|
- `public/apple-touch-icon.png`, `public/favicon.ico`, `public/favicon-16x16.png` and `public/favicon-32x32.png`: your website favicon
|
||||||
|
- `src/utils/AppConfig.ts`: configuration file
|
||||||
|
- `src/templates/BaseTemplate.tsx`: default theme
|
||||||
|
- `next.config.mjs`: Next.js configuration
|
||||||
|
- `.env`: default environment variables
|
||||||
|
|
||||||
|
You have full access to the source code for further customization. The provided code is just an example to help you start your project. The sky's the limit 🚀.
|
||||||
|
|
||||||
|
In the source code, you will also find `PRO` comments that indicate the code that is only available in the PRO version. You can easily remove or replace this code with your own implementation.
|
||||||
|
|
||||||
|
### Change database schema
|
||||||
|
|
||||||
|
To modify the database schema in the project, you can update the schema file located at `./src/models/Schema.ts`. This file defines the structure of your database tables using the Drizzle ORM library.
|
||||||
|
|
||||||
|
After making changes to the schema, generate a migration by running the following command:
|
||||||
|
|
||||||
|
```shell
|
||||||
|
npm run db:generate
|
||||||
|
```
|
||||||
|
|
||||||
|
This will create a migration file that reflects your schema changes. The migration is automatically applied during the next database interaction, so there is no need to run it manually or restart the Next.js server.
|
||||||
|
|
||||||
|
### Commit Message Format
|
||||||
|
|
||||||
|
The project follows the [Conventional Commits](https://www.conventionalcommits.org/) specification, meaning all commit messages must be formatted accordingly. To help you write commit messages, the project uses [Commitizen](https://github.com/commitizen/cz-cli), an interactive CLI that guides you through the commit process. To use it, run the following command:
|
||||||
|
|
||||||
|
```shell
|
||||||
|
npm run commit
|
||||||
|
```
|
||||||
|
|
||||||
|
One of the benefits of using Conventional Commits is the ability to automatically generate a `CHANGELOG` file. It also allows us to automatically determine the next version number based on the types of commits that are included in a release.
|
||||||
|
|
||||||
|
### Subscription payment with Stripe
|
||||||
|
|
||||||
|
The project is integrated with Stripe for subscription payment. You need to create a Stripe account and you also need to install the Stripe CLI. After installing the Stripe CLI, you need to login using the CLI:
|
||||||
|
|
||||||
|
```shell
|
||||||
|
stripe login
|
||||||
|
```
|
||||||
|
|
||||||
|
Then, you can run the following command to create a new price:
|
||||||
|
|
||||||
|
```shell
|
||||||
|
npm run stripe:setup-price
|
||||||
|
```
|
||||||
|
|
||||||
|
After running the command, you need to copy the price ID and paste it in `src/utils/AppConfig.ts` by updating the existing price ID with the new one.
|
||||||
|
|
||||||
|
In your Stripe Dashboard, you are required to configure your customer portal settings at https://dashboard.stripe.com/test/settings/billing/portal. Most importantly, you need to save the settings.
|
||||||
|
|
||||||
|
In your `.env` file, you need to update the `NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY` with your own Stripe Publishable key. You can find the key in your Stripe Dashboard. Then, you also need to create a new file named `.env.local` and add the following environment variables in the newly created file:
|
||||||
|
|
||||||
|
```shell
|
||||||
|
STRIPE_SECRET_KEY=your_stripe_secret_key
|
||||||
|
STRIPE_WEBHOOK_SECRET=your_stripe_webhook_secret
|
||||||
|
```
|
||||||
|
|
||||||
|
You get the `STRIPE_SECRET_KEY` from your Stripe Dashboard. The `STRIPE_WEBHOOK_SECRET` is generated by running the following command:
|
||||||
|
|
||||||
|
```shell
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
You'll find in your terminal the webhook signing secret. You can copy it and paste it in your `.env.local` file.
|
||||||
|
|
||||||
|
### Testing
|
||||||
|
|
||||||
|
All unit tests are located alongside the source code in the same directory, making them easier to find. The project uses Vitest and React Testing Library for unit testing. You can run the tests with the following command:
|
||||||
|
|
||||||
|
```shell
|
||||||
|
npm run test
|
||||||
|
```
|
||||||
|
|
||||||
|
### Integration & E2E Testing
|
||||||
|
|
||||||
|
The project uses Playwright for integration and end-to-end (E2E) testing. You can run the tests with the following commands:
|
||||||
|
|
||||||
|
```shell
|
||||||
|
npx playwright install # Only for the first time in a new environment
|
||||||
|
npm run test:e2e
|
||||||
|
```
|
||||||
|
|
||||||
|
In the local environment, visual testing is disabled, and the terminal will display the message `[percy] Percy is not running, disabling snapshots.`. By default, visual testing only runs in GitHub Actions.
|
||||||
|
|
||||||
|
### Enable Edge runtime (optional)
|
||||||
|
|
||||||
|
The App Router folder is compatible with the Edge runtime. You can enable it by adding the following lines `src/app/layouts.tsx`:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
export const runtime = 'edge';
|
||||||
|
```
|
||||||
|
|
||||||
|
For your information, the database migration is not compatible with the Edge runtime. So, you need to disable the automatic migration in `src/libs/DB.ts`:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
await migrate(db, { migrationsFolder: './migrations' });
|
||||||
|
```
|
||||||
|
|
||||||
|
After disabling it, you are required to run the migration manually with:
|
||||||
|
|
||||||
|
```shell
|
||||||
|
npm run db:migrate
|
||||||
|
```
|
||||||
|
|
||||||
|
You also require to run the command each time you want to update the database schema.
|
||||||
|
|
||||||
|
### Deploy to production
|
||||||
|
|
||||||
|
During the build process, database migrations are automatically executed, so there's no need to run them manually. However, you must define `DATABASE_URL` in your environment variables.
|
||||||
|
|
||||||
|
Then, you can generate a production build with:
|
||||||
|
|
||||||
|
```shell
|
||||||
|
$ npm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
It generates an optimized production build of the boilerplate. To test the generated build, run:
|
||||||
|
|
||||||
|
```shell
|
||||||
|
$ npm run start
|
||||||
|
```
|
||||||
|
|
||||||
|
You also need to defined the environment variables `CLERK_SECRET_KEY` using your own key.
|
||||||
|
|
||||||
|
This command starts a local server using the production build. You can now open http://localhost:3000 in your preferred browser to see the result.
|
||||||
|
|
||||||
|
### Error Monitoring
|
||||||
|
|
||||||
|
The project uses [Sentry](https://sentry.io/for/nextjs/?utm_source=github&utm_medium=paid-community&utm_campaign=general-fy25q1-nextjs&utm_content=github-banner-nextjsboilerplate-logo) to monitor errors. In the development environment, no additional setup is needed: NextJS SaaS Boilerplate is pre-configured to use Sentry and Spotlight (Sentry for Development). All errors will automatically be sent to your local Spotlight instance, allowing you to experience Sentry locally.
|
||||||
|
|
||||||
|
For production environment, you'll need to create a Sentry account and a new project. Then, in `next.config.mjs`, you need to update the `org` and `project` attributes in `withSentryConfig` function. Additionally, add your Sentry DSN to `sentry.client.config.ts`, `sentry.edge.config.ts` and `sentry.server.config.ts`.
|
||||||
|
|
||||||
|
### Code coverage
|
||||||
|
|
||||||
|
Next.js SaaS Template relies on [Codecov](https://about.codecov.io/codecov-free-trial/?utm_source=github&utm_medium=paid-community&utm_campaign=general-fy25q1-nextjs&utm_content=github-banner-nextjsboilerplate-logo) for code coverage reporting solution. To enable Codecov, create a Codecov account and connect it to your GitHub account. Your repositories should appear on your Codecov dashboard. Select the desired repository and copy the token. In GitHub Actions, define the `CODECOV_TOKEN` environment variable and paste the token.
|
||||||
|
|
||||||
|
Make sure to create `CODECOV_TOKEN` as a GitHub Actions secret, do not paste it directly into your source code.
|
||||||
|
|
||||||
|
### Logging
|
||||||
|
|
||||||
|
The project uses Pino.js for logging. In the development environment, logs are displayed in the console by default.
|
||||||
|
|
||||||
|
For production, the project is already integrated with [Better Stack](https://betterstack.com/?utm_source=github&utm_medium=sponsorship&utm_campaign=next-js-boilerplate) to manage and query your logs using SQL. To use Better Stack, you need to create a [Better Stack](https://betterstack.com/?utm_source=github&utm_medium=sponsorship&utm_campaign=next-js-boilerplate) account and create a new source: go to your Better Stack Logs Dashboard > Sources > Connect source. Then, you need to give a name to your source and select Node.js as the platform.
|
||||||
|
|
||||||
|
After creating the source, you will be able to view and copy your source token. In your environment variables, paste the token into the `LOGTAIL_SOURCE_TOKEN` variable. Now, all logs will automatically be sent to and ingested by Better Stack.
|
||||||
|
|
||||||
|
### Checkly monitoring
|
||||||
|
|
||||||
|
The project uses [Checkly](https://www.checklyhq.com/?utm_source=github&utm_medium=sponsorship&utm_campaign=next-js-boilerplate) to ensure that your production environment is always up and running. At regular intervals, Checkly runs the tests ending with `*.check.e2e.ts` extension and notifies you if any of the tests fail. Additionally, you have the flexibility to execute tests from multiple locations to ensure that your application is available worldwide.
|
||||||
|
|
||||||
|
To use Checkly, you must first create an account on [their website](https://www.checklyhq.com/?utm_source=github&utm_medium=sponsorship&utm_campaign=next-js-boilerplate). After creating an account, generate a new API key in the Checkly Dashboard and set the `CHECKLY_API_KEY` environment variable in GitHub Actions. Additionally, you will need to define the `CHECKLY_ACCOUNT_ID`, which can also be found in your Checkly Dashboard under User Settings > General.
|
||||||
|
|
||||||
|
To complete the setup, update the `checkly.config.ts` file with your own email address and production URL.
|
||||||
|
|
||||||
|
### Useful commands
|
||||||
|
|
||||||
|
#### Bundle Analyzer
|
||||||
|
|
||||||
|
Next.js SaaS Starter Kit includes a built-in bundle analyzer. It can be used to analyze the size of your JavaScript bundles. To begin, run the following command:
|
||||||
|
|
||||||
|
```shell
|
||||||
|
npm run build-stats
|
||||||
|
```
|
||||||
|
|
||||||
|
By running the command, it'll automatically open a new browser window with the results.
|
||||||
|
|
||||||
|
#### Database Studio
|
||||||
|
|
||||||
|
The project is already configured with Drizzle Studio to explore the database. You can run the following command to open the database studio:
|
||||||
|
|
||||||
|
```shell
|
||||||
|
npm run db:studio
|
||||||
|
```
|
||||||
|
|
||||||
|
Then, you can open https://local.drizzle.studio with your favorite browser to explore your database.
|
||||||
|
|
||||||
|
### VSCode information (optional)
|
||||||
|
|
||||||
|
If you are VSCode user, you can have a better integration with VSCode by installing the suggested extension in `.vscode/extension.json`. The starter code comes up with Settings for a seamless integration with VSCode. The Debug configuration is also provided for frontend and backend debugging experience.
|
||||||
|
|
||||||
|
With the plugins installed in your VSCode, ESLint and Prettier can automatically fix the code and display errors. The same applies to testing: you can install the VSCode Vitest extension to automatically run your tests, and it also shows the code coverage in context.
|
||||||
|
|
||||||
|
Pro tips: if you need a project wide-type checking with TypeScript, you can run a build with <kbd>Cmd</kbd> + <kbd>Shift</kbd> + <kbd>B</kbd> on Mac.
|
||||||
|
|
||||||
|
### Contributions
|
||||||
|
|
||||||
|
Everyone is welcome to contribute to this project. Feel free to open an issue if you have any questions or find a bug. Totally open to suggestions and improvements.
|
||||||
|
|
||||||
|
### License
|
||||||
|
|
||||||
|
Licensed under the MIT License, Copyright © 2024
|
||||||
|
|
||||||
|
See [LICENSE](LICENSE) for more information.
|
||||||
|
|
||||||
|
## Sponsors
|
||||||
|
|
||||||
|
<table width="100%">
|
||||||
|
<tr height="187px">
|
||||||
|
<td align="center" width="33%">
|
||||||
|
<a href="https://go.clerk.com/zGlzydF">
|
||||||
|
<picture>
|
||||||
|
<source media="(prefers-color-scheme: dark)" srcset="https://github.com/ixartz/SaaS-Boilerplate/assets/1328388/6fb61971-3bf1-4580-98a0-10bd3f1040a2">
|
||||||
|
<source media="(prefers-color-scheme: light)" srcset="https://github.com/ixartz/SaaS-Boilerplate/assets/1328388/f80a8bb5-66da-4772-ad36-5fabc5b02c60">
|
||||||
|
<img alt="Clerk – Authentication & User Management for Next.js" src="https://github.com/ixartz/SaaS-Boilerplate/assets/1328388/f80a8bb5-66da-4772-ad36-5fabc5b02c60">
|
||||||
|
</picture>
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
<td align="center" width="33%">
|
||||||
|
<a href="https://l.crowdin.com/next-js">
|
||||||
|
<picture>
|
||||||
|
<source media="(prefers-color-scheme: dark)" srcset="public/assets/images/crowdin-white.png?raw=true">
|
||||||
|
<source media="(prefers-color-scheme: light)" srcset="public/assets/images/crowdin-dark.png?raw=true">
|
||||||
|
<img alt="Crowdin" src="public/assets/images/crowdin-dark.png?raw=true">
|
||||||
|
</picture>
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
<td align="center" width="33%">
|
||||||
|
<a href="https://sentry.io/for/nextjs/?utm_source=github&utm_medium=paid-community&utm_campaign=general-fy25q1-nextjs&utm_content=github-banner-nextjsboilerplate-logo">
|
||||||
|
<picture>
|
||||||
|
<source media="(prefers-color-scheme: dark)" srcset="public/assets/images/sentry-white.png?raw=true">
|
||||||
|
<source media="(prefers-color-scheme: light)" srcset="public/assets/images/sentry-dark.png?raw=true">
|
||||||
|
<img alt="Sentry" src="public/assets/images/sentry-dark.png?raw=true">
|
||||||
|
</picture>
|
||||||
|
</a>
|
||||||
|
<a href="https://about.codecov.io/codecov-free-trial/?utm_source=github&utm_medium=paid-community&utm_campaign=general-fy25q1-nextjs&utm_content=github-banner-nextjsboilerplate-logo">
|
||||||
|
<picture>
|
||||||
|
<source media="(prefers-color-scheme: dark)" srcset="public/assets/images/codecov-white.svg?raw=true">
|
||||||
|
<source media="(prefers-color-scheme: light)" srcset="public/assets/images/codecov-dark.svg?raw=true">
|
||||||
|
<img alt="Codecov" src="public/assets/images/codecov-dark.svg?raw=true">
|
||||||
|
</picture>
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr height="187px">
|
||||||
|
<td align="center" style=width="33%">
|
||||||
|
<a href="https://nextjs-boilerplate.com/pro-saas-starter-kit">
|
||||||
|
<img src="public/assets/images/nextjs-boilerplate-saas.png?raw=true" alt="Next.js SaaS Boilerplate with React" />
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr height="187px">
|
||||||
|
<td align="center" width="33%">
|
||||||
|
<a href="mailto:contact@creativedesignsguru.com">
|
||||||
|
Add your logo here
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
Made with ♥ by [CreativeDesignsGuru](https://creativedesignsguru.com) [](https://twitter.com/ixartz)
|
||||||
|
|
||||||
|
Looking for a custom boilerplate to kick off your project? I'd be glad to discuss how I can help you build one. Feel free to reach out anytime at contact@creativedesignsguru.com!
|
||||||
|
|
||||||
|
[](https://github.com/sponsors/ixartz)
|
||||||
48
checkly.config.ts
Normal file
|
|
@ -0,0 +1,48 @@
|
||||||
|
import { defineConfig } from 'checkly';
|
||||||
|
import { EmailAlertChannel, Frequency } from 'checkly/constructs';
|
||||||
|
|
||||||
|
const sendDefaults = {
|
||||||
|
sendFailure: true,
|
||||||
|
sendRecovery: true,
|
||||||
|
sendDegraded: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
// FIXME: Add your production URL
|
||||||
|
const productionURL = 'https://react-saas.com';
|
||||||
|
|
||||||
|
const emailChannel = new EmailAlertChannel('email-channel-1', {
|
||||||
|
// FIXME: add your own email address, Checkly will send you an email notification if a check fails
|
||||||
|
address: 'contact@creativedesignsguru.com',
|
||||||
|
...sendDefaults,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const config = defineConfig({
|
||||||
|
// FIXME: Add your own project name, logical ID, and repository URL
|
||||||
|
projectName: 'SaaS Boilerplate',
|
||||||
|
logicalId: 'saas-boilerplate',
|
||||||
|
repoUrl: 'https://github.com/ixartz/Next-js-Boilerplate',
|
||||||
|
checks: {
|
||||||
|
locations: ['us-east-1', 'eu-west-1'],
|
||||||
|
tags: ['website'],
|
||||||
|
runtimeId: '2024.02',
|
||||||
|
browserChecks: {
|
||||||
|
frequency: Frequency.EVERY_24H,
|
||||||
|
testMatch: '**/tests/e2e/**/*.check.e2e.ts',
|
||||||
|
alertChannels: [emailChannel],
|
||||||
|
},
|
||||||
|
playwrightConfig: {
|
||||||
|
use: {
|
||||||
|
baseURL: process.env.ENVIRONMENT_URL || productionURL,
|
||||||
|
extraHTTPHeaders: {
|
||||||
|
'x-vercel-protection-bypass': process.env.VERCEL_BYPASS_TOKEN,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
cli: {
|
||||||
|
runLocation: 'eu-west-1',
|
||||||
|
reporters: ['list'],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export default config;
|
||||||
3
codecov.yml
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
coverage:
|
||||||
|
status:
|
||||||
|
patch: off
|
||||||
7
commitlint.config.ts
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
import type { UserConfig } from '@commitlint/types';
|
||||||
|
|
||||||
|
const Configuration: UserConfig = {
|
||||||
|
extends: ['@commitlint/config-conventional'],
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Configuration;
|
||||||
17
components.json
Normal file
|
|
@ -0,0 +1,17 @@
|
||||||
|
{
|
||||||
|
"$schema": "https://ui.shadcn.com/schema.json",
|
||||||
|
"style": "default",
|
||||||
|
"rsc": true,
|
||||||
|
"tsx": true,
|
||||||
|
"tailwind": {
|
||||||
|
"config": "tailwind.config.ts",
|
||||||
|
"css": "src/styles/global.css",
|
||||||
|
"baseColor": "slate",
|
||||||
|
"cssVariables": true,
|
||||||
|
"prefix": ""
|
||||||
|
},
|
||||||
|
"aliases": {
|
||||||
|
"components": "@/components",
|
||||||
|
"utils": "@/utils/Helpers"
|
||||||
|
}
|
||||||
|
}
|
||||||
32
crowdin.yml
Normal file
|
|
@ -0,0 +1,32 @@
|
||||||
|
#
|
||||||
|
# Your Crowdin credentials
|
||||||
|
#
|
||||||
|
# No need modify CROWDIN_PROJECT_ID and CROWDIN_PERSONAL_TOKEN, you can set them in GitHub Actions secrets
|
||||||
|
project_id_env: CROWDIN_PROJECT_ID
|
||||||
|
api_token_env: CROWDIN_PERSONAL_TOKEN
|
||||||
|
base_path: .
|
||||||
|
base_url: 'https://api.crowdin.com' # https://{organization-name}.crowdin.com for Crowdin Enterprise
|
||||||
|
|
||||||
|
#
|
||||||
|
# Choose file structure in Crowdin
|
||||||
|
# e.g. true or false
|
||||||
|
#
|
||||||
|
preserve_hierarchy: true
|
||||||
|
|
||||||
|
#
|
||||||
|
# Files configuration
|
||||||
|
#
|
||||||
|
files:
|
||||||
|
- source: /src/locales/en.json
|
||||||
|
|
||||||
|
#
|
||||||
|
# Where translations will be placed
|
||||||
|
# e.g. "/resources/%two_letters_code%/%original_file_name%"
|
||||||
|
#
|
||||||
|
translation: '/src/locales/%two_letters_code%.json'
|
||||||
|
|
||||||
|
#
|
||||||
|
# File type
|
||||||
|
# e.g. "json"
|
||||||
|
#
|
||||||
|
type: json
|
||||||
12
drizzle.config.ts
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
import { defineConfig } from 'drizzle-kit';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
out: './migrations',
|
||||||
|
schema: './src/models/Schema.ts',
|
||||||
|
dialect: 'postgresql',
|
||||||
|
dbCredentials: {
|
||||||
|
url: process.env.DATABASE_URL ?? '',
|
||||||
|
},
|
||||||
|
verbose: true,
|
||||||
|
strict: true,
|
||||||
|
});
|
||||||
68
eslint.config.mjs
Normal file
|
|
@ -0,0 +1,68 @@
|
||||||
|
import antfu from '@antfu/eslint-config';
|
||||||
|
import nextPlugin from '@next/eslint-plugin-next';
|
||||||
|
import jestDom from 'eslint-plugin-jest-dom';
|
||||||
|
import jsxA11y from 'eslint-plugin-jsx-a11y';
|
||||||
|
import playwright from 'eslint-plugin-playwright';
|
||||||
|
import simpleImportSort from 'eslint-plugin-simple-import-sort';
|
||||||
|
import tailwind from 'eslint-plugin-tailwindcss';
|
||||||
|
import testingLibrary from 'eslint-plugin-testing-library';
|
||||||
|
|
||||||
|
export default antfu({
|
||||||
|
react: true,
|
||||||
|
typescript: true,
|
||||||
|
|
||||||
|
lessOpinionated: true,
|
||||||
|
isInEditor: false,
|
||||||
|
|
||||||
|
stylistic: {
|
||||||
|
semi: true,
|
||||||
|
},
|
||||||
|
|
||||||
|
formatters: {
|
||||||
|
css: true,
|
||||||
|
},
|
||||||
|
|
||||||
|
ignores: [
|
||||||
|
'migrations/**/*',
|
||||||
|
'next-env.d.ts',
|
||||||
|
],
|
||||||
|
}, ...tailwind.configs['flat/recommended'], jsxA11y.flatConfigs.recommended, {
|
||||||
|
plugins: {
|
||||||
|
'@next/next': nextPlugin,
|
||||||
|
},
|
||||||
|
rules: {
|
||||||
|
...nextPlugin.configs.recommended.rules,
|
||||||
|
...nextPlugin.configs['core-web-vitals'].rules,
|
||||||
|
},
|
||||||
|
}, {
|
||||||
|
plugins: {
|
||||||
|
'simple-import-sort': simpleImportSort,
|
||||||
|
},
|
||||||
|
rules: {
|
||||||
|
'simple-import-sort/imports': 'error',
|
||||||
|
'simple-import-sort/exports': 'error',
|
||||||
|
},
|
||||||
|
}, {
|
||||||
|
files: [
|
||||||
|
'**/*.test.ts?(x)',
|
||||||
|
],
|
||||||
|
...testingLibrary.configs['flat/react'],
|
||||||
|
...jestDom.configs['flat/recommended'],
|
||||||
|
}, {
|
||||||
|
files: [
|
||||||
|
'**/*.spec.ts',
|
||||||
|
'**/*.e2e.ts',
|
||||||
|
],
|
||||||
|
...playwright.configs['flat/recommended'],
|
||||||
|
}, {
|
||||||
|
rules: {
|
||||||
|
'import/order': 'off', // Avoid conflicts with `simple-import-sort` plugin
|
||||||
|
'sort-imports': 'off', // Avoid conflicts with `simple-import-sort` plugin
|
||||||
|
'style/brace-style': ['error', '1tbs'], // Use the default brace style
|
||||||
|
'ts/consistent-type-definitions': ['error', 'type'], // Use `type` instead of `interface`
|
||||||
|
'react/prefer-destructuring-assignment': 'off', // Vscode doesn't support automatically destructuring, it's a pain to add a new variable
|
||||||
|
'node/prefer-global/process': 'off', // Allow using `process.env`
|
||||||
|
'test/padding-around-all': 'error', // Add padding in test files
|
||||||
|
'test/prefer-lowercase-title': 'off', // Allow using uppercase titles in test titles
|
||||||
|
},
|
||||||
|
});
|
||||||
4
lint-staged.config.js
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
module.exports = {
|
||||||
|
'*': ['eslint --fix --no-warn-ignored'],
|
||||||
|
'**/*.ts?(x)': () => 'npm run check-types',
|
||||||
|
};
|
||||||
21
migrations/0000_init-db.sql
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
CREATE TABLE IF NOT EXISTS "organization" (
|
||||||
|
"id" text PRIMARY KEY NOT NULL,
|
||||||
|
"stripe_customer_id" text,
|
||||||
|
"stripe_subscription_id" text,
|
||||||
|
"stripe_subscription_price_id" text,
|
||||||
|
"stripe_subscription_status" text,
|
||||||
|
"stripe_subscription_current_period_end" bigint,
|
||||||
|
"updated_at" timestamp DEFAULT now() NOT NULL,
|
||||||
|
"created_at" timestamp DEFAULT now() NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE IF NOT EXISTS "todo" (
|
||||||
|
"id" serial PRIMARY KEY NOT NULL,
|
||||||
|
"owner_id" text NOT NULL,
|
||||||
|
"title" text NOT NULL,
|
||||||
|
"message" text NOT NULL,
|
||||||
|
"updated_at" timestamp DEFAULT now() NOT NULL,
|
||||||
|
"created_at" timestamp DEFAULT now() NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS "stripe_customer_id_idx" ON "organization" USING btree ("stripe_customer_id");
|
||||||
140
migrations/meta/0000_snapshot.json
Normal file
|
|
@ -0,0 +1,140 @@
|
||||||
|
{
|
||||||
|
"id": "400db9e7-780a-44b8-b0be-2db504f59104",
|
||||||
|
"prevId": "00000000-0000-0000-0000-000000000000",
|
||||||
|
"version": "7",
|
||||||
|
"dialect": "postgresql",
|
||||||
|
"tables": {
|
||||||
|
"public.organization": {
|
||||||
|
"name": "organization",
|
||||||
|
"schema": "",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"stripe_customer_id": {
|
||||||
|
"name": "stripe_customer_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"stripe_subscription_id": {
|
||||||
|
"name": "stripe_subscription_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"stripe_subscription_price_id": {
|
||||||
|
"name": "stripe_subscription_price_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"stripe_subscription_status": {
|
||||||
|
"name": "stripe_subscription_status",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"stripe_subscription_current_period_end": {
|
||||||
|
"name": "stripe_subscription_current_period_end",
|
||||||
|
"type": "bigint",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"updated_at": {
|
||||||
|
"name": "updated_at",
|
||||||
|
"type": "timestamp",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "now()"
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "timestamp",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "now()"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {
|
||||||
|
"stripe_customer_id_idx": {
|
||||||
|
"name": "stripe_customer_id_idx",
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"expression": "stripe_customer_id",
|
||||||
|
"isExpression": false,
|
||||||
|
"asc": true,
|
||||||
|
"nulls": "last"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"isUnique": true,
|
||||||
|
"concurrently": false,
|
||||||
|
"method": "btree",
|
||||||
|
"with": {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"foreignKeys": {},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {}
|
||||||
|
},
|
||||||
|
"public.todo": {
|
||||||
|
"name": "todo",
|
||||||
|
"schema": "",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "serial",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"owner_id": {
|
||||||
|
"name": "owner_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"title": {
|
||||||
|
"name": "title",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"message": {
|
||||||
|
"name": "message",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"updated_at": {
|
||||||
|
"name": "updated_at",
|
||||||
|
"type": "timestamp",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "now()"
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "timestamp",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "now()"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"enums": {},
|
||||||
|
"schemas": {},
|
||||||
|
"sequences": {},
|
||||||
|
"_meta": {
|
||||||
|
"columns": {},
|
||||||
|
"schemas": {},
|
||||||
|
"tables": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
13
migrations/meta/_journal.json
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
{
|
||||||
|
"version": "7",
|
||||||
|
"dialect": "postgresql",
|
||||||
|
"entries": [
|
||||||
|
{
|
||||||
|
"idx": 0,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1726784321202,
|
||||||
|
"tag": "0000_init-db",
|
||||||
|
"breakpoints": true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
69
next.config.mjs
Normal file
|
|
@ -0,0 +1,69 @@
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
|
||||||
|
import withBundleAnalyzer from '@next/bundle-analyzer';
|
||||||
|
import { withSentryConfig } from '@sentry/nextjs';
|
||||||
|
import createJiti from 'jiti';
|
||||||
|
import withNextIntl from 'next-intl/plugin';
|
||||||
|
|
||||||
|
const jiti = createJiti(fileURLToPath(import.meta.url));
|
||||||
|
|
||||||
|
jiti('./src/libs/Env');
|
||||||
|
|
||||||
|
const withNextIntlConfig = withNextIntl('./src/libs/i18n.ts');
|
||||||
|
|
||||||
|
const bundleAnalyzer = withBundleAnalyzer({
|
||||||
|
enabled: process.env.ANALYZE === 'true',
|
||||||
|
});
|
||||||
|
|
||||||
|
/** @type {import('next').NextConfig} */
|
||||||
|
export default withSentryConfig(
|
||||||
|
bundleAnalyzer(
|
||||||
|
withNextIntlConfig({
|
||||||
|
eslint: {
|
||||||
|
dirs: ['.'],
|
||||||
|
},
|
||||||
|
poweredByHeader: false,
|
||||||
|
reactStrictMode: true,
|
||||||
|
experimental: {
|
||||||
|
serverComponentsExternalPackages: ['@electric-sql/pglite'],
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
{
|
||||||
|
// For all available options, see:
|
||||||
|
// https://github.com/getsentry/sentry-webpack-plugin#options
|
||||||
|
// FIXME: Add your Sentry organization and project names
|
||||||
|
org: 'nextjs-boilerplate-org',
|
||||||
|
project: 'nextjs-boilerplate',
|
||||||
|
|
||||||
|
// Only print logs for uploading source maps in CI
|
||||||
|
silent: !process.env.CI,
|
||||||
|
|
||||||
|
// For all available options, see:
|
||||||
|
// https://docs.sentry.io/platforms/javascript/guides/nextjs/manual-setup/
|
||||||
|
|
||||||
|
// Upload a larger set of source maps for prettier stack traces (increases build time)
|
||||||
|
widenClientFileUpload: true,
|
||||||
|
|
||||||
|
// Route browser requests to Sentry through a Next.js rewrite to circumvent ad-blockers.
|
||||||
|
// This can increase your server load as well as your hosting bill.
|
||||||
|
// Note: Check that the configured route will not match with your Next.js middleware, otherwise reporting of client-
|
||||||
|
// side errors will fail.
|
||||||
|
tunnelRoute: '/monitoring',
|
||||||
|
|
||||||
|
// Hides source maps from generated client bundles
|
||||||
|
hideSourceMaps: true,
|
||||||
|
|
||||||
|
// Automatically tree-shake Sentry logger statements to reduce bundle size
|
||||||
|
disableLogger: true,
|
||||||
|
|
||||||
|
// Enables automatic instrumentation of Vercel Cron Monitors. (Does not yet work with App Router route handlers.)
|
||||||
|
// See the following for more information:
|
||||||
|
// https://docs.sentry.io/product/crons/
|
||||||
|
// https://vercel.com/docs/cron-jobs
|
||||||
|
automaticVercelMonitors: true,
|
||||||
|
|
||||||
|
// Disable Sentry telemetry
|
||||||
|
telemetry: false,
|
||||||
|
},
|
||||||
|
);
|
||||||
42057
package-lock.json
generated
Normal file
166
package.json
Normal file
|
|
@ -0,0 +1,166 @@
|
||||||
|
{
|
||||||
|
"name": "saas-boilerplate",
|
||||||
|
"version": "1.7.3",
|
||||||
|
"scripts": {
|
||||||
|
"dev:spotlight": "spotlight-sidecar",
|
||||||
|
"dev:next": "next dev",
|
||||||
|
"dev": "run-p dev:*",
|
||||||
|
"build": "next build",
|
||||||
|
"start": "next start",
|
||||||
|
"build-stats": "cross-env ANALYZE=true npm run build",
|
||||||
|
"clean": "rimraf .next out coverage",
|
||||||
|
"lint": "eslint .",
|
||||||
|
"lint:fix": "eslint . --fix",
|
||||||
|
"check-types": "tsc --noEmit --pretty",
|
||||||
|
"test": "vitest run",
|
||||||
|
"test:e2e": "playwright test",
|
||||||
|
"commit": "cz",
|
||||||
|
"db:generate": "drizzle-kit generate",
|
||||||
|
"db:migrate": "dotenv -c production -- drizzle-kit migrate",
|
||||||
|
"db:studio": "dotenv -c production -- drizzle-kit studio",
|
||||||
|
"storybook": "storybook dev -p 6006",
|
||||||
|
"storybook:build": "storybook build",
|
||||||
|
"storybook:serve": "http-server storybook-static --port 6006 --silent",
|
||||||
|
"serve-storybook": "run-s storybook:*",
|
||||||
|
"test-storybook:ci": "start-server-and-test serve-storybook http://127.0.0.1:6006 test-storybook",
|
||||||
|
"prepare": "husky"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@clerk/localizations": "^3.2.1",
|
||||||
|
"@clerk/nextjs": "^5.7.3",
|
||||||
|
"@clerk/themes": "^2.1.36",
|
||||||
|
"@electric-sql/pglite": "^0.2.12",
|
||||||
|
"@hookform/resolvers": "^3.9.0",
|
||||||
|
"@logtail/pino": "^0.5.2",
|
||||||
|
"@radix-ui/react-accordion": "^1.2.1",
|
||||||
|
"@radix-ui/react-avatar": "^1.1.1",
|
||||||
|
"@radix-ui/react-dropdown-menu": "^2.1.2",
|
||||||
|
"@radix-ui/react-icons": "^1.3.0",
|
||||||
|
"@radix-ui/react-label": "^2.1.0",
|
||||||
|
"@radix-ui/react-separator": "^1.1.0",
|
||||||
|
"@radix-ui/react-slot": "^1.1.0",
|
||||||
|
"@radix-ui/react-tooltip": "^1.1.3",
|
||||||
|
"@sentry/nextjs": "^8.34.0",
|
||||||
|
"@spotlightjs/spotlight": "^2.5.0",
|
||||||
|
"@t3-oss/env-nextjs": "^0.11.1",
|
||||||
|
"@tanstack/react-table": "^8.20.5",
|
||||||
|
"@types/recharts": "^1.8.29",
|
||||||
|
"class-variance-authority": "^0.7.0",
|
||||||
|
"clsx": "^2.1.1",
|
||||||
|
"date-fns": "^4.1.0",
|
||||||
|
"drizzle-orm": "^0.35.1",
|
||||||
|
"lucide-react": "^0.453.0",
|
||||||
|
"next": "^14.2.15",
|
||||||
|
"next-intl": "^3.21.1",
|
||||||
|
"next-themes": "^0.3.0",
|
||||||
|
"pg": "^8.13.0",
|
||||||
|
"pino": "^9.5.0",
|
||||||
|
"pino-pretty": "^11.3.0",
|
||||||
|
"react": "^18.3.1",
|
||||||
|
"react-day-picker": "^9.4.0",
|
||||||
|
"react-dom": "^18.3.1",
|
||||||
|
"react-hook-form": "^7.53.0",
|
||||||
|
"recharts": "^2.13.3",
|
||||||
|
"sharp": "^0.33.5",
|
||||||
|
"stripe": "^16.12.0",
|
||||||
|
"tailwind-merge": "^2.5.5",
|
||||||
|
"zod": "^3.23.8"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@antfu/eslint-config": "^2.27.3",
|
||||||
|
"@clerk/testing": "^1.3.11",
|
||||||
|
"@commitlint/cli": "^19.5.0",
|
||||||
|
"@commitlint/config-conventional": "^19.5.0",
|
||||||
|
"@commitlint/cz-commitlint": "^19.5.0",
|
||||||
|
"@eslint-react/eslint-plugin": "^1.15.0",
|
||||||
|
"@faker-js/faker": "^9.0.3",
|
||||||
|
"@next/bundle-analyzer": "^14.2.15",
|
||||||
|
"@next/eslint-plugin-next": "^14.2.15",
|
||||||
|
"@percy/cli": "1.30.1",
|
||||||
|
"@percy/playwright": "^1.0.6",
|
||||||
|
"@playwright/test": "^1.48.1",
|
||||||
|
"@semantic-release/changelog": "^6.0.3",
|
||||||
|
"@semantic-release/git": "^10.0.1",
|
||||||
|
"@storybook/addon-essentials": "^8.3.5",
|
||||||
|
"@storybook/addon-interactions": "^8.3.5",
|
||||||
|
"@storybook/addon-links": "^8.3.5",
|
||||||
|
"@storybook/addon-onboarding": "^8.3.5",
|
||||||
|
"@storybook/blocks": "^8.3.5",
|
||||||
|
"@storybook/nextjs": "^8.3.5",
|
||||||
|
"@storybook/react": "^8.3.5",
|
||||||
|
"@storybook/test": "^8.3.5",
|
||||||
|
"@storybook/test-runner": "^0.19.1",
|
||||||
|
"@testing-library/jest-dom": "^6.6.1",
|
||||||
|
"@testing-library/react": "^16.0.1",
|
||||||
|
"@testing-library/user-event": "^14.5.2",
|
||||||
|
"@types/node": "^22.7.6",
|
||||||
|
"@types/pg": "^8.11.10",
|
||||||
|
"@types/react": "^18.3.11",
|
||||||
|
"@vitejs/plugin-react": "^4.3.2",
|
||||||
|
"@vitest/coverage-v8": "^2.1.3",
|
||||||
|
"@vitest/expect": "^2.1.3",
|
||||||
|
"autoprefixer": "^10.4.20",
|
||||||
|
"checkly": "^4.9.0",
|
||||||
|
"commitizen": "^4.3.1",
|
||||||
|
"cross-env": "^7.0.3",
|
||||||
|
"cssnano": "^7.0.6",
|
||||||
|
"dotenv-cli": "^7.4.2",
|
||||||
|
"drizzle-kit": "^0.26.2",
|
||||||
|
"eslint": "^8.57.1",
|
||||||
|
"eslint-plugin-format": "^0.1.2",
|
||||||
|
"eslint-plugin-jest-dom": "^5.4.0",
|
||||||
|
"eslint-plugin-jsx-a11y": "^6.10.0",
|
||||||
|
"eslint-plugin-playwright": "^1.7.0",
|
||||||
|
"eslint-plugin-react-hooks": "^4.6.2",
|
||||||
|
"eslint-plugin-react-refresh": "^0.4.12",
|
||||||
|
"eslint-plugin-simple-import-sort": "^12.1.1",
|
||||||
|
"eslint-plugin-tailwindcss": "^3.17.5",
|
||||||
|
"eslint-plugin-testing-library": "^6.3.0",
|
||||||
|
"http-server": "^14.1.1",
|
||||||
|
"husky": "^9.1.6",
|
||||||
|
"jiti": "^1.21.6",
|
||||||
|
"jsdom": "^25.0.1",
|
||||||
|
"lint-staged": "^15.2.10",
|
||||||
|
"npm-run-all": "^4.1.5",
|
||||||
|
"postcss": "^8.4.47",
|
||||||
|
"rimraf": "^6.0.1",
|
||||||
|
"semantic-release": "^24.1.2",
|
||||||
|
"start-server-and-test": "^2.0.8",
|
||||||
|
"storybook": "^8.3.5",
|
||||||
|
"tailwindcss": "^3.4.14",
|
||||||
|
"tailwindcss-animate": "^1.0.7",
|
||||||
|
"tsx": "^4.19.1",
|
||||||
|
"typescript": "^5.6.3",
|
||||||
|
"vite-tsconfig-paths": "^5.0.1",
|
||||||
|
"vitest": "^2.1.3",
|
||||||
|
"vitest-fail-on-console": "^0.7.1"
|
||||||
|
},
|
||||||
|
"config": {
|
||||||
|
"commitizen": {
|
||||||
|
"path": "@commitlint/cz-commitlint"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"release": {
|
||||||
|
"branches": [
|
||||||
|
"main"
|
||||||
|
],
|
||||||
|
"plugins": [
|
||||||
|
[
|
||||||
|
"@semantic-release/commit-analyzer",
|
||||||
|
{
|
||||||
|
"preset": "conventionalcommits"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"@semantic-release/release-notes-generator",
|
||||||
|
"@semantic-release/changelog",
|
||||||
|
[
|
||||||
|
"@semantic-release/npm",
|
||||||
|
{
|
||||||
|
"npmPublish": false
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"@semantic-release/git",
|
||||||
|
"@semantic-release/github"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
74
playwright.config.ts
Normal file
|
|
@ -0,0 +1,74 @@
|
||||||
|
import { defineConfig, devices } from '@playwright/test';
|
||||||
|
|
||||||
|
// Use process.env.PORT by default and fallback to port 3000
|
||||||
|
const PORT = process.env.PORT || 3000;
|
||||||
|
|
||||||
|
// Set webServer.url and use.baseURL with the location of the WebServer respecting the correct set port
|
||||||
|
const baseURL = `http://localhost:${PORT}`;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* See https://playwright.dev/docs/test-configuration.
|
||||||
|
*/
|
||||||
|
export default defineConfig({
|
||||||
|
testDir: './tests',
|
||||||
|
// Look for files with the .spec.js or .e2e.js extension
|
||||||
|
testMatch: '*.@(spec|e2e).?(c|m)[jt]s?(x)',
|
||||||
|
// Timeout per test
|
||||||
|
timeout: 30 * 1000,
|
||||||
|
// Fail the build on CI if you accidentally left test.only in the source code.
|
||||||
|
forbidOnly: !!process.env.CI,
|
||||||
|
// Reporter to use. See https://playwright.dev/docs/test-reporters
|
||||||
|
reporter: process.env.CI ? 'github' : 'list',
|
||||||
|
|
||||||
|
expect: {
|
||||||
|
// Set timeout for async expect matchers
|
||||||
|
timeout: 10 * 1000,
|
||||||
|
},
|
||||||
|
|
||||||
|
// Run your local dev server before starting the tests:
|
||||||
|
// https://playwright.dev/docs/test-advanced#launching-a-development-web-server-during-the-tests
|
||||||
|
webServer: {
|
||||||
|
command: process.env.CI ? 'npm run start' : 'npm run dev:next',
|
||||||
|
url: baseURL,
|
||||||
|
timeout: 2 * 60 * 1000,
|
||||||
|
reuseExistingServer: !process.env.CI,
|
||||||
|
},
|
||||||
|
|
||||||
|
// Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions.
|
||||||
|
use: {
|
||||||
|
// Use baseURL so to make navigations relative.
|
||||||
|
// More information: https://playwright.dev/docs/api/class-testoptions#test-options-base-url
|
||||||
|
baseURL,
|
||||||
|
|
||||||
|
// Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer
|
||||||
|
trace: process.env.CI ? 'retain-on-failure' : undefined,
|
||||||
|
|
||||||
|
// Record videos when retrying the failed test.
|
||||||
|
video: process.env.CI ? 'retain-on-failure' : undefined,
|
||||||
|
},
|
||||||
|
|
||||||
|
projects: [
|
||||||
|
// `setup` and `teardown` are used to run code before and after all E2E tests.
|
||||||
|
// These functions can be used to configure Clerk for testing purposes. For example, bypassing bot detection.
|
||||||
|
// In the `setup` file, you can create an account in `Test mode`.
|
||||||
|
// For each test, an organization can be created within this account to ensure total isolation.
|
||||||
|
// After all tests are completed, the `teardown` file can delete the account and all associated organizations.
|
||||||
|
// You can find the `setup` and `teardown` files at: https://nextjs-boilerplate.com/pro-saas-starter-kit
|
||||||
|
{ name: 'setup', testMatch: /.*\.setup\.ts/, teardown: 'teardown' },
|
||||||
|
{ name: 'teardown', testMatch: /.*\.teardown\.ts/ },
|
||||||
|
{
|
||||||
|
name: 'chromium',
|
||||||
|
use: { ...devices['Desktop Chrome'] },
|
||||||
|
dependencies: ['setup'],
|
||||||
|
},
|
||||||
|
...(process.env.CI
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
name: 'firefox',
|
||||||
|
use: { ...devices['Desktop Firefox'] },
|
||||||
|
dependencies: ['setup'],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: []),
|
||||||
|
],
|
||||||
|
});
|
||||||
11
postcss.config.js
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
// Please do not use the array form (like ['tailwindcss', 'postcss-preset-env'])
|
||||||
|
// it will create an unexpected error: Invalid PostCSS Plugin found: [0]
|
||||||
|
|
||||||
|
/** @type {import('postcss-load-config').Config} */
|
||||||
|
module.exports = {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {},
|
||||||
|
...(process.env.NODE_ENV === 'production' ? { cssnano: {} } : {}),
|
||||||
|
},
|
||||||
|
};
|
||||||
BIN
public/apple-touch-icon.png
Normal file
|
After Width: | Height: | Size: 8.3 KiB |
BIN
public/assets/images/better-stack-dark.png
Normal file
|
After Width: | Height: | Size: 11 KiB |
BIN
public/assets/images/better-stack-white.png
Normal file
|
After Width: | Height: | Size: 13 KiB |
BIN
public/assets/images/checkly-logo-dark.png
Normal file
|
After Width: | Height: | Size: 5.5 KiB |
BIN
public/assets/images/checkly-logo-light.png
Normal file
|
After Width: | Height: | Size: 5.8 KiB |
BIN
public/assets/images/clerk-logo-dark.png
Normal file
|
After Width: | Height: | Size: 4 KiB |
BIN
public/assets/images/clerk-logo-white.png
Normal file
|
After Width: | Height: | Size: 3.6 KiB |
23
public/assets/images/codecov-dark.svg
Normal file
|
|
@ -0,0 +1,23 @@
|
||||||
|
<svg width="160" height="44" viewBox="0 0 160 44" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<g clip-path="url(#clip0_2_23)">
|
||||||
|
<g clip-path="url(#clip1_2_23)">
|
||||||
|
<path d="M137.239 41.0207L132.798 35.2884H131.692V42.9725H132.814V37.0839L137.38 42.9725H138.361V35.2884H137.239V41.0207Z" fill="#1F1633"/>
|
||||||
|
<path d="M126.058 39.5951H130.037V38.5967H126.056V36.284H130.546V35.2854H124.912V42.9725H130.603V41.9741H126.056L126.058 39.5951Z" fill="#1F1633"/>
|
||||||
|
<path d="M121.378 38.6208C119.829 38.2478 119.396 37.9531 119.396 37.2374C119.396 36.5937 119.964 36.1577 120.812 36.1577C121.585 36.1802 122.33 36.4489 122.939 36.9245L123.541 36.0734C122.77 35.4691 121.813 35.1503 120.833 35.1711C119.312 35.1711 118.25 36.0734 118.25 37.3576C118.25 38.741 119.152 39.2192 120.795 39.6192C122.257 39.9561 122.704 40.2688 122.704 40.9696C122.704 41.6703 122.103 42.1034 121.173 42.1034C120.248 42.0992 119.358 41.7496 118.678 41.1229L118.001 41.932C118.873 42.6812 119.985 43.0921 121.135 43.0899C122.783 43.0899 123.842 42.2027 123.842 40.8313C123.833 39.6704 123.147 39.0478 121.378 38.6208Z" fill="#1F1633"/>
|
||||||
|
<path d="M158.707 35.2884L156.394 38.8974L154.097 35.2884H152.755L155.789 39.935V42.9756H156.945V39.8989L160 35.2884H158.707Z" fill="#1F1633"/>
|
||||||
|
<path d="M139.268 36.3291H141.786V42.9756H142.941V36.3291H145.458V35.2884H139.272L139.268 36.3291Z" fill="#1F1633"/>
|
||||||
|
<path fill-rule="evenodd" clip-rule="evenodd" d="M150.8 39.9742C151.962 39.6523 152.605 38.8403 152.605 37.6794C152.605 36.2028 151.524 35.2734 149.783 35.2734H146.366V42.9695H147.51V40.2087H149.45L151.399 42.9756H152.734L150.629 40.0223L150.8 39.9742ZM147.507 39.2223V36.305H149.663C150.788 36.305 151.431 36.8373 151.431 37.7606C151.431 38.6839 150.743 39.2223 149.675 39.2223H147.507Z" fill="#1F1633"/>
|
||||||
|
<path fill-rule="evenodd" clip-rule="evenodd" d="M101.252 42.9497C101.171 42.9497 101.109 42.9279 101.066 42.8839C101.022 42.8328 101 42.7707 101 42.6977V35.5401C101 35.4597 101.022 35.3976 101.066 35.3538C101.109 35.3027 101.171 35.2771 101.252 35.2771H104.259C104.808 35.2771 105.253 35.3683 105.597 35.5511C105.948 35.7264 106.204 35.9676 106.365 36.2745C106.533 36.5814 106.618 36.9249 106.618 37.3049C106.618 37.6044 106.566 37.8639 106.464 38.083C106.369 38.295 106.249 38.4704 106.102 38.6092C105.956 38.7408 105.814 38.8431 105.674 38.9161C105.96 39.0549 106.212 39.2815 106.431 39.5956C106.657 39.9098 106.771 40.2898 106.771 40.7356C106.771 41.1375 106.68 41.5066 106.497 41.8426C106.315 42.1789 106.04 42.4491 105.674 42.6538C105.315 42.8511 104.87 42.9497 104.336 42.9497H101.252ZM102.076 42.0619H104.193C104.668 42.0619 105.034 41.934 105.29 41.6782C105.546 41.4225 105.674 41.1083 105.674 40.7356C105.674 40.3483 105.546 40.0305 105.29 39.7819C105.034 39.5263 104.668 39.3984 104.193 39.3984H102.076V42.0619ZM102.076 38.5215H104.094C104.562 38.5215 104.917 38.4155 105.158 38.2036C105.4 37.9844 105.521 37.6885 105.521 37.3158C105.521 36.9431 105.4 36.6582 105.158 36.4608C104.917 36.2636 104.562 36.1649 104.094 36.1649H102.076V38.5215Z" fill="#1F1633"/>
|
||||||
|
<path d="M110.221 42.8839C110.273 42.9279 110.335 42.9497 110.408 42.9497H110.979C111.06 42.9497 111.122 42.9279 111.165 42.8839C111.217 42.8328 111.242 42.7707 111.242 42.6977V40.1109L113.876 35.6387C113.89 35.6169 113.901 35.595 113.907 35.573C113.915 35.5511 113.919 35.5255 113.919 35.4962C113.919 35.4378 113.897 35.3867 113.853 35.3428C113.81 35.299 113.755 35.2771 113.689 35.2771H113.13C113.064 35.2771 113.004 35.2953 112.953 35.3318C112.909 35.3611 112.87 35.405 112.833 35.4634L110.693 39.0147L108.553 35.4634C108.532 35.405 108.495 35.3611 108.444 35.3318C108.392 35.2953 108.334 35.2771 108.269 35.2771H107.698C107.64 35.2771 107.588 35.299 107.545 35.3428C107.5 35.3867 107.479 35.4378 107.479 35.4962C107.479 35.5255 107.483 35.5511 107.489 35.573C107.493 35.5838 107.497 35.5946 107.5 35.6053C107.504 35.6165 107.508 35.6276 107.512 35.6387L110.155 40.1109V42.6977C110.155 42.7707 110.178 42.8328 110.221 42.8839Z" fill="#1F1633"/>
|
||||||
|
<path d="M51.0821 8.70567C53.3791 8.70567 55.2164 9.46902 56.7476 11.0467L56.8497 11.1484L58.8913 9.16369L58.8403 9.0619C57.0026 7.02624 54.1956 5.90663 50.9803 5.90663C44.6515 5.90663 40.0068 10.2833 40.0068 16.3393C40.0068 22.3954 44.6004 26.7721 50.9292 26.7721C54.1445 26.7721 56.9519 25.6525 58.8403 23.5659L58.8913 23.4641L56.8497 21.4794L56.7476 21.5812C55.2164 23.1588 53.328 23.973 51.0821 23.973C46.4888 23.973 43.1714 20.7669 43.1714 16.3393C43.1714 11.9118 46.5399 8.70567 51.0821 8.70567ZM67.6189 24.1257C64.6076 24.1257 62.5149 21.9374 62.5149 18.8839C62.5149 15.7795 64.6076 13.6421 67.6189 13.6421C70.6302 13.6421 72.7226 15.7795 72.7226 18.8839C72.7226 21.9883 70.6302 24.1257 67.6189 24.1257ZM67.6189 10.9958C62.8721 10.9958 59.4525 14.3037 59.4525 18.8839C59.4525 23.4641 62.8721 26.7721 67.6189 26.7721C72.3654 26.7721 75.785 23.4641 75.785 18.8839C75.785 14.3037 72.3144 10.9958 67.6189 10.9958ZM84.9722 24.1257C81.9609 24.1257 79.8171 21.9374 79.8171 18.833C79.8171 15.7287 81.9098 13.5403 84.9722 13.5403C87.9833 13.5403 90.0759 15.7287 90.0759 18.833C90.0759 21.9374 87.9833 24.1257 84.9722 24.1257ZM90.0759 13.1332C88.8001 11.6573 86.9625 10.894 84.7679 10.894C80.0722 10.894 76.8058 14.151 76.8058 18.833C76.8058 23.515 80.0722 26.7721 84.7679 26.7721C87.0135 26.7721 88.953 25.9069 90.1781 24.3802V26.5685H93.0873V4.88882H90.0249L90.0759 13.1332ZM102.887 13.5403C105.388 13.5403 107.276 15.2197 107.633 17.6625H98.1913C98.4974 15.2197 100.335 13.5403 102.887 13.5403ZM102.887 10.9958C98.3952 10.9958 95.1799 14.3037 95.1799 18.8839C95.1799 23.515 98.5485 26.7721 103.448 26.7721C106 26.7721 108.093 25.9069 109.522 24.2784L109.573 24.2275L107.889 22.2936L107.786 22.3954C106.715 23.5659 105.286 24.1766 103.499 24.1766C100.641 24.1766 98.5485 22.5481 98.1402 19.9526H110.39V19.8509C110.441 19.5964 110.441 19.2401 110.441 18.9857C110.492 14.2528 107.327 10.9958 102.887 10.9958ZM124.425 21.9883C123.506 23.3623 122.077 24.1257 120.291 24.1257C117.178 24.1257 115.085 21.9883 115.085 18.8839C115.085 15.7795 117.229 13.6421 120.291 13.6421C122.077 13.6421 123.506 14.3546 124.425 15.7795L124.476 15.8813L126.824 14.4055L126.773 14.3037C125.548 12.2172 123.2 10.9958 120.342 10.9958C115.544 10.9958 112.023 14.3037 112.023 18.8839C112.023 23.4641 115.544 26.7721 120.342 26.7721C123.2 26.7721 125.548 25.5507 126.773 23.4132L126.824 23.3114L124.476 21.8865L124.425 21.9883ZM136.011 24.1257C133 24.1257 130.907 21.9374 130.907 18.8839C130.907 15.7795 133 13.6421 136.011 13.6421C139.022 13.6421 141.115 15.7795 141.115 18.8839C141.115 21.9883 139.022 24.1257 136.011 24.1257ZM136.011 10.9958C131.264 10.9958 127.845 14.3037 127.845 18.8839C127.845 23.4641 131.264 26.7721 136.011 26.7721C140.758 26.7721 144.177 23.4641 144.177 18.8839C144.177 14.3037 140.707 10.9958 136.011 10.9958ZM157.039 10.9958L151.833 22.9043L146.729 10.9958H143.616L150.302 26.2123L150.353 26.2631H153.313L159.999 10.9958H157.039ZM16.2354 0.910034C7.28788 0.910034 0 8.1049 0 16.8826V16.9546L2.74197 18.5374H2.81413C4.5459 17.3863 6.63848 16.9546 8.73103 17.3143C10.1742 17.6021 11.5451 18.2496 12.6275 19.2569L13.1326 19.6886L13.4213 19.113C13.7099 18.5374 13.9985 18.0338 14.2871 17.5302C14.4314 17.3143 14.5758 17.1704 14.7201 16.9546L15.0087 16.5948L14.6479 16.307C13.1326 15.0839 11.3287 14.2205 9.38043 13.8608C7.50436 13.501 5.62825 13.573 3.8965 14.1486C5.19533 8.46464 10.2463 4.4355 16.2354 4.4355C19.6268 4.4355 22.8017 5.73059 25.1829 8.1049C26.9147 9.75972 28.0692 11.8462 28.5743 14.1486C27.4919 13.7888 26.3374 13.6449 25.1829 13.6449H24.9664C24.5335 13.6449 24.1005 13.7169 23.5954 13.7169H23.5233C23.379 13.7169 23.1625 13.7888 23.0182 13.7888C22.7295 13.8608 22.513 13.8608 22.2244 13.9327L22.008 14.0047C21.7915 14.0766 21.575 14.1486 21.3585 14.2205H21.2864C20.7813 14.3644 20.3483 14.5803 19.8433 14.7961C19.6268 14.8681 19.4103 15.012 19.1938 15.1559H19.1217C18.0393 15.8034 17.0291 16.5948 16.2354 17.6021L16.1632 17.746C15.9468 18.0338 15.8024 18.2496 15.6581 18.3935C15.5138 18.5374 15.4416 18.7533 15.2973 18.9691L15.2252 19.113C15.0809 19.3289 15.0087 19.5447 14.9366 19.6886V19.7606C14.7201 20.1922 14.5036 20.6959 14.3593 21.1995V21.2715C13.9985 22.4227 13.782 23.6458 13.782 24.9408V25.0848C13.782 25.2286 13.782 25.4445 13.782 25.5884C13.782 25.6603 13.782 25.7323 13.782 25.8042C13.782 25.8762 13.782 26.0201 13.782 26.092V26.164V26.3079C13.782 26.4518 13.8542 26.6676 13.8542 26.8115C14.215 28.5383 15.0087 30.193 16.2354 31.7041L16.3075 31.776L16.3797 31.7041C16.8848 31.1286 18.0393 29.3297 18.1836 28.2505C17.6064 27.1713 17.3177 25.9481 17.3177 24.797C17.3177 20.7678 20.4927 17.3863 24.6056 17.1704H24.8942C26.5539 17.0985 28.2135 17.6021 29.5845 18.5374H29.6566L32.3986 16.9546V16.8826C32.3986 12.6377 30.7391 8.60852 27.6362 5.58671C24.6056 2.56486 20.5648 0.910034 16.2354 0.910034Z" fill="#F01F7A"/>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
<defs>
|
||||||
|
<clipPath id="clip0_2_23">
|
||||||
|
<rect width="160" height="44" fill="white"/>
|
||||||
|
</clipPath>
|
||||||
|
<clipPath id="clip1_2_23">
|
||||||
|
<rect width="160" height="44" fill="white"/>
|
||||||
|
</clipPath>
|
||||||
|
</defs>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 8.8 KiB |
28
public/assets/images/codecov-white.svg
Normal file
|
|
@ -0,0 +1,28 @@
|
||||||
|
<svg width="160" height="44" viewBox="0 0 160 44" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<g clip-path="url(#clip0_943_358)">
|
||||||
|
<mask id="mask0_943_358" style="mask-type:luminance" maskUnits="userSpaceOnUse" x="0" y="0" width="160" height="44">
|
||||||
|
<path d="M160 0H0V44H160V0Z" fill="white"/>
|
||||||
|
</mask>
|
||||||
|
<g mask="url(#mask0_943_358)">
|
||||||
|
<mask id="mask1_943_358" style="mask-type:luminance" maskUnits="userSpaceOnUse" x="0" y="0" width="160" height="44">
|
||||||
|
<path d="M160 0H0V44H160V0Z" fill="white"/>
|
||||||
|
</mask>
|
||||||
|
<g mask="url(#mask1_943_358)">
|
||||||
|
<path d="M137.239 41.0209L132.798 35.2886H131.692V42.9727H132.814V37.0841L137.38 42.9727H138.361V35.2886H137.239V41.0209Z" fill="white"/>
|
||||||
|
<path d="M126.058 39.5953H130.037V38.5969H126.056V36.2842H130.546V35.2856H124.912V42.9727H130.603V41.9743H126.056L126.058 39.5953Z" fill="white"/>
|
||||||
|
<path d="M121.378 38.6211C119.829 38.2481 119.396 37.9534 119.396 37.2377C119.396 36.594 119.964 36.158 120.812 36.158C121.585 36.1805 122.33 36.4492 122.939 36.9248L123.541 36.0737C122.77 35.4694 121.813 35.1506 120.833 35.1714C119.312 35.1714 118.25 36.0737 118.25 37.3579C118.25 38.7413 119.152 39.2195 120.795 39.6195C122.257 39.9564 122.704 40.2691 122.704 40.9699C122.704 41.6706 122.103 42.1037 121.173 42.1037C120.248 42.0995 119.358 41.7499 118.678 41.1232L118.001 41.9323C118.873 42.6815 119.985 43.0924 121.135 43.0902C122.783 43.0902 123.842 42.203 123.842 40.8316C123.833 39.6707 123.147 39.0481 121.378 38.6211Z" fill="white"/>
|
||||||
|
<path d="M158.707 35.2886L156.394 38.8976L154.097 35.2886H152.755L155.789 39.9352V42.9758H156.945V39.8991L160 35.2886H158.707Z" fill="white"/>
|
||||||
|
<path d="M139.268 36.3293H141.786V42.9758H142.941V36.3293H145.458V35.2886H139.272L139.268 36.3293Z" fill="white"/>
|
||||||
|
<path fill-rule="evenodd" clip-rule="evenodd" d="M150.8 39.9742C151.962 39.6523 152.605 38.8403 152.605 37.6794C152.605 36.2028 151.524 35.2734 149.783 35.2734H146.366V42.9695H147.51V40.2087H149.45L151.399 42.9756H152.734L150.629 40.0223L150.8 39.9742ZM147.507 39.2223V36.305H149.663C150.788 36.305 151.431 36.8373 151.431 37.7606C151.431 38.6839 150.743 39.2223 149.675 39.2223H147.507Z" fill="white"/>
|
||||||
|
<path fill-rule="evenodd" clip-rule="evenodd" d="M101.252 42.9499C101.171 42.9499 101.109 42.9281 101.066 42.8841C101.022 42.833 101 42.7709 101 42.6979V35.5403C101 35.4599 101.022 35.3978 101.066 35.354C101.109 35.3029 101.171 35.2773 101.252 35.2773H104.259C104.808 35.2773 105.253 35.3685 105.597 35.5513C105.948 35.7266 106.204 35.9678 106.365 36.2747C106.533 36.5816 106.618 36.9251 106.618 37.3051C106.618 37.6046 106.566 37.8641 106.464 38.0832C106.369 38.2952 106.249 38.4706 106.102 38.6094C105.956 38.741 105.814 38.8433 105.674 38.9163C105.96 39.0551 106.212 39.2817 106.431 39.5958C106.657 39.91 106.771 40.29 106.771 40.7358C106.771 41.1377 106.68 41.5068 106.497 41.8428C106.315 42.1791 106.04 42.4493 105.674 42.654C105.315 42.8513 104.87 42.9499 104.336 42.9499H101.252ZM102.076 42.0621H104.193C104.668 42.0621 105.034 41.9342 105.29 41.6784C105.546 41.4227 105.674 41.1085 105.674 40.7358C105.674 40.3485 105.546 40.0307 105.29 39.7821C105.034 39.5265 104.668 39.3986 104.193 39.3986H102.076V42.0621ZM102.076 38.5217H104.094C104.562 38.5217 104.917 38.4157 105.158 38.2038C105.4 37.9846 105.521 37.6887 105.521 37.316C105.521 36.9433 105.4 36.6584 105.158 36.461C104.917 36.2638 104.562 36.1651 104.094 36.1651H102.076V38.5217Z" fill="white"/>
|
||||||
|
<path d="M110.221 42.8841C110.273 42.9281 110.335 42.9499 110.408 42.9499H110.979C111.06 42.9499 111.122 42.9281 111.165 42.8841C111.217 42.833 111.242 42.7709 111.242 42.6979V40.1111L113.876 35.6389C113.89 35.6171 113.901 35.5952 113.907 35.5732C113.915 35.5513 113.919 35.5257 113.919 35.4964C113.919 35.438 113.897 35.3869 113.853 35.343C113.81 35.2992 113.755 35.2773 113.689 35.2773H113.13C113.064 35.2773 113.004 35.2955 112.953 35.332C112.909 35.3613 112.87 35.4052 112.833 35.4636L110.693 39.0149L108.553 35.4636C108.532 35.4052 108.495 35.3613 108.444 35.332C108.392 35.2955 108.334 35.2773 108.269 35.2773H107.698C107.64 35.2773 107.588 35.2992 107.545 35.343C107.5 35.3869 107.479 35.438 107.479 35.4964C107.479 35.5257 107.483 35.5513 107.489 35.5732C107.493 35.584 107.497 35.5948 107.5 35.6055C107.504 35.6167 107.508 35.6278 107.512 35.6389L110.155 40.1111V42.6979C110.155 42.7709 110.178 42.833 110.221 42.8841Z" fill="white"/>
|
||||||
|
<path d="M51.0821 8.70579C53.3791 8.70579 55.2164 9.46914 56.7476 11.0468L56.8497 11.1485L58.8913 9.16381L58.8403 9.06202C57.0026 7.02636 54.1956 5.90675 50.9803 5.90675C44.6515 5.90675 40.0068 10.2834 40.0068 16.3394C40.0068 22.3955 44.6004 26.7722 50.9292 26.7722C54.1445 26.7722 56.9519 25.6526 58.8403 23.566L58.8913 23.4642L56.8497 21.4795L56.7476 21.5813C55.2164 23.1589 53.328 23.9731 51.0821 23.9731C46.4888 23.9731 43.1714 20.767 43.1714 16.3394C43.1714 11.9119 46.5399 8.70579 51.0821 8.70579ZM67.6189 24.1258C64.6076 24.1258 62.5149 21.9375 62.5149 18.884C62.5149 15.7796 64.6076 13.6422 67.6189 13.6422C70.6302 13.6422 72.7226 15.7796 72.7226 18.884C72.7226 21.9884 70.6302 24.1258 67.6189 24.1258ZM67.6189 10.9959C62.8721 10.9959 59.4525 14.3038 59.4525 18.884C59.4525 23.4642 62.8721 26.7722 67.6189 26.7722C72.3654 26.7722 75.785 23.4642 75.785 18.884C75.785 14.3038 72.3144 10.9959 67.6189 10.9959ZM84.9722 24.1258C81.9609 24.1258 79.8171 21.9375 79.8171 18.8331C79.8171 15.7288 81.9098 13.5404 84.9722 13.5404C87.9833 13.5404 90.0759 15.7288 90.0759 18.8331C90.0759 21.9375 87.9833 24.1258 84.9722 24.1258ZM90.0759 13.1333C88.8001 11.6574 86.9625 10.8941 84.7679 10.8941C80.0722 10.8941 76.8058 14.1511 76.8058 18.8331C76.8058 23.5151 80.0722 26.7722 84.7679 26.7722C87.0135 26.7722 88.953 25.907 90.1781 24.3803V26.5686H93.0873V4.88894H90.0249L90.0759 13.1333ZM102.887 13.5404C105.388 13.5404 107.276 15.2198 107.633 17.6626H98.1913C98.4974 15.2198 100.335 13.5404 102.887 13.5404ZM102.887 10.9959C98.3952 10.9959 95.1799 14.3038 95.1799 18.884C95.1799 23.5151 98.5485 26.7722 103.448 26.7722C106 26.7722 108.093 25.907 109.522 24.2785L109.573 24.2276L107.889 22.2937L107.786 22.3955C106.715 23.566 105.286 24.1767 103.499 24.1767C100.641 24.1767 98.5485 22.5482 98.1402 19.9527H110.39V19.851C110.441 19.5965 110.441 19.2402 110.441 18.9858C110.492 14.2529 107.327 10.9959 102.887 10.9959ZM124.425 21.9884C123.506 23.3624 122.077 24.1258 120.291 24.1258C117.178 24.1258 115.085 21.9884 115.085 18.884C115.085 15.7796 117.229 13.6422 120.291 13.6422C122.077 13.6422 123.506 14.3547 124.425 15.7796L124.476 15.8814L126.824 14.4056L126.773 14.3038C125.548 12.2173 123.2 10.9959 120.342 10.9959C115.544 10.9959 112.023 14.3038 112.023 18.884C112.023 23.4642 115.544 26.7722 120.342 26.7722C123.2 26.7722 125.548 25.5508 126.773 23.4133L126.824 23.3115L124.476 21.8866L124.425 21.9884ZM136.011 24.1258C133 24.1258 130.907 21.9375 130.907 18.884C130.907 15.7796 133 13.6422 136.011 13.6422C139.022 13.6422 141.115 15.7796 141.115 18.884C141.115 21.9884 139.022 24.1258 136.011 24.1258ZM136.011 10.9959C131.264 10.9959 127.845 14.3038 127.845 18.884C127.845 23.4642 131.264 26.7722 136.011 26.7722C140.758 26.7722 144.177 23.4642 144.177 18.884C144.177 14.3038 140.707 10.9959 136.011 10.9959ZM157.039 10.9959L151.833 22.9044L146.729 10.9959H143.616L150.302 26.2124L150.353 26.2632H153.313L159.999 10.9959H157.039ZM16.2354 0.910156C7.28788 0.910156 0 8.10502 0 16.8827V16.9547L2.74197 18.5375H2.81413C4.5459 17.3864 6.63848 16.9547 8.73103 17.3144C10.1742 17.6022 11.5451 18.2497 12.6275 19.257L13.1326 19.6887L13.4213 19.1131C13.7099 18.5375 13.9985 18.0339 14.2871 17.5303C14.4314 17.3144 14.5758 17.1705 14.7201 16.9547L15.0087 16.5949L14.6479 16.3071C13.1326 15.084 11.3287 14.2206 9.38043 13.8609C7.50436 13.5011 5.62825 13.5731 3.8965 14.1487C5.19533 8.46476 10.2463 4.43562 16.2354 4.43562C19.6268 4.43562 22.8017 5.73071 25.1829 8.10502C26.9147 9.75984 28.0692 11.8463 28.5743 14.1487C27.4919 13.7889 26.3374 13.645 25.1829 13.645H24.9664C24.5335 13.645 24.1005 13.717 23.5954 13.717H23.5233C23.379 13.717 23.1625 13.7889 23.0182 13.7889C22.7295 13.8609 22.513 13.8609 22.2244 13.9328L22.008 14.0048C21.7915 14.0767 21.575 14.1487 21.3585 14.2206H21.2864C20.7813 14.3645 20.3483 14.5804 19.8433 14.7962C19.6268 14.8682 19.4103 15.0121 19.1938 15.156H19.1217C18.0393 15.8035 17.0291 16.5949 16.2354 17.6022L16.1632 17.7461C15.9468 18.0339 15.8024 18.2497 15.6581 18.3936C15.5138 18.5375 15.4416 18.7534 15.2973 18.9692L15.2252 19.1131C15.0809 19.329 15.0087 19.5448 14.9366 19.6887V19.7607C14.7201 20.1923 14.5036 20.696 14.3593 21.1996V21.2716C13.9985 22.4228 13.782 23.6459 13.782 24.9409V25.0849C13.782 25.2287 13.782 25.4446 13.782 25.5885C13.782 25.6604 13.782 25.7324 13.782 25.8043C13.782 25.8763 13.782 26.0202 13.782 26.0921V26.1641V26.308C13.782 26.4519 13.8542 26.6677 13.8542 26.8116C14.215 28.5384 15.0087 30.1931 16.2354 31.7042L16.3075 31.7761L16.3797 31.7042C16.8848 31.1287 18.0393 29.3298 18.1836 28.2506C17.6064 27.1714 17.3177 25.9482 17.3177 24.7971C17.3177 20.7679 20.4927 17.3864 24.6056 17.1705H24.8942C26.5539 17.0986 28.2135 17.6022 29.5845 18.5375H29.6566L32.3986 16.9547V16.8827C32.3986 12.6378 30.7391 8.60864 27.6362 5.58683C24.6056 2.56498 20.5648 0.910156 16.2354 0.910156Z" fill="white"/>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
<defs>
|
||||||
|
<clipPath id="clip0_943_358">
|
||||||
|
<rect width="160" height="44" fill="white"/>
|
||||||
|
</clipPath>
|
||||||
|
</defs>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 9.1 KiB |
BIN
public/assets/images/crowdin-dark.png
Normal file
|
After Width: | Height: | Size: 9.9 KiB |
BIN
public/assets/images/crowdin-white.png
Normal file
|
After Width: | Height: | Size: 8 KiB |
|
After Width: | Height: | Size: 374 KiB |
BIN
public/assets/images/nextjs-boilerplate-saas-landing-page.png
Normal file
|
After Width: | Height: | Size: 370 KiB |
BIN
public/assets/images/nextjs-boilerplate-saas-multi-tenancy.png
Normal file
|
After Width: | Height: | Size: 118 KiB |
BIN
public/assets/images/nextjs-boilerplate-saas-sign-in.png
Normal file
|
After Width: | Height: | Size: 73 KiB |
BIN
public/assets/images/nextjs-boilerplate-saas-sign-up.png
Normal file
|
After Width: | Height: | Size: 74 KiB |
|
After Width: | Height: | Size: 100 KiB |
|
After Width: | Height: | Size: 84 KiB |
|
After Width: | Height: | Size: 91 KiB |
BIN
public/assets/images/nextjs-boilerplate-saas-user-dashboard.png
Normal file
|
After Width: | Height: | Size: 102 KiB |
BIN
public/assets/images/nextjs-boilerplate-saas-user-profile.png
Normal file
|
After Width: | Height: | Size: 106 KiB |
BIN
public/assets/images/nextjs-boilerplate-saas.png
Normal file
|
After Width: | Height: | Size: 14 KiB |
BIN
public/assets/images/nextjs-starter-banner.png
Normal file
|
After Width: | Height: | Size: 263 KiB |
BIN
public/assets/images/sentry-dark.png
Normal file
|
After Width: | Height: | Size: 12 KiB |
BIN
public/assets/images/sentry-white.png
Normal file
|
After Width: | Height: | Size: 9.1 KiB |
BIN
public/favicon-16x16.png
Normal file
|
After Width: | Height: | Size: 445 B |
BIN
public/favicon-32x32.png
Normal file
|
After Width: | Height: | Size: 1,001 B |
BIN
public/favicon.ico
Normal file
|
After Width: | Height: | Size: 15 KiB |
36
sentry.client.config.ts
Normal file
|
|
@ -0,0 +1,36 @@
|
||||||
|
// This file configures the initialization of Sentry on the client.
|
||||||
|
// The config you add here will be used whenever a users loads a page in their browser.
|
||||||
|
// https://docs.sentry.io/platforms/javascript/guides/nextjs/
|
||||||
|
|
||||||
|
import * as Sentry from '@sentry/nextjs';
|
||||||
|
import * as Spotlight from '@spotlightjs/spotlight';
|
||||||
|
|
||||||
|
Sentry.init({
|
||||||
|
// Sentry DSN
|
||||||
|
dsn: process.env.NEXT_PUBLIC_SENTRY_DSN,
|
||||||
|
|
||||||
|
// Adjust this value in production, or use tracesSampler for greater control
|
||||||
|
tracesSampleRate: 1,
|
||||||
|
|
||||||
|
// Setting this option to true will print useful information to the console while you're setting up Sentry.
|
||||||
|
debug: false,
|
||||||
|
|
||||||
|
replaysOnErrorSampleRate: 1.0,
|
||||||
|
|
||||||
|
// This sets the sample rate to be 10%. You may want this to be 100% while
|
||||||
|
// in development and sample at a lower rate in production
|
||||||
|
replaysSessionSampleRate: 0.1,
|
||||||
|
|
||||||
|
// You can remove this option if you're not planning to use the Sentry Session Replay feature:
|
||||||
|
integrations: [
|
||||||
|
Sentry.replayIntegration({
|
||||||
|
// Additional Replay configuration goes in here, for example:
|
||||||
|
maskAllText: true,
|
||||||
|
blockAllMedia: true,
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
if (process.env.NODE_ENV === 'development') {
|
||||||
|
Spotlight.init();
|
||||||
|
}
|
||||||
16
src/app/[locale]/(auth)/(center)/layout.tsx
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
import { auth } from '@clerk/nextjs/server';
|
||||||
|
import { redirect } from 'next/navigation';
|
||||||
|
|
||||||
|
export default function CenteredLayout(props: { children: React.ReactNode }) {
|
||||||
|
const { userId } = auth();
|
||||||
|
|
||||||
|
if (userId) {
|
||||||
|
redirect('/dashboard');
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-screen items-center justify-center">
|
||||||
|
{props.children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,22 @@
|
||||||
|
import { SignIn } from '@clerk/nextjs';
|
||||||
|
import { getTranslations } from 'next-intl/server';
|
||||||
|
|
||||||
|
import { getI18nPath } from '@/utils/Helpers';
|
||||||
|
|
||||||
|
export async function generateMetadata(props: { params: { locale: string } }) {
|
||||||
|
const t = await getTranslations({
|
||||||
|
locale: props.params.locale,
|
||||||
|
namespace: 'SignIn',
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
title: t('meta_title'),
|
||||||
|
description: t('meta_description'),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const SignInPage = (props: { params: { locale: string } }) => (
|
||||||
|
<SignIn path={getI18nPath('/sign-in', props.params.locale)} />
|
||||||
|
);
|
||||||
|
|
||||||
|
export default SignInPage;
|
||||||
|
|
@ -0,0 +1,22 @@
|
||||||
|
import { SignUp } from '@clerk/nextjs';
|
||||||
|
import { getTranslations } from 'next-intl/server';
|
||||||
|
|
||||||
|
import { getI18nPath } from '@/utils/Helpers';
|
||||||
|
|
||||||
|
export async function generateMetadata(props: { params: { locale: string } }) {
|
||||||
|
const t = await getTranslations({
|
||||||
|
locale: props.params.locale,
|
||||||
|
namespace: 'SignUp',
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
title: t('meta_title'),
|
||||||
|
description: t('meta_description'),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const SignUpPage = (props: { params: { locale: string } }) => (
|
||||||
|
<SignUp path={getI18nPath('/sign-up', props.params.locale)} />
|
||||||
|
);
|
||||||
|
|
||||||
|
export default SignUpPage;
|
||||||
37
src/app/[locale]/(auth)/dashboard/analytics/page.tsx
Normal file
|
|
@ -0,0 +1,37 @@
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import { Analytics } from '@/components/features/Analytics';
|
||||||
|
|
||||||
|
export default function AnalyticsPage() {
|
||||||
|
return (
|
||||||
|
<div className="flex-1 space-y-4 p-4 pt-6 md:p-8">
|
||||||
|
<div className="flex items-center justify-between space-y-2">
|
||||||
|
<h2 className="text-3xl font-bold tracking-tight">Analytics</h2>
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-7">
|
||||||
|
<div className="col-span-4">
|
||||||
|
<Analytics />
|
||||||
|
</div>
|
||||||
|
<div className="col-span-3">
|
||||||
|
<div className="rounded-lg border bg-card p-6">
|
||||||
|
<h3 className="text-xl font-semibold">Key Metrics</h3>
|
||||||
|
<div className="mt-4 space-y-4">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium text-muted-foreground">Total Users</p>
|
||||||
|
<p className="text-2xl font-bold">12,345</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium text-muted-foreground">Active Sessions</p>
|
||||||
|
<p className="text-2xl font-bold">1,234</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium text-muted-foreground">Conversion Rate</p>
|
||||||
|
<p className="text-2xl font-bold">2.4%</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
76
src/app/[locale]/(auth)/dashboard/calendar/page.tsx
Normal file
|
|
@ -0,0 +1,76 @@
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import { Calendar as CalendarIcon } from 'lucide-react';
|
||||||
|
import { useState } from 'react';
|
||||||
|
|
||||||
|
import { Calendar } from '@/components/ui/calendar';
|
||||||
|
|
||||||
|
const events = [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
title: 'Team Meeting',
|
||||||
|
date: '2024-01-10',
|
||||||
|
time: '10:00 AM',
|
||||||
|
type: 'meeting',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
title: 'Project Deadline',
|
||||||
|
date: '2024-01-15',
|
||||||
|
time: '5:00 PM',
|
||||||
|
type: 'deadline',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 3,
|
||||||
|
title: 'Client Presentation',
|
||||||
|
date: '2024-01-20',
|
||||||
|
time: '2:00 PM',
|
||||||
|
type: 'presentation',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function CalendarPage() {
|
||||||
|
const [date, setDate] = useState<Date | undefined>(new Date());
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex-1 space-y-4 p-4 pt-6 md:p-8">
|
||||||
|
<div className="flex items-center justify-between space-y-2">
|
||||||
|
<h2 className="text-3xl font-bold tracking-tight">Calendar</h2>
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
|
<div className="rounded-lg border bg-card p-6">
|
||||||
|
<Calendar
|
||||||
|
mode="single"
|
||||||
|
selected={date}
|
||||||
|
onSelect={setDate}
|
||||||
|
className="rounded-md border"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-lg border bg-card p-6">
|
||||||
|
<h3 className="mb-4 text-lg font-semibold">Upcoming Events</h3>
|
||||||
|
<div className="space-y-4">
|
||||||
|
{events.map(event => (
|
||||||
|
<div
|
||||||
|
key={event.id}
|
||||||
|
className="flex items-center space-x-4 rounded-lg border p-4"
|
||||||
|
>
|
||||||
|
<CalendarIcon className="size-5 text-muted-foreground" />
|
||||||
|
<div className="flex-1">
|
||||||
|
<p className="text-sm font-medium">{event.title}</p>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{event.date}
|
||||||
|
{' at '}
|
||||||
|
{event.time}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<span className="text-xs capitalize text-muted-foreground">
|
||||||
|
{event.type}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
105
src/app/[locale]/(auth)/dashboard/inbox/page.tsx
Normal file
|
|
@ -0,0 +1,105 @@
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import { Mail, Star, Trash2 } from 'lucide-react';
|
||||||
|
|
||||||
|
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
||||||
|
|
||||||
|
const emails = [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
sender: 'Alice Johnson',
|
||||||
|
avatar: '/avatars/05.png',
|
||||||
|
subject: 'Project Update: Q1 Goals',
|
||||||
|
preview: 'I wanted to share the latest updates on our Q1 goals and progress...',
|
||||||
|
time: '10:30 AM',
|
||||||
|
unread: true,
|
||||||
|
starred: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
sender: 'Robert Smith',
|
||||||
|
avatar: '/avatars/06.png',
|
||||||
|
subject: 'Design Review Meeting',
|
||||||
|
preview: 'Let\'s schedule a design review meeting for the new feature...',
|
||||||
|
time: 'Yesterday',
|
||||||
|
unread: false,
|
||||||
|
starred: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 3,
|
||||||
|
sender: 'Emma Davis',
|
||||||
|
avatar: '/avatars/07.png',
|
||||||
|
subject: 'Client Feedback',
|
||||||
|
preview: 'The client has provided feedback on the latest deliverables...',
|
||||||
|
time: 'Yesterday',
|
||||||
|
unread: true,
|
||||||
|
starred: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 4,
|
||||||
|
sender: 'James Wilson',
|
||||||
|
avatar: '/avatars/08.png',
|
||||||
|
subject: 'Team Building Event',
|
||||||
|
preview: 'I\'m excited to announce our upcoming team building event...',
|
||||||
|
time: 'Dec 20',
|
||||||
|
unread: false,
|
||||||
|
starred: false,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function InboxPage() {
|
||||||
|
return (
|
||||||
|
<div className="flex-1 space-y-4 p-4 pt-6 md:p-8">
|
||||||
|
<div className="flex items-center justify-between space-y-2">
|
||||||
|
<h2 className="text-3xl font-bold tracking-tight">Inbox</h2>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-lg border bg-card">
|
||||||
|
{emails.map(email => (
|
||||||
|
<div
|
||||||
|
key={email.id}
|
||||||
|
className={`flex cursor-pointer items-center space-x-4 border-b p-4 hover:bg-muted/50 ${
|
||||||
|
email.unread ? 'bg-muted/30' : ''
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Avatar>
|
||||||
|
<AvatarImage src={email.avatar} alt={email.sender} />
|
||||||
|
<AvatarFallback>
|
||||||
|
{email.sender
|
||||||
|
.split(' ')
|
||||||
|
.map(n => n[0])
|
||||||
|
.join('')}
|
||||||
|
</AvatarFallback>
|
||||||
|
</Avatar>
|
||||||
|
<div className="flex-1 space-y-1">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<p className={`text-sm ${email.unread ? 'font-semibold' : 'font-medium'}`}>
|
||||||
|
{email.sender}
|
||||||
|
</p>
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<p className="text-xs text-muted-foreground">{email.time}</p>
|
||||||
|
{email.starred && (
|
||||||
|
<Star className="size-4 fill-primary text-primary" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p className={`text-sm ${email.unread ? 'font-medium' : ''}`}>
|
||||||
|
{email.subject}
|
||||||
|
</p>
|
||||||
|
<p className="line-clamp-1 text-xs text-muted-foreground">
|
||||||
|
{email.preview}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<button className="rounded-full p-2 hover:bg-muted">
|
||||||
|
<Mail className="size-4 text-muted-foreground" />
|
||||||
|
</button>
|
||||||
|
<button className="rounded-full p-2 hover:bg-muted">
|
||||||
|
<Trash2 className="size-4 text-muted-foreground" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
28
src/app/[locale]/(auth)/dashboard/layout.tsx
Normal file
|
|
@ -0,0 +1,28 @@
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import dynamic from 'next/dynamic';
|
||||||
|
|
||||||
|
// Use dynamic import with ssr: false to avoid hydration issues
|
||||||
|
const Header = dynamic(() => import('@/components/layout/Header'), {
|
||||||
|
ssr: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const Sidebar = dynamic(() => import('@/components/layout/Sidebar'), {
|
||||||
|
ssr: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
export default function DashboardLayout(props: { children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen">
|
||||||
|
<Header />
|
||||||
|
<div className="flex h-screen overflow-hidden">
|
||||||
|
<Sidebar />
|
||||||
|
<main className="w-full pt-16">
|
||||||
|
<div className="px-4 py-8 md:px-16">
|
||||||
|
{props.children}
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
78
src/app/[locale]/(auth)/dashboard/messages/page.tsx
Normal file
|
|
@ -0,0 +1,78 @@
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
||||||
|
|
||||||
|
const messages = [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
sender: 'John Doe',
|
||||||
|
avatar: '/avatars/01.png',
|
||||||
|
message: 'Hey, can you review the latest design changes?',
|
||||||
|
time: '2 hours ago',
|
||||||
|
unread: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
sender: 'Sarah Wilson',
|
||||||
|
avatar: '/avatars/02.png',
|
||||||
|
message: 'The client meeting went well. They loved the proposal!',
|
||||||
|
time: '4 hours ago',
|
||||||
|
unread: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 3,
|
||||||
|
sender: 'Michael Brown',
|
||||||
|
avatar: '/avatars/03.png',
|
||||||
|
message: 'Don\'t forget about the team meeting tomorrow at 10 AM',
|
||||||
|
time: '6 hours ago',
|
||||||
|
unread: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 4,
|
||||||
|
sender: 'Emily Davis',
|
||||||
|
avatar: '/avatars/04.png',
|
||||||
|
message: 'I\'ve updated the project timeline. Please check when you can.',
|
||||||
|
time: '1 day ago',
|
||||||
|
unread: false,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function MessagesPage() {
|
||||||
|
return (
|
||||||
|
<div className="flex-1 space-y-4 p-4 pt-6 md:p-8">
|
||||||
|
<div className="flex items-center justify-between space-y-2">
|
||||||
|
<h2 className="text-3xl font-bold tracking-tight">Messages</h2>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-lg border bg-card">
|
||||||
|
{messages.map(message => (
|
||||||
|
<div
|
||||||
|
key={message.id}
|
||||||
|
className={`flex items-center space-x-4 border-b p-4 ${
|
||||||
|
message.unread ? 'bg-muted/50' : ''
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Avatar>
|
||||||
|
<AvatarImage src={message.avatar} alt={message.sender} />
|
||||||
|
<AvatarFallback>
|
||||||
|
{message.sender
|
||||||
|
.split(' ')
|
||||||
|
.map(n => n[0])
|
||||||
|
.join('')}
|
||||||
|
</AvatarFallback>
|
||||||
|
</Avatar>
|
||||||
|
<div className="flex-1 space-y-1">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<p className="text-sm font-medium">{message.sender}</p>
|
||||||
|
<p className="text-xs text-muted-foreground">{message.time}</p>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-muted-foreground">{message.message}</p>
|
||||||
|
</div>
|
||||||
|
{message.unread && (
|
||||||
|
<div className="size-2 rounded-full bg-primary"></div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
16
src/app/[locale]/(auth)/dashboard/metadata.ts
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
import type { Metadata } from 'next';
|
||||||
|
import { getTranslations } from 'next-intl/server';
|
||||||
|
|
||||||
|
export async function generateMetadata(props: {
|
||||||
|
params: { locale: string };
|
||||||
|
}): Promise<Metadata> {
|
||||||
|
const t = await getTranslations({
|
||||||
|
locale: props.params.locale,
|
||||||
|
namespace: 'Dashboard',
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
title: t('meta_title'),
|
||||||
|
description: t('meta_description'),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,35 @@
|
||||||
|
import { OrganizationProfile } from '@clerk/nextjs';
|
||||||
|
import { useTranslations } from 'next-intl';
|
||||||
|
|
||||||
|
import { TitleBar } from '@/features/dashboard/TitleBar';
|
||||||
|
import { getI18nPath } from '@/utils/Helpers';
|
||||||
|
|
||||||
|
const OrganizationProfilePage = (props: { params: { locale: string } }) => {
|
||||||
|
const t = useTranslations('OrganizationProfile');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<TitleBar
|
||||||
|
title={t('title_bar')}
|
||||||
|
description={t('title_bar_description')}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<OrganizationProfile
|
||||||
|
routing="path"
|
||||||
|
path={getI18nPath(
|
||||||
|
'/dashboard/organization-profile',
|
||||||
|
props.params.locale,
|
||||||
|
)}
|
||||||
|
afterLeaveOrganizationUrl="/onboarding/organization-selection"
|
||||||
|
appearance={{
|
||||||
|
elements: {
|
||||||
|
rootBox: 'w-full',
|
||||||
|
cardBox: 'w-full flex',
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default OrganizationProfilePage;
|
||||||
36
src/app/[locale]/(auth)/dashboard/page.tsx
Normal file
|
|
@ -0,0 +1,36 @@
|
||||||
|
import { Analytics } from '@/components/features/Analytics';
|
||||||
|
import { TeamMembers } from '@/components/features/TeamMembers';
|
||||||
|
|
||||||
|
export default function DashboardPage() {
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||||
|
<div className="rounded-lg border bg-card p-6">
|
||||||
|
<div className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
|
<h3 className="text-sm font-medium tracking-tight">Total Revenue</h3>
|
||||||
|
</div>
|
||||||
|
<div className="text-2xl font-bold">$15,231.89</div>
|
||||||
|
<p className="text-xs text-muted-foreground">+20.1% from last month</p>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-lg border bg-card p-6">
|
||||||
|
<div className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
|
<h3 className="text-sm font-medium tracking-tight">Active Users</h3>
|
||||||
|
</div>
|
||||||
|
<div className="text-2xl font-bold">+2,350</div>
|
||||||
|
<p className="text-xs text-muted-foreground">+180.1% from last month</p>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-lg border bg-card p-6">
|
||||||
|
<div className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
|
<h3 className="text-sm font-medium tracking-tight">Sales Today</h3>
|
||||||
|
</div>
|
||||||
|
<div className="text-2xl font-bold">+12,234</div>
|
||||||
|
<p className="text-xs text-muted-foreground">+19% from yesterday</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
|
<Analytics />
|
||||||
|
<TeamMembers />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,31 @@
|
||||||
|
import { UserProfile } from '@clerk/nextjs';
|
||||||
|
import { useTranslations } from 'next-intl';
|
||||||
|
|
||||||
|
import { TitleBar } from '@/features/dashboard/TitleBar';
|
||||||
|
import { getI18nPath } from '@/utils/Helpers';
|
||||||
|
|
||||||
|
const UserProfilePage = (props: { params: { locale: string } }) => {
|
||||||
|
const t = useTranslations('UserProfile');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<TitleBar
|
||||||
|
title={t('title_bar')}
|
||||||
|
description={t('title_bar_description')}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<UserProfile
|
||||||
|
routing="path"
|
||||||
|
path={getI18nPath('/dashboard/user-profile', props.params.locale)}
|
||||||
|
appearance={{
|
||||||
|
elements: {
|
||||||
|
rootBox: 'w-full',
|
||||||
|
cardBox: 'w-full flex',
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default UserProfilePage;
|
||||||
45
src/app/[locale]/(auth)/layout.tsx
Normal file
|
|
@ -0,0 +1,45 @@
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import { enUS, frFR } from '@clerk/localizations';
|
||||||
|
import { ClerkProvider } from '@clerk/nextjs';
|
||||||
|
|
||||||
|
import { AppConfig } from '@/utils/AppConfig';
|
||||||
|
|
||||||
|
export default function AuthLayout(props: {
|
||||||
|
children: React.ReactNode;
|
||||||
|
params: { locale: string };
|
||||||
|
}) {
|
||||||
|
let clerkLocale = enUS;
|
||||||
|
let signInUrl = '/sign-in';
|
||||||
|
let signUpUrl = '/sign-up';
|
||||||
|
let dashboardUrl = '/dashboard';
|
||||||
|
let afterSignOutUrl = '/';
|
||||||
|
let orgProfileUrl = '/dashboard/organization-profile';
|
||||||
|
let createOrgUrl = '/dashboard/organization-profile/create-organization';
|
||||||
|
|
||||||
|
if (props.params.locale === 'fr') {
|
||||||
|
clerkLocale = frFR;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (props.params.locale !== AppConfig.defaultLocale) {
|
||||||
|
signInUrl = `/${props.params.locale}${signInUrl}`;
|
||||||
|
signUpUrl = `/${props.params.locale}${signUpUrl}`;
|
||||||
|
dashboardUrl = `/${props.params.locale}${dashboardUrl}`;
|
||||||
|
afterSignOutUrl = `/${props.params.locale}${afterSignOutUrl}`;
|
||||||
|
orgProfileUrl = `/${props.params.locale}${orgProfileUrl}`;
|
||||||
|
createOrgUrl = `/${props.params.locale}${createOrgUrl}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ClerkProvider
|
||||||
|
localization={clerkLocale}
|
||||||
|
signInUrl={signInUrl}
|
||||||
|
signUpUrl={signUpUrl}
|
||||||
|
afterSignInUrl={dashboardUrl}
|
||||||
|
afterSignUpUrl={dashboardUrl}
|
||||||
|
afterSignOutUrl={afterSignOutUrl}
|
||||||
|
>
|
||||||
|
{props.children}
|
||||||
|
</ClerkProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,29 @@
|
||||||
|
import { OrganizationList } from '@clerk/nextjs';
|
||||||
|
import { getTranslations } from 'next-intl/server';
|
||||||
|
|
||||||
|
export async function generateMetadata(props: { params: { locale: string } }) {
|
||||||
|
const t = await getTranslations({
|
||||||
|
locale: props.params.locale,
|
||||||
|
namespace: 'Dashboard',
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
title: t('meta_title'),
|
||||||
|
description: t('meta_description'),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const OrganizationSelectionPage = () => (
|
||||||
|
<div className="flex min-h-screen items-center justify-center">
|
||||||
|
<OrganizationList
|
||||||
|
afterSelectOrganizationUrl="/dashboard"
|
||||||
|
afterCreateOrganizationUrl="/dashboard"
|
||||||
|
hidePersonal
|
||||||
|
skipInvitationScreen
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
export const dynamic = 'force-dynamic';
|
||||||
|
|
||||||
|
export default OrganizationSelectionPage;
|
||||||
41
src/app/[locale]/(unauth)/page.tsx
Normal file
|
|
@ -0,0 +1,41 @@
|
||||||
|
import { getTranslations, unstable_setRequestLocale } from 'next-intl/server';
|
||||||
|
|
||||||
|
import { CTA } from '@/templates/CTA';
|
||||||
|
import { FAQ } from '@/templates/FAQ';
|
||||||
|
import { Features } from '@/templates/Features';
|
||||||
|
import { Footer } from '@/templates/Footer';
|
||||||
|
import { Hero } from '@/templates/Hero';
|
||||||
|
import { Navbar } from '@/templates/Navbar';
|
||||||
|
import { Pricing } from '@/templates/Pricing';
|
||||||
|
import { Sponsors } from '@/templates/Sponsors';
|
||||||
|
|
||||||
|
export async function generateMetadata(props: { params: { locale: string } }) {
|
||||||
|
const t = await getTranslations({
|
||||||
|
locale: props.params.locale,
|
||||||
|
namespace: 'Index',
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
title: t('meta_title'),
|
||||||
|
description: t('meta_description'),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const IndexPage = (props: { params: { locale: string } }) => {
|
||||||
|
unstable_setRequestLocale(props.params.locale);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Navbar />
|
||||||
|
<Hero />
|
||||||
|
<Sponsors />
|
||||||
|
<Features />
|
||||||
|
<Pricing />
|
||||||
|
<FAQ />
|
||||||
|
<CTA />
|
||||||
|
<Footer />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default IndexPage;
|
||||||
57
src/app/[locale]/layout.tsx
Normal file
|
|
@ -0,0 +1,57 @@
|
||||||
|
import '@/styles/global.css';
|
||||||
|
|
||||||
|
import type { Metadata } from 'next';
|
||||||
|
import { NextIntlClientProvider, useMessages } from 'next-intl';
|
||||||
|
import { unstable_setRequestLocale } from 'next-intl/server';
|
||||||
|
|
||||||
|
import { AllLocales } from '@/utils/AppConfig';
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
icons: [
|
||||||
|
{
|
||||||
|
rel: 'apple-touch-icon',
|
||||||
|
url: '/apple-touch-icon.png',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
rel: 'icon',
|
||||||
|
type: 'image/png',
|
||||||
|
sizes: '32x32',
|
||||||
|
url: '/favicon-32x32.png',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
rel: 'icon',
|
||||||
|
type: 'image/png',
|
||||||
|
sizes: '16x16',
|
||||||
|
url: '/favicon-16x16.png',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
rel: 'icon',
|
||||||
|
url: '/favicon.ico',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
export function generateStaticParams() {
|
||||||
|
return AllLocales.map(locale => ({ locale }));
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function RootLayout(props: {
|
||||||
|
children: React.ReactNode;
|
||||||
|
params: { locale: string };
|
||||||
|
}) {
|
||||||
|
unstable_setRequestLocale(props.params.locale);
|
||||||
|
const messages = useMessages();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<html lang={props.params.locale} suppressHydrationWarning>
|
||||||
|
<body className="bg-background text-foreground antialiased" suppressHydrationWarning>
|
||||||
|
<NextIntlClientProvider
|
||||||
|
locale={props.params.locale}
|
||||||
|
messages={messages}
|
||||||
|
>
|
||||||
|
{props.children}
|
||||||
|
</NextIntlClientProvider>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
);
|
||||||
|
}
|
||||||
26
src/app/global-error.tsx
Normal file
|
|
@ -0,0 +1,26 @@
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import * as Sentry from '@sentry/nextjs';
|
||||||
|
import NextError from 'next/error';
|
||||||
|
import { useEffect } from 'react';
|
||||||
|
|
||||||
|
export default function GlobalError(props: {
|
||||||
|
error: Error & { digest?: string };
|
||||||
|
params: { locale: string };
|
||||||
|
}) {
|
||||||
|
useEffect(() => {
|
||||||
|
Sentry.captureException(props.error);
|
||||||
|
}, [props.error]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<html lang={props.params.locale}>
|
||||||
|
<body>
|
||||||
|
{/* `NextError` is the default Next.js error page component. Its type
|
||||||
|
definition requires a `statusCode` prop. However, since the App Router
|
||||||
|
does not expose status codes for errors, we simply pass 0 to render a
|
||||||
|
generic error message. */}
|
||||||
|
<NextError statusCode={0} />
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
);
|
||||||
|
}
|
||||||
13
src/app/robots.ts
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
import type { MetadataRoute } from 'next';
|
||||||
|
|
||||||
|
import { getBaseUrl } from '@/utils/Helpers';
|
||||||
|
|
||||||
|
export default function robots(): MetadataRoute.Robots {
|
||||||
|
return {
|
||||||
|
rules: {
|
||||||
|
userAgent: '*',
|
||||||
|
allow: '/',
|
||||||
|
},
|
||||||
|
sitemap: `${getBaseUrl()}/sitemap.xml`,
|
||||||
|
};
|
||||||
|
}
|
||||||
15
src/app/sitemap.ts
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
import type { MetadataRoute } from 'next';
|
||||||
|
|
||||||
|
import { getBaseUrl } from '@/utils/Helpers';
|
||||||
|
|
||||||
|
export default function sitemap(): MetadataRoute.Sitemap {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
url: `${getBaseUrl()}/`,
|
||||||
|
lastModified: new Date(),
|
||||||
|
changeFrequency: 'daily',
|
||||||
|
priority: 0.7,
|
||||||
|
},
|
||||||
|
// Add more URLs here
|
||||||
|
];
|
||||||
|
}
|
||||||
23
src/components/ActiveLink.tsx
Normal file
|
|
@ -0,0 +1,23 @@
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { usePathname } from 'next/navigation';
|
||||||
|
|
||||||
|
import { cn } from '@/utils/Helpers';
|
||||||
|
|
||||||
|
export const ActiveLink = (props: { href: string; children: React.ReactNode }) => {
|
||||||
|
const pathname = usePathname();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
href={props.href}
|
||||||
|
className={cn(
|
||||||
|
'px-3 py-2',
|
||||||
|
pathname.endsWith(props.href)
|
||||||
|
&& 'rounded-md bg-primary text-primary-foreground',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{props.children}
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
};
|
||||||
28
src/components/Background.stories.tsx
Normal file
|
|
@ -0,0 +1,28 @@
|
||||||
|
import type { Meta, StoryObj } from '@storybook/react';
|
||||||
|
|
||||||
|
import { Background } from './Background';
|
||||||
|
|
||||||
|
const meta = {
|
||||||
|
title: 'Components/Background',
|
||||||
|
component: Background,
|
||||||
|
parameters: {
|
||||||
|
layout: 'fullscreen',
|
||||||
|
},
|
||||||
|
tags: ['autodocs'],
|
||||||
|
} satisfies Meta<typeof Background>;
|
||||||
|
|
||||||
|
export default meta;
|
||||||
|
type Story = StoryObj<typeof meta>;
|
||||||
|
|
||||||
|
export const DefaultBackgroundWithChildren = {
|
||||||
|
args: {
|
||||||
|
children: <div>Children node</div>,
|
||||||
|
},
|
||||||
|
} satisfies Story;
|
||||||
|
|
||||||
|
export const RedBackgroundWithChildren = {
|
||||||
|
args: {
|
||||||
|
className: 'bg-red-500',
|
||||||
|
children: <div>Children node</div>,
|
||||||
|
},
|
||||||
|
} satisfies Story;
|
||||||
10
src/components/Background.tsx
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
import { cn } from '@/utils/Helpers';
|
||||||
|
|
||||||
|
export const Background = (props: {
|
||||||
|
children: React.ReactNode;
|
||||||
|
className?: string;
|
||||||
|
}) => (
|
||||||
|
<div className={cn('w-full bg-secondary', props.className)}>
|
||||||
|
{props.children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
22
src/components/ClerkProvider.tsx
Normal file
|
|
@ -0,0 +1,22 @@
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import { ClerkProvider as BaseClerkProvider } from '@clerk/nextjs';
|
||||||
|
import { useLocale } from 'next-intl';
|
||||||
|
import * as React from 'react';
|
||||||
|
|
||||||
|
export const ClerkProvider = ({ children }: { children: React.ReactNode }) => {
|
||||||
|
const locale = useLocale();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<BaseClerkProvider
|
||||||
|
localization={{ locale }}
|
||||||
|
signInUrl={`/${locale}/sign-in`}
|
||||||
|
signUpUrl={`/${locale}/sign-up`}
|
||||||
|
afterSignInUrl={`/${locale}/dashboard`}
|
||||||
|
afterSignUpUrl={`/${locale}/dashboard`}
|
||||||
|
afterSignOutUrl={`/${locale}`}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</BaseClerkProvider>
|
||||||
|
);
|
||||||
|
};
|
||||||
12
src/components/DemoBadge.tsx
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
export const DemoBadge = () => (
|
||||||
|
<div className="fixed bottom-0 right-20 z-10">
|
||||||
|
<a
|
||||||
|
href="https://react-saas.com"
|
||||||
|
>
|
||||||
|
<div className="rounded-md bg-gray-900 px-3 py-2 font-semibold text-gray-100">
|
||||||
|
<span className="text-gray-500">Demo of</span>
|
||||||
|
{' SaaS Boilerplate'}
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
55
src/components/LocaleSwitcher.tsx
Normal file
|
|
@ -0,0 +1,55 @@
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useLocale } from 'next-intl';
|
||||||
|
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuRadioGroup,
|
||||||
|
DropdownMenuRadioItem,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from '@/components/ui/dropdown-menu';
|
||||||
|
import { usePathname, useRouter } from '@/libs/i18nNavigation';
|
||||||
|
import { AppConfig } from '@/utils/AppConfig';
|
||||||
|
|
||||||
|
export const LocaleSwitcher = () => {
|
||||||
|
const router = useRouter();
|
||||||
|
const pathname = usePathname();
|
||||||
|
const locale = useLocale();
|
||||||
|
|
||||||
|
const handleChange = (value: string) => {
|
||||||
|
router.push(pathname, { locale: value });
|
||||||
|
router.refresh();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button className="p-2 focus-visible:ring-offset-0" variant="ghost" size="icon" aria-label="lang-switcher">
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
className="size-6 stroke-current stroke-2"
|
||||||
|
fill="none"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path stroke="none" d="M0 0h24v24H0z" />
|
||||||
|
<path d="M3 12a9 9 0 1 0 18 0 9 9 0 0 0-18 0M3.6 9h16.8M3.6 15h16.8" />
|
||||||
|
<path d="M11.5 3a17 17 0 0 0 0 18M12.5 3a17 17 0 0 1 0 18" />
|
||||||
|
</svg>
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent>
|
||||||
|
<DropdownMenuRadioGroup value={locale} onValueChange={handleChange}>
|
||||||
|
{AppConfig.locales.map(elt => (
|
||||||
|
<DropdownMenuRadioItem key={elt.id} value={elt.id}>
|
||||||
|
{elt.name}
|
||||||
|
</DropdownMenuRadioItem>
|
||||||
|
))}
|
||||||
|
</DropdownMenuRadioGroup>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
);
|
||||||
|
};
|
||||||
8
src/components/ThemeProvider.tsx
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import { ThemeProvider as NextThemesProvider } from 'next-themes';
|
||||||
|
import type { ThemeProviderProps } from 'next-themes/dist/types';
|
||||||
|
|
||||||
|
export function ThemeProvider({ children, ...props }: ThemeProviderProps) {
|
||||||
|
return <NextThemesProvider {...props}>{children}</NextThemesProvider>;
|
||||||
|
}
|
||||||
19
src/components/ToggleMenuButton.test.tsx
Normal file
|
|
@ -0,0 +1,19 @@
|
||||||
|
import { render, screen } from '@testing-library/react';
|
||||||
|
import userEvent from '@testing-library/user-event';
|
||||||
|
import { vi } from 'vitest';
|
||||||
|
|
||||||
|
import { ToggleMenuButton } from './ToggleMenuButton';
|
||||||
|
|
||||||
|
describe('ToggleMenuButton', () => {
|
||||||
|
describe('onClick props', () => {
|
||||||
|
it('should call the callback when the user click on the button', async () => {
|
||||||
|
const handler = vi.fn();
|
||||||
|
|
||||||
|
render(<ToggleMenuButton onClick={handler} />);
|
||||||
|
const button = screen.getByRole('button');
|
||||||
|
await userEvent.click(button);
|
||||||
|
|
||||||
|
expect(handler).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
40
src/components/ToggleMenuButton.tsx
Normal file
|
|
@ -0,0 +1,40 @@
|
||||||
|
import { type ForwardedRef, forwardRef } from 'react';
|
||||||
|
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A toggle button to show/hide component in small screen.
|
||||||
|
* @component
|
||||||
|
* @params props - Component props.
|
||||||
|
* @params props.onClick - Function to run when the button is clicked.
|
||||||
|
*/
|
||||||
|
const ToggleMenuButtonInternal = (
|
||||||
|
props: {
|
||||||
|
onClick?: () => void;
|
||||||
|
},
|
||||||
|
ref?: ForwardedRef<HTMLButtonElement>,
|
||||||
|
) => (
|
||||||
|
<Button
|
||||||
|
className="p-2 focus-visible:ring-offset-0"
|
||||||
|
variant="ghost"
|
||||||
|
ref={ref}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
className="size-6 stroke-current"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
strokeWidth="1.5"
|
||||||
|
fill="none"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
>
|
||||||
|
<path d="M0 0h24v24H0z" stroke="none" />
|
||||||
|
<path d="M4 6h16M4 12h16M4 18h16" />
|
||||||
|
</svg>
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
|
||||||
|
const ToggleMenuButton = forwardRef(ToggleMenuButtonInternal);
|
||||||
|
|
||||||
|
export { ToggleMenuButton };
|
||||||
107
src/components/features/Analytics.tsx
Normal file
|
|
@ -0,0 +1,107 @@
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import type { TooltipProps } from 'recharts';
|
||||||
|
import { Line, LineChart, ResponsiveContainer, Tooltip, XAxis } from 'recharts';
|
||||||
|
import type { NameType, ValueType } from 'recharts/types/component/DefaultTooltipContent';
|
||||||
|
|
||||||
|
type DataPoint = {
|
||||||
|
revenue: number;
|
||||||
|
date: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const data: DataPoint[] = [
|
||||||
|
{
|
||||||
|
revenue: 400,
|
||||||
|
date: 'Jan 1',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
revenue: 300,
|
||||||
|
date: 'Jan 2',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
revenue: 500,
|
||||||
|
date: 'Jan 3',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
revenue: 700,
|
||||||
|
date: 'Jan 4',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
revenue: 400,
|
||||||
|
date: 'Jan 5',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
revenue: 600,
|
||||||
|
date: 'Jan 6',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const CustomTooltip = ({
|
||||||
|
active,
|
||||||
|
payload,
|
||||||
|
}: TooltipProps<ValueType, NameType>) => {
|
||||||
|
if (!active || !payload?.length) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const value = payload[0]?.value;
|
||||||
|
if (typeof value !== 'number') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="rounded-lg border bg-background p-2 shadow-sm">
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<span className="text-[0.70rem] uppercase text-muted-foreground">
|
||||||
|
Revenue
|
||||||
|
</span>
|
||||||
|
<span className="font-bold text-muted-foreground">
|
||||||
|
$
|
||||||
|
{value}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export function Analytics() {
|
||||||
|
return (
|
||||||
|
<div className="rounded-lg border bg-card p-6">
|
||||||
|
<div className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
|
<h3 className="text-sm font-medium tracking-tight">Revenue over time</h3>
|
||||||
|
</div>
|
||||||
|
<div className="h-[200px]">
|
||||||
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
|
<LineChart data={data}>
|
||||||
|
<XAxis
|
||||||
|
dataKey="date"
|
||||||
|
stroke="#888888"
|
||||||
|
fontSize={12}
|
||||||
|
tickLine={false}
|
||||||
|
axisLine={false}
|
||||||
|
|
||||||
|
/>
|
||||||
|
<Tooltip content={CustomTooltip} />
|
||||||
|
<Line
|
||||||
|
type="monotone"
|
||||||
|
dataKey="revenue"
|
||||||
|
strokeWidth={2}
|
||||||
|
activeDot={{
|
||||||
|
r: 6,
|
||||||
|
style: { fill: 'var(--theme-primary)', opacity: 0.8 },
|
||||||
|
}}
|
||||||
|
style={
|
||||||
|
{
|
||||||
|
stroke: 'var(--theme-primary)',
|
||||||
|
opacity: 0.8,
|
||||||
|
} as React.CSSProperties
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</LineChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
66
src/components/features/TeamMembers.tsx
Normal file
|
|
@ -0,0 +1,66 @@
|
||||||
|
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
||||||
|
|
||||||
|
type TeamMember = {
|
||||||
|
name: string;
|
||||||
|
email: string;
|
||||||
|
avatar?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const teamMembers: TeamMember[] = [
|
||||||
|
{
|
||||||
|
name: 'Sofia Davis',
|
||||||
|
email: 'sofia.davis@example.com',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Jackson Lee',
|
||||||
|
email: 'jackson.lee@example.com',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Isabella Kim',
|
||||||
|
email: 'isabella.kim@example.com',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'William Chen',
|
||||||
|
email: 'william.chen@example.com',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Emma Wilson',
|
||||||
|
email: 'emma.wilson@example.com',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export function TeamMembers() {
|
||||||
|
return (
|
||||||
|
<div className="rounded-lg border bg-card p-6">
|
||||||
|
<div className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
|
<h3 className="text-sm font-medium tracking-tight">Team Members</h3>
|
||||||
|
</div>
|
||||||
|
<div className="divide-y">
|
||||||
|
{teamMembers.map(member => (
|
||||||
|
<div
|
||||||
|
key={member.email}
|
||||||
|
className="flex items-center justify-between py-4"
|
||||||
|
>
|
||||||
|
<div className="flex items-center space-x-4">
|
||||||
|
<Avatar>
|
||||||
|
{member.avatar && (
|
||||||
|
<AvatarImage src={member.avatar} alt={member.name} />
|
||||||
|
)}
|
||||||
|
<AvatarFallback>
|
||||||
|
{member.name
|
||||||
|
.split(' ')
|
||||||
|
.map(n => n[0])
|
||||||
|
.join('')}
|
||||||
|
</AvatarFallback>
|
||||||
|
</Avatar>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium leading-none">{member.name}</p>
|
||||||
|
<p className="text-sm text-muted-foreground">{member.email}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
56
src/components/layout/Header.tsx
Normal file
|
|
@ -0,0 +1,56 @@
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import { UserButton } from '@clerk/nextjs';
|
||||||
|
import { Bell, Mail, Moon, Search, Sun } from 'lucide-react';
|
||||||
|
import { useTheme } from 'next-themes';
|
||||||
|
|
||||||
|
export default function Header() {
|
||||||
|
const { theme, setTheme } = useTheme();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<header className="fixed top-0 z-50 w-full border-b border-border/40 bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60">
|
||||||
|
<div className="flex h-16 items-center px-4 md:px-6">
|
||||||
|
<div className="flex flex-1 items-center gap-4">
|
||||||
|
<div className="relative w-full max-w-sm">
|
||||||
|
<Search className="absolute left-2.5 top-2.5 size-4 text-muted-foreground" />
|
||||||
|
<input
|
||||||
|
type="search"
|
||||||
|
placeholder="Search..."
|
||||||
|
className="flex h-9 w-full rounded-md border border-input bg-background px-8 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
|
/>
|
||||||
|
<kbd className="pointer-events-none absolute right-2.5 top-2.5 hidden h-5 select-none items-center gap-1 rounded border bg-muted px-1.5 font-mono text-[10px] font-medium opacity-100 sm:flex">
|
||||||
|
<span className="text-xs">⌘</span>
|
||||||
|
K
|
||||||
|
</kbd>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<button className="inline-flex size-10 items-center justify-center rounded-md text-sm font-medium ring-offset-background transition-colors hover:bg-accent hover:text-accent-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50">
|
||||||
|
<Bell className="size-4" />
|
||||||
|
<span className="sr-only">Notifications</span>
|
||||||
|
</button>
|
||||||
|
<button className="inline-flex size-10 items-center justify-center rounded-md text-sm font-medium ring-offset-background transition-colors hover:bg-accent hover:text-accent-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50">
|
||||||
|
<Mail className="size-4" />
|
||||||
|
<span className="sr-only">Messages</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setTheme(theme === 'dark' ? 'light' : 'dark')}
|
||||||
|
className="inline-flex size-10 items-center justify-center rounded-md text-sm font-medium ring-offset-background transition-colors hover:bg-accent hover:text-accent-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50"
|
||||||
|
>
|
||||||
|
<Sun className="size-4 rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0" />
|
||||||
|
<Moon className="absolute size-4 rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100" />
|
||||||
|
<span className="sr-only">Toggle theme</span>
|
||||||
|
</button>
|
||||||
|
<UserButton
|
||||||
|
appearance={{
|
||||||
|
elements: {
|
||||||
|
rootBox: 'h-10 w-10',
|
||||||
|
userButtonAvatarBox: 'w-10 h-10',
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
);
|
||||||
|
}
|
||||||
75
src/components/layout/Sidebar.tsx
Normal file
|
|
@ -0,0 +1,75 @@
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import { OrganizationSwitcher } from '@clerk/nextjs';
|
||||||
|
import {
|
||||||
|
BarChart3,
|
||||||
|
Calendar,
|
||||||
|
CreditCard,
|
||||||
|
Home,
|
||||||
|
Inbox,
|
||||||
|
LayoutDashboard,
|
||||||
|
MessageSquare,
|
||||||
|
Settings,
|
||||||
|
Users,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { usePathname } from 'next/navigation';
|
||||||
|
import { useLocale } from 'next-intl';
|
||||||
|
|
||||||
|
import { getI18nPath } from '@/utils/Helpers';
|
||||||
|
|
||||||
|
const navigation = [
|
||||||
|
{ name: 'Dashboard', href: '/dashboard', icon: Home },
|
||||||
|
{ name: 'Analytics', href: '/dashboard/analytics', icon: BarChart3 },
|
||||||
|
{ name: 'Team', href: '/dashboard/organization-profile/organization-members', icon: Users },
|
||||||
|
{ name: 'Messages', href: '/dashboard/messages', icon: MessageSquare },
|
||||||
|
{ name: 'Calendar', href: '/dashboard/calendar', icon: Calendar },
|
||||||
|
{ name: 'Inbox', href: '/dashboard/inbox', icon: Inbox },
|
||||||
|
{ name: 'Payments', href: '/dashboard/payments', icon: CreditCard },
|
||||||
|
{ name: 'Settings', href: '/dashboard/organization-profile', icon: Settings },
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function Sidebar() {
|
||||||
|
const pathname = usePathname();
|
||||||
|
const locale = useLocale();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-full w-[250px] flex-col border-r bg-muted/10">
|
||||||
|
<div className="flex h-16 items-center gap-2 border-b px-6">
|
||||||
|
<LayoutDashboard className="size-6" />
|
||||||
|
<OrganizationSwitcher
|
||||||
|
organizationProfileMode="navigation"
|
||||||
|
organizationProfileUrl={getI18nPath('/dashboard/organization-profile', locale)}
|
||||||
|
afterCreateOrganizationUrl="/dashboard"
|
||||||
|
hidePersonal
|
||||||
|
appearance={{
|
||||||
|
elements: {
|
||||||
|
organizationSwitcherTrigger: 'font-semibold',
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 overflow-auto py-2">
|
||||||
|
<nav className="grid items-start px-4 text-sm font-medium">
|
||||||
|
{navigation.map((item) => {
|
||||||
|
const Icon = item.icon;
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
key={item.href}
|
||||||
|
href={item.href}
|
||||||
|
className={`flex items-center gap-3 rounded-lg px-3 py-2 text-muted-foreground transition-all hover:text-foreground ${
|
||||||
|
pathname === item.href
|
||||||
|
? 'bg-muted/50 text-foreground'
|
||||||
|
: 'hover:bg-muted/30'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Icon className="size-4" />
|
||||||
|
{item.name}
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
58
src/components/ui/accordion.tsx
Normal file
|
|
@ -0,0 +1,58 @@
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import * as AccordionPrimitive from '@radix-ui/react-accordion';
|
||||||
|
import { ChevronRight } from 'lucide-react';
|
||||||
|
import * as React from 'react';
|
||||||
|
|
||||||
|
import { cn } from '@/utils/Helpers';
|
||||||
|
|
||||||
|
const Accordion = AccordionPrimitive.Root;
|
||||||
|
|
||||||
|
const AccordionItem = React.forwardRef<
|
||||||
|
React.ElementRef<typeof AccordionPrimitive.Item>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Item>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<AccordionPrimitive.Item
|
||||||
|
ref={ref}
|
||||||
|
className={cn('border-b', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
AccordionItem.displayName = 'AccordionItem';
|
||||||
|
|
||||||
|
const AccordionTrigger = React.forwardRef<
|
||||||
|
React.ElementRef<typeof AccordionPrimitive.Trigger>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Trigger>
|
||||||
|
>(({ className, children, ...props }, ref) => (
|
||||||
|
<AccordionPrimitive.Header className="flex">
|
||||||
|
<AccordionPrimitive.Trigger
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
'flex flex-1 items-center justify-between py-5 text-left text-lg font-medium transition-all hover:no-underline [&[data-state=open]>svg]:rotate-90',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
<ChevronRight className="size-4 shrink-0 transition-transform duration-200" />
|
||||||
|
</AccordionPrimitive.Trigger>
|
||||||
|
</AccordionPrimitive.Header>
|
||||||
|
));
|
||||||
|
AccordionTrigger.displayName = AccordionPrimitive.Trigger.displayName;
|
||||||
|
|
||||||
|
const AccordionContent = React.forwardRef<
|
||||||
|
React.ElementRef<typeof AccordionPrimitive.Content>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Content>
|
||||||
|
>(({ className, children, ...props }, ref) => (
|
||||||
|
<AccordionPrimitive.Content
|
||||||
|
ref={ref}
|
||||||
|
className="overflow-hidden text-base text-muted-foreground transition-all data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down"
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<div className={cn('pb-5 pt-0', className)}>{children}</div>
|
||||||
|
</AccordionPrimitive.Content>
|
||||||
|
));
|
||||||
|
|
||||||
|
AccordionContent.displayName = AccordionPrimitive.Content.displayName;
|
||||||
|
|
||||||
|
export { Accordion, AccordionContent, AccordionItem, AccordionTrigger };
|
||||||
48
src/components/ui/avatar.tsx
Normal file
|
|
@ -0,0 +1,48 @@
|
||||||
|
import * as AvatarPrimitive from '@radix-ui/react-avatar';
|
||||||
|
import * as React from 'react';
|
||||||
|
|
||||||
|
import { cn } from '@/utils/cn';
|
||||||
|
|
||||||
|
const Avatar = React.forwardRef<
|
||||||
|
React.ElementRef<typeof AvatarPrimitive.Root>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Root>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<AvatarPrimitive.Root
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
'relative flex h-10 w-10 shrink-0 overflow-hidden rounded-full',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
Avatar.displayName = AvatarPrimitive.Root.displayName;
|
||||||
|
|
||||||
|
const AvatarImage = React.forwardRef<
|
||||||
|
React.ElementRef<typeof AvatarPrimitive.Image>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Image>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<AvatarPrimitive.Image
|
||||||
|
ref={ref}
|
||||||
|
className={cn('aspect-square h-full w-full', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
AvatarImage.displayName = AvatarPrimitive.Image.displayName;
|
||||||
|
|
||||||
|
const AvatarFallback = React.forwardRef<
|
||||||
|
React.ElementRef<typeof AvatarPrimitive.Fallback>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Fallback>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<AvatarPrimitive.Fallback
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
'flex h-full w-full items-center justify-center rounded-full bg-muted',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName;
|
||||||
|
|
||||||
|
export { Avatar, AvatarFallback, AvatarImage };
|
||||||
16
src/components/ui/badge.tsx
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
import type { VariantProps } from 'class-variance-authority';
|
||||||
|
import * as React from 'react';
|
||||||
|
|
||||||
|
import { cn } from '@/utils/Helpers';
|
||||||
|
|
||||||
|
import { badgeVariants } from './badgeVariants';
|
||||||
|
|
||||||
|
export type BadgeProps = {} & React.HTMLAttributes<HTMLDivElement> & VariantProps<typeof badgeVariants>;
|
||||||
|
|
||||||
|
function Badge({ className, variant, ...props }: BadgeProps) {
|
||||||
|
return (
|
||||||
|
<div className={cn(badgeVariants({ variant }), className)} {...props} />
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Badge };
|
||||||
21
src/components/ui/badgeVariants.ts
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
import { cva } from 'class-variance-authority';
|
||||||
|
|
||||||
|
export const badgeVariants = cva(
|
||||||
|
'inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2',
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default:
|
||||||
|
'border-transparent bg-primary text-primary-foreground hover:bg-primary/80',
|
||||||
|
secondary:
|
||||||
|
'border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80',
|
||||||
|
destructive:
|
||||||
|
'border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80',
|
||||||
|
outline: 'text-foreground',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: 'default',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
27
src/components/ui/button.tsx
Normal file
|
|
@ -0,0 +1,27 @@
|
||||||
|
import { Slot } from '@radix-ui/react-slot';
|
||||||
|
import type { VariantProps } from 'class-variance-authority';
|
||||||
|
import * as React from 'react';
|
||||||
|
|
||||||
|
import { cn } from '@/utils/Helpers';
|
||||||
|
|
||||||
|
import { buttonVariants } from './buttonVariants';
|
||||||
|
|
||||||
|
export type ButtonProps = {
|
||||||
|
asChild?: boolean;
|
||||||
|
} & React.ButtonHTMLAttributes<HTMLButtonElement> & VariantProps<typeof buttonVariants>;
|
||||||
|
|
||||||
|
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||||
|
({ className, variant, size, asChild = false, ...props }, ref) => {
|
||||||
|
const Comp = asChild ? Slot : 'button';
|
||||||
|
return (
|
||||||
|
<Comp
|
||||||
|
className={cn(buttonVariants({ variant, size, className }))}
|
||||||
|
ref={ref}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
Button.displayName = 'Button';
|
||||||
|
|
||||||
|
export { Button };
|
||||||
30
src/components/ui/buttonVariants.ts
Normal file
|
|
@ -0,0 +1,30 @@
|
||||||
|
import { cva } from 'class-variance-authority';
|
||||||
|
|
||||||
|
export const buttonVariants = cva(
|
||||||
|
'inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50',
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default: 'bg-primary text-primary-foreground hover:bg-primary/90',
|
||||||
|
destructive:
|
||||||
|
'bg-destructive text-destructive-foreground hover:bg-destructive/90',
|
||||||
|
outline:
|
||||||
|
'border border-input bg-background hover:bg-accent hover:text-accent-foreground',
|
||||||
|
secondary:
|
||||||
|
'bg-secondary text-secondary-foreground hover:bg-secondary/80',
|
||||||
|
ghost: 'hover:bg-accent hover:text-accent-foreground',
|
||||||
|
link: 'text-primary underline-offset-4 hover:underline',
|
||||||
|
},
|
||||||
|
size: {
|
||||||
|
default: 'h-10 px-4 py-2',
|
||||||
|
sm: 'h-9 rounded-md px-3',
|
||||||
|
lg: 'h-11 rounded-md px-8',
|
||||||
|
icon: 'size-10',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: 'default',
|
||||||
|
size: 'default',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
65
src/components/ui/calendar.tsx
Normal file
|
|
@ -0,0 +1,65 @@
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import { ChevronLeft, ChevronRight } from 'lucide-react';
|
||||||
|
import * as React from 'react';
|
||||||
|
import { DayPicker } from 'react-day-picker';
|
||||||
|
|
||||||
|
import { buttonVariants } from '@/components/ui/buttonVariants';
|
||||||
|
import { cn } from '@/utils/cn';
|
||||||
|
|
||||||
|
export type CalendarProps = React.ComponentProps<typeof DayPicker>;
|
||||||
|
|
||||||
|
function Calendar({
|
||||||
|
className,
|
||||||
|
classNames,
|
||||||
|
showOutsideDays = true,
|
||||||
|
...props
|
||||||
|
}: CalendarProps) {
|
||||||
|
return (
|
||||||
|
<DayPicker
|
||||||
|
showOutsideDays={showOutsideDays}
|
||||||
|
className={cn('p-3', className)}
|
||||||
|
classNames={{
|
||||||
|
months: 'flex flex-col sm:flex-row space-y-4 sm:space-x-4 sm:space-y-0',
|
||||||
|
month: 'space-y-4',
|
||||||
|
caption: 'flex justify-center pt-1 relative items-center',
|
||||||
|
caption_label: 'text-sm font-medium',
|
||||||
|
nav: 'space-x-1 flex items-center',
|
||||||
|
nav_button: cn(
|
||||||
|
buttonVariants({ variant: 'outline' }),
|
||||||
|
'size-7 bg-transparent p-0 opacity-50 hover:opacity-100'
|
||||||
|
),
|
||||||
|
nav_button_previous: 'absolute left-1',
|
||||||
|
nav_button_next: 'absolute right-1',
|
||||||
|
table: 'w-full border-collapse space-y-1',
|
||||||
|
head_row: 'flex',
|
||||||
|
head_cell:
|
||||||
|
'text-muted-foreground rounded-md w-9 font-normal text-[0.8rem]',
|
||||||
|
row: 'flex w-full mt-2',
|
||||||
|
cell: 'size-9 text-center text-sm p-0 relative [&:has([aria-selected].day-range-end)]:rounded-r-md [&:has([aria-selected].day-outside)]:bg-accent/50 [&:has([aria-selected])]:bg-accent first:[&:has([aria-selected])]:rounded-l-md last:[&:has([aria-selected])]:rounded-r-md focus-within:relative focus-within:z-20',
|
||||||
|
day: cn(
|
||||||
|
buttonVariants({ variant: 'ghost' }),
|
||||||
|
'size-9 p-0 font-normal aria-selected:opacity-100'
|
||||||
|
),
|
||||||
|
day_range_end: 'day-range-end',
|
||||||
|
day_selected:
|
||||||
|
'bg-primary text-primary-foreground hover:bg-primary hover:text-primary-foreground focus:bg-primary focus:text-primary-foreground',
|
||||||
|
day_today: 'bg-accent text-accent-foreground',
|
||||||
|
day_outside:
|
||||||
|
'day-outside text-muted-foreground opacity-50 aria-selected:bg-accent/50 aria-selected:text-muted-foreground aria-selected:opacity-30',
|
||||||
|
day_disabled: 'text-muted-foreground opacity-50',
|
||||||
|
day_range_middle:
|
||||||
|
'aria-selected:bg-accent aria-selected:text-accent-foreground',
|
||||||
|
day_hidden: 'invisible',
|
||||||
|
}}
|
||||||
|
components={{
|
||||||
|
IconLeft: () => <ChevronLeft className="size-4" />,
|
||||||
|
IconRight: () => <ChevronRight className="size-4" />,
|
||||||
|
}}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Calendar.displayName = 'Calendar';
|
||||||
|
|
||||||
|
export { Calendar };
|
||||||
84
src/components/ui/data-table.tsx
Normal file
|
|
@ -0,0 +1,84 @@
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import type { ColumnDef } from '@tanstack/react-table';
|
||||||
|
import {
|
||||||
|
flexRender,
|
||||||
|
getCoreRowModel,
|
||||||
|
useReactTable,
|
||||||
|
} from '@tanstack/react-table';
|
||||||
|
import { useTranslations } from 'next-intl';
|
||||||
|
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from '@/components/ui/table';
|
||||||
|
|
||||||
|
type DataTableProps<TData, TValue> = {
|
||||||
|
columns: ColumnDef<TData, TValue>[];
|
||||||
|
data: TData[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export function DataTable<TData, TValue>({
|
||||||
|
columns,
|
||||||
|
data,
|
||||||
|
}: DataTableProps<TData, TValue>) {
|
||||||
|
const table = useReactTable({
|
||||||
|
data,
|
||||||
|
columns,
|
||||||
|
getCoreRowModel: getCoreRowModel(),
|
||||||
|
});
|
||||||
|
const t = useTranslations('DataTable');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="rounded-md border bg-card">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
{table.getHeaderGroups().map(headerGroup => (
|
||||||
|
<TableRow key={headerGroup.id}>
|
||||||
|
{headerGroup.headers.map((header) => {
|
||||||
|
return (
|
||||||
|
<TableHead key={header.id}>
|
||||||
|
{header.isPlaceholder
|
||||||
|
? null
|
||||||
|
: flexRender(
|
||||||
|
header.column.columnDef.header,
|
||||||
|
header.getContext(),
|
||||||
|
)}
|
||||||
|
</TableHead>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{table.getRowModel().rows?.length
|
||||||
|
? (
|
||||||
|
table.getRowModel().rows.map(row => (
|
||||||
|
<TableRow
|
||||||
|
key={row.id}
|
||||||
|
data-state={row.getIsSelected() && 'selected'}
|
||||||
|
>
|
||||||
|
{row.getVisibleCells().map(cell => (
|
||||||
|
<TableCell key={cell.id} className="whitespace-nowrap">
|
||||||
|
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||||
|
</TableCell>
|
||||||
|
))}
|
||||||
|
</TableRow>
|
||||||
|
))
|
||||||
|
)
|
||||||
|
: (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={columns.length} className="h-24 text-center">
|
||||||
|
{t('no_results')}
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
)}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
200
src/components/ui/dropdown-menu.tsx
Normal file
|
|
@ -0,0 +1,200 @@
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import * as DropdownMenuPrimitive from '@radix-ui/react-dropdown-menu';
|
||||||
|
import { Check, ChevronRight, Circle } from 'lucide-react';
|
||||||
|
import * as React from 'react';
|
||||||
|
|
||||||
|
import { cn } from '@/utils/Helpers';
|
||||||
|
|
||||||
|
const DropdownMenu = DropdownMenuPrimitive.Root;
|
||||||
|
|
||||||
|
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger;
|
||||||
|
|
||||||
|
const DropdownMenuGroup = DropdownMenuPrimitive.Group;
|
||||||
|
|
||||||
|
const DropdownMenuPortal = DropdownMenuPrimitive.Portal;
|
||||||
|
|
||||||
|
const DropdownMenuSub = DropdownMenuPrimitive.Sub;
|
||||||
|
|
||||||
|
const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup;
|
||||||
|
|
||||||
|
const DropdownMenuSubTrigger = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & {
|
||||||
|
inset?: boolean;
|
||||||
|
}
|
||||||
|
>(({ className, inset, children, ...props }, ref) => (
|
||||||
|
<DropdownMenuPrimitive.SubTrigger
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
'flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent data-[state=open]:bg-accent',
|
||||||
|
inset && 'pl-8',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
<ChevronRight className="ml-auto size-4" />
|
||||||
|
</DropdownMenuPrimitive.SubTrigger>
|
||||||
|
));
|
||||||
|
DropdownMenuSubTrigger.displayName
|
||||||
|
= DropdownMenuPrimitive.SubTrigger.displayName;
|
||||||
|
|
||||||
|
const DropdownMenuSubContent = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DropdownMenuPrimitive.SubContent>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubContent>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<DropdownMenuPrimitive.SubContent
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
'z-50 min-w-32 overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
DropdownMenuSubContent.displayName
|
||||||
|
= DropdownMenuPrimitive.SubContent.displayName;
|
||||||
|
|
||||||
|
const DropdownMenuContent = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DropdownMenuPrimitive.Content>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>
|
||||||
|
>(({ className, sideOffset = 4, ...props }, ref) => (
|
||||||
|
<DropdownMenuPrimitive.Portal>
|
||||||
|
<DropdownMenuPrimitive.Content
|
||||||
|
ref={ref}
|
||||||
|
sideOffset={sideOffset}
|
||||||
|
className={cn(
|
||||||
|
'z-50 min-w-32 overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</DropdownMenuPrimitive.Portal>
|
||||||
|
));
|
||||||
|
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName;
|
||||||
|
|
||||||
|
const DropdownMenuItem = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DropdownMenuPrimitive.Item>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & {
|
||||||
|
inset?: boolean;
|
||||||
|
}
|
||||||
|
>(({ className, inset, ...props }, ref) => (
|
||||||
|
<DropdownMenuPrimitive.Item
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
'relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
|
||||||
|
inset && 'pl-8',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName;
|
||||||
|
|
||||||
|
const DropdownMenuCheckboxItem = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DropdownMenuPrimitive.CheckboxItem>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.CheckboxItem>
|
||||||
|
>(({ className, children, checked, ...props }, ref) => (
|
||||||
|
<DropdownMenuPrimitive.CheckboxItem
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
'relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
checked={checked}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<span className="absolute left-2 flex size-3.5 items-center justify-center">
|
||||||
|
<DropdownMenuPrimitive.ItemIndicator>
|
||||||
|
<Check className="size-4" />
|
||||||
|
</DropdownMenuPrimitive.ItemIndicator>
|
||||||
|
</span>
|
||||||
|
{children}
|
||||||
|
</DropdownMenuPrimitive.CheckboxItem>
|
||||||
|
));
|
||||||
|
DropdownMenuCheckboxItem.displayName
|
||||||
|
= DropdownMenuPrimitive.CheckboxItem.displayName;
|
||||||
|
|
||||||
|
const DropdownMenuRadioItem = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DropdownMenuPrimitive.RadioItem>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.RadioItem>
|
||||||
|
>(({ className, children, ...props }, ref) => (
|
||||||
|
<DropdownMenuPrimitive.RadioItem
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
'relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<span className="absolute left-2 flex size-3.5 items-center justify-center">
|
||||||
|
<DropdownMenuPrimitive.ItemIndicator>
|
||||||
|
<Circle className="size-2 fill-current" />
|
||||||
|
</DropdownMenuPrimitive.ItemIndicator>
|
||||||
|
</span>
|
||||||
|
{children}
|
||||||
|
</DropdownMenuPrimitive.RadioItem>
|
||||||
|
));
|
||||||
|
DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName;
|
||||||
|
|
||||||
|
const DropdownMenuLabel = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DropdownMenuPrimitive.Label>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label> & {
|
||||||
|
inset?: boolean;
|
||||||
|
}
|
||||||
|
>(({ className, inset, ...props }, ref) => (
|
||||||
|
<DropdownMenuPrimitive.Label
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
'px-2 py-1.5 text-sm font-semibold',
|
||||||
|
inset && 'pl-8',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName;
|
||||||
|
|
||||||
|
const DropdownMenuSeparator = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DropdownMenuPrimitive.Separator>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<DropdownMenuPrimitive.Separator
|
||||||
|
ref={ref}
|
||||||
|
className={cn('-mx-1 my-1 h-px bg-muted', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName;
|
||||||
|
|
||||||
|
const DropdownMenuShortcut = ({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.HTMLAttributes<HTMLSpanElement>) => {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={cn('ml-auto text-xs tracking-widest opacity-60', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
DropdownMenuShortcut.displayName = 'DropdownMenuShortcut';
|
||||||
|
|
||||||
|
export {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuCheckboxItem,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuGroup,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuLabel,
|
||||||
|
DropdownMenuPortal,
|
||||||
|
DropdownMenuRadioGroup,
|
||||||
|
DropdownMenuRadioItem,
|
||||||
|
DropdownMenuSeparator,
|
||||||
|
DropdownMenuShortcut,
|
||||||
|
DropdownMenuSub,
|
||||||
|
DropdownMenuSubContent,
|
||||||
|
DropdownMenuSubTrigger,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
};
|
||||||
135
src/components/ui/form.tsx
Normal file
|
|
@ -0,0 +1,135 @@
|
||||||
|
import type * as LabelPrimitive from '@radix-ui/react-label';
|
||||||
|
import { Slot } from '@radix-ui/react-slot';
|
||||||
|
import * as React from 'react';
|
||||||
|
import type { ControllerProps, FieldPath, FieldValues } from 'react-hook-form';
|
||||||
|
import { Controller, FormProvider } from 'react-hook-form';
|
||||||
|
|
||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
import { cn } from '@/utils/Helpers';
|
||||||
|
|
||||||
|
import { FormFieldContext, FormItemContext, useFormField } from './useFormField';
|
||||||
|
|
||||||
|
const Form = FormProvider;
|
||||||
|
|
||||||
|
const FormField = <
|
||||||
|
TFieldValues extends FieldValues = FieldValues,
|
||||||
|
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
|
||||||
|
>({
|
||||||
|
...props
|
||||||
|
}: ControllerProps<TFieldValues, TName>) => {
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
const formFieldName = React.useMemo(() => ({ name: props.name }), []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<FormFieldContext.Provider value={formFieldName}>
|
||||||
|
<Controller {...props} />
|
||||||
|
</FormFieldContext.Provider>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const FormItem = React.forwardRef<
|
||||||
|
HTMLDivElement,
|
||||||
|
React.HTMLAttributes<HTMLDivElement>
|
||||||
|
>(({ className, ...props }, ref) => {
|
||||||
|
const id = React.useId();
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
const formItemId = React.useMemo(() => ({ id }), []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<FormItemContext.Provider value={formItemId}>
|
||||||
|
<div ref={ref} className={cn('space-y-2', className)} {...props} />
|
||||||
|
</FormItemContext.Provider>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
FormItem.displayName = 'FormItem';
|
||||||
|
|
||||||
|
const FormLabel = React.forwardRef<
|
||||||
|
React.ElementRef<typeof LabelPrimitive.Root>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root>
|
||||||
|
>(({ className, ...props }, ref) => {
|
||||||
|
const { error, formItemId } = useFormField();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Label
|
||||||
|
ref={ref}
|
||||||
|
className={cn(error && 'text-destructive', className)}
|
||||||
|
htmlFor={formItemId}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
FormLabel.displayName = 'FormLabel';
|
||||||
|
|
||||||
|
const FormControl = React.forwardRef<
|
||||||
|
React.ElementRef<typeof Slot>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof Slot>
|
||||||
|
>(({ ...props }, ref) => {
|
||||||
|
const { error, formItemId, formDescriptionId, formMessageId }
|
||||||
|
= useFormField();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Slot
|
||||||
|
ref={ref}
|
||||||
|
id={formItemId}
|
||||||
|
aria-describedby={
|
||||||
|
!error
|
||||||
|
? `${formDescriptionId}`
|
||||||
|
: `${formDescriptionId} ${formMessageId}`
|
||||||
|
}
|
||||||
|
aria-invalid={!!error}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
FormControl.displayName = 'FormControl';
|
||||||
|
|
||||||
|
const FormDescription = React.forwardRef<
|
||||||
|
HTMLParagraphElement,
|
||||||
|
React.HTMLAttributes<HTMLParagraphElement>
|
||||||
|
>(({ className, ...props }, ref) => {
|
||||||
|
const { formDescriptionId } = useFormField();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<p
|
||||||
|
ref={ref}
|
||||||
|
id={formDescriptionId}
|
||||||
|
className={cn('text-sm text-muted-foreground', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
FormDescription.displayName = 'FormDescription';
|
||||||
|
|
||||||
|
const FormMessage = React.forwardRef<
|
||||||
|
HTMLParagraphElement,
|
||||||
|
React.HTMLAttributes<HTMLParagraphElement>
|
||||||
|
>(({ className, children, ...props }, ref) => {
|
||||||
|
const { error, formMessageId } = useFormField();
|
||||||
|
const body = error ? String(error?.message) : children;
|
||||||
|
|
||||||
|
if (!body) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<p
|
||||||
|
ref={ref}
|
||||||
|
id={formMessageId}
|
||||||
|
className={cn('text-sm font-medium text-destructive', className)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{body}
|
||||||
|
</p>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
FormMessage.displayName = 'FormMessage';
|
||||||
|
|
||||||
|
export {
|
||||||
|
Form,
|
||||||
|
FormControl,
|
||||||
|
FormDescription,
|
||||||
|
FormField,
|
||||||
|
FormItem,
|
||||||
|
FormLabel,
|
||||||
|
FormMessage,
|
||||||
|
};
|
||||||
24
src/components/ui/input.tsx
Normal file
|
|
@ -0,0 +1,24 @@
|
||||||
|
import * as React from 'react';
|
||||||
|
|
||||||
|
import { cn } from '@/utils/Helpers';
|
||||||
|
|
||||||
|
export type InputProps = {} & React.InputHTMLAttributes<HTMLInputElement>;
|
||||||
|
|
||||||
|
const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
||||||
|
({ className, type, ...props }, ref) => {
|
||||||
|
return (
|
||||||
|
<input
|
||||||
|
type={type}
|
||||||
|
className={cn(
|
||||||
|
'flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
ref={ref}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
Input.displayName = 'Input';
|
||||||
|
|
||||||
|
export { Input };
|
||||||
26
src/components/ui/label.tsx
Normal file
|
|
@ -0,0 +1,26 @@
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import * as LabelPrimitive from '@radix-ui/react-label';
|
||||||
|
import { cva, type VariantProps } from 'class-variance-authority';
|
||||||
|
import * as React from 'react';
|
||||||
|
|
||||||
|
import { cn } from '@/utils/Helpers';
|
||||||
|
|
||||||
|
const labelVariants = cva(
|
||||||
|
'text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70',
|
||||||
|
);
|
||||||
|
|
||||||
|
const Label = React.forwardRef<
|
||||||
|
React.ElementRef<typeof LabelPrimitive.Root>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> &
|
||||||
|
VariantProps<typeof labelVariants>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<LabelPrimitive.Root
|
||||||
|
ref={ref}
|
||||||
|
className={cn(labelVariants(), className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
Label.displayName = LabelPrimitive.Root.displayName;
|
||||||
|
|
||||||
|
export { Label };
|
||||||
31
src/components/ui/separator.tsx
Normal file
|
|
@ -0,0 +1,31 @@
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import * as SeparatorPrimitive from '@radix-ui/react-separator';
|
||||||
|
import * as React from 'react';
|
||||||
|
|
||||||
|
import { cn } from '@/utils/Helpers';
|
||||||
|
|
||||||
|
const Separator = React.forwardRef<
|
||||||
|
React.ElementRef<typeof SeparatorPrimitive.Root>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof SeparatorPrimitive.Root>
|
||||||
|
>(
|
||||||
|
(
|
||||||
|
{ className, orientation = 'horizontal', decorative = true, ...props },
|
||||||
|
ref,
|
||||||
|
) => (
|
||||||
|
<SeparatorPrimitive.Root
|
||||||
|
ref={ref}
|
||||||
|
decorative={decorative}
|
||||||
|
orientation={orientation}
|
||||||
|
className={cn(
|
||||||
|
'shrink-0 bg-border',
|
||||||
|
orientation === 'horizontal' ? 'h-[1px] w-full' : 'h-full w-[1px]',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
);
|
||||||
|
Separator.displayName = SeparatorPrimitive.Root.displayName;
|
||||||
|
|
||||||
|
export { Separator };
|
||||||
117
src/components/ui/table.tsx
Normal file
|
|
@ -0,0 +1,117 @@
|
||||||
|
import * as React from 'react';
|
||||||
|
|
||||||
|
import { cn } from '@/utils/Helpers';
|
||||||
|
|
||||||
|
const Table = React.forwardRef<
|
||||||
|
HTMLTableElement,
|
||||||
|
React.HTMLAttributes<HTMLTableElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<div className="relative w-full overflow-auto">
|
||||||
|
<table
|
||||||
|
ref={ref}
|
||||||
|
className={cn('w-full caption-bottom text-sm', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
));
|
||||||
|
Table.displayName = 'Table';
|
||||||
|
|
||||||
|
const TableHeader = React.forwardRef<
|
||||||
|
HTMLTableSectionElement,
|
||||||
|
React.HTMLAttributes<HTMLTableSectionElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<thead ref={ref} className={cn('[&_tr]:border-b', className)} {...props} />
|
||||||
|
));
|
||||||
|
TableHeader.displayName = 'TableHeader';
|
||||||
|
|
||||||
|
const TableBody = React.forwardRef<
|
||||||
|
HTMLTableSectionElement,
|
||||||
|
React.HTMLAttributes<HTMLTableSectionElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<tbody
|
||||||
|
ref={ref}
|
||||||
|
className={cn('[&_tr:last-child]:border-0', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
TableBody.displayName = 'TableBody';
|
||||||
|
|
||||||
|
const TableFooter = React.forwardRef<
|
||||||
|
HTMLTableSectionElement,
|
||||||
|
React.HTMLAttributes<HTMLTableSectionElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<tfoot
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
'border-t bg-muted/50 font-medium [&>tr]:last:border-b-0',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
TableFooter.displayName = 'TableFooter';
|
||||||
|
|
||||||
|
const TableRow = React.forwardRef<
|
||||||
|
HTMLTableRowElement,
|
||||||
|
React.HTMLAttributes<HTMLTableRowElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<tr
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
'border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
TableRow.displayName = 'TableRow';
|
||||||
|
|
||||||
|
const TableHead = React.forwardRef<
|
||||||
|
HTMLTableCellElement,
|
||||||
|
React.ThHTMLAttributes<HTMLTableCellElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<th
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
'h-12 px-4 text-left align-middle font-medium text-muted-foreground [&:has([role=checkbox])]:pr-0',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
TableHead.displayName = 'TableHead';
|
||||||
|
|
||||||
|
const TableCell = React.forwardRef<
|
||||||
|
HTMLTableCellElement,
|
||||||
|
React.TdHTMLAttributes<HTMLTableCellElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<td
|
||||||
|
ref={ref}
|
||||||
|
className={cn('p-4 align-middle [&:has([role=checkbox])]:pr-0', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
TableCell.displayName = 'TableCell';
|
||||||
|
|
||||||
|
const TableCaption = React.forwardRef<
|
||||||
|
HTMLTableCaptionElement,
|
||||||
|
React.HTMLAttributes<HTMLTableCaptionElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<caption
|
||||||
|
ref={ref}
|
||||||
|
className={cn('mt-4 text-sm text-muted-foreground', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
TableCaption.displayName = 'TableCaption';
|
||||||
|
|
||||||
|
export {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCaption,
|
||||||
|
TableCell,
|
||||||
|
TableFooter,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
};
|
||||||
30
src/components/ui/tooltip.tsx
Normal file
|
|
@ -0,0 +1,30 @@
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import * as TooltipPrimitive from '@radix-ui/react-tooltip';
|
||||||
|
import * as React from 'react';
|
||||||
|
|
||||||
|
import { cn } from '@/utils/Helpers';
|
||||||
|
|
||||||
|
const TooltipProvider = TooltipPrimitive.Provider;
|
||||||
|
|
||||||
|
const Tooltip = TooltipPrimitive.Root;
|
||||||
|
|
||||||
|
const TooltipTrigger = TooltipPrimitive.Trigger;
|
||||||
|
|
||||||
|
const TooltipContent = React.forwardRef<
|
||||||
|
React.ElementRef<typeof TooltipPrimitive.Content>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content>
|
||||||
|
>(({ className, sideOffset = 4, ...props }, ref) => (
|
||||||
|
<TooltipPrimitive.Content
|
||||||
|
ref={ref}
|
||||||
|
sideOffset={sideOffset}
|
||||||
|
className={cn(
|
||||||
|
'z-50 overflow-hidden rounded-md border bg-popover px-3 py-1.5 text-sm text-popover-foreground shadow-md animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
TooltipContent.displayName = TooltipPrimitive.Content.displayName;
|
||||||
|
|
||||||
|
export { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger };
|
||||||