├── .npmrc ├── apps └── web │ ├── cypress │ ├── support │ │ ├── e2e.ts │ │ └── commands.ts │ └── e2e │ │ ├── signup.cy.ts │ │ ├── homepage.cy.ts │ │ └── pricing.cy.ts │ ├── modules │ ├── analytics │ │ ├── index.tsx │ │ └── provider │ │ │ ├── custom │ │ │ └── index.tsx │ │ │ ├── vercel │ │ │ └── index.tsx │ │ │ ├── mixpanel │ │ │ └── index.tsx │ │ │ ├── plausible │ │ │ └── index.tsx │ │ │ ├── umami │ │ │ └── index.tsx │ │ │ ├── pirsch │ │ │ └── index.tsx │ │ │ └── google │ │ │ └── index.tsx │ ├── saas │ │ ├── shared │ │ │ ├── constants.ts │ │ │ └── components │ │ │ │ ├── PageHeader.tsx │ │ │ │ ├── LoadingWrapper.tsx │ │ │ │ ├── TabGroup.tsx │ │ │ │ ├── CreateTeamDialog.tsx │ │ │ │ ├── Pagination.tsx │ │ │ │ └── ActionBlock.tsx │ │ ├── dashboard │ │ │ ├── state.ts │ │ │ └── components │ │ │ │ └── ProductNameGenerator.tsx │ │ ├── auth │ │ │ ├── hooks │ │ │ │ └── use-user.ts │ │ │ ├── lib │ │ │ │ └── current-team-id.tsx │ │ │ └── components │ │ │ │ ├── TeamInvitationInfo.tsx │ │ │ │ ├── SigninModeSwitch.tsx │ │ │ │ ├── SocialSigninButton.tsx │ │ │ │ └── VerifyTokenView.tsx │ │ ├── admin │ │ │ └── component │ │ │ │ ├── EmailVerified.tsx │ │ │ │ └── SideMenu.tsx │ │ └── settings │ │ │ └── components │ │ │ ├── TeamRoleSelect.tsx │ │ │ ├── SubscriptionStatusBadge.tsx │ │ │ ├── SettingsMenu.tsx │ │ │ ├── CustomerPortalButton.tsx │ │ │ ├── TeamMembersBlock.tsx │ │ │ ├── ChangePassword.tsx │ │ │ ├── PauseSubscriptionButton.tsx │ │ │ ├── ResumeSubscriptionButton.tsx │ │ │ ├── CancelSubscriptionButton.tsx │ │ │ ├── ChangeNameForm.tsx │ │ │ ├── UpgradePlan.tsx │ │ │ ├── ChangeTeamNameForm.tsx │ │ │ └── CropImageDialog.tsx │ ├── shared │ │ ├── lib │ │ │ └── api-client.ts │ │ ├── hooks │ │ │ └── locale-currency.tsx │ │ └── components │ │ │ ├── ClientProviders.tsx │ │ │ ├── UserAvatar.tsx │ │ │ ├── TeamAvatar.tsx │ │ │ ├── ApiClientProvider.tsx │ │ │ ├── ConsentBanner.tsx │ │ │ └── LocaleSwitch.tsx │ ├── ui │ │ ├── lib │ │ │ └── index.ts │ │ └── components │ │ │ ├── skeleton.tsx │ │ │ ├── label.tsx │ │ │ ├── input.tsx │ │ │ ├── badge.tsx │ │ │ ├── password-input.tsx │ │ │ ├── tooltip.tsx │ │ │ ├── avatar.tsx │ │ │ ├── toaster.tsx │ │ │ └── alert.tsx │ ├── i18n │ │ └── index.ts │ └── marketing │ │ ├── blog │ │ ├── components │ │ │ └── PostContent.tsx │ │ └── utils │ │ │ └── mdx-components.tsx │ │ ├── shared │ │ └── components │ │ │ ├── NotFound.tsx │ │ │ ├── Banner.tsx │ │ │ └── Footer.tsx │ │ └── pricing │ │ └── components │ │ └── PricingTable.tsx │ ├── .eslintrc.js │ ├── app │ ├── api │ │ ├── oauth │ │ │ ├── github │ │ │ │ ├── route.ts │ │ │ │ └── callback │ │ │ │ │ └── route.ts │ │ │ └── google │ │ │ │ ├── route.ts │ │ │ │ └── callback │ │ │ │ └── route.ts │ │ └── [trpc] │ │ │ └── route.ts │ ├── icon.png │ ├── favicon.ico │ ├── [locale] │ │ ├── not-found.tsx │ │ ├── [...rest] │ │ │ └── page.tsx │ │ ├── (saas) │ │ │ ├── auth │ │ │ │ ├── verify │ │ │ │ │ └── page.tsx │ │ │ │ ├── otp │ │ │ │ │ └── page.tsx │ │ │ │ ├── login │ │ │ │ │ └── page.tsx │ │ │ │ ├── signup │ │ │ │ │ └── page.tsx │ │ │ │ ├── forgot-password │ │ │ │ │ └── page.tsx │ │ │ │ └── layout.tsx │ │ │ ├── app │ │ │ │ ├── admin │ │ │ │ │ ├── users │ │ │ │ │ │ └── page.tsx │ │ │ │ │ └── layout.tsx │ │ │ │ ├── settings │ │ │ │ │ ├── loading.tsx │ │ │ │ │ ├── account │ │ │ │ │ │ └── general │ │ │ │ │ │ │ └── page.tsx │ │ │ │ │ └── team │ │ │ │ │ │ ├── general │ │ │ │ │ │ └── page.tsx │ │ │ │ │ │ ├── members │ │ │ │ │ │ └── page.tsx │ │ │ │ │ │ └── billing │ │ │ │ │ │ └── page.tsx │ │ │ │ ├── ai-demo │ │ │ │ │ └── page.tsx │ │ │ │ ├── dashboard │ │ │ │ │ └── page.tsx │ │ │ │ └── layout.tsx │ │ │ ├── loading.tsx │ │ │ └── team │ │ │ │ └── invitation │ │ │ │ └── route.tsx │ │ ├── globals.css │ │ ├── (marketing) │ │ │ ├── (home) │ │ │ │ └── page.tsx │ │ │ ├── layout.tsx │ │ │ ├── pricing │ │ │ │ └── page.tsx │ │ │ └── blog │ │ │ │ └── page.tsx │ │ └── layout.tsx │ └── robots.ts │ ├── postcss.config.cjs │ ├── public │ └── images │ │ ├── blog │ │ ├── cover.png │ │ ├── author.jpg │ │ └── author2.jpg │ │ └── hero-dark.svg │ ├── cypress.config.js │ ├── global.d.ts │ ├── tailwind.config.ts │ ├── content │ └── posts │ │ ├── first-post.mdx │ │ └── second-post.mdx │ ├── components.json │ ├── middleware.ts │ ├── .gitignore │ ├── tsconfig.json │ ├── i18n.ts │ ├── config.ts │ ├── contentlayer.config.ts │ └── next.config.js ├── packages ├── mail │ ├── .gitignore │ ├── provider │ │ ├── index.ts │ │ ├── custom.ts │ │ ├── plunk.ts │ │ ├── nodemailer.ts │ │ ├── resend.ts │ │ └── postmark.ts │ ├── index.ts │ ├── config.ts │ ├── tsconfig.json │ ├── types.ts │ ├── emails │ │ ├── components │ │ │ └── PrimaryButton.tsx │ │ ├── NewsletterSignup.tsx │ │ ├── EmailChange.tsx │ │ ├── TeamInvitation.tsx │ │ ├── MagicLink.tsx │ │ ├── NewUser.tsx │ │ └── ForgotPassword.tsx │ ├── util │ │ ├── send.ts │ │ └── templates.ts │ └── package.json ├── storage │ ├── index.ts │ ├── provider │ │ ├── index.ts │ │ ├── supabase │ │ │ └── index.ts │ │ └── s3 │ │ │ └── index.ts │ ├── tsconfig.json │ ├── types.ts │ └── package.json ├── utils │ ├── index.ts │ ├── tsconfig.json │ ├── lib │ │ └── base-url.ts │ └── package.json ├── api │ ├── modules │ │ ├── billing │ │ │ ├── provider │ │ │ │ ├── index.ts │ │ │ │ ├── stripe │ │ │ │ │ ├── index.ts │ │ │ │ │ └── api.ts │ │ │ │ └── lemonsqueezy │ │ │ │ │ ├── index.ts │ │ │ │ │ └── api.ts │ │ │ ├── procedures │ │ │ │ ├── index.ts │ │ │ │ ├── plans.ts │ │ │ │ ├── create-checkout-link.ts │ │ │ │ ├── pause-subscription.ts │ │ │ │ ├── cancel-subscription.ts │ │ │ │ ├── resume-subscription.ts │ │ │ │ ├── sync-subscription.ts │ │ │ │ └── create-customer-portal-link.ts │ │ │ └── types.ts │ │ ├── newsletter │ │ │ └── procedures │ │ │ │ ├── index.ts │ │ │ │ └── signup.ts │ │ ├── ai │ │ │ └── procedures │ │ │ │ ├── index.ts │ │ │ │ └── generate-product-names.ts │ │ ├── uploads │ │ │ └── procedures │ │ │ │ ├── index.ts │ │ │ │ └── signed-upload-url.ts │ │ ├── admin │ │ │ └── procedures │ │ │ │ ├── index.ts │ │ │ │ ├── delete-user.ts │ │ │ │ ├── impersonate.ts │ │ │ │ └── unimpersonate.ts │ │ ├── auth │ │ │ ├── procedures │ │ │ │ ├── index.ts │ │ │ │ ├── update.ts │ │ │ │ ├── change-password.ts │ │ │ │ ├── logout.ts │ │ │ │ ├── delete-account.ts │ │ │ │ ├── verify-token.ts │ │ │ │ ├── change-email.ts │ │ │ │ ├── login-with-email.ts │ │ │ │ ├── forgot-password.ts │ │ │ │ ├── verify-otp.ts │ │ │ │ ├── user.ts │ │ │ │ └── login-with-password.ts │ │ │ └── abilities.ts │ │ └── team │ │ │ └── procedures │ │ │ ├── index.ts │ │ │ ├── subscription.ts │ │ │ ├── invitations.ts │ │ │ ├── invitation-by-id.ts │ │ │ ├── by-id.ts │ │ │ ├── update.ts │ │ │ ├── delete.ts │ │ │ ├── create.ts │ │ │ ├── revoke-invitation.ts │ │ │ ├── memberships.ts │ │ │ ├── remove-member.ts │ │ │ ├── update-membership.ts │ │ │ ├── accept-invite.ts │ │ │ └── invite-member.ts │ ├── tsconfig.json │ ├── trpc │ │ ├── router-handler.ts │ │ ├── caller.ts │ │ ├── router.ts │ │ ├── base.ts │ │ └── context.ts │ └── package.json ├── auth │ ├── index.ts │ ├── tsconfig.json │ ├── types.ts │ ├── lib │ │ ├── password.ts │ │ └── lucia.ts │ └── package.json ├── database │ ├── index.ts │ ├── tsconfig.json │ ├── src │ │ └── client.ts │ └── package.json └── config │ ├── tsconfig │ ├── package.json │ ├── react-library.json │ ├── base.json │ └── nextjs.json │ ├── eslint-config-custom │ ├── index.js │ └── package.json │ └── tailwind │ └── package.json ├── .eslintrc.js ├── .github ├── dependabot.yml └── workflows │ └── main.yml ├── .vscode ├── extensions.json └── settings.json ├── .prettierrc ├── next-env.d.ts ├── pnpm-workspace.yaml ├── README.md ├── .gitignore ├── package.json ├── .env └── turbo.json /.npmrc: -------------------------------------------------------------------------------- 1 | public-hoist-pattern[]=*prisma* -------------------------------------------------------------------------------- /apps/web/cypress/support/e2e.ts: -------------------------------------------------------------------------------- 1 | import "./commands"; 2 | -------------------------------------------------------------------------------- /packages/mail/.gitignore: -------------------------------------------------------------------------------- 1 | .vercel 2 | .react-email 3 | out -------------------------------------------------------------------------------- /packages/storage/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./provider"; 2 | -------------------------------------------------------------------------------- /packages/mail/provider/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./plunk"; 2 | -------------------------------------------------------------------------------- /packages/utils/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./lib/base-url"; 2 | -------------------------------------------------------------------------------- /packages/mail/index.ts: -------------------------------------------------------------------------------- 1 | export { sendEmail } from "./util/send"; 2 | -------------------------------------------------------------------------------- /packages/storage/provider/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./supabase"; 2 | -------------------------------------------------------------------------------- /apps/web/cypress/support/commands.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /apps/web/modules/analytics/index.tsx: -------------------------------------------------------------------------------- 1 | export * from "./provider/custom"; 2 | -------------------------------------------------------------------------------- /packages/api/modules/billing/provider/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./stripe"; 2 | -------------------------------------------------------------------------------- /packages/api/modules/newsletter/procedures/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./signup"; 2 | -------------------------------------------------------------------------------- /packages/api/modules/ai/procedures/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./generate-product-names"; 2 | -------------------------------------------------------------------------------- /packages/api/modules/billing/provider/stripe/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./subscriptions"; 2 | -------------------------------------------------------------------------------- /packages/api/modules/uploads/procedures/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./signed-upload-url"; 2 | -------------------------------------------------------------------------------- /packages/auth/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./lib/lucia"; 2 | export * from "./lib/tokens"; 3 | -------------------------------------------------------------------------------- /apps/web/.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | extends: ["custom"], 4 | }; 5 | -------------------------------------------------------------------------------- /packages/api/modules/billing/provider/lemonsqueezy/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./subscriptions"; 2 | -------------------------------------------------------------------------------- /packages/mail/config.ts: -------------------------------------------------------------------------------- 1 | export const config = { 2 | from: "", 3 | }; 4 | -------------------------------------------------------------------------------- /apps/web/app/api/oauth/github/route.ts: -------------------------------------------------------------------------------- 1 | export { githubRouteHandler as GET } from "auth/oauth/github"; 2 | -------------------------------------------------------------------------------- /apps/web/app/api/oauth/google/route.ts: -------------------------------------------------------------------------------- 1 | export { googleRouteHandler as GET } from "auth/oauth/google"; 2 | -------------------------------------------------------------------------------- /apps/web/app/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/284247028/supastarter-nextjs/HEAD/apps/web/app/icon.png -------------------------------------------------------------------------------- /apps/web/modules/saas/shared/constants.ts: -------------------------------------------------------------------------------- 1 | export const CURRENT_TEAM_ID_COOKIE_NAME = "current-team-id"; 2 | -------------------------------------------------------------------------------- /apps/web/postcss.config.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | tailwindcss: {}, 4 | }, 5 | }; 6 | -------------------------------------------------------------------------------- /apps/web/app/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/284247028/supastarter-nextjs/HEAD/apps/web/app/favicon.ico -------------------------------------------------------------------------------- /apps/web/app/api/oauth/github/callback/route.ts: -------------------------------------------------------------------------------- 1 | export { githubCallbackRouteHandler as GET } from "auth/oauth/github"; 2 | -------------------------------------------------------------------------------- /apps/web/app/api/oauth/google/callback/route.ts: -------------------------------------------------------------------------------- 1 | export { googleCallbackRouteHandler as GET } from "auth/oauth/google"; 2 | -------------------------------------------------------------------------------- /apps/web/public/images/blog/cover.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/284247028/supastarter-nextjs/HEAD/apps/web/public/images/blog/cover.png -------------------------------------------------------------------------------- /apps/web/public/images/blog/author.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/284247028/supastarter-nextjs/HEAD/apps/web/public/images/blog/author.jpg -------------------------------------------------------------------------------- /apps/web/public/images/blog/author2.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/284247028/supastarter-nextjs/HEAD/apps/web/public/images/blog/author2.jpg -------------------------------------------------------------------------------- /packages/database/index.ts: -------------------------------------------------------------------------------- 1 | export { PrismaClient } from "@prisma/client"; 2 | export * from "./src/client"; 3 | export * from "./src/zod"; 4 | -------------------------------------------------------------------------------- /packages/auth/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "tsconfig/base.json", 3 | "include": ["**/*.ts"], 4 | "exclude": ["dist", "build", "node_modules"] 5 | } 6 | -------------------------------------------------------------------------------- /packages/mail/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "tsconfig/react-library.json", 3 | "include": ["."], 4 | "exclude": ["dist", "build", "node_modules"] 5 | } 6 | -------------------------------------------------------------------------------- /packages/storage/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "tsconfig/base.json", 3 | "include": ["**/*.ts"], 4 | "exclude": ["dist", "build", "node_modules"] 5 | } 6 | -------------------------------------------------------------------------------- /packages/utils/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "tsconfig/base.json", 3 | "include": ["**/*.ts"], 4 | "exclude": ["dist", "build", "node_modules"] 5 | } 6 | -------------------------------------------------------------------------------- /apps/web/app/[locale]/not-found.tsx: -------------------------------------------------------------------------------- 1 | import { redirect } from "next/navigation"; 2 | 3 | export default function NotFoundPage() { 4 | return redirect("/404"); 5 | } 6 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | extends: ['custom'], 4 | settings: { 5 | next: { 6 | rootDir: ['apps/*/'], 7 | }, 8 | }, 9 | }; 10 | -------------------------------------------------------------------------------- /packages/api/modules/admin/procedures/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./delete-user"; 2 | export * from "./impersonate"; 3 | export * from "./unimpersonate"; 4 | export * from "./users"; 5 | -------------------------------------------------------------------------------- /apps/web/cypress.config.js: -------------------------------------------------------------------------------- 1 | const { defineConfig } = require("cypress"); 2 | 3 | module.exports = defineConfig({ 4 | e2e: { 5 | baseUrl: "http://localhost:3000", 6 | }, 7 | }); 8 | -------------------------------------------------------------------------------- /apps/web/global.d.ts: -------------------------------------------------------------------------------- 1 | // Use type safe message keys with `next-intl` 2 | type Messages = typeof import("./locales/en.json"); 3 | 4 | declare interface IntlMessages extends Messages {} 5 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "npm" 4 | directory: "/" 5 | schedule: 6 | interval: "daily" 7 | open-pull-requests-limit: 25 8 | -------------------------------------------------------------------------------- /apps/web/app/[locale]/[...rest]/page.tsx: -------------------------------------------------------------------------------- 1 | import { NotFound } from "@marketing/shared/components/NotFound"; 2 | 3 | export default function NotFoundPage() { 4 | return ; 5 | } 6 | -------------------------------------------------------------------------------- /packages/api/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "tsconfig/react-library.json", 3 | "include": ["**/*.ts", "../auth/lucia.d.ts"], 4 | "exclude": ["dist", "build", "node_modules"] 5 | } 6 | -------------------------------------------------------------------------------- /apps/web/modules/saas/dashboard/state.ts: -------------------------------------------------------------------------------- 1 | import { atom } from "jotai"; 2 | 3 | export const sidebarExpanded = atom(false); 4 | 5 | export const createTeamDialogOpen = atom(false); 6 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": [ 3 | "esbenp.prettier-vscode", 4 | "dbaeumer.vscode-eslint", 5 | "lokalise.i18n-ally", 6 | "bradlc.vscode-tailwindcss" 7 | ] 8 | } 9 | -------------------------------------------------------------------------------- /apps/web/modules/shared/lib/api-client.ts: -------------------------------------------------------------------------------- 1 | import { createTRPCReact } from "@trpc/react-query"; 2 | import { ApiRouter } from "api/trpc/router"; 3 | 4 | export const apiClient = createTRPCReact({}); 5 | -------------------------------------------------------------------------------- /packages/config/tsconfig/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "tsconfig", 3 | "version": "0.0.0", 4 | "private": true, 5 | "license": "MIT", 6 | "publishConfig": { 7 | "access": "public" 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /apps/web/app/[locale]/(saas)/auth/verify/page.tsx: -------------------------------------------------------------------------------- 1 | import { VerifyTokenView } from "@saas/auth/components/VerifyTokenView"; 2 | 3 | export default function VerifyTokenPage() { 4 | return ; 5 | } 6 | -------------------------------------------------------------------------------- /apps/web/modules/ui/lib/index.ts: -------------------------------------------------------------------------------- 1 | import { clsx, type ClassValue } from "clsx"; 2 | import { twMerge } from "tailwind-merge"; 3 | 4 | export function cn(...inputs: ClassValue[]) { 5 | return twMerge(clsx(inputs)); 6 | } 7 | -------------------------------------------------------------------------------- /packages/database/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "tsconfig/react-library.json", 3 | "include": ["."], 4 | "exclude": ["dist", "build", "node_modules"], 5 | "compilerOptions": { 6 | "baseUrl": "." 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /packages/mail/types.ts: -------------------------------------------------------------------------------- 1 | export interface SendEmailParams { 2 | to: string; 3 | subject: string; 4 | text: string; 5 | html?: string; 6 | } 7 | 8 | export type SendEmailHandler = (params: SendEmailParams) => Promise; 9 | -------------------------------------------------------------------------------- /apps/web/modules/saas/auth/hooks/use-user.ts: -------------------------------------------------------------------------------- 1 | import { useContext } from "react"; 2 | import { userContext } from "../lib/user-context"; 3 | 4 | export function useUser() { 5 | const context = useContext(userContext); 6 | return context; 7 | } 8 | -------------------------------------------------------------------------------- /apps/web/app/api/[trpc]/route.ts: -------------------------------------------------------------------------------- 1 | import { trpcApiRouteHandler } from "api/trpc/router-handler"; 2 | 3 | export const dynamic = "force-dynamic"; 4 | export const revalidate = 0; 5 | 6 | export { trpcApiRouteHandler as GET, trpcApiRouteHandler as POST }; 7 | -------------------------------------------------------------------------------- /apps/web/app/[locale]/(saas)/app/admin/users/page.tsx: -------------------------------------------------------------------------------- 1 | import { UserList } from "@saas/admin/component/UserList"; 2 | 3 | export default function AdminUserPage() { 4 | return ( 5 | 6 | 7 | 8 | ); 9 | } 10 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "arrowParens": "always", 3 | "printWidth": 80, 4 | "singleQuote": false, 5 | "jsxSingleQuote": false, 6 | "semi": true, 7 | "trailingComma": "all", 8 | "tabWidth": 2, 9 | "plugins": ["prettier-plugin-tailwindcss"] 10 | } 11 | -------------------------------------------------------------------------------- /apps/web/tailwind.config.ts: -------------------------------------------------------------------------------- 1 | import baseConfig from "tailwind-config/tailwind.config"; 2 | import { Config } from "tailwindcss"; 3 | 4 | export default { 5 | presets: [baseConfig], 6 | content: ["./app/**/*.tsx", "./modules/**/*.tsx"], 7 | } satisfies Config; 8 | -------------------------------------------------------------------------------- /apps/web/app/robots.ts: -------------------------------------------------------------------------------- 1 | import { MetadataRoute } from "next"; 2 | 3 | export default function robots(): MetadataRoute.Robots { 4 | return { 5 | rules: { 6 | userAgent: "*", 7 | allow: "/", 8 | }, 9 | // sitemap: `${BASE_URL}/sitemap.xml`, 10 | }; 11 | } 12 | -------------------------------------------------------------------------------- /packages/utils/lib/base-url.ts: -------------------------------------------------------------------------------- 1 | export function getBaseUrl() { 2 | if (process.env.NEXT_PUBLIC_SITE_URL) return process.env.NEXT_PUBLIC_SITE_URL; 3 | if (process.env.VERCEL_URL) return `https://${process.env.VERCEL_URL}`; 4 | return `http://localhost:${process.env.PORT ?? 3000}`; 5 | } 6 | -------------------------------------------------------------------------------- /packages/mail/provider/custom.ts: -------------------------------------------------------------------------------- 1 | import { config } from "../config"; 2 | import { SendEmailHandler } from "../types"; 3 | 4 | const { from } = config; 5 | 6 | export const send: SendEmailHandler = async ({ to, subject, text, html }) => { 7 | // handle your custom email sending logic here 8 | }; 9 | -------------------------------------------------------------------------------- /next-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | /// 3 | /// 4 | 5 | // NOTE: This file should not be edited 6 | // see https://nextjs.org/docs/basic-features/typescript for more information. 7 | -------------------------------------------------------------------------------- /packages/auth/types.ts: -------------------------------------------------------------------------------- 1 | import { User } from "database"; 2 | 3 | export type PartialBy = Omit & Partial>; 4 | 5 | export type DatabaseSessionAttributes = { 6 | impersonatorId?: string; 7 | }; 8 | export type DatabaseUserAttributes = PartialBy; 9 | -------------------------------------------------------------------------------- /pnpm-workspace.yaml: -------------------------------------------------------------------------------- 1 | packages: 2 | - apps/web 3 | - packages/api 4 | - packages/auth 5 | - packages/database 6 | - packages/mail 7 | - packages/config/eslint-config-custom 8 | - packages/config/tsconfig 9 | - packages/config/tailwind 10 | - packages/storage 11 | - packages/utils 12 | -------------------------------------------------------------------------------- /packages/config/eslint-config-custom/index.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | extends: ["next", "turbo", "prettier"], 3 | rules: { 4 | "@next/next/no-html-link-for-pages": "off", 5 | }, 6 | parserOptions: { 7 | babelOptions: { 8 | presets: [require.resolve("next/babel")], 9 | }, 10 | }, 11 | }; 12 | -------------------------------------------------------------------------------- /apps/web/modules/i18n/index.ts: -------------------------------------------------------------------------------- 1 | import { appConfig } from "@config"; 2 | import { createSharedPathnamesNavigation } from "next-intl/navigation"; 3 | 4 | export const { Link, redirect, usePathname, useRouter } = 5 | createSharedPathnamesNavigation({ 6 | locales: appConfig.i18n.locales, 7 | localePrefix: "never", 8 | }); 9 | -------------------------------------------------------------------------------- /packages/api/modules/billing/procedures/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./cancel-subscription"; 2 | export * from "./create-checkout-link"; 3 | export * from "./create-customer-portal-link"; 4 | export * from "./pause-subscription"; 5 | export * from "./plans"; 6 | export * from "./resume-subscription"; 7 | export * from "./sync-subscription"; 8 | -------------------------------------------------------------------------------- /packages/config/tsconfig/react-library.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://json.schemastore.org/tsconfig", 3 | "display": "React Library", 4 | "extends": "./base.json", 5 | "compilerOptions": { 6 | "lib": ["ES2021", "DOM"], 7 | "module": "ESNext", 8 | "target": "ES6", 9 | "jsx": "preserve" 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /apps/web/app/[locale]/(saas)/app/settings/loading.tsx: -------------------------------------------------------------------------------- 1 | import { Icon } from "@ui/components/icon"; 2 | 3 | export default function Loading() { 4 | return ( 5 | 6 | 7 | 8 | ); 9 | } 10 | -------------------------------------------------------------------------------- /apps/web/modules/saas/auth/lib/current-team-id.tsx: -------------------------------------------------------------------------------- 1 | import { CURRENT_TEAM_ID_COOKIE_NAME } from "@saas/shared/constants"; 2 | import Cookies from "js-cookie"; 3 | 4 | export function updateCurrentTeamIdCookie(teamId: string) { 5 | Cookies.set(CURRENT_TEAM_ID_COOKIE_NAME, teamId, { 6 | path: "/", 7 | expires: 30, 8 | }); 9 | } 10 | -------------------------------------------------------------------------------- /apps/web/app/[locale]/(saas)/loading.tsx: -------------------------------------------------------------------------------- 1 | import { Logo } from "@shared/components/Logo"; 2 | 3 | export default function Loading() { 4 | return ( 5 | 6 | 7 | 8 | 9 | 10 | ); 11 | } 12 | -------------------------------------------------------------------------------- /packages/auth/lib/password.ts: -------------------------------------------------------------------------------- 1 | import { Argon2id } from "oslo/password"; 2 | 3 | export async function hashPassword(password: string) { 4 | return await new Argon2id().hash(password); 5 | } 6 | 7 | export async function verifyPassword(hashedPassword: string, password: string) { 8 | return new Argon2id().verify(hashedPassword, password); 9 | } 10 | -------------------------------------------------------------------------------- /apps/web/modules/ui/components/skeleton.tsx: -------------------------------------------------------------------------------- 1 | import { cn } from "@ui/lib" 2 | 3 | function Skeleton({ 4 | className, 5 | ...props 6 | }: React.HTMLAttributes) { 7 | return ( 8 | 12 | ) 13 | } 14 | 15 | export { Skeleton } 16 | -------------------------------------------------------------------------------- /apps/web/cypress/e2e/signup.cy.ts: -------------------------------------------------------------------------------- 1 | describe("signup", () => { 2 | beforeEach(() => { 3 | cy.visit("/auth/signup"); 4 | }); 5 | 6 | it("should show all form fields", () => { 7 | cy.get('input[name="name"]').should("exist"); 8 | cy.get('input[name="email"]').should("exist"); 9 | cy.get('input[name="password"]').should("exist"); 10 | }); 11 | }); 12 | -------------------------------------------------------------------------------- /packages/api/trpc/router-handler.ts: -------------------------------------------------------------------------------- 1 | import { fetchRequestHandler } from "@trpc/server/adapters/fetch"; 2 | import { createContext } from "./context"; 3 | import { apiRouter } from "./router"; 4 | 5 | export const trpcApiRouteHandler = (req: Request) => 6 | fetchRequestHandler({ 7 | endpoint: "/api", 8 | req, 9 | router: apiRouter, 10 | createContext, 11 | }); 12 | -------------------------------------------------------------------------------- /packages/utils/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "devDependencies": { 3 | "@types/node": "^20.11.24", 4 | "eslint-config-custom": "workspace:*", 5 | "tsconfig": "workspace:*" 6 | }, 7 | "license": "MIT", 8 | "main": "./index.tsx", 9 | "name": "utils", 10 | "scripts": { 11 | "lint": "eslint \"**/*.ts*\"" 12 | }, 13 | "types": "./**/.tsx", 14 | "version": "0.0.0" 15 | } 16 | -------------------------------------------------------------------------------- /apps/web/app/[locale]/globals.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | @layer base { 6 | * { 7 | @apply border-border; 8 | } 9 | } 10 | 11 | @layer utilities { 12 | .no-scrollbar::-webkit-scrollbar { 13 | display: none; 14 | } 15 | 16 | .no-scrollbar { 17 | -ms-overflow-style: none; 18 | scrollbar-width: none; 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /packages/api/modules/billing/procedures/plans.ts: -------------------------------------------------------------------------------- 1 | import { z } from "zod"; 2 | import { publicProcedure } from "../../../trpc/base"; 3 | import { getAllPlans } from "../provider"; 4 | import { SubscriptionPlanModel } from "../types"; 5 | 6 | export const plans = publicProcedure 7 | .output(z.array(SubscriptionPlanModel)) 8 | .query(async () => { 9 | return await getAllPlans(); 10 | }); 11 | -------------------------------------------------------------------------------- /apps/web/app/[locale]/(marketing)/(home)/page.tsx: -------------------------------------------------------------------------------- 1 | import { Features } from "@marketing/home/components/Features"; 2 | import { Hero } from "@marketing/home/components/Hero"; 3 | import { Newsletter } from "@marketing/home/components/Newsletter"; 4 | 5 | export default function Home() { 6 | return ( 7 | <> 8 | 9 | 10 | 11 | > 12 | ); 13 | } 14 | -------------------------------------------------------------------------------- /apps/web/app/[locale]/(saas)/auth/otp/page.tsx: -------------------------------------------------------------------------------- 1 | import { OtpForm } from "@saas/auth/components/OtpForm"; 2 | import { getTranslations } from "next-intl/server"; 3 | 4 | export async function generateMetadata() { 5 | const t = await getTranslations(); 6 | 7 | return { 8 | title: t("auth.verifyOtp.title"), 9 | }; 10 | } 11 | 12 | export default function OtpPage() { 13 | return ; 14 | } 15 | -------------------------------------------------------------------------------- /apps/web/modules/shared/hooks/locale-currency.tsx: -------------------------------------------------------------------------------- 1 | import { appConfig } from "@config"; 2 | import { useLocale } from "next-intl"; 3 | 4 | export function useLocaleCurrency() { 5 | const locale = useLocale(); 6 | const localeCurrency = 7 | Object.entries(appConfig.i18n.localeCurrencies).find( 8 | ([key, value]) => key === locale, 9 | )?.[1] || "USD"; 10 | 11 | return localeCurrency; 12 | } 13 | -------------------------------------------------------------------------------- /apps/web/app/[locale]/(saas)/auth/login/page.tsx: -------------------------------------------------------------------------------- 1 | import { LoginForm } from "@saas/auth/components/LoginForm"; 2 | import { getTranslations } from "next-intl/server"; 3 | 4 | export async function generateMetadata() { 5 | const t = await getTranslations(); 6 | 7 | return { 8 | title: t("auth.login.title"), 9 | }; 10 | } 11 | 12 | export default function LoginPage() { 13 | return ; 14 | } 15 | -------------------------------------------------------------------------------- /apps/web/app/[locale]/(saas)/auth/signup/page.tsx: -------------------------------------------------------------------------------- 1 | import { SignupForm } from "@saas/auth/components/SignupForm"; 2 | import { getTranslations } from "next-intl/server"; 3 | 4 | export async function generateMetadata() { 5 | const t = await getTranslations(); 6 | 7 | return { 8 | title: t("auth.signup.title"), 9 | }; 10 | } 11 | 12 | export default function SignupPage() { 13 | return ; 14 | } 15 | -------------------------------------------------------------------------------- /apps/web/content/posts/first-post.mdx: -------------------------------------------------------------------------------- 1 | --- 2 | title: Favorite Things 3 | date: 2023-02-28 4 | image: /images/blog/cover.png 5 | authorName: Elon Musk 6 | authorImage: /images/blog/author2.jpg 7 | excerpt: In this post I'm going to tell you about my favorite things. 8 | tags: [first, post] 9 | published: true 10 | --- 11 | 12 | ## This is a heading 13 | 14 | And we also have some great content here. What do you think? 15 | -------------------------------------------------------------------------------- /apps/web/modules/saas/shared/components/PageHeader.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | export function PageHeader({ 4 | title, 5 | subtitle, 6 | }: { 7 | title: string; 8 | subtitle?: string; 9 | }) { 10 | return ( 11 | 12 | {title} 13 | {subtitle} 14 | 15 | ); 16 | } 17 | -------------------------------------------------------------------------------- /packages/database/src/client.ts: -------------------------------------------------------------------------------- 1 | import { PrismaClient } from "@prisma/client"; 2 | declare let global: { prisma: PrismaClient }; 3 | 4 | let prisma: PrismaClient; 5 | 6 | if (process.env.NODE_ENV === "production") { 7 | prisma = new PrismaClient(); 8 | } else { 9 | if (!global.prisma) { 10 | global.prisma = new PrismaClient(); 11 | } 12 | 13 | prisma = global.prisma; 14 | } 15 | 16 | export { prisma as db }; 17 | -------------------------------------------------------------------------------- /packages/config/eslint-config-custom/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "eslint-config-custom", 3 | "version": "0.0.0", 4 | "main": "index.js", 5 | "license": "MIT", 6 | "dependencies": { 7 | "eslint-config-next": "^14.1.0", 8 | "eslint-config-prettier": "^9.1.0", 9 | "eslint-config-turbo": "^1.12.4", 10 | "eslint-plugin-react": "7.33.2" 11 | }, 12 | "publishConfig": { 13 | "access": "public" 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /packages/api/trpc/caller.ts: -------------------------------------------------------------------------------- 1 | import { createContext } from "./context"; 2 | import { apiRouter } from "./router"; 3 | 4 | export const createApiCaller = async () => { 5 | const context = await createContext(); 6 | return apiRouter.createCaller(context); 7 | }; 8 | 9 | export const createAdminApiCaller = async () => { 10 | const context = await createContext({ isAdmin: true }); 11 | return apiRouter.createCaller(context); 12 | }; 13 | -------------------------------------------------------------------------------- /apps/web/modules/shared/components/ClientProviders.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { ThemeProvider } from "next-themes"; 4 | import { PropsWithChildren } from "react"; 5 | import { ApiClientProvider } from "./ApiClientProvider"; 6 | 7 | export function ClientProviders({ children }: PropsWithChildren) { 8 | return ( 9 | 10 | {children} 11 | 12 | ); 13 | } 14 | -------------------------------------------------------------------------------- /packages/api/modules/auth/procedures/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./change-email"; 2 | export * from "./change-password"; 3 | export * from "./delete-account"; 4 | export * from "./forgot-password"; 5 | export * from "./login-with-email"; 6 | export * from "./login-with-password"; 7 | export * from "./logout"; 8 | export * from "./signup"; 9 | export * from "./update"; 10 | export * from "./user"; 11 | export * from "./verify-otp"; 12 | export * from "./verify-token"; 13 | -------------------------------------------------------------------------------- /apps/web/app/[locale]/(saas)/auth/forgot-password/page.tsx: -------------------------------------------------------------------------------- 1 | import { ForgotPasswordForm } from "@saas/auth/components/ForgotPasswordForm"; 2 | import { getTranslations } from "next-intl/server"; 3 | 4 | export async function generateMetadata() { 5 | const t = await getTranslations(); 6 | 7 | return { 8 | title: t("auth.forgotPassword.title"), 9 | }; 10 | } 11 | 12 | export default function ForgotPasswordPage() { 13 | return ; 14 | } 15 | -------------------------------------------------------------------------------- /apps/web/components.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://ui.shadcn.com/schema.json", 3 | "style": "default", 4 | "rsc": true, 5 | "tsx": true, 6 | "tailwind": { 7 | "config": "tailwind.config.ts", 8 | "css": "styles/globals.css", 9 | "baseColor": "slate", 10 | "cssVariables": true, 11 | "prefix": "" 12 | }, 13 | "aliases": { 14 | "components": "@ui/components", 15 | "utils": "@ui/lib", 16 | "ui": "@ui/components" 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /packages/api/modules/newsletter/procedures/signup.ts: -------------------------------------------------------------------------------- 1 | import { sendEmail } from "mail"; 2 | import { z } from "zod"; 3 | import { publicProcedure } from "../../../trpc/base"; 4 | 5 | export const signup = publicProcedure 6 | .input( 7 | z.object({ 8 | email: z.string(), 9 | }), 10 | ) 11 | .mutation(async ({ input: { email } }) => { 12 | await sendEmail({ 13 | to: email, 14 | templateId: "newsletterSignup", 15 | }); 16 | }); 17 | -------------------------------------------------------------------------------- /apps/web/modules/analytics/provider/custom/index.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | export function AnalyticsScript() { 4 | // return your script here 5 | return null; 6 | } 7 | 8 | export function useAnalytics() { 9 | const trackEvent = (event: string, data: Record) => { 10 | // call your analytics service to track a custom event here 11 | console.info("tracking event", event, data); 12 | }; 13 | 14 | return { 15 | trackEvent, 16 | }; 17 | } 18 | -------------------------------------------------------------------------------- /packages/mail/emails/components/PrimaryButton.tsx: -------------------------------------------------------------------------------- 1 | import { Button } from "@react-email/components"; 2 | import { PropsWithChildren } from "react"; 3 | 4 | export default function PrimaryButton({ 5 | href, 6 | children, 7 | }: PropsWithChildren<{ 8 | href: string; 9 | }>) { 10 | return ( 11 | 15 | {children} 16 | 17 | ); 18 | } 19 | -------------------------------------------------------------------------------- /packages/storage/types.ts: -------------------------------------------------------------------------------- 1 | export type CreateBucketHandler = ( 2 | name: string, 3 | options?: { 4 | public?: boolean; 5 | }, 6 | ) => Promise; 7 | 8 | export type GetSignedUploadUrlHandler = ( 9 | path: string, 10 | options: { 11 | bucket: string; 12 | }, 13 | ) => Promise; 14 | 15 | export type GetSignedUrlHander = ( 16 | path: string, 17 | options: { 18 | bucket: string; 19 | expiresIn?: number; 20 | }, 21 | ) => Promise; 22 | -------------------------------------------------------------------------------- /packages/api/modules/team/procedures/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./accept-invite"; 2 | export * from "./by-id"; 3 | export * from "./create"; 4 | export * from "./delete"; 5 | export * from "./invitation-by-id"; 6 | export * from "./invitations"; 7 | export * from "./invite-member"; 8 | export * from "./memberships"; 9 | export * from "./remove-member"; 10 | export * from "./revoke-invitation"; 11 | export * from "./subscription"; 12 | export * from "./update"; 13 | export * from "./update-membership"; 14 | -------------------------------------------------------------------------------- /apps/web/modules/analytics/provider/vercel/index.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | // @ts-ignore 4 | import { Analytics } from "@vercel/analytics/react"; 5 | // @ts-ignore 6 | import { track } from "@vercel/analytics"; 7 | 8 | export function AnalyticsScript() { 9 | return ; 10 | } 11 | 12 | export function useAnalytics() { 13 | const trackEvent = (event: string, data?: Record) => { 14 | track(event, data); 15 | }; 16 | 17 | return { 18 | trackEvent, 19 | }; 20 | } 21 | -------------------------------------------------------------------------------- /packages/config/tailwind/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "tailwind-config", 3 | "version": "0.0.0", 4 | "private": true, 5 | "main": "index.ts", 6 | "devDependencies": { 7 | "@mertasan/tailwindcss-variables": "^2.7.0", 8 | "@tailwindcss/container-queries": "^0.1.1", 9 | "@tailwindcss/forms": "^0.5.6", 10 | "@tailwindcss/typography": "^0.5.10", 11 | "@types/mertasan__tailwindcss-variables": "^2.6.1", 12 | "tailwindcss": "^3.4.1", 13 | "tailwindcss-animate": "^1.0.7" 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | # supastarter – next.js 5 | 6 | supastarter is the ultimate starter kit for production-ready, scalable SaaS applications. 7 | 8 | ## Helpful links 9 | 10 | - [📘 Documentation](https://docs.supastarter.dev) 11 | - [🚀 Demo](https://demo.supastarter.dev) 12 | - [💬 Discord](https://discord.gg/RUSASaAj4V) 13 | 14 | -------------------------------------------------------------------------------- /apps/web/modules/marketing/blog/components/PostContent.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { MDX } from "contentlayer/core"; 4 | import { useMDXComponent } from "next-contentlayer/hooks"; 5 | import { mdxComponents } from "../utils/mdx-components"; 6 | 7 | export function PostContent({ mdx }: { mdx: MDX }) { 8 | const MDXContent = useMDXComponent(mdx.code); 9 | 10 | return ( 11 | 12 | 13 | 14 | ); 15 | } 16 | -------------------------------------------------------------------------------- /apps/web/middleware.ts: -------------------------------------------------------------------------------- 1 | import createMiddleware from "next-intl/middleware"; 2 | import { NextRequest } from "next/server"; 3 | import { appConfig } from "./config"; 4 | 5 | const intlMiddleware = createMiddleware({ 6 | locales: appConfig.i18n.locales, 7 | defaultLocale: appConfig.i18n.defaultLocale, 8 | localePrefix: "never", 9 | }); 10 | 11 | export default async function middleware(req: NextRequest) { 12 | return intlMiddleware(req); 13 | } 14 | 15 | export const config = { 16 | matcher: ["/((?!api|_next|.*\\..*).*)"], 17 | }; 18 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | node_modules 5 | .pnp 6 | .pnp.js 7 | 8 | # testing 9 | coverage 10 | 11 | # next.js 12 | .next/ 13 | out/ 14 | build 15 | .swc/ 16 | 17 | # misc 18 | .DS_Store 19 | *.pem 20 | 21 | # debug 22 | npm-debug.log* 23 | yarn-debug.log* 24 | yarn-error.log* 25 | 26 | # local env files 27 | # .env 28 | .env.local 29 | .env.development.local 30 | .env.test.local 31 | .env.production.local 32 | 33 | # turbo 34 | .turbo 35 | 36 | # ui 37 | dist/ -------------------------------------------------------------------------------- /packages/storage/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "dependencies": { 3 | "@aws-sdk/client-s3": "3.437.0", 4 | "@aws-sdk/s3-request-presigner": "3.437.0", 5 | "@supabase/supabase-js": "^2.39.7" 6 | }, 7 | "devDependencies": { 8 | "@types/node": "^20.11.24", 9 | "eslint-config-custom": "workspace:*", 10 | "tsconfig": "workspace:*" 11 | }, 12 | "license": "MIT", 13 | "main": "./index.ts", 14 | "name": "storage", 15 | "scripts": { 16 | "lint": "eslint \"**/*.ts*\"" 17 | }, 18 | "types": "./**/.ts", 19 | "version": "0.0.0" 20 | } 21 | -------------------------------------------------------------------------------- /apps/web/.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # next.js 12 | /.next/ 13 | /out/ 14 | 15 | # production 16 | /build 17 | 18 | # misc 19 | .DS_Store 20 | *.pem 21 | 22 | # debug 23 | npm-debug.log* 24 | yarn-debug.log* 25 | yarn-error.log* 26 | 27 | # local env files 28 | .env*.local 29 | 30 | # vercel 31 | .vercel 32 | 33 | # typescript 34 | *.tsbuildinfo 35 | next-env.d.ts 36 | 37 | # contentlayer 38 | .contentlayer/ -------------------------------------------------------------------------------- /apps/web/app/[locale]/(marketing)/layout.tsx: -------------------------------------------------------------------------------- 1 | import { Footer } from "@marketing/shared/components/Footer"; 2 | import { NavBar } from "@marketing/shared/components/NavBar"; 3 | import { UserContextProvider } from "@saas/auth/lib/user-context"; 4 | import { PropsWithChildren } from "react"; 5 | 6 | export default function MarketingLayout({ children }: PropsWithChildren) { 7 | return ( 8 | 9 | 10 | {children} 11 | 12 | 13 | ); 14 | } 15 | -------------------------------------------------------------------------------- /packages/config/tsconfig/base.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://json.schemastore.org/tsconfig", 3 | "display": "Default", 4 | "compilerOptions": { 5 | "composite": false, 6 | "esModuleInterop": true, 7 | "forceConsistentCasingInFileNames": true, 8 | "inlineSources": false, 9 | "isolatedModules": true, 10 | "moduleResolution": "node", 11 | "noUnusedLocals": false, 12 | "noUnusedParameters": false, 13 | "preserveWatchOutput": true, 14 | "skipLibCheck": true, 15 | "strict": true, 16 | "strictNullChecks": true 17 | }, 18 | "exclude": ["node_modules"] 19 | } 20 | -------------------------------------------------------------------------------- /packages/api/modules/auth/procedures/update.ts: -------------------------------------------------------------------------------- 1 | import { db } from "database"; 2 | import { z } from "zod"; 3 | import { protectedProcedure } from "../../../trpc/base"; 4 | 5 | export const update = protectedProcedure 6 | .input( 7 | z.object({ 8 | name: z.string().min(1).optional(), 9 | avatarUrl: z.string().min(1).optional(), 10 | }), 11 | ) 12 | .mutation(async ({ ctx: { user }, input: { name, avatarUrl } }) => { 13 | await db.user.update({ 14 | where: { 15 | id: user.id, 16 | }, 17 | data: { 18 | name, 19 | avatarUrl, 20 | }, 21 | }); 22 | }); 23 | -------------------------------------------------------------------------------- /apps/web/modules/saas/shared/components/LoadingWrapper.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { useUser } from "@saas/auth/hooks/use-user"; 4 | import { Logo } from "@shared/components/Logo"; 5 | import { PropsWithChildren } from "react"; 6 | 7 | export function LoadingWrapper({ children }: PropsWithChildren) { 8 | const { user, loaded } = useUser(); 9 | 10 | if (!user || !loaded) 11 | return ( 12 | 13 | 14 | 15 | 16 | 17 | ); 18 | 19 | return {children}; 20 | } 21 | -------------------------------------------------------------------------------- /packages/api/modules/billing/provider/stripe/api.ts: -------------------------------------------------------------------------------- 1 | export async function callStripeApi(endpoint: string, options?: RequestInit) { 2 | const url = `https://api.stripe.com/v1${endpoint}`; 3 | 4 | const response = await fetch(url, { 5 | headers: new Headers({ 6 | Authorization: `Bearer ${process.env.STRIPE_SECRET_KEY}`, 7 | }), 8 | ...options, 9 | cache: "no-cache", 10 | }); 11 | 12 | if (response.ok) { 13 | return response.json(); 14 | } 15 | 16 | throw new Error( 17 | `Request failed: ${response.status} ${response.statusText} ${JSON.stringify( 18 | await response.json(), 19 | )}`, 20 | ); 21 | } 22 | -------------------------------------------------------------------------------- /packages/api/modules/auth/procedures/change-password.ts: -------------------------------------------------------------------------------- 1 | import { hashPassword } from "auth/lib/password"; 2 | import { db } from "database"; 3 | import { z } from "zod"; 4 | import { protectedProcedure } from "../../../trpc/base"; 5 | 6 | export const changePassword = protectedProcedure 7 | .input( 8 | z.object({ 9 | password: z.string().min(8).max(255), 10 | }), 11 | ) 12 | .mutation(async ({ ctx: { user }, input: { password } }) => { 13 | await db.user.update({ 14 | where: { 15 | id: user.id, 16 | }, 17 | data: { 18 | hashedPassword: await hashPassword(password), 19 | }, 20 | }); 21 | }); 22 | -------------------------------------------------------------------------------- /apps/web/modules/marketing/shared/components/NotFound.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { Button } from "@ui/components/button"; 4 | import { Icon } from "@ui/components/icon"; 5 | import Link from "next/link"; 6 | 7 | export function NotFound() { 8 | return ( 9 | 10 | 404 11 | Page not found 12 | 13 | 14 | 15 | Go to homepage 16 | 17 | 18 | 19 | ); 20 | } 21 | -------------------------------------------------------------------------------- /apps/web/modules/saas/auth/components/TeamInvitationInfo.tsx: -------------------------------------------------------------------------------- 1 | import { Alert, AlertDescription, AlertTitle } from "@ui/components/alert"; 2 | import { Icon } from "@ui/components/icon"; 3 | import { useTranslations } from "next-intl"; 4 | 5 | export function TeamInvitationInfo({ className }: { className?: string }) { 6 | const t = useTranslations(); 7 | return ( 8 | 9 | 10 | {t("auth.teamInvitation.title")} 11 | 12 | {t("auth.teamInvitation.description")} 13 | 14 | 15 | ); 16 | } 17 | -------------------------------------------------------------------------------- /packages/api/modules/team/procedures/subscription.ts: -------------------------------------------------------------------------------- 1 | import { SubscriptionSchema, db } from "database"; 2 | import { z } from "zod"; 3 | import { protectedProcedure } from "../../../trpc/base"; 4 | 5 | export const subscription = protectedProcedure 6 | .input( 7 | z.object({ 8 | teamId: z.string(), 9 | }), 10 | ) 11 | .output(SubscriptionSchema.nullable()) 12 | .query(async ({ input: { teamId }, ctx: { abilities } }) => { 13 | if (!abilities.isTeamMember(teamId)) throw new Error("Unauthorized"); 14 | 15 | const subscription = await db.subscription.findFirst({ 16 | where: { 17 | teamId, 18 | }, 19 | }); 20 | 21 | return subscription; 22 | }); 23 | -------------------------------------------------------------------------------- /packages/mail/provider/plunk.ts: -------------------------------------------------------------------------------- 1 | import fetch from "node-fetch"; 2 | import { SendEmailHandler } from "../types"; 3 | 4 | export const send: SendEmailHandler = async ({ to, subject, html, text }) => { 5 | const response = await fetch("https://api.useplunk.com/v1/send", { 6 | method: "POST", 7 | headers: { 8 | "Content-Type": "application/json", 9 | Authorization: `Bearer ${process.env.PLUNK_API_KEY}`, 10 | }, 11 | body: JSON.stringify({ 12 | to, 13 | subject, 14 | body: html, 15 | text, 16 | }), 17 | }); 18 | 19 | if (!response.ok) { 20 | console.error(await response.json()); 21 | throw new Error("Could not send email"); 22 | } 23 | }; 24 | -------------------------------------------------------------------------------- /packages/api/modules/billing/provider/lemonsqueezy/api.ts: -------------------------------------------------------------------------------- 1 | export async function callLemonsqueezyApi( 2 | endpoint: string, 3 | options?: RequestInit, 4 | ) { 5 | const url = `https://api.lemonsqueezy.com/v1${endpoint}`; 6 | 7 | const response = await fetch(url, { 8 | headers: new Headers({ 9 | Authorization: `Bearer ${process.env.LEMONSQUEEZY_API_KEY}`, 10 | Accept: "application/vnd.api+json", 11 | "Content-Type": "application/vnd.api+json", 12 | }), 13 | ...options, 14 | cache: "no-cache", 15 | }); 16 | 17 | if (response.ok) { 18 | return response.json(); 19 | } 20 | 21 | throw new Error(`Request failed: ${response.status} ${response.statusText}`); 22 | } 23 | -------------------------------------------------------------------------------- /packages/mail/provider/nodemailer.ts: -------------------------------------------------------------------------------- 1 | import nodemailer from "nodemailer"; 2 | import { config } from "../config"; 3 | import { SendEmailHandler } from "../types"; 4 | 5 | const { from } = config; 6 | 7 | export const send: SendEmailHandler = async ({ to, subject, text, html }) => { 8 | const transporter = nodemailer.createTransport({ 9 | host: process.env.MAIL_HOST as string, 10 | port: parseInt(process.env.MAIL_PORT as string, 10), 11 | auth: { 12 | user: process.env.MAIL_USER as string, 13 | pass: process.env.MAIL_PASS as string, 14 | }, 15 | }); 16 | 17 | await transporter.sendMail({ 18 | to, 19 | from, 20 | subject, 21 | text, 22 | html, 23 | }); 24 | }; 25 | -------------------------------------------------------------------------------- /packages/auth/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "dependencies": { 3 | "@lucia-auth/adapter-prisma": "^4.0.0", 4 | "arctic": "^1.2.1", 5 | "database": "workspace:*", 6 | "lucia": "^3.0.1", 7 | "mail": "workspace:*", 8 | "next": "14.1.1", 9 | "oslo": "^1.1.3", 10 | "utils": "workspace:*", 11 | "zod": "^3.22.2" 12 | }, 13 | "devDependencies": { 14 | "@types/node": "^20.11.24", 15 | "@types/react": "18.2.61", 16 | "eslint-config-custom": "workspace:*", 17 | "tsconfig": "workspace:*" 18 | }, 19 | "license": "MIT", 20 | "main": "./index.tsx", 21 | "name": "auth", 22 | "scripts": { 23 | "lint": "eslint \"**/*.ts*\"" 24 | }, 25 | "types": "./**/.tsx", 26 | "version": "0.0.0" 27 | } 28 | -------------------------------------------------------------------------------- /packages/mail/emails/NewsletterSignup.tsx: -------------------------------------------------------------------------------- 1 | import { Container, Heading, Section, Text } from "@react-email/components"; 2 | import Wrapper from "./components/Wrapper"; 3 | 4 | export function NewsletterSignup(): JSX.Element { 5 | return ( 6 | 7 | 8 | 9 | Welcome to our newsletter! 10 | Thank you for signing up for the supastarter newsletter. 11 | 12 | 13 | 14 | ); 15 | } 16 | 17 | NewsletterSignup.subjects = { 18 | en: "Welcome to our newsletter!", 19 | de: "Willkommen bei unserem Newsletter!", 20 | }; 21 | 22 | export default NewsletterSignup; 23 | -------------------------------------------------------------------------------- /apps/web/modules/analytics/provider/mixpanel/index.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | // @ts-ignore 4 | import mixpanel from "mixpanel-browser"; 5 | import { useEffect } from "react"; 6 | 7 | const mixpanelToken = process.env.NEXT_PUBLIC_MIXPANEL_TOKEN as string; 8 | 9 | export function AnalyticsScript() { 10 | useEffect(() => { 11 | mixpanel.init(mixpanelToken, { 12 | debug: true, 13 | track_pageview: true, 14 | persistence: "localStorage", 15 | }); 16 | }, []); 17 | 18 | return null; 19 | } 20 | 21 | export function useAnalytics() { 22 | const trackEvent = (event: string, data?: Record) => { 23 | mixpanel.track(event, data); 24 | }; 25 | 26 | return { 27 | trackEvent, 28 | }; 29 | } 30 | -------------------------------------------------------------------------------- /apps/web/modules/marketing/blog/utils/mdx-components.tsx: -------------------------------------------------------------------------------- 1 | import Image from "next/image"; 2 | import Link from "next/link"; 3 | 4 | const CustomImage = (props: any) => ( 5 | 6 | ); 7 | 8 | const CustomLink = (props: any) => { 9 | const href = props.href; 10 | const isInternalLink = href && (href.startsWith("/") || href.startsWith("#")); 11 | 12 | return isInternalLink ? ( 13 | 14 | {props.children} 15 | 16 | ) : ( 17 | 18 | {props.children} 19 | 20 | ); 21 | }; 22 | 23 | export const mdxComponents = { 24 | a: CustomLink, 25 | img: CustomImage, 26 | }; 27 | -------------------------------------------------------------------------------- /packages/config/tsconfig/nextjs.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://json.schemastore.org/tsconfig", 3 | "display": "Next.js", 4 | "extends": "./base.json", 5 | "compilerOptions": { 6 | "plugins": [{ "name": "next" }], 7 | "target": "es5", 8 | "lib": ["dom", "dom.iterable", "esnext"], 9 | "allowJs": true, 10 | "skipLibCheck": true, 11 | "strict": false, 12 | "forceConsistentCasingInFileNames": true, 13 | "noEmit": true, 14 | "incremental": true, 15 | "esModuleInterop": true, 16 | "module": "esnext", 17 | "resolveJsonModule": true, 18 | "isolatedModules": true, 19 | "jsx": "preserve" 20 | }, 21 | "include": ["next-env.d.ts"], 22 | "exclude": ["node_modules"] 23 | } -------------------------------------------------------------------------------- /packages/api/modules/auth/procedures/logout.ts: -------------------------------------------------------------------------------- 1 | import { TRPCError } from "@trpc/server"; 2 | import { lucia } from "auth"; 3 | import { z } from "zod"; 4 | import { protectedProcedure } from "../../../trpc/base"; 5 | 6 | export const logout = protectedProcedure 7 | .input(z.void()) 8 | .mutation(async ({ ctx: { session, responseHeaders } }) => { 9 | try { 10 | await lucia.invalidateSession(session.id); 11 | const sessionCookie = lucia.createBlankSessionCookie(); 12 | responseHeaders?.append("Set-Cookie", sessionCookie.serialize()); 13 | } catch (e) { 14 | console.error(e); 15 | throw new TRPCError({ 16 | code: "INTERNAL_SERVER_ERROR", 17 | message: "An unknown error occurred.", 18 | }); 19 | } 20 | }); 21 | -------------------------------------------------------------------------------- /packages/database/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "dependencies": { 3 | "@prisma/client": "^5.10.2", 4 | "zod": "^3.22.2" 5 | }, 6 | "devDependencies": { 7 | "@types/node": "20.11.24", 8 | "dotenv-cli": "^7.3.0", 9 | "eslint-config-custom": "workspace:*", 10 | "prisma": "^5.10.2", 11 | "tsconfig": "workspace:*", 12 | "zod-prisma-types": "^3.1.6" 13 | }, 14 | "license": "MIT", 15 | "main": "./index.tsx", 16 | "name": "database", 17 | "scripts": { 18 | "lint": "eslint \"**/*.ts*\"", 19 | "db:generate": "prisma generate", 20 | "db:push": "dotenv -c -e ../../.env -- prisma db push --skip-generate", 21 | "db:studio": "dotenv -c -e ../../.env -- prisma studio" 22 | }, 23 | "types": "./**/.tsx", 24 | "version": "0.0.0" 25 | } 26 | -------------------------------------------------------------------------------- /packages/mail/provider/resend.ts: -------------------------------------------------------------------------------- 1 | import fetch from "node-fetch"; 2 | import { config } from "../config"; 3 | import { SendEmailHandler } from "../types"; 4 | 5 | const { from } = config; 6 | 7 | export const send: SendEmailHandler = async ({ to, subject, html, text }) => { 8 | const response = await fetch("https://api.resend.com/emails", { 9 | method: "POST", 10 | headers: { 11 | "Content-Type": "application/json", 12 | Authorization: `Bearer ${process.env.RESEND_API_KEY}`, 13 | }, 14 | body: JSON.stringify({ 15 | from, 16 | to, 17 | subject, 18 | html, 19 | }), 20 | }); 21 | 22 | if (!response.ok) { 23 | console.error(await response.json()); 24 | throw new Error("Could not send email"); 25 | } 26 | }; 27 | -------------------------------------------------------------------------------- /apps/web/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "tsconfig/nextjs.json", 3 | "compilerOptions": { 4 | "plugins": [{ "name": "next" }], 5 | "paths": { 6 | "@config": ["./config"], 7 | "@analytics": ["./modules/analytics"], 8 | "@marketing/*": ["./modules/marketing/*"], 9 | "@saas/*": ["./modules/saas/*"], 10 | "@ui/*": ["./modules/ui/*"], 11 | "@i18n": ["./modules/i18n"], 12 | "@shared/*": ["./modules/shared/*"], 13 | "contentlayer/generated": ["./.contentlayer/generated"] 14 | } 15 | }, 16 | "include": [ 17 | "**/*.ts", 18 | "**/*.tsx", 19 | "**/*.cjs", 20 | "**/*.mjs", 21 | ".next/types/**/*.ts", 22 | ".contentlayer/generated", 23 | "../../packages/auth/lucia.d.ts" 24 | ], 25 | "exclude": ["node_modules"] 26 | } 27 | -------------------------------------------------------------------------------- /packages/api/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "dependencies": { 3 | "@trpc/server": "11.0.0-next-beta.303", 4 | "auth": "workspace:*", 5 | "database": "workspace:*", 6 | "mail": "workspace:*", 7 | "next": "14.1.1", 8 | "openai": "^4.28.4", 9 | "storage": "workspace:*", 10 | "superjson": "^2.2.1", 11 | "utils": "workspace:*", 12 | "zod": "^3.22.2" 13 | }, 14 | "devDependencies": { 15 | "@types/react": "18.2.61", 16 | "encoding": "^0.1.13", 17 | "eslint-config-custom": "workspace:*", 18 | "prisma": "^5.10.2", 19 | "tsconfig": "workspace:*" 20 | }, 21 | "license": "MIT", 22 | "main": "./index.tsx", 23 | "name": "api", 24 | "scripts": { 25 | "lint": "eslint \"**/*.ts*\"" 26 | }, 27 | "types": "./**/.tsx", 28 | "version": "0.0.0" 29 | } 30 | -------------------------------------------------------------------------------- /packages/mail/util/send.ts: -------------------------------------------------------------------------------- 1 | import { send } from "../provider"; 2 | import { getTemplate, type mailTemplates } from "./templates"; 3 | 4 | export async function sendEmail< 5 | TemplateId extends keyof typeof mailTemplates, 6 | >(params: { 7 | to: string; 8 | templateId: TemplateId; 9 | context?: Parameters<(typeof mailTemplates)[TemplateId]>[0]; 10 | }) { 11 | const { to, templateId, context } = params; 12 | 13 | const { html, text, subject } = await getTemplate({ 14 | templateId, 15 | context, 16 | locale: "en", 17 | }); 18 | 19 | try { 20 | // send the email 21 | await send({ 22 | to, 23 | subject, 24 | text, 25 | html, 26 | }); 27 | return true; 28 | } catch (e) { 29 | console.error(e); 30 | return false; 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /apps/web/modules/analytics/provider/plausible/index.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import Script from "next/script"; 4 | 5 | const plausibleUrl = process.env.NEXT_PUBLIC_PLAUSIBLE_URL as string; 6 | 7 | export function AnalyticsScript() { 8 | return ( 9 | 15 | ); 16 | } 17 | 18 | export function useAnalytics() { 19 | const trackEvent = (event: string, data?: Record) => { 20 | if (typeof window === "undefined" || !(window as any).plausible) { 21 | return; 22 | } 23 | 24 | (window as any).plausible(event, { 25 | props: data, 26 | }); 27 | }; 28 | 29 | return { 30 | trackEvent, 31 | }; 32 | } 33 | -------------------------------------------------------------------------------- /apps/web/app/[locale]/(saas)/app/ai-demo/page.tsx: -------------------------------------------------------------------------------- 1 | import { ProductNameGenerator } from "@saas/dashboard/components/ProductNameGenerator"; 2 | import { PageHeader } from "@saas/shared/components/PageHeader"; 3 | 4 | export default function AiDemoPage() { 5 | return ( 6 | 7 | 11 | 12 | 13 | 14 | Enter a topic and we will generate some funny product names for you: 15 | 16 | 17 | 18 | 19 | 20 | ); 21 | } 22 | -------------------------------------------------------------------------------- /apps/web/modules/analytics/provider/umami/index.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import Script from "next/script"; 4 | 5 | const umamiTrackingId = process.env.NEXT_PUBLIC_UMAMI_TRACKING_ID as string; 6 | 7 | export function AnalyticsScript() { 8 | return ( 9 | 15 | ); 16 | } 17 | 18 | export function useAnalytics() { 19 | const trackEvent = (event: string, data?: Record) => { 20 | if (typeof window === "undefined" || !(window as any).umami) { 21 | return; 22 | } 23 | 24 | (window as any).umami.track(event, { 25 | props: data, 26 | }); 27 | }; 28 | 29 | return { 30 | trackEvent, 31 | }; 32 | } 33 | -------------------------------------------------------------------------------- /apps/web/modules/analytics/provider/pirsch/index.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import Script from "next/script"; 4 | 5 | const pirschCode = process.env.NEXT_PUBLIC_PIRSCH_CODE as string; 6 | 7 | export function AnalyticsScript() { 8 | return ( 9 | 16 | ); 17 | } 18 | 19 | export function useAnalytics() { 20 | const trackEvent = (event: string, data?: Record) => { 21 | if (typeof window === "undefined" || !(window as any).pirsch) { 22 | return; 23 | } 24 | 25 | (window as any).pirsch(event, { 26 | meta: data, 27 | }); 28 | }; 29 | 30 | return { 31 | trackEvent, 32 | }; 33 | } 34 | -------------------------------------------------------------------------------- /apps/web/modules/ui/components/label.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import * as LabelPrimitive from "@radix-ui/react-label"; 4 | import { cn } from "@ui/lib"; 5 | import { VariantProps, cva } from "class-variance-authority"; 6 | import * as React from "react"; 7 | 8 | const labelVariants = cva( 9 | "text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70", 10 | ); 11 | 12 | const Label = React.forwardRef< 13 | React.ElementRef, 14 | React.ComponentPropsWithoutRef & 15 | VariantProps 16 | >(({ className, ...props }, ref) => ( 17 | 22 | )); 23 | Label.displayName = LabelPrimitive.Root.displayName; 24 | 25 | export { Label }; 26 | -------------------------------------------------------------------------------- /packages/mail/provider/postmark.ts: -------------------------------------------------------------------------------- 1 | import fetch from "node-fetch"; 2 | import { config } from "../config"; 3 | import { SendEmailHandler } from "../types"; 4 | 5 | const { from } = config; 6 | 7 | export const send: SendEmailHandler = async ({ to, subject, html, text }) => { 8 | const response = await fetch("https://api.postmarkapp.com/email", { 9 | method: "POST", 10 | headers: { 11 | "Content-Type": "application/json", 12 | "X-Postmark-Server-Token": process.env.POSTMARK_SERVER_TOKEN as string, 13 | }, 14 | body: JSON.stringify({ 15 | From: from, 16 | To: to, 17 | Subject: subject, 18 | HtmlBody: html, 19 | MessageStream: "outbound", 20 | }), 21 | }); 22 | 23 | if (!response.ok) { 24 | console.error(await response.json()); 25 | throw new Error("Could not send email"); 26 | } 27 | }; 28 | -------------------------------------------------------------------------------- /packages/api/modules/uploads/procedures/signed-upload-url.ts: -------------------------------------------------------------------------------- 1 | import { TRPCError } from "@trpc/server"; 2 | import { getSignedUploadUrl } from "storage"; 3 | import { z } from "zod"; 4 | import { protectedProcedure } from "../../../trpc/base"; 5 | 6 | export const signedUploadUrl = protectedProcedure 7 | .input( 8 | z.object({ 9 | bucket: z.string().min(1), 10 | path: z.string().min(1), 11 | }), 12 | ) 13 | .mutation(async ({ input: { bucket, path } }) => { 14 | // ATTENTION: be careful with how you give access to write to the storage 15 | // always check if the user has the right to write to the desired bucket before giving them a signed url 16 | 17 | if (bucket === "avatars") { 18 | return await getSignedUploadUrl(path, { bucket }); 19 | } 20 | 21 | throw new TRPCError({ 22 | code: "FORBIDDEN", 23 | }); 24 | }); 25 | -------------------------------------------------------------------------------- /packages/api/modules/team/procedures/invitations.ts: -------------------------------------------------------------------------------- 1 | import { TRPCError } from "@trpc/server"; 2 | import { TeamInvitationSchema, db } from "database"; 3 | import { z } from "zod"; 4 | import { protectedProcedure } from "../../../trpc/base"; 5 | 6 | export const invitations = protectedProcedure 7 | .input( 8 | z.object({ 9 | teamId: z.string(), 10 | }), 11 | ) 12 | .output(z.array(TeamInvitationSchema)) 13 | .query(async ({ input: { teamId }, ctx: { abilities } }) => { 14 | if (!abilities.isTeamMember(teamId)) { 15 | throw new TRPCError({ 16 | code: "UNAUTHORIZED", 17 | message: "No permission to read the invitations for this team.", 18 | }); 19 | } 20 | 21 | const invitations = await db.teamInvitation.findMany({ 22 | where: { 23 | teamId, 24 | }, 25 | }); 26 | 27 | return invitations; 28 | }); 29 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "private": true, 3 | "scripts": { 4 | "build": "dotenv -c -- turbo build", 5 | "dev": "dotenv -c -- turbo dev --concurrency 15", 6 | "lint": "turbo lint", 7 | "clean": "turbo clean", 8 | "format": "prettier --write \"**/*.{ts,tsx,md}\"", 9 | "db:push": "turbo db:push", 10 | "db:generate": "turbo db:generate", 11 | "db:studio": "pnpm --filter database run db:studio", 12 | "mail:preview": "pnpm --filter mail run preview", 13 | "e2e": "pnpm --filter web e2e" 14 | }, 15 | "dependencies": { 16 | "eslint": "^8.57.0", 17 | "eslint-config-custom": "workspace:*", 18 | "prettier": "^3.2.5", 19 | "turbo": "^1.12.4", 20 | "typescript": "5.3.3" 21 | }, 22 | "packageManager": "pnpm@8.3.0", 23 | "devDependencies": { 24 | "dotenv-cli": "^7.3.0", 25 | "prettier-plugin-tailwindcss": "^0.5.11" 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /packages/api/modules/team/procedures/invitation-by-id.ts: -------------------------------------------------------------------------------- 1 | import { TeamInvitationSchema, db } from "database"; 2 | import { z } from "zod"; 3 | import { publicProcedure } from "../../../trpc/base"; 4 | 5 | export const invitationById = publicProcedure 6 | .input( 7 | z.object({ 8 | id: z.string(), 9 | }), 10 | ) 11 | .output( 12 | TeamInvitationSchema.extend({ 13 | team: z 14 | .object({ 15 | name: z.string(), 16 | }) 17 | .nullish(), 18 | }).nullable(), 19 | ) 20 | .mutation(async ({ input: { id } }) => { 21 | const invitation = await db.teamInvitation.findFirst({ 22 | where: { 23 | id, 24 | }, 25 | include: { 26 | team: { 27 | select: { 28 | name: true, 29 | }, 30 | }, 31 | }, 32 | }); 33 | 34 | return invitation; 35 | }); 36 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "editor.formatOnSave": true, 3 | "editor.codeActionsOnSave": { 4 | "source.organizeImports": "explicit" 5 | }, 6 | "tailwindCSS.experimental.classRegex": [ 7 | ["tv\\(([^)]*)\\)", "[\"'`]([^\"'`]*).*?[\"'`]"] 8 | ], 9 | "typescript.preferences.importModuleSpecifier": "shortest", 10 | "typescript.tsdk": "node_modules/typescript/lib", 11 | "typescript.enablePromptUseWorkspaceTsdk": true, 12 | "eslint.workingDirectories": [ 13 | { 14 | "pattern": "apps/*/" 15 | }, 16 | "packages/api", 17 | "packages/mail", 18 | "packages/database", 19 | "packages/config/eslint-config-custom", 20 | "packages/config/tailwind-config", 21 | "packages/config/tsconfig" 22 | ], 23 | "i18n-ally.localesPaths": ["apps/web/locales"], 24 | "i18n-ally.keystyle": "nested", 25 | "i18n-ally.enabledFrameworks": ["next-intl"] 26 | } 27 | -------------------------------------------------------------------------------- /packages/mail/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "dependencies": { 3 | "@react-email/components": "^0.0.15", 4 | "@react-email/render": "^0.0.12", 5 | "node-fetch": "^3.3.2", 6 | "nodemailer": "^6.9.11", 7 | "react": "18.2.0", 8 | "react-dom": "18.2.0", 9 | "react-email": "^2.1.0" 10 | }, 11 | "devDependencies": { 12 | "@tailwindcss/line-clamp": "^0.4.4", 13 | "@types/mjml": "^4.7.2", 14 | "@types/node": "20.11.24", 15 | "@types/nodemailer": "^6.4.11", 16 | "@types/react": "18.2.61", 17 | "eslint-config-custom": "workspace:*", 18 | "tailwind-config": "workspace:*", 19 | "tsconfig": "workspace:*" 20 | }, 21 | "license": "MIT", 22 | "main": "./index.ts", 23 | "name": "mail", 24 | "scripts": { 25 | "preview": "email dev --port 3005", 26 | "export": "email export" 27 | }, 28 | "types": "./index.ts", 29 | "version": "0.1.0" 30 | } 31 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: e2e 2 | 3 | on: push 4 | 5 | jobs: 6 | e2e: 7 | name: E2E tests 8 | runs-on: ubuntu-22.04 9 | steps: 10 | - name: Checkout 11 | uses: actions/checkout@v4 12 | - uses: pnpm/action-setup@v2 13 | with: 14 | version: 8 15 | - name: Install cypress 16 | uses: cypress-io/github-action@v5 17 | with: 18 | runTests: false 19 | - run: pnpm build 20 | - name: Cypress run 21 | uses: cypress-io/github-action@v6 22 | with: 23 | install: false 24 | working-directory: apps/web 25 | start: pnpm start 26 | wait-on: "http://localhost:3000" 27 | env: 28 | NEXT_PUBLIC_SITE_URL: ${{secrets.NEXT_PUBLIC_SITE_URL}} 29 | DATABASE_URL: ${{secrets.DATABASE_URL}} 30 | STRIPE_SECRET_KEY: ${{secrets.STRIPE_SECRET_KEY}} 31 | -------------------------------------------------------------------------------- /apps/web/modules/shared/components/UserAvatar.tsx: -------------------------------------------------------------------------------- 1 | import { Avatar, AvatarFallback, AvatarImage } from "@ui/components/avatar"; 2 | import { forwardRef, useMemo } from "react"; 3 | 4 | export const UserAvatar = forwardRef< 5 | HTMLSpanElement, 6 | { 7 | name: string; 8 | avatarUrl?: string | null; 9 | className?: string; 10 | } 11 | >(({ name, avatarUrl, className }, ref) => { 12 | const initials = useMemo( 13 | () => 14 | name 15 | .split(" ") 16 | .map((n) => n[0]) 17 | .join(""), 18 | [name], 19 | ); 20 | 21 | const avatarSrc = useMemo(() => avatarUrl ?? undefined, [avatarUrl]); 22 | 23 | return ( 24 | 25 | 26 | 27 | {initials} 28 | 29 | 30 | ); 31 | }); 32 | -------------------------------------------------------------------------------- /packages/api/modules/auth/abilities.ts: -------------------------------------------------------------------------------- 1 | import { SessionUser } from "auth"; 2 | import { TeamMemberRoleSchema, TeamMembership, UserRoleSchema } from "database"; 3 | 4 | export function defineAbilitiesFor({ 5 | user, 6 | teamMemberships, 7 | }: { 8 | user: SessionUser | null; 9 | teamMemberships: TeamMembership[] | null; 10 | }) { 11 | const isAdmin = user?.role === UserRoleSchema.Values.ADMIN; 12 | 13 | const getTeamRole = (teamId: string) => 14 | teamMemberships?.find((m) => m.teamId === teamId)?.role ?? null; 15 | 16 | const isTeamOwner = (teamId: string) => 17 | isAdmin || getTeamRole(teamId) === TeamMemberRoleSchema.Values.OWNER; 18 | 19 | const isTeamMember = (teamId: string) => 20 | isTeamOwner(teamId) || 21 | getTeamRole(teamId) === TeamMemberRoleSchema.Values.MEMBER; 22 | 23 | return { 24 | isAdmin, 25 | isTeamMember, 26 | isTeamOwner, 27 | }; 28 | } 29 | -------------------------------------------------------------------------------- /apps/web/i18n.ts: -------------------------------------------------------------------------------- 1 | import { appConfig } from "@config"; 2 | import deepmerge from "deepmerge"; 3 | import { getRequestConfig } from "next-intl/server"; 4 | 5 | export const importLocale = async (locale: string) => { 6 | return (await import(`./locales/${locale}.json`)).default; 7 | }; 8 | 9 | export const importDefaultLocale = async () => { 10 | return (await import(`./locales/${appConfig.i18n.defaultLocale}.json`)) 11 | .default; 12 | }; 13 | 14 | export const getMessagesForLocale = async (locale: string) => { 15 | const localeMessages = await importLocale(locale); 16 | if (locale === appConfig.i18n.defaultLocale) return localeMessages; 17 | const defaultLocaleMessages = await importDefaultLocale(); 18 | return deepmerge(defaultLocaleMessages, localeMessages); 19 | }; 20 | 21 | export default getRequestConfig(async ({ locale }) => ({ 22 | messages: await getMessagesForLocale(locale), 23 | })); 24 | -------------------------------------------------------------------------------- /.env: -------------------------------------------------------------------------------- 1 | # Database 2 | DATABASE_URL="postgresql://postgres.qzswrwibiozgbcbdyyfp:wangmeng.666@aws-0-ap-southeast-1.pooler.supabase.com:5432/postgres" 3 | 4 | # Site url 5 | NEXT_PUBLIC_SITE_URL="http://localhost:3000" 6 | 7 | # Payments 8 | 9 | # Mailing 10 | 11 | # Analytics 12 | 13 | # Authentication 14 | GITHUB_CLIENT_ID="Iv1.de323995618d51e8" 15 | GITHUB_CLIENT_SECRET="d8a57a75fbc31f19970f00874e801d14d74c8631" 16 | GOOGLE_CLIENT_ID="791406970175-1gr1u69hi76brqllu0tk631djsasc2e4.apps.googleusercontent.com" 17 | GOOGLE_CLIENT_SECRET="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" 18 | # add more providers here... 19 | 20 | # Storage 21 | NEXT_PUBLIC_SUPABASE_URL="https://qzswrwibiozgbcbdyyfp.supabase.co" 22 | SUPABASE_SERVICE_ROLE_KEY="eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6InF6c3dyd2liaW96Z2JjYmR5eWZwIiwicm9sZSI6ImFub24iLCJpYXQiOjE3MDk2MjUzNzMsImV4cCI6MjAyNTIwMTM3M30.ApL9nNmQz88ccCAa9dzWxCInwuX8f6mWgn3woD4Rp8Q" -------------------------------------------------------------------------------- /apps/web/modules/shared/components/TeamAvatar.tsx: -------------------------------------------------------------------------------- 1 | import { appConfig } from "@config"; 2 | import { Avatar, AvatarFallback, AvatarImage } from "@ui/components/avatar"; 3 | import BoringAvatar from "boring-avatars"; 4 | import { forwardRef, useMemo } from "react"; 5 | 6 | export const TeamAvatar = forwardRef< 7 | HTMLSpanElement, 8 | { 9 | name: string; 10 | avatarUrl?: string | null; 11 | className?: string; 12 | } 13 | >(({ name, avatarUrl, className }, ref) => { 14 | const avatarSrc = useMemo(() => avatarUrl ?? undefined, [avatarUrl]); 15 | 16 | return ( 17 | 18 | 19 | 20 | 26 | 27 | 28 | ); 29 | }); 30 | -------------------------------------------------------------------------------- /packages/api/modules/team/procedures/by-id.ts: -------------------------------------------------------------------------------- 1 | import { TRPCError } from "@trpc/server"; 2 | import { TeamSchema, db } from "database"; 3 | import { z } from "zod"; 4 | import { protectedProcedure } from "../../../trpc/base"; 5 | 6 | export const byId = protectedProcedure 7 | .input( 8 | z.object({ 9 | id: z.string(), 10 | }), 11 | ) 12 | .output(TeamSchema) 13 | .query(async ({ input: { id }, ctx: { abilities } }) => { 14 | const team = await db.team.findFirst({ 15 | where: { 16 | id, 17 | }, 18 | }); 19 | 20 | if (!team) { 21 | throw new TRPCError({ 22 | code: "NOT_FOUND", 23 | message: "Team not found.", 24 | }); 25 | } 26 | 27 | if (!abilities.isTeamMember(team.id)) { 28 | throw new TRPCError({ 29 | code: "UNAUTHORIZED", 30 | message: "No permission to read this team.", 31 | }); 32 | } 33 | 34 | return team; 35 | }); 36 | -------------------------------------------------------------------------------- /apps/web/modules/marketing/pricing/components/PricingTable.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { PricingTable as PricingTablePrimitive } from "@shared/components/PricingTable"; 4 | import { ApiOutput } from "api/trpc/router"; 5 | import { useTranslations } from "next-intl"; 6 | import { useRouter } from "next/navigation"; 7 | 8 | export function PricingTable({ 9 | plans, 10 | }: { 11 | plans: ApiOutput["billing"]["plans"]; 12 | }) { 13 | const t = useTranslations(); 14 | const router = useRouter(); 15 | 16 | return ( 17 | { 20 | router.push("/app/settings/team/billing"); 21 | }} 22 | labels={{ 23 | year: t("pricing.year"), 24 | month: t("pricing.month"), 25 | yearly: t("pricing.yearly"), 26 | monthly: t("pricing.monthly"), 27 | subscribe: t("pricing.subscribe"), 28 | }} 29 | /> 30 | ); 31 | } 32 | -------------------------------------------------------------------------------- /apps/web/modules/ui/components/input.tsx: -------------------------------------------------------------------------------- 1 | import { cn } from "@ui/lib"; 2 | import React from "react"; 3 | 4 | export interface InputProps 5 | extends React.InputHTMLAttributes {} 6 | 7 | const Input = React.forwardRef( 8 | ({ className, type, ...props }, ref) => { 9 | return ( 10 | 19 | ); 20 | }, 21 | ); 22 | Input.displayName = "Input"; 23 | 24 | export { Input }; 25 | -------------------------------------------------------------------------------- /apps/web/config.ts: -------------------------------------------------------------------------------- 1 | export const appConfig = { 2 | i18n: { 3 | locales: ["en", "de", "es"] as const, 4 | defaultLocale: "en" as const, 5 | localeLabels: { 6 | en: "English", 7 | es: "Español", 8 | de: "Deutsch", 9 | fr: "asdf", 10 | }, 11 | localeCurrencies: { 12 | /* This only works with Stripe for now. For LemonSqueezy, we need to set the currency in the LemonSqueezy dashboard and there can only be one. */ 13 | en: "USD", 14 | de: "USD", 15 | es: "USD", 16 | }, 17 | }, 18 | auth: { 19 | oAuthProviders: ["google", "github"], 20 | }, 21 | marketing: { 22 | menu: [ 23 | { 24 | translationKey: "pricing", 25 | href: "/pricing", 26 | }, 27 | { 28 | translationKey: "blog", 29 | href: "/Blog", 30 | }, 31 | ], 32 | }, 33 | teams: { 34 | avatarColors: ["#7976d2", "#9dbee5", "#8e7db7", "#d29776"], 35 | }, 36 | }; 37 | -------------------------------------------------------------------------------- /packages/api/modules/team/procedures/update.ts: -------------------------------------------------------------------------------- 1 | import { TRPCError } from "@trpc/server"; 2 | import { TeamSchema, db } from "database"; 3 | import { z } from "zod"; 4 | import { protectedProcedure } from "../../../trpc/base"; 5 | 6 | export const update = protectedProcedure 7 | .input( 8 | z.object({ 9 | id: z.string(), 10 | name: z.string().optional(), 11 | avatarUrl: z.string().optional(), 12 | }), 13 | ) 14 | .output(TeamSchema) 15 | .mutation(async ({ input: { id, name, avatarUrl }, ctx: { abilities } }) => { 16 | if (!abilities.isTeamOwner(id)) { 17 | throw new TRPCError({ 18 | code: "UNAUTHORIZED", 19 | message: "No permission to update this team.", 20 | }); 21 | } 22 | 23 | const team = await db.team.update({ 24 | where: { 25 | id, 26 | }, 27 | data: { 28 | name, 29 | avatarUrl, 30 | }, 31 | }); 32 | 33 | return team; 34 | }); 35 | -------------------------------------------------------------------------------- /packages/api/modules/team/procedures/delete.ts: -------------------------------------------------------------------------------- 1 | import { TRPCError } from "@trpc/server"; 2 | import { db } from "database"; 3 | import { z } from "zod"; 4 | import { protectedProcedure } from "../../../trpc/base"; 5 | 6 | export const deleteTeam = protectedProcedure 7 | .input( 8 | z.object({ 9 | id: z.string(), 10 | }), 11 | ) 12 | .mutation( 13 | async ({ input: { id }, ctx: { responseHeaders, user, abilities } }) => { 14 | try { 15 | if (!abilities.isTeamOwner(id)) { 16 | throw new TRPCError({ 17 | code: "FORBIDDEN", 18 | }); 19 | } 20 | 21 | await db.team.delete({ 22 | where: { 23 | id, 24 | }, 25 | }); 26 | } catch (e) { 27 | console.error(e); 28 | throw new TRPCError({ 29 | code: "INTERNAL_SERVER_ERROR", 30 | message: "An unknown error occurred.", 31 | }); 32 | } 33 | }, 34 | ); 35 | -------------------------------------------------------------------------------- /apps/web/content/posts/second-post.mdx: -------------------------------------------------------------------------------- 1 | --- 2 | title: Awesome second post 3 | date: 2023-03-01 4 | image: /images/blog/cover.png 5 | authorName: Tony Stark 6 | authorImage: /images/blog/author.jpg 7 | excerpt: This is my first post. I'm so excited! 8 | tags: [first, post] 9 | published: true 10 | --- 11 | 12 | ## Hello world 13 | 14 | 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: 15 | 16 | ### What's next? 17 | 18 | I'm going to write a lot more posts. I'm going to write about my favorite things, like: 19 | 20 | - Cats 21 | - Dogs 22 | - Pizza 23 | 24 | You can even add some nice links here: 25 | 26 | - [My favorite cat](https://www.youtube.com/watch?v=5dsGWM5XGdg) 27 | - [My favorite dog](https://www.youtube.com/watch?v=5dsGWM5XGdg) 28 | - [My favorite pizza](https://www.youtube.com/watch?v=5dsGWM5XGdg) 29 | - [Homepage](/) 30 | -------------------------------------------------------------------------------- /packages/api/modules/admin/procedures/delete-user.ts: -------------------------------------------------------------------------------- 1 | import { TRPCError } from "@trpc/server"; 2 | import { db } from "database"; 3 | import { z } from "zod"; 4 | import { adminProcedure } from "../../../trpc/base"; 5 | 6 | export const deleteUser = adminProcedure 7 | .input( 8 | z.object({ 9 | id: z.string(), 10 | }), 11 | ) 12 | .mutation(async ({ input: { id }, ctx: { responseHeaders, user } }) => { 13 | try { 14 | await db.user.delete({ 15 | where: { 16 | id: user.id, 17 | }, 18 | }); 19 | 20 | await db.team.deleteMany({ 21 | where: { 22 | memberships: { 23 | every: { 24 | userId: user.id, 25 | }, 26 | }, 27 | }, 28 | }); 29 | } catch (e) { 30 | console.error(e); 31 | throw new TRPCError({ 32 | code: "INTERNAL_SERVER_ERROR", 33 | message: "An unknown error occurred.", 34 | }); 35 | } 36 | }); 37 | -------------------------------------------------------------------------------- /apps/web/modules/saas/auth/components/SigninModeSwitch.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { Tabs, TabsList, TabsTrigger } from "@ui/components/tabs"; 4 | 5 | export default function SigninModeSwitch({ 6 | activeMode, 7 | onChange, 8 | className, 9 | }: { 10 | activeMode: "password" | "magic-link"; 11 | onChange: (mode: string) => void; 12 | className?: string; 13 | }) { 14 | const modes = [ 15 | { 16 | value: "magic-link", 17 | label: "Magic Link", 18 | }, 19 | { 20 | value: "password", 21 | label: "Password", 22 | }, 23 | ]; 24 | 25 | return ( 26 | 27 | 28 | 29 | Magic Link 30 | 31 | 32 | Password 33 | 34 | 35 | 36 | ); 37 | } 38 | -------------------------------------------------------------------------------- /packages/mail/emails/EmailChange.tsx: -------------------------------------------------------------------------------- 1 | import { Link, Text } from "@react-email/components"; 2 | import PrimaryButton from "./components/PrimaryButton"; 3 | import Wrapper from "./components/Wrapper"; 4 | 5 | export function EmailChange({ 6 | url, 7 | name, 8 | }: { 9 | url: string; 10 | name: string; 11 | }): JSX.Element { 12 | return ( 13 | 14 | 15 | Hey {name}, you changed your email. Please click the link below 16 | to confirm your new email address. 17 | 18 | 19 | Confirm email → 20 | 21 | 22 | If you want to open the link in a different browser than your default 23 | one, copy and paste this link: 24 | {url} 25 | 26 | 27 | ); 28 | } 29 | 30 | EmailChange.subjects = { 31 | en: "Email changed", 32 | de: "Email geändert", 33 | }; 34 | 35 | export default EmailChange; 36 | -------------------------------------------------------------------------------- /apps/web/app/[locale]/(saas)/team/invitation/route.tsx: -------------------------------------------------------------------------------- 1 | import { createApiCaller } from "api/trpc/caller"; 2 | import { redirect } from "next/navigation"; 3 | export const dynamic = "force-dynamic"; 4 | export const revalidate = 0; 5 | 6 | export async function GET(request: Request) { 7 | const url = new URL(request.url); 8 | const code = url.searchParams.get("code"); 9 | 10 | if (!code) redirect("/"); 11 | 12 | const apiCaller = await createApiCaller(); 13 | 14 | const invitation = await apiCaller.team.invitationById({ 15 | id: code, 16 | }); 17 | 18 | if (!invitation) redirect("/"); 19 | 20 | const user = await apiCaller.auth.user(); 21 | 22 | if (!user) 23 | return redirect( 24 | `/auth/login?invitationCode=${invitation.id}&email=${encodeURIComponent( 25 | invitation.email, 26 | )}`, 27 | ); 28 | 29 | const team = await apiCaller.team.acceptInvitation({ 30 | id: code, 31 | }); 32 | 33 | if (!team) redirect("/"); 34 | 35 | return redirect(`/app/dashboard`); 36 | } 37 | -------------------------------------------------------------------------------- /apps/web/modules/shared/components/ApiClientProvider.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; 4 | import { httpBatchLink } from "@trpc/client"; 5 | import { PropsWithChildren, useState } from "react"; 6 | import superjson from "superjson"; 7 | import { apiClient } from "../lib/api-client"; 8 | 9 | export function ApiClientProvider({ children }: PropsWithChildren<{}>) { 10 | const baseUrl = typeof window !== "undefined" ? window.location.origin : ""; 11 | 12 | const [queryClient] = useState(() => new QueryClient()); 13 | const [trpcClient] = useState(() => 14 | apiClient.createClient({ 15 | links: [ 16 | httpBatchLink({ 17 | url: baseUrl + "/api", 18 | transformer: superjson, 19 | }), 20 | ], 21 | }), 22 | ); 23 | return ( 24 | 25 | {children} 26 | 27 | ); 28 | } 29 | -------------------------------------------------------------------------------- /packages/api/modules/ai/procedures/generate-product-names.ts: -------------------------------------------------------------------------------- 1 | import OpenAI from "openai"; 2 | import { z } from "zod"; 3 | import { protectedProcedure } from "../../../trpc/base"; 4 | 5 | export const generateProductNames = protectedProcedure 6 | .input( 7 | z.object({ 8 | topic: z.string(), 9 | }), 10 | ) 11 | .output(z.array(z.string())) 12 | .query(async ({ input: { topic } }) => { 13 | const openai = new OpenAI({ 14 | // eslint-disable-next-line turbo/no-undeclared-env-vars 15 | apiKey: process.env.OPENAI_API_KEY as string, 16 | }); 17 | 18 | const response = await openai.chat.completions.create({ 19 | model: "gpt-3.5-turbo", 20 | messages: [ 21 | { 22 | role: "user", 23 | content: `List me five funny product names that could be used for ${topic}`, 24 | }, 25 | ], 26 | }); 27 | 28 | const ideas = (response.choices[0].message.content ?? "") 29 | .split("\n") 30 | .filter((name) => name.length > 0); 31 | 32 | return ideas; 33 | }); 34 | -------------------------------------------------------------------------------- /apps/web/app/[locale]/(marketing)/pricing/page.tsx: -------------------------------------------------------------------------------- 1 | import { PricingTable } from "@marketing/pricing/components/PricingTable"; 2 | import { createApiCaller } from "api/trpc/caller"; 3 | import { getTranslations } from "next-intl/server"; 4 | 5 | export default async function PricingPage() { 6 | const apiCaller = await createApiCaller(); 7 | const plans = await apiCaller.billing.plans(); 8 | const t = await getTranslations(); 9 | 10 | return ( 11 | 12 | 13 | 14 | 15 | 16 | {t("pricing.title")} 17 | 18 | 19 | {t("pricing.description")} 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | ); 30 | } 31 | -------------------------------------------------------------------------------- /apps/web/contentlayer.config.ts: -------------------------------------------------------------------------------- 1 | import { defineDocumentType, makeSource } from "contentlayer/source-files"; 2 | 3 | export const Post = defineDocumentType(() => ({ 4 | name: "Post", 5 | filePathPattern: `**/*.mdx`, 6 | contentType: "mdx", 7 | fields: { 8 | title: { type: "string", required: true }, 9 | date: { type: "date", required: true }, 10 | image: { type: "string" }, 11 | authorName: { type: "string", required: true }, 12 | authorImage: { type: "string" }, 13 | authorLink: { type: "string" }, 14 | excerpt: { type: "string" }, 15 | tags: { type: "list", of: { type: "string" } }, 16 | published: { type: "boolean" }, 17 | }, 18 | computedFields: { 19 | url: { 20 | type: "string", 21 | resolve: (post) => `/blog/${post._raw.flattenedPath}`, 22 | }, 23 | slug: { type: "string", resolve: (post) => post._raw.flattenedPath }, 24 | }, 25 | })); 26 | 27 | export default makeSource({ 28 | contentDirPath: "content/posts", 29 | documentTypes: [Post], 30 | disableImportAliasWarning: true, 31 | }); 32 | -------------------------------------------------------------------------------- /apps/web/modules/saas/shared/components/TabGroup.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { Link } from "@i18n"; 4 | import { useSelectedLayoutSegment } from "next/navigation"; 5 | import { useMemo } from "react"; 6 | 7 | export function TabGroup({ 8 | items, 9 | className, 10 | }: { 11 | items: { label: string; href: string; segment: string }[]; 12 | className?: string; 13 | }) { 14 | const selectedSegment = useSelectedLayoutSegment(); 15 | const activeItem = useMemo(() => { 16 | return items.find((item) => item.segment === selectedSegment); 17 | }, [items, selectedSegment]); 18 | 19 | return ( 20 | 21 | {items.map((item) => ( 22 | 31 | {item.label} 32 | 33 | ))} 34 | 35 | ); 36 | } 37 | -------------------------------------------------------------------------------- /packages/api/modules/auth/procedures/delete-account.ts: -------------------------------------------------------------------------------- 1 | import { TRPCError } from "@trpc/server"; 2 | import { lucia } from "auth"; 3 | import { db } from "database"; 4 | import { z } from "zod"; 5 | import { protectedProcedure } from "../../../trpc/base"; 6 | 7 | export const deleteAccount = protectedProcedure 8 | .input(z.void()) 9 | .mutation(async ({ ctx: { responseHeaders, user } }) => { 10 | try { 11 | await db.user.delete({ 12 | where: { 13 | id: user.id, 14 | }, 15 | }); 16 | 17 | await db.team.deleteMany({ 18 | where: { 19 | memberships: { 20 | every: { 21 | userId: user.id, 22 | }, 23 | }, 24 | }, 25 | }); 26 | 27 | const sessionCookie = lucia.createBlankSessionCookie(); 28 | responseHeaders?.append("Set-Cookie", sessionCookie.serialize()); 29 | } catch (e) { 30 | console.error(e); 31 | throw new TRPCError({ 32 | code: "INTERNAL_SERVER_ERROR", 33 | message: "An unknown error occurred.", 34 | }); 35 | } 36 | }); 37 | -------------------------------------------------------------------------------- /apps/web/modules/ui/components/badge.tsx: -------------------------------------------------------------------------------- 1 | import { VariantProps, cva } from "class-variance-authority"; 2 | import React from "react"; 3 | 4 | export const badge = cva( 5 | [ 6 | "inline-block", 7 | "rounded-full", 8 | "px-3", 9 | "py-1", 10 | "text-xs", 11 | "uppercase", 12 | "font-semibold", 13 | "leading-tight", 14 | ], 15 | { 16 | variants: { 17 | status: { 18 | success: ["bg-emerald-500/10", "text-emerald-500"], 19 | info: ["bg-primary/10", "text-primary"], 20 | warning: ["bg-amber-500/10", "text-amber-500"], 21 | error: ["bg-rose-500/10", "text-rose-500"], 22 | }, 23 | }, 24 | defaultVariants: { 25 | status: "info", 26 | }, 27 | }, 28 | ); 29 | 30 | export type BadgeProps = React.HtmlHTMLAttributes & 31 | VariantProps; 32 | 33 | export const Badge = ({ 34 | children, 35 | className, 36 | status, 37 | ...props 38 | }: BadgeProps) => ( 39 | 40 | {children} 41 | 42 | ); 43 | 44 | Badge.displayName = "Badge"; 45 | -------------------------------------------------------------------------------- /apps/web/modules/ui/components/password-input.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import React from "react"; 4 | import { Icon } from "./icon"; 5 | import { Input } from "./input"; 6 | 7 | export function PasswordInput({ 8 | value, 9 | onChange, 10 | className, 11 | }: { 12 | value: string; 13 | onChange: (value: string) => void; 14 | className?: string; 15 | }) { 16 | const [showPassword, setShowPassword] = React.useState(false); 17 | 18 | return ( 19 | 20 | onChange(e.target.value)} 25 | /> 26 | setShowPassword(!showPassword)} 29 | className="text-primary absolute inset-y-0 right-0 flex items-center pr-4 text-xl" 30 | > 31 | {showPassword ? ( 32 | 33 | ) : ( 34 | 35 | )} 36 | 37 | 38 | ); 39 | } 40 | -------------------------------------------------------------------------------- /apps/web/modules/saas/admin/component/EmailVerified.tsx: -------------------------------------------------------------------------------- 1 | import { Icon } from "@ui/components/icon"; 2 | import { 3 | Tooltip, 4 | TooltipContent, 5 | TooltipProvider, 6 | TooltipTrigger, 7 | } from "@ui/components/tooltip"; 8 | import { cn } from "@ui/lib"; 9 | import { useTranslations } from "next-intl"; 10 | 11 | export function EmailVerified({ 12 | verified, 13 | className, 14 | }: { 15 | verified: boolean; 16 | className?: string; 17 | }) { 18 | const t = useTranslations(); 19 | return ( 20 | 21 | 22 | 23 | {verified 24 | ? t("admin.users.emailVerified.verified") 25 | : t("admin.users.emailVerified.waiting")} 26 | 27 | 28 | 29 | {verified ? ( 30 | 31 | ) : ( 32 | 33 | )} 34 | 35 | 36 | 37 | 38 | ); 39 | } 40 | -------------------------------------------------------------------------------- /packages/api/trpc/router.ts: -------------------------------------------------------------------------------- 1 | import type {} from "@prisma/client"; 2 | import { inferRouterInputs, inferRouterOutputs } from "@trpc/server"; 3 | import * as adminProcedures from "../modules/admin/procedures"; 4 | import * as aiProcedures from "../modules/ai/procedures"; 5 | import * as authProcedures from "../modules/auth/procedures"; 6 | import * as billingProcedures from "../modules/billing/procedures"; 7 | import * as newsletterProcedures from "../modules/newsletter/procedures"; 8 | import * as teamProcedures from "../modules/team/procedures"; 9 | import * as uploadsProcedures from "../modules/uploads/procedures"; 10 | import { router } from "./base"; 11 | 12 | export const apiRouter = router({ 13 | auth: router(authProcedures), 14 | billing: router(billingProcedures), 15 | team: router(teamProcedures), 16 | newsletter: router(newsletterProcedures), 17 | ai: router(aiProcedures), 18 | uploads: router(uploadsProcedures), 19 | admin: router(adminProcedures), 20 | }); 21 | 22 | export type ApiRouter = typeof apiRouter; 23 | export type ApiInput = inferRouterInputs; 24 | export type ApiOutput = inferRouterOutputs; 25 | -------------------------------------------------------------------------------- /apps/web/modules/analytics/provider/google/index.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import Script from "next/script"; 4 | 5 | const googleAnalyticsId = process.env.NEXT_PUBLIC_GOOGLE_ANALYTICS_ID as string; 6 | 7 | export function AnalyticsScript() { 8 | return ( 9 |
{subtitle}
Page not found
14 | Enter a topic and we will generate some funny product names for you: 15 |
19 | {t("pricing.description")} 20 |