first
This commit is contained in:
commit
8538ab969a
434 changed files with 44051 additions and 0 deletions
17
.cursor/rules/key-principles.mdc
Normal file
17
.cursor/rules/key-principles.mdc
Normal file
|
|
@ -0,0 +1,17 @@
|
||||||
|
---
|
||||||
|
description:
|
||||||
|
globs:
|
||||||
|
alwaysApply: true
|
||||||
|
---
|
||||||
|
This rule describes the key principles of how to generate code for this project.
|
||||||
|
|
||||||
|
Whenever code is generated assume you are an expert in TypeScript, Node.js, Next.js App Router, React, Shadcn UI, Radix UI and Tailwind.
|
||||||
|
|
||||||
|
- Write concise, technical TypeScript code with accurate examples.
|
||||||
|
- Use functional and declarative programming patterns; avoid classes.
|
||||||
|
- Prefer iteration and modularization over code duplication.
|
||||||
|
- Use descriptive variable names with auxiliary verbs (e.g., isLoading, hasError).
|
||||||
|
- Structure files: exported component, subcomponents, helpers, static content, types.
|
||||||
|
|
||||||
|
Follow Next.js docs for Data Fetching, Rendering, and Routing.
|
||||||
|
Follow the documentation at supastarter.dev/docs/nextjs for supastarter specifc patterns.
|
||||||
11
.cursor/rules/naming-coventions.mdc
Normal file
11
.cursor/rules/naming-coventions.mdc
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
---
|
||||||
|
description:
|
||||||
|
globs:
|
||||||
|
alwaysApply: true
|
||||||
|
---
|
||||||
|
This rule describes what naming conventions to use for files, folders, components and methods.
|
||||||
|
|
||||||
|
- Use lowercase with dashes for directories (e.g., components/auth-wizard).
|
||||||
|
- Use PascalCase for component names
|
||||||
|
- Use camelCase for variables and method names
|
||||||
|
- Favor named exports for components.
|
||||||
11
.cursor/rules/performance.mdc
Normal file
11
.cursor/rules/performance.mdc
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
---
|
||||||
|
description:
|
||||||
|
globs:
|
||||||
|
alwaysApply: true
|
||||||
|
---
|
||||||
|
This rule describes how to optimize the code for performance.
|
||||||
|
|
||||||
|
- Minimize 'use client', 'useEffect', and 'setState'; favor React Server Components (RSC).
|
||||||
|
- Wrap client components in Suspense with fallback.
|
||||||
|
- Use dynamic loading for non-critical components.
|
||||||
|
- Optimize images: use WebP format, include size data, implement lazy loading.
|
||||||
20
.cursor/rules/project-structure.mdc
Normal file
20
.cursor/rules/project-structure.mdc
Normal file
|
|
@ -0,0 +1,20 @@
|
||||||
|
---
|
||||||
|
description:
|
||||||
|
globs:
|
||||||
|
alwaysApply: true
|
||||||
|
---
|
||||||
|
This rule describes the basic project structure of the repository and where to find or add certain parts of the application:
|
||||||
|
|
||||||
|
- The main application is in the `packages/app` directory which is a Next.js App Router. Put all frontend-only code in this directory.
|
||||||
|
- Put all backend logic into one of the `packages` directories:
|
||||||
|
- `packages/ai` contains all AI-related code
|
||||||
|
- `packages/api` contains all API routes
|
||||||
|
- `packages/auth` contains the config for better-auth and helper functions
|
||||||
|
- `packages/database` contains the database schema and auto-generated types
|
||||||
|
- `packages/i18n` contains translations and internationalization helper functions
|
||||||
|
- `packages/logs` contains the logging config and helper functions
|
||||||
|
- `packages/mail` contains providers for sending mails and email templates
|
||||||
|
- `packages/payments` contains code for payment providers and payment processing
|
||||||
|
- `packages/storage` contains providers for storing files and images
|
||||||
|
- `packages/utils` contains utility functions
|
||||||
|
- `config` contains the application configuration
|
||||||
10
.cursor/rules/syntax-and-formatting.mdc
Normal file
10
.cursor/rules/syntax-and-formatting.mdc
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
---
|
||||||
|
description:
|
||||||
|
globs:
|
||||||
|
alwaysApply: true
|
||||||
|
---
|
||||||
|
This rule describes syntax and formatting rules used across the codebase.
|
||||||
|
|
||||||
|
- Use the "function" keyword for pure functions.
|
||||||
|
- Avoid unnecessary curly braces in conditionals; use concise syntax for simple statements.
|
||||||
|
- Use declarative JSX.
|
||||||
10
.cursor/rules/typescript-usage.mdc
Normal file
10
.cursor/rules/typescript-usage.mdc
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
---
|
||||||
|
description:
|
||||||
|
globs:
|
||||||
|
alwaysApply: true
|
||||||
|
---
|
||||||
|
This rule describes how to use typescript across the project.
|
||||||
|
|
||||||
|
- Use TypeScript for all code; prefer interfaces over types.
|
||||||
|
- Avoid enums; use maps instead.
|
||||||
|
- Use functional components with TypeScript interfaces.
|
||||||
11
.cursor/rules/ui-and-styling.mdc
Normal file
11
.cursor/rules/ui-and-styling.mdc
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
---
|
||||||
|
description:
|
||||||
|
globs:
|
||||||
|
alwaysApply: true
|
||||||
|
---
|
||||||
|
This rule describes guidelines for creating UI and styling components:
|
||||||
|
|
||||||
|
- Use Shadcn UI, Radix, and Tailwind for components and styling.
|
||||||
|
- Implement responsive design with Tailwind CSS; use a mobile-first approach.
|
||||||
|
- Use the `cn` function for class name concatenation.
|
||||||
|
- The global theme variables and tailwind config are defined in `tooling/tailwind/theme.css`.
|
||||||
9
.editorconfig
Normal file
9
.editorconfig
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
root = true
|
||||||
|
|
||||||
|
[*]
|
||||||
|
indent_style = tab
|
||||||
|
indent_size = 4
|
||||||
|
end_of_line = lf
|
||||||
|
charset = utf-8
|
||||||
|
trim_trailing_whitespace = false
|
||||||
|
insert_final_newline = true
|
||||||
76
.env.local.example
Normal file
76
.env.local.example
Normal file
|
|
@ -0,0 +1,76 @@
|
||||||
|
# Database
|
||||||
|
DATABASE_URL="YOUR_DATABASE_CONNECTION_STRING"
|
||||||
|
# ... if you use Supabase
|
||||||
|
DIRECT_URL=""
|
||||||
|
|
||||||
|
# Site url
|
||||||
|
NEXT_PUBLIC_SITE_URL="http://localhost:3000"
|
||||||
|
|
||||||
|
# Authentication
|
||||||
|
BETTER_AUTH_SECRET="A_RANDOM_SECRET_STRING"
|
||||||
|
# ... for Github
|
||||||
|
GITHUB_CLIENT_ID="YOUR_GITHUB_CLIENT_ID"
|
||||||
|
GITHUB_CLIENT_SECRET="YOUR_GITHUB_CLIENT_SECRET"
|
||||||
|
# ... for Google
|
||||||
|
GOOGLE_CLIENT_ID="YOUR_GOOGLE_CLIENT_ID"
|
||||||
|
GOOGLE_CLIENT_SECRET="YOUR_GOOGLE_CLIENT_SECRET"
|
||||||
|
|
||||||
|
# Mails
|
||||||
|
# ... with nodemailer
|
||||||
|
MAIL_HOST=""
|
||||||
|
MAIL_PORT=""
|
||||||
|
MAIL_USER=""
|
||||||
|
MAIL_PASS=""
|
||||||
|
# ... with Plunk
|
||||||
|
PLUNK_API_KEY=""
|
||||||
|
# ... with Resend
|
||||||
|
RESEND_API_KEY=""
|
||||||
|
# ... with Postmark
|
||||||
|
POSTMARK_SERVER_TOKEN=""
|
||||||
|
# ... with Mailgun
|
||||||
|
MAILGUN_API_KEY=""
|
||||||
|
MAILGUN_DOMAIN=""
|
||||||
|
|
||||||
|
# Payments
|
||||||
|
# ... with Lemonsqueezy
|
||||||
|
LEMONSQUEEZY_API_KEY=""
|
||||||
|
LEMONSQUEEZY_WEBHOOK_SECRET=""
|
||||||
|
LEMONSQUEEZY_STORE_ID=""
|
||||||
|
# ... with Stripe
|
||||||
|
STRIPE_SECRET_KEY=""
|
||||||
|
STRIPE_WEBHOOK_SECRET=""
|
||||||
|
# ... with Chargebee
|
||||||
|
CHARGEBEE_SITE=""
|
||||||
|
CHARGEBEE_API_KEY=""
|
||||||
|
# ... with Creem
|
||||||
|
CREEM_API_KEY=""
|
||||||
|
CREEM_WEBHOOK_SECRET=""
|
||||||
|
# ... with Polar
|
||||||
|
POLAR_ACCESS_TOKEN=""
|
||||||
|
POLAR_WEBHOOK_SECRET=""
|
||||||
|
|
||||||
|
# Product price ids
|
||||||
|
NEXT_PUBLIC_PRICE_ID_PRO_MONTHLY="asdf"
|
||||||
|
NEXT_PUBLIC_PRICE_ID_PRO_YEARLY="asdf"
|
||||||
|
NEXT_PUBLIC_PRICE_ID_LIFETIME="asdf"
|
||||||
|
|
||||||
|
# Analytics
|
||||||
|
# ... for Pirsch
|
||||||
|
NEXT_PUBLIC_PIRSCH_CODE=""
|
||||||
|
# ... for Plausible
|
||||||
|
NEXT_PUBLIC_PLAUSIBLE_URL=""
|
||||||
|
# ... for Mixpanel
|
||||||
|
NEXT_PUBLIC_MIXPANEL_TOKEN=""
|
||||||
|
# ... for Google Analytics
|
||||||
|
NEXT_PUBLIC_GOOGLE_ANALYTICS_ID=""
|
||||||
|
|
||||||
|
# Storage
|
||||||
|
S3_ACCESS_KEY_ID=""
|
||||||
|
S3_SECRET_ACCESS_KEY=""
|
||||||
|
S3_ENDPOINT=""
|
||||||
|
S3_REGION=""
|
||||||
|
NEXT_PUBLIC_AVATARS_BUCKET_NAME="avatars"
|
||||||
|
|
||||||
|
# AI
|
||||||
|
# ... with OpenAI
|
||||||
|
OPENAI_API_KEY=""
|
||||||
15
.github/dependabot.yml
vendored
Normal file
15
.github/dependabot.yml
vendored
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
version: 2
|
||||||
|
updates:
|
||||||
|
- package-ecosystem: "npm"
|
||||||
|
directory: "/"
|
||||||
|
open-pull-requests-limit: 2
|
||||||
|
schedule:
|
||||||
|
interval: "daily"
|
||||||
|
ignore:
|
||||||
|
- dependency-name: "cropperjs"
|
||||||
|
versions: [">1.6.2"]
|
||||||
|
groups:
|
||||||
|
production-dependencies:
|
||||||
|
dependency-type: "production"
|
||||||
|
development-dependencies:
|
||||||
|
dependency-type: "development"
|
||||||
39
.github/workflows/validate-prs.yml
vendored
Normal file
39
.github/workflows/validate-prs.yml
vendored
Normal file
|
|
@ -0,0 +1,39 @@
|
||||||
|
name: Validate PRs
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
branches: [main]
|
||||||
|
|
||||||
|
env:
|
||||||
|
DATABASE_URL: ${{ secrets.DATABASE_URL }}
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
lint:
|
||||||
|
name: Lint code
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- name: Setup Biome
|
||||||
|
uses: biomejs/setup-biome@v2
|
||||||
|
- name: Run Biome
|
||||||
|
run: biome ci .
|
||||||
|
e2e:
|
||||||
|
name: Run e2e tests
|
||||||
|
timeout-minutes: 60
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: lts/*
|
||||||
|
- uses: pnpm/action-setup@v4
|
||||||
|
- name: Install dependencies
|
||||||
|
run: pnpm install && pnpm --filter database generate
|
||||||
|
- name: Run Playwright tests
|
||||||
|
run: pnpm --filter web e2e:ci
|
||||||
|
- uses: actions/upload-artifact@v4
|
||||||
|
if: ${{ !cancelled() }}
|
||||||
|
with:
|
||||||
|
name: playwright-report
|
||||||
|
path: apps/web/playwright-report/
|
||||||
|
retention-days: 30
|
||||||
44
.gitignore
vendored
Normal file
44
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,44 @@
|
||||||
|
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||||
|
|
||||||
|
# dependencies
|
||||||
|
node_modules
|
||||||
|
.pnp
|
||||||
|
.pnp.js
|
||||||
|
|
||||||
|
# testing
|
||||||
|
coverage
|
||||||
|
|
||||||
|
# next.js
|
||||||
|
.next/
|
||||||
|
out/
|
||||||
|
build
|
||||||
|
.swc/
|
||||||
|
|
||||||
|
# misc
|
||||||
|
.DS_Store
|
||||||
|
*.pem
|
||||||
|
|
||||||
|
# debug
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
|
||||||
|
# local env files
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
.env.development.local
|
||||||
|
.env.test.local
|
||||||
|
.env.production.local
|
||||||
|
|
||||||
|
# turbo
|
||||||
|
.turbo
|
||||||
|
|
||||||
|
# ui
|
||||||
|
dist/
|
||||||
|
|
||||||
|
# typescript
|
||||||
|
*.tsbuildinfo
|
||||||
|
|
||||||
|
# other
|
||||||
|
.react-email/
|
||||||
|
.content-collections/
|
||||||
8
.idea/.gitignore
generated
vendored
Normal file
8
.idea/.gitignore
generated
vendored
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
# Default ignored files
|
||||||
|
/shelf/
|
||||||
|
/workspace.xml
|
||||||
|
# Editor-based HTTP Client requests
|
||||||
|
/httpRequests/
|
||||||
|
# Datasource local storage ignored files
|
||||||
|
/dataSources/
|
||||||
|
/dataSources.local.xml
|
||||||
12
.idea/dataSources.xml
generated
Normal file
12
.idea/dataSources.xml
generated
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="DataSourceManagerImpl" format="xml" multifile-model="true">
|
||||||
|
<data-source source="LOCAL" name="betterauth@localhost" uuid="cbe9f8bd-bcd6-45a9-8abe-45e248fe6ea4">
|
||||||
|
<driver-ref>postgresql</driver-ref>
|
||||||
|
<synchronize>true</synchronize>
|
||||||
|
<jdbc-driver>org.postgresql.Driver</jdbc-driver>
|
||||||
|
<jdbc-url>jdbc:postgresql://localhost:5432/betterauth</jdbc-url>
|
||||||
|
<working-dir>$ProjectFileDir$</working-dir>
|
||||||
|
</data-source>
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
6
.idea/data_source_mapping.xml
generated
Normal file
6
.idea/data_source_mapping.xml
generated
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="DataSourcePerFileMappings">
|
||||||
|
<file url="file://$APPLICATION_CONFIG_DIR$/consoles/db/cbe9f8bd-bcd6-45a9-8abe-45e248fe6ea4/console.sql" value="cbe9f8bd-bcd6-45a9-8abe-45e248fe6ea4" />
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
12
.idea/main-test.iml
generated
Normal file
12
.idea/main-test.iml
generated
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<module type="WEB_MODULE" version="4">
|
||||||
|
<component name="NewModuleRootManager">
|
||||||
|
<content url="file://$MODULE_DIR$">
|
||||||
|
<excludeFolder url="file://$MODULE_DIR$/.tmp" />
|
||||||
|
<excludeFolder url="file://$MODULE_DIR$/temp" />
|
||||||
|
<excludeFolder url="file://$MODULE_DIR$/tmp" />
|
||||||
|
</content>
|
||||||
|
<orderEntry type="inheritedJdk" />
|
||||||
|
<orderEntry type="sourceFolder" forTests="false" />
|
||||||
|
</component>
|
||||||
|
</module>
|
||||||
8
.idea/modules.xml
generated
Normal file
8
.idea/modules.xml
generated
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="ProjectModuleManager">
|
||||||
|
<modules>
|
||||||
|
<module fileurl="file://$PROJECT_DIR$/.idea/main-test.iml" filepath="$PROJECT_DIR$/.idea/main-test.iml" />
|
||||||
|
</modules>
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
6
.idea/vcs.xml
generated
Normal file
6
.idea/vcs.xml
generated
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="VcsDirectoryMappings">
|
||||||
|
<mapping directory="" vcs="Git" />
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
1
.npmrc
Normal file
1
.npmrc
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
public-hoist-pattern[]=*prisma*
|
||||||
7
.vscode/extensions.json
vendored
Normal file
7
.vscode/extensions.json
vendored
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
{
|
||||||
|
"recommendations": [
|
||||||
|
"lokalise.i18n-ally",
|
||||||
|
"bradlc.vscode-tailwindcss",
|
||||||
|
"biomejs.biome"
|
||||||
|
]
|
||||||
|
}
|
||||||
32
.vscode/settings.json
vendored
Normal file
32
.vscode/settings.json
vendored
Normal file
|
|
@ -0,0 +1,32 @@
|
||||||
|
{
|
||||||
|
"editor.defaultFormatter": "biomejs.biome",
|
||||||
|
"editor.formatOnSave": true,
|
||||||
|
"editor.formatOnPaste": true,
|
||||||
|
"emmet.showExpandedAbbreviation": "never",
|
||||||
|
"tailwindCSS.experimental.classRegex": [
|
||||||
|
["cn\\(([^)]*)\\)", "[\"'`]([^\"'`]*).*?[\"'`]"]
|
||||||
|
],
|
||||||
|
"editor.codeActionsOnSave": {
|
||||||
|
"quickfix.biome": "explicit",
|
||||||
|
"source.organizeImports.biome": "explicit"
|
||||||
|
},
|
||||||
|
"typescript.preferences.importModuleSpecifier": "non-relative",
|
||||||
|
"typescript.tsdk": "node_modules/typescript/lib",
|
||||||
|
"i18n-ally.localesPaths": ["packages/i18n/translations"],
|
||||||
|
"i18n-ally.keystyle": "nested",
|
||||||
|
"i18n-ally.enabledFrameworks": ["next-intl"],
|
||||||
|
"i18n-ally.keysInUse": ["mail.organizationInvitation.headline"],
|
||||||
|
"i18n-ally.tabStyle": "tab",
|
||||||
|
"[typescript]": {
|
||||||
|
"editor.defaultFormatter": "biomejs.biome"
|
||||||
|
},
|
||||||
|
"[typescriptreact]": {
|
||||||
|
"editor.defaultFormatter": "biomejs.biome"
|
||||||
|
},
|
||||||
|
"[json]": {
|
||||||
|
"editor.defaultFormatter": "biomejs.biome"
|
||||||
|
},
|
||||||
|
"[prisma]": {
|
||||||
|
"editor.defaultFormatter": "Prisma.prisma"
|
||||||
|
}
|
||||||
|
}
|
||||||
60
.windsurfrules
Normal file
60
.windsurfrules
Normal file
|
|
@ -0,0 +1,60 @@
|
||||||
|
You are an expert in TypeScript, Node.js, Next.js App Router, React, Shadcn UI, Radix UI and Tailwind.
|
||||||
|
|
||||||
|
Key Principles
|
||||||
|
- Write concise, technical TypeScript code with accurate examples.
|
||||||
|
- Use functional and declarative programming patterns; avoid classes.
|
||||||
|
- Prefer iteration and modularization over code duplication.
|
||||||
|
- Use descriptive variable names with auxiliary verbs (e.g., isLoading, hasError).
|
||||||
|
- Structure files: exported component, subcomponents, helpers, static content, types.
|
||||||
|
|
||||||
|
Project Structure
|
||||||
|
- The main application is in the `packages/app` directory which is a Next.js App Router. Put all frontend-only code in this directory.
|
||||||
|
- Put all backend logic into one of the `packages` directories:
|
||||||
|
- `packages/ai` contains all AI-related code
|
||||||
|
- `packages/api` contains all API routes
|
||||||
|
- `packages/auth` contains the config for better-auth and helper functions
|
||||||
|
- `packages/database` contains the database schema and auto-generated types
|
||||||
|
- `packages/i18n` contains translations and internationalization helper functions
|
||||||
|
- `packages/logs` contains the logging config and helper functions
|
||||||
|
- `packages/mail` contains providers for sending mails and email templates
|
||||||
|
- `packages/payments` contains code for payment providers and payment processing
|
||||||
|
- `packages/storage` contains providers for storing files and images
|
||||||
|
- `packages/utils` contains utility functions
|
||||||
|
- `config` contains the application configuration
|
||||||
|
|
||||||
|
Naming Conventions
|
||||||
|
- Use lowercase with dashes for directories (e.g., components/auth-wizard).
|
||||||
|
- Favor named exports for components.
|
||||||
|
|
||||||
|
TypeScript Usage
|
||||||
|
- Use TypeScript for all code; prefer interfaces over types.
|
||||||
|
- Avoid enums; use maps instead.
|
||||||
|
- Use functional components with TypeScript interfaces.
|
||||||
|
|
||||||
|
Syntax and Formatting
|
||||||
|
- Use the "function" keyword for pure functions.
|
||||||
|
- Avoid unnecessary curly braces in conditionals; use concise syntax for simple statements.
|
||||||
|
- Use declarative JSX.
|
||||||
|
|
||||||
|
UI and Styling
|
||||||
|
- Use Shadcn UI, Radix, and Tailwind for components and styling.
|
||||||
|
- Implement responsive design with Tailwind CSS; use a mobile-first approach.
|
||||||
|
- Use the `cn` function for class name concatenation.
|
||||||
|
- The global theme variables and tailwind config are defined in `tooling/tailwind/theme.css`.
|
||||||
|
|
||||||
|
Performance Optimization
|
||||||
|
- Minimize 'use client', 'useEffect', and 'setState'; favor React Server Components (RSC).
|
||||||
|
- Wrap client components in Suspense with fallback.
|
||||||
|
- Use dynamic loading for non-critical components.
|
||||||
|
- Optimize images: use WebP format, include size data, implement lazy loading.
|
||||||
|
|
||||||
|
Key Conventions
|
||||||
|
- Use 'nuqs' for URL search parameter state management.
|
||||||
|
- Optimize Web Vitals (LCP, CLS, FID).
|
||||||
|
- Limit 'use client':
|
||||||
|
- Favor server components and Next.js SSR.
|
||||||
|
- Use only for Web API access in small components.
|
||||||
|
- Avoid for data fetching or state management.
|
||||||
|
|
||||||
|
Follow Next.js docs for Data Fetching, Rendering, and Routing.
|
||||||
|
Follow the documentation at supastarter.dev/docs/nextjs for supastarter specifc patterns.
|
||||||
8
README.md
Normal file
8
README.md
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
# supastarter for Next.js
|
||||||
|
|
||||||
|
supastarter is the ultimate starter kit for production-ready, scalable SaaS applications.
|
||||||
|
|
||||||
|
## Helpful links
|
||||||
|
|
||||||
|
- [📘 Documentation](https://supastarter.dev/docs/nextjs)
|
||||||
|
- [🚀 Demo](https://demo.supastarter.dev)
|
||||||
39
apps/web/.gitignore
vendored
Normal file
39
apps/web/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,39 @@
|
||||||
|
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||||
|
|
||||||
|
# dependencies
|
||||||
|
/node_modules
|
||||||
|
/.pnp
|
||||||
|
.pnp.js
|
||||||
|
|
||||||
|
# testing
|
||||||
|
/coverage
|
||||||
|
/playwright-report
|
||||||
|
/test-results
|
||||||
|
|
||||||
|
# next.js
|
||||||
|
/.next/
|
||||||
|
/out/
|
||||||
|
|
||||||
|
# production
|
||||||
|
/build
|
||||||
|
|
||||||
|
# misc
|
||||||
|
.DS_Store
|
||||||
|
*.pem
|
||||||
|
|
||||||
|
# debug
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
|
||||||
|
# local env files
|
||||||
|
.env*.local
|
||||||
|
|
||||||
|
# vercel
|
||||||
|
.vercel
|
||||||
|
|
||||||
|
# typescript
|
||||||
|
*.tsbuildinfo
|
||||||
|
next-env.d.ts
|
||||||
|
|
||||||
|
.content-collections
|
||||||
25
apps/web/app/(marketing)/[locale]/(home)/page.tsx
Normal file
25
apps/web/app/(marketing)/[locale]/(home)/page.tsx
Normal file
|
|
@ -0,0 +1,25 @@
|
||||||
|
import { FaqSection } from "@marketing/home/components/FaqSection";
|
||||||
|
import { Features } from "@marketing/home/components/Features";
|
||||||
|
import { Hero } from "@marketing/home/components/Hero";
|
||||||
|
import { Newsletter } from "@marketing/home/components/Newsletter";
|
||||||
|
import { PricingSection } from "@marketing/home/components/PricingSection";
|
||||||
|
import { setRequestLocale } from "next-intl/server";
|
||||||
|
|
||||||
|
export default async function Home({
|
||||||
|
params,
|
||||||
|
}: {
|
||||||
|
params: Promise<{ locale: string }>;
|
||||||
|
}) {
|
||||||
|
const { locale } = await params;
|
||||||
|
setRequestLocale(locale);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Hero />
|
||||||
|
<Features />
|
||||||
|
<PricingSection />
|
||||||
|
<FaqSection />
|
||||||
|
<Newsletter />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
5
apps/web/app/(marketing)/[locale]/[...rest]/page.tsx
Normal file
5
apps/web/app/(marketing)/[locale]/[...rest]/page.tsx
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
import { notFound } from "next/navigation";
|
||||||
|
|
||||||
|
export default function CatchAll() {
|
||||||
|
notFound();
|
||||||
|
}
|
||||||
128
apps/web/app/(marketing)/[locale]/blog/[...path]/page.tsx
Normal file
128
apps/web/app/(marketing)/[locale]/blog/[...path]/page.tsx
Normal file
|
|
@ -0,0 +1,128 @@
|
||||||
|
import { LocaleLink, localeRedirect } from "@i18n/routing";
|
||||||
|
import { PostContent } from "@marketing/blog/components/PostContent";
|
||||||
|
import { getPostBySlug } from "@marketing/blog/utils/lib/posts";
|
||||||
|
import { getBaseUrl } from "@repo/utils";
|
||||||
|
import { getActivePathFromUrlParam } from "@shared/lib/content";
|
||||||
|
import { getLocale, getTranslations, setRequestLocale } from "next-intl/server";
|
||||||
|
import Image from "next/image";
|
||||||
|
|
||||||
|
type Params = {
|
||||||
|
path: string;
|
||||||
|
locale: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function generateMetadata(props: { params: Promise<Params> }) {
|
||||||
|
const params = await props.params;
|
||||||
|
|
||||||
|
const { path } = params;
|
||||||
|
|
||||||
|
const locale = await getLocale();
|
||||||
|
const slug = getActivePathFromUrlParam(path);
|
||||||
|
const post = await getPostBySlug(slug, { locale });
|
||||||
|
|
||||||
|
return {
|
||||||
|
title: post?.title,
|
||||||
|
description: post?.excerpt,
|
||||||
|
openGraph: {
|
||||||
|
title: post?.title,
|
||||||
|
description: post?.excerpt,
|
||||||
|
images: post?.image
|
||||||
|
? [
|
||||||
|
post.image.startsWith("http")
|
||||||
|
? post.image
|
||||||
|
: new URL(post.image, getBaseUrl()).toString(),
|
||||||
|
]
|
||||||
|
: [],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function BlogPostPage(props: { params: Promise<Params> }) {
|
||||||
|
const { path, locale } = await props.params;
|
||||||
|
setRequestLocale(locale);
|
||||||
|
|
||||||
|
const t = await getTranslations();
|
||||||
|
|
||||||
|
const slug = getActivePathFromUrlParam(path);
|
||||||
|
const post = await getPostBySlug(slug, { locale });
|
||||||
|
|
||||||
|
if (!post) {
|
||||||
|
return localeRedirect({ href: "/blog", locale });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { title, date, authorName, authorImage, tags, image, body } = post;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="container max-w-6xl pt-32 pb-24">
|
||||||
|
<div className="mx-auto max-w-2xl">
|
||||||
|
<div className="mb-12">
|
||||||
|
<LocaleLink href="/blog">
|
||||||
|
← {t("blog.back")}
|
||||||
|
</LocaleLink>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h1 className="font-bold text-4xl">{title}</h1>
|
||||||
|
|
||||||
|
<div className="mt-4 flex items-center justify-start gap-6">
|
||||||
|
{authorName && (
|
||||||
|
<div className="flex items-center">
|
||||||
|
{authorImage && (
|
||||||
|
<div className="relative mr-2 size-8 overflow-hidden rounded-full">
|
||||||
|
<Image
|
||||||
|
src={authorImage}
|
||||||
|
alt={authorName}
|
||||||
|
fill
|
||||||
|
sizes="96px"
|
||||||
|
className="object-cover object-center"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div>
|
||||||
|
<p className="font-semibold text-sm opacity-50">
|
||||||
|
{authorName}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="mr-0 ml-auto">
|
||||||
|
<p className="text-sm opacity-30">
|
||||||
|
{Intl.DateTimeFormat("en-US").format(
|
||||||
|
new Date(date),
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{tags && (
|
||||||
|
<div className="flex flex-1 flex-wrap gap-2">
|
||||||
|
{tags.map((tag) => (
|
||||||
|
<span
|
||||||
|
key={tag}
|
||||||
|
className="font-semibold text-primary text-xs uppercase tracking-wider"
|
||||||
|
>
|
||||||
|
#{tag}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{image && (
|
||||||
|
<div className="relative mt-6 aspect-16/9 overflow-hidden rounded-xl">
|
||||||
|
<Image
|
||||||
|
src={image}
|
||||||
|
alt={title}
|
||||||
|
fill
|
||||||
|
sizes="(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 33vw"
|
||||||
|
className="object-cover object-center"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="pb-8">
|
||||||
|
<PostContent content={body} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
39
apps/web/app/(marketing)/[locale]/blog/page.tsx
Normal file
39
apps/web/app/(marketing)/[locale]/blog/page.tsx
Normal file
|
|
@ -0,0 +1,39 @@
|
||||||
|
import { PostListItem } from "@marketing/blog/components/PostListItem";
|
||||||
|
import { getAllPosts } from "@marketing/blog/utils/lib/posts";
|
||||||
|
import { getLocale, getTranslations } from "next-intl/server";
|
||||||
|
|
||||||
|
export async function generateMetadata() {
|
||||||
|
const t = await getTranslations();
|
||||||
|
return {
|
||||||
|
title: t("blog.title"),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function BlogListPage() {
|
||||||
|
const locale = await getLocale();
|
||||||
|
const t = await getTranslations();
|
||||||
|
|
||||||
|
const posts = await getAllPosts();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="container max-w-6xl pt-32 pb-16">
|
||||||
|
<div className="mb-12 pt-8 text-center">
|
||||||
|
<h1 className="mb-2 font-bold text-5xl">{t("blog.title")}</h1>
|
||||||
|
<p className="text-lg opacity-50">{t("blog.description")}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-8 md:grid-cols-2">
|
||||||
|
{posts
|
||||||
|
.filter((post) => post.published && locale === post.locale)
|
||||||
|
.sort(
|
||||||
|
(a, b) =>
|
||||||
|
new Date(b.date).getTime() -
|
||||||
|
new Date(a.date).getTime(),
|
||||||
|
)
|
||||||
|
.map((post) => (
|
||||||
|
<PostListItem post={post} key={post.path} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
35
apps/web/app/(marketing)/[locale]/changelog/page.tsx
Normal file
35
apps/web/app/(marketing)/[locale]/changelog/page.tsx
Normal file
|
|
@ -0,0 +1,35 @@
|
||||||
|
import { ChangelogSection } from "@marketing/changelog/components/ChangelogSection";
|
||||||
|
import { getTranslations } from "next-intl/server";
|
||||||
|
|
||||||
|
export default async function PricingPage() {
|
||||||
|
const t = await getTranslations();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="container max-w-3xl pt-32 pb-16">
|
||||||
|
<div className="mb-12 text-balance pt-8 text-center">
|
||||||
|
<h1 className="mb-2 font-bold text-5xl">
|
||||||
|
{t("changelog.title")}
|
||||||
|
</h1>
|
||||||
|
<p className="text-lg opacity-50">
|
||||||
|
{t("changelog.description")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<ChangelogSection
|
||||||
|
items={[
|
||||||
|
{
|
||||||
|
date: "2024-03-01",
|
||||||
|
changes: ["🚀 Improved performance"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
date: "2024-02-01",
|
||||||
|
changes: ["🎨 Updated design", "🐞 Fixed a bug"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
date: "2024-01-01",
|
||||||
|
changes: ["🎉 Added new feature", "🐞 Fixed a bug"],
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
33
apps/web/app/(marketing)/[locale]/contact/page.tsx
Normal file
33
apps/web/app/(marketing)/[locale]/contact/page.tsx
Normal file
|
|
@ -0,0 +1,33 @@
|
||||||
|
import { ContactForm } from "@marketing/home/components/ContactForm";
|
||||||
|
import { config } from "@repo/config";
|
||||||
|
import { getTranslations } from "next-intl/server";
|
||||||
|
import { redirect } from "next/navigation";
|
||||||
|
|
||||||
|
export async function generateMetadata() {
|
||||||
|
const t = await getTranslations();
|
||||||
|
return {
|
||||||
|
title: t("contact.title"),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function ContactPage() {
|
||||||
|
if (!config.contactForm.enabled) {
|
||||||
|
redirect("/");
|
||||||
|
}
|
||||||
|
|
||||||
|
const t = await getTranslations();
|
||||||
|
return (
|
||||||
|
<div className="container max-w-xl pt-32 pb-16">
|
||||||
|
<div className="mb-12 pt-8 text-center">
|
||||||
|
<h1 className="mb-2 font-bold text-5xl">
|
||||||
|
{t("contact.title")}
|
||||||
|
</h1>
|
||||||
|
<p className="text-balance text-lg opacity-50">
|
||||||
|
{t("contact.description")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ContactForm />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,33 @@
|
||||||
|
import { DocsLayout } from "fumadocs-ui/layouts/docs";
|
||||||
|
import { getTranslations } from "next-intl/server";
|
||||||
|
import type { PropsWithChildren } from "react";
|
||||||
|
import { docsSource } from "../../../../docs-source";
|
||||||
|
|
||||||
|
export default async function DocumentationLayout({
|
||||||
|
children,
|
||||||
|
params,
|
||||||
|
}: PropsWithChildren<{
|
||||||
|
params: Promise<{ locale: string }>;
|
||||||
|
}>) {
|
||||||
|
const t = await getTranslations();
|
||||||
|
const { locale } = await params;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="pt-[4.5rem]">
|
||||||
|
<DocsLayout
|
||||||
|
tree={docsSource.pageTree[locale]}
|
||||||
|
disableThemeSwitch
|
||||||
|
i18n
|
||||||
|
nav={{
|
||||||
|
title: <strong>{t("documentation.title")}</strong>,
|
||||||
|
url: "/docs",
|
||||||
|
}}
|
||||||
|
sidebar={{
|
||||||
|
defaultOpenLevel: 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</DocsLayout>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
93
apps/web/app/(marketing)/[locale]/docs/[[...path]]/page.tsx
Normal file
93
apps/web/app/(marketing)/[locale]/docs/[[...path]]/page.tsx
Normal file
|
|
@ -0,0 +1,93 @@
|
||||||
|
import { MDXContent } from "@content-collections/mdx/react";
|
||||||
|
import { config } from "@repo/config";
|
||||||
|
import { File, Files, Folder } from "fumadocs-ui/components/files";
|
||||||
|
import { ImageZoom } from "fumadocs-ui/components/image-zoom";
|
||||||
|
import { Step, Steps } from "fumadocs-ui/components/steps";
|
||||||
|
import { Tab, Tabs } from "fumadocs-ui/components/tabs";
|
||||||
|
import defaultMdxComponents from "fumadocs-ui/mdx";
|
||||||
|
import { DocsBody, DocsPage } from "fumadocs-ui/page";
|
||||||
|
import { getTranslations, setRequestLocale } from "next-intl/server";
|
||||||
|
import { notFound } from "next/navigation";
|
||||||
|
import { docsSource } from "../../../../docs-source";
|
||||||
|
|
||||||
|
export default async function DocumentationPage(props: {
|
||||||
|
params: Promise<{ path?: string[]; locale: string }>;
|
||||||
|
}) {
|
||||||
|
const params = await props.params;
|
||||||
|
setRequestLocale(params.locale);
|
||||||
|
const page = docsSource.getPage(params.path, params.locale);
|
||||||
|
|
||||||
|
if (!page) {
|
||||||
|
notFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DocsPage
|
||||||
|
toc={page.data.toc}
|
||||||
|
full={page.data.full}
|
||||||
|
breadcrumb={{
|
||||||
|
enabled: true,
|
||||||
|
includePage: true,
|
||||||
|
includeSeparator: true,
|
||||||
|
}}
|
||||||
|
tableOfContent={{
|
||||||
|
enabled: true,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DocsBody>
|
||||||
|
<h1 className="text-foreground">{page.data.title}</h1>
|
||||||
|
{page.data.description && (
|
||||||
|
<p className="-mt-6 text-foreground/50 text-lg lg:text-xl">
|
||||||
|
{page.data.description}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<div className="prose dark:prose-invert max-w-full prose-a:text-foreground prose-p:text-foreground/80">
|
||||||
|
<MDXContent
|
||||||
|
code={page.data.body}
|
||||||
|
// @ts-expect-error
|
||||||
|
components={{
|
||||||
|
...defaultMdxComponents,
|
||||||
|
Tabs,
|
||||||
|
Tab,
|
||||||
|
Steps,
|
||||||
|
Step,
|
||||||
|
File,
|
||||||
|
Folder,
|
||||||
|
Files,
|
||||||
|
img: (props) => (
|
||||||
|
<ImageZoom
|
||||||
|
{...(props as any)}
|
||||||
|
className="rounded-lg border-4 border-secondary/10"
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</DocsBody>
|
||||||
|
</DocsPage>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function generateStaticParams() {
|
||||||
|
return docsSource.getPages().flatMap((page) => ({
|
||||||
|
path: page.slugs,
|
||||||
|
locale: page.locale ?? config.i18n.defaultLocale,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function generateMetadata(props: {
|
||||||
|
params: Promise<{ path?: string[]; locale: string }>;
|
||||||
|
}) {
|
||||||
|
const t = await getTranslations();
|
||||||
|
const params = await props.params;
|
||||||
|
const page = docsSource.getPage(params.path, params.locale);
|
||||||
|
|
||||||
|
if (!page) {
|
||||||
|
notFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
title: `${page.data.title} | ${t("documentation.title")}`,
|
||||||
|
description: page.data.description,
|
||||||
|
};
|
||||||
|
}
|
||||||
55
apps/web/app/(marketing)/[locale]/layout.tsx
Normal file
55
apps/web/app/(marketing)/[locale]/layout.tsx
Normal file
|
|
@ -0,0 +1,55 @@
|
||||||
|
import { Footer } from "@marketing/shared/components/Footer";
|
||||||
|
import { NavBar } from "@marketing/shared/components/NavBar";
|
||||||
|
import { config } from "@repo/config";
|
||||||
|
import { SessionProvider } from "@saas/auth/components/SessionProvider";
|
||||||
|
import { Document } from "@shared/components/Document";
|
||||||
|
import { I18nProvider as FumadocsI18nProvider } from "fumadocs-ui/i18n";
|
||||||
|
import { RootProvider as FumadocsRootProvider } from "fumadocs-ui/provider";
|
||||||
|
import { NextIntlClientProvider } from "next-intl";
|
||||||
|
import { getMessages, setRequestLocale } from "next-intl/server";
|
||||||
|
import { notFound } from "next/navigation";
|
||||||
|
import type { PropsWithChildren } from "react";
|
||||||
|
|
||||||
|
const locales = Object.keys(config.i18n.locales);
|
||||||
|
|
||||||
|
export function generateStaticParams() {
|
||||||
|
return locales.map((locale) => ({ locale }));
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function MarketingLayout({
|
||||||
|
children,
|
||||||
|
params,
|
||||||
|
}: PropsWithChildren<{ params: Promise<{ locale: string }> }>) {
|
||||||
|
const { locale } = await params;
|
||||||
|
|
||||||
|
setRequestLocale(locale);
|
||||||
|
|
||||||
|
if (!locales.includes(locale as any)) {
|
||||||
|
notFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
const messages = await getMessages();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Document locale={locale}>
|
||||||
|
<FumadocsI18nProvider locale={locale}>
|
||||||
|
<FumadocsRootProvider
|
||||||
|
search={{
|
||||||
|
enabled: true,
|
||||||
|
options: {
|
||||||
|
api: "/api/docs-search",
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<NextIntlClientProvider locale={locale} messages={messages}>
|
||||||
|
<SessionProvider>
|
||||||
|
<NavBar />
|
||||||
|
<main className="min-h-screen">{children}</main>
|
||||||
|
<Footer />
|
||||||
|
</SessionProvider>
|
||||||
|
</NextIntlClientProvider>
|
||||||
|
</FumadocsRootProvider>
|
||||||
|
</FumadocsI18nProvider>
|
||||||
|
</Document>
|
||||||
|
);
|
||||||
|
}
|
||||||
68
apps/web/app/(marketing)/[locale]/legal/[...path]/page.tsx
Normal file
68
apps/web/app/(marketing)/[locale]/legal/[...path]/page.tsx
Normal file
|
|
@ -0,0 +1,68 @@
|
||||||
|
import { localeRedirect } from "@i18n/routing";
|
||||||
|
import { PostContent } from "@marketing/blog/components/PostContent";
|
||||||
|
import {
|
||||||
|
getActivePathFromUrlParam,
|
||||||
|
getLocalizedDocumentWithFallback,
|
||||||
|
} from "@shared/lib/content";
|
||||||
|
import { allLegalPages } from "content-collections";
|
||||||
|
import { getLocale } from "next-intl/server";
|
||||||
|
|
||||||
|
type Params = {
|
||||||
|
path: string;
|
||||||
|
locale: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function generateMetadata(props: {
|
||||||
|
params: Promise<Params>;
|
||||||
|
}) {
|
||||||
|
const params = await props.params;
|
||||||
|
|
||||||
|
const { path } = params;
|
||||||
|
|
||||||
|
const locale = await getLocale();
|
||||||
|
const activePath = getActivePathFromUrlParam(path);
|
||||||
|
const page = getLocalizedDocumentWithFallback(
|
||||||
|
allLegalPages,
|
||||||
|
activePath,
|
||||||
|
locale,
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
title: page?.title,
|
||||||
|
openGraph: {
|
||||||
|
title: page?.title,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function BlogPostPage(props: {
|
||||||
|
params: Promise<Params>;
|
||||||
|
}) {
|
||||||
|
const params = await props.params;
|
||||||
|
|
||||||
|
const { path } = params;
|
||||||
|
|
||||||
|
const locale = await getLocale();
|
||||||
|
const activePath = getActivePathFromUrlParam(path);
|
||||||
|
const page = getLocalizedDocumentWithFallback(
|
||||||
|
allLegalPages,
|
||||||
|
activePath,
|
||||||
|
locale,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!page) {
|
||||||
|
localeRedirect({ href: "/", locale });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { title, body } = page;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="container max-w-6xl pt-32 pb-24">
|
||||||
|
<div className="mx-auto mb-12 max-w-2xl">
|
||||||
|
<h1 className="text-center font-bold text-4xl">{title}</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<PostContent content={body} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
5
apps/web/app/(marketing)/[locale]/not-found.tsx
Normal file
5
apps/web/app/(marketing)/[locale]/not-found.tsx
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
import { NotFound } from "@marketing/shared/components/NotFound";
|
||||||
|
|
||||||
|
export default async function NotFoundPage() {
|
||||||
|
return <NotFound />;
|
||||||
|
}
|
||||||
73
apps/web/app/(saas)/app/(account)/admin/layout.tsx
Normal file
73
apps/web/app/(saas)/app/(account)/admin/layout.tsx
Normal file
|
|
@ -0,0 +1,73 @@
|
||||||
|
import { config } from "@repo/config";
|
||||||
|
import { getSession } from "@saas/auth/lib/server";
|
||||||
|
import { SettingsMenu } from "@saas/settings/components/SettingsMenu";
|
||||||
|
import { PageHeader } from "@saas/shared/components/PageHeader";
|
||||||
|
import { SidebarContentLayout } from "@saas/shared/components/SidebarContentLayout";
|
||||||
|
import { Logo } from "@shared/components/Logo";
|
||||||
|
import { Building2Icon, UsersIcon } from "lucide-react";
|
||||||
|
import { getTranslations } from "next-intl/server";
|
||||||
|
import { redirect } from "next/navigation";
|
||||||
|
import type { PropsWithChildren } from "react";
|
||||||
|
|
||||||
|
export default async function AdminLayout({ children }: PropsWithChildren) {
|
||||||
|
const t = await getTranslations();
|
||||||
|
const session = await getSession();
|
||||||
|
|
||||||
|
if (!session) {
|
||||||
|
return redirect("/auth/login");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (session.user?.role !== "admin") {
|
||||||
|
redirect("/app");
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<PageHeader
|
||||||
|
title={t("admin.title")}
|
||||||
|
subtitle={t("admin.description")}
|
||||||
|
/>
|
||||||
|
<SidebarContentLayout
|
||||||
|
sidebar={
|
||||||
|
<SettingsMenu
|
||||||
|
menuItems={[
|
||||||
|
{
|
||||||
|
avatar: (
|
||||||
|
<Logo
|
||||||
|
className="size-8"
|
||||||
|
withLabel={false}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
title: t("admin.title"),
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
title: t("admin.menu.users"),
|
||||||
|
href: "/app/admin/users",
|
||||||
|
icon: (
|
||||||
|
<UsersIcon className="size-4 opacity-50" />
|
||||||
|
),
|
||||||
|
},
|
||||||
|
...(config.organizations.enable
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
title: t(
|
||||||
|
"admin.menu.organizations",
|
||||||
|
),
|
||||||
|
href: "/app/admin/organizations",
|
||||||
|
icon: (
|
||||||
|
<Building2Icon className="size-4 opacity-50" />
|
||||||
|
),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: []),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</SidebarContentLayout>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,52 @@
|
||||||
|
import { auth } from "@repo/auth";
|
||||||
|
import { OrganizationForm } from "@saas/admin/component/organizations/OrganizationForm";
|
||||||
|
import { getAdminPath } from "@saas/admin/lib/links";
|
||||||
|
import { fullOrganizationQueryKey } from "@saas/organizations/lib/api";
|
||||||
|
import { getServerQueryClient } from "@shared/lib/server";
|
||||||
|
import { HydrationBoundary, dehydrate } from "@tanstack/react-query";
|
||||||
|
import { Button } from "@ui/components/button";
|
||||||
|
import { ArrowLeftIcon } from "lucide-react";
|
||||||
|
import { getTranslations } from "next-intl/server";
|
||||||
|
import { headers } from "next/headers";
|
||||||
|
import Link from "next/link";
|
||||||
|
|
||||||
|
export default async function OrganizationFormPage({
|
||||||
|
params,
|
||||||
|
searchParams,
|
||||||
|
}: {
|
||||||
|
params: Promise<{ id: string }>;
|
||||||
|
searchParams: Promise<{ backTo?: string }>;
|
||||||
|
}) {
|
||||||
|
const { id } = await params;
|
||||||
|
const { backTo } = await searchParams;
|
||||||
|
|
||||||
|
const t = await getTranslations();
|
||||||
|
const queryClient = getServerQueryClient();
|
||||||
|
|
||||||
|
await queryClient.prefetchQuery({
|
||||||
|
queryKey: fullOrganizationQueryKey(id),
|
||||||
|
queryFn: async () =>
|
||||||
|
await auth.api.getFullOrganization({
|
||||||
|
query: {
|
||||||
|
organizationId: id,
|
||||||
|
},
|
||||||
|
headers: await headers(),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<HydrationBoundary state={dehydrate(queryClient)}>
|
||||||
|
<div>
|
||||||
|
<div className="mb-2 flex justify-start">
|
||||||
|
<Button variant="link" size="sm" asChild className="px-0">
|
||||||
|
<Link href={backTo ?? getAdminPath("/organizations")}>
|
||||||
|
<ArrowLeftIcon className="mr-1.5 size-4" />
|
||||||
|
{t("admin.organizations.backToList")}
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<OrganizationForm organizationId={id} />
|
||||||
|
</div>
|
||||||
|
</HydrationBoundary>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,5 @@
|
||||||
|
import { OrganizationList } from "@saas/admin/component/organizations/OrganizationList";
|
||||||
|
|
||||||
|
export default function AdminOrganizationsPage() {
|
||||||
|
return <OrganizationList />;
|
||||||
|
}
|
||||||
9
apps/web/app/(saas)/app/(account)/admin/users/page.tsx
Normal file
9
apps/web/app/(saas)/app/(account)/admin/users/page.tsx
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
import { UserList } from "@saas/admin/component/users/UserList";
|
||||||
|
|
||||||
|
export default function AdminUserPage() {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<UserList />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
54
apps/web/app/(saas)/app/(account)/chatbot/page.tsx
Normal file
54
apps/web/app/(saas)/app/(account)/chatbot/page.tsx
Normal file
|
|
@ -0,0 +1,54 @@
|
||||||
|
import { AiChat } from "@saas/ai/components/AiChat";
|
||||||
|
import { aiChatListQueryKey, aiChatQueryKey } from "@saas/ai/lib/api";
|
||||||
|
import { PageHeader } from "@saas/shared/components/PageHeader";
|
||||||
|
import { getServerApiClient, getServerQueryClient } from "@shared/lib/server";
|
||||||
|
|
||||||
|
export default async function AiDemoPage() {
|
||||||
|
const queryClient = getServerQueryClient();
|
||||||
|
const apiClient = await getServerApiClient();
|
||||||
|
|
||||||
|
const chats = await (async () => {
|
||||||
|
const response = await apiClient.ai.chats.$get({});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error("Failed to fetch chats");
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.json().then((data) => data.chats);
|
||||||
|
})();
|
||||||
|
|
||||||
|
await queryClient.prefetchQuery({
|
||||||
|
queryKey: aiChatListQueryKey(),
|
||||||
|
queryFn: async () => chats,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (chats.length > 0) {
|
||||||
|
await queryClient.prefetchQuery({
|
||||||
|
queryKey: aiChatQueryKey(chats[0].id),
|
||||||
|
queryFn: async () => {
|
||||||
|
const response = await apiClient.ai.chats[":id"].$get({
|
||||||
|
param: {
|
||||||
|
id: chats[0].id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error("Failed to fetch chat");
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.json();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<PageHeader
|
||||||
|
title="AI Chatbot"
|
||||||
|
subtitle="This is an example chatbot built with the OpenAI API"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<AiChat />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
6
apps/web/app/(saas)/app/(account)/layout.tsx
Normal file
6
apps/web/app/(saas)/app/(account)/layout.tsx
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
import { AppWrapper } from "@saas/shared/components/AppWrapper";
|
||||||
|
import type { PropsWithChildren } from "react";
|
||||||
|
|
||||||
|
export default function UserLayout({ children }: PropsWithChildren) {
|
||||||
|
return <AppWrapper>{children}</AppWrapper>;
|
||||||
|
}
|
||||||
26
apps/web/app/(saas)/app/(account)/page.tsx
Normal file
26
apps/web/app/(saas)/app/(account)/page.tsx
Normal file
|
|
@ -0,0 +1,26 @@
|
||||||
|
import { getSession } from "@saas/auth/lib/server";
|
||||||
|
import { PageHeader } from "@saas/shared/components/PageHeader";
|
||||||
|
import UserStart from "@saas/start/UserStart";
|
||||||
|
import { getTranslations } from "next-intl/server";
|
||||||
|
import { redirect } from "next/navigation";
|
||||||
|
|
||||||
|
export default async function AppStartPage() {
|
||||||
|
const session = await getSession();
|
||||||
|
|
||||||
|
if (!session) {
|
||||||
|
return redirect("/auth/login");
|
||||||
|
}
|
||||||
|
|
||||||
|
const t = await getTranslations();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="">
|
||||||
|
<PageHeader
|
||||||
|
title={t("start.welcome", { name: session?.user.name })}
|
||||||
|
subtitle={t("start.subtitle")}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<UserStart />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
40
apps/web/app/(saas)/app/(account)/settings/billing/page.tsx
Normal file
40
apps/web/app/(saas)/app/(account)/settings/billing/page.tsx
Normal file
|
|
@ -0,0 +1,40 @@
|
||||||
|
import { createPurchasesHelper } from "@repo/payments/lib/helper";
|
||||||
|
import { getSession } from "@saas/auth/lib/server";
|
||||||
|
import { ActivePlan } from "@saas/payments/components/ActivePlan";
|
||||||
|
import { ChangePlan } from "@saas/payments/components/ChangePlan";
|
||||||
|
import { purchasesQueryKey } from "@saas/payments/lib/api";
|
||||||
|
import { getPurchases } from "@saas/payments/lib/server";
|
||||||
|
import { SettingsList } from "@saas/shared/components/SettingsList";
|
||||||
|
import { getServerQueryClient } from "@shared/lib/server";
|
||||||
|
import { getTranslations } from "next-intl/server";
|
||||||
|
|
||||||
|
export async function generateMetadata() {
|
||||||
|
const t = await getTranslations();
|
||||||
|
|
||||||
|
return {
|
||||||
|
title: t("settings.billing.title"),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function BillingSettingsPage() {
|
||||||
|
const session = await getSession();
|
||||||
|
const purchases = await getPurchases();
|
||||||
|
const queryClient = getServerQueryClient();
|
||||||
|
|
||||||
|
await queryClient.prefetchQuery({
|
||||||
|
queryKey: purchasesQueryKey(),
|
||||||
|
queryFn: () => purchases,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { activePlan } = createPurchasesHelper(purchases);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SettingsList>
|
||||||
|
{activePlan && <ActivePlan />}
|
||||||
|
<ChangePlan
|
||||||
|
userId={session?.user.id}
|
||||||
|
activePlanId={activePlan?.id}
|
||||||
|
/>
|
||||||
|
</SettingsList>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,27 @@
|
||||||
|
import { getSession } from "@saas/auth/lib/server";
|
||||||
|
import { DeleteAccountForm } from "@saas/settings/components/DeleteAccountForm";
|
||||||
|
import { SettingsList } from "@saas/shared/components/SettingsList";
|
||||||
|
import { getTranslations } from "next-intl/server";
|
||||||
|
import { redirect } from "next/navigation";
|
||||||
|
|
||||||
|
export async function generateMetadata() {
|
||||||
|
const t = await getTranslations();
|
||||||
|
|
||||||
|
return {
|
||||||
|
title: t("settings.account.title"),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function AccountSettingsPage() {
|
||||||
|
const session = await getSession();
|
||||||
|
|
||||||
|
if (!session) {
|
||||||
|
return redirect("/auth/login");
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SettingsList>
|
||||||
|
<DeleteAccountForm />
|
||||||
|
</SettingsList>
|
||||||
|
);
|
||||||
|
}
|
||||||
34
apps/web/app/(saas)/app/(account)/settings/general/page.tsx
Normal file
34
apps/web/app/(saas)/app/(account)/settings/general/page.tsx
Normal file
|
|
@ -0,0 +1,34 @@
|
||||||
|
import { config } from "@repo/config";
|
||||||
|
import { getSession } from "@saas/auth/lib/server";
|
||||||
|
import { ChangeEmailForm } from "@saas/settings/components/ChangeEmailForm";
|
||||||
|
import { ChangeNameForm } from "@saas/settings/components/ChangeNameForm";
|
||||||
|
import { UserAvatarForm } from "@saas/settings/components/UserAvatarForm";
|
||||||
|
import { UserLanguageForm } from "@saas/settings/components/UserLanguageForm";
|
||||||
|
import { SettingsList } from "@saas/shared/components/SettingsList";
|
||||||
|
import { getTranslations } from "next-intl/server";
|
||||||
|
import { redirect } from "next/navigation";
|
||||||
|
|
||||||
|
export async function generateMetadata() {
|
||||||
|
const t = await getTranslations();
|
||||||
|
|
||||||
|
return {
|
||||||
|
title: t("settings.account.title"),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function AccountSettingsPage() {
|
||||||
|
const session = await getSession();
|
||||||
|
|
||||||
|
if (!session) {
|
||||||
|
return redirect("/auth/login");
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SettingsList>
|
||||||
|
<UserAvatarForm />
|
||||||
|
{config.i18n.enabled && <UserLanguageForm />}
|
||||||
|
<ChangeNameForm />
|
||||||
|
<ChangeEmailForm />
|
||||||
|
</SettingsList>
|
||||||
|
);
|
||||||
|
}
|
||||||
77
apps/web/app/(saas)/app/(account)/settings/layout.tsx
Normal file
77
apps/web/app/(saas)/app/(account)/settings/layout.tsx
Normal file
|
|
@ -0,0 +1,77 @@
|
||||||
|
import { config } from "@repo/config";
|
||||||
|
import { getSession } from "@saas/auth/lib/server";
|
||||||
|
import { SettingsMenu } from "@saas/settings/components/SettingsMenu";
|
||||||
|
import { PageHeader } from "@saas/shared/components/PageHeader";
|
||||||
|
import { SidebarContentLayout } from "@saas/shared/components/SidebarContentLayout";
|
||||||
|
import { UserAvatar } from "@shared/components/UserAvatar";
|
||||||
|
import {
|
||||||
|
CreditCardIcon,
|
||||||
|
LockKeyholeIcon,
|
||||||
|
SettingsIcon,
|
||||||
|
TriangleAlertIcon,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { getTranslations } from "next-intl/server";
|
||||||
|
import { redirect } from "next/navigation";
|
||||||
|
import type { PropsWithChildren } from "react";
|
||||||
|
export default async function SettingsLayout({ children }: PropsWithChildren) {
|
||||||
|
const t = await getTranslations();
|
||||||
|
const session = await getSession();
|
||||||
|
|
||||||
|
if (!session) {
|
||||||
|
return redirect("/auth/login");
|
||||||
|
}
|
||||||
|
|
||||||
|
const menuItems = [
|
||||||
|
{
|
||||||
|
title: t("settings.menu.account.title"),
|
||||||
|
avatar: (
|
||||||
|
<UserAvatar
|
||||||
|
name={session.user.name ?? ""}
|
||||||
|
avatarUrl={session.user.image}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
title: t("settings.menu.account.general"),
|
||||||
|
href: "/app/settings/general",
|
||||||
|
icon: <SettingsIcon className="size-4 opacity-50" />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: t("settings.menu.account.security"),
|
||||||
|
href: "/app/settings/security",
|
||||||
|
icon: <LockKeyholeIcon className="size-4 opacity-50" />,
|
||||||
|
},
|
||||||
|
...(config.users.enableBilling
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
title: t("settings.menu.account.billing"),
|
||||||
|
href: "/app/settings/billing",
|
||||||
|
icon: (
|
||||||
|
<CreditCardIcon className="size-4 opacity-50" />
|
||||||
|
),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: []),
|
||||||
|
{
|
||||||
|
title: t("settings.menu.account.dangerZone"),
|
||||||
|
href: "/app/settings/danger-zone",
|
||||||
|
icon: <TriangleAlertIcon className="size-4 opacity-50" />,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<PageHeader
|
||||||
|
title={t("settings.account.title")}
|
||||||
|
subtitle={t("settings.account.subtitle")}
|
||||||
|
/>
|
||||||
|
<SidebarContentLayout
|
||||||
|
sidebar={<SettingsMenu menuItems={menuItems} />}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</SidebarContentLayout>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
68
apps/web/app/(saas)/app/(account)/settings/security/page.tsx
Normal file
68
apps/web/app/(saas)/app/(account)/settings/security/page.tsx
Normal file
|
|
@ -0,0 +1,68 @@
|
||||||
|
import { config } from "@repo/config";
|
||||||
|
import { userAccountQueryKey, userPasskeyQueryKey } from "@saas/auth/lib/api";
|
||||||
|
import {
|
||||||
|
getSession,
|
||||||
|
getUserAccounts,
|
||||||
|
getUserPasskeys,
|
||||||
|
} from "@saas/auth/lib/server";
|
||||||
|
import { ActiveSessionsBlock } from "@saas/settings/components/ActiveSessionsBlock";
|
||||||
|
import { ChangePasswordForm } from "@saas/settings/components/ChangePassword";
|
||||||
|
import { ConnectedAccountsBlock } from "@saas/settings/components/ConnectedAccountsBlock";
|
||||||
|
import { PasskeysBlock } from "@saas/settings/components/PasskeysBlock";
|
||||||
|
import { SetPasswordForm } from "@saas/settings/components/SetPassword";
|
||||||
|
import { TwoFactorBlock } from "@saas/settings/components/TwoFactorBlock";
|
||||||
|
import { SettingsList } from "@saas/shared/components/SettingsList";
|
||||||
|
import { getServerQueryClient } from "@shared/lib/server";
|
||||||
|
import { getTranslations } from "next-intl/server";
|
||||||
|
import { redirect } from "next/navigation";
|
||||||
|
|
||||||
|
export async function generateMetadata() {
|
||||||
|
const t = await getTranslations();
|
||||||
|
|
||||||
|
return {
|
||||||
|
title: t("settings.account.security.title"),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function AccountSettingsPage() {
|
||||||
|
const session = await getSession();
|
||||||
|
|
||||||
|
if (!session) {
|
||||||
|
return redirect("/auth/login");
|
||||||
|
}
|
||||||
|
|
||||||
|
const userAccounts = await getUserAccounts();
|
||||||
|
|
||||||
|
const userHasPassword = userAccounts?.some(
|
||||||
|
(account) => account.provider === "credential",
|
||||||
|
);
|
||||||
|
|
||||||
|
const queryClient = getServerQueryClient();
|
||||||
|
|
||||||
|
await queryClient.prefetchQuery({
|
||||||
|
queryKey: userAccountQueryKey,
|
||||||
|
queryFn: () => getUserAccounts(),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (config.auth.enablePasskeys) {
|
||||||
|
await queryClient.prefetchQuery({
|
||||||
|
queryKey: userPasskeyQueryKey,
|
||||||
|
queryFn: () => getUserPasskeys(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SettingsList>
|
||||||
|
{config.auth.enablePasswordLogin &&
|
||||||
|
(userHasPassword ? (
|
||||||
|
<ChangePasswordForm />
|
||||||
|
) : (
|
||||||
|
<SetPasswordForm />
|
||||||
|
))}
|
||||||
|
{config.auth.enableSocialLogin && <ConnectedAccountsBlock />}
|
||||||
|
{config.auth.enablePasskeys && <PasskeysBlock />}
|
||||||
|
{config.auth.enableTwoFactor && <TwoFactorBlock />}
|
||||||
|
<ActiveSessionsBlock />
|
||||||
|
</SettingsList>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,8 @@
|
||||||
|
import { AuthWrapper } from "@saas/shared/components/AuthWrapper";
|
||||||
|
import type { PropsWithChildren } from "react";
|
||||||
|
|
||||||
|
export default function WithoutOrganizationSlugLayout({
|
||||||
|
children,
|
||||||
|
}: PropsWithChildren) {
|
||||||
|
return <AuthWrapper>{children}</AuthWrapper>;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,21 @@
|
||||||
|
import { config } from "@repo/config";
|
||||||
|
import { getOrganizationList } from "@saas/auth/lib/server";
|
||||||
|
import { CreateOrganizationForm } from "@saas/organizations/components/CreateOrganizationForm";
|
||||||
|
import { redirect } from "next/navigation";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
export default async function NewOrganizationPage() {
|
||||||
|
const organizations = await getOrganizationList();
|
||||||
|
|
||||||
|
if (
|
||||||
|
!config.organizations.enable ||
|
||||||
|
(!config.organizations.enableUsersToCreateOrganizations &&
|
||||||
|
(!config.organizations.requireOrganization ||
|
||||||
|
organizations.length > 0))
|
||||||
|
) {
|
||||||
|
return redirect("/app");
|
||||||
|
}
|
||||||
|
|
||||||
|
return <CreateOrganizationForm />;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,35 @@
|
||||||
|
import { auth } from "@repo/auth";
|
||||||
|
import { getOrganizationById } from "@repo/database";
|
||||||
|
import { OrganizationInvitationModal } from "@saas/organizations/components/OrganizationInvitationModal";
|
||||||
|
import { headers } from "next/headers";
|
||||||
|
import { redirect } from "next/navigation";
|
||||||
|
|
||||||
|
export default async function OrganizationInvitationPage({
|
||||||
|
params,
|
||||||
|
}: {
|
||||||
|
params: Promise<{ invitationId: string }>;
|
||||||
|
}) {
|
||||||
|
const { invitationId } = await params;
|
||||||
|
|
||||||
|
const invitation = await auth.api.getInvitation({
|
||||||
|
query: {
|
||||||
|
id: invitationId,
|
||||||
|
},
|
||||||
|
headers: await headers(),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!invitation) {
|
||||||
|
redirect("/app");
|
||||||
|
}
|
||||||
|
|
||||||
|
const organization = await getOrganizationById(invitation.organizationId);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<OrganizationInvitationModal
|
||||||
|
organizationName={invitation.organizationName}
|
||||||
|
organizationSlug={invitation.organizationSlug}
|
||||||
|
logoUrl={organization?.logo || undefined}
|
||||||
|
invitationId={invitationId}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,72 @@
|
||||||
|
import { AiChat } from "@saas/ai/components/AiChat";
|
||||||
|
import { aiChatListQueryKey, aiChatQueryKey } from "@saas/ai/lib/api";
|
||||||
|
import { getActiveOrganization } from "@saas/auth/lib/server";
|
||||||
|
import { PageHeader } from "@saas/shared/components/PageHeader";
|
||||||
|
import { getServerApiClient, getServerQueryClient } from "@shared/lib/server";
|
||||||
|
import { redirect } from "next/navigation";
|
||||||
|
|
||||||
|
export default async function AiDemoPage({
|
||||||
|
params,
|
||||||
|
}: {
|
||||||
|
params: Promise<{ organizationSlug: string }>;
|
||||||
|
}) {
|
||||||
|
const { organizationSlug } = await params;
|
||||||
|
const organization = await getActiveOrganization(organizationSlug);
|
||||||
|
const queryClient = getServerQueryClient();
|
||||||
|
const apiClient = await getServerApiClient();
|
||||||
|
|
||||||
|
if (!organization) {
|
||||||
|
redirect("/app");
|
||||||
|
}
|
||||||
|
|
||||||
|
const organizationId = organization.id;
|
||||||
|
|
||||||
|
const chats = await (async () => {
|
||||||
|
const response = await apiClient.ai.chats.$get({
|
||||||
|
query: {
|
||||||
|
organizationId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error("Failed to fetch chats");
|
||||||
|
}
|
||||||
|
|
||||||
|
return (await response.json()).chats;
|
||||||
|
})();
|
||||||
|
|
||||||
|
await queryClient.prefetchQuery({
|
||||||
|
queryKey: aiChatListQueryKey(organizationId),
|
||||||
|
queryFn: async () => chats,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (chats.length > 0) {
|
||||||
|
await queryClient.prefetchQuery({
|
||||||
|
queryKey: aiChatQueryKey(chats[0].id),
|
||||||
|
queryFn: async () => {
|
||||||
|
const response = await apiClient.ai.chats[":id"].$get({
|
||||||
|
param: {
|
||||||
|
id: chats[0].id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error("Failed to fetch chat");
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.json();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<PageHeader
|
||||||
|
title="AI Chatbot"
|
||||||
|
subtitle="This is an example chatbot built with the OpenAI API"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<AiChat organizationId={organizationId} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,42 @@
|
||||||
|
import { config } from "@repo/config";
|
||||||
|
import { getActiveOrganization } from "@saas/auth/lib/server";
|
||||||
|
import { activeOrganizationQueryKey } from "@saas/organizations/lib/api";
|
||||||
|
import { purchasesQueryKey } from "@saas/payments/lib/api";
|
||||||
|
import { getPurchases } from "@saas/payments/lib/server";
|
||||||
|
import { AppWrapper } from "@saas/shared/components/AppWrapper";
|
||||||
|
import { getServerQueryClient } from "@shared/lib/server";
|
||||||
|
import { notFound } from "next/navigation";
|
||||||
|
import type { PropsWithChildren } from "react";
|
||||||
|
|
||||||
|
export default async function OrganizationLayout({
|
||||||
|
children,
|
||||||
|
params,
|
||||||
|
}: PropsWithChildren<{
|
||||||
|
params: Promise<{
|
||||||
|
organizationSlug: string;
|
||||||
|
}>;
|
||||||
|
}>) {
|
||||||
|
const { organizationSlug } = await params;
|
||||||
|
|
||||||
|
const organization = await getActiveOrganization(organizationSlug);
|
||||||
|
|
||||||
|
if (!organization) {
|
||||||
|
return notFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
const queryClient = getServerQueryClient();
|
||||||
|
|
||||||
|
await queryClient.prefetchQuery({
|
||||||
|
queryKey: activeOrganizationQueryKey(organizationSlug),
|
||||||
|
queryFn: () => organization,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (config.users.enableBilling) {
|
||||||
|
await queryClient.prefetchQuery({
|
||||||
|
queryKey: purchasesQueryKey(organization.id),
|
||||||
|
queryFn: () => getPurchases(organization.id),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return <AppWrapper>{children}</AppWrapper>;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,47 @@
|
||||||
|
import { getActiveOrganization } from "@saas/auth/lib/server";
|
||||||
|
import OrganizationStart from "@saas/organizations/components/OrganizationStart";
|
||||||
|
import { PageHeader } from "@saas/shared/components/PageHeader";
|
||||||
|
import { getTranslations } from "next-intl/server";
|
||||||
|
import { notFound } from "next/navigation";
|
||||||
|
|
||||||
|
export async function generateMetadata({
|
||||||
|
params,
|
||||||
|
}: {
|
||||||
|
params: Promise<{ organizationSlug: string }>;
|
||||||
|
}) {
|
||||||
|
const { organizationSlug } = await params;
|
||||||
|
|
||||||
|
const activeOrganization = await getActiveOrganization(
|
||||||
|
organizationSlug as string,
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
title: activeOrganization?.name,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function OrganizationPage({
|
||||||
|
params,
|
||||||
|
}: { params: Promise<{ organizationSlug: string }> }) {
|
||||||
|
const { organizationSlug } = await params;
|
||||||
|
const t = await getTranslations();
|
||||||
|
|
||||||
|
const activeOrganization = await getActiveOrganization(
|
||||||
|
organizationSlug as string,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!activeOrganization) {
|
||||||
|
return notFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<PageHeader
|
||||||
|
title={activeOrganization.name}
|
||||||
|
subtitle={t("organizations.start.subtitle")}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<OrganizationStart />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,51 @@
|
||||||
|
import { createPurchasesHelper } from "@repo/payments/lib/helper";
|
||||||
|
import { getActiveOrganization } from "@saas/auth/lib/server";
|
||||||
|
import { ActivePlan } from "@saas/payments/components/ActivePlan";
|
||||||
|
import { ChangePlan } from "@saas/payments/components/ChangePlan";
|
||||||
|
import { purchasesQueryKey } from "@saas/payments/lib/api";
|
||||||
|
import { getPurchases } from "@saas/payments/lib/server";
|
||||||
|
import { SettingsList } from "@saas/shared/components/SettingsList";
|
||||||
|
import { getServerQueryClient } from "@shared/lib/server";
|
||||||
|
import { getTranslations } from "next-intl/server";
|
||||||
|
import { notFound } from "next/navigation";
|
||||||
|
|
||||||
|
export async function generateMetadata() {
|
||||||
|
const t = await getTranslations();
|
||||||
|
|
||||||
|
return {
|
||||||
|
title: t("settings.billing.title"),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function BillingSettingsPage({
|
||||||
|
params,
|
||||||
|
}: {
|
||||||
|
params: Promise<{ organizationSlug: string }>;
|
||||||
|
}) {
|
||||||
|
const { organizationSlug } = await params;
|
||||||
|
const organization = await getActiveOrganization(organizationSlug);
|
||||||
|
|
||||||
|
if (!organization) {
|
||||||
|
return notFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
const purchases = await getPurchases(organization.id);
|
||||||
|
const queryClient = getServerQueryClient();
|
||||||
|
|
||||||
|
await queryClient.prefetchQuery({
|
||||||
|
queryKey: purchasesQueryKey(organization.id),
|
||||||
|
queryFn: () => purchases,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { activePlan } = createPurchasesHelper(purchases);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SettingsList>
|
||||||
|
{activePlan && <ActivePlan organizationId={organization.id} />}
|
||||||
|
<ChangePlan
|
||||||
|
organizationId={organization.id}
|
||||||
|
activePlanId={activePlan?.id}
|
||||||
|
/>
|
||||||
|
</SettingsList>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,19 @@
|
||||||
|
import { DeleteOrganizationForm } from "@saas/organizations/components/DeleteOrganizationForm";
|
||||||
|
import { SettingsList } from "@saas/shared/components/SettingsList";
|
||||||
|
import { getTranslations } from "next-intl/server";
|
||||||
|
|
||||||
|
export async function generateMetadata() {
|
||||||
|
const t = await getTranslations();
|
||||||
|
|
||||||
|
return {
|
||||||
|
title: t("organizations.settings.dangerZone.title"),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function OrganizationSettingsPage() {
|
||||||
|
return (
|
||||||
|
<SettingsList>
|
||||||
|
<DeleteOrganizationForm />
|
||||||
|
</SettingsList>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,21 @@
|
||||||
|
import { ChangeOrganizationNameForm } from "@saas/organizations/components/ChangeOrganizationNameForm";
|
||||||
|
import { OrganizationLogoForm } from "@saas/organizations/components/OrganizationLogoForm";
|
||||||
|
import { SettingsList } from "@saas/shared/components/SettingsList";
|
||||||
|
import { getTranslations } from "next-intl/server";
|
||||||
|
|
||||||
|
export async function generateMetadata() {
|
||||||
|
const t = await getTranslations();
|
||||||
|
|
||||||
|
return {
|
||||||
|
title: t("organizations.settings.title"),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function OrganizationSettingsPage() {
|
||||||
|
return (
|
||||||
|
<SettingsList>
|
||||||
|
<OrganizationLogoForm />
|
||||||
|
<ChangeOrganizationNameForm />
|
||||||
|
</SettingsList>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,99 @@
|
||||||
|
import { isOrganizationAdmin } from "@repo/auth/lib/helper";
|
||||||
|
import { config } from "@repo/config";
|
||||||
|
import { getActiveOrganization, getSession } from "@saas/auth/lib/server";
|
||||||
|
import { OrganizationLogo } from "@saas/organizations/components/OrganizationLogo";
|
||||||
|
import { SettingsMenu } from "@saas/settings/components/SettingsMenu";
|
||||||
|
import { PageHeader } from "@saas/shared/components/PageHeader";
|
||||||
|
import { SidebarContentLayout } from "@saas/shared/components/SidebarContentLayout";
|
||||||
|
import {
|
||||||
|
CreditCardIcon,
|
||||||
|
Settings2Icon,
|
||||||
|
TriangleAlertIcon,
|
||||||
|
Users2Icon,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { getTranslations } from "next-intl/server";
|
||||||
|
import { redirect } from "next/navigation";
|
||||||
|
import type { PropsWithChildren } from "react";
|
||||||
|
|
||||||
|
export default async function SettingsLayout({
|
||||||
|
children,
|
||||||
|
params,
|
||||||
|
}: PropsWithChildren<{
|
||||||
|
params: Promise<{ organizationSlug: string }>;
|
||||||
|
}>) {
|
||||||
|
const t = await getTranslations();
|
||||||
|
const session = await getSession();
|
||||||
|
const { organizationSlug } = await params;
|
||||||
|
const organization = await getActiveOrganization(organizationSlug);
|
||||||
|
|
||||||
|
if (!organization) {
|
||||||
|
redirect("/app");
|
||||||
|
}
|
||||||
|
|
||||||
|
const userIsOrganizationAdmin = isOrganizationAdmin(
|
||||||
|
organization,
|
||||||
|
session?.user,
|
||||||
|
);
|
||||||
|
|
||||||
|
const organizationSettingsBasePath = `/app/${organizationSlug}/settings`;
|
||||||
|
|
||||||
|
const menuItems = [
|
||||||
|
{
|
||||||
|
title: t("settings.menu.organization.title"),
|
||||||
|
avatar: (
|
||||||
|
<OrganizationLogo
|
||||||
|
name={organization.name}
|
||||||
|
logoUrl={organization.logo}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
title: t("settings.menu.organization.general"),
|
||||||
|
href: `${organizationSettingsBasePath}/general`,
|
||||||
|
icon: <Settings2Icon className="size-4 opacity-50" />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: t("settings.menu.organization.members"),
|
||||||
|
href: `${organizationSettingsBasePath}/members`,
|
||||||
|
icon: <Users2Icon className="size-4 opacity-50" />,
|
||||||
|
},
|
||||||
|
...(config.organizations.enable &&
|
||||||
|
config.organizations.enableBilling &&
|
||||||
|
userIsOrganizationAdmin
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
title: t("settings.menu.organization.billing"),
|
||||||
|
href: `${organizationSettingsBasePath}/billing`,
|
||||||
|
icon: (
|
||||||
|
<CreditCardIcon className="size-4 opacity-50" />
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: t(
|
||||||
|
"settings.menu.organization.dangerZone",
|
||||||
|
),
|
||||||
|
href: `${organizationSettingsBasePath}/danger-zone`,
|
||||||
|
icon: (
|
||||||
|
<TriangleAlertIcon className="size-4 opacity-50" />
|
||||||
|
),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: []),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<PageHeader
|
||||||
|
title={t("organizations.settings.title")}
|
||||||
|
subtitle={t("organizations.settings.subtitle")}
|
||||||
|
/>
|
||||||
|
<SidebarContentLayout
|
||||||
|
sidebar={<SettingsMenu menuItems={menuItems} />}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</SidebarContentLayout>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,37 @@
|
||||||
|
import { isOrganizationAdmin } from "@repo/auth/lib/helper";
|
||||||
|
import { getActiveOrganization, getSession } from "@saas/auth/lib/server";
|
||||||
|
import { InviteMemberForm } from "@saas/organizations/components/InviteMemberForm";
|
||||||
|
import { OrganizationMembersBlock } from "@saas/organizations/components/OrganizationMembersBlock";
|
||||||
|
import { SettingsList } from "@saas/shared/components/SettingsList";
|
||||||
|
import { getTranslations } from "next-intl/server";
|
||||||
|
import { notFound } from "next/navigation";
|
||||||
|
export async function generateMetadata() {
|
||||||
|
const t = await getTranslations();
|
||||||
|
|
||||||
|
return {
|
||||||
|
title: t("organizations.settings.title"),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function OrganizationSettingsPage({
|
||||||
|
params,
|
||||||
|
}: {
|
||||||
|
params: Promise<{ organizationSlug: string }>;
|
||||||
|
}) {
|
||||||
|
const session = await getSession();
|
||||||
|
const { organizationSlug } = await params;
|
||||||
|
const organization = await getActiveOrganization(organizationSlug);
|
||||||
|
|
||||||
|
if (!organization) {
|
||||||
|
return notFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SettingsList>
|
||||||
|
{isOrganizationAdmin(organization, session?.user) && (
|
||||||
|
<InviteMemberForm organizationId={organization.id} />
|
||||||
|
)}
|
||||||
|
<OrganizationMembersBlock organizationId={organization.id} />
|
||||||
|
</SettingsList>
|
||||||
|
);
|
||||||
|
}
|
||||||
5
apps/web/app/(saas)/app/[...rest]/page.tsx
Normal file
5
apps/web/app/(saas)/app/[...rest]/page.tsx
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
import { notFound } from "next/navigation";
|
||||||
|
|
||||||
|
export default function CatchAll() {
|
||||||
|
notFound();
|
||||||
|
}
|
||||||
71
apps/web/app/(saas)/app/choose-plan/page.tsx
Normal file
71
apps/web/app/(saas)/app/choose-plan/page.tsx
Normal file
|
|
@ -0,0 +1,71 @@
|
||||||
|
import { config } from "@repo/config";
|
||||||
|
import { createPurchasesHelper } from "@repo/payments/lib/helper";
|
||||||
|
import { getOrganizationList, getSession } from "@saas/auth/lib/server";
|
||||||
|
import { PricingTable } from "@saas/payments/components/PricingTable";
|
||||||
|
import { getPurchases } from "@saas/payments/lib/server";
|
||||||
|
import { AuthWrapper } from "@saas/shared/components/AuthWrapper";
|
||||||
|
import { getTranslations } from "next-intl/server";
|
||||||
|
import { redirect } from "next/navigation";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
export const revalidate = 0;
|
||||||
|
|
||||||
|
export async function generateMetadata() {
|
||||||
|
const t = await getTranslations();
|
||||||
|
|
||||||
|
return {
|
||||||
|
title: t("choosePlan.title"),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function ChoosePlanPage() {
|
||||||
|
const t = await getTranslations();
|
||||||
|
const session = await getSession();
|
||||||
|
|
||||||
|
if (!session) {
|
||||||
|
return redirect("/auth/login");
|
||||||
|
}
|
||||||
|
|
||||||
|
let organizationId: string | undefined;
|
||||||
|
if (config.organizations.enable && config.organizations.enableBilling) {
|
||||||
|
const organization = (await getOrganizationList()).at(0);
|
||||||
|
|
||||||
|
if (!organization) {
|
||||||
|
redirect("/app/new-organization");
|
||||||
|
}
|
||||||
|
|
||||||
|
organizationId = organization.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
const purchases = await getPurchases(organizationId);
|
||||||
|
const { activePlan } = createPurchasesHelper(purchases);
|
||||||
|
|
||||||
|
if (activePlan) {
|
||||||
|
return redirect("/app");
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AuthWrapper contentClass="max-w-5xl">
|
||||||
|
<div className="mb-4 text-center">
|
||||||
|
<h1 className="text-center font-bold text-2xl lg:text-3xl">
|
||||||
|
{t("choosePlan.title")}
|
||||||
|
</h1>
|
||||||
|
<p className="text-muted-foreground text-sm lg:text-base">
|
||||||
|
{t("choosePlan.description")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<PricingTable
|
||||||
|
{...(organizationId
|
||||||
|
? {
|
||||||
|
organizationId,
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
userId: session.user.id,
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</AuthWrapper>
|
||||||
|
);
|
||||||
|
}
|
||||||
52
apps/web/app/(saas)/app/layout.tsx
Normal file
52
apps/web/app/(saas)/app/layout.tsx
Normal file
|
|
@ -0,0 +1,52 @@
|
||||||
|
import { config } from "@repo/config";
|
||||||
|
import { SessionProvider } from "@saas/auth/components/SessionProvider";
|
||||||
|
import { sessionQueryKey } from "@saas/auth/lib/api";
|
||||||
|
import { getOrganizationList, getSession } from "@saas/auth/lib/server";
|
||||||
|
import { ActiveOrganizationProvider } from "@saas/organizations/components/ActiveOrganizationProvider";
|
||||||
|
import { organizationListQueryKey } from "@saas/organizations/lib/api";
|
||||||
|
import { purchasesQueryKey } from "@saas/payments/lib/api";
|
||||||
|
import { getPurchases } from "@saas/payments/lib/server";
|
||||||
|
import { ConfirmationAlertProvider } from "@saas/shared/components/ConfirmationAlertProvider";
|
||||||
|
import { getServerQueryClient } from "@shared/lib/server";
|
||||||
|
import { HydrationBoundary, dehydrate } from "@tanstack/react-query";
|
||||||
|
import type { PropsWithChildren } from "react";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
export const revalidate = 0;
|
||||||
|
|
||||||
|
export default async function Layout({ children }: PropsWithChildren) {
|
||||||
|
const session = await getSession();
|
||||||
|
|
||||||
|
const queryClient = getServerQueryClient();
|
||||||
|
|
||||||
|
await queryClient.prefetchQuery({
|
||||||
|
queryKey: sessionQueryKey,
|
||||||
|
queryFn: () => session,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (config.organizations.enable) {
|
||||||
|
await queryClient.prefetchQuery({
|
||||||
|
queryKey: organizationListQueryKey,
|
||||||
|
queryFn: getOrganizationList,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (config.users.enableBilling) {
|
||||||
|
await queryClient.prefetchQuery({
|
||||||
|
queryKey: purchasesQueryKey(),
|
||||||
|
queryFn: () => getPurchases(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<HydrationBoundary state={dehydrate(queryClient)}>
|
||||||
|
<SessionProvider>
|
||||||
|
<ActiveOrganizationProvider>
|
||||||
|
<ConfirmationAlertProvider>
|
||||||
|
{children}
|
||||||
|
</ConfirmationAlertProvider>
|
||||||
|
</ActiveOrganizationProvider>
|
||||||
|
</SessionProvider>
|
||||||
|
</HydrationBoundary>
|
||||||
|
);
|
||||||
|
}
|
||||||
5
apps/web/app/(saas)/app/not-found.tsx
Normal file
5
apps/web/app/(saas)/app/not-found.tsx
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
import { NotFound } from "@marketing/shared/components/NotFound";
|
||||||
|
|
||||||
|
export default async function NotFoundPage() {
|
||||||
|
return <NotFound />;
|
||||||
|
}
|
||||||
35
apps/web/app/(saas)/app/onboarding/page.tsx
Normal file
35
apps/web/app/(saas)/app/onboarding/page.tsx
Normal file
|
|
@ -0,0 +1,35 @@
|
||||||
|
import { config } from "@repo/config";
|
||||||
|
import { getSession } from "@saas/auth/lib/server";
|
||||||
|
import { OnboardingForm } from "@saas/onboarding/components/OnboardingForm";
|
||||||
|
import { AuthWrapper } from "@saas/shared/components/AuthWrapper";
|
||||||
|
import { getTranslations } from "next-intl/server";
|
||||||
|
import { redirect } from "next/navigation";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
export const revalidate = 0;
|
||||||
|
|
||||||
|
export async function generateMetadata() {
|
||||||
|
const t = await getTranslations();
|
||||||
|
|
||||||
|
return {
|
||||||
|
title: t("onboarding.title"),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function OnboardingPage() {
|
||||||
|
const session = await getSession();
|
||||||
|
|
||||||
|
if (!session) {
|
||||||
|
return redirect("/auth/login");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!config.users.enableOnboarding || session.user.onboardingComplete) {
|
||||||
|
return redirect("/app");
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AuthWrapper>
|
||||||
|
<OnboardingForm />
|
||||||
|
</AuthWrapper>
|
||||||
|
);
|
||||||
|
}
|
||||||
17
apps/web/app/(saas)/auth/forgot-password/page.tsx
Normal file
17
apps/web/app/(saas)/auth/forgot-password/page.tsx
Normal file
|
|
@ -0,0 +1,17 @@
|
||||||
|
import { ForgotPasswordForm } from "@saas/auth/components/ForgotPasswordForm";
|
||||||
|
import { getTranslations } from "next-intl/server";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
export const revalidate = 0;
|
||||||
|
|
||||||
|
export async function generateMetadata() {
|
||||||
|
const t = await getTranslations();
|
||||||
|
|
||||||
|
return {
|
||||||
|
title: t("auth.forgotPassword.title"),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ForgotPasswordPage() {
|
||||||
|
return <ForgotPasswordForm />;
|
||||||
|
}
|
||||||
14
apps/web/app/(saas)/auth/layout.tsx
Normal file
14
apps/web/app/(saas)/auth/layout.tsx
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
import { SessionProvider } from "@saas/auth/components/SessionProvider";
|
||||||
|
import { AuthWrapper } from "@saas/shared/components/AuthWrapper";
|
||||||
|
import type { PropsWithChildren } from "react";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
export const revalidate = 0;
|
||||||
|
|
||||||
|
export default function AuthLayout({ children }: PropsWithChildren) {
|
||||||
|
return (
|
||||||
|
<SessionProvider>
|
||||||
|
<AuthWrapper>{children}</AuthWrapper>
|
||||||
|
</SessionProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
17
apps/web/app/(saas)/auth/login/page.tsx
Normal file
17
apps/web/app/(saas)/auth/login/page.tsx
Normal file
|
|
@ -0,0 +1,17 @@
|
||||||
|
import { LoginForm } from "@saas/auth/components/LoginForm";
|
||||||
|
import { getTranslations } from "next-intl/server";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
export const revalidate = 0;
|
||||||
|
|
||||||
|
export async function generateMetadata() {
|
||||||
|
const t = await getTranslations();
|
||||||
|
|
||||||
|
return {
|
||||||
|
title: t("auth.login.title"),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function LoginPage() {
|
||||||
|
return <LoginForm />;
|
||||||
|
}
|
||||||
17
apps/web/app/(saas)/auth/reset-password/page.tsx
Normal file
17
apps/web/app/(saas)/auth/reset-password/page.tsx
Normal file
|
|
@ -0,0 +1,17 @@
|
||||||
|
import { ResetPasswordForm } from "@saas/auth/components/ResetPasswordForm";
|
||||||
|
import { getTranslations } from "next-intl/server";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
export const revalidate = 0;
|
||||||
|
|
||||||
|
export async function generateMetadata() {
|
||||||
|
const t = await getTranslations();
|
||||||
|
|
||||||
|
return {
|
||||||
|
title: t("auth.resetPassword.title"),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ResetPasswordPage() {
|
||||||
|
return <ResetPasswordForm />;
|
||||||
|
}
|
||||||
48
apps/web/app/(saas)/auth/signup/page.tsx
Normal file
48
apps/web/app/(saas)/auth/signup/page.tsx
Normal file
|
|
@ -0,0 +1,48 @@
|
||||||
|
import { config } from "@repo/config";
|
||||||
|
import { SignupForm } from "@saas/auth/components/SignupForm";
|
||||||
|
import { getInvitation } from "@saas/auth/lib/server";
|
||||||
|
import { getTranslations } from "next-intl/server";
|
||||||
|
import { redirect } from "next/navigation";
|
||||||
|
import { withQuery } from "ufo";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
export const revalidate = 0;
|
||||||
|
|
||||||
|
export async function generateMetadata() {
|
||||||
|
const t = await getTranslations();
|
||||||
|
|
||||||
|
return {
|
||||||
|
title: t("auth.signup.title"),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
export default async function SignupPage({
|
||||||
|
searchParams,
|
||||||
|
}: {
|
||||||
|
searchParams: Promise<{
|
||||||
|
[key: string]: string | string[] | undefined;
|
||||||
|
invitationId?: string;
|
||||||
|
}>;
|
||||||
|
}) {
|
||||||
|
const params = await searchParams;
|
||||||
|
const { invitationId } = params;
|
||||||
|
|
||||||
|
if (!(config.auth.enableSignup || invitationId)) {
|
||||||
|
return redirect(withQuery("/auth/login", params));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (invitationId) {
|
||||||
|
const invitation = await getInvitation(invitationId);
|
||||||
|
|
||||||
|
if (
|
||||||
|
!invitation ||
|
||||||
|
invitation.status !== "pending" ||
|
||||||
|
invitation.expiresAt.getTime() < new Date().getTime()
|
||||||
|
) {
|
||||||
|
return redirect(withQuery("/auth/login", params));
|
||||||
|
}
|
||||||
|
|
||||||
|
return <SignupForm prefillEmail={invitation.email} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return <SignupForm />;
|
||||||
|
}
|
||||||
17
apps/web/app/(saas)/auth/verify/page.tsx
Normal file
17
apps/web/app/(saas)/auth/verify/page.tsx
Normal file
|
|
@ -0,0 +1,17 @@
|
||||||
|
import { OtpForm } from "@saas/auth/components/OtpForm";
|
||||||
|
import { getTranslations } from "next-intl/server";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
export const revalidate = 0;
|
||||||
|
|
||||||
|
export async function generateMetadata() {
|
||||||
|
const t = await getTranslations();
|
||||||
|
|
||||||
|
return {
|
||||||
|
title: t("auth.verify.title"),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function VerifyPage() {
|
||||||
|
return <OtpForm />;
|
||||||
|
}
|
||||||
17
apps/web/app/(saas)/layout.tsx
Normal file
17
apps/web/app/(saas)/layout.tsx
Normal file
|
|
@ -0,0 +1,17 @@
|
||||||
|
import { Document } from "@shared/components/Document";
|
||||||
|
import { NextIntlClientProvider } from "next-intl";
|
||||||
|
import { getLocale, getMessages } from "next-intl/server";
|
||||||
|
import type { PropsWithChildren } from "react";
|
||||||
|
|
||||||
|
export default async function LocaleLayout({ children }: PropsWithChildren) {
|
||||||
|
const locale = await getLocale();
|
||||||
|
const messages = await getMessages();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Document locale={locale}>
|
||||||
|
<NextIntlClientProvider messages={messages}>
|
||||||
|
{children}
|
||||||
|
</NextIntlClientProvider>
|
||||||
|
</Document>
|
||||||
|
);
|
||||||
|
}
|
||||||
11
apps/web/app/api/[[...rest]]/route.ts
Normal file
11
apps/web/app/api/[[...rest]]/route.ts
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
import { app } from "@repo/api";
|
||||||
|
import { handle } from "hono/vercel";
|
||||||
|
|
||||||
|
const handler = handle(app);
|
||||||
|
|
||||||
|
export const GET = handler;
|
||||||
|
export const POST = handler;
|
||||||
|
export const PUT = handler;
|
||||||
|
export const PATCH = handler;
|
||||||
|
export const DELETE = handler;
|
||||||
|
export const OPTIONS = handler;
|
||||||
20
apps/web/app/api/docs-search/route.ts
Normal file
20
apps/web/app/api/docs-search/route.ts
Normal file
|
|
@ -0,0 +1,20 @@
|
||||||
|
import { config } from "@repo/config";
|
||||||
|
import { createI18nSearchAPI } from "fumadocs-core/search/server";
|
||||||
|
import { docsSource } from "../../docs-source";
|
||||||
|
|
||||||
|
export const { GET } = createI18nSearchAPI("advanced", {
|
||||||
|
i18n: {
|
||||||
|
defaultLanguage: config.i18n.defaultLocale,
|
||||||
|
languages: Object.keys(config.i18n.locales),
|
||||||
|
},
|
||||||
|
indexes: docsSource.getLanguages().flatMap((entry) =>
|
||||||
|
entry.pages.map((page) => ({
|
||||||
|
title: page.data.title,
|
||||||
|
description: page.data.description,
|
||||||
|
structuredData: page.data.structuredData,
|
||||||
|
id: page.url,
|
||||||
|
url: page.url,
|
||||||
|
locale: entry.language,
|
||||||
|
})),
|
||||||
|
),
|
||||||
|
});
|
||||||
28
apps/web/app/docs-source.ts
Normal file
28
apps/web/app/docs-source.ts
Normal file
|
|
@ -0,0 +1,28 @@
|
||||||
|
import { createMDXSource } from "@fumadocs/content-collections";
|
||||||
|
import { config } from "@repo/config";
|
||||||
|
import { allDocs, allDocsMetas } from "content-collections";
|
||||||
|
import { loader } from "fumadocs-core/source";
|
||||||
|
import { Home } from "lucide-react";
|
||||||
|
import { createElement } from "react";
|
||||||
|
|
||||||
|
export const docsSource = loader({
|
||||||
|
baseUrl: "/docs",
|
||||||
|
i18n: {
|
||||||
|
defaultLanguage: config.i18n.defaultLocale,
|
||||||
|
languages: Object.keys(config.i18n.locales),
|
||||||
|
},
|
||||||
|
source: createMDXSource(allDocs, allDocsMetas),
|
||||||
|
icon(icon) {
|
||||||
|
if (!icon) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const icons = {
|
||||||
|
Home,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (icon in icons) {
|
||||||
|
return createElement(icons[icon as keyof typeof icons]);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
BIN
apps/web/app/favicon.ico
Normal file
BIN
apps/web/app/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 6.3 KiB |
42
apps/web/app/globals.css
Normal file
42
apps/web/app/globals.css
Normal file
|
|
@ -0,0 +1,42 @@
|
||||||
|
@import "tailwindcss";
|
||||||
|
@import "fumadocs-ui/css/neutral.css";
|
||||||
|
@import "fumadocs-ui/css/preset.css";
|
||||||
|
@import "@repo/tailwind-config/theme.css";
|
||||||
|
@import "@repo/tailwind-config/tailwind-animate.css";
|
||||||
|
|
||||||
|
@source "../node_modules/fumadocs-ui/dist/**/*.js";
|
||||||
|
|
||||||
|
@variant dark (&:where(.dark, .dark *));
|
||||||
|
|
||||||
|
pre.shiki {
|
||||||
|
@apply mb-4 rounded-lg p-6;
|
||||||
|
}
|
||||||
|
|
||||||
|
#nd-sidebar {
|
||||||
|
@apply bg-card! top-[4.5rem] md:h-[calc(100dvh-4.5rem)]!;
|
||||||
|
|
||||||
|
button[data-search-full] {
|
||||||
|
@apply bg-transparent;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#nd-page .prose {
|
||||||
|
h1,
|
||||||
|
h2,
|
||||||
|
h3,
|
||||||
|
h4,
|
||||||
|
h5,
|
||||||
|
h6 {
|
||||||
|
a {
|
||||||
|
@apply no-underline!;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
div[role="tablist"].bg-secondary {
|
||||||
|
@apply bg-muted!;
|
||||||
|
}
|
||||||
|
|
||||||
|
input[cmdk-input] {
|
||||||
|
@apply border-none focus-visible:ring-0;
|
||||||
|
}
|
||||||
BIN
apps/web/app/icon.png
Normal file
BIN
apps/web/app/icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 6.3 KiB |
31
apps/web/app/image-proxy/[...path]/route.ts
Normal file
31
apps/web/app/image-proxy/[...path]/route.ts
Normal file
|
|
@ -0,0 +1,31 @@
|
||||||
|
import { config } from "@repo/config";
|
||||||
|
import { getSignedUrl } from "@repo/storage";
|
||||||
|
import { NextResponse } from "next/server";
|
||||||
|
|
||||||
|
export const GET = async (
|
||||||
|
_req: Request,
|
||||||
|
{ params }: { params: Promise<{ path: string[] }> },
|
||||||
|
) => {
|
||||||
|
const { path } = await params;
|
||||||
|
|
||||||
|
const [bucket, filePath] = path;
|
||||||
|
|
||||||
|
if (!(bucket && filePath)) {
|
||||||
|
return new Response("Invalid path", { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (bucket === config.storage.bucketNames.avatars) {
|
||||||
|
const signedUrl = await getSignedUrl(filePath, {
|
||||||
|
bucket,
|
||||||
|
expiresIn: 60 * 60,
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.redirect(signedUrl, {
|
||||||
|
headers: { "Cache-Control": "max-age=3600" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Response("Not found", {
|
||||||
|
status: 404,
|
||||||
|
});
|
||||||
|
};
|
||||||
17
apps/web/app/layout.tsx
Normal file
17
apps/web/app/layout.tsx
Normal file
|
|
@ -0,0 +1,17 @@
|
||||||
|
import type { Metadata } from "next";
|
||||||
|
import type { PropsWithChildren } from "react";
|
||||||
|
import "./globals.css";
|
||||||
|
import "cropperjs/dist/cropper.css";
|
||||||
|
import { config } from "@repo/config";
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: {
|
||||||
|
absolute: config.appName,
|
||||||
|
default: config.appName,
|
||||||
|
template: `%s | ${config.appName}`,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function RootLayout({ children }: PropsWithChildren) {
|
||||||
|
return children;
|
||||||
|
}
|
||||||
10
apps/web/app/robots.ts
Normal file
10
apps/web/app/robots.ts
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
import type { MetadataRoute } from "next";
|
||||||
|
|
||||||
|
export default function robots(): MetadataRoute.Robots {
|
||||||
|
return {
|
||||||
|
rules: {
|
||||||
|
userAgent: "*",
|
||||||
|
allow: "/",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
43
apps/web/app/sitemap.ts
Normal file
43
apps/web/app/sitemap.ts
Normal file
|
|
@ -0,0 +1,43 @@
|
||||||
|
import { getAllPosts } from "@marketing/blog/utils/lib/posts";
|
||||||
|
import { config } from "@repo/config";
|
||||||
|
import { getBaseUrl } from "@repo/utils";
|
||||||
|
import { allLegalPages } from "content-collections";
|
||||||
|
import type { MetadataRoute } from "next";
|
||||||
|
import { docsSource } from "./docs-source";
|
||||||
|
|
||||||
|
const baseUrl = getBaseUrl();
|
||||||
|
const locales = config.i18n.enabled
|
||||||
|
? Object.keys(config.i18n.locales)
|
||||||
|
: [config.i18n.defaultLocale];
|
||||||
|
|
||||||
|
const staticMarketingPages = ["", "/changelog"];
|
||||||
|
|
||||||
|
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
|
||||||
|
const posts = await getAllPosts();
|
||||||
|
|
||||||
|
return [
|
||||||
|
...staticMarketingPages.flatMap((page) =>
|
||||||
|
locales.map((locale) => ({
|
||||||
|
url: new URL(`/${locale}${page}`, baseUrl).href,
|
||||||
|
lastModified: new Date(),
|
||||||
|
})),
|
||||||
|
),
|
||||||
|
...posts.map((post) => ({
|
||||||
|
url: new URL(`/${post.locale}/blog/${post.path}`, baseUrl).href,
|
||||||
|
lastModified: new Date(),
|
||||||
|
})),
|
||||||
|
...allLegalPages.map((page) => ({
|
||||||
|
url: new URL(`/${page.locale}/legal/${page.path}`, baseUrl).href,
|
||||||
|
lastModified: new Date(),
|
||||||
|
})),
|
||||||
|
...docsSource.getLanguages().flatMap((locale) =>
|
||||||
|
docsSource.getPages(locale.language).map((page) => ({
|
||||||
|
url: new URL(
|
||||||
|
`/${locale.language}/docs/${page.slugs.join("/")}`,
|
||||||
|
baseUrl,
|
||||||
|
).href,
|
||||||
|
lastModified: new Date(),
|
||||||
|
})),
|
||||||
|
),
|
||||||
|
];
|
||||||
|
}
|
||||||
3
apps/web/biome.json
Normal file
3
apps/web/biome.json
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
{
|
||||||
|
"extends": ["../../biome.json"]
|
||||||
|
}
|
||||||
17
apps/web/components.json
Normal file
17
apps/web/components.json
Normal file
|
|
@ -0,0 +1,17 @@
|
||||||
|
{
|
||||||
|
"$schema": "https://ui.shadcn.com/schema.json",
|
||||||
|
"style": "default",
|
||||||
|
"rsc": true,
|
||||||
|
"tsx": true,
|
||||||
|
"tailwind": {
|
||||||
|
"css": "styles/globals.css",
|
||||||
|
"baseColor": "slate",
|
||||||
|
"cssVariables": true,
|
||||||
|
"prefix": ""
|
||||||
|
},
|
||||||
|
"aliases": {
|
||||||
|
"components": "@ui/components",
|
||||||
|
"utils": "@ui/lib",
|
||||||
|
"ui": "@ui/components"
|
||||||
|
}
|
||||||
|
}
|
||||||
112
apps/web/content-collections.ts
Normal file
112
apps/web/content-collections.ts
Normal file
|
|
@ -0,0 +1,112 @@
|
||||||
|
import { defineCollection, defineConfig } from "@content-collections/core";
|
||||||
|
import { compileMDX } from "@content-collections/mdx";
|
||||||
|
import {
|
||||||
|
createDocSchema,
|
||||||
|
createMetaSchema,
|
||||||
|
transformMDX,
|
||||||
|
} from "@fumadocs/content-collections/configuration";
|
||||||
|
import rehypeShiki from "@shikijs/rehype";
|
||||||
|
import { remarkImage } from "fumadocs-core/mdx-plugins";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { config } from "../../config";
|
||||||
|
|
||||||
|
function sanitizePath(path: string) {
|
||||||
|
return path
|
||||||
|
.replace(/(\.[a-zA-Z-]{2,5})$/, "")
|
||||||
|
.replace(/^\//, "")
|
||||||
|
.replace(/\/$/, "")
|
||||||
|
.replace(/index$/, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
function getLocaleFromFilePath(path: string) {
|
||||||
|
return (
|
||||||
|
path
|
||||||
|
.match(/(\.[a-zA-Z-]{2,5})+\.(md|mdx|json)$/)?.[1]
|
||||||
|
?.replace(".", "") ?? config.i18n.defaultLocale
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const posts = defineCollection({
|
||||||
|
name: "posts",
|
||||||
|
directory: "content/posts",
|
||||||
|
include: "**/*.{mdx,md}",
|
||||||
|
schema: z.object({
|
||||||
|
title: z.string(),
|
||||||
|
date: z.string(),
|
||||||
|
image: z.string().optional(),
|
||||||
|
authorName: z.string(),
|
||||||
|
authorImage: z.string().optional(),
|
||||||
|
authorLink: z.string().optional(),
|
||||||
|
excerpt: z.string().optional(),
|
||||||
|
tags: z.array(z.string()),
|
||||||
|
published: z.boolean(),
|
||||||
|
}),
|
||||||
|
transform: async (document, context) => {
|
||||||
|
const body = await compileMDX(context, document, {
|
||||||
|
rehypePlugins: [
|
||||||
|
[
|
||||||
|
rehypeShiki,
|
||||||
|
{
|
||||||
|
theme: "nord",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
...document,
|
||||||
|
body,
|
||||||
|
locale: getLocaleFromFilePath(document._meta.filePath),
|
||||||
|
path: sanitizePath(document._meta.path),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const legalPages = defineCollection({
|
||||||
|
name: "legalPages",
|
||||||
|
directory: "content/legal",
|
||||||
|
include: "**/*.{mdx,md}",
|
||||||
|
schema: z.object({
|
||||||
|
title: z.string(),
|
||||||
|
}),
|
||||||
|
transform: async (document, context) => {
|
||||||
|
const body = await compileMDX(context, document);
|
||||||
|
|
||||||
|
return {
|
||||||
|
...document,
|
||||||
|
body,
|
||||||
|
locale: getLocaleFromFilePath(document._meta.filePath),
|
||||||
|
path: sanitizePath(document._meta.path),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const docs = defineCollection({
|
||||||
|
name: "docs",
|
||||||
|
directory: "content/docs",
|
||||||
|
include: "**/*.mdx",
|
||||||
|
schema: z.object(createDocSchema(z)),
|
||||||
|
transform: async (document, context) =>
|
||||||
|
transformMDX(document, context, {
|
||||||
|
remarkPlugins: [
|
||||||
|
[
|
||||||
|
remarkImage,
|
||||||
|
{
|
||||||
|
publicDir: "public",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const docsMeta = defineCollection({
|
||||||
|
name: "docsMeta",
|
||||||
|
directory: "content/docs",
|
||||||
|
include: "**/meta.json",
|
||||||
|
parser: "json",
|
||||||
|
schema: z.object(createMetaSchema(z)),
|
||||||
|
});
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
collections: [posts, legalPages, docs, docsMeta],
|
||||||
|
});
|
||||||
4
apps/web/content/docs/getting-started/meta.de.json
Normal file
4
apps/web/content/docs/getting-started/meta.de.json
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
{
|
||||||
|
"title": "Einführung",
|
||||||
|
"pages": ["overview"]
|
||||||
|
}
|
||||||
4
apps/web/content/docs/getting-started/meta.json
Normal file
4
apps/web/content/docs/getting-started/meta.json
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
{
|
||||||
|
"title": "Getting Started",
|
||||||
|
"pages": ["overview"]
|
||||||
|
}
|
||||||
6
apps/web/content/docs/getting-started/overview.de.mdx
Normal file
6
apps/web/content/docs/getting-started/overview.de.mdx
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
---
|
||||||
|
title: Übersicht
|
||||||
|
subtitle: Das ist der erste Schritt
|
||||||
|
---
|
||||||
|
|
||||||
|
Das hier ist die Übersichtseite.
|
||||||
6
apps/web/content/docs/getting-started/overview.mdx
Normal file
6
apps/web/content/docs/getting-started/overview.mdx
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
---
|
||||||
|
title: Overview
|
||||||
|
subtitle: This is the first step to start using acme
|
||||||
|
---
|
||||||
|
|
||||||
|
This is the overview page of the getting started section.
|
||||||
6
apps/web/content/docs/index.de.mdx
Normal file
6
apps/web/content/docs/index.de.mdx
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
---
|
||||||
|
title: Dokumentation
|
||||||
|
description: Dies ist eine kurze Beschreibung dieser Dokumentationsseite, die auch als Meta-Beschreibung für SEO verwendet wird.
|
||||||
|
---
|
||||||
|
|
||||||
|
Willkommen zur Dokumentation von acme. Hier finden Sie alle Informationen, die Sie benötigen, um mit unserer Software zu arbeiten.
|
||||||
43
apps/web/content/docs/index.mdx
Normal file
43
apps/web/content/docs/index.mdx
Normal file
|
|
@ -0,0 +1,43 @@
|
||||||
|
---
|
||||||
|
title: Documentation
|
||||||
|
description: This is a short description of this docs page that is also used as a meta description for SEO.
|
||||||
|
---
|
||||||
|
|
||||||
|
Welcome to the documentation of acme. Here you will find all the information you need to work with our software.
|
||||||
|
|
||||||
|
|
||||||
|
## 1.1 Getting started - Overview
|
||||||
|
|
||||||
|
Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua. At vero eos et accusam et justo duo dolores et ea rebum. Stet clita kasd gubergren, no sea takimata sanctus est Lorem ipsum dolor sit amet. Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua. At vero eos et accusam et justo duo dolores et ea rebum. Stet clita kasd gubergren, no sea takimata sanctus est Lorem ipsum dolor sit amet. Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua. At vero eos et accusam et justo duo dolores et ea rebum. Stet clita kasd gubergren, no sea takimata sanctus est Lorem ipsum dolor sit amet.
|
||||||
|
|
||||||
|
Duis autem vel eum iriure dolor in hendrerit in vulputate velit esse molestie consequat, vel illum dolore eu feugiat nulla facilisis at vero eros et accumsan et iusto odio dignissim qui blandit praesent luptatum zzril delenit augue duis dolore te feugait nulla facilisi. Lorem ipsum dolor sit amet, consectetuer adipiscing elit, sed diam nonummy nibh euismod tincidunt ut laoreet dolore magna aliquam erat volutpat.
|
||||||
|
|
||||||
|
### Another sub-section
|
||||||
|
|
||||||
|
Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua. At vero eos et accusam et justo duo dolores et ea rebum. Stet clita kasd gubergren, no sea takimata sanctus est Lorem ipsum dolor sit amet. Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua. At vero eos et accusam et justo duo dolores et ea rebum. Stet clita kasd gubergren, no sea takimata sanctus est Lorem ipsum dolor sit amet. Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua. At vero eos et accusam et justo duo dolores et ea rebum. Stet clita kasd gubergren, no sea takimata sanctus est Lorem ipsum dolor sit amet.
|
||||||
|
|
||||||
|
Duis autem vel eum iriure dolor in hendrerit in vulputate velit esse molestie consequat, vel illum dolore eu feugiat nulla facilisis at vero eros et accumsan et iusto odio dignissim qui blandit praesent luptatum zzril delenit augue duis dolore te feugait nulla facilisi. Lorem ipsum dolor sit amet, consectetuer adipiscing elit, sed diam nonummy nibh euismod tincidunt ut laoreet dolore magna aliquam erat volutpat.
|
||||||
|
|
||||||
|
## Code example
|
||||||
|
|
||||||
|
This is an example of code highlighting:
|
||||||
|
|
||||||
|
```python {1}
|
||||||
|
def hello_world():
|
||||||
|
print("Hello World!")
|
||||||
|
```
|
||||||
|
|
||||||
|
## Images
|
||||||
|
|
||||||
|
This is an example of an image:
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
## Lists
|
||||||
|
|
||||||
|
This is an example of a list:
|
||||||
|
|
||||||
|
- Item 1
|
||||||
|
- Item 2
|
||||||
|
- Item 3
|
||||||
|
|
||||||
3
apps/web/content/docs/meta.json
Normal file
3
apps/web/content/docs/meta.json
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
{
|
||||||
|
"pages": ["index", "getting-started"]
|
||||||
|
}
|
||||||
5
apps/web/content/legal/privacy-policy.de.md
Normal file
5
apps/web/content/legal/privacy-policy.de.md
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
---
|
||||||
|
title: Datenschutzerklärung
|
||||||
|
---
|
||||||
|
|
||||||
|
Dies ist die Platzhalterseite für Ihre Datenschutzerklärung. Bearbeiten Sie die Datei `content/legal/privacy-policy.md`, um Ihren eigenen Inhalt hinzuzufügen.
|
||||||
5
apps/web/content/legal/privacy-policy.md
Normal file
5
apps/web/content/legal/privacy-policy.md
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
---
|
||||||
|
title: Privacy Policy
|
||||||
|
---
|
||||||
|
|
||||||
|
This is the placeholder page for your privacy policy. Edit the `content/legal/privacy-policy.md` file to add your own content here.
|
||||||
5
apps/web/content/legal/terms.de.md
Normal file
5
apps/web/content/legal/terms.de.md
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
---
|
||||||
|
title: Allgemeine Geschäftsbedingungen
|
||||||
|
---
|
||||||
|
|
||||||
|
Dies ist die Platzhalterseite für Ihre Allgemeinen Geschäftsbedingungen. Bearbeiten Sie die Datei `content/legal/terms.de.md`, um Ihren eigenen Inhalt hinzuzufügen.
|
||||||
5
apps/web/content/legal/terms.md
Normal file
5
apps/web/content/legal/terms.md
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
---
|
||||||
|
title: Terms and conditions
|
||||||
|
---
|
||||||
|
|
||||||
|
This is the placeholder page for your terms and conditions. Edit the `content/legal/terms.md` file to add your own content here.
|
||||||
14
apps/web/content/posts/first-post.de.mdx
Normal file
14
apps/web/content/posts/first-post.de.mdx
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
---
|
||||||
|
title: Lieblingsdinge
|
||||||
|
date: 2023-02-28
|
||||||
|
image: /images/blog/cover.png
|
||||||
|
authorName: Elon Musk
|
||||||
|
authorImage: /images/blog/author2.jpg
|
||||||
|
excerpt: Hier schreibe ich über meine Lieblingsdinge.
|
||||||
|
tags: [first, post]
|
||||||
|
published: true
|
||||||
|
---
|
||||||
|
|
||||||
|
## Meine Lieblingsdinge
|
||||||
|
|
||||||
|
Das ist ein Testpost. Hier schreibe ich über meine Lieblingsdinge.
|
||||||
14
apps/web/content/posts/first-post.mdx
Normal file
14
apps/web/content/posts/first-post.mdx
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
---
|
||||||
|
title: Favorite Things
|
||||||
|
date: 2023-02-28
|
||||||
|
image: /images/blog/cover.png
|
||||||
|
authorName: Elon Musk
|
||||||
|
authorImage: /images/blog/author2.jpg
|
||||||
|
excerpt: In this post I'm going to tell you about my favorite things.
|
||||||
|
tags: [first, post]
|
||||||
|
published: true
|
||||||
|
---
|
||||||
|
|
||||||
|
## This is a heading
|
||||||
|
|
||||||
|
And we also have some great content here. What do you think?
|
||||||
29
apps/web/content/posts/second-post.mdx
Normal file
29
apps/web/content/posts/second-post.mdx
Normal file
|
|
@ -0,0 +1,29 @@
|
||||||
|
---
|
||||||
|
title: Awesome second post
|
||||||
|
date: 2023-03-01
|
||||||
|
image: /images/blog/cover.png
|
||||||
|
authorName: Tony Stark
|
||||||
|
authorImage: /images/blog/author.jpg
|
||||||
|
excerpt: This is my first post. I'm so excited!
|
||||||
|
tags: [first, post]
|
||||||
|
published: true
|
||||||
|
---
|
||||||
|
|
||||||
|
## Hello world
|
||||||
|
|
||||||
|
This is my first post. I'm so excited! This blog posts needs some more text lines to fill up the page, so I'm just going to write some random stuff here. I'm going to write about my favorite things, like:
|
||||||
|
|
||||||
|
### What's next?
|
||||||
|
|
||||||
|
I'm going to write a lot more posts. I'm going to write about my favorite things, like:
|
||||||
|
|
||||||
|
- Cats
|
||||||
|
- Dogs
|
||||||
|
- Pizza
|
||||||
|
|
||||||
|
You can even add some nice links here:
|
||||||
|
|
||||||
|
- [My favorite cat](https://www.youtube.com/watch?v=5dsGWM5XGdg)
|
||||||
|
- [My favorite dog](https://www.youtube.com/watch?v=5dsGWM5XGdg)
|
||||||
|
- [My favorite pizza](https://www.youtube.com/watch?v=5dsGWM5XGdg)
|
||||||
|
- [Homepage](/)
|
||||||
16
apps/web/global.d.ts
vendored
Normal file
16
apps/web/global.d.ts
vendored
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
import type { Messages } from "@repo/i18n";
|
||||||
|
import type { JSX as Jsx } from "react/jsx-runtime";
|
||||||
|
|
||||||
|
// temporary fix for mdx types
|
||||||
|
// TODO: remove once mdx has fully compatibility with react 19
|
||||||
|
declare global {
|
||||||
|
namespace JSX {
|
||||||
|
type ElementClass = Jsx.ElementClass;
|
||||||
|
type Element = Jsx.Element;
|
||||||
|
type IntrinsicElements = Jsx.IntrinsicElements;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
interface IntlMessages extends Messages {}
|
||||||
|
}
|
||||||
144
apps/web/middleware.ts
Normal file
144
apps/web/middleware.ts
Normal file
|
|
@ -0,0 +1,144 @@
|
||||||
|
import { routing } from "@i18n/routing";
|
||||||
|
import { config as appConfig } from "@repo/config";
|
||||||
|
import { createPurchasesHelper } from "@repo/payments/lib/helper";
|
||||||
|
import {
|
||||||
|
getOrganizationsForSession,
|
||||||
|
getPurchasesForSession,
|
||||||
|
getSession,
|
||||||
|
} from "@shared/lib/middleware-helpers";
|
||||||
|
import createMiddleware from "next-intl/middleware";
|
||||||
|
import { type NextRequest, NextResponse } from "next/server";
|
||||||
|
import { withQuery } from "ufo";
|
||||||
|
|
||||||
|
const intlMiddleware = createMiddleware(routing);
|
||||||
|
|
||||||
|
export default async function middleware(req: NextRequest) {
|
||||||
|
const { pathname, origin } = req.nextUrl;
|
||||||
|
|
||||||
|
if (pathname.startsWith("/app")) {
|
||||||
|
const response = NextResponse.next();
|
||||||
|
|
||||||
|
if (!appConfig.ui.saas.enabled) {
|
||||||
|
return NextResponse.redirect(new URL("/", origin));
|
||||||
|
}
|
||||||
|
|
||||||
|
const session = await getSession(req);
|
||||||
|
let locale = req.cookies.get(appConfig.i18n.localeCookieName)?.value;
|
||||||
|
|
||||||
|
if (!session) {
|
||||||
|
return NextResponse.redirect(
|
||||||
|
new URL(
|
||||||
|
withQuery("/auth/login", {
|
||||||
|
redirectTo: pathname,
|
||||||
|
}),
|
||||||
|
origin,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
appConfig.users.enableOnboarding &&
|
||||||
|
!session.user.onboardingComplete &&
|
||||||
|
pathname !== "/app/onboarding"
|
||||||
|
) {
|
||||||
|
return NextResponse.redirect(
|
||||||
|
new URL(
|
||||||
|
withQuery("/app/onboarding", {
|
||||||
|
redirectTo: pathname,
|
||||||
|
}),
|
||||||
|
origin,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
!locale ||
|
||||||
|
(session.user.locale && locale !== session.user.locale)
|
||||||
|
) {
|
||||||
|
locale = session.user.locale ?? appConfig.i18n.defaultLocale;
|
||||||
|
response.cookies.set(appConfig.i18n.localeCookieName, locale);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
appConfig.organizations.enable &&
|
||||||
|
appConfig.organizations.requireOrganization &&
|
||||||
|
pathname === "/app"
|
||||||
|
) {
|
||||||
|
const organizations = await getOrganizationsForSession(req);
|
||||||
|
const organization =
|
||||||
|
organizations.find(
|
||||||
|
(org) => org.id === session?.session.activeOrganizationId,
|
||||||
|
) || organizations[0];
|
||||||
|
|
||||||
|
return NextResponse.redirect(
|
||||||
|
new URL(
|
||||||
|
organization
|
||||||
|
? `/app/${organization.slug}`
|
||||||
|
: "/app/new-organization",
|
||||||
|
origin,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasFreePlan = Object.values(appConfig.payments.plans).some(
|
||||||
|
(plan) => "isFree" in plan,
|
||||||
|
);
|
||||||
|
if (
|
||||||
|
((appConfig.organizations.enable &&
|
||||||
|
appConfig.organizations.enableBilling) ||
|
||||||
|
appConfig.users.enableBilling) &&
|
||||||
|
!hasFreePlan
|
||||||
|
) {
|
||||||
|
const organizationId = appConfig.organizations.enable
|
||||||
|
? session?.session.activeOrganizationId ||
|
||||||
|
(await getOrganizationsForSession(req))?.at(0)?.id
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
const purchases = await getPurchasesForSession(req, organizationId);
|
||||||
|
const { activePlan } = createPurchasesHelper(purchases);
|
||||||
|
|
||||||
|
const validPathsWithoutPlan = [
|
||||||
|
"/app/choose-plan",
|
||||||
|
"/app/onboarding",
|
||||||
|
"/app/new-organization",
|
||||||
|
"/app/organization-invitation/",
|
||||||
|
];
|
||||||
|
if (
|
||||||
|
!activePlan &&
|
||||||
|
!validPathsWithoutPlan.some((path) => pathname.startsWith(path))
|
||||||
|
) {
|
||||||
|
return NextResponse.redirect(
|
||||||
|
new URL("/app/choose-plan", origin),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pathname.startsWith("/auth")) {
|
||||||
|
if (!appConfig.ui.saas.enabled) {
|
||||||
|
return NextResponse.redirect(new URL("/", origin));
|
||||||
|
}
|
||||||
|
|
||||||
|
const session = await getSession(req);
|
||||||
|
|
||||||
|
if (session && pathname !== "/auth/reset-password") {
|
||||||
|
return NextResponse.redirect(new URL("/app", origin));
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.next();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!appConfig.ui.marketing.enabled) {
|
||||||
|
return NextResponse.redirect(new URL("/app", origin));
|
||||||
|
}
|
||||||
|
|
||||||
|
return intlMiddleware(req);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const config = {
|
||||||
|
matcher: [
|
||||||
|
"/((?!api|image-proxy|images|fonts|_next/static|_next/image|favicon.ico|sitemap.xml|robots.txt).*)",
|
||||||
|
],
|
||||||
|
};
|
||||||
1
apps/web/modules/analytics/index.tsx
Normal file
1
apps/web/modules/analytics/index.tsx
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
export * from "./provider/custom";
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue