This commit is contained in:
 Илья Шалонин 2025-10-29 13:43:43 +05:00
commit 8538ab969a
434 changed files with 44051 additions and 0 deletions

View 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.

View 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.

View 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.

View 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

View 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.

View 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.

View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View file

@ -0,0 +1 @@
public-hoist-pattern[]=*prisma*

7
.vscode/extensions.json vendored Normal file
View file

@ -0,0 +1,7 @@
{
"recommendations": [
"lokalise.i18n-ally",
"bradlc.vscode-tailwindcss",
"biomejs.biome"
]
}

32
.vscode/settings.json vendored Normal file
View 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
View 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
View 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
View 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

View 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 />
</>
);
}

View file

@ -0,0 +1,5 @@
import { notFound } from "next/navigation";
export default function CatchAll() {
notFound();
}

View 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">
&larr; {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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View file

@ -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>
);
}

View 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,
};
}

View 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>
);
}

View 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>
);
}

View file

@ -0,0 +1,5 @@
import { NotFound } from "@marketing/shared/components/NotFound";
export default async function NotFoundPage() {
return <NotFound />;
}

View 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>
</>
);
}

View file

@ -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>
);
}

View file

@ -0,0 +1,5 @@
import { OrganizationList } from "@saas/admin/component/organizations/OrganizationList";
export default function AdminOrganizationsPage() {
return <OrganizationList />;
}

View file

@ -0,0 +1,9 @@
import { UserList } from "@saas/admin/component/users/UserList";
export default function AdminUserPage() {
return (
<div>
<UserList />
</div>
);
}

View 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 />
</>
);
}

View 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>;
}

View 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>
);
}

View 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>
);
}

View file

@ -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>
);
}

View 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>
);
}

View 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>
</>
);
}

View 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>
);
}

View file

@ -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>;
}

View file

@ -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 />;
}

View file

@ -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}
/>
);
}

View file

@ -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} />
</>
);
}

View file

@ -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>;
}

View file

@ -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>
);
}

View file

@ -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>
);
}

View file

@ -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>
);
}

View file

@ -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>
);
}

View file

@ -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>
</>
);
}

View file

@ -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>
);
}

View file

@ -0,0 +1,5 @@
import { notFound } from "next/navigation";
export default function CatchAll() {
notFound();
}

View 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>
);
}

View 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>
);
}

View file

@ -0,0 +1,5 @@
import { NotFound } from "@marketing/shared/components/NotFound";
export default async function NotFoundPage() {
return <NotFound />;
}

View 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>
);
}

View 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 />;
}

View 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>
);
}

View 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 />;
}

View 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 />;
}

View 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 />;
}

View 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 />;
}

View 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>
);
}

View 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;

View 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,
})),
),
});

View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.3 KiB

42
apps/web/app/globals.css Normal file
View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.3 KiB

View 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
View 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
View 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
View 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
View file

@ -0,0 +1,3 @@
{
"extends": ["../../biome.json"]
}

17
apps/web/components.json Normal file
View 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"
}
}

View 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],
});

View file

@ -0,0 +1,4 @@
{
"title": "Einführung",
"pages": ["overview"]
}

View file

@ -0,0 +1,4 @@
{
"title": "Getting Started",
"pages": ["overview"]
}

View file

@ -0,0 +1,6 @@
---
title: Übersicht
subtitle: Das ist der erste Schritt
---
Das hier ist die Übersichtseite.

View 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.

View 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.

View 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:
![Login](/images/docs/login.png)
## Lists
This is an example of a list:
- Item 1
- Item 2
- Item 3

View file

@ -0,0 +1,3 @@
{
"pages": ["index", "getting-started"]
}

View 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.

View 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.

View 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.

View 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.

View 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.

View 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?

View 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
View 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
View 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).*)",
],
};

View file

@ -0,0 +1 @@
export * from "./provider/custom";

Some files were not shown because too many files have changed in this diff Show more