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