├── .devcontainer
├── Dockerfile
├── compose.dev.yml
└── devcontainer.json
├── .env.example
├── .eslintrc.cjs
├── .github
├── ISSUE_TEMPLATE
│ ├── bug_report.md
│ └── feature_request.md
├── pull_request_template.md
└── workflows
│ └── check.yaml
├── .gitignore
├── LICENSE
├── README.md
├── components.json
├── drizzle.config.ts
├── next.config.js
├── package.json
├── playwright.config.ts
├── pnpm-lock.yaml
├── postcss.config.cjs
├── prettier.config.js
├── src
├── app
│ ├── (auth)
│ │ ├── layout.tsx
│ │ ├── login
│ │ │ ├── discord
│ │ │ │ ├── callback
│ │ │ │ │ └── route.ts
│ │ │ │ └── route.ts
│ │ │ ├── login.tsx
│ │ │ └── page.tsx
│ │ ├── reset-password
│ │ │ ├── [token]
│ │ │ │ ├── page.tsx
│ │ │ │ └── reset-password.tsx
│ │ │ ├── page.tsx
│ │ │ └── send-reset-email.tsx
│ │ ├── signup
│ │ │ ├── page.tsx
│ │ │ └── signup.tsx
│ │ └── verify-email
│ │ │ ├── page.tsx
│ │ │ └── verify-code.tsx
│ ├── (landing)
│ │ ├── _components
│ │ │ ├── copy-to-clipboard.tsx
│ │ │ ├── feature-icons.tsx
│ │ │ ├── footer.tsx
│ │ │ ├── header.tsx
│ │ │ └── hover-card.tsx
│ │ ├── layout.tsx
│ │ └── page.tsx
│ ├── (main)
│ │ ├── _components
│ │ │ ├── footer.tsx
│ │ │ ├── header.tsx
│ │ │ └── user-dropdown.tsx
│ │ ├── account
│ │ │ └── page.tsx
│ │ ├── dashboard
│ │ │ ├── _components
│ │ │ │ ├── dashboard-nav.tsx
│ │ │ │ ├── new-post.tsx
│ │ │ │ ├── post-card-skeleton.tsx
│ │ │ │ ├── post-card.tsx
│ │ │ │ ├── posts-skeleton.tsx
│ │ │ │ ├── posts.tsx
│ │ │ │ └── verificiation-warning.tsx
│ │ │ ├── billing
│ │ │ │ ├── _components
│ │ │ │ │ ├── billing-skeleton.tsx
│ │ │ │ │ ├── billing.tsx
│ │ │ │ │ └── manage-subscription-form.tsx
│ │ │ │ └── page.tsx
│ │ │ ├── layout.tsx
│ │ │ ├── page.tsx
│ │ │ └── settings
│ │ │ │ └── page.tsx
│ │ ├── editor
│ │ │ └── [postId]
│ │ │ │ ├── _components
│ │ │ │ ├── post-editor.tsx
│ │ │ │ └── post-preview.tsx
│ │ │ │ └── page.tsx
│ │ └── layout.tsx
│ ├── api
│ │ ├── trpc
│ │ │ └── [trpc]
│ │ │ │ └── route.ts
│ │ └── webhooks
│ │ │ └── stripe
│ │ │ └── route.ts
│ ├── icon.tsx
│ ├── layout.tsx
│ ├── robots.ts
│ └── sitemap.ts
├── components
│ ├── icons.tsx
│ ├── loading-button.tsx
│ ├── password-input.tsx
│ ├── responsive-dialog.tsx
│ ├── submit-button.tsx
│ ├── theme-provider.tsx
│ ├── theme-toggle.tsx
│ └── ui
│ │ ├── alert-dialog.tsx
│ │ ├── alert.tsx
│ │ ├── badge.tsx
│ │ ├── button.tsx
│ │ ├── card.tsx
│ │ ├── dialog.tsx
│ │ ├── drawer.tsx
│ │ ├── dropdown-menu.tsx
│ │ ├── form.tsx
│ │ ├── input.tsx
│ │ ├── label.tsx
│ │ ├── pagination.tsx
│ │ ├── skeleton.tsx
│ │ ├── sonner.tsx
│ │ ├── tabs.tsx
│ │ └── textarea.tsx
├── config
│ └── subscriptions.ts
├── env.js
├── lib
│ ├── auth
│ │ ├── actions.ts
│ │ ├── index.ts
│ │ └── validate-request.ts
│ ├── constants.ts
│ ├── email
│ │ ├── index.tsx
│ │ └── templates
│ │ │ ├── email-verification.tsx
│ │ │ └── reset-password.tsx
│ ├── fonts.ts
│ ├── hooks
│ │ ├── use-debounce.ts
│ │ └── use-media-query.ts
│ ├── logger.ts
│ ├── stripe.ts
│ ├── utils.ts
│ └── validators
│ │ └── auth.ts
├── middleware.ts
├── server
│ ├── api
│ │ ├── root.ts
│ │ ├── routers
│ │ │ ├── post
│ │ │ │ ├── post.input.ts
│ │ │ │ ├── post.procedure.ts
│ │ │ │ └── post.service.ts
│ │ │ ├── stripe
│ │ │ │ ├── stripe.input.ts
│ │ │ │ ├── stripe.procedure.ts
│ │ │ │ └── stripe.service.ts
│ │ │ └── user
│ │ │ │ └── user.procedure.ts
│ │ └── trpc.ts
│ └── db
│ │ ├── index.ts
│ │ └── schema.ts
├── styles
│ └── globals.css
└── trpc
│ ├── react.tsx
│ ├── server.ts
│ └── shared.ts
├── tailwind.config.ts
├── tests
└── e2e
│ ├── auth-with-credential.spec.ts
│ └── utils.ts
└── tsconfig.json
/.devcontainer/Dockerfile:
--------------------------------------------------------------------------------
1 | FROM mcr.microsoft.com/devcontainers/javascript-node:20
2 |
--------------------------------------------------------------------------------
/.devcontainer/compose.dev.yml:
--------------------------------------------------------------------------------
1 | services:
2 | workspace:
3 | build:
4 | dockerfile: Dockerfile
5 | volumes:
6 | - ../:/workspace:cached
7 | command: /bin/sh -c "while sleep 1000; do :; done"
8 | depends_on:
9 | - database
10 |
11 | database:
12 | image: postgres:17.2-alpine
13 | environment:
14 | POSTGRES_DB: acme
15 | POSTGRES_USER: postgres
16 | POSTGRES_PASSWORD: root
17 |
--------------------------------------------------------------------------------
/.devcontainer/devcontainer.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "Next.js Auth Template",
3 | "dockerComposeFile": ["compose.dev.yml"],
4 | "service": "workspace",
5 | "workspaceFolder": "/workspace",
6 | "postCreateCommand": "pnpm config set store-dir $HOME/.pnpm-store",
7 | "postStartCommand": "pnpm install",
8 | "forwardPorts": [3000],
9 | "features": {
10 | "ghcr.io/devcontainers-extra/features/pnpm": "latest"
11 | },
12 | "customizations": {
13 | "vscode": {
14 | "settings": {
15 | "editor.codeActionsOnSave": {
16 | "source.fixAll.eslint": "explicit",
17 | "source.organizeImports": "explicit",
18 | "source.removeUnusedImports": "explicit"
19 | },
20 | "editor.guides.bracketPairs": "active",
21 | "editor.rulers": [100],
22 | "typescript.tsdk": "node_modules/typescript/lib"
23 | },
24 | "extensions": [
25 | "dsznajder.es7-react-js-snippets",
26 | "eamodio.gitlens",
27 | "esbenp.prettier-vscode",
28 | "YoavBls.pretty-ts-errors",
29 | "bradlc.vscode-tailwindcss"
30 | ]
31 | }
32 | }
33 | }
34 |
--------------------------------------------------------------------------------
/.env.example:
--------------------------------------------------------------------------------
1 | # This file will be committed to version control, so make sure not to have any
2 | # secrets in it. If you are cloning this repo, create a copy of this file named
3 | # ".env" and populate it with your secrets.
4 |
5 | # When adding additional environment variables, the schema in "/src/env.js"
6 | # should be updated accordingly.
7 |
8 | DATABASE_URL='postgresql://postgres:root@database:5432/acme'
9 | NEXT_PUBLIC_APP_URL='http://localhost:3000'
10 | MOCK_SEND_EMAIL=true
11 |
12 | SMTP_HOST='smtp.example-host.com'
13 | SMTP_PORT=25
14 | SMTP_USER='smtp_example_username'
15 | SMTP_PASSWORD='smtp_example_password'
16 |
17 | DISCORD_CLIENT_ID='discord_client_id'
18 | DISCORD_CLIENT_SECRET='discord_client_secret'
19 |
20 | # Stripe
21 | # Stripe Secret Key found at https://dashboard.stripe.com/test/apikeys
22 | STRIPE_API_KEY='sk_test_'
23 | # Stripe Webhook Secret found at https://dashboard.stripe.com/test/webhooks/create?endpoint_location=local
24 | # This need to replaced with the webhook secret for your webhook endpoint in production
25 | STRIPE_WEBHOOK_SECRET='whsec_'
26 | # Stripe Product and Price IDs for your created products
27 | # found at https://dashboard.stripe.com/test/products
28 | STRIPE_PRO_MONTHLY_PLAN_ID='price_'
29 |
--------------------------------------------------------------------------------
/.eslintrc.cjs:
--------------------------------------------------------------------------------
1 | /** @type {import("eslint").Linter.Config} */
2 | const config = {
3 | parser: "@typescript-eslint/parser",
4 | parserOptions: {
5 | project: true,
6 | },
7 | plugins: ["@typescript-eslint"],
8 | extends: [
9 | "plugin:@next/next/recommended",
10 | "plugin:@typescript-eslint/recommended-type-checked",
11 | "plugin:@typescript-eslint/stylistic-type-checked",
12 | ],
13 | rules: {
14 | // These opinionated rules are enabled in stylistic-type-checked above.
15 | // Feel free to reconfigure them to your own preference.
16 | "@typescript-eslint/array-type": "off",
17 | "@typescript-eslint/consistent-type-definitions": "off",
18 | "@typescript-eslint/no-empty-interface": "off",
19 |
20 | "@typescript-eslint/consistent-type-imports": [
21 | "warn",
22 | {
23 | prefer: "type-imports",
24 | fixStyle: "inline-type-imports",
25 | },
26 | ],
27 | "@typescript-eslint/no-unused-vars": ["warn", { argsIgnorePattern: "^_" }],
28 | "@typescript-eslint/require-await": "off",
29 | "@typescript-eslint/no-misused-promises": [
30 | "error",
31 | {
32 | checksVoidReturn: { attributes: false },
33 | },
34 | ],
35 | },
36 | ignorePatterns: ["*.js"],
37 | };
38 |
39 | module.exports = config;
40 |
--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE/bug_report.md:
--------------------------------------------------------------------------------
1 | ---
2 | name: Bug report
3 | about: Create a report to help us improve
4 | title: ''
5 | labels: ''
6 | assignees: ''
7 |
8 | ---
9 |
10 | **Describe the bug**
11 | A clear and concise description of what the bug is.
12 |
13 | **To Reproduce**
14 | Steps to reproduce the behavior:
15 | 1. Go to '...'
16 | 2. Click on '....'
17 | 3. Scroll down to '....'
18 | 4. See error
19 |
20 | **Expected behavior**
21 | A clear and concise description of what you expected to happen.
22 |
23 | **Screenshots**
24 | If applicable, add screenshots to help explain your problem.
25 |
26 | **Desktop (please complete the following information):**
27 | - OS: [e.g. iOS]
28 | - Browser [e.g. chrome, safari]
29 | - Version [e.g. 22]
30 |
31 | **Smartphone (please complete the following information):**
32 | - Device: [e.g. iPhone6]
33 | - OS: [e.g. iOS8.1]
34 | - Browser [e.g. stock browser, safari]
35 | - Version [e.g. 22]
36 |
37 | **Additional context**
38 | Add any other context about the problem here.
39 |
--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE/feature_request.md:
--------------------------------------------------------------------------------
1 | ---
2 | name: Feature request
3 | about: Suggest an idea for this project
4 | title: ''
5 | labels: ''
6 | assignees: ''
7 |
8 | ---
9 |
10 | **Is your feature request related to a problem? Please describe.**
11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
12 |
13 | **Describe the solution you'd like**
14 | A clear and concise description of what you want to happen.
15 |
16 | **Describe alternatives you've considered**
17 | A clear and concise description of any alternative solutions or features you've considered.
18 |
19 | **Additional context**
20 | Add any other context or screenshots about the feature request here.
21 |
--------------------------------------------------------------------------------
/.github/pull_request_template.md:
--------------------------------------------------------------------------------
1 | # Description
2 |
3 | Please include a summary of the changes and the related issue. Please also include relevant motivation and context. List any dependencies that are required for this change.
4 |
5 | Fixes # (issue)
6 |
7 | ## Type of change
8 | - [ ] Bug fix (non-breaking change which fixes an issue)
9 | - [ ] New feature (non-breaking change which adds functionality)
10 | - [ ] This change requires a documentation update
11 | - [ ] This change requires installing new dependencies
12 |
13 |
--------------------------------------------------------------------------------
/.github/workflows/check.yaml:
--------------------------------------------------------------------------------
1 | name: Lint & Test
2 |
3 | on:
4 | pull_request:
5 | branches: [main]
6 |
7 | concurrency:
8 | group: ci-${{ github.ref }}
9 | cancel-in-progress: true
10 |
11 | permissions:
12 | contents: read
13 |
14 | jobs:
15 | typecheck-and-lint:
16 | runs-on: ubuntu-latest
17 |
18 | steps:
19 | - uses: actions/checkout@v2
20 | - uses: pnpm/action-setup@v2
21 | with:
22 | version: 9
23 | - uses: actions/setup-node@v3
24 | with:
25 | node-version: 20.x
26 | cache: "pnpm"
27 |
28 | - name: Install dependencies
29 | run: pnpm install --frozen-lockfile
30 |
31 | - name: Type Check and Lint
32 | run: pnpm run typecheck && pnpm run lint
33 | env:
34 | SKIP_ENV_VALIDATION: true
35 |
36 | e2e-test:
37 | needs: typecheck-and-lint
38 | timeout-minutes: 60
39 | runs-on: ubuntu-latest
40 |
41 | env:
42 | DATABASE_URL: ${{secrets.DATABASE_URL}}
43 | DISCORD_CLIENT_ID: ${{secrets.DISCORD_CLIENT_ID}}
44 | DISCORD_CLIENT_SECRET: ${{secrets.DISCORD_CLIENT_SECRET}}
45 | MOCK_SEND_EMAIL: "true"
46 | SMTP_HOST: host
47 | SMTP_PORT: 587
48 | SMTP_USER: user
49 | SMTP_PASSWORD: password
50 | NEXT_PUBLIC_APP_URL: http://localhost:3000
51 | STRIPE_API_KEY: stripe_api_key
52 | STRIPE_WEBHOOK_SECRET: stripe_webhook_secret
53 | STRIPE_PRO_MONTHLY_PLAN_ID: stripe_pro_monthly_plan_id
54 |
55 | steps:
56 | - uses: actions/checkout@v2
57 | - uses: pnpm/action-setup@v2
58 | with:
59 | version: 9
60 | - uses: actions/setup-node@v3
61 | with:
62 | node-version: 20.x
63 | cache: "pnpm"
64 | - name: Install dependencies
65 | run: pnpm install --frozen-lockfile
66 | - name: Build the app
67 | run: pnpm build
68 | - name: Install Playwright Browsers
69 | run: pnpm exec playwright install chromium --with-deps
70 | - name: Run Playwright tests
71 | run: pnpm exec playwright test
72 | - uses: actions/upload-artifact@v4
73 | if: always()
74 | with:
75 | name: playwright-report
76 | path: playwright-report/
77 | retention-days: 30
78 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
2 |
3 | # dependencies
4 | /node_modules
5 | /.pnp
6 | .pnp.js
7 |
8 | # testing
9 | /coverage
10 |
11 | # database
12 | /prisma/db.sqlite
13 | /prisma/db.sqlite-journal
14 |
15 | # next.js
16 | /.next/
17 | /out/
18 | next-env.d.ts
19 |
20 | # production
21 | /build
22 |
23 | # misc
24 | .DS_Store
25 | *.pem
26 |
27 | # debug
28 | npm-debug.log*
29 | yarn-debug.log*
30 | yarn-error.log*
31 | .pnpm-debug.log*
32 | application.log
33 |
34 | # local env files
35 | # do not commit any .env files to git, except for the .env.example file. https://create.t3.gg/en/usage/env-variables#using-environment-variables
36 | .env
37 | .env*.local
38 |
39 | # vercel
40 | .vercel
41 |
42 | # typescript
43 | *.tsbuildinfo
44 | /test-results/
45 | /playwright-report/
46 | /blob-report/
47 | /playwright/.cache/
48 | tests/e2e/output
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | # MIT License
2 |
3 | Copyright (c) [2023] [Touha Zohair]
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Next.js Auth Starter Template
2 |
3 | ## Motivation
4 |
5 | Implementing authentication in Next.js, especially Email+Password authentication, can be challenging. NextAuth intentionally limits email password functionality to discourage the use of passwords due to security risks and added complexity. However, in certain projects, clients may require user password authentication. Lucia offers a flexible alternative to NextAuth.js, providing more customization options without compromising on security. This template serves as a starting point for building a Next.js app with Lucia authentication.
6 |
7 | ## Lucia vs. NextAuth.js
8 |
9 | Lucia is less opinionated than NextAuth, offering greater flexibility for customization. While Lucia involves more setup, it provides a higher degree of flexibility, making it a suitable choice for projects requiring unique authentication configurations.
10 |
11 | ## Key Features
12 |
13 | - **Authentication:** 💼 Support for Credential and OAuth authentication.
14 | - **Authorization:** 🔒 Easily manage public and protected routes within the `app directory`.
15 | - **Email Verification:** 📧 Verify user identities through email.
16 | - **Password Reset:** 🔑 Streamline password resets by sending email password reset links.
17 | - **Lucia + tRPC:** 🔄 Similar to NextAuth with tRPC, granting access to sessions and user information through tRPC procedures.
18 | - **E2E tests:** 🧪 Catch every issue before your users do with comprehensive E2E testing.
19 | - **Stripe Payment:** 💳 Setup user subscriptions seamlessly with stripe.
20 | - **Email template with react-email:** ✉️ Craft your email templates using React.
21 | - **PostgreSQL Database:** 🛢️ Utilize a PostgreSQL database set up using Drizzle for enhanced performance and type safety.
22 | - **Database Migration:** 🚀 Included migration script to extend the database schema according to your project needs.
23 |
24 | ## Tech Stack
25 |
26 | - [Next.js](https://nextjs.org)
27 | - [Lucia](https://lucia-auth.com/)
28 | - [tRPC](https://trpc.io)
29 | - [Drizzle ORM](https://orm.drizzle.team/)
30 | - [PostgreSQL](https://www.postgresql.org/)
31 | - [Stripe](https://stripe.com/)
32 | - [Tailwind CSS](https://tailwindcss.com)
33 | - [Shadcn UI](https://ui.shadcn.com/)
34 | - [React Hook Form](https://www.react-hook-form.com/)
35 | - [React Email](https://react.email/)
36 | - [Playwright](https://playwright.dev/)
37 |
38 | ## Get Started
39 |
40 | 1. Clone this repository to your local machine.
41 | 2. Copy `.env.example` to `.env` and fill in the required environment variables.
42 | 3. Run `pnpm install` to install dependencies.
43 | 4. `(for node v18 or lower):` Uncomment polyfills for `webCrypto` in `src/lib/auth/index.ts`
44 | 5. Update app title, database prefix, and other parameters in the `src/lib/constants.ts` file.
45 | 6. Run `pnpm db:push` to push your schema to the database.
46 | 7. Execute `pnpm dev` to start the development server and enjoy!
47 |
48 | ## Testing
49 |
50 | 1. Install [Playwright](https://playwright.dev/) (use this command if you want to install chromium only `pnpm exec playwright install chromium --with-deps`)
51 | 2. Build production files using `pnpm build`
52 | 3. Run `pnpm test:e2e` (add --debug flag to open tests in browser in debug mode)
53 |
54 | ## Using Github actions
55 |
56 | Add the following environment variables to your **github actions repository secrets** -
57 | `DATABASE_URL`, `DISCORD_CLIENT_ID`, `DISCORD_CLIENT_SECRET`
58 |
59 | ## Roadmap
60 |
61 | - [ ] Update Password
62 | - [x] Stripe Integration
63 |
64 | - [ ] Admin Dashboard (under consideration)
65 | - [ ] Role-Based Access Policy (under consideration)
66 |
67 | ## Contributing
68 |
69 | To contribute, fork the repository and create a feature branch. Test your changes, and if possible, open an issue for discussion before submitting a pull request. Follow project guidelines, and welcome feedback to ensure a smooth integration of your contributions. Your pull requests are warmly welcome.
70 |
--------------------------------------------------------------------------------
/components.json:
--------------------------------------------------------------------------------
1 | {
2 | "$schema": "https://ui.shadcn.com/schema.json",
3 | "style": "default",
4 | "rsc": true,
5 | "tsx": true,
6 | "tailwind": {
7 | "config": "tailwind.config.ts",
8 | "css": "src/styles/globals.css",
9 | "baseColor": "zinc",
10 | "cssVariables": true
11 | },
12 | "aliases": {
13 | "components": "@/components",
14 | "utils": "@/lib/utils"
15 | }
16 | }
17 |
--------------------------------------------------------------------------------
/drizzle.config.ts:
--------------------------------------------------------------------------------
1 | import { defineConfig } from "drizzle-kit";
2 | import { DATABASE_PREFIX } from "@/lib/constants";
3 |
4 | export default defineConfig({
5 | schema: "./src/server/db/schema.ts",
6 | out: "./drizzle",
7 | dialect: "postgresql",
8 | dbCredentials: {
9 | url: process.env.DATABASE_URL!,
10 | },
11 | tablesFilter: [`${DATABASE_PREFIX}_*`],
12 | });
13 |
--------------------------------------------------------------------------------
/next.config.js:
--------------------------------------------------------------------------------
1 | await import("./src/env.js");
2 |
3 | /** @type {import("next").NextConfig} */
4 | const config = {};
5 |
6 | export default config;
7 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "next-lucia-auth",
3 | "version": "0.1.0",
4 | "private": true,
5 | "type": "module",
6 | "scripts": {
7 | "build": "next build",
8 | "db:push": "dotenv drizzle-kit push",
9 | "db:generate": "dotenv drizzle-kit generate",
10 | "db:migrate": "dotenv drizzle-kit migrate",
11 | "db:studio": "dotenv drizzle-kit studio",
12 | "dev": "next dev",
13 | "start": "next start",
14 | "lint": "next lint",
15 | "typecheck": "tsc --noEmit",
16 | "stripe:listen": "stripe listen --forward-to localhost:3000/api/webhooks/stripe --latest",
17 | "test:e2e": "playwright test"
18 | },
19 | "dependencies": {
20 | "@hookform/resolvers": "^3.9.0",
21 | "@lucia-auth/adapter-drizzle": "1.0.7",
22 | "@radix-ui/react-alert-dialog": "^1.1.1",
23 | "@radix-ui/react-dialog": "^1.1.1",
24 | "@radix-ui/react-dropdown-menu": "^2.1.1",
25 | "@radix-ui/react-icons": "^1.3.0",
26 | "@radix-ui/react-label": "^2.1.0",
27 | "@radix-ui/react-slot": "^1.1.0",
28 | "@radix-ui/react-tabs": "^1.1.0",
29 | "@react-email/components": "^0.0.12",
30 | "@react-email/render": "^0.0.10",
31 | "@t3-oss/env-nextjs": "^0.7.3",
32 | "@tanstack/react-query": "^4.36.1",
33 | "@trpc/client": "^10.45.2",
34 | "@trpc/next": "^10.45.2",
35 | "@trpc/react-query": "^10.45.2",
36 | "@trpc/server": "^10.45.2",
37 | "arctic": "^1.9.2",
38 | "class-variance-authority": "^0.7.0",
39 | "clsx": "^2.1.1",
40 | "lucia": "3.2.0",
41 | "next": "^14.2.5",
42 | "next-themes": "^0.2.1",
43 | "nodemailer": "^6.9.14",
44 | "oslo": "^1.2.1",
45 | "postgres": "^3.4.4",
46 | "react": "18.2.0",
47 | "react-dom": "18.2.0",
48 | "react-hook-form": "^7.52.1",
49 | "react-markdown": "^9.0.1",
50 | "react-syntax-highlighter": "^15.5.0",
51 | "rehype-raw": "^7.0.0",
52 | "remark-gfm": "^4.0.0",
53 | "server-only": "^0.0.1",
54 | "sonner": "^1.5.0",
55 | "stripe": "^14.25.0",
56 | "superjson": "^2.2.1",
57 | "tailwind-merge": "^2.4.0",
58 | "tailwindcss-animate": "^1.0.7",
59 | "vaul": "^0.8.9",
60 | "zod": "^3.23.8"
61 | },
62 | "devDependencies": {
63 | "@next/eslint-plugin-next": "^14.2.5",
64 | "@playwright/test": "^1.45.3",
65 | "@tailwindcss/typography": "^0.5.13",
66 | "@types/eslint": "^8.56.11",
67 | "@types/node": "^18.19.42",
68 | "@types/nodemailer": "^6.4.15",
69 | "@types/react": "^18.3.3",
70 | "@types/react-dom": "^18.3.0",
71 | "@types/react-syntax-highlighter": "^15.5.13",
72 | "@typescript-eslint/eslint-plugin": "^6.21.0",
73 | "@typescript-eslint/parser": "^6.21.0",
74 | "autoprefixer": "^10.4.19",
75 | "dotenv": "^16.4.5",
76 | "dotenv-cli": "^7.4.2",
77 | "drizzle-kit": "^0.23.0",
78 | "drizzle-orm": "^0.32.1",
79 | "eslint": "^8.57.0",
80 | "pg": "^8.12.0",
81 | "postcss": "^8.4.40",
82 | "prettier": "^3.3.3",
83 | "prettier-plugin-tailwindcss": "^0.5.14",
84 | "tailwindcss": "^3.4.7",
85 | "tsx": "^4.16.2",
86 | "typescript": "^5.5.4"
87 | },
88 | "ct3aMetadata": {
89 | "initVersion": "7.24.2"
90 | }
91 | }
92 |
--------------------------------------------------------------------------------
/playwright.config.ts:
--------------------------------------------------------------------------------
1 | import { defineConfig, devices } from "@playwright/test";
2 | import "dotenv/config";
3 |
4 | const baseURL = `http://localhost:${process.env.PORT ?? 3000}`;
5 |
6 | export default defineConfig({
7 | testDir: "./tests/e2e",
8 | outputDir: "./tests/e2e/output",
9 | timeout: 60 * 1000,
10 | fullyParallel: true,
11 |
12 | forbidOnly: !!process.env.CI,
13 | retries: process.env.CI ? 2 : 0,
14 | workers: process.env.CI ? 1 : undefined,
15 | reporter: "html",
16 | use: {
17 | trace: "on-first-retry",
18 | baseURL,
19 | },
20 |
21 | projects: [
22 | {
23 | name: "chromium",
24 | use: { ...devices["Desktop Chrome"] },
25 | },
26 | ],
27 | webServer: {
28 | command: "npx cross-env NODE_ENV=test npm run start",
29 | url: baseURL,
30 | stdout: "pipe",
31 | stderr: "pipe",
32 | reuseExistingServer: !process.env.CI,
33 | },
34 | });
35 |
--------------------------------------------------------------------------------
/postcss.config.cjs:
--------------------------------------------------------------------------------
1 | const config = {
2 | plugins: {
3 | tailwindcss: {},
4 | autoprefixer: {},
5 | },
6 | };
7 |
8 | module.exports = config;
9 |
--------------------------------------------------------------------------------
/prettier.config.js:
--------------------------------------------------------------------------------
1 | /** @type {import('prettier').Config & import('prettier-plugin-tailwindcss').PluginOptions} */
2 | const config = {
3 | plugins: ["prettier-plugin-tailwindcss"],
4 | tabWidth: 2,
5 | semi: true,
6 | singleQuote: false,
7 | printWidth: 100,
8 | };
9 |
10 | export default config;
11 |
--------------------------------------------------------------------------------
/src/app/(auth)/layout.tsx:
--------------------------------------------------------------------------------
1 | import type { ReactNode } from "react";
2 |
3 | const AuthLayout = ({ children }: { children: ReactNode }) => {
4 | return (
5 |
{children}
6 | );
7 | };
8 |
9 | export default AuthLayout;
10 |
--------------------------------------------------------------------------------
/src/app/(auth)/login/discord/callback/route.ts:
--------------------------------------------------------------------------------
1 | import { cookies } from "next/headers";
2 | import { generateId } from "lucia";
3 | import { OAuth2RequestError } from "arctic";
4 | import { eq } from "drizzle-orm";
5 | import { discord, lucia } from "@/lib/auth";
6 | import { db } from "@/server/db";
7 | import { Paths } from "@/lib/constants";
8 | import { users } from "@/server/db/schema";
9 |
10 | export async function GET(request: Request): Promise {
11 | const url = new URL(request.url);
12 | const code = url.searchParams.get("code");
13 | const state = url.searchParams.get("state");
14 | const storedState = cookies().get("discord_oauth_state")?.value ?? null;
15 |
16 | if (!code || !state || !storedState || state !== storedState) {
17 | return new Response(null, {
18 | status: 400,
19 | headers: { Location: Paths.Login },
20 | });
21 | }
22 |
23 | try {
24 | const tokens = await discord.validateAuthorizationCode(code);
25 |
26 | const discordUserRes = await fetch("https://discord.com/api/users/@me", {
27 | headers: {
28 | Authorization: `Bearer ${tokens.accessToken}`,
29 | },
30 | });
31 | const discordUser = (await discordUserRes.json()) as DiscordUser;
32 |
33 | if (!discordUser.email || !discordUser.verified) {
34 | return new Response(
35 | JSON.stringify({
36 | error: "Your Discord account must have a verified email address.",
37 | }),
38 | { status: 400, headers: { Location: Paths.Login } },
39 | );
40 | }
41 | const existingUser = await db.query.users.findFirst({
42 | where: (table, { eq, or }) =>
43 | or(eq(table.discordId, discordUser.id), eq(table.email, discordUser.email!)),
44 | });
45 |
46 | const avatar = discordUser.avatar
47 | ? `https://cdn.discordapp.com/avatars/${discordUser.id}/${discordUser.avatar}.webp`
48 | : null;
49 |
50 | if (!existingUser) {
51 | const userId = generateId(21);
52 | await db.insert(users).values({
53 | id: userId,
54 | email: discordUser.email,
55 | emailVerified: true,
56 | discordId: discordUser.id,
57 | avatar,
58 | });
59 | const session = await lucia.createSession(userId, {});
60 | const sessionCookie = lucia.createSessionCookie(session.id);
61 | cookies().set(sessionCookie.name, sessionCookie.value, sessionCookie.attributes);
62 | return new Response(null, {
63 | status: 302,
64 | headers: { Location: Paths.Dashboard },
65 | });
66 | }
67 |
68 | if (existingUser.discordId !== discordUser.id || existingUser.avatar !== avatar) {
69 | await db
70 | .update(users)
71 | .set({
72 | discordId: discordUser.id,
73 | emailVerified: true,
74 | avatar,
75 | })
76 | .where(eq(users.id, existingUser.id));
77 | }
78 | const session = await lucia.createSession(existingUser.id, {});
79 | const sessionCookie = lucia.createSessionCookie(session.id);
80 | cookies().set(sessionCookie.name, sessionCookie.value, sessionCookie.attributes);
81 | return new Response(null, {
82 | status: 302,
83 | headers: { Location: Paths.Dashboard },
84 | });
85 | } catch (e) {
86 | // the specific error message depends on the provider
87 | if (e instanceof OAuth2RequestError) {
88 | // invalid code
89 | return new Response(JSON.stringify({ message: "Invalid code" }), {
90 | status: 400,
91 | });
92 | }
93 | console.error(e);
94 |
95 | return new Response(JSON.stringify({ message: "internal server error" }), {
96 | status: 500,
97 | });
98 | }
99 | }
100 |
101 | interface DiscordUser {
102 | id: string;
103 | username: string;
104 | avatar: string | null;
105 | banner: string | null;
106 | global_name: string | null;
107 | banner_color: string | null;
108 | mfa_enabled: boolean;
109 | locale: string;
110 | email: string | null;
111 | verified: boolean;
112 | }
113 |
--------------------------------------------------------------------------------
/src/app/(auth)/login/discord/route.ts:
--------------------------------------------------------------------------------
1 | import { cookies } from "next/headers";
2 | import { generateState } from "arctic";
3 | import { discord } from "@/lib/auth";
4 | import { env } from "@/env";
5 |
6 | export async function GET(): Promise {
7 | const state = generateState();
8 | const url = await discord.createAuthorizationURL(state, {
9 | scopes: ["identify", "email"],
10 | });
11 |
12 | cookies().set("discord_oauth_state", state, {
13 | path: "/",
14 | secure: env.NODE_ENV === "production",
15 | httpOnly: true,
16 | maxAge: 60 * 10,
17 | sameSite: "lax",
18 | });
19 |
20 | return Response.redirect(url);
21 | }
22 |
--------------------------------------------------------------------------------
/src/app/(auth)/login/login.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import Link from "next/link";
4 | import { useFormState } from "react-dom";
5 | import { Input } from "@/components/ui/input";
6 | import { Button } from "@/components/ui/button";
7 | import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
8 | import { PasswordInput } from "@/components/password-input";
9 | import { DiscordLogoIcon } from "@/components/icons";
10 | import { APP_TITLE } from "@/lib/constants";
11 | import { login } from "@/lib/auth/actions";
12 | import { Label } from "@/components/ui/label";
13 | import { SubmitButton } from "@/components/submit-button";
14 |
15 | export function Login() {
16 | const [state, formAction] = useFormState(login, null);
17 |
18 | return (
19 |
20 |
21 | {APP_TITLE} Log In
22 | Log in to your account to access your dashboard
23 |
24 |
25 |
26 |
27 |
28 | Log in with Discord
29 |
30 |
31 |
36 |
89 |
90 |
91 | );
92 | }
93 |
--------------------------------------------------------------------------------
/src/app/(auth)/login/page.tsx:
--------------------------------------------------------------------------------
1 | import { redirect } from "next/navigation";
2 | import { validateRequest } from "@/lib/auth/validate-request";
3 | import { Paths } from "@/lib/constants";
4 | import { Login } from "./login";
5 |
6 | export const metadata = {
7 | title: "Login",
8 | description: "Login Page",
9 | };
10 |
11 | export default async function LoginPage() {
12 | const { user } = await validateRequest();
13 |
14 | if (user) redirect(Paths.Dashboard);
15 |
16 | return ;
17 | }
18 |
--------------------------------------------------------------------------------
/src/app/(auth)/reset-password/[token]/page.tsx:
--------------------------------------------------------------------------------
1 | import {
2 | Card,
3 | CardContent,
4 | CardDescription,
5 | CardHeader,
6 | CardTitle,
7 | } from "@/components/ui/card";
8 | import { ResetPassword } from "./reset-password";
9 |
10 | export const metadata = {
11 | title: "Reset Password",
12 | description: "Reset Password Page",
13 | };
14 |
15 | export default function ResetPasswordPage({
16 | params,
17 | }: {
18 | params: { token: string };
19 | }) {
20 | return (
21 |
22 |
23 | Reset password
24 | Enter new password.
25 |
26 |
27 |
28 |
29 |
30 | );
31 | }
32 |
--------------------------------------------------------------------------------
/src/app/(auth)/reset-password/[token]/reset-password.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { useEffect } from "react";
4 | import { useFormState } from "react-dom";
5 | import { toast } from "sonner";
6 | import { ExclamationTriangleIcon } from "@/components/icons";
7 | import { SubmitButton } from "@/components/submit-button";
8 | import { PasswordInput } from "@/components/password-input";
9 | import { Label } from "@/components/ui/label";
10 | import { resetPassword } from "@/lib/auth/actions";
11 |
12 | export function ResetPassword({ token }: { token: string }) {
13 | const [state, formAction] = useFormState(resetPassword, null);
14 |
15 | useEffect(() => {
16 | if (state?.error) {
17 | toast(state.error, {
18 | icon: ,
19 | });
20 | }
21 | }, [state?.error]);
22 |
23 | return (
24 |
37 | );
38 | }
39 |
--------------------------------------------------------------------------------
/src/app/(auth)/reset-password/page.tsx:
--------------------------------------------------------------------------------
1 | import { redirect } from "next/navigation";
2 | import {
3 | Card,
4 | CardContent,
5 | CardDescription,
6 | CardHeader,
7 | CardTitle,
8 | } from "@/components/ui/card";
9 | import { SendResetEmail } from "./send-reset-email";
10 | import { validateRequest } from "@/lib/auth/validate-request";
11 | import { Paths } from "@/lib/constants";
12 |
13 | export const metadata = {
14 | title: "Forgot Password",
15 | description: "Forgot Password Page",
16 | };
17 |
18 | export default async function ForgotPasswordPage() {
19 | const { user } = await validateRequest();
20 |
21 | if (user) redirect(Paths.Dashboard);
22 |
23 | return (
24 |
25 |
26 | Forgot password?
27 |
28 | Password reset link will be sent to your email.
29 |
30 |
31 |
32 |
33 |
34 |
35 | );
36 | }
37 |
--------------------------------------------------------------------------------
/src/app/(auth)/reset-password/send-reset-email.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { useEffect } from "react";
4 | import { useFormState } from "react-dom";
5 | import Link from "next/link";
6 | import { useRouter } from "next/navigation";
7 | import { toast } from "sonner";
8 | import { Input } from "@/components/ui/input";
9 | import { Button } from "@/components/ui/button";
10 | import { Label } from "@/components/ui/label";
11 | import { SubmitButton } from "@/components/submit-button";
12 | import { sendPasswordResetLink } from "@/lib/auth/actions";
13 | import { ExclamationTriangleIcon } from "@/components/icons";
14 | import { Paths } from "@/lib/constants";
15 |
16 | export function SendResetEmail() {
17 | const [state, formAction] = useFormState(sendPasswordResetLink, null);
18 | const router = useRouter();
19 |
20 | useEffect(() => {
21 | if (state?.success) {
22 | toast("A password reset link has been sent to your email.");
23 | router.push(Paths.Login);
24 | }
25 | if (state?.error) {
26 | toast(state.error, {
27 | icon: ,
28 | });
29 | }
30 | }, [state?.error, state?.success]);
31 |
32 | return (
33 |
58 | );
59 | }
60 |
--------------------------------------------------------------------------------
/src/app/(auth)/signup/page.tsx:
--------------------------------------------------------------------------------
1 | import { redirect } from "next/navigation";
2 | import { Signup } from "./signup";
3 | import { validateRequest } from "@/lib/auth/validate-request";
4 | import { Paths } from "@/lib/constants";
5 |
6 | export const metadata = {
7 | title: "Sign Up",
8 | description: "Signup Page",
9 | };
10 |
11 | export default async function SignupPage() {
12 | const { user } = await validateRequest();
13 |
14 | if (user) redirect(Paths.Dashboard);
15 |
16 | return ;
17 | }
18 |
--------------------------------------------------------------------------------
/src/app/(auth)/signup/signup.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { useFormState } from "react-dom";
4 | import Link from "next/link";
5 | import { PasswordInput } from "@/components/password-input";
6 | import { Button } from "@/components/ui/button";
7 | import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
8 | import { Input } from "@/components/ui/input";
9 | import { DiscordLogoIcon } from "@/components/icons";
10 | import { APP_TITLE } from "@/lib/constants";
11 | import { Label } from "@/components/ui/label";
12 | import { signup } from "@/lib/auth/actions";
13 | import { SubmitButton } from "@/components/submit-button";
14 |
15 | export function Signup() {
16 | const [state, formAction] = useFormState(signup, null);
17 |
18 | return (
19 |
20 |
21 | {APP_TITLE} Sign Up
22 | Sign up to start using the app
23 |
24 |
25 |
26 |
27 |
28 | Sign up with Discord
29 |
30 |
31 |
36 |
37 |
88 |
89 |
90 | );
91 | }
92 |
--------------------------------------------------------------------------------
/src/app/(auth)/verify-email/page.tsx:
--------------------------------------------------------------------------------
1 | import {
2 | Card,
3 | CardContent,
4 | CardDescription,
5 | CardHeader,
6 | CardTitle,
7 | } from "@/components/ui/card";
8 | import { redirect } from "next/navigation";
9 | import { validateRequest } from "@/lib/auth/validate-request";
10 | import { VerifyCode } from "./verify-code";
11 | import { Paths } from "@/lib/constants";
12 |
13 | export const metadata = {
14 | title: "Verify Email",
15 | description: "Verify Email Page",
16 | };
17 |
18 | export default async function VerifyEmailPage() {
19 | const { user } = await validateRequest();
20 |
21 | if (!user) redirect(Paths.Login);
22 | if (user.emailVerified) redirect(Paths.Dashboard);
23 |
24 | return (
25 |
26 |
27 | Verify Email
28 |
29 | Verification code was sent to {user.email} . Check
30 | your spam folder if you can't find the email.
31 |
32 |
33 |
34 |
35 |
36 |
37 | );
38 | }
39 |
--------------------------------------------------------------------------------
/src/app/(auth)/verify-email/verify-code.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 | import { Input } from "@/components/ui/input";
3 | import { Label } from "@radix-ui/react-label";
4 | import { useEffect, useRef } from "react";
5 | import { useFormState } from "react-dom";
6 | import { toast } from "sonner";
7 | import { ExclamationTriangleIcon } from "@/components/icons";
8 | import { logout, verifyEmail, resendVerificationEmail as resendEmail } from "@/lib/auth/actions";
9 | import { SubmitButton } from "@/components/submit-button";
10 |
11 | export const VerifyCode = () => {
12 | const [verifyEmailState, verifyEmailAction] = useFormState(verifyEmail, null);
13 | const [resendState, resendAction] = useFormState(resendEmail, null);
14 | const codeFormRef = useRef(null);
15 |
16 | useEffect(() => {
17 | if (resendState?.success) {
18 | toast("Email sent!");
19 | }
20 | if (resendState?.error) {
21 | toast(resendState.error, {
22 | icon: ,
23 | });
24 | }
25 | }, [resendState?.error, resendState?.success]);
26 |
27 | useEffect(() => {
28 | if (verifyEmailState?.error) {
29 | toast(verifyEmailState.error, {
30 | icon: ,
31 | });
32 | }
33 | }, [verifyEmailState?.error]);
34 |
35 | return (
36 |
37 |
44 |
49 |
54 |
55 | );
56 | };
57 |
--------------------------------------------------------------------------------
/src/app/(landing)/_components/copy-to-clipboard.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { Button } from "@/components/ui/button";
4 | import { Input } from "@/components/ui/input";
5 | import { cn } from "@/lib/utils";
6 | import { CheckIcon, CopyIcon } from "@radix-ui/react-icons";
7 | import { useState } from "react";
8 | import { toast } from "sonner";
9 |
10 | export const CopyToClipboard = ({ text }: { text: string }) => {
11 | const [copied, setCopied] = useState(false);
12 | const copyToClipboard = async () => {
13 | setCopied(true);
14 | setTimeout(() => {
15 | setCopied(false);
16 | }, 2000);
17 | await navigator.clipboard.writeText(text);
18 | toast("Copied to clipboard", {
19 | icon: ,
20 | });
21 | };
22 | return (
23 |
24 |
25 | copyToClipboard()}>
26 | {copied ? (
27 |
33 | ) : (
34 |
35 | )}
36 |
37 |
38 | );
39 | };
40 |
--------------------------------------------------------------------------------
/src/app/(landing)/_components/footer.tsx:
--------------------------------------------------------------------------------
1 | import { ThemeToggle } from "@/components/theme-toggle";
2 | import { CodeIcon } from "@radix-ui/react-icons";
3 |
4 | const githubUrl = "https://github.com/iamtouha/next-lucia-auth";
5 | const twitterUrl = "https://twitter.com/iamtouha";
6 |
7 | export const Footer = () => {
8 | return (
9 |
28 | );
29 | };
30 |
--------------------------------------------------------------------------------
/src/app/(landing)/_components/header.tsx:
--------------------------------------------------------------------------------
1 | import Link from "next/link";
2 | import { RocketIcon } from "@/components/icons";
3 | import { APP_TITLE } from "@/lib/constants";
4 | import { Button } from "@/components/ui/button";
5 | import {
6 | DropdownMenu,
7 | DropdownMenuContent,
8 | DropdownMenuItem,
9 | DropdownMenuTrigger,
10 | } from "@/components/ui/dropdown-menu";
11 | import { HamburgerMenuIcon } from "@radix-ui/react-icons";
12 |
13 | const routes = [
14 | { name: "Home", href: "/" },
15 | { name: "Features", href: "/#features" },
16 | {
17 | name: "Documentation",
18 | href: "https://www.touha.dev/posts/simple-nextjs-t3-authentication-with-lucia",
19 | },
20 | ] as const;
21 |
22 | export const Header = () => {
23 | return (
24 |
70 | );
71 | };
72 |
--------------------------------------------------------------------------------
/src/app/(landing)/_components/hover-card.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 | import { Card, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
3 | import React, { useRef, useState } from "react";
4 |
5 | type FeaturesProps = {
6 | name: string;
7 | description: string;
8 | logo: React.ReactNode;
9 | };
10 |
11 | const CardSpotlight = (props: FeaturesProps) => {
12 | const divRef = useRef(null);
13 | const [isFocused, setIsFocused] = useState(false);
14 | const [position, setPosition] = useState({ x: 0, y: 0 });
15 | const [opacity, setOpacity] = useState(0);
16 |
17 | const handleMouseMove = (e: React.MouseEvent) => {
18 | if (!divRef.current || isFocused) return;
19 |
20 | const div = divRef.current;
21 | const rect = div.getBoundingClientRect();
22 |
23 | setPosition({ x: e.clientX - rect.left, y: e.clientY - rect.top });
24 | };
25 |
26 | const handleFocus = () => {
27 | setIsFocused(true);
28 | setOpacity(1);
29 | };
30 |
31 | const handleBlur = () => {
32 | setIsFocused(false);
33 | setOpacity(0);
34 | };
35 |
36 | const handleMouseEnter = () => {
37 | setOpacity(1);
38 | };
39 |
40 | const handleMouseLeave = () => {
41 | setOpacity(0);
42 | };
43 |
44 | return (
45 |
54 |
61 | {props.logo}
62 |
63 | {props.name}
64 | {props.description}
65 |
66 |
67 | );
68 | };
69 |
70 | export default CardSpotlight;
71 |
--------------------------------------------------------------------------------
/src/app/(landing)/layout.tsx:
--------------------------------------------------------------------------------
1 | import { APP_TITLE } from "@/lib/constants";
2 | import { type Metadata } from "next";
3 | import { type ReactNode } from "react";
4 | import { Footer } from "./_components/footer";
5 | import { Header } from "./_components/header";
6 |
7 | export const metadata: Metadata = {
8 | title: APP_TITLE,
9 | description: "A Next.js starter with T3 stack and Lucia auth.",
10 | };
11 |
12 | function LandingPageLayout({ children }: { children: ReactNode }) {
13 | return (
14 | <>
15 |
16 | {children}
17 |
18 |
19 | >
20 | );
21 | }
22 |
23 | export default LandingPageLayout;
24 |
--------------------------------------------------------------------------------
/src/app/(landing)/page.tsx:
--------------------------------------------------------------------------------
1 | import { PlusIcon } from "@/components/icons";
2 | import { Button } from "@/components/ui/button";
3 | import { GitHubLogoIcon } from "@radix-ui/react-icons";
4 | import { type Metadata } from "next";
5 | import Link from "next/link";
6 | import { CopyToClipboard } from "./_components/copy-to-clipboard";
7 | import {
8 | Drizzle,
9 | LuciaAuth,
10 | NextjsDark,
11 | NextjsLight,
12 | ReactEmail,
13 | ReactJs,
14 | ShadcnUi,
15 | StripeLogo,
16 | TRPC,
17 | TailwindCss,
18 | } from "./_components/feature-icons";
19 | import CardSpotlight from "./_components/hover-card";
20 |
21 | export const metadata: Metadata = {
22 | title: "Next.js Lucia Auth Starter Template",
23 | description:
24 | "A Next.js starter template with nextjs and Lucia auth. Includes drizzle, trpc, react-email, tailwindcss and shadcn-ui",
25 | };
26 |
27 | const githubUrl = "https://github.com/iamtouha/next-lucia-auth";
28 |
29 | const features = [
30 | {
31 | name: "Next.js",
32 | description: "The React Framework for Production",
33 | logo: NextjsIcon,
34 | },
35 | {
36 | name: "React.js",
37 | description: "Server and client components.",
38 | logo: ReactJs,
39 | },
40 | {
41 | name: "Authentication",
42 | description: "Credential authentication with password reset and email validation",
43 | logo: LuciaAuth,
44 | },
45 | {
46 | name: "Database",
47 | description: "Drizzle with postgres database",
48 | logo: Drizzle,
49 | },
50 | {
51 | name: "TypeSafe Backend",
52 | description: "Preserve type safety from backend to frontend with tRPC",
53 | logo: TRPC,
54 | },
55 | {
56 | name: "Subscription",
57 | description: "Subscription with stripe",
58 | logo: StripeLogo,
59 | },
60 | {
61 | name: "Tailwindcss",
62 | description: "Simple and elegant UI components built with Tailwind CSS",
63 | logo: TailwindCss,
64 | },
65 | {
66 | name: "Shadcn UI",
67 | description: "A set of beautifully designed UI components for React",
68 | logo: ShadcnUi,
69 | },
70 | {
71 | name: "React Email",
72 | description: "Write emails in React with ease.",
73 | logo: ReactEmail,
74 | },
75 | ];
76 |
77 | const HomePage = () => {
78 | return (
79 | <>
80 |
81 |
82 |
87 |
88 | Next.js Lucia Auth Starter Template
89 |
90 |
91 | A Next.js Authentication starter template (password reset, email validation and oAuth).
92 | Includes Lucia, Drizzle, tRPC, Stripe, tailwindcss, shadcn-ui and react-email.
93 |
94 |
99 |
110 |
111 |
112 |
113 |
114 |
115 | Features
116 |
117 |
118 | This starter template is a guide to help you get started with Next.js for large scale
119 | applications. Feel free to add or remove features to suit your needs.
120 |
121 |
122 | {features.map((feature, i) => (
123 | }
128 | />
129 | ))}
130 |
131 |
132 |
133 | >
134 | );
135 | };
136 |
137 | export default HomePage;
138 |
139 | function NextjsIcon({ className }: { className?: string }) {
140 | return (
141 | <>
142 |
143 |
144 | >
145 | );
146 | }
147 |
--------------------------------------------------------------------------------
/src/app/(main)/_components/footer.tsx:
--------------------------------------------------------------------------------
1 | import { ThemeToggle } from "@/components/theme-toggle";
2 | import { CodeIcon } from "@radix-ui/react-icons";
3 |
4 | const githubUrl = "https://github.com/iamtouha/next-lucia-auth";
5 | const twitterUrl = "https://twitter.com/iamtouha";
6 |
7 | export const Footer = () => {
8 | return (
9 |
28 | );
29 | };
30 |
--------------------------------------------------------------------------------
/src/app/(main)/_components/header.tsx:
--------------------------------------------------------------------------------
1 | import { UserDropdown } from "@/app/(main)/_components/user-dropdown";
2 | import { RocketIcon } from "@/components/icons";
3 | import { validateRequest } from "@/lib/auth/validate-request";
4 | import { APP_TITLE } from "@/lib/constants";
5 | import Link from "next/link";
6 |
7 | export const Header = async () => {
8 | const { user } = await validateRequest();
9 |
10 | return (
11 |
19 | );
20 | };
21 |
--------------------------------------------------------------------------------
/src/app/(main)/_components/user-dropdown.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { ExclamationTriangleIcon } from "@/components/icons";
4 | import { LoadingButton } from "@/components/loading-button";
5 | import {
6 | AlertDialog,
7 | AlertDialogContent,
8 | AlertDialogDescription,
9 | AlertDialogHeader,
10 | AlertDialogTitle,
11 | AlertDialogTrigger,
12 | } from "@/components/ui/alert-dialog";
13 | import { Button } from "@/components/ui/button";
14 | import {
15 | DropdownMenu,
16 | DropdownMenuContent,
17 | DropdownMenuGroup,
18 | DropdownMenuItem,
19 | DropdownMenuLabel,
20 | DropdownMenuSeparator,
21 | DropdownMenuTrigger,
22 | } from "@/components/ui/dropdown-menu";
23 | import { logout } from "@/lib/auth/actions";
24 | import { APP_TITLE } from "@/lib/constants";
25 | import Link from "next/link";
26 | import { useState } from "react";
27 | import { toast } from "sonner";
28 |
29 | export const UserDropdown = ({
30 | email,
31 | avatar,
32 | className,
33 | }: {
34 | email: string;
35 | avatar?: string | null;
36 | className?: string;
37 | }) => {
38 | return (
39 |
40 |
41 | {/* eslint @next/next/no-img-element:off */}
42 |
49 |
50 |
51 | {email}
52 |
53 |
54 |
55 | Dashboard
56 |
57 |
58 | Billing
59 |
60 |
61 | Settings
62 |
63 |
64 |
65 |
66 |
67 |
68 |
69 |
70 |
71 | );
72 | };
73 |
74 | const SignoutConfirmation = () => {
75 | const [open, setOpen] = useState(false);
76 | const [isLoading, setIsLoading] = useState(false);
77 |
78 | const handleSignout = async () => {
79 | setIsLoading(true);
80 | try {
81 | await logout();
82 | toast("Signed out successfully");
83 | } catch (error) {
84 | if (error instanceof Error) {
85 | toast(error.message, {
86 | icon: ,
87 | });
88 | }
89 | } finally {
90 | setOpen(false);
91 | setIsLoading(false);
92 | }
93 | };
94 |
95 | return (
96 |
97 |
101 | Sign out
102 |
103 |
104 |
105 | Sign out from {APP_TITLE}?
106 | You will be redirected to the home page.
107 |
108 |
109 | setOpen(false)}>
110 | Cancel
111 |
112 |
113 | Continue
114 |
115 |
116 |
117 |
118 | );
119 | };
120 |
--------------------------------------------------------------------------------
/src/app/(main)/account/page.tsx:
--------------------------------------------------------------------------------
1 | import { SubmitButton } from "@/components/submit-button";
2 | import {
3 | Card,
4 | CardContent,
5 | CardDescription,
6 | CardFooter,
7 | CardHeader,
8 | CardTitle,
9 | } from "@/components/ui/card";
10 | import { logout } from "@/lib/auth/actions";
11 | import { validateRequest } from "@/lib/auth/validate-request";
12 | import { Paths } from "@/lib/constants";
13 | import { redirect } from "next/navigation";
14 |
15 | export default async function AccountPage() {
16 | const { user } = await validateRequest();
17 | if (!user) redirect(Paths.Login);
18 |
19 | return (
20 |
21 |
22 |
23 | {user.email}!
24 | You've successfully logged in!
25 |
26 | This is a private page.
27 |
28 |
31 |
32 |
33 |
34 | );
35 | }
36 |
--------------------------------------------------------------------------------
/src/app/(main)/dashboard/_components/dashboard-nav.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { CreditCard, FileTextIcon, GearIcon } from "@/components/icons";
4 | import Link from "next/link";
5 | import { usePathname } from "next/navigation";
6 |
7 | import { cn } from "@/lib/utils";
8 |
9 | const items = [
10 | {
11 | title: "Posts",
12 | href: "/dashboard",
13 | icon: FileTextIcon,
14 | },
15 |
16 | {
17 | title: "Billing",
18 | href: "/dashboard/billing",
19 | icon: CreditCard,
20 | },
21 | {
22 | title: "Settings",
23 | href: "/dashboard/settings",
24 | icon: GearIcon,
25 | },
26 | ];
27 |
28 | interface Props {
29 | className?: string;
30 | }
31 |
32 | export function DashboardNav({ className }: Props) {
33 | const path = usePathname();
34 |
35 | return (
36 |
37 | {items.map((item) => (
38 |
39 |
45 |
46 | {item.title}
47 |
48 |
49 | ))}
50 |
51 | );
52 | }
53 |
--------------------------------------------------------------------------------
/src/app/(main)/dashboard/_components/new-post.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { FilePlusIcon } from "@/components/icons";
4 | import { Button } from "@/components/ui/button";
5 | import { api } from "@/trpc/react";
6 | import { type RouterOutputs } from "@/trpc/shared";
7 | import { useRouter } from "next/navigation";
8 | import * as React from "react";
9 | import { toast } from "sonner";
10 | interface NewPostProps {
11 | isEligible: boolean;
12 | setOptimisticPosts: (action: {
13 | action: "add" | "delete" | "update";
14 | post: RouterOutputs["post"]["myPosts"][number];
15 | }) => void;
16 | }
17 |
18 | export const NewPost = ({ isEligible, setOptimisticPosts }: NewPostProps) => {
19 | const router = useRouter();
20 | const post = api.post.create.useMutation();
21 | const [isCreatePending, startCreateTransaction] = React.useTransition();
22 |
23 | const createPost = () => {
24 | if (!isEligible) {
25 | toast.message("You've reached the limit of posts for your current plan", {
26 | description: "Upgrade to create more posts",
27 | });
28 | return;
29 | }
30 |
31 | startCreateTransaction(async () => {
32 | await post.mutateAsync(
33 | {
34 | title: "Untitled Post",
35 | content: "Write your content here",
36 | excerpt: "untitled post",
37 | },
38 | {
39 | onSettled: () => {
40 | setOptimisticPosts({
41 | action: "add",
42 | post: {
43 | id: crypto.randomUUID(),
44 | title: "Untitled Post",
45 | excerpt: "untitled post",
46 | status: "draft",
47 | createdAt: new Date(),
48 | },
49 | });
50 | },
51 | onSuccess: ({ id }) => {
52 | toast.success("Post created");
53 | router.refresh();
54 | // This is a workaround for a bug in navigation because of router.refresh()
55 | setTimeout(() => {
56 | router.push(`/editor/${id}`);
57 | }, 100);
58 | },
59 | onError: () => {
60 | toast.error("Failed to create post");
61 | },
62 | },
63 | );
64 | });
65 | };
66 |
67 | return (
68 |
73 |
77 |
78 | );
79 | };
80 |
--------------------------------------------------------------------------------
/src/app/(main)/dashboard/_components/post-card-skeleton.tsx:
--------------------------------------------------------------------------------
1 | import { Card, CardContent, CardFooter, CardHeader } from "@/components/ui/card";
2 | import { Skeleton } from "@/components/ui/skeleton";
3 |
4 | export function PostCardSkeleton() {
5 | return (
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 | );
23 | }
24 |
--------------------------------------------------------------------------------
/src/app/(main)/dashboard/_components/post-card.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { Pencil2Icon, TrashIcon } from "@/components/icons";
4 | import { Badge } from "@/components/ui/badge";
5 | import { Button } from "@/components/ui/button";
6 | import {
7 | Card,
8 | CardContent,
9 | CardDescription,
10 | CardFooter,
11 | CardHeader,
12 | CardTitle,
13 | } from "@/components/ui/card";
14 | import { api } from "@/trpc/react";
15 | import { type RouterOutputs } from "@/trpc/shared";
16 | import Link from "next/link";
17 | import { useRouter } from "next/navigation";
18 | import * as React from "react";
19 | import { toast } from "sonner";
20 |
21 | interface PostCardProps {
22 | post: RouterOutputs["post"]["myPosts"][number];
23 | userName?: string;
24 | setOptimisticPosts: (action: {
25 | action: "add" | "delete" | "update";
26 | post: RouterOutputs["post"]["myPosts"][number];
27 | }) => void;
28 | }
29 |
30 | export const PostCard = ({ post, userName, setOptimisticPosts }: PostCardProps) => {
31 | const router = useRouter();
32 | const postMutation = api.post.delete.useMutation();
33 | const [isDeletePending, startDeleteTransition] = React.useTransition();
34 |
35 | return (
36 |
37 |
38 | {post.title}
39 |
40 | {userName ? {userName} at : null}
41 | {new Date(post.createdAt.toJSON()).toLocaleString(undefined, {
42 | dateStyle: "medium",
43 | timeStyle: "short",
44 | })}
45 |
46 |
47 | {post.excerpt}
48 |
49 |
50 |
51 |
52 | Edit
53 |
54 |
55 | {
60 | startDeleteTransition(async () => {
61 | await postMutation.mutateAsync(
62 | { id: post.id },
63 | {
64 | onSettled: () => {
65 | setOptimisticPosts({
66 | action: "delete",
67 | post,
68 | });
69 | },
70 | onSuccess: () => {
71 | toast.success("Post deleted");
72 | router.refresh();
73 | },
74 | onError: () => {
75 | toast.error("Failed to delete post");
76 | },
77 | },
78 | );
79 | });
80 | }}
81 | disabled={isDeletePending}
82 | >
83 |
84 | Delete
85 |
86 |
87 | {post.status} Post
88 |
89 |
90 |
91 | );
92 | };
93 |
--------------------------------------------------------------------------------
/src/app/(main)/dashboard/_components/posts-skeleton.tsx:
--------------------------------------------------------------------------------
1 | import { PostCardSkeleton } from "./post-card-skeleton";
2 |
3 | export function PostsSkeleton() {
4 | return (
5 |
6 | {Array.from({ length: 3 }).map((_, i) => (
7 |
8 | ))}
9 |
10 | );
11 | }
12 |
--------------------------------------------------------------------------------
/src/app/(main)/dashboard/_components/posts.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { type RouterOutputs } from "@/trpc/shared";
4 | import * as React from "react";
5 | import { NewPost } from "./new-post";
6 | import { PostCard } from "./post-card";
7 |
8 | interface PostsProps {
9 | promises: Promise<[RouterOutputs["post"]["myPosts"], RouterOutputs["stripe"]["getPlan"]]>;
10 | }
11 |
12 | export function Posts({ promises }: PostsProps) {
13 | /**
14 | * use is a React Hook that lets you read the value of a resource like a Promise or context.
15 | * @see https://react.dev/reference/react/use
16 | */
17 | const [posts, subscriptionPlan] = React.use(promises);
18 |
19 | /**
20 | * useOptimistic is a React Hook that lets you show a different state while an async action is underway.
21 | * It accepts some state as an argument and returns a copy of that state that can be different during the duration of an async action such as a network request.
22 | * @see https://react.dev/reference/react/useOptimistic
23 | */
24 | const [optimisticPosts, setOptimisticPosts] = React.useOptimistic(
25 | posts,
26 | (
27 | state,
28 | {
29 | action,
30 | post,
31 | }: {
32 | action: "add" | "delete" | "update";
33 | post: RouterOutputs["post"]["myPosts"][number];
34 | },
35 | ) => {
36 | switch (action) {
37 | case "delete":
38 | return state.filter((p) => p.id !== post.id);
39 | case "update":
40 | return state.map((p) => (p.id === post.id ? post : p));
41 | default:
42 | return [...state, post];
43 | }
44 | },
45 | );
46 |
47 | return (
48 |
49 |
53 | {optimisticPosts.map((post) => (
54 |
55 | ))}
56 |
57 | );
58 | }
59 |
--------------------------------------------------------------------------------
/src/app/(main)/dashboard/_components/verificiation-warning.tsx:
--------------------------------------------------------------------------------
1 | import { ExclamationTriangleIcon } from "@/components/icons";
2 |
3 | import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
4 | import { Button } from "@/components/ui/button";
5 | import { validateRequest } from "@/lib/auth/validate-request";
6 | import Link from "next/link";
7 |
8 | export async function VerificiationWarning() {
9 | const { user } = await validateRequest();
10 |
11 | return user?.emailVerified === false ? (
12 |
13 |
14 |
15 |
16 |
Account verification required
17 |
18 | A verification email has been sent to your email address. Please verify your account to
19 | access all features.
20 |
21 |
22 |
23 | Verify Email
24 |
25 |
26 |
27 | ) : null;
28 | }
29 |
--------------------------------------------------------------------------------
/src/app/(main)/dashboard/billing/_components/billing-skeleton.tsx:
--------------------------------------------------------------------------------
1 | import { Card, CardContent, CardFooter, CardHeader } from "@/components/ui/card";
2 | import { Skeleton } from "@/components/ui/skeleton";
3 |
4 | export function BillingSkeleton() {
5 | return (
6 | <>
7 |
13 |
14 | {Array.from({ length: 2 }).map((_, i) => (
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 | {Array.from({ length: 2 }).map((_, i) => (
24 |
25 |
26 |
27 |
28 | ))}
29 |
30 |
31 |
32 |
33 |
34 |
35 | ))}
36 |
37 | >
38 | );
39 | }
40 |
--------------------------------------------------------------------------------
/src/app/(main)/dashboard/billing/_components/billing.tsx:
--------------------------------------------------------------------------------
1 | import Link from "next/link";
2 |
3 | import { CheckIcon } from "@/components/icons";
4 |
5 | import { Button } from "@/components/ui/button";
6 | import {
7 | Card,
8 | CardContent,
9 | CardDescription,
10 | CardFooter,
11 | CardHeader,
12 | CardTitle,
13 | } from "@/components/ui/card";
14 | import { formatDate } from "@/lib/utils";
15 | import { type RouterOutputs } from "@/trpc/shared";
16 | import { ManageSubscriptionForm } from "./manage-subscription-form";
17 |
18 | interface BillingProps {
19 | stripePromises: Promise<
20 | [RouterOutputs["stripe"]["getPlans"], RouterOutputs["stripe"]["getPlan"]]
21 | >;
22 | }
23 |
24 | export async function Billing({ stripePromises }: BillingProps) {
25 | const [plans, plan] = await stripePromises;
26 |
27 | return (
28 | <>
29 |
30 |
31 | {plan?.name ?? "Free"} plan
32 |
33 | {!plan?.isPro
34 | ? "The free plan is limited to 2 posts. Upgrade to the Pro plan to unlock unlimited posts."
35 | : plan.isCanceled
36 | ? "Your plan will be canceled on "
37 | : "Your plan renews on "}
38 | {plan?.stripeCurrentPeriodEnd ? formatDate(plan.stripeCurrentPeriodEnd) : null}
39 |
40 |
41 |
42 |
43 | {plans.map((item) => (
44 |
45 |
46 | {item.name}
47 | {item.description}
48 |
49 |
50 |
51 | {item.price}
52 | /month
53 |
54 |
55 | {item.features.map((feature) => (
56 |
57 |
58 |
59 |
60 |
{feature}
61 |
62 | ))}
63 |
64 |
65 |
66 | {item.name === "Free" ? (
67 |
68 |
69 | Get started
70 | Get started
71 |
72 |
73 | ) : (
74 |
80 | )}
81 |
82 |
83 | ))}
84 |
85 | >
86 | );
87 | }
88 |
--------------------------------------------------------------------------------
/src/app/(main)/dashboard/billing/_components/manage-subscription-form.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import * as React from "react";
4 |
5 | import { Button } from "@/components/ui/button";
6 | import type { ManageSubscriptionInput } from "@/server/api/routers/stripe/stripe.input";
7 | import { api } from "@/trpc/react";
8 | import { toast } from "sonner";
9 |
10 | export function ManageSubscriptionForm({
11 | isPro,
12 | stripeCustomerId,
13 | stripeSubscriptionId,
14 | stripePriceId,
15 | }: ManageSubscriptionInput) {
16 | const [isPending, startTransition] = React.useTransition();
17 | const managePlanMutation = api.stripe.managePlan.useMutation();
18 |
19 | function onSubmit(e: React.FormEvent) {
20 | e.preventDefault();
21 |
22 | startTransition(async () => {
23 | try {
24 | const session = await managePlanMutation.mutateAsync({
25 | isPro,
26 | stripeCustomerId,
27 | stripeSubscriptionId,
28 | stripePriceId,
29 | });
30 |
31 | if (session) {
32 | window.location.href = session.url ?? "/dashboard/billing";
33 | }
34 | } catch (err) {
35 | err instanceof Error
36 | ? toast.error(err.message)
37 | : toast.error("An error occurred. Please try again.");
38 | }
39 | });
40 | }
41 |
42 | return (
43 |
48 | );
49 | }
50 |
--------------------------------------------------------------------------------
/src/app/(main)/dashboard/billing/page.tsx:
--------------------------------------------------------------------------------
1 | import type { Metadata } from "next";
2 | import { redirect } from "next/navigation";
3 |
4 | import { ExclamationTriangleIcon } from "@/components/icons";
5 |
6 | import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
7 | import { env } from "@/env";
8 | import { validateRequest } from "@/lib/auth/validate-request";
9 | import { APP_TITLE } from "@/lib/constants";
10 | import { api } from "@/trpc/server";
11 | import * as React from "react";
12 | import { Billing } from "./_components/billing";
13 | import { BillingSkeleton } from "./_components/billing-skeleton";
14 |
15 | export const metadata: Metadata = {
16 | metadataBase: new URL(env.NEXT_PUBLIC_APP_URL),
17 | title: "Billing",
18 | description: "Manage your billing and subscription",
19 | };
20 |
21 | export default async function BillingPage() {
22 | const { user } = await validateRequest();
23 |
24 | if (!user) {
25 | redirect("/signin");
26 | }
27 |
28 | const stripePromises = Promise.all([api.stripe.getPlans.query(), api.stripe.getPlan.query()]);
29 |
30 | return (
31 |
32 |
33 |
Billing
34 |
Manage your billing and subscription
35 |
36 |
37 |
38 |
39 | This is a demo app.
40 |
41 | {APP_TITLE} app is a demo app using a Stripe test environment. You can find a list of
42 | test card numbers on the{" "}
43 |
49 | Stripe docs
50 |
51 | .
52 |
53 |
54 |
55 |
}>
56 |
57 |
58 |
59 | );
60 | }
61 |
--------------------------------------------------------------------------------
/src/app/(main)/dashboard/layout.tsx:
--------------------------------------------------------------------------------
1 | import { DashboardNav } from "./_components/dashboard-nav";
2 | import { VerificiationWarning } from "./_components/verificiation-warning";
3 |
4 | interface Props {
5 | children: React.ReactNode;
6 | }
7 |
8 | export default function DashboardLayout({ children }: Props) {
9 | return (
10 |
11 |
12 |
13 |
14 |
15 | {children}
16 |
17 |
18 |
19 | );
20 | }
21 |
--------------------------------------------------------------------------------
/src/app/(main)/dashboard/page.tsx:
--------------------------------------------------------------------------------
1 | import { env } from "@/env";
2 | import { validateRequest } from "@/lib/auth/validate-request";
3 | import { Paths } from "@/lib/constants";
4 | import { myPostsSchema } from "@/server/api/routers/post/post.input";
5 | import { api } from "@/trpc/server";
6 | import { type Metadata } from "next";
7 | import { redirect } from "next/navigation";
8 | import * as React from "react";
9 | import { Posts } from "./_components/posts";
10 | import { PostsSkeleton } from "./_components/posts-skeleton";
11 |
12 | export const metadata: Metadata = {
13 | metadataBase: new URL(env.NEXT_PUBLIC_APP_URL),
14 | title: "Posts",
15 | description: "Manage your posts here",
16 | };
17 |
18 | interface Props {
19 | searchParams: Record;
20 | }
21 |
22 | export default async function DashboardPage({ searchParams }: Props) {
23 | const { page, perPage } = myPostsSchema.parse(searchParams);
24 |
25 | const { user } = await validateRequest();
26 | if (!user) redirect(Paths.Login);
27 |
28 | /**
29 | * Passing multiple promises to `Promise.all` to fetch data in parallel to prevent waterfall requests.
30 | * Passing promises to the `Posts` component to make them hot promises (they can run without being awaited) to prevent waterfall requests.
31 | * @see https://www.youtube.com/shorts/A7GGjutZxrs
32 | * @see https://nextjs.org/docs/app/building-your-application/data-fetching/patterns#parallel-data-fetching
33 | */
34 | const promises = Promise.all([
35 | api.post.myPosts.query({ page, perPage }),
36 | api.stripe.getPlan.query(),
37 | ]);
38 |
39 | return (
40 |
41 |
42 |
Posts
43 |
Manage your posts here
44 |
45 |
}>
46 |
47 |
48 |
49 | );
50 | }
51 |
--------------------------------------------------------------------------------
/src/app/(main)/dashboard/settings/page.tsx:
--------------------------------------------------------------------------------
1 | import type { Metadata } from "next";
2 | import { redirect } from "next/navigation";
3 |
4 | import { env } from "@/env";
5 | import { validateRequest } from "@/lib/auth/validate-request";
6 |
7 | export const metadata: Metadata = {
8 | metadataBase: new URL(env.NEXT_PUBLIC_APP_URL),
9 | title: "Billing",
10 | description: "Manage your billing and subscription",
11 | };
12 |
13 | export default async function BillingPage() {
14 | const { user } = await validateRequest();
15 |
16 | if (!user) {
17 | redirect("/signin");
18 | }
19 |
20 | return (
21 |
22 |
23 |
Settings
24 |
Manage your account settings
25 |
26 |
Work in progress...
27 |
28 | );
29 | }
30 |
--------------------------------------------------------------------------------
/src/app/(main)/editor/[postId]/_components/post-editor.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 | import { useRef } from "react";
3 | import { type RouterOutputs } from "@/trpc/shared";
4 | import { useForm } from "react-hook-form";
5 | import { zodResolver } from "@hookform/resolvers/zod";
6 | import {
7 | Form,
8 | FormControl,
9 | FormDescription,
10 | FormField,
11 | FormItem,
12 | FormLabel,
13 | FormMessage,
14 | } from "@/components/ui/form";
15 | import { Input } from "@/components/ui/input";
16 | import { Textarea } from "@/components/ui/textarea";
17 | import { PostPreview } from "./post-preview";
18 | import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
19 | import { api } from "@/trpc/react";
20 | import { Pencil2Icon } from "@/components/icons";
21 | import { LoadingButton } from "@/components/loading-button";
22 | import Link from "next/link";
23 | import { createPostSchema } from "@/server/api/routers/post/post.input";
24 |
25 | const markdownlink = "https://remarkjs.github.io/react-markdown/";
26 |
27 | interface Props {
28 | post: RouterOutputs["post"]["get"];
29 | }
30 |
31 | export const PostEditor = ({ post }: Props) => {
32 | if (!post) return null;
33 | const formRef = useRef(null);
34 | const updatePost = api.post.update.useMutation();
35 | const form = useForm({
36 | defaultValues: {
37 | title: post.title,
38 | excerpt: post.excerpt,
39 | content: post.content,
40 | },
41 | resolver: zodResolver(createPostSchema),
42 | });
43 | const onSubmit = form.handleSubmit(async (values) => {
44 | updatePost.mutate({ id: post.id, ...values });
45 | });
46 |
47 | return (
48 | <>
49 |
50 |
51 |
{post.title}
52 |
53 |
formRef.current?.requestSubmit()}
57 | className="ml-auto"
58 | >
59 | Save
60 |
61 |
62 |
63 |
124 |
125 | >
126 | );
127 | };
128 |
--------------------------------------------------------------------------------
/src/app/(main)/editor/[postId]/_components/post-preview.tsx:
--------------------------------------------------------------------------------
1 | import Markdown, { type Components } from "react-markdown";
2 | import remarkGfm from "remark-gfm";
3 | import rehypeRaw from "rehype-raw";
4 | import { Prism as SyntaxHighlighter } from "react-syntax-highlighter";
5 | import { materialOceanic } from "react-syntax-highlighter/dist/cjs/styles/prism";
6 |
7 | const options: Components = {
8 | code: (props) => (
9 |
15 | {String(props.children)}
16 |
17 | ),
18 | };
19 |
20 | export const PostPreview = ({ text }: { text: string }) => {
21 | return (
22 |
27 | {text}
28 |
29 | );
30 | };
31 |
--------------------------------------------------------------------------------
/src/app/(main)/editor/[postId]/page.tsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import { api } from "@/trpc/server";
3 | import { notFound, redirect } from "next/navigation";
4 | import { PostEditor } from "./_components/post-editor";
5 | import { ArrowLeftIcon } from "@/components/icons";
6 | import Link from "next/link";
7 | import { validateRequest } from "@/lib/auth/validate-request";
8 | import { Paths } from "@/lib/constants";
9 |
10 | interface Props {
11 | params: {
12 | postId: string;
13 | };
14 | }
15 |
16 | export default async function EditPostPage({ params }: Props) {
17 | const { user } = await validateRequest();
18 | if (!user) redirect(Paths.Login);
19 |
20 | const post = await api.post.get.query({ id: params.postId });
21 | if (!post) notFound();
22 |
23 | return (
24 |
25 |
29 | back to dashboard
30 |
31 |
32 |
33 |
34 | );
35 | }
36 |
--------------------------------------------------------------------------------
/src/app/(main)/layout.tsx:
--------------------------------------------------------------------------------
1 | import { type ReactNode } from "react";
2 | import { Header } from "./_components/header";
3 | import { Footer } from "./_components/footer";
4 |
5 | const MainLayout = ({ children }: { children: ReactNode }) => {
6 | return (
7 | <>
8 |
9 | {children}
10 |
11 | >
12 | );
13 | };
14 |
15 | export default MainLayout;
16 |
--------------------------------------------------------------------------------
/src/app/api/trpc/[trpc]/route.ts:
--------------------------------------------------------------------------------
1 | import { fetchRequestHandler } from "@trpc/server/adapters/fetch";
2 | import { type NextRequest } from "next/server";
3 |
4 | import { env } from "@/env";
5 | import { appRouter } from "@/server/api/root";
6 | import { createTRPCContext } from "@/server/api/trpc";
7 |
8 | /**
9 | * This wraps the `createTRPCContext` helper and provides the required context for the tRPC API when
10 | * handling a HTTP request (e.g. when you make requests from Client Components).
11 | */
12 | const createContext = async (req: NextRequest) => {
13 | return createTRPCContext({ headers: req.headers });
14 | };
15 |
16 | const handler = (req: NextRequest) =>
17 | fetchRequestHandler({
18 | endpoint: "/api/trpc",
19 | req,
20 | router: appRouter,
21 | createContext: () => createContext(req),
22 | onError:
23 | env.NODE_ENV === "development"
24 | ? ({ path, error }) => {
25 | console.error(
26 | `❌ tRPC failed on ${path ?? ""}: ${error.message}`,
27 | );
28 | }
29 | : undefined,
30 | });
31 |
32 | export { handler as GET, handler as POST };
33 |
--------------------------------------------------------------------------------
/src/app/api/webhooks/stripe/route.ts:
--------------------------------------------------------------------------------
1 | import { headers } from "next/headers";
2 |
3 | import type Stripe from "stripe";
4 |
5 | import { env } from "@/env";
6 | import { stripe } from "@/lib/stripe";
7 | import { db } from "@/server/db";
8 | import { users } from "@/server/db/schema";
9 | import { eq } from "drizzle-orm";
10 |
11 | export async function POST(req: Request) {
12 | const body = await req.text();
13 | const signature = headers().get("Stripe-Signature") ?? "";
14 |
15 | let event: Stripe.Event;
16 |
17 | try {
18 | event = stripe.webhooks.constructEvent(
19 | body,
20 | signature,
21 | env.STRIPE_WEBHOOK_SECRET,
22 | );
23 | } catch (err) {
24 | return new Response(
25 | `Webhook Error: ${err instanceof Error ? err.message : "Unknown error."}`,
26 | { status: 400 },
27 | );
28 | }
29 |
30 | switch (event.type) {
31 | case "checkout.session.completed": {
32 | const checkoutSessionCompleted = event.data.object;
33 |
34 | const userId = checkoutSessionCompleted?.metadata?.userId;
35 |
36 | if (!userId) {
37 | return new Response("User id not found in checkout session metadata.", {
38 | status: 404,
39 | });
40 | }
41 |
42 | // Retrieve the subscription details from Stripe
43 | const subscription = await stripe.subscriptions.retrieve(
44 | checkoutSessionCompleted.subscription as string,
45 | );
46 |
47 | // Update the user stripe into in our database
48 | // Since this is the initial subscription, we need to update
49 | // the subscription id and customer id
50 | await db
51 | .update(users)
52 | .set({
53 | stripeSubscriptionId: subscription.id,
54 | stripeCustomerId: subscription.customer as string,
55 | stripePriceId: subscription.items.data[0]?.price.id,
56 | stripeCurrentPeriodEnd: new Date(
57 | subscription.current_period_end * 1000,
58 | ),
59 | })
60 | .where(eq(users.id, userId));
61 |
62 | break;
63 | }
64 | case "invoice.payment_succeeded": {
65 | const invoicePaymentSucceeded = event.data.object;
66 |
67 | const userId = invoicePaymentSucceeded?.metadata?.userId;
68 |
69 | if (!userId) {
70 | return new Response("User id not found in invoice metadata.", {
71 | status: 404,
72 | });
73 | }
74 |
75 | // Retrieve the subscription details from Stripe
76 | const subscription = await stripe.subscriptions.retrieve(
77 | invoicePaymentSucceeded.subscription as string,
78 | );
79 |
80 | // Update the price id and set the new period end
81 | await db
82 | .update(users)
83 | .set({
84 | stripePriceId: subscription.items.data[0]?.price.id,
85 | stripeCurrentPeriodEnd: new Date(
86 | subscription.current_period_end * 1000,
87 | ),
88 | })
89 | .where(eq(users.id, userId));
90 |
91 | break;
92 | }
93 | default:
94 | console.warn(`Unhandled event type: ${event.type}`);
95 | }
96 |
97 | return new Response(null, { status: 200 });
98 | }
99 |
--------------------------------------------------------------------------------
/src/app/icon.tsx:
--------------------------------------------------------------------------------
1 | import { ImageResponse } from "next/og";
2 |
3 | // Route segment config
4 | export const runtime = "edge";
5 |
6 | // Image metadata
7 | export const size = {
8 | width: 32,
9 | height: 32,
10 | };
11 | export const contentType = "image/png";
12 |
13 | // Image generation
14 | export default function Icon() {
15 | return new ImageResponse(
16 | (
17 | // ImageResponse JSX element
18 |
40 | ),
41 | // ImageResponse options
42 | {
43 | // For convenience, we can re-use the exported icons size metadata
44 | // config to also set the ImageResponse's width and height.
45 | ...size,
46 | },
47 | );
48 | }
49 |
--------------------------------------------------------------------------------
/src/app/layout.tsx:
--------------------------------------------------------------------------------
1 | import "@/styles/globals.css";
2 |
3 | import { ThemeProvider } from "@/components/theme-provider";
4 | import { Toaster } from "@/components/ui/sonner";
5 | import { APP_TITLE } from "@/lib/constants";
6 | import { fontSans } from "@/lib/fonts";
7 | import { cn } from "@/lib/utils";
8 | import { TRPCReactProvider } from "@/trpc/react";
9 | import type { Metadata, Viewport } from "next";
10 |
11 | export const metadata: Metadata = {
12 | title: {
13 | default: APP_TITLE,
14 | template: `%s | ${APP_TITLE}`,
15 | },
16 | description: "Acme - Simple auth with lucia and trpc",
17 | icons: [{ rel: "icon", url: "/icon.png" }],
18 | };
19 |
20 | export const viewport: Viewport = {
21 | themeColor: [
22 | { media: "(prefers-color-scheme: light)", color: "white" },
23 | { media: "(prefers-color-scheme: dark)", color: "black" },
24 | ],
25 | };
26 |
27 | export default function RootLayout({
28 | children,
29 | }: {
30 | children: React.ReactNode;
31 | }) {
32 | return (
33 |
34 |
40 |
46 | {children}
47 |
48 |
49 |
50 |
51 | );
52 | }
53 |
--------------------------------------------------------------------------------
/src/app/robots.ts:
--------------------------------------------------------------------------------
1 | import { type MetadataRoute } from "next"
2 |
3 | import { absoluteUrl } from "@/lib/utils"
4 |
5 | export default function robots(): MetadataRoute.Robots {
6 | return {
7 | rules: {
8 | userAgent: "*",
9 | allow: "/",
10 | },
11 | sitemap: absoluteUrl("/sitemap.xml"),
12 | }
13 | }
14 |
--------------------------------------------------------------------------------
/src/app/sitemap.ts:
--------------------------------------------------------------------------------
1 | import { type MetadataRoute } from "next";
2 |
3 | import { absoluteUrl } from "@/lib/utils";
4 |
5 | export default async function sitemap(): Promise {
6 | const routes = ["", "/dashboard", "/dashboard/billing"].map((route) => ({
7 | url: absoluteUrl(route),
8 | lastModified: new Date().toISOString(),
9 | }));
10 |
11 | return [...routes];
12 | }
13 |
--------------------------------------------------------------------------------
/src/components/icons.tsx:
--------------------------------------------------------------------------------
1 | import { forwardRef, type SVGProps } from "react";
2 | import { cn } from "@/lib/utils";
3 |
4 | const AnimatedSpinner = forwardRef>(
5 | ({ className, ...props }, ref) => (
6 |
14 |
15 |
16 |
24 |
32 |
40 |
48 |
56 |
57 |
58 |
59 | ),
60 | );
61 | AnimatedSpinner.displayName = "AnimatedSpinner";
62 |
63 | const CreditCard = forwardRef>(
64 | ({ className, ...props }, ref) => (
65 |
77 |
78 |
79 |
80 | ),
81 | );
82 | CreditCard.displayName = "CreditCard";
83 |
84 | export { AnimatedSpinner, CreditCard };
85 |
86 | export {
87 | EyeOpenIcon,
88 | EyeNoneIcon as EyeCloseIcon,
89 | SunIcon,
90 | MoonIcon,
91 | ExclamationTriangleIcon,
92 | ExitIcon,
93 | EnterIcon,
94 | GearIcon,
95 | RocketIcon,
96 | PlusIcon,
97 | HamburgerMenuIcon,
98 | Pencil2Icon,
99 | UpdateIcon,
100 | CheckCircledIcon,
101 | PlayIcon,
102 | TrashIcon,
103 | ArchiveIcon,
104 | ResetIcon,
105 | DiscordLogoIcon,
106 | FileTextIcon,
107 | IdCardIcon,
108 | PlusCircledIcon,
109 | FilePlusIcon,
110 | CheckIcon,
111 | ChevronLeftIcon,
112 | ChevronRightIcon,
113 | DotsHorizontalIcon,
114 | ArrowLeftIcon,
115 | } from "@radix-ui/react-icons";
116 |
--------------------------------------------------------------------------------
/src/components/loading-button.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { forwardRef } from "react";
4 | import { AnimatedSpinner } from "@/components/icons";
5 | import { Button, type ButtonProps } from "@/components/ui/button";
6 |
7 | import { cn } from "@/lib/utils";
8 |
9 | export interface LoadingButtonProps extends ButtonProps {
10 | loading?: boolean;
11 | }
12 |
13 | const LoadingButton = forwardRef(
14 | ({ loading = false, className, children, ...props }, ref) => {
15 | return (
16 |
22 | {children}
23 | {loading ? (
24 |
27 | ) : null}
28 |
29 | );
30 | },
31 | );
32 |
33 | LoadingButton.displayName = "LoadingButton";
34 |
35 | export { LoadingButton };
36 |
--------------------------------------------------------------------------------
/src/components/password-input.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import * as React from "react";
4 | import { EyeOpenIcon, EyeCloseIcon } from "@/components/icons";
5 | import { Button } from "@/components/ui/button";
6 | import { Input, type InputProps } from "@/components/ui/input";
7 |
8 | import { cn } from "@/lib/utils";
9 |
10 | const PasswordInputComponent = React.forwardRef(
11 | ({ className, ...props }, ref) => {
12 | const [showPassword, setShowPassword] = React.useState(false);
13 |
14 | return (
15 |
16 |
22 | setShowPassword((prev) => !prev)}
28 | disabled={props.value === "" || props.disabled}
29 | >
30 | {showPassword ? (
31 |
32 | ) : (
33 |
34 | )}
35 |
36 | {showPassword ? "Hide password" : "Show password"}
37 |
38 |
39 |
40 | );
41 | },
42 | );
43 | PasswordInputComponent.displayName = "PasswordInput";
44 |
45 | export const PasswordInput = PasswordInputComponent;
46 |
--------------------------------------------------------------------------------
/src/components/responsive-dialog.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import {
4 | useState,
5 | type ReactNode,
6 | type Dispatch,
7 | type SetStateAction,
8 | } from "react";
9 | import { Button } from "@/components/ui/button";
10 | import {
11 | Dialog,
12 | DialogTitle,
13 | DialogContent,
14 | DialogDescription,
15 | DialogHeader,
16 | DialogTrigger,
17 | DialogFooter,
18 | } from "@/components/ui/dialog";
19 | import {
20 | Drawer,
21 | DrawerClose,
22 | DrawerContent,
23 | DrawerDescription,
24 | DrawerFooter,
25 | DrawerHeader,
26 | DrawerTitle,
27 | DrawerTrigger,
28 | } from "@/components/ui/drawer";
29 | import { useMediaQuery } from "@/lib/hooks/use-media-query";
30 | import { cn } from "@/lib/utils";
31 |
32 | type StatefulContent = ({
33 | open,
34 | setOpen,
35 | }: {
36 | open: boolean;
37 | setOpen: Dispatch>;
38 | }) => ReactNode | ReactNode[];
39 |
40 | export const ResponsiveDialog = (props: {
41 | trigger: ReactNode;
42 | title?: ReactNode;
43 | description?: ReactNode;
44 | children: ReactNode | ReactNode[] | StatefulContent;
45 | footer?: ReactNode;
46 | contentClassName?: string;
47 | }) => {
48 | const [open, setOpen] = useState(false);
49 | const isDesktop = useMediaQuery("(min-width: 640px)");
50 |
51 | return isDesktop ? (
52 |
53 | {props.trigger}
54 |
55 |
56 | {props.title}
57 | {props.description}
58 |
59 | {isFunctionType(props.children)
60 | ? props.children({ open, setOpen })
61 | : props.children}
62 |
63 | {props.footer ? {props.footer} : null}
64 |
65 | ) : (
66 |
67 | {props.trigger}
68 |
69 |
70 | {props.title}
71 | {props.description}
72 |
73 |
74 | {isFunctionType(props.children)
75 | ? props.children({ open, setOpen })
76 | : props.children}
77 |
78 |
79 | {props.footer ? (
80 | props.footer
81 | ) : (
82 |
83 | Cancel
84 |
85 | )}
86 |
87 |
88 |
89 | );
90 | };
91 |
92 | const isFunctionType = (
93 | prop: ReactNode | ReactNode[] | StatefulContent,
94 | ): prop is ({
95 | open,
96 | setOpen,
97 | }: {
98 | open: boolean;
99 | setOpen: Dispatch>;
100 | }) => ReactNode | ReactNode[] => {
101 | return typeof prop === "function";
102 | };
103 |
--------------------------------------------------------------------------------
/src/components/submit-button.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { forwardRef } from "react";
4 | import { useFormStatus } from "react-dom";
5 | import { LoadingButton } from "@/components/loading-button";
6 | import type { ButtonProps } from "@/components/ui/button";
7 |
8 | const SubmitButton = forwardRef(
9 | ({ className, children, ...props }, ref) => {
10 | const { pending } = useFormStatus();
11 | return (
12 |
18 | {children}
19 |
20 | );
21 | },
22 | );
23 | SubmitButton.displayName = "SubmitButton";
24 |
25 | export { SubmitButton };
26 |
--------------------------------------------------------------------------------
/src/components/theme-provider.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import * as React from "react";
4 | import { ThemeProvider as NextThemesProvider } from "next-themes";
5 | import { type ThemeProviderProps } from "next-themes/dist/types";
6 |
7 | export function ThemeProvider({ children, ...props }: ThemeProviderProps) {
8 | return {children} ;
9 | }
10 |
--------------------------------------------------------------------------------
/src/components/theme-toggle.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { useTheme } from "next-themes";
4 | import { SunIcon, MoonIcon } from "@/components/icons";
5 | import { Button } from "@/components/ui/button";
6 | import {
7 | DropdownMenu,
8 | DropdownMenuContent,
9 | DropdownMenuItem,
10 | DropdownMenuTrigger,
11 | } from "@/components/ui/dropdown-menu";
12 |
13 | export const ThemeToggle = () => {
14 | const { setTheme } = useTheme();
15 |
16 | return (
17 |
18 |
19 |
20 |
21 |
22 | Toggle theme
23 |
24 |
25 |
26 | setTheme("light")}>
27 | Light
28 |
29 | setTheme("dark")}>
30 | Dark
31 |
32 | setTheme("system")}>
33 | System
34 |
35 |
36 |
37 | );
38 | };
39 |
--------------------------------------------------------------------------------
/src/components/ui/alert-dialog.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import * as React from "react"
4 | import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog"
5 |
6 | import { cn } from "@/lib/utils"
7 | import { buttonVariants } from "@/components/ui/button"
8 |
9 | const AlertDialog = AlertDialogPrimitive.Root
10 |
11 | const AlertDialogTrigger = AlertDialogPrimitive.Trigger
12 |
13 | const AlertDialogPortal = AlertDialogPrimitive.Portal
14 |
15 | const AlertDialogOverlay = React.forwardRef<
16 | React.ElementRef,
17 | React.ComponentPropsWithoutRef
18 | >(({ className, ...props }, ref) => (
19 |
27 | ))
28 | AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName
29 |
30 | const AlertDialogContent = React.forwardRef<
31 | React.ElementRef,
32 | React.ComponentPropsWithoutRef
33 | >(({ className, ...props }, ref) => (
34 |
35 |
36 |
44 |
45 | ))
46 | AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName
47 |
48 | const AlertDialogHeader = ({
49 | className,
50 | ...props
51 | }: React.HTMLAttributes) => (
52 |
59 | )
60 | AlertDialogHeader.displayName = "AlertDialogHeader"
61 |
62 | const AlertDialogFooter = ({
63 | className,
64 | ...props
65 | }: React.HTMLAttributes) => (
66 |
73 | )
74 | AlertDialogFooter.displayName = "AlertDialogFooter"
75 |
76 | const AlertDialogTitle = React.forwardRef<
77 | React.ElementRef,
78 | React.ComponentPropsWithoutRef
79 | >(({ className, ...props }, ref) => (
80 |
85 | ))
86 | AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName
87 |
88 | const AlertDialogDescription = React.forwardRef<
89 | React.ElementRef,
90 | React.ComponentPropsWithoutRef
91 | >(({ className, ...props }, ref) => (
92 |
97 | ))
98 | AlertDialogDescription.displayName =
99 | AlertDialogPrimitive.Description.displayName
100 |
101 | const AlertDialogAction = React.forwardRef<
102 | React.ElementRef,
103 | React.ComponentPropsWithoutRef
104 | >(({ className, ...props }, ref) => (
105 |
110 | ))
111 | AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName
112 |
113 | const AlertDialogCancel = React.forwardRef<
114 | React.ElementRef,
115 | React.ComponentPropsWithoutRef
116 | >(({ className, ...props }, ref) => (
117 |
126 | ))
127 | AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName
128 |
129 | export {
130 | AlertDialog,
131 | AlertDialogPortal,
132 | AlertDialogOverlay,
133 | AlertDialogTrigger,
134 | AlertDialogContent,
135 | AlertDialogHeader,
136 | AlertDialogFooter,
137 | AlertDialogTitle,
138 | AlertDialogDescription,
139 | AlertDialogAction,
140 | AlertDialogCancel,
141 | }
142 |
--------------------------------------------------------------------------------
/src/components/ui/alert.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react"
2 | import { cva, type VariantProps } from "class-variance-authority"
3 |
4 | import { cn } from "@/lib/utils"
5 |
6 | const alertVariants = cva(
7 | "relative w-full rounded-lg border p-4 [&>svg~*]:pl-7 [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground",
8 | {
9 | variants: {
10 | variant: {
11 | default: "bg-background text-foreground",
12 | destructive:
13 | "border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive",
14 | },
15 | },
16 | defaultVariants: {
17 | variant: "default",
18 | },
19 | }
20 | )
21 |
22 | const Alert = React.forwardRef<
23 | HTMLDivElement,
24 | React.HTMLAttributes & VariantProps
25 | >(({ className, variant, ...props }, ref) => (
26 |
32 | ))
33 | Alert.displayName = "Alert"
34 |
35 | const AlertTitle = React.forwardRef<
36 | HTMLParagraphElement,
37 | React.HTMLAttributes
38 | >(({ className, ...props }, ref) => (
39 |
44 | ))
45 | AlertTitle.displayName = "AlertTitle"
46 |
47 | const AlertDescription = React.forwardRef<
48 | HTMLParagraphElement,
49 | React.HTMLAttributes
50 | >(({ className, ...props }, ref) => (
51 |
56 | ))
57 | AlertDescription.displayName = "AlertDescription"
58 |
59 | export { Alert, AlertTitle, AlertDescription }
60 |
--------------------------------------------------------------------------------
/src/components/ui/badge.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react"
2 | import { cva, type VariantProps } from "class-variance-authority"
3 |
4 | import { cn } from "@/lib/utils"
5 |
6 | const badgeVariants = cva(
7 | "inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
8 | {
9 | variants: {
10 | variant: {
11 | default:
12 | "border-transparent bg-primary text-primary-foreground hover:bg-primary/80",
13 | secondary:
14 | "border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
15 | destructive:
16 | "border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80",
17 | outline: "text-foreground",
18 | },
19 | },
20 | defaultVariants: {
21 | variant: "default",
22 | },
23 | }
24 | )
25 |
26 | export interface BadgeProps
27 | extends React.HTMLAttributes,
28 | VariantProps {}
29 |
30 | function Badge({ className, variant, ...props }: BadgeProps) {
31 | return (
32 |
33 | )
34 | }
35 |
36 | export { Badge, badgeVariants }
37 |
--------------------------------------------------------------------------------
/src/components/ui/button.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react"
2 | import { Slot } from "@radix-ui/react-slot"
3 | import { cva, type VariantProps } from "class-variance-authority"
4 |
5 | import { cn } from "@/lib/utils"
6 |
7 | const buttonVariants = cva(
8 | "inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50",
9 | {
10 | variants: {
11 | variant: {
12 | default:
13 | "bg-primary text-primary-foreground shadow hover:bg-primary/90",
14 | destructive:
15 | "bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90",
16 | outline:
17 | "border border-input bg-transparent shadow-sm hover:bg-accent hover:text-accent-foreground",
18 | secondary:
19 | "bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80",
20 | ghost: "hover:bg-accent hover:text-accent-foreground",
21 | link: "text-primary underline-offset-4 hover:underline",
22 | },
23 | size: {
24 | default: "h-9 px-4 py-2",
25 | sm: "h-8 rounded-md px-3 text-xs",
26 | lg: "h-10 rounded-md px-8",
27 | icon: "h-9 w-9",
28 | },
29 | },
30 | defaultVariants: {
31 | variant: "default",
32 | size: "default",
33 | },
34 | }
35 | )
36 |
37 | export interface ButtonProps
38 | extends React.ButtonHTMLAttributes,
39 | VariantProps {
40 | asChild?: boolean
41 | }
42 |
43 | const Button = React.forwardRef(
44 | ({ className, variant, size, asChild = false, ...props }, ref) => {
45 | const Comp = asChild ? Slot : "button"
46 | return (
47 |
52 | )
53 | }
54 | )
55 | Button.displayName = "Button"
56 |
57 | export { Button, buttonVariants }
58 |
--------------------------------------------------------------------------------
/src/components/ui/card.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react"
2 |
3 | import { cn } from "@/lib/utils"
4 |
5 | const Card = React.forwardRef<
6 | HTMLDivElement,
7 | React.HTMLAttributes
8 | >(({ className, ...props }, ref) => (
9 |
17 | ))
18 | Card.displayName = "Card"
19 |
20 | const CardHeader = React.forwardRef<
21 | HTMLDivElement,
22 | React.HTMLAttributes
23 | >(({ className, ...props }, ref) => (
24 |
29 | ))
30 | CardHeader.displayName = "CardHeader"
31 |
32 | const CardTitle = React.forwardRef<
33 | HTMLParagraphElement,
34 | React.HTMLAttributes
35 | >(({ className, ...props }, ref) => (
36 |
44 | ))
45 | CardTitle.displayName = "CardTitle"
46 |
47 | const CardDescription = React.forwardRef<
48 | HTMLParagraphElement,
49 | React.HTMLAttributes
50 | >(({ className, ...props }, ref) => (
51 |
56 | ))
57 | CardDescription.displayName = "CardDescription"
58 |
59 | const CardContent = React.forwardRef<
60 | HTMLDivElement,
61 | React.HTMLAttributes
62 | >(({ className, ...props }, ref) => (
63 |
64 | ))
65 | CardContent.displayName = "CardContent"
66 |
67 | const CardFooter = React.forwardRef<
68 | HTMLDivElement,
69 | React.HTMLAttributes
70 | >(({ className, ...props }, ref) => (
71 |
76 | ))
77 | CardFooter.displayName = "CardFooter"
78 |
79 | export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }
80 |
--------------------------------------------------------------------------------
/src/components/ui/dialog.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import * as React from "react"
4 | import * as DialogPrimitive from "@radix-ui/react-dialog"
5 | import { Cross1Icon } from "@radix-ui/react-icons";
6 |
7 | import { cn } from "@/lib/utils"
8 |
9 | const Dialog = DialogPrimitive.Root
10 |
11 | const DialogTrigger = DialogPrimitive.Trigger
12 |
13 | const DialogPortal = DialogPrimitive.Portal
14 |
15 | const DialogClose = DialogPrimitive.Close
16 |
17 | const DialogOverlay = React.forwardRef<
18 | React.ElementRef,
19 | React.ComponentPropsWithoutRef
20 | >(({ className, ...props }, ref) => (
21 |
29 | ))
30 | DialogOverlay.displayName = DialogPrimitive.Overlay.displayName
31 |
32 | const DialogContent = React.forwardRef<
33 | React.ElementRef,
34 | React.ComponentPropsWithoutRef
35 | >(({ className, children, ...props }, ref) => (
36 |
37 |
38 |
46 | {children}
47 |
48 |
49 | Close
50 |
51 |
52 |
53 | ));
54 | DialogContent.displayName = DialogPrimitive.Content.displayName
55 |
56 | const DialogHeader = ({
57 | className,
58 | ...props
59 | }: React.HTMLAttributes) => (
60 |
67 | )
68 | DialogHeader.displayName = "DialogHeader"
69 |
70 | const DialogFooter = ({
71 | className,
72 | ...props
73 | }: React.HTMLAttributes) => (
74 |
81 | )
82 | DialogFooter.displayName = "DialogFooter"
83 |
84 | const DialogTitle = React.forwardRef<
85 | React.ElementRef,
86 | React.ComponentPropsWithoutRef
87 | >(({ className, ...props }, ref) => (
88 |
96 | ))
97 | DialogTitle.displayName = DialogPrimitive.Title.displayName
98 |
99 | const DialogDescription = React.forwardRef<
100 | React.ElementRef,
101 | React.ComponentPropsWithoutRef
102 | >(({ className, ...props }, ref) => (
103 |
108 | ))
109 | DialogDescription.displayName = DialogPrimitive.Description.displayName
110 |
111 | export {
112 | Dialog,
113 | DialogPortal,
114 | DialogOverlay,
115 | DialogClose,
116 | DialogTrigger,
117 | DialogContent,
118 | DialogHeader,
119 | DialogFooter,
120 | DialogTitle,
121 | DialogDescription,
122 | }
123 |
--------------------------------------------------------------------------------
/src/components/ui/drawer.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import * as React from "react"
4 | import { Drawer as DrawerPrimitive } from "vaul"
5 |
6 | import { cn } from "@/lib/utils"
7 |
8 | const Drawer = ({
9 | shouldScaleBackground = true,
10 | ...props
11 | }: React.ComponentProps) => (
12 |
16 | )
17 | Drawer.displayName = "Drawer"
18 |
19 | const DrawerTrigger = DrawerPrimitive.Trigger
20 |
21 | const DrawerPortal = DrawerPrimitive.Portal
22 |
23 | const DrawerClose = DrawerPrimitive.Close
24 |
25 | const DrawerOverlay = React.forwardRef<
26 | React.ElementRef,
27 | React.ComponentPropsWithoutRef
28 | >(({ className, ...props }, ref) => (
29 |
34 | ))
35 | DrawerOverlay.displayName = DrawerPrimitive.Overlay.displayName
36 |
37 | const DrawerContent = React.forwardRef<
38 | React.ElementRef,
39 | React.ComponentPropsWithoutRef
40 | >(({ className, children, ...props }, ref) => (
41 |
42 |
43 |
51 |
52 | {children}
53 |
54 |
55 | ))
56 | DrawerContent.displayName = "DrawerContent"
57 |
58 | const DrawerHeader = ({
59 | className,
60 | ...props
61 | }: React.HTMLAttributes) => (
62 |
66 | )
67 | DrawerHeader.displayName = "DrawerHeader"
68 |
69 | const DrawerFooter = ({
70 | className,
71 | ...props
72 | }: React.HTMLAttributes) => (
73 |
77 | )
78 | DrawerFooter.displayName = "DrawerFooter"
79 |
80 | const DrawerTitle = React.forwardRef<
81 | React.ElementRef,
82 | React.ComponentPropsWithoutRef
83 | >(({ className, ...props }, ref) => (
84 |
92 | ))
93 | DrawerTitle.displayName = DrawerPrimitive.Title.displayName
94 |
95 | const DrawerDescription = React.forwardRef<
96 | React.ElementRef,
97 | React.ComponentPropsWithoutRef
98 | >(({ className, ...props }, ref) => (
99 |
104 | ))
105 | DrawerDescription.displayName = DrawerPrimitive.Description.displayName
106 |
107 | export {
108 | Drawer,
109 | DrawerPortal,
110 | DrawerOverlay,
111 | DrawerTrigger,
112 | DrawerClose,
113 | DrawerContent,
114 | DrawerHeader,
115 | DrawerFooter,
116 | DrawerTitle,
117 | DrawerDescription,
118 | }
119 |
--------------------------------------------------------------------------------
/src/components/ui/dropdown-menu.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import * as React from "react"
4 | import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"
5 | import {
6 | CheckIcon,
7 | ChevronRightIcon,
8 | DotFilledIcon,
9 | } from "@radix-ui/react-icons"
10 |
11 | import { cn } from "@/lib/utils"
12 |
13 | const DropdownMenu = DropdownMenuPrimitive.Root
14 |
15 | const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger
16 |
17 | const DropdownMenuGroup = DropdownMenuPrimitive.Group
18 |
19 | const DropdownMenuPortal = DropdownMenuPrimitive.Portal
20 |
21 | const DropdownMenuSub = DropdownMenuPrimitive.Sub
22 |
23 | const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup
24 |
25 | const DropdownMenuSubTrigger = React.forwardRef<
26 | React.ElementRef,
27 | React.ComponentPropsWithoutRef & {
28 | inset?: boolean
29 | }
30 | >(({ className, inset, children, ...props }, ref) => (
31 |
40 | {children}
41 |
42 |
43 | ))
44 | DropdownMenuSubTrigger.displayName =
45 | DropdownMenuPrimitive.SubTrigger.displayName
46 |
47 | const DropdownMenuSubContent = React.forwardRef<
48 | React.ElementRef,
49 | React.ComponentPropsWithoutRef
50 | >(({ className, ...props }, ref) => (
51 |
59 | ))
60 | DropdownMenuSubContent.displayName =
61 | DropdownMenuPrimitive.SubContent.displayName
62 |
63 | const DropdownMenuContent = React.forwardRef<
64 | React.ElementRef,
65 | React.ComponentPropsWithoutRef
66 | >(({ className, sideOffset = 4, ...props }, ref) => (
67 |
68 |
78 |
79 | ))
80 | DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName
81 |
82 | const DropdownMenuItem = React.forwardRef<
83 | React.ElementRef,
84 | React.ComponentPropsWithoutRef & {
85 | inset?: boolean
86 | }
87 | >(({ className, inset, ...props }, ref) => (
88 |
97 | ))
98 | DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName
99 |
100 | const DropdownMenuCheckboxItem = React.forwardRef<
101 | React.ElementRef,
102 | React.ComponentPropsWithoutRef
103 | >(({ className, children, checked, ...props }, ref) => (
104 |
113 |
114 |
115 |
116 |
117 |
118 | {children}
119 |
120 | ))
121 | DropdownMenuCheckboxItem.displayName =
122 | DropdownMenuPrimitive.CheckboxItem.displayName
123 |
124 | const DropdownMenuRadioItem = React.forwardRef<
125 | React.ElementRef,
126 | React.ComponentPropsWithoutRef
127 | >(({ className, children, ...props }, ref) => (
128 |
136 |
137 |
138 |
139 |
140 |
141 | {children}
142 |
143 | ))
144 | DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName
145 |
146 | const DropdownMenuLabel = React.forwardRef<
147 | React.ElementRef,
148 | React.ComponentPropsWithoutRef & {
149 | inset?: boolean
150 | }
151 | >(({ className, inset, ...props }, ref) => (
152 |
161 | ))
162 | DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName
163 |
164 | const DropdownMenuSeparator = React.forwardRef<
165 | React.ElementRef,
166 | React.ComponentPropsWithoutRef
167 | >(({ className, ...props }, ref) => (
168 |
173 | ))
174 | DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName
175 |
176 | const DropdownMenuShortcut = ({
177 | className,
178 | ...props
179 | }: React.HTMLAttributes) => {
180 | return (
181 |
185 | )
186 | }
187 | DropdownMenuShortcut.displayName = "DropdownMenuShortcut"
188 |
189 | export {
190 | DropdownMenu,
191 | DropdownMenuTrigger,
192 | DropdownMenuContent,
193 | DropdownMenuItem,
194 | DropdownMenuCheckboxItem,
195 | DropdownMenuRadioItem,
196 | DropdownMenuLabel,
197 | DropdownMenuSeparator,
198 | DropdownMenuShortcut,
199 | DropdownMenuGroup,
200 | DropdownMenuPortal,
201 | DropdownMenuSub,
202 | DropdownMenuSubContent,
203 | DropdownMenuSubTrigger,
204 | DropdownMenuRadioGroup,
205 | }
206 |
--------------------------------------------------------------------------------
/src/components/ui/form.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 | import type * as LabelPrimitive from "@radix-ui/react-label";
3 | import { Slot } from "@radix-ui/react-slot";
4 | import {
5 | Controller,
6 | type ControllerProps,
7 | type FieldPath,
8 | type FieldValues,
9 | FormProvider,
10 | useFormContext,
11 | } from "react-hook-form";
12 |
13 | import { cn } from "@/lib/utils";
14 | import { Label } from "@/components/ui/label";
15 |
16 | const Form = FormProvider;
17 |
18 | type FormFieldContextValue<
19 | TFieldValues extends FieldValues = FieldValues,
20 | TName extends FieldPath = FieldPath,
21 | > = {
22 | name: TName;
23 | };
24 |
25 | const FormFieldContext = React.createContext(
26 | {} as FormFieldContextValue,
27 | );
28 |
29 | const FormField = <
30 | TFieldValues extends FieldValues = FieldValues,
31 | TName extends FieldPath = FieldPath,
32 | >({
33 | ...props
34 | }: ControllerProps) => {
35 | return (
36 |
37 |
38 |
39 | );
40 | };
41 |
42 | const useFormField = () => {
43 | const fieldContext = React.useContext(FormFieldContext);
44 | const itemContext = React.useContext(FormItemContext);
45 | const { getFieldState, formState } = useFormContext();
46 |
47 | const fieldState = getFieldState(fieldContext.name, formState);
48 |
49 | if (!fieldContext) {
50 | throw new Error("useFormField should be used within ");
51 | }
52 |
53 | const { id } = itemContext;
54 |
55 | return {
56 | id,
57 | name: fieldContext.name,
58 | formItemId: `${id}-form-item`,
59 | formDescriptionId: `${id}-form-item-description`,
60 | formMessageId: `${id}-form-item-message`,
61 | ...fieldState,
62 | };
63 | };
64 |
65 | type FormItemContextValue = {
66 | id: string;
67 | };
68 |
69 | const FormItemContext = React.createContext(
70 | {} as FormItemContextValue,
71 | );
72 |
73 | const FormItem = React.forwardRef<
74 | HTMLDivElement,
75 | React.HTMLAttributes
76 | >(({ className, ...props }, ref) => {
77 | const id = React.useId();
78 |
79 | return (
80 |
81 |
82 |
83 | );
84 | });
85 | FormItem.displayName = "FormItem";
86 |
87 | const FormLabel = React.forwardRef<
88 | React.ElementRef,
89 | React.ComponentPropsWithoutRef
90 | >(({ className, ...props }, ref) => {
91 | const { error, formItemId } = useFormField();
92 |
93 | return (
94 |
100 | );
101 | });
102 | FormLabel.displayName = "FormLabel";
103 |
104 | const FormControl = React.forwardRef<
105 | React.ElementRef,
106 | React.ComponentPropsWithoutRef
107 | >(({ ...props }, ref) => {
108 | const { error, formItemId, formDescriptionId, formMessageId } =
109 | useFormField();
110 |
111 | return (
112 |
123 | );
124 | });
125 | FormControl.displayName = "FormControl";
126 |
127 | const FormDescription = React.forwardRef<
128 | HTMLParagraphElement,
129 | React.HTMLAttributes
130 | >(({ className, ...props }, ref) => {
131 | const { formDescriptionId } = useFormField();
132 |
133 | return (
134 |
140 | );
141 | });
142 | FormDescription.displayName = "FormDescription";
143 |
144 | const FormMessage = React.forwardRef<
145 | HTMLParagraphElement,
146 | React.HTMLAttributes
147 | >(({ className, children, ...props }, ref) => {
148 | const { error, formMessageId } = useFormField();
149 | const body = error ? String(error?.message) : children;
150 |
151 | if (!body) {
152 | return null;
153 | }
154 |
155 | return (
156 |
162 | {body}
163 |
164 | );
165 | });
166 | FormMessage.displayName = "FormMessage";
167 |
168 | export {
169 | useFormField,
170 | Form,
171 | FormItem,
172 | FormLabel,
173 | FormControl,
174 | FormDescription,
175 | FormMessage,
176 | FormField,
177 | };
178 |
--------------------------------------------------------------------------------
/src/components/ui/input.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react"
2 |
3 | import { cn } from "@/lib/utils"
4 |
5 | export interface InputProps
6 | extends React.InputHTMLAttributes {}
7 |
8 | const Input = React.forwardRef(
9 | ({ className, type, ...props }, ref) => {
10 | return (
11 |
20 | )
21 | }
22 | )
23 | Input.displayName = "Input"
24 |
25 | export { Input }
26 |
--------------------------------------------------------------------------------
/src/components/ui/label.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import * as React from "react"
4 | import * as LabelPrimitive from "@radix-ui/react-label"
5 | import { cva, type VariantProps } from "class-variance-authority"
6 |
7 | import { cn } from "@/lib/utils"
8 |
9 | const labelVariants = cva(
10 | "text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
11 | )
12 |
13 | const Label = React.forwardRef<
14 | React.ElementRef,
15 | React.ComponentPropsWithoutRef &
16 | VariantProps
17 | >(({ className, ...props }, ref) => (
18 |
23 | ))
24 | Label.displayName = LabelPrimitive.Root.displayName
25 |
26 | export { Label }
27 |
--------------------------------------------------------------------------------
/src/components/ui/pagination.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 | import Link from "next/link";
3 | import {
4 | ChevronLeftIcon,
5 | ChevronRightIcon,
6 | DotsHorizontalIcon,
7 | } from "@/components/icons";
8 |
9 | import { cn } from "@/lib/utils";
10 | import { buttonVariants, type ButtonProps } from "@/components/ui/button";
11 |
12 | const Pagination = ({ className, ...props }: React.ComponentProps<"nav">) => (
13 |
19 | );
20 | Pagination.displayName = "Pagination";
21 |
22 | const PaginationContent = React.forwardRef<
23 | HTMLUListElement,
24 | React.ComponentProps<"ul">
25 | >(({ className, ...props }, ref) => (
26 |
31 | ));
32 | PaginationContent.displayName = "PaginationContent";
33 |
34 | const PaginationItem = React.forwardRef<
35 | HTMLLIElement,
36 | React.ComponentProps<"li">
37 | >(({ className, ...props }, ref) => (
38 |
39 | ));
40 | PaginationItem.displayName = "PaginationItem";
41 |
42 | type PaginationLinkProps = {
43 | isActive?: boolean;
44 | } & Pick &
45 | React.ComponentProps;
46 |
47 | const PaginationLink = ({
48 | className,
49 | isActive,
50 | size = "icon",
51 | children,
52 | ...props
53 | }: PaginationLinkProps) => (
54 |
65 | {children}
66 |
67 | );
68 | PaginationLink.displayName = "PaginationLink";
69 |
70 | const PaginationPrevious = ({
71 | className,
72 | ...props
73 | }: React.ComponentProps) => (
74 |
80 |
81 | Previous
82 |
83 | );
84 | PaginationPrevious.displayName = "PaginationPrevious";
85 |
86 | const PaginationNext = ({
87 | className,
88 | ...props
89 | }: React.ComponentProps) => (
90 |
96 | Next
97 |
98 |
99 | );
100 | PaginationNext.displayName = "PaginationNext";
101 |
102 | const PaginationEllipsis = ({
103 | className,
104 | ...props
105 | }: React.ComponentProps<"span">) => (
106 |
111 |
112 | More pages
113 |
114 | );
115 | PaginationEllipsis.displayName = "PaginationEllipsis";
116 |
117 | export {
118 | Pagination,
119 | PaginationContent,
120 | PaginationEllipsis,
121 | PaginationItem,
122 | PaginationLink,
123 | PaginationNext,
124 | PaginationPrevious,
125 | };
126 |
--------------------------------------------------------------------------------
/src/components/ui/skeleton.tsx:
--------------------------------------------------------------------------------
1 | import { cn } from "@/lib/utils"
2 |
3 | function Skeleton({
4 | className,
5 | ...props
6 | }: React.HTMLAttributes) {
7 | return (
8 |
12 | )
13 | }
14 |
15 | export { Skeleton }
16 |
--------------------------------------------------------------------------------
/src/components/ui/sonner.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import { useTheme } from "next-themes"
4 | import { Toaster as Sonner } from "sonner"
5 |
6 | type ToasterProps = React.ComponentProps
7 |
8 | const Toaster = ({ ...props }: ToasterProps) => {
9 | const { theme = "system" } = useTheme()
10 |
11 | return (
12 |
28 | )
29 | }
30 |
31 | export { Toaster }
32 |
--------------------------------------------------------------------------------
/src/components/ui/tabs.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import * as React from "react"
4 | import * as TabsPrimitive from "@radix-ui/react-tabs"
5 |
6 | import { cn } from "@/lib/utils"
7 |
8 | const Tabs = TabsPrimitive.Root
9 |
10 | const TabsList = React.forwardRef<
11 | React.ElementRef,
12 | React.ComponentPropsWithoutRef
13 | >(({ className, ...props }, ref) => (
14 |
22 | ))
23 | TabsList.displayName = TabsPrimitive.List.displayName
24 |
25 | const TabsTrigger = React.forwardRef<
26 | React.ElementRef,
27 | React.ComponentPropsWithoutRef
28 | >(({ className, ...props }, ref) => (
29 |
37 | ))
38 | TabsTrigger.displayName = TabsPrimitive.Trigger.displayName
39 |
40 | const TabsContent = React.forwardRef<
41 | React.ElementRef,
42 | React.ComponentPropsWithoutRef
43 | >(({ className, ...props }, ref) => (
44 |
52 | ))
53 | TabsContent.displayName = TabsPrimitive.Content.displayName
54 |
55 | export { Tabs, TabsList, TabsTrigger, TabsContent }
56 |
--------------------------------------------------------------------------------
/src/components/ui/textarea.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react"
2 |
3 | import { cn } from "@/lib/utils"
4 |
5 | export interface TextareaProps
6 | extends React.TextareaHTMLAttributes {}
7 |
8 | const Textarea = React.forwardRef(
9 | ({ className, ...props }, ref) => {
10 | return (
11 |
19 | )
20 | }
21 | )
22 | Textarea.displayName = "Textarea"
23 |
24 | export { Textarea }
25 |
--------------------------------------------------------------------------------
/src/config/subscriptions.ts:
--------------------------------------------------------------------------------
1 | import { env } from "@/env";
2 |
3 | export interface SubscriptionPlan {
4 | name: string;
5 | description: string;
6 | features: string[];
7 | stripePriceId: string;
8 | }
9 |
10 | export const freePlan: SubscriptionPlan = {
11 | name: "Free",
12 | description: "The free plan is limited to 3 posts.",
13 | features: ["Up to 3 posts", "Limited support"],
14 | stripePriceId: "",
15 | };
16 |
17 | export const proPlan: SubscriptionPlan = {
18 | name: "Pro",
19 | description: "The Pro plan has unlimited posts.",
20 | features: ["Unlimited posts", "Priority support"],
21 | stripePriceId: env.STRIPE_PRO_MONTHLY_PLAN_ID,
22 | };
23 |
24 | export const subscriptionPlans = [freePlan, proPlan];
25 |
--------------------------------------------------------------------------------
/src/env.js:
--------------------------------------------------------------------------------
1 | import { createEnv } from "@t3-oss/env-nextjs";
2 | import { z } from "zod";
3 |
4 | export const env = createEnv({
5 | /**
6 | * Specify your server-side environment variables schema here. This way you can ensure the app
7 | * isn't built with invalid env vars.
8 | */
9 | server: {
10 | DATABASE_URL: z
11 | .string()
12 | .url()
13 | .refine(
14 | (str) => !str.includes("YOUR_DATABASE_URL_HERE"),
15 | "You forgot to change the default URL",
16 | ),
17 | NODE_ENV: z.enum(["development", "test", "production"]).default("development"),
18 | MOCK_SEND_EMAIL: z.boolean().default(false),
19 | DISCORD_CLIENT_ID: z.string().trim().min(1),
20 | DISCORD_CLIENT_SECRET: z.string().trim().min(1),
21 | SMTP_HOST: z.string().trim().min(1),
22 | SMTP_PORT: z.number().int().min(1),
23 | SMTP_USER: z.string().trim().min(1),
24 | SMTP_PASSWORD: z.string().trim().min(1),
25 | STRIPE_API_KEY: z.string().trim().min(1),
26 | STRIPE_WEBHOOK_SECRET: z.string().trim().min(1),
27 | STRIPE_PRO_MONTHLY_PLAN_ID: z.string().trim().min(1),
28 | },
29 |
30 | /**
31 | * Specify your client-side environment variables schema here. This way you can ensure the app
32 | * isn't built with invalid env vars. To expose them to the client, prefix them with
33 | * `NEXT_PUBLIC_`.
34 | */
35 | client: {
36 | // NEXT_PUBLIC_CLIENTVAR: z.string(),
37 | NEXT_PUBLIC_APP_URL: z.string().url(),
38 | },
39 |
40 | /**
41 | * You can't destruct `process.env` as a regular object in the Next.js edge runtimes (e.g.
42 | * middlewares) or client-side so we need to destruct manually.
43 | */
44 | runtimeEnv: {
45 | // Server-side env vars
46 | DATABASE_URL: process.env.DATABASE_URL,
47 | NODE_ENV: process.env.NODE_ENV,
48 | SMTP_HOST: process.env.SMTP_HOST,
49 | SMTP_PORT: parseInt(process.env.SMTP_PORT ?? ""),
50 | SMTP_USER: process.env.SMTP_USER,
51 | SMTP_PASSWORD: process.env.SMTP_PASSWORD,
52 | MOCK_SEND_EMAIL: process.env.MOCK_SEND_EMAIL === "true" || process.env.MOCK_SEND_EMAIL === "1",
53 | DISCORD_CLIENT_ID: process.env.DISCORD_CLIENT_ID,
54 | DISCORD_CLIENT_SECRET: process.env.DISCORD_CLIENT_SECRET,
55 | STRIPE_API_KEY: process.env.STRIPE_API_KEY,
56 | STRIPE_WEBHOOK_SECRET: process.env.STRIPE_WEBHOOK_SECRET,
57 | STRIPE_PRO_MONTHLY_PLAN_ID: process.env.STRIPE_PRO_MONTHLY_PLAN_ID,
58 | // Client-side env vars
59 | NEXT_PUBLIC_APP_URL: process.env.NEXT_PUBLIC_APP_URL,
60 | },
61 | /**
62 | * Run `build` or `dev` with `SKIP_ENV_VALIDATION` to skip env validation. This is especially
63 | * useful for Docker builds.
64 | */
65 | skipValidation: !!process.env.SKIP_ENV_VALIDATION,
66 | /**
67 | * Makes it so that empty strings are treated as undefined.
68 | * `SOME_VAR: z.string()` and `SOME_VAR=''` will throw an error.
69 | */
70 | emptyStringAsUndefined: true,
71 | });
72 |
--------------------------------------------------------------------------------
/src/lib/auth/index.ts:
--------------------------------------------------------------------------------
1 | import { Lucia, TimeSpan } from "lucia";
2 | import { Discord } from "arctic";
3 | import { DrizzlePostgreSQLAdapter } from "@lucia-auth/adapter-drizzle";
4 | import { env } from "@/env.js";
5 | import { db } from "@/server/db";
6 | import { sessions, users, type User as DbUser } from "@/server/db/schema";
7 | import { absoluteUrl } from "@/lib/utils"
8 |
9 | // Uncomment the following lines if you are using nodejs 18 or lower. Not required in Node.js 20, CloudFlare Workers, Deno, Bun, and Vercel Edge Functions.
10 | // import { webcrypto } from "node:crypto";
11 | // globalThis.crypto = webcrypto as Crypto;
12 |
13 | const adapter = new DrizzlePostgreSQLAdapter(db, sessions, users);
14 |
15 | export const lucia = new Lucia(adapter, {
16 | getSessionAttributes: (/* attributes */) => {
17 | return {};
18 | },
19 | getUserAttributes: (attributes) => {
20 | return {
21 | id: attributes.id,
22 | email: attributes.email,
23 | emailVerified: attributes.emailVerified,
24 | avatar: attributes.avatar,
25 | createdAt: attributes.createdAt,
26 | updatedAt: attributes.updatedAt,
27 | };
28 | },
29 | sessionExpiresIn: new TimeSpan(30, "d"),
30 | sessionCookie: {
31 | name: "session",
32 |
33 | expires: false, // session cookies have very long lifespan (2 years)
34 | attributes: {
35 | secure: env.NODE_ENV === "production",
36 | },
37 | },
38 | });
39 |
40 | export const discord = new Discord(
41 | env.DISCORD_CLIENT_ID,
42 | env.DISCORD_CLIENT_SECRET,
43 | absoluteUrl("/login/discord/callback")
44 | );
45 |
46 | declare module "lucia" {
47 | interface Register {
48 | Lucia: typeof lucia;
49 | DatabaseSessionAttributes: DatabaseSessionAttributes;
50 | DatabaseUserAttributes: DatabaseUserAttributes;
51 | }
52 | }
53 |
54 | interface DatabaseSessionAttributes {}
55 | interface DatabaseUserAttributes extends Omit {}
56 |
--------------------------------------------------------------------------------
/src/lib/auth/validate-request.ts:
--------------------------------------------------------------------------------
1 | import { cache } from "react";
2 | import { cookies } from "next/headers";
3 | import type { Session, User } from "lucia";
4 | import { lucia } from "@/lib/auth";
5 |
6 |
7 | export const uncachedValidateRequest = async (): Promise<
8 | { user: User; session: Session } | { user: null; session: null }
9 | > => {
10 | const sessionId = cookies().get(lucia.sessionCookieName)?.value ?? null;
11 | if (!sessionId) {
12 | return { user: null, session: null };
13 | }
14 | const result = await lucia.validateSession(sessionId);
15 | // next.js throws when you attempt to set cookie when rendering page
16 | try {
17 | if (result.session && result.session.fresh) {
18 | const sessionCookie = lucia.createSessionCookie(result.session.id);
19 | cookies().set(
20 | sessionCookie.name,
21 | sessionCookie.value,
22 | sessionCookie.attributes,
23 | );
24 | }
25 | if (!result.session) {
26 | const sessionCookie = lucia.createBlankSessionCookie();
27 | cookies().set(
28 | sessionCookie.name,
29 | sessionCookie.value,
30 | sessionCookie.attributes,
31 | );
32 | }
33 | } catch {
34 | console.error("Failed to set session cookie");
35 | }
36 | return result;
37 | };
38 |
39 | export const validateRequest = cache(uncachedValidateRequest);
40 |
--------------------------------------------------------------------------------
/src/lib/constants.ts:
--------------------------------------------------------------------------------
1 | export const APP_TITLE = "Acme";
2 | export const DATABASE_PREFIX = "acme";
3 | export const TEST_DB_PREFIX = "test_acme";
4 | export const EMAIL_SENDER = '"Acme" ';
5 |
6 | export enum Paths {
7 | Home = "/",
8 | Login = "/login",
9 | Signup = "/signup",
10 | Dashboard = "/dashboard",
11 | VerifyEmail = "/verify-email",
12 | ResetPassword = "/reset-password",
13 | }
14 |
--------------------------------------------------------------------------------
/src/lib/email/index.tsx:
--------------------------------------------------------------------------------
1 | import "server-only";
2 |
3 | import { EmailVerificationTemplate } from "./templates/email-verification";
4 | import { ResetPasswordTemplate } from "./templates/reset-password";
5 | import { render } from "@react-email/render";
6 | import { env } from "@/env";
7 | import { EMAIL_SENDER } from "@/lib/constants";
8 | import { createTransport, type TransportOptions } from "nodemailer";
9 | import type { ComponentProps } from "react";
10 | import { logger } from "../logger";
11 |
12 | export enum EmailTemplate {
13 | EmailVerification = "EmailVerification",
14 | PasswordReset = "PasswordReset",
15 | }
16 |
17 | export type PropsMap = {
18 | [EmailTemplate.EmailVerification]: ComponentProps;
19 | [EmailTemplate.PasswordReset]: ComponentProps;
20 | };
21 |
22 | const getEmailTemplate = (template: T, props: PropsMap[NoInfer]) => {
23 | switch (template) {
24 | case EmailTemplate.EmailVerification:
25 | return {
26 | subject: "Verify your email address",
27 | body: render(
28 | ,
29 | ),
30 | };
31 | case EmailTemplate.PasswordReset:
32 | return {
33 | subject: "Reset your password",
34 | body: render(
35 | ,
36 | ),
37 | };
38 | default:
39 | throw new Error("Invalid email template");
40 | }
41 | };
42 |
43 | const smtpConfig = {
44 | host: env.SMTP_HOST,
45 | port: env.SMTP_PORT,
46 | auth: {
47 | user: env.SMTP_USER,
48 | pass: env.SMTP_PASSWORD,
49 | },
50 | };
51 |
52 | const transporter = createTransport(smtpConfig as TransportOptions);
53 |
54 | export const sendMail = async (
55 | to: string,
56 | template: T,
57 | props: PropsMap[NoInfer],
58 | ) => {
59 | if (env.MOCK_SEND_EMAIL) {
60 | logger.info("📨 Email sent to:", to, "with template:", template, "and props:", props);
61 | return;
62 | }
63 |
64 | const { subject, body } = getEmailTemplate(template, props);
65 |
66 | return transporter.sendMail({ from: EMAIL_SENDER, to, subject, html: body });
67 | };
68 |
--------------------------------------------------------------------------------
/src/lib/email/templates/email-verification.tsx:
--------------------------------------------------------------------------------
1 | import { Body, Container, Head, Html, Preview, Section, Text } from "@react-email/components";
2 | import { APP_TITLE } from "@/lib/constants";
3 |
4 | export interface EmailVerificationTemplateProps {
5 | code: string;
6 | }
7 |
8 | export const EmailVerificationTemplate = ({ code }: EmailVerificationTemplateProps) => {
9 | return (
10 |
11 |
12 | Verify your email address to complete your {APP_TITLE} registration
13 |
14 |
15 |
16 | {APP_TITLE}
17 | Hi,
18 |
19 | Thank you for registering for an account on {APP_TITLE}. To complete your
20 | registration, please verify your your account by using the following code:
21 |
22 | {code}
23 |
24 | Have a nice day!
25 |
26 |
27 |
28 |
29 | );
30 | };
31 |
32 | const main = {
33 | backgroundColor: "#f6f9fc",
34 | padding: "10px 0",
35 | };
36 |
37 | const container = {
38 | backgroundColor: "#ffffff",
39 | border: "1px solid #f0f0f0",
40 | padding: "45px",
41 | };
42 |
43 | const text = {
44 | fontSize: "16px",
45 | fontFamily:
46 | "'Open Sans', 'HelveticaNeue-Light', 'Helvetica Neue Light', 'Helvetica Neue', Helvetica, Arial, 'Lucida Grande', sans-serif",
47 | fontWeight: "300",
48 | color: "#404040",
49 | lineHeight: "26px",
50 | };
51 |
52 | const title = {
53 | ...text,
54 | fontSize: "22px",
55 | fontWeight: "700",
56 | lineHeight: "32px",
57 | };
58 |
59 | const codePlaceholder = {
60 | backgroundColor: "#fbfbfb",
61 | border: "1px solid #f0f0f0",
62 | borderRadius: "4px",
63 | color: "#1c1c1c",
64 | fontFamily: "'Open Sans', 'Helvetica Neue', Arial",
65 | fontSize: "15px",
66 | textDecoration: "none",
67 | textAlign: "center" as const,
68 | display: "block",
69 | width: "210px",
70 | padding: "14px 7px",
71 | };
72 |
73 | // const anchor = {
74 | // textDecoration: "underline",
75 | // };
76 |
--------------------------------------------------------------------------------
/src/lib/email/templates/reset-password.tsx:
--------------------------------------------------------------------------------
1 | import { render } from "@react-email/render";
2 | import {
3 | Body,
4 | Button,
5 | Container,
6 | Head,
7 | Html,
8 | Preview,
9 | Section,
10 | Text,
11 | } from "@react-email/components";
12 | import { APP_TITLE } from "@/lib/constants";
13 |
14 | export interface ResetPasswordTemplateProps {
15 | link: string;
16 | }
17 |
18 | export const ResetPasswordTemplate = ({ link }: ResetPasswordTemplateProps) => {
19 | return (
20 |
21 |
22 | Reset your password
23 |
24 |
25 |
26 | {APP_TITLE}
27 | Hi,
28 |
29 | Someone recently requested a password change for your {APP_TITLE} account. If this was
30 | you, you can set a new password here:
31 |
32 |
33 | Reset password
34 |
35 |
36 | If you don't want to change your password or didn't request this, just
37 | ignore and delete this message.
38 |
39 |
40 | To keep your account secure, please don't forward this email to anyone.
41 |
42 | Have a nice day!
43 |
44 |
45 |
46 |
47 | );
48 | };
49 |
50 | const main = {
51 | backgroundColor: "#f6f9fc",
52 | padding: "10px 0",
53 | };
54 |
55 | const container = {
56 | backgroundColor: "#ffffff",
57 | border: "1px solid #f0f0f0",
58 | padding: "45px",
59 | };
60 |
61 | const text = {
62 | fontSize: "16px",
63 | fontFamily:
64 | "'Open Sans', 'HelveticaNeue-Light', 'Helvetica Neue Light', 'Helvetica Neue', Helvetica, Arial, 'Lucida Grande', sans-serif",
65 | fontWeight: "300",
66 | color: "#404040",
67 | lineHeight: "26px",
68 | };
69 |
70 | const title = {
71 | ...text,
72 | fontSize: "22px",
73 | fontWeight: "700",
74 | lineHeight: "32px",
75 | };
76 |
77 | const button = {
78 | backgroundColor: "#09090b",
79 | borderRadius: "4px",
80 | color: "#fafafa",
81 | fontFamily: "'Open Sans', 'Helvetica Neue', Arial",
82 | fontSize: "15px",
83 | textDecoration: "none",
84 | textAlign: "center" as const,
85 | display: "block",
86 | width: "210px",
87 | padding: "14px 7px",
88 | };
89 |
90 | // const anchor = {
91 | // textDecoration: "underline",
92 | // };
93 |
--------------------------------------------------------------------------------
/src/lib/fonts.ts:
--------------------------------------------------------------------------------
1 | import "@/styles/globals.css";
2 |
3 | import { Inter as FontSans } from "next/font/google";
4 |
5 | export const fontSans = FontSans({
6 | subsets: ["latin"],
7 | variable: "--font-sans",
8 | });
9 |
--------------------------------------------------------------------------------
/src/lib/hooks/use-debounce.ts:
--------------------------------------------------------------------------------
1 | import { useEffect, useState } from "react";
2 |
3 | export function useDebounce(value: T, delay: number) {
4 | const [debouncedValue, setDebouncedValue] = useState(value);
5 |
6 | useEffect(() => {
7 | const handler = setTimeout(() => {
8 | setDebouncedValue(value);
9 | }, delay);
10 |
11 | return () => {
12 | clearTimeout(handler);
13 | };
14 | }, [value, delay]);
15 |
16 | return debouncedValue;
17 | }
18 |
--------------------------------------------------------------------------------
/src/lib/hooks/use-media-query.ts:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 |
3 | export function useMediaQuery(query: string) {
4 | const [value, setValue] = React.useState(false);
5 |
6 | React.useEffect(() => {
7 | function onChange(event: MediaQueryListEvent) {
8 | setValue(event.matches);
9 | }
10 |
11 | const result = matchMedia(query);
12 | result.addEventListener("change", onChange);
13 | setValue(result.matches);
14 |
15 | return () => result.removeEventListener("change", onChange);
16 | }, [query]);
17 |
18 | return value;
19 | }
20 |
--------------------------------------------------------------------------------
/src/lib/logger.ts:
--------------------------------------------------------------------------------
1 | import { env } from "@/env";
2 | import * as fs from "fs";
3 | import * as path from "path";
4 |
5 | enum LogLevel {
6 | DEBUG = "DEBUG",
7 | INFO = "INFO",
8 | WARN = "WARN",
9 | ERROR = "ERROR",
10 | }
11 |
12 | class Logger {
13 | private level: LogLevel;
14 | private logFilePath: string;
15 |
16 | constructor(level: LogLevel = LogLevel.INFO, logFilePath = "application.log") {
17 | this.level = level;
18 | this.logFilePath = path.resolve(logFilePath);
19 | }
20 |
21 | private getTimestamp(): string {
22 | return new Date().toISOString();
23 | }
24 |
25 | private formatMessage(level: LogLevel, args: unknown[]): string {
26 | const message = args
27 | .map((arg) => (typeof arg === "object" ? JSON.stringify(arg) : arg))
28 | .join(" ");
29 |
30 | if (env.NODE_ENV === "development") {
31 | console.log(message);
32 | }
33 |
34 | return `[${this.getTimestamp()}] [${level}] ${message}`;
35 | }
36 |
37 | private log(level: LogLevel, ...args: unknown[]): void {
38 | if (this.shouldLog(level)) {
39 | const logMessage = this.formatMessage(level, args) + "\n";
40 | fs.appendFile(this.logFilePath, logMessage, (err) => {
41 | if (err) throw err;
42 | });
43 | }
44 | }
45 |
46 | private shouldLog(level: LogLevel): boolean {
47 | const levels = [LogLevel.DEBUG, LogLevel.INFO, LogLevel.WARN, LogLevel.ERROR];
48 | return levels.indexOf(level) >= levels.indexOf(this.level);
49 | }
50 |
51 | debug(...args: unknown[]): void {
52 | this.log(LogLevel.DEBUG, ...args);
53 | }
54 |
55 | info(...args: unknown[]): void {
56 | this.log(LogLevel.INFO, ...args);
57 | }
58 |
59 | warn(...args: unknown[]): void {
60 | this.log(LogLevel.WARN, ...args);
61 | }
62 |
63 | error(...args: unknown[]): void {
64 | this.log(LogLevel.ERROR, ...args);
65 | }
66 | }
67 |
68 | export const logger = new Logger(env.NODE_ENV === "development" ? LogLevel.DEBUG : LogLevel.INFO);
69 |
--------------------------------------------------------------------------------
/src/lib/stripe.ts:
--------------------------------------------------------------------------------
1 | import { env } from "@/env";
2 | import Stripe from "stripe";
3 |
4 | export const stripe = new Stripe(env.STRIPE_API_KEY, {
5 | apiVersion: "2023-10-16",
6 | typescript: true,
7 | });
8 |
--------------------------------------------------------------------------------
/src/lib/utils.ts:
--------------------------------------------------------------------------------
1 | import { env } from "@/env";
2 | import { clsx, type ClassValue } from "clsx";
3 | import { twMerge } from "tailwind-merge";
4 |
5 | export function cn(...inputs: ClassValue[]) {
6 | return twMerge(clsx(inputs));
7 | }
8 |
9 | export const getExceptionType = (error: unknown) => {
10 | const UnknownException = {
11 | type: "UnknownException",
12 | status: 500,
13 | message: "An unknown error occurred",
14 | };
15 |
16 | if (!error) return UnknownException;
17 |
18 | if ((error as Record).name === "DatabaseError") {
19 | return {
20 | type: "DatabaseException",
21 | status: 400,
22 | message: "Duplicate key entry",
23 | };
24 | }
25 |
26 | return UnknownException;
27 | };
28 |
29 | export function formatDate(
30 | date: Date | string | number,
31 | options: Intl.DateTimeFormatOptions = {
32 | month: "long",
33 | day: "numeric",
34 | year: "numeric",
35 | },
36 | ) {
37 | return new Intl.DateTimeFormat("en-US", {
38 | ...options,
39 | }).format(new Date(date));
40 | }
41 |
42 | export function formatPrice(
43 | price: number | string,
44 | options: Intl.NumberFormatOptions = {},
45 | ) {
46 | return new Intl.NumberFormat("en-US", {
47 | style: "currency",
48 | currency: options.currency ?? "USD",
49 | notation: options.notation ?? "compact",
50 | ...options,
51 | }).format(Number(price));
52 | }
53 |
54 | export function absoluteUrl(path: string) {
55 | return new URL(path, env.NEXT_PUBLIC_APP_URL).href
56 | }
57 |
--------------------------------------------------------------------------------
/src/lib/validators/auth.ts:
--------------------------------------------------------------------------------
1 | import { z } from "zod";
2 |
3 | export const signupSchema = z.object({
4 | email: z.string().email("Please enter a valid email"),
5 | password: z.string().min(1, "Please provide your password.").max(255),
6 | });
7 | export type SignupInput = z.infer;
8 |
9 | export const loginSchema = z.object({
10 | email: z.string().email("Please enter a valid email."),
11 | password: z
12 | .string()
13 | .min(8, "Password is too short. Minimum 8 characters required.")
14 | .max(255),
15 | });
16 | export type LoginInput = z.infer;
17 |
18 | export const forgotPasswordSchema = z.object({
19 | email: z.string().email(),
20 | });
21 | export type ForgotPasswordInput = z.infer;
22 |
23 | export const resetPasswordSchema = z.object({
24 | token: z.string().min(1, "Invalid token"),
25 | password: z.string().min(8, "Password is too short").max(255),
26 | });
27 | export type ResetPasswordInput = z.infer;
28 |
--------------------------------------------------------------------------------
/src/middleware.ts:
--------------------------------------------------------------------------------
1 | // middleware.ts
2 | import { verifyRequestOrigin } from "lucia";
3 | import { NextResponse } from "next/server";
4 | import type { NextRequest } from "next/server";
5 |
6 | export async function middleware(request: NextRequest): Promise {
7 | if (request.method === "GET") {
8 | return NextResponse.next();
9 | }
10 | const originHeader = request.headers.get("Origin");
11 | const hostHeader = request.headers.get("Host");
12 | if (
13 | !originHeader ||
14 | !hostHeader ||
15 | !verifyRequestOrigin(originHeader, [hostHeader])
16 | ) {
17 | return new NextResponse(null, {
18 | status: 403,
19 | });
20 | }
21 | return NextResponse.next();
22 | }
23 |
24 | export const config = {
25 | matcher: [
26 | "/((?!api|static|.*\\..*|_next|favicon.ico|sitemap.xml|robots.txt).*)",
27 | ],
28 | };
29 |
--------------------------------------------------------------------------------
/src/server/api/root.ts:
--------------------------------------------------------------------------------
1 | import { postRouter } from "./routers/post/post.procedure";
2 | import { stripeRouter } from "./routers/stripe/stripe.procedure";
3 | import { userRouter } from "./routers/user/user.procedure";
4 | import { createTRPCRouter } from "./trpc";
5 |
6 | export const appRouter = createTRPCRouter({
7 | user: userRouter,
8 | post: postRouter,
9 | stripe: stripeRouter,
10 | });
11 |
12 | export type AppRouter = typeof appRouter;
13 |
--------------------------------------------------------------------------------
/src/server/api/routers/post/post.input.ts:
--------------------------------------------------------------------------------
1 | import { z } from "zod";
2 |
3 | export const listPostsSchema = z.object({
4 | page: z.number().int().default(1),
5 | perPage: z.number().int().default(12),
6 | });
7 | export type ListPostsInput = z.infer;
8 |
9 | export const getPostSchema = z.object({
10 | id: z.string(),
11 | });
12 | export type GetPostInput = z.infer;
13 |
14 | export const createPostSchema = z.object({
15 | title: z.string().min(3).max(255),
16 | excerpt: z.string().min(3).max(255),
17 | content: z.string().min(3),
18 | });
19 | export type CreatePostInput = z.infer;
20 |
21 | export const updatePostSchema = createPostSchema.extend({
22 | id: z.string(),
23 | });
24 | export type UpdatePostInput = z.infer;
25 |
26 | export const deletePostSchema = z.object({
27 | id: z.string(),
28 | });
29 | export type DeletePostInput = z.infer;
30 |
31 | export const myPostsSchema = z.object({
32 | page: z.number().int().default(1),
33 | perPage: z.number().int().default(12),
34 | });
35 | export type MyPostsInput = z.infer;
36 |
--------------------------------------------------------------------------------
/src/server/api/routers/post/post.procedure.ts:
--------------------------------------------------------------------------------
1 | import { createTRPCRouter, protectedProcedure } from "../../trpc";
2 | import * as inputs from "./post.input";
3 | import * as services from "./post.service";
4 |
5 | export const postRouter = createTRPCRouter({
6 | list: protectedProcedure
7 | .input(inputs.listPostsSchema)
8 | .query(({ ctx, input }) => services.listPosts(ctx, input)),
9 |
10 | get: protectedProcedure
11 | .input(inputs.getPostSchema)
12 | .query(({ ctx, input }) => services.getPost(ctx, input)),
13 |
14 | create: protectedProcedure
15 | .input(inputs.createPostSchema)
16 | .mutation(({ ctx, input }) => services.createPost(ctx, input)),
17 |
18 | update: protectedProcedure
19 | .input(inputs.updatePostSchema)
20 | .mutation(({ ctx, input }) => services.updatePost(ctx, input)),
21 |
22 | delete: protectedProcedure
23 | .input(inputs.deletePostSchema)
24 | .mutation(async ({ ctx, input }) => services.deletePost(ctx, input)),
25 |
26 | myPosts: protectedProcedure
27 | .input(inputs.myPostsSchema)
28 | .query(({ ctx, input }) => services.myPosts(ctx, input)),
29 | });
30 |
--------------------------------------------------------------------------------
/src/server/api/routers/post/post.service.ts:
--------------------------------------------------------------------------------
1 | import { generateId } from "lucia";
2 | import type { ProtectedTRPCContext } from "../../trpc";
3 | import type {
4 | CreatePostInput,
5 | DeletePostInput,
6 | GetPostInput,
7 | ListPostsInput,
8 | MyPostsInput,
9 | UpdatePostInput,
10 | } from "./post.input";
11 | import { posts } from "@/server/db/schema";
12 | import { eq } from "drizzle-orm";
13 |
14 | export const listPosts = async (ctx: ProtectedTRPCContext, input: ListPostsInput) => {
15 | return ctx.db.query.posts.findMany({
16 | where: (table, { eq }) => eq(table.status, "published"),
17 | offset: (input.page - 1) * input.perPage,
18 | limit: input.perPage,
19 | orderBy: (table, { desc }) => desc(table.createdAt),
20 | columns: {
21 | id: true,
22 | title: true,
23 | excerpt: true,
24 | status: true,
25 | createdAt: true,
26 | },
27 | with: { user: { columns: { email: true } } },
28 | });
29 | };
30 |
31 | export const getPost = async (ctx: ProtectedTRPCContext, { id }: GetPostInput) => {
32 | return ctx.db.query.posts.findFirst({
33 | where: (table, { eq }) => eq(table.id, id),
34 | with: { user: { columns: { email: true } } },
35 | });
36 | };
37 |
38 | export const createPost = async (ctx: ProtectedTRPCContext, input: CreatePostInput) => {
39 | const id = generateId(15);
40 |
41 | await ctx.db.insert(posts).values({
42 | id,
43 | userId: ctx.user.id,
44 | title: input.title,
45 | excerpt: input.excerpt,
46 | content: input.content,
47 | });
48 |
49 | return { id };
50 | };
51 |
52 | export const updatePost = async (ctx: ProtectedTRPCContext, input: UpdatePostInput) => {
53 | const [item] = await ctx.db
54 | .update(posts)
55 | .set({
56 | title: input.title,
57 | excerpt: input.excerpt,
58 | content: input.content,
59 | })
60 | .where(eq(posts.id, input.id))
61 | .returning();
62 |
63 | return item;
64 | };
65 |
66 | export const deletePost = async (ctx: ProtectedTRPCContext, { id }: DeletePostInput) => {
67 | const [item] = await ctx.db.delete(posts).where(eq(posts.id, id)).returning();
68 | return item;
69 | };
70 |
71 | export const myPosts = async (ctx: ProtectedTRPCContext, input: MyPostsInput) => {
72 | return ctx.db.query.posts.findMany({
73 | where: (table, { eq }) => eq(table.userId, ctx.user.id),
74 | offset: (input.page - 1) * input.perPage,
75 | limit: input.perPage,
76 | orderBy: (table, { desc }) => desc(table.createdAt),
77 | columns: {
78 | id: true,
79 | title: true,
80 | excerpt: true,
81 | status: true,
82 | createdAt: true,
83 | },
84 | });
85 | };
86 |
--------------------------------------------------------------------------------
/src/server/api/routers/stripe/stripe.input.ts:
--------------------------------------------------------------------------------
1 | import { z } from "zod";
2 |
3 | export const manageSubscriptionSchema = z.object({
4 | stripePriceId: z.string(),
5 | stripeCustomerId: z.string().optional().nullable(),
6 | stripeSubscriptionId: z.string().optional().nullable(),
7 | isPro: z.boolean(),
8 | });
9 |
10 | export type ManageSubscriptionInput = z.infer;
11 |
--------------------------------------------------------------------------------
/src/server/api/routers/stripe/stripe.procedure.ts:
--------------------------------------------------------------------------------
1 | import { createTRPCRouter, protectedProcedure } from "../../trpc";
2 | import * as services from "./stripe.service";
3 | import * as inputs from "./stripe.input";
4 |
5 | export const stripeRouter = createTRPCRouter({
6 | getPlans: protectedProcedure.query(({ ctx }) => services.getStripePlans(ctx)),
7 |
8 | getPlan: protectedProcedure.query(({ ctx }) => services.getStripePlan(ctx)),
9 |
10 | managePlan: protectedProcedure
11 | .input(inputs.manageSubscriptionSchema)
12 | .mutation(({ ctx, input }) => services.manageSubscription(ctx, input)),
13 | });
14 |
--------------------------------------------------------------------------------
/src/server/api/routers/stripe/stripe.service.ts:
--------------------------------------------------------------------------------
1 | import { freePlan, proPlan, subscriptionPlans } from "@/config/subscriptions";
2 | import type { ProtectedTRPCContext } from "../../trpc";
3 | import { stripe } from "@/lib/stripe";
4 | import { absoluteUrl, formatPrice } from "@/lib/utils";
5 | import type { ManageSubscriptionInput } from "./stripe.input";
6 |
7 | export const getStripePlans = async (ctx: ProtectedTRPCContext) => {
8 | try {
9 | const user = await ctx.db.query.users.findFirst({
10 | where: (table, { eq }) => eq(table.id, ctx.user.id),
11 | columns: {
12 | id: true,
13 | },
14 | });
15 |
16 | if (!user) {
17 | throw new Error("User not found.");
18 | }
19 |
20 | const proPrice = await stripe.prices.retrieve(proPlan.stripePriceId);
21 |
22 | return subscriptionPlans.map((plan) => {
23 | return {
24 | ...plan,
25 | price:
26 | plan.stripePriceId === proPlan.stripePriceId
27 | ? formatPrice((proPrice.unit_amount ?? 0) / 100, {
28 | currency: proPrice.currency,
29 | })
30 | : formatPrice(0 / 100, { currency: proPrice.currency }),
31 | };
32 | });
33 | } catch (err) {
34 | console.error(err);
35 | return [];
36 | }
37 | };
38 |
39 | export const getStripePlan = async (ctx: ProtectedTRPCContext) => {
40 | try {
41 | const user = await ctx.db.query.users.findFirst({
42 | where: (table, { eq }) => eq(table.id, ctx.user.id),
43 | columns: {
44 | stripePriceId: true,
45 | stripeCurrentPeriodEnd: true,
46 | stripeSubscriptionId: true,
47 | stripeCustomerId: true,
48 | },
49 | });
50 |
51 | if (!user) {
52 | throw new Error("User not found.");
53 | }
54 |
55 | // Check if user is on a pro plan
56 | const isPro =
57 | !!user.stripePriceId &&
58 | (user.stripeCurrentPeriodEnd?.getTime() ?? 0) + 86_400_000 > Date.now();
59 |
60 | const plan = isPro ? proPlan : freePlan;
61 |
62 | // Check if user has canceled subscription
63 | let isCanceled = false;
64 | if (isPro && !!user.stripeSubscriptionId) {
65 | const stripePlan = await stripe.subscriptions.retrieve(user.stripeSubscriptionId);
66 | isCanceled = stripePlan.cancel_at_period_end;
67 | }
68 |
69 | return {
70 | ...plan,
71 | stripeSubscriptionId: user.stripeSubscriptionId,
72 | stripeCurrentPeriodEnd: user.stripeCurrentPeriodEnd,
73 | stripeCustomerId: user.stripeCustomerId,
74 | isPro,
75 | isCanceled,
76 | };
77 | } catch (err) {
78 | console.error(err);
79 | return null;
80 | }
81 | };
82 |
83 | export const manageSubscription = async (
84 | ctx: ProtectedTRPCContext,
85 | input: ManageSubscriptionInput,
86 | ) => {
87 | const billingUrl = absoluteUrl("/dashboard/billing");
88 |
89 | const user = await ctx.db.query.users.findFirst({
90 | where: (table, { eq }) => eq(table.id, ctx.user.id),
91 | columns: {
92 | id: true,
93 | email: true,
94 | stripeCustomerId: true,
95 | stripeSubscriptionId: true,
96 | stripePriceId: true,
97 | },
98 | });
99 |
100 | if (!user) {
101 | throw new Error("User not found.");
102 | }
103 |
104 | // If the user is already subscribed to a plan, we redirect them to the Stripe billing portal
105 | if (input.isPro && input.stripeCustomerId) {
106 | const stripeSession = await ctx.stripe.billingPortal.sessions.create({
107 | customer: input.stripeCustomerId,
108 | return_url: billingUrl,
109 | });
110 |
111 | return {
112 | url: stripeSession.url,
113 | };
114 | }
115 |
116 | // If the user is not subscribed to a plan, we create a Stripe Checkout session
117 | const stripeSession = await ctx.stripe.checkout.sessions.create({
118 | success_url: billingUrl,
119 | cancel_url: billingUrl,
120 | payment_method_types: ["card"],
121 | mode: "subscription",
122 | billing_address_collection: "auto",
123 | customer_email: user.email,
124 | line_items: [
125 | {
126 | price: input.stripePriceId,
127 | quantity: 1,
128 | },
129 | ],
130 | metadata: {
131 | userId: user.id,
132 | },
133 | });
134 |
135 | return {
136 | url: stripeSession.url,
137 | };
138 | };
139 |
--------------------------------------------------------------------------------
/src/server/api/routers/user/user.procedure.ts:
--------------------------------------------------------------------------------
1 | import { protectedProcedure, createTRPCRouter } from "../../trpc";
2 |
3 | export const userRouter = createTRPCRouter({
4 | get: protectedProcedure.query(({ ctx }) => ctx.user),
5 | });
6 |
--------------------------------------------------------------------------------
/src/server/api/trpc.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * YOU PROBABLY DON'T NEED TO EDIT THIS FILE, UNLESS:
3 | * 1. You want to modify request context (see Part 1).
4 | * 2. You want to create a new middleware or type of procedure (see Part 3).
5 | *
6 | * TL;DR - This is where all the tRPC server stuff is created and plugged in. The pieces you will
7 | * need to use are documented accordingly near the end.
8 | */
9 |
10 | import { uncachedValidateRequest } from "@/lib/auth/validate-request";
11 | import { stripe } from "@/lib/stripe";
12 | import { db } from "@/server/db";
13 | import { initTRPC, TRPCError, type inferAsyncReturnType } from "@trpc/server";
14 | import superjson from "superjson";
15 | import { ZodError } from "zod";
16 |
17 | /**
18 | * 1. CONTEXT
19 | *
20 | * This section defines the "contexts" that are available in the backend API.
21 | *
22 | * These allow you to access things when processing a request, like the database, the session, etc.
23 | *
24 | * This helper generates the "internals" for a tRPC context. The API handler and RSC clients each
25 | * wrap this and provides the required context.
26 | *
27 | * @see https://trpc.io/docs/server/context
28 | */
29 | export const createTRPCContext = async (opts: { headers: Headers }) => {
30 | const { session, user } = await uncachedValidateRequest();
31 | return {
32 | session,
33 | user,
34 | db,
35 | headers: opts.headers,
36 | stripe: stripe,
37 | };
38 | };
39 |
40 | /**
41 | * 2. INITIALIZATION
42 | *
43 | * This is where the tRPC API is initialized, connecting the context and transformer. We also parse
44 | * ZodErrors so that you get typesafety on the frontend if your procedure fails due to validation
45 | * errors on the backend.
46 | */
47 | const t = initTRPC.context().create({
48 | transformer: superjson,
49 | errorFormatter({ shape, error }) {
50 | return {
51 | ...shape,
52 | data: {
53 | ...shape.data,
54 | zodError: error.cause instanceof ZodError ? error.cause.flatten() : null,
55 | },
56 | };
57 | },
58 | });
59 |
60 | /**
61 | * 3. ROUTER & PROCEDURE (THE IMPORTANT BIT)
62 | *
63 | * These are the pieces you use to build your tRPC API. You should import these a lot in the
64 | * "/src/server/api/routers" directory.
65 | */
66 |
67 | /**
68 | * This is how you create new routers and sub-routers in your tRPC API.
69 | *
70 | * @see https://trpc.io/docs/router
71 | */
72 | export const createTRPCRouter = t.router;
73 |
74 | /**
75 | * Public (unauthenticated) procedure
76 | *
77 | * This is the base piece you use to build new queries and mutations on your tRPC API. It does not
78 | * guarantee that a user querying is authorized, but you can still access user session data if they
79 | * are logged in.
80 | */
81 | export const publicProcedure = t.procedure;
82 |
83 | /**
84 | * Protected (authenticated) procedure
85 | *
86 | * If you want a query or mutation to ONLY be accessible to logged in users, use this. It verifies
87 | * the session is valid and guarantees `ctx.session.user` is not null.
88 | *
89 | * @see https://trpc.io/docs/procedures
90 | */
91 | export const protectedProcedure = t.procedure.use(({ ctx, next }) => {
92 | if (!ctx.session || !ctx.user) {
93 | throw new TRPCError({ code: "UNAUTHORIZED" });
94 | }
95 | return next({
96 | ctx: {
97 | // infers the `session` and `user` as non-nullable
98 | session: { ...ctx.session },
99 | user: { ...ctx.user },
100 | },
101 | });
102 | });
103 |
104 | export type TRPCContext = inferAsyncReturnType;
105 | export type ProtectedTRPCContext = TRPCContext & {
106 | user: NonNullable;
107 | session: NonNullable;
108 | };
109 |
--------------------------------------------------------------------------------
/src/server/db/index.ts:
--------------------------------------------------------------------------------
1 | import { drizzle } from "drizzle-orm/postgres-js";
2 | import postgres from "postgres";
3 | import { env } from "@/env";
4 | import * as schema from "./schema";
5 |
6 | export const connection = postgres(env.DATABASE_URL, {
7 | max_lifetime: 10, // Remove this line if you're deploying to Docker / VPS
8 | // idle_timeout: 20, // Uncomment this line if you're deploying to Docker / VPS
9 | });
10 |
11 | export const db = drizzle(connection, { schema });
12 |
--------------------------------------------------------------------------------
/src/server/db/schema.ts:
--------------------------------------------------------------------------------
1 | import { relations } from "drizzle-orm";
2 | import {
3 | pgTableCreator,
4 | serial,
5 | boolean,
6 | index,
7 | text,
8 | timestamp,
9 | varchar,
10 | } from "drizzle-orm/pg-core";
11 | import { DATABASE_PREFIX as prefix } from "@/lib/constants";
12 |
13 | export const pgTable = pgTableCreator((name) => `${prefix}_${name}`);
14 |
15 | export const users = pgTable(
16 | "users",
17 | {
18 | id: varchar("id", { length: 21 }).primaryKey(),
19 | discordId: varchar("discord_id", { length: 255 }).unique(),
20 | email: varchar("email", { length: 255 }).unique().notNull(),
21 | emailVerified: boolean("email_verified").default(false).notNull(),
22 | hashedPassword: varchar("hashed_password", { length: 255 }),
23 | avatar: varchar("avatar", { length: 255 }),
24 | stripeSubscriptionId: varchar("stripe_subscription_id", { length: 191 }),
25 | stripePriceId: varchar("stripe_price_id", { length: 191 }),
26 | stripeCustomerId: varchar("stripe_customer_id", { length: 191 }),
27 | stripeCurrentPeriodEnd: timestamp("stripe_current_period_end"),
28 | createdAt: timestamp("created_at").defaultNow().notNull(),
29 | updatedAt: timestamp("updated_at", { mode: "date" }).$onUpdate(() => new Date()),
30 | },
31 | (t) => ({
32 | emailIdx: index("user_email_idx").on(t.email),
33 | discordIdx: index("user_discord_idx").on(t.discordId),
34 | }),
35 | );
36 |
37 | export type User = typeof users.$inferSelect;
38 | export type NewUser = typeof users.$inferInsert;
39 |
40 | export const sessions = pgTable(
41 | "sessions",
42 | {
43 | id: varchar("id", { length: 255 }).primaryKey(),
44 | userId: varchar("user_id", { length: 21 }).notNull(),
45 | expiresAt: timestamp("expires_at", { withTimezone: true, mode: "date" }).notNull(),
46 | },
47 | (t) => ({
48 | userIdx: index("session_user_idx").on(t.userId),
49 | }),
50 | );
51 |
52 | export const emailVerificationCodes = pgTable(
53 | "email_verification_codes",
54 | {
55 | id: serial("id").primaryKey(),
56 | userId: varchar("user_id", { length: 21 }).unique().notNull(),
57 | email: varchar("email", { length: 255 }).notNull(),
58 | code: varchar("code", { length: 8 }).notNull(),
59 | expiresAt: timestamp("expires_at", { withTimezone: true, mode: "date" }).notNull(),
60 | },
61 | (t) => ({
62 | userIdx: index("verification_code_user_idx").on(t.userId),
63 | emailIdx: index("verification_code_email_idx").on(t.email),
64 | }),
65 | );
66 |
67 | export const passwordResetTokens = pgTable(
68 | "password_reset_tokens",
69 | {
70 | id: varchar("id", { length: 40 }).primaryKey(),
71 | userId: varchar("user_id", { length: 21 }).notNull(),
72 | expiresAt: timestamp("expires_at", { withTimezone: true, mode: "date" }).notNull(),
73 | },
74 | (t) => ({
75 | userIdx: index("password_token_user_idx").on(t.userId),
76 | }),
77 | );
78 |
79 | export const posts = pgTable(
80 | "posts",
81 | {
82 | id: varchar("id", { length: 15 }).primaryKey(),
83 | userId: varchar("user_id", { length: 255 }).notNull(),
84 | title: varchar("title", { length: 255 }).notNull(),
85 | excerpt: varchar("excerpt", { length: 255 }).notNull(),
86 | content: text("content").notNull(),
87 | status: varchar("status", { length: 10, enum: ["draft", "published"] })
88 | .default("draft")
89 | .notNull(),
90 | tags: varchar("tags", { length: 255 }),
91 | createdAt: timestamp("created_at").defaultNow().notNull(),
92 | updatedAt: timestamp("updated_at", { mode: "date" }).$onUpdate(() => new Date()),
93 | },
94 | (t) => ({
95 | userIdx: index("post_user_idx").on(t.userId),
96 | createdAtIdx: index("post_created_at_idx").on(t.createdAt),
97 | }),
98 | );
99 |
100 | export const postRelations = relations(posts, ({ one }) => ({
101 | user: one(users, {
102 | fields: [posts.userId],
103 | references: [users.id],
104 | }),
105 | }));
106 |
107 | export type Post = typeof posts.$inferSelect;
108 | export type NewPost = typeof posts.$inferInsert;
109 |
--------------------------------------------------------------------------------
/src/styles/globals.css:
--------------------------------------------------------------------------------
1 | @tailwind base;
2 | @tailwind components;
3 | @tailwind utilities;
4 |
5 | @layer base {
6 | :root {
7 | --background: 0 0% 98%;
8 | --foreground: 240 10% 3.9%;
9 |
10 | --card: 0 0% 100%;
11 | --card-foreground: 240 10% 3.9%;
12 |
13 | --popover: 0 0% 100%;
14 | --popover-foreground: 240 10% 3.9%;
15 |
16 | --primary: 240 5.9% 10%;
17 | --primary-foreground: 0 0% 98%;
18 |
19 | --secondary: 240 4.8% 95.9%;
20 | --secondary-foreground: 240 5.9% 10%;
21 |
22 | --muted: 240 4.8% 95.9%;
23 | --muted-foreground: 240 3.8% 46.1%;
24 |
25 | --accent: 240 4.8% 95.9%;
26 | --accent-foreground: 240 5.9% 10%;
27 |
28 | --destructive: 0 84.2% 60.2%;
29 | --destructive-foreground: 0 0% 98%;
30 |
31 | --border: 240 5.9% 90%;
32 | --input: 240 5.9% 90%;
33 | --ring: 240 10% 3.9%;
34 |
35 | --radius: 0.5rem;
36 | }
37 |
38 | .dark {
39 | --background: 240 10% 3.9%;
40 | --foreground: 0 0% 98%;
41 |
42 | --card: 240 10% 3.9%;
43 | --card-foreground: 0 0% 98%;
44 |
45 | --popover: 240 10% 3.9%;
46 | --popover-foreground: 0 0% 98%;
47 |
48 | --primary: 0 0% 98%;
49 | --primary-foreground: 240 5.9% 10%;
50 |
51 | --secondary: 240 3.7% 15.9%;
52 | --secondary-foreground: 0 0% 98%;
53 |
54 | --muted: 240 3.7% 15.9%;
55 | --muted-foreground: 240 5% 64.9%;
56 |
57 | --accent: 240 3.7% 15.9%;
58 | --accent-foreground: 0 0% 98%;
59 |
60 | --destructive: 0 62.8% 40.6%;
61 | --destructive-foreground: 0 0% 98%;
62 |
63 | --border: 240 3.7% 15.9%;
64 | --input: 240 3.7% 15.9%;
65 | --ring: 240 4.9% 83.9%;
66 | }
67 | }
68 |
69 | @layer base {
70 | * {
71 | @apply border-border;
72 | }
73 | body {
74 | @apply bg-background text-foreground;
75 | }
76 | }
77 |
78 | @layer utilities {
79 | .text-balance {
80 | text-wrap: balance;
81 | }
82 | }
83 |
84 | .animated-spinner {
85 | transform-origin: center;
86 | animation: loader-spin 0.75s step-end infinite;
87 | }
88 | @keyframes loader-spin {
89 | 8.3% {
90 | transform: rotate(30deg);
91 | }
92 | 16.6% {
93 | transform: rotate(60deg);
94 | }
95 | 25% {
96 | transform: rotate(90deg);
97 | }
98 | 33.3% {
99 | transform: rotate(120deg);
100 | }
101 | 41.6% {
102 | transform: rotate(150deg);
103 | }
104 | 50% {
105 | transform: rotate(180deg);
106 | }
107 | 58.3% {
108 | transform: rotate(210deg);
109 | }
110 | 66.6% {
111 | transform: rotate(240deg);
112 | }
113 | 75% {
114 | transform: rotate(270deg);
115 | }
116 | 83.3% {
117 | transform: rotate(300deg);
118 | }
119 | 91.6% {
120 | transform: rotate(330deg);
121 | }
122 | 100% {
123 | transform: rotate(360deg);
124 | }
125 | }
126 |
--------------------------------------------------------------------------------
/src/trpc/react.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
4 | import { loggerLink, unstable_httpBatchStreamLink } from "@trpc/client";
5 | import { createTRPCReact } from "@trpc/react-query";
6 | import { useState } from "react";
7 |
8 | import { type AppRouter } from "@/server/api/root";
9 | import { getUrl, transformer } from "./shared";
10 |
11 | export const api = createTRPCReact();
12 |
13 | export function TRPCReactProvider(props: { children: React.ReactNode }) {
14 | const [queryClient] = useState(() => new QueryClient());
15 |
16 | const [trpcClient] = useState(() =>
17 | api.createClient({
18 | transformer,
19 | links: [
20 | loggerLink({
21 | enabled: (op) =>
22 | process.env.NODE_ENV === "development" ||
23 | (op.direction === "down" && op.result instanceof Error),
24 | }),
25 | unstable_httpBatchStreamLink({
26 | url: getUrl(),
27 | }),
28 | ],
29 | }),
30 | );
31 |
32 | return (
33 |
34 |
35 | {props.children}
36 |
37 |
38 | );
39 | }
40 |
--------------------------------------------------------------------------------
/src/trpc/server.ts:
--------------------------------------------------------------------------------
1 | import "server-only";
2 |
3 | import {
4 | createTRPCProxyClient,
5 | loggerLink,
6 | TRPCClientError,
7 | } from "@trpc/client";
8 | import { callProcedure } from "@trpc/server";
9 | import { observable } from "@trpc/server/observable";
10 | import { type TRPCErrorResponse } from "@trpc/server/rpc";
11 | import { cache } from "react";
12 | import { headers } from "next/headers";
13 |
14 | import { appRouter, type AppRouter } from "@/server/api/root";
15 | import { createTRPCContext } from "@/server/api/trpc";
16 | import { transformer } from "./shared";
17 |
18 | /**
19 | * This wraps the `createTRPCContext` helper and provides the required context for the tRPC API when
20 | * handling a tRPC call from a React Server Component.
21 | */
22 | const createContext = cache(() => {
23 | const heads = new Headers(headers());
24 | heads.set("x-trpc-source", "rsc");
25 | return createTRPCContext({
26 | headers: heads,
27 | });
28 | });
29 |
30 | export const api = createTRPCProxyClient({
31 | transformer,
32 | links: [
33 | loggerLink({
34 | enabled: (op) =>
35 | // process.env.NODE_ENV === "development" ||
36 | op.direction === "down" && op.result instanceof Error,
37 | }),
38 | /**
39 | * Custom RSC link that lets us invoke procedures without using http requests. Since Server
40 | * Components always run on the server, we can just call the procedure as a function.
41 | */
42 | () =>
43 | ({ op }) =>
44 | observable((observer) => {
45 | createContext()
46 | .then((ctx) => {
47 | return callProcedure({
48 | procedures: appRouter._def.procedures,
49 | path: op.path,
50 | rawInput: op.input,
51 | ctx,
52 | type: op.type,
53 | });
54 | })
55 | .then((data) => {
56 | observer.next({ result: { data } });
57 | observer.complete();
58 | })
59 | .catch((cause: TRPCErrorResponse) => {
60 | observer.error(TRPCClientError.from(cause));
61 | });
62 | }),
63 | ],
64 | });
65 |
--------------------------------------------------------------------------------
/src/trpc/shared.ts:
--------------------------------------------------------------------------------
1 | import { type inferRouterInputs, type inferRouterOutputs } from "@trpc/server";
2 | import superjson from "superjson";
3 |
4 | import { type AppRouter } from "@/server/api/root";
5 |
6 | export const transformer = superjson;
7 |
8 | function getBaseUrl() {
9 | if (typeof window !== "undefined") return "";
10 | if (process.env.VERCEL_URL) return `https://${process.env.VERCEL_URL}`;
11 | return `http://localhost:${process.env.PORT ?? 3000}`;
12 | }
13 |
14 | export function getUrl() {
15 | return getBaseUrl() + "/api/trpc";
16 | }
17 |
18 | /**
19 | * Inference helper for inputs.
20 | *
21 | * @example type HelloInput = RouterInputs['example']['hello']
22 | */
23 | export type RouterInputs = inferRouterInputs;
24 |
25 | /**
26 | * Inference helper for outputs.
27 | *
28 | * @example type HelloOutput = RouterOutputs['example']['hello']
29 | */
30 | export type RouterOutputs = inferRouterOutputs;
31 |
--------------------------------------------------------------------------------
/tailwind.config.ts:
--------------------------------------------------------------------------------
1 | import { type Config } from "tailwindcss";
2 | import { fontFamily } from "tailwindcss/defaultTheme";
3 |
4 | export default {
5 | darkMode: ["class"],
6 | content: ["./src/**/*.{js,ts,jsx,tsx,mdx}"],
7 | theme: {
8 | container: {
9 | center: true,
10 | padding: "2rem",
11 | screens: {
12 | "2xl": "1400px",
13 | },
14 | },
15 | extend: {
16 | fontFamily: {
17 | sans: ["var(--font-sans)", ...fontFamily.sans],
18 | },
19 | colors: {
20 | border: "hsl(var(--border))",
21 | input: "hsl(var(--input))",
22 | ring: "hsl(var(--ring))",
23 | background: "hsl(var(--background))",
24 | foreground: "hsl(var(--foreground))",
25 | primary: {
26 | DEFAULT: "hsl(var(--primary))",
27 | foreground: "hsl(var(--primary-foreground))",
28 | },
29 | secondary: {
30 | DEFAULT: "hsl(var(--secondary))",
31 | foreground: "hsl(var(--secondary-foreground))",
32 | },
33 | destructive: {
34 | DEFAULT: "hsl(var(--destructive))",
35 | foreground: "hsl(var(--destructive-foreground))",
36 | },
37 | muted: {
38 | DEFAULT: "hsl(var(--muted))",
39 | foreground: "hsl(var(--muted-foreground))",
40 | },
41 | accent: {
42 | DEFAULT: "hsl(var(--accent))",
43 | foreground: "hsl(var(--accent-foreground))",
44 | },
45 | popover: {
46 | DEFAULT: "hsl(var(--popover))",
47 | foreground: "hsl(var(--popover-foreground))",
48 | },
49 | card: {
50 | DEFAULT: "hsl(var(--card))",
51 | foreground: "hsl(var(--card-foreground))",
52 | },
53 | },
54 | borderRadius: {
55 | lg: "var(--radius)",
56 | md: "calc(var(--radius) - 2px)",
57 | sm: "calc(var(--radius) - 4px)",
58 | },
59 | keyframes: {
60 | "accordion-down": {
61 | from: { height: "0" },
62 | to: { height: "var(--radix-accordion-content-height)" },
63 | },
64 | "accordion-up": {
65 | from: { height: "var(--radix-accordion-content-height)" },
66 | to: { height: "0" },
67 | },
68 | },
69 | animation: {
70 | "accordion-down": "accordion-down 0.2s ease-out",
71 | "accordion-up": "accordion-up 0.2s ease-out",
72 | },
73 | },
74 | },
75 |
76 | plugins: [require("tailwindcss-animate"), require("@tailwindcss/typography")],
77 | } satisfies Config;
78 |
--------------------------------------------------------------------------------
/tests/e2e/auth-with-credential.spec.ts:
--------------------------------------------------------------------------------
1 | import { db } from "@/server/db";
2 | import { users } from "@/server/db/schema";
3 | import { test, expect } from "@playwright/test";
4 | import { eq } from "drizzle-orm";
5 | import { extractLastCode, testUser } from "./utils";
6 | import { readFileSync } from "fs";
7 |
8 | test.beforeAll(() => {
9 | db.delete(users)
10 | .where(eq(users.email, testUser.email))
11 | .catch((error) => {
12 | console.error(error);
13 | });
14 | });
15 |
16 | test.describe("signup and login", () => {
17 | test("signup", async ({ page }) => {
18 | await page.goto("/");
19 | await page.getByText("login").click();
20 | await page.getByText(/sign up/i).click();
21 | await page.waitForURL("/signup");
22 | await page.getByLabel("Email").fill(testUser.email);
23 | await page.getByLabel("Password").fill(testUser.password);
24 | await page.getByLabel("submit-btn").click();
25 | await page.waitForURL("/verify-email");
26 | const data = readFileSync("application.log", { encoding: "utf-8" });
27 | const code = extractLastCode(data);
28 | expect(code).not.toBeNull();
29 | await page.getByLabel("Verification Code").fill(code!);
30 | await page.getByLabel("submit-btn").click();
31 | await page.waitForURL("/dashboard");
32 | });
33 | test("login and logout", async ({ page }) => {
34 | await page.goto("/");
35 | await page.getByText("login").click();
36 | await page.getByLabel("Email").fill(testUser.email);
37 | await page.getByLabel("Password").fill(testUser.password);
38 | await page.getByLabel("submit-btn").click();
39 | await page.waitForURL("/dashboard");
40 | await page.getByAltText("Avatar").click();
41 | await page.getByText("Sign out").click();
42 | await page.getByText("Continue").click();
43 | await page.waitForURL("/");
44 | });
45 | });
46 |
--------------------------------------------------------------------------------
/tests/e2e/utils.ts:
--------------------------------------------------------------------------------
1 | export const testUser = {
2 | name: "Test User",
3 | email: "test@saasykits.com",
4 | password: "testPass123",
5 | };
6 |
7 | export function extractLastCode(log: string): string | null {
8 | // Regular expression to match the code value
9 | const regex = /"code":"(\d+)"/g;
10 |
11 | let match: RegExpExecArray | null;
12 | let lastCode: string | null = null;
13 |
14 | // Find all matches and keep track of the last one
15 | while ((match = regex.exec(log)) !== null) {
16 | lastCode = match[1] ?? null;
17 | }
18 | return lastCode;
19 | }
20 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | /* Base Options: */
4 | "esModuleInterop": true,
5 | "skipLibCheck": true,
6 | "target": "es2022",
7 | "allowJs": true,
8 | "resolveJsonModule": true,
9 | "moduleDetection": "force",
10 | "isolatedModules": true,
11 |
12 | /* Strictness */
13 | "strict": true,
14 | "noUncheckedIndexedAccess": true,
15 | "checkJs": true,
16 |
17 | /* Bundled projects */
18 | "lib": ["dom", "dom.iterable", "ES2022"],
19 | "noEmit": true,
20 | "module": "ESNext",
21 | "moduleResolution": "Bundler",
22 | "jsx": "preserve",
23 | "plugins": [{ "name": "next" }],
24 | "incremental": true,
25 |
26 | /* Path Aliases */
27 | "baseUrl": ".",
28 | "paths": {
29 | "@/*": ["./src/*"]
30 | }
31 | },
32 | "include": [
33 | ".eslintrc.cjs",
34 | "next-env.d.ts",
35 | "**/*.ts",
36 | "**/*.tsx",
37 | "**/*.cjs",
38 | "**/*.js",
39 | ".next/types/**/*.ts"
40 | ],
41 | "exclude": ["node_modules"]
42 | }
43 |
--------------------------------------------------------------------------------