├── tooling ├── tailwind-config │ ├── .eslintignore │ ├── postcss.js │ ├── tsconfig.json │ └── package.json ├── typescript-config │ ├── package.json │ └── base.json ├── eslint-config │ ├── tsconfig.json │ ├── nextjs.js │ ├── react.js │ ├── package.json │ └── base.js └── prettier-config │ ├── tsconfig.json │ ├── package.json │ └── index.mjs ├── packages ├── common │ ├── .eslintignore │ ├── src │ │ ├── email.ts │ │ ├── index.ts │ │ ├── config │ │ │ └── site.ts │ │ ├── env.mjs │ │ ├── emails │ │ │ └── magic-link-email.tsx │ │ └── subscriptions.ts │ ├── tsconfig.json │ └── package.json ├── stripe │ ├── .eslintignore │ ├── tsconfig.json │ ├── src │ │ ├── plans.ts │ │ └── env.mjs │ └── package.json ├── api │ ├── .eslintignore │ ├── src │ │ ├── root.ts │ │ ├── router │ │ │ ├── health_check.ts │ │ │ ├── auth.ts │ │ │ └── customer.ts │ │ ├── edge.ts │ │ ├── index.ts │ │ ├── transformer.ts │ │ ├── trpc.ts │ │ └── env.mjs │ ├── tsconfig.json │ └── package.json ├── auth │ ├── .eslintignore │ ├── .prettierignore │ ├── tsconfig.json │ ├── db.ts │ ├── package.json │ └── env.mjs ├── ui │ ├── src │ │ ├── index.ts │ │ ├── utils │ │ │ └── cn.ts │ │ ├── skeleton.tsx │ │ ├── card-skeleton.tsx │ │ ├── label.tsx │ │ ├── input.tsx │ │ ├── toaster.tsx │ │ ├── callout.tsx │ │ ├── animated-gradient-text.tsx │ │ ├── meteors.tsx │ │ ├── checkbox.tsx │ │ ├── popover.tsx │ │ ├── switch.tsx │ │ ├── marquee.tsx │ │ ├── text-generate-effect.tsx │ │ ├── avatar.tsx │ │ ├── scroll-area.tsx │ │ ├── button.tsx │ │ ├── animated-list.tsx │ │ ├── alert.tsx │ │ ├── animated-tooltip.tsx │ │ ├── tabs.tsx │ │ ├── text-reveal.tsx │ │ ├── accordion.tsx │ │ ├── card.tsx │ │ └── data-table.tsx │ ├── tsconfig.json │ ├── .eslintignore │ └── tailwind.config.ts └── db │ ├── prisma │ ├── README.md │ ├── enums.ts │ └── types.ts │ ├── tsconfig.json │ ├── index.ts │ └── package.json ├── apps ├── nextjs │ ├── .prettierignore │ ├── postcss.config.cjs │ ├── public │ │ ├── favicon.ico │ │ └── images │ │ │ ├── noise.webp │ │ │ ├── avatars │ │ │ └── nok8s.jpeg │ │ │ └── blog │ │ │ ├── blog-post-1.jpg │ │ │ ├── blog-post-2.jpg │ │ │ ├── blog-post-3.jpg │ │ │ └── blog-post-4.jpg │ ├── src │ │ ├── styles │ │ │ ├── calsans.ttf │ │ │ ├── fonts │ │ │ │ ├── Inter-Bold.ttf │ │ │ │ ├── Inter-Regular.ttf │ │ │ │ ├── CalSans-SemiBold.ttf │ │ │ │ ├── CalSans-SemiBold.woff │ │ │ │ └── CalSans-SemiBold.woff2 │ │ │ ├── mdx.css │ │ │ ├── theme │ │ │ │ └── default.css │ │ │ └── globals.css │ │ ├── content │ │ │ ├── authors │ │ │ │ └── nok8s.mdx │ │ │ └── docs │ │ │ │ ├── in-progress.mdx │ │ │ │ ├── index.mdx │ │ │ │ └── documentation │ │ │ │ └── index.mdx │ │ ├── config │ │ │ ├── ph-config.ts │ │ │ ├── site.ts │ │ │ ├── i18n-config.ts │ │ │ ├── ui │ │ │ │ ├── marketing.ts │ │ │ │ └── dashboard.ts │ │ │ └── providers.tsx │ │ ├── lib │ │ │ ├── validations │ │ │ │ └── user.ts │ │ │ ├── currency.ts │ │ │ ├── use-mounted.ts │ │ │ ├── utils.ts │ │ │ ├── use-debounce.tsx │ │ │ ├── zod-form.tsx │ │ │ ├── get-dictionary.ts │ │ │ └── toc.ts │ │ ├── types │ │ │ ├── meteors.d.ts │ │ │ ├── next-auth.d.ts │ │ │ ├── k8s.d.ts │ │ │ └── index.d.ts │ │ ├── components │ │ │ ├── theme-provider.tsx │ │ │ ├── base-item.tsx │ │ │ ├── word-reveal.tsx │ │ │ ├── modal-provider.tsx │ │ │ ├── textGenerateEffect.tsx │ │ │ ├── card-skeleton.tsx │ │ │ ├── header.tsx │ │ │ ├── docs │ │ │ │ ├── page-header.tsx │ │ │ │ ├── search.tsx │ │ │ │ ├── pager.tsx │ │ │ │ └── sidebar-nav.tsx │ │ │ ├── tailwind-indicator.tsx │ │ │ ├── user-avatar.tsx │ │ │ ├── typewriterEffectSmooth.tsx │ │ │ ├── document-guide.tsx │ │ │ ├── card-hover-effect.tsx │ │ │ ├── shell.tsx │ │ │ ├── content │ │ │ │ └── mdx-card.tsx │ │ │ ├── meteors-card.tsx │ │ │ ├── site-footer.tsx │ │ │ ├── price │ │ │ │ ├── pricing-faq.tsx │ │ │ │ └── billing-form-button.tsx │ │ │ ├── locale-change.tsx │ │ │ ├── mode-toggle.tsx │ │ │ ├── infiniteMovingCards.tsx │ │ │ ├── k8s │ │ │ │ ├── cluster-item.tsx │ │ │ │ └── cluster-create-button.tsx │ │ │ ├── modal.tsx │ │ │ ├── nav.tsx │ │ │ ├── mobile-nav.tsx │ │ │ ├── theme-toggle.tsx │ │ │ ├── sparkles.tsx │ │ │ ├── empty-placeholder.tsx │ │ │ ├── shimmer-button.tsx │ │ │ ├── sign-in-modal.tsx │ │ │ └── user-account-nav.tsx │ │ ├── app │ │ │ ├── admin │ │ │ │ ├── layout.tsx │ │ │ │ └── (dashboard) │ │ │ │ │ └── dashboard │ │ │ │ │ └── loading.tsx │ │ │ ├── [lang] │ │ │ │ ├── (auth) │ │ │ │ │ ├── layout.tsx │ │ │ │ │ ├── login │ │ │ │ │ │ └── page.tsx │ │ │ │ │ └── register │ │ │ │ │ │ └── page.tsx │ │ │ │ ├── (dashboard) │ │ │ │ │ └── dashboard │ │ │ │ │ │ ├── billing │ │ │ │ │ │ ├── subscription-form.tsx │ │ │ │ │ │ ├── loading.tsx │ │ │ │ │ │ └── page.tsx │ │ │ │ │ │ ├── settings │ │ │ │ │ │ ├── loading.tsx │ │ │ │ │ │ └── page.tsx │ │ │ │ │ │ └── loading.tsx │ │ │ │ ├── (marketing) │ │ │ │ │ ├── blog │ │ │ │ │ │ └── page.tsx │ │ │ │ │ ├── pricing │ │ │ │ │ │ ├── loading.tsx │ │ │ │ │ │ └── page.tsx │ │ │ │ │ └── layout.tsx │ │ │ │ ├── (docs) │ │ │ │ │ ├── docs │ │ │ │ │ │ └── layout.tsx │ │ │ │ │ └── layout.tsx │ │ │ │ └── (editor) │ │ │ │ │ └── editor │ │ │ │ │ ├── cluster │ │ │ │ │ └── [clusterId] │ │ │ │ │ │ └── page.tsx │ │ │ │ │ └── layout.tsx │ │ │ ├── robots.ts │ │ │ └── api │ │ │ │ ├── auth │ │ │ │ └── [...nextauth] │ │ │ │ │ └── route.ts │ │ │ │ ├── trpc │ │ │ │ └── edge │ │ │ │ │ └── [trpc] │ │ │ │ │ └── route.ts │ │ │ │ └── webhooks │ │ │ │ └── stripe │ │ │ │ └── route.ts │ │ ├── utils │ │ │ └── api.ts │ │ ├── hooks │ │ │ ├── use-mounted.ts │ │ │ ├── use-signin-modal.ts │ │ │ ├── use-lock-body.ts │ │ │ ├── use-scroll.ts │ │ │ └── use-media-query.ts │ │ ├── trpc │ │ │ ├── client.ts │ │ │ ├── server.ts │ │ │ └── shared.ts │ │ └── env.mjs │ ├── tailwind.config.ts │ ├── .eslintignore │ ├── tsconfig.json │ └── next.config.mjs ├── .gitignore └── auth-proxy │ ├── tsconfig.json │ ├── .env.example │ ├── routes │ └── [...auth].ts │ └── package.json ├── vercel.json ├── twillot.png ├── .github ├── FUNDING.yml └── workflows │ └── ci.yml ├── tailwind.config.js ├── turbo └── generators │ ├── templates │ ├── tsconfig.json.hbs │ └── package.json.hbs │ └── config.ts ├── .gitignore ├── turbo.json ├── LICENSE ├── package.json ├── SECURITY.md ├── .env.example └── CONTRIBUTING.md /tooling/tailwind-config/.eslintignore: -------------------------------------------------------------------------------- 1 | index.ts -------------------------------------------------------------------------------- /packages/common/.eslintignore: -------------------------------------------------------------------------------- 1 | src/subscriptions.ts -------------------------------------------------------------------------------- /apps/nextjs/.prettierignore: -------------------------------------------------------------------------------- 1 | .next/ 2 | .contentlayer/ 3 | -------------------------------------------------------------------------------- /packages/stripe/.eslintignore: -------------------------------------------------------------------------------- 1 | src/index.ts 2 | src/plans.ts -------------------------------------------------------------------------------- /packages/api/.eslintignore: -------------------------------------------------------------------------------- 1 | src/transformer.ts 2 | src/trpc.ts -------------------------------------------------------------------------------- /packages/auth/.eslintignore: -------------------------------------------------------------------------------- 1 | auth-rest-adapter.ts 2 | index.ts -------------------------------------------------------------------------------- /packages/ui/src/index.ts: -------------------------------------------------------------------------------- 1 | export { cn } from "./utils/cn"; 2 | -------------------------------------------------------------------------------- /apps/.gitignore: -------------------------------------------------------------------------------- 1 | nextjs/.contentlayer 2 | nextjs/tailwind.config.js -------------------------------------------------------------------------------- /vercel.json: -------------------------------------------------------------------------------- 1 | { 2 | "github": { 3 | "silent": true 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /twillot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/K-Jadeja/saasfly/main/twillot.png -------------------------------------------------------------------------------- /packages/auth/.prettierignore: -------------------------------------------------------------------------------- 1 | #auth-rest-adapter.ts 2 | .next/ 3 | .contentlayer -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | open_collective: saasfly 3 | -------------------------------------------------------------------------------- /apps/nextjs/postcss.config.cjs: -------------------------------------------------------------------------------- 1 | module.exports = require("@saasfly/tailwind-config/postcss"); 2 | -------------------------------------------------------------------------------- /apps/nextjs/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/K-Jadeja/saasfly/main/apps/nextjs/public/favicon.ico -------------------------------------------------------------------------------- /apps/nextjs/src/styles/calsans.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/K-Jadeja/saasfly/main/apps/nextjs/src/styles/calsans.ttf -------------------------------------------------------------------------------- /apps/auth-proxy/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@saasfly/typescript-config/base.json", 3 | "include": ["routes"] 4 | } 5 | -------------------------------------------------------------------------------- /apps/nextjs/public/images/noise.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/K-Jadeja/saasfly/main/apps/nextjs/public/images/noise.webp -------------------------------------------------------------------------------- /apps/nextjs/src/content/authors/nok8s.mdx: -------------------------------------------------------------------------------- 1 | --- 2 | title: repo 3 | twitter: repo 4 | avatar: /images/avatars/repo.jpeg 5 | --- 6 | -------------------------------------------------------------------------------- /packages/db/prisma/README.md: -------------------------------------------------------------------------------- 1 | #NOTE 2 | 3 | ##FAQ 4 | if you found can't generate prisma types, please use bun i -g prisma-kysely 5 | -------------------------------------------------------------------------------- /apps/nextjs/src/styles/fonts/Inter-Bold.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/K-Jadeja/saasfly/main/apps/nextjs/src/styles/fonts/Inter-Bold.ttf -------------------------------------------------------------------------------- /apps/nextjs/public/images/avatars/nok8s.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/K-Jadeja/saasfly/main/apps/nextjs/public/images/avatars/nok8s.jpeg -------------------------------------------------------------------------------- /tooling/tailwind-config/postcss.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | }; 7 | -------------------------------------------------------------------------------- /apps/nextjs/public/images/blog/blog-post-1.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/K-Jadeja/saasfly/main/apps/nextjs/public/images/blog/blog-post-1.jpg -------------------------------------------------------------------------------- /apps/nextjs/public/images/blog/blog-post-2.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/K-Jadeja/saasfly/main/apps/nextjs/public/images/blog/blog-post-2.jpg -------------------------------------------------------------------------------- /apps/nextjs/public/images/blog/blog-post-3.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/K-Jadeja/saasfly/main/apps/nextjs/public/images/blog/blog-post-3.jpg -------------------------------------------------------------------------------- /apps/nextjs/public/images/blog/blog-post-4.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/K-Jadeja/saasfly/main/apps/nextjs/public/images/blog/blog-post-4.jpg -------------------------------------------------------------------------------- /apps/nextjs/src/styles/fonts/Inter-Regular.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/K-Jadeja/saasfly/main/apps/nextjs/src/styles/fonts/Inter-Regular.ttf -------------------------------------------------------------------------------- /apps/nextjs/src/styles/fonts/CalSans-SemiBold.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/K-Jadeja/saasfly/main/apps/nextjs/src/styles/fonts/CalSans-SemiBold.ttf -------------------------------------------------------------------------------- /apps/nextjs/src/styles/fonts/CalSans-SemiBold.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/K-Jadeja/saasfly/main/apps/nextjs/src/styles/fonts/CalSans-SemiBold.woff -------------------------------------------------------------------------------- /apps/nextjs/src/styles/fonts/CalSans-SemiBold.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/K-Jadeja/saasfly/main/apps/nextjs/src/styles/fonts/CalSans-SemiBold.woff2 -------------------------------------------------------------------------------- /apps/auth-proxy/.env.example: -------------------------------------------------------------------------------- 1 | 2 | AUTH_SECRET="" 3 | GITHUB_CLIENT_ID="" 4 | GITHUB_CLIENT_SECRET="" 5 | AUTH_REDIRECT_PROXY_URL="" 6 | 7 | NITRO_PRESET="vercel_edge" -------------------------------------------------------------------------------- /apps/nextjs/src/config/ph-config.ts: -------------------------------------------------------------------------------- 1 | import { PostHog } from "posthog-node"; 2 | 3 | export const phConfig = new PostHog("", { host: "https://app.posthog.com" }); 4 | -------------------------------------------------------------------------------- /apps/nextjs/src/lib/validations/user.ts: -------------------------------------------------------------------------------- 1 | import * as z from "zod"; 2 | 3 | export const userNameSchema = z.object({ 4 | name: z.string().min(3).max(32), 5 | }); 6 | -------------------------------------------------------------------------------- /apps/nextjs/src/types/meteors.d.ts: -------------------------------------------------------------------------------- 1 | export interface Meteor { 2 | name: string; 3 | description: string; 4 | button_content: string; 5 | url: string; 6 | } 7 | -------------------------------------------------------------------------------- /apps/nextjs/src/lib/currency.ts: -------------------------------------------------------------------------------- 1 | export const currencySymbol = (curr: string) => 2 | ({ 3 | USD: "$", 4 | EUR: "€", 5 | GBP: "£", 6 | })[curr] ?? curr; 7 | -------------------------------------------------------------------------------- /packages/common/src/email.ts: -------------------------------------------------------------------------------- 1 | import { Resend } from "resend"; 2 | 3 | import { env } from "./env.mjs"; 4 | 5 | export const resend = new Resend(env.RESEND_API_KEY); 6 | -------------------------------------------------------------------------------- /tailwind.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('tailwindcss').Config} */ 2 | module.exports = { 3 | content: [], 4 | theme: { 5 | extend: {}, 6 | }, 7 | plugins: [], 8 | } 9 | -------------------------------------------------------------------------------- /packages/common/src/index.ts: -------------------------------------------------------------------------------- 1 | export { resend } from "./email"; 2 | 3 | export { MagicLinkEmail } from "./emails/magic-link-email"; 4 | 5 | export { siteConfig } from "./config/site"; 6 | -------------------------------------------------------------------------------- /tooling/typescript-config/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@saasfly/typescript-config", 3 | "version": "0.0.1", 4 | "private": true, 5 | "files": [ 6 | "base.json" 7 | ] 8 | } 9 | -------------------------------------------------------------------------------- /apps/nextjs/src/content/docs/in-progress.mdx: -------------------------------------------------------------------------------- 1 | --- 2 | title: Not Implemented 3 | description: This page is in progress. 4 | --- 5 | 6 | This site is a work in progress. 7 | -------------------------------------------------------------------------------- /apps/nextjs/src/components/theme-provider.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { ThemeProvider as NextThemeProvider } from "next-themes"; 4 | 5 | export const ThemeProvider = NextThemeProvider; 6 | -------------------------------------------------------------------------------- /packages/ui/src/utils/cn.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 | -------------------------------------------------------------------------------- /apps/nextjs/src/app/admin/layout.tsx: -------------------------------------------------------------------------------- 1 | interface AuthLayoutProps { 2 | children: React.ReactNode; 3 | } 4 | 5 | export default function AuthLayout({ children }: AuthLayoutProps) { 6 | return
{children}
; 7 | } 8 | -------------------------------------------------------------------------------- /packages/api/src/root.ts: -------------------------------------------------------------------------------- 1 | import { edgeRouter } from "./edge"; 2 | import { mergeRouters } from "./trpc"; 3 | 4 | export const appRouter = mergeRouters(edgeRouter); 5 | // export type definition of API 6 | export type AppRouter = typeof appRouter; 7 | -------------------------------------------------------------------------------- /packages/auth/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@saasfly/typescript-config/base.json", 3 | "compilerOptions": { 4 | "tsBuildInfoFile": "node_modules/.cache/tsbuildinfo.json" 5 | }, 6 | "include": ["."], 7 | "exclude": ["node_modules"] 8 | } 9 | -------------------------------------------------------------------------------- /apps/nextjs/src/app/[lang]/(auth)/layout.tsx: -------------------------------------------------------------------------------- 1 | interface AuthLayoutProps { 2 | children: React.ReactNode; 3 | } 4 | 5 | export default function AuthLayout({ children }: AuthLayoutProps) { 6 | return
{children}
; 7 | } 8 | -------------------------------------------------------------------------------- /apps/nextjs/src/app/robots.ts: -------------------------------------------------------------------------------- 1 | import type { MetadataRoute } from "next"; 2 | 3 | export default function robots(): MetadataRoute.Robots { 4 | return { 5 | rules: { 6 | userAgent: "*", 7 | allow: "/", 8 | }, 9 | }; 10 | } 11 | -------------------------------------------------------------------------------- /packages/common/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@saasfly/typescript-config/base.json", 3 | "compilerOptions": { 4 | "tsBuildInfoFile": "node_modules/.cache/tsbuildinfo.json" 5 | }, 6 | "include": ["*.ts", "src"], 7 | "exclude": ["node_modules"] 8 | } 9 | -------------------------------------------------------------------------------- /packages/stripe/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@saasfly/typescript-config/base.json", 3 | "compilerOptions": { 4 | "tsBuildInfoFile": "node_modules/.cache/tsbuildinfo.json" 5 | }, 6 | "include": ["*.ts", "src"], 7 | "exclude": ["node_modules"] 8 | } 9 | -------------------------------------------------------------------------------- /packages/ui/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@saasfly/typescript-config/base.json", 3 | "compilerOptions": { 4 | "tsBuildInfoFile": "node_modules/.cache/tsbuildinfo.json" 5 | }, 6 | "include": ["*.ts", "src"], 7 | "exclude": ["node_modules"] 8 | } 9 | -------------------------------------------------------------------------------- /tooling/eslint-config/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@saasfly/typescript-config/base.json", 3 | "compilerOptions": { 4 | "tsBuildInfoFile": "node_modules/.cache/tsbuildinfo.json" 5 | }, 6 | "include": ["."], 7 | "exclude": ["node_modules"] 8 | } 9 | -------------------------------------------------------------------------------- /tooling/prettier-config/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@saasfly/typescript-config/base.json", 3 | "compilerOptions": { 4 | "tsBuildInfoFile": "node_modules/.cache/tsbuildinfo.json" 5 | }, 6 | "include": ["."], 7 | "exclude": ["node_modules"] 8 | } 9 | -------------------------------------------------------------------------------- /apps/nextjs/src/utils/api.ts: -------------------------------------------------------------------------------- 1 | import { createTRPCReact } from "@trpc/react-query"; 2 | 3 | import type { AppRouter } from "@saasfly/api"; 4 | 5 | export const api = createTRPCReact(); 6 | 7 | export { type RouterInputs, type RouterOutputs } from "@saasfly/api"; 8 | -------------------------------------------------------------------------------- /packages/db/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@saasfly/typescript-config/base.json", 3 | "compilerOptions": { 4 | "tsBuildInfoFile": "node_modules/.cache/tsbuildinfo.json" 5 | }, 6 | "include": ["*.ts", "prisma", "src"], 7 | "exclude": ["node_modules"] 8 | } 9 | -------------------------------------------------------------------------------- /tooling/tailwind-config/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@saasfly/typescript-config/base.json", 3 | "compilerOptions": { 4 | "tsBuildInfoFile": "node_modules/.cache/tsbuildinfo.json" 5 | }, 6 | "include": ["*.ts", "src"], 7 | "exclude": ["node_modules"] 8 | } 9 | -------------------------------------------------------------------------------- /tooling/eslint-config/nextjs.js: -------------------------------------------------------------------------------- 1 | /** @type {import('eslint').Linter.Config} */ 2 | const config = { 3 | extends: ["plugin:@next/next/recommended"], 4 | rules: { 5 | "@next/next/no-html-link-for-pages": "off", 6 | }, 7 | }; 8 | 9 | module.exports = config; 10 | -------------------------------------------------------------------------------- /apps/nextjs/src/hooks/use-mounted.ts: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | 3 | export function useMounted() { 4 | const [mounted, setMounted] = React.useState(false); 5 | 6 | React.useEffect(() => { 7 | setMounted(true); 8 | }, []); 9 | 10 | return mounted; 11 | } 12 | -------------------------------------------------------------------------------- /apps/nextjs/src/lib/use-mounted.ts: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | 3 | export function useMounted() { 4 | const [mounted, setMounted] = React.useState(false); 5 | 6 | React.useEffect(() => { 7 | setMounted(true); 8 | }, []); 9 | 10 | return mounted; 11 | } 12 | -------------------------------------------------------------------------------- /apps/nextjs/tailwind.config.ts: -------------------------------------------------------------------------------- 1 | import type { Config } from "tailwindcss"; 2 | 3 | import baseConfig from "@saasfly/tailwind-config"; 4 | 5 | export default { 6 | content: [...baseConfig.content, "../../packages/ui/src/**/*.{ts,tsx}"], 7 | presets: [baseConfig], 8 | } satisfies Config; 9 | -------------------------------------------------------------------------------- /packages/ui/.eslintignore: -------------------------------------------------------------------------------- 1 | src/button.tsx 2 | src/alert.tsx 3 | src/data-table.tsx 4 | src/form.tsx 5 | src/label.tsx 6 | src/sheet.tsx 7 | src/toast.tsx 8 | src/utils/ 9 | src/table.tsx 10 | src/sparkles.tsx 11 | src/globe.tsx 12 | src/following-pointer.tsx 13 | src/marquee.tsx 14 | src/text-reveal.tsx -------------------------------------------------------------------------------- /packages/api/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@saasfly/typescript-config/base.json", 3 | "compilerOptions": { 4 | "tsBuildInfoFile": "node_modules/.cache/tsbuildinfo.json" 5 | }, 6 | "include": [ 7 | "*.ts", 8 | "src" 9 | ], 10 | "exclude": [ 11 | "node_modules" 12 | ] 13 | } 14 | -------------------------------------------------------------------------------- /apps/nextjs/src/app/api/auth/[...nextauth]/route.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-unsafe-assignment */ 2 | 3 | import NextAuth from "next-auth"; 4 | 5 | import { authOptions } from "@saasfly/auth"; 6 | 7 | const handler = NextAuth(authOptions); 8 | 9 | export { handler as GET, handler as POST }; 10 | -------------------------------------------------------------------------------- /packages/common/src/config/site.ts: -------------------------------------------------------------------------------- 1 | export const siteConfig = { 2 | name: "Saasfly", 3 | description: "We provide an easier way to build saas service in production", 4 | url: "https://github.com/saaslfy/saasfly", 5 | ogImage: "", 6 | links: { 7 | github: "https://github.com/saaslfy", 8 | }, 9 | }; 10 | -------------------------------------------------------------------------------- /apps/nextjs/src/config/site.ts: -------------------------------------------------------------------------------- 1 | export const siteConfig = { 2 | name: "Saasfly", 3 | description: "We provide an easier way to build saas service in production", 4 | url: "https://github.com/saasfly/saasfly", 5 | ogImage: "", 6 | links: { 7 | github: "https://github.com/saasfly/saasfly", 8 | }, 9 | }; 10 | -------------------------------------------------------------------------------- /packages/db/index.ts: -------------------------------------------------------------------------------- 1 | import { createKysely } from "@vercel/postgres-kysely"; 2 | 3 | import type { DB } from "./prisma/types"; 4 | 5 | export { jsonArrayFrom, jsonObjectFrom } from "kysely/helpers/postgres"; 6 | 7 | export * from "./prisma/types"; 8 | export * from "./prisma/enums"; 9 | 10 | export const db = createKysely(); 11 | -------------------------------------------------------------------------------- /apps/nextjs/src/types/next-auth.d.ts: -------------------------------------------------------------------------------- 1 | import type { User } from "next-auth"; 2 | 3 | type UserId = string; 4 | 5 | declare module "next-auth/jwt" { 6 | interface JWT { 7 | id: UserId; 8 | } 9 | } 10 | 11 | declare module "next-auth" { 12 | interface Session { 13 | user: User & { 14 | id: UserId; 15 | }; 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /turbo/generators/templates/tsconfig.json.hbs: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@saasfly/typescript-config/base.json", 3 | "compilerOptions": { 4 | "tsBuildInfoFile": "node_modules/.cache/tsbuildinfo.json" 5 | }, 6 | "include": [ 7 | "*.ts", 8 | "src" 9 | ], 10 | "exclude": [ 11 | "node_modules" 12 | ] 13 | } -------------------------------------------------------------------------------- /apps/nextjs/src/config/i18n-config.ts: -------------------------------------------------------------------------------- 1 | export const i18n = { 2 | defaultLocale: "zh", 3 | locales: ["en", "zh", "ko", "ja"], 4 | } as const; 5 | 6 | export type Locale = (typeof i18n)["locales"][number]; 7 | 8 | // 新增的映射对象 9 | export const localeMap = { 10 | en: "English", 11 | zh: "中文", 12 | ko: "한국어", 13 | ja: "日本語", 14 | } as const; 15 | -------------------------------------------------------------------------------- /packages/ui/src/skeleton.tsx: -------------------------------------------------------------------------------- 1 | import { cn } from "./utils/cn"; 2 | 3 | function Skeleton({ 4 | className, 5 | ...props 6 | }: React.HTMLAttributes) { 7 | return ( 8 |
12 | ); 13 | } 14 | 15 | export { Skeleton }; 16 | -------------------------------------------------------------------------------- /apps/nextjs/.eslintignore: -------------------------------------------------------------------------------- 1 | .next/ 2 | .contentlayer/ 3 | src/components/billing-form.tsx 4 | src/components/content/mdx-components.tsx 5 | src/components/docs/pager.tsx 6 | src/components/k8s/cluster-operation.tsx 7 | src/components/price/pricing-cards.tsx 8 | src/components/user-account-nav.tsx 9 | src/lib/toc.ts 10 | src/middleware.ts 11 | contentlayer.config.ts -------------------------------------------------------------------------------- /apps/nextjs/src/components/base-item.tsx: -------------------------------------------------------------------------------- 1 | import { Skeleton } from "@saasfly/ui/skeleton"; 2 | 3 | export function BasicItemSkeleton() { 4 | return ( 5 |
6 |
7 | 8 | 9 |
10 |
11 | ); 12 | } 13 | -------------------------------------------------------------------------------- /apps/nextjs/src/components/word-reveal.tsx: -------------------------------------------------------------------------------- 1 | import TextReveal from "@saasfly/ui/text-reveal"; 2 | 3 | export function WordReveal() { 4 | return ( 5 |
6 | 7 |
8 | ); 9 | } 10 | -------------------------------------------------------------------------------- /apps/nextjs/src/hooks/use-signin-modal.ts: -------------------------------------------------------------------------------- 1 | import { create } from "zustand"; 2 | 3 | interface useSigninModalStore { 4 | isOpen: boolean; 5 | onOpen: () => void; 6 | onClose: () => void; 7 | } 8 | 9 | export const useSigninModal = create((set) => ({ 10 | isOpen: false, 11 | onOpen: () => set({ isOpen: true }), 12 | onClose: () => set({ isOpen: false }), 13 | })); 14 | -------------------------------------------------------------------------------- /packages/ui/tailwind.config.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * This file is not used for any compilation purpose, it is only used 3 | * for Tailwind Intellisense & Autocompletion in the source files 4 | */ 5 | import type { Config } from "tailwindcss"; 6 | 7 | import baseConfig from "@saasfly/tailwind-config"; 8 | 9 | export default { 10 | content: baseConfig.content, 11 | presets: [baseConfig], 12 | } satisfies Config; 13 | -------------------------------------------------------------------------------- /apps/nextjs/src/lib/utils.ts: -------------------------------------------------------------------------------- 1 | import { env } from "~/env.mjs"; 2 | 3 | export function formatDate(input: string | number): string { 4 | const date = new Date(input); 5 | return date.toLocaleDateString("en-US", { 6 | month: "long", 7 | day: "numeric", 8 | year: "numeric", 9 | }); 10 | } 11 | 12 | export function absoluteUrl(path: string) { 13 | return `${env.NEXT_PUBLIC_APP_URL}${path}`; 14 | } 15 | -------------------------------------------------------------------------------- /apps/nextjs/src/hooks/use-lock-body.ts: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | 3 | // @see https://usehooks.com/useLockBodyScroll. 4 | export function useLockBody() { 5 | React.useLayoutEffect((): (() => void) => { 6 | const originalStyle: string = window.getComputedStyle( 7 | document.body, 8 | ).overflow; 9 | document.body.style.overflow = "hidden"; 10 | return () => (document.body.style.overflow = originalStyle); 11 | }, []); 12 | } 13 | -------------------------------------------------------------------------------- /apps/nextjs/src/components/modal-provider.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { SignInModal } from "~/components/sign-in-modal"; 4 | import { useMounted } from "~/hooks/use-mounted"; 5 | 6 | export const ModalProvider = ({ dict }: { dict: Record }) => { 7 | const mounted = useMounted(); 8 | 9 | if (!mounted) { 10 | return null; 11 | } 12 | 13 | return ( 14 | <> 15 | 16 | 17 | ); 18 | }; 19 | -------------------------------------------------------------------------------- /apps/nextjs/src/components/textGenerateEffect.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { TextGenerateEffect } from "@saasfly/ui/text-generate-effect"; 4 | 5 | const words = `Your complete All-in-One solution for building SaaS services. From coding to product launch, we have 6 | everything you need covered!`; 7 | 8 | const TextGenerateEffects = () => { 9 | return ; 10 | }; 11 | 12 | export default TextGenerateEffects; 13 | -------------------------------------------------------------------------------- /packages/api/src/router/health_check.ts: -------------------------------------------------------------------------------- 1 | import { z } from "zod"; 2 | 3 | import { createTRPCRouter, protectedProcedure } from "../trpc"; 4 | 5 | export const helloRouter = createTRPCRouter({ 6 | hello: protectedProcedure 7 | .input( 8 | z.object({ 9 | text: z.string(), 10 | }), 11 | ) 12 | .query((opts: { input: { text: string } }) => { 13 | return { 14 | greeting: `hello ${opts.input.text}`, 15 | }; 16 | }), 17 | }); 18 | -------------------------------------------------------------------------------- /apps/nextjs/src/lib/use-debounce.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from "react"; 2 | 3 | export function useDebounce(value: T, delay: number) { 4 | const [debouncedValue, setDebouncedValue] = useState(value); 5 | 6 | useEffect(() => { 7 | const timeoutId = setTimeout(() => { 8 | setDebouncedValue(value); 9 | }, delay); 10 | 11 | return () => { 12 | clearTimeout(timeoutId); 13 | }; 14 | }, [value, delay]); 15 | 16 | return debouncedValue; 17 | } 18 | -------------------------------------------------------------------------------- /packages/db/prisma/enums.ts: -------------------------------------------------------------------------------- 1 | export const SubscriptionPlan = { 2 | FREE: "FREE", 3 | PRO: "PRO", 4 | BUSINESS: "BUSINESS", 5 | } as const; 6 | export type SubscriptionPlan = 7 | (typeof SubscriptionPlan)[keyof typeof SubscriptionPlan]; 8 | export const Status = { 9 | PENDING: "PENDING", 10 | CREATING: "CREATING", 11 | INITING: "INITING", 12 | RUNNING: "RUNNING", 13 | STOPPED: "STOPPED", 14 | DELETED: "DELETED", 15 | } as const; 16 | export type Status = (typeof Status)[keyof typeof Status]; 17 | -------------------------------------------------------------------------------- /packages/api/src/edge.ts: -------------------------------------------------------------------------------- 1 | import { authRouter } from "./router/auth"; 2 | import { customerRouter } from "./router/customer"; 3 | import { helloRouter } from "./router/health_check"; 4 | import { k8sRouter } from "./router/k8s"; 5 | import { stripeRouter } from "./router/stripe"; 6 | import { createTRPCRouter } from "./trpc"; 7 | 8 | export const edgeRouter = createTRPCRouter({ 9 | stripe: stripeRouter, 10 | hello: helloRouter, 11 | k8s: k8sRouter, 12 | auth: authRouter, 13 | customer: customerRouter, 14 | }); 15 | -------------------------------------------------------------------------------- /apps/nextjs/src/lib/zod-form.tsx: -------------------------------------------------------------------------------- 1 | import { zodResolver } from "@hookform/resolvers/zod"; 2 | import { useForm, type UseFormProps } from "react-hook-form"; 3 | import type { ZodType } from "zod"; 4 | 5 | export function useZodForm( 6 | props: Omit, "resolver"> & { 7 | schema: TSchema; 8 | }, 9 | ) { 10 | const form = useForm({ 11 | ...props, 12 | resolver: zodResolver(props.schema, undefined), 13 | }); 14 | 15 | return form; 16 | } 17 | -------------------------------------------------------------------------------- /tooling/eslint-config/react.js: -------------------------------------------------------------------------------- 1 | /** @type {import('eslint').Linter.Config} */ 2 | const config = { 3 | extends: [ 4 | "plugin:react/recommended", 5 | "plugin:react-hooks/recommended", 6 | "plugin:jsx-a11y/recommended", 7 | ], 8 | rules: { 9 | "react/prop-types": "off", 10 | }, 11 | globals: { 12 | React: "writable", 13 | }, 14 | settings: { 15 | react: { 16 | version: "detect", 17 | }, 18 | }, 19 | env: { 20 | browser: true, 21 | }, 22 | }; 23 | 24 | module.exports = config; 25 | -------------------------------------------------------------------------------- /apps/nextjs/src/hooks/use-scroll.ts: -------------------------------------------------------------------------------- 1 | import { useCallback, useEffect, useState } from "react"; 2 | 3 | export default function useScroll(threshold: number) { 4 | const [scrolled, setScrolled] = useState(false); 5 | 6 | const onScroll = useCallback(() => { 7 | setScrolled(window.pageYOffset > threshold); 8 | }, [threshold]); 9 | 10 | useEffect(() => { 11 | window.addEventListener("scroll", onScroll); 12 | return () => window.removeEventListener("scroll", onScroll); 13 | }, [onScroll]); 14 | 15 | return scrolled; 16 | } 17 | -------------------------------------------------------------------------------- /packages/ui/src/card-skeleton.tsx: -------------------------------------------------------------------------------- 1 | import { Card, CardContent, CardFooter, CardHeader } from "@saasfly/ui/card"; 2 | import { Skeleton } from "@saasfly/ui/skeleton"; 3 | 4 | export function CardSkeleton() { 5 | return ( 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | ); 17 | } 18 | -------------------------------------------------------------------------------- /apps/nextjs/src/components/card-skeleton.tsx: -------------------------------------------------------------------------------- 1 | import { Card, CardContent, CardFooter, CardHeader } from "@saasfly/ui/card"; 2 | import { Skeleton } from "@saasfly/ui/skeleton"; 3 | 4 | export function CardSkeleton() { 5 | return ( 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | ); 17 | } 18 | -------------------------------------------------------------------------------- /apps/nextjs/src/app/[lang]/(dashboard)/dashboard/billing/subscription-form.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import Link from "next/link"; 4 | 5 | import { cn } from "@saasfly/ui"; 6 | import { buttonVariants } from "@saasfly/ui/button"; 7 | 8 | export function SubscriptionForm(props: { 9 | hasSubscription: boolean; 10 | dict: Record; 11 | }) { 12 | return ( 13 | 14 | {props.hasSubscription 15 | ? props.dict.manage_subscription 16 | : props.dict.upgrade} 17 | 18 | ); 19 | } 20 | -------------------------------------------------------------------------------- /apps/nextjs/src/app/[lang]/(dashboard)/dashboard/settings/loading.tsx: -------------------------------------------------------------------------------- 1 | import { CardSkeleton } from "~/components/card-skeleton"; 2 | import { DashboardHeader } from "~/components/header"; 3 | import { DashboardShell } from "~/components/shell"; 4 | 5 | export default function DashboardSettingsLoading() { 6 | return ( 7 | 8 | 12 |
13 | 14 |
15 |
16 | ); 17 | } 18 | -------------------------------------------------------------------------------- /apps/auth-proxy/routes/[...auth].ts: -------------------------------------------------------------------------------- 1 | import { Auth } from "@auth/core"; 2 | import GitHub from "@auth/core/providers/github"; 3 | import { eventHandler, toWebRequest } from "h3"; 4 | 5 | export default eventHandler(async (event) => 6 | Auth(toWebRequest(event), { 7 | secret: process.env.AUTH_SECRET, 8 | trustHost: !!process.env.VERCEL, 9 | redirectProxyUrl: process.env.AUTH_REDIRECT_PROXY_URL, 10 | providers: [ 11 | GitHub({ 12 | clientId: process.env.GITHUB_CLIENT_ID, 13 | clientSecret: process.env.GITHUB_CLIENT_SECRET, 14 | }), 15 | ], 16 | }), 17 | ); 18 | -------------------------------------------------------------------------------- /apps/nextjs/src/app/[lang]/(marketing)/blog/page.tsx: -------------------------------------------------------------------------------- 1 | import { compareDesc } from "date-fns"; 2 | 3 | import { BlogPosts } from "~/components/blog/blog-posts"; 4 | import { allPosts } from ".contentlayer/generated"; 5 | 6 | export const metadata = { 7 | title: "Blog", 8 | }; 9 | 10 | export default function BlogPage() { 11 | const posts = allPosts 12 | .filter((post) => post.published) 13 | .sort((a, b) => { 14 | return compareDesc(new Date(a.date), new Date(b.date)); 15 | }); 16 | 17 | return ( 18 |
19 | 20 |
21 | ); 22 | } 23 | -------------------------------------------------------------------------------- /apps/nextjs/src/components/header.tsx: -------------------------------------------------------------------------------- 1 | interface DashboardHeaderProps { 2 | heading: string; 3 | text?: string; 4 | children?: React.ReactNode; 5 | } 6 | 7 | export function DashboardHeader({ 8 | heading, 9 | text, 10 | children, 11 | }: DashboardHeaderProps) { 12 | return ( 13 |
14 |
15 |

{heading}

16 | {text &&

{text}

} 17 |
18 | {children} 19 |
20 | ); 21 | } 22 | -------------------------------------------------------------------------------- /apps/nextjs/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@saasfly/typescript-config/base.json", 3 | "compilerOptions": { 4 | "baseUrl": ".", 5 | "paths": { 6 | "~/*": ["./src/*"], 7 | "contentlayer/generated": ["./.contentlayer/generated"] 8 | }, 9 | "plugins": [ 10 | { 11 | "name": "next" 12 | } 13 | ], 14 | "tsBuildInfoFile": "node_modules/.cache/tsbuildinfo.json" 15 | }, 16 | "include": [ 17 | "next-env.d.ts", 18 | ".next/types/**/*.ts", 19 | "*.ts", 20 | "*.tsx", 21 | "*.mjs", 22 | "src", 23 | "contentlayer.config.ts" 24 | ], 25 | "exclude": ["node_modules"] 26 | } 27 | -------------------------------------------------------------------------------- /apps/nextjs/src/types/k8s.d.ts: -------------------------------------------------------------------------------- 1 | interface ClusterStatus { 2 | PENDING: "PENDING"; 3 | CREATING: "CREATING"; 4 | INITING: "INITING"; 5 | RUNNING: "RUNNING"; 6 | STOPPED: "STOPPED"; 7 | DELETED: "DELETED"; 8 | } 9 | 10 | type ClusterPlan = "FREE" | "BUSINESS" | "PRO"; 11 | 12 | export interface Cluster { 13 | id: number; 14 | name: string; 15 | status: keyof ClusterStatus | null; 16 | location: string; 17 | authUserId: string; 18 | plan: ClusterPlan | null; 19 | network: string | null; 20 | createdAt: Date; 21 | updatedAt: Date; 22 | delete: boolean | null; 23 | } 24 | 25 | export type ClustersArray = Cluster[] | undefined; 26 | -------------------------------------------------------------------------------- /tooling/prettier-config/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@saasfly/prettier-config", 3 | "private": true, 4 | "version": "0.1.0", 5 | "main": "index.mjs", 6 | "scripts": { 7 | "clean": "rm -rf .turbo node_modules", 8 | "format": "prettier --check '**/*.{mjs,json}' ", 9 | "typecheck": "tsc --noEmit" 10 | }, 11 | "dependencies": { 12 | "@ianvs/prettier-plugin-sort-imports": "4.2.1", 13 | "prettier": "3.2.5", 14 | "prettier-plugin-tailwindcss": "0.5.13" 15 | }, 16 | "devDependencies": { 17 | "@saasfly/typescript-config": "workspace:*", 18 | "typescript": "5.4.5" 19 | }, 20 | "prettier": "@saasfly/prettier-config" 21 | } 22 | -------------------------------------------------------------------------------- /apps/nextjs/src/app/admin/(dashboard)/dashboard/loading.tsx: -------------------------------------------------------------------------------- 1 | import { BasicItemSkeleton } from "~/components/base-item"; 2 | import { DashboardHeader } from "~/components/header"; 3 | import { DashboardShell } from "~/components/shell"; 4 | 5 | export default function DashboardLoading() { 6 | return ( 7 | 8 | 12 |
13 | 14 | 15 | 16 |
17 |
18 | ); 19 | } 20 | -------------------------------------------------------------------------------- /apps/nextjs/src/app/[lang]/(dashboard)/dashboard/loading.tsx: -------------------------------------------------------------------------------- 1 | import { BasicItemSkeleton } from "~/components/base-item"; 2 | import { DashboardHeader } from "~/components/header"; 3 | import { DashboardShell } from "~/components/shell"; 4 | 5 | export default function DashboardLoading() { 6 | return ( 7 | 8 | 12 |
13 | 14 | 15 | 16 |
17 |
18 | ); 19 | } 20 | -------------------------------------------------------------------------------- /packages/api/src/index.ts: -------------------------------------------------------------------------------- 1 | import type { inferRouterInputs, inferRouterOutputs } from "@trpc/server"; 2 | 3 | import type { AppRouter } from "./root"; 4 | 5 | export { createTRPCContext, createInnerTRPCContext } from "./trpc"; 6 | 7 | export { t } from "./trpc"; 8 | 9 | export type { AppRouter } from "./root"; 10 | 11 | /** 12 | * Inference helpers for input types 13 | * @example type HelloInput = RouterInputs['example']['hello'] 14 | **/ 15 | export type RouterInputs = inferRouterInputs; 16 | 17 | /** 18 | * Inference helpers for output types 19 | * @example type HelloOutput = RouterOutputs['example']['hello'] 20 | **/ 21 | export type RouterOutputs = inferRouterOutputs; 22 | -------------------------------------------------------------------------------- /apps/nextjs/src/components/docs/page-header.tsx: -------------------------------------------------------------------------------- 1 | import { cn } from "@saasfly/ui"; 2 | 3 | interface DocsPageHeaderProps extends React.HTMLAttributes { 4 | heading: string; 5 | text?: string; 6 | } 7 | 8 | export function DocsPageHeader({ 9 | heading, 10 | text, 11 | className, 12 | ...props 13 | }: DocsPageHeaderProps) { 14 | return ( 15 | <> 16 |
17 |

18 | {heading} 19 |

20 | {text &&

{text}

} 21 |
22 |
23 | 24 | ); 25 | } 26 | -------------------------------------------------------------------------------- /.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 | next-env.d.ts 15 | 16 | # expo 17 | .expo/ 18 | dist/ 19 | expo-env.d.ts 20 | apps/expo/.gitignore 21 | 22 | # production 23 | build 24 | 25 | # misc 26 | .DS_Store 27 | *.pem 28 | 29 | # debug 30 | npm-debug.log* 31 | yarn-debug.log* 32 | yarn-error.log* 33 | .pnpm-debug.log* 34 | 35 | # local env files 36 | .env.local 37 | 38 | # vercel 39 | .vercel 40 | 41 | # typescript 42 | *.tsbuildinfo 43 | 44 | # turbo 45 | .turbo 46 | 47 | yarn.lock 48 | package-lock.json 49 | bun.lockb 50 | 51 | .idea/ 52 | 53 | .env 54 | -------------------------------------------------------------------------------- /packages/api/src/router/auth.ts: -------------------------------------------------------------------------------- 1 | import { unstable_noStore as noStore } from "next/cache"; 2 | 3 | import { db } from "@saasfly/db"; 4 | 5 | import { createTRPCRouter, protectedProcedure } from "../trpc"; 6 | 7 | export const authRouter = createTRPCRouter({ 8 | mySubscription: protectedProcedure.query(async (opts) => { 9 | noStore(); 10 | const userId = opts.ctx.userId as string; 11 | const customer = await db 12 | .selectFrom("Customer") 13 | .select(["plan", "stripeCurrentPeriodEnd"]) 14 | .where("authUserId", "=", userId) 15 | .executeTakeFirst(); 16 | 17 | if (!customer) return null; 18 | return { 19 | plan: customer.plan, 20 | endsAt: customer.stripeCurrentPeriodEnd, 21 | }; 22 | }), 23 | }); 24 | -------------------------------------------------------------------------------- /tooling/typescript-config/base.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://json.schemastore.org/tsconfig", 3 | "compilerOptions": { 4 | "target": "es2022", 5 | "lib": ["dom", "dom.iterable", "ES2022"], 6 | "allowJs": true, 7 | "checkJs": true, 8 | "skipLibCheck": true, 9 | "strict": true, 10 | "forceConsistentCasingInFileNames": true, 11 | "noEmit": true, 12 | "esModuleInterop": true, 13 | "module": "ESNext", 14 | "moduleResolution": "Bundler", 15 | "resolveJsonModule": true, 16 | "moduleDetection": "force", 17 | "isolatedModules": true, 18 | "jsx": "preserve", 19 | "incremental": true, 20 | "noUncheckedIndexedAccess": true 21 | }, 22 | "exclude": ["node_modules", "build", "dist", ".next", ".expo"] 23 | } 24 | -------------------------------------------------------------------------------- /apps/nextjs/src/app/api/trpc/edge/[trpc]/route.ts: -------------------------------------------------------------------------------- 1 | import type { NextRequest } from "next/server"; 2 | import { fetchRequestHandler } from "@trpc/server/adapters/fetch"; 3 | 4 | import { createTRPCContext } from "@saasfly/api"; 5 | import { edgeRouter } from "@saasfly/api/edge"; 6 | 7 | // export const runtime = "edge"; 8 | 9 | const handler = (req: NextRequest) => 10 | fetchRequestHandler({ 11 | endpoint: "/api/trpc/edge", 12 | router: edgeRouter, 13 | req: req, 14 | createContext: () => createTRPCContext({ req }), 15 | // createContext: () => ({}), 16 | onError: ({ error, path }) => { 17 | console.log("Error in tRPC handler (edge) on path", path); 18 | console.error(error); 19 | }, 20 | }); 21 | 22 | export { handler as GET, handler as POST }; 23 | -------------------------------------------------------------------------------- /apps/nextjs/src/components/tailwind-indicator.tsx: -------------------------------------------------------------------------------- 1 | export function TailwindIndicator() { 2 | if (process.env.NODE_ENV === "production") return null; 3 | 4 | return ( 5 |
6 |
xs
7 |
8 | sm 9 |
10 |
md
11 |
lg
12 |
xl
13 |
2xl
14 |
15 | ); 16 | } 17 | -------------------------------------------------------------------------------- /apps/nextjs/src/components/user-avatar.tsx: -------------------------------------------------------------------------------- 1 | import type { AvatarProps } from "@radix-ui/react-avatar"; 2 | import type { User } from "next-auth"; 3 | 4 | import { Avatar, AvatarFallback, AvatarImage } from "@saasfly/ui/avatar"; 5 | import * as Icons from "@saasfly/ui/icons"; 6 | 7 | interface UserAvatarProps extends AvatarProps { 8 | user: Pick; 9 | } 10 | 11 | export function UserAvatar({ user, ...props }: UserAvatarProps) { 12 | return ( 13 | 14 | {user.image ? ( 15 | 16 | ) : ( 17 | 18 | {user.name} 19 | 20 | 21 | )} 22 | 23 | ); 24 | } 25 | -------------------------------------------------------------------------------- /packages/api/src/transformer.ts: -------------------------------------------------------------------------------- 1 | import { dinero, type Dinero, type DineroSnapshot } from "dinero.js"; 2 | import superjson from "superjson"; 3 | //@ts-ignore 4 | import { JSONValue } from "superjson/dist/types"; 5 | 6 | superjson.registerCustom( 7 | { 8 | isApplicable: (val): val is Dinero => { 9 | try { 10 | // if this doesn't crash we're kinda sure it's a Dinero instance 11 | (val as Dinero).calculator.add(1, 2); 12 | return true; 13 | } catch { 14 | return false; 15 | } 16 | }, 17 | serialize: (val) => { 18 | return val.toJSON() as JSONValue; 19 | }, 20 | deserialize: (val) => { 21 | return dinero(val as DineroSnapshot); 22 | }, 23 | }, 24 | "Dinero", 25 | ); 26 | 27 | export const transformer = superjson; 28 | -------------------------------------------------------------------------------- /apps/nextjs/src/app/[lang]/(docs)/docs/layout.tsx: -------------------------------------------------------------------------------- 1 | import { DocsSidebarNav } from "~/components/docs/sidebar-nav"; 2 | import type { Locale } from "~/config/i18n-config"; 3 | import { getDocsConfig } from "~/config/ui/docs"; 4 | 5 | export default function DocsLayout({ 6 | children, 7 | params: { lang }, 8 | }: { 9 | children: React.ReactNode; 10 | params: { 11 | lang: Locale; 12 | }; 13 | }) { 14 | return ( 15 |
16 | 19 | {children} 20 |
21 | ); 22 | } 23 | -------------------------------------------------------------------------------- /apps/nextjs/src/components/typewriterEffectSmooth.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { TextGenerateEffect } from "@saasfly/ui/typewriter-effect"; 4 | 5 | export function TypewriterEffectSmooths() { 6 | const words = [ 7 | { 8 | text: "Build", 9 | }, 10 | { 11 | text: "awesome", 12 | }, 13 | { 14 | text: "apps", 15 | }, 16 | { 17 | text: "and", 18 | }, 19 | { 20 | text: "ship", 21 | }, 22 | { 23 | text: "fast", 24 | }, 25 | { 26 | text: "with", 27 | }, 28 | { 29 | text: "Saasfly.", 30 | className: "text-blue-500", 31 | }, 32 | ]; 33 | return ( 34 |

35 | 36 |

37 | ); 38 | } 39 | -------------------------------------------------------------------------------- /packages/ui/src/label.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import * as React from "react"; 4 | import * as LabelPrimitive from "@radix-ui/react-label"; 5 | import { cva, type VariantProps } from "class-variance-authority"; 6 | 7 | import { cn } from "./utils/cn"; 8 | 9 | const labelVariants = cva( 10 | "text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70", 11 | ); 12 | 13 | const Label = React.forwardRef< 14 | React.ElementRef, 15 | React.ComponentPropsWithoutRef & 16 | VariantProps 17 | >(({ className, ...props }, ref) => ( 18 | 23 | )); 24 | Label.displayName = LabelPrimitive.Root.displayName; 25 | 26 | export { Label }; 27 | -------------------------------------------------------------------------------- /turbo.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://turborepo.org/schema.json", 3 | "globalDependencies": ["**/.env"], 4 | "pipeline": { 5 | "topo": { 6 | "dependsOn": ["^topo"] 7 | }, 8 | "build": { 9 | "dependsOn": ["^build"], 10 | "outputs": [".next/**", "!.next/cache/**", "next-env.d.ts"] 11 | }, 12 | "dev": { 13 | "persistent": true, 14 | "cache": false 15 | }, 16 | "format": { 17 | "outputs": ["node_modules/.cache/.prettiercache"], 18 | "outputMode": "new-only" 19 | }, 20 | "lint": { 21 | "dependsOn": ["^topo"], 22 | "outputs": ["node_modules/.cache/.eslintcache"] 23 | }, 24 | "typecheck": { 25 | "dependsOn": ["^topo"], 26 | "outputs": ["node_modules/.cache/tsbuildinfo.json"] 27 | }, 28 | "clean": { 29 | "cache": false 30 | } 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /apps/nextjs/src/components/document-guide.tsx: -------------------------------------------------------------------------------- 1 | import { ReactNode } from "react"; 2 | import { ChevronRight } from "lucide-react"; 3 | 4 | import { cn } from "@saasfly/ui"; 5 | import { AnimatedGradientText } from "@saasfly/ui/animated-gradient-text"; 6 | 7 | export function DocumentGuide({ children }: { children: ReactNode }) { 8 | return ( 9 | 10 | 🚀
{" "} 11 | 16 | {children} 17 | 18 | 19 |
20 | ); 21 | } 22 | -------------------------------------------------------------------------------- /packages/stripe/src/plans.ts: -------------------------------------------------------------------------------- 1 | import { SubscriptionPlan } from "@saasfly/db"; 2 | 3 | import { env } from "./env.mjs"; 4 | 5 | export const PLANS: Record< 6 | string, 7 | (typeof SubscriptionPlan)[keyof typeof SubscriptionPlan] 8 | > = { 9 | // @ts-ignore 10 | [env.NEXT_PUBLIC_STRIPE_PRO_MONTHLY_PRICE_ID]: SubscriptionPlan.PRO, 11 | // @ts-ignore 12 | [env.NEXT_PUBLIC_STRIPE_PRO_YEARLY_PRICE_ID]: SubscriptionPlan.PRO, 13 | // @ts-ignore 14 | [env.NEXT_PUBLIC_STRIPE_BUSINESS_MONTHLY_PRICE_ID]: SubscriptionPlan.BUSINESS, 15 | // @ts-ignore 16 | [env.NEXT_PUBLIC_STRIPE_BUSINESS_YEARLY_PRICE_ID]: SubscriptionPlan.BUSINESS, 17 | }; 18 | 19 | type PlanType = (typeof SubscriptionPlan)[keyof typeof SubscriptionPlan]; 20 | 21 | export function getSubscriptionPlan(priceId: string | undefined): PlanType { 22 | return priceId && PLANS[priceId] ? PLANS[priceId]! : SubscriptionPlan.FREE; 23 | } 24 | -------------------------------------------------------------------------------- /apps/nextjs/src/app/[lang]/(dashboard)/dashboard/billing/loading.tsx: -------------------------------------------------------------------------------- 1 | import { Card, CardContent, CardHeader, CardTitle } from "@saasfly/ui/card"; 2 | 3 | import { DashboardShell } from "~/components/shell"; 4 | 5 | export default function Loading() { 6 | return ( 7 | 12 | 13 | 14 | 15 | ); 16 | } 17 | 18 | function LoadingCard(props: { title: string }) { 19 | return ( 20 | 21 | 22 | {props.title} 23 | 24 | 25 |
26 | 27 | 28 | ); 29 | } 30 | -------------------------------------------------------------------------------- /apps/nextjs/src/config/ui/marketing.ts: -------------------------------------------------------------------------------- 1 | import type { Locale } from "~/config/i18n-config"; 2 | import { getDictionary } from "~/lib/get-dictionary"; 3 | import type { MarketingConfig } from "~/types"; 4 | 5 | export const getMarketingConfig = async ({ 6 | params: { lang }, 7 | }: { 8 | params: { 9 | lang: Locale; 10 | }; 11 | }): Promise => { 12 | const dict = await getDictionary(lang); 13 | return { 14 | mainNav: [ 15 | { 16 | title: dict.marketing.main_nav_features, 17 | href: `/#features`, 18 | }, 19 | { 20 | title: dict.marketing.main_nav_pricing, 21 | href: `/pricing`, 22 | }, 23 | { 24 | title: dict.marketing.main_nav_blog, 25 | href: `/blog`, 26 | }, 27 | { 28 | title: dict.marketing.main_nav_documentation, 29 | href: `/docs`, 30 | }, 31 | ], 32 | }; 33 | }; 34 | -------------------------------------------------------------------------------- /apps/nextjs/src/lib/get-dictionary.ts: -------------------------------------------------------------------------------- 1 | import "server-only"; 2 | 3 | import type { Locale } from "~/config/i18n-config"; 4 | 5 | // We enumerate all dictionaries here for better linting and typescript support 6 | // We also get the default import for cleaner types 7 | const dictionaries = { 8 | en: () => 9 | import("~/config/dictionaries/en.json").then((module) => module.default), 10 | zh: () => 11 | import("~/config/dictionaries/zh.json").then((module) => module.default), 12 | ko: () => 13 | import("~/config/dictionaries/ko.json").then((module) => module.default), 14 | ja: () => 15 | import("~/config/dictionaries/ja.json").then((module) => module.default), 16 | }; 17 | 18 | export const getDictionary = async (locale: Locale) => 19 | dictionaries[locale]?.() ?? dictionaries.en(); 20 | 21 | export const getDictionarySync = (locale: Locale) => 22 | dictionaries[locale]?.() ?? dictionaries.en(); 23 | -------------------------------------------------------------------------------- /packages/ui/src/input.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | 3 | import { cn } from "./utils/cn"; 4 | 5 | export type InputProps = 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 | -------------------------------------------------------------------------------- /packages/ui/src/toaster.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { 4 | Toast, 5 | ToastClose, 6 | ToastDescription, 7 | ToastProvider, 8 | ToastTitle, 9 | ToastViewport, 10 | } from "./toast"; 11 | import { useToast } from "./use-toast"; 12 | 13 | export function Toaster() { 14 | const { toasts } = useToast(); 15 | 16 | return ( 17 | 18 | {toasts.map(function ({ id, title, description, action, ...props }) { 19 | return ( 20 | 21 |
22 | {title && {title}} 23 | {description && ( 24 | {description} 25 | )} 26 |
27 | {action} 28 | 29 |
30 | ); 31 | })} 32 | 33 |
34 | ); 35 | } 36 | -------------------------------------------------------------------------------- /apps/nextjs/src/trpc/client.ts: -------------------------------------------------------------------------------- 1 | import { loggerLink } from "@trpc/client"; 2 | import { experimental_createTRPCNextAppDirClient } from "@trpc/next/app-dir/client"; 3 | 4 | import type { AppRouter } from "@saasfly/api"; 5 | 6 | import { endingLink, transformer } from "./shared"; 7 | 8 | export const trpc = experimental_createTRPCNextAppDirClient({ 9 | config() { 10 | return { 11 | transformer, 12 | links: [ 13 | // loggerLink({ 14 | // enabled: (opts) => 15 | // process.env.NODE_ENV === "development" || 16 | // (opts.direction === "down" && opts.result instanceof Error), 17 | // }), 18 | loggerLink({ 19 | enabled: () => true, 20 | }), 21 | endingLink({ 22 | headers: { 23 | "x-trpc-source": "client", 24 | }, 25 | }), 26 | ], 27 | }; 28 | }, 29 | }); 30 | 31 | export { type RouterInputs, type RouterOutputs } from "@saasfly/api"; 32 | -------------------------------------------------------------------------------- /tooling/tailwind-config/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@saasfly/tailwind-config", 3 | "private": true, 4 | "version": "0.1.0", 5 | "main": "index.ts", 6 | "files": [ 7 | "index.ts", 8 | "postcss.js" 9 | ], 10 | "scripts": { 11 | "clean": "rm -rf .turbo node_modules", 12 | "lint": "eslint ", 13 | "format": "prettier --check '**/*.{ts,json}' ", 14 | "typecheck": "tsc --noEmit" 15 | }, 16 | "dependencies": { 17 | "autoprefixer": "10.4.17", 18 | "postcss": "8.4.34", 19 | "tailwindcss": "3.4.1" 20 | }, 21 | "devDependencies": { 22 | "@saasfly/eslint-config": "workspace:*", 23 | "@saasfly/prettier-config": "workspace:*", 24 | "@saasfly/typescript-config": "workspace:*", 25 | "eslint": "8.57.0", 26 | "prettier": "3.2.5", 27 | "typescript": "5.4.5" 28 | }, 29 | "eslintConfig": { 30 | "root": true, 31 | "extends": [ 32 | "@saasfly/eslint-config/base" 33 | ] 34 | }, 35 | "prettier": "@saasfly/prettier-config" 36 | } 37 | -------------------------------------------------------------------------------- /apps/nextjs/src/components/card-hover-effect.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import React from "react"; 4 | 5 | import { HoverEffect } from "@saasfly/ui/card-hover-effect"; 6 | 7 | export const projects = [ 8 | { 9 | title: "Kubernetes", 10 | description: 11 | "Kubernetes is an open-source container-orchestration system for automating computer application deployment, scaling, and management.", 12 | link: "/", 13 | }, 14 | { 15 | title: "DevOps + FinOps", 16 | description: 17 | "DevOps is a set of practices that combines software development and IT operations. FinOps is the practice of bringing financial accountability to the variable spend model of cloud.", 18 | link: "/", 19 | }, 20 | { 21 | title: "AI First", 22 | description: 23 | "AI-first is a strategy that leverages artificial intelligence to improve products and services.", 24 | link: "/", 25 | }, 26 | ]; 27 | export function HoverEffects() { 28 | return ; 29 | } 30 | -------------------------------------------------------------------------------- /apps/auth-proxy/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@saasfly/auth-proxy", 3 | "private": true, 4 | "type": "module", 5 | "scripts": { 6 | "clean": "rm -rf .turbo node_modules", 7 | "lint": "eslint", 8 | "format": "prettier --write '**/*.{js,cjs,mjs,ts,tsx,md,json}' --ignore-path .prettierignore", 9 | "typecheck": "tsc --noEmit" 10 | }, 11 | "dependencies": { 12 | "@auth/core": "0.31.0" 13 | }, 14 | "devDependencies": { 15 | "@saasfly/eslint-config": "workspace:*", 16 | "@saasfly/prettier-config": "workspace:*", 17 | "@saasfly/tailwind-config": "workspace:*", 18 | "@saasfly/typescript-config": "workspace:*", 19 | "@types/node": "20.12.12", 20 | "eslint": "8.57.0", 21 | "h3": "1.11.1", 22 | "nitropack": "2.9.6", 23 | "prettier": "3.2.5", 24 | "typescript": "5.4.5" 25 | }, 26 | "eslintConfig": { 27 | "root": true, 28 | "extends": [ 29 | "@saasfly/eslint-config/base" 30 | ] 31 | }, 32 | "prettier": "@saasfly/prettier-config" 33 | } 34 | -------------------------------------------------------------------------------- /apps/nextjs/src/app/[lang]/(dashboard)/dashboard/settings/page.tsx: -------------------------------------------------------------------------------- 1 | import { redirect } from "next/navigation"; 2 | 3 | import { authOptions, getCurrentUser } from "@saasfly/auth"; 4 | 5 | import { DashboardHeader } from "~/components/header"; 6 | import { DashboardShell } from "~/components/shell"; 7 | import { UserNameForm } from "~/components/user-name-form"; 8 | 9 | export const metadata = { 10 | title: "Settings", 11 | description: "Manage account and website settings.", 12 | }; 13 | 14 | export default async function SettingsPage() { 15 | const user = await getCurrentUser(); 16 | if (!user) { 17 | redirect(authOptions?.pages?.signIn ?? "/login"); 18 | } 19 | return ( 20 | 21 | 25 |
26 | 27 |
28 |
29 | ); 30 | } 31 | -------------------------------------------------------------------------------- /turbo/generators/templates/package.json.hbs: -------------------------------------------------------------------------------- 1 | { 2 | "name":"@saasfly/{{name}}", 3 | "private":true, 4 | "version":"0.1.0", 5 | "exports":{ 6 | ".":"./index.ts" 7 | }, 8 | "typesVersions":{ 9 | "*":{ 10 | "*":[ 11 | "src/*" 12 | ] 13 | } 14 | }, 15 | "scripts":{ 16 | "clean":"rm -rf .turbo node_modules", 17 | "lint":"eslint .", 18 | "format":"prettier --check \"**/*.{mjs,ts,md,json}\"", 19 | "typecheck":"tsc --noEmit" 20 | }, 21 | "dependencies":{ 22 | }, 23 | "devDependencies":{ 24 | "@saasfly/eslint-config":"workspace:*", 25 | "@saasfly/prettier-config":"workspace:*", 26 | "@saasfly/typescript-config":"workspace:*", 27 | "eslint":"8.57.0", 28 | "typescript":"5.4.5" 29 | }, 30 | "eslintConfig":{ 31 | "extends":[ 32 | "@saasfly/eslint-config/base" 33 | ] 34 | }, 35 | "prettier":"@saasfly/prettier-config" 36 | } -------------------------------------------------------------------------------- /apps/nextjs/src/components/shell.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | 3 | export function DashboardShell(props: { 4 | title?: string; 5 | description?: React.ReactNode; 6 | breadcrumb?: boolean; 7 | headerAction?: React.ReactNode; 8 | children: React.ReactNode; 9 | className?: string; 10 | }) { 11 | return ( 12 |
13 |
14 |
15 |

16 | {props.title} 17 |

18 | {typeof props.description === "string" ? ( 19 |

20 | {props.description} 21 |

22 | ) : ( 23 | props.description 24 | )} 25 |
26 | {props.headerAction} 27 |
28 | {/*{props.breadcrumb && }*/} 29 |
{props.children}
30 |
31 | ); 32 | } 33 | -------------------------------------------------------------------------------- /apps/nextjs/src/app/api/webhooks/stripe/route.ts: -------------------------------------------------------------------------------- 1 | import { NextResponse, type NextRequest } from "next/server"; 2 | 3 | import { handleEvent, stripe, type Stripe } from "@saasfly/stripe"; 4 | 5 | import { env } from "~/env.mjs"; 6 | 7 | const handler = async (req: NextRequest) => { 8 | const payload = await req.text(); 9 | const signature = req.headers.get("Stripe-Signature")!; 10 | try { 11 | const event = stripe.webhooks.constructEvent( 12 | payload, 13 | signature, 14 | env.STRIPE_WEBHOOK_SECRET, 15 | ) as Stripe.DiscriminatedEvent; 16 | await handleEvent(event); 17 | 18 | console.log("✅ Handled Stripe Event", event.type); 19 | return NextResponse.json({ received: true }, { status: 200 }); 20 | } catch (error) { 21 | const message = error instanceof Error ? error.message : "Unknown error"; 22 | console.log(`❌ Error when handling Stripe Event: ${message}`); 23 | return NextResponse.json({ error: message }, { status: 400 }); 24 | } 25 | }; 26 | 27 | export { handler as GET, handler as POST }; 28 | -------------------------------------------------------------------------------- /packages/auth/db.ts: -------------------------------------------------------------------------------- 1 | import { createKysely } from "@vercel/postgres-kysely"; 2 | import type { GeneratedAlways } from "kysely"; 3 | 4 | interface Database { 5 | User: { 6 | id: GeneratedAlways; 7 | name: string | null; 8 | email: string; 9 | emailVerified: Date | null; 10 | image: string | null; 11 | }; 12 | Account: { 13 | id: GeneratedAlways; 14 | userId: string; 15 | type: string; 16 | provider: string; 17 | providerAccountId: string; 18 | refresh_token: string | null; 19 | access_token: string | null; 20 | expires_at: number | null; 21 | token_type: string | null; 22 | scope: string | null; 23 | id_token: string | null; 24 | session_state: string | null; 25 | }; 26 | Session: { 27 | id: GeneratedAlways; 28 | userId: string; 29 | sessionToken: string; 30 | expires: Date; 31 | }; 32 | VerificationToken: { 33 | identifier: string; 34 | token: string; 35 | expires: Date; 36 | }; 37 | } 38 | 39 | export const db = createKysely(); 40 | -------------------------------------------------------------------------------- /tooling/prettier-config/index.mjs: -------------------------------------------------------------------------------- 1 | import { fileURLToPath } from "url"; 2 | 3 | /** @typedef {import("prettier").Config} PrettierConfig */ 4 | /** @typedef {import("prettier-plugin-tailwindcss").PluginOptions} TailwindConfig */ 5 | /** @typedef {import("@ianvs/prettier-plugin-sort-imports").PluginConfig} SortImportsConfig */ 6 | 7 | /** @type { PrettierConfig | SortImportsConfig | TailwindConfig } */ 8 | const config = { 9 | plugins: [ 10 | "@ianvs/prettier-plugin-sort-imports", 11 | "prettier-plugin-tailwindcss", 12 | ], 13 | tailwindConfig: fileURLToPath( 14 | new URL("../../tooling/tailwind-config/index.ts", import.meta.url), 15 | ), 16 | importOrder: [ 17 | "^(react/(.*)$)|^(react$)|^(react-native(.*)$)", 18 | "^(next/(.*)$)|^(next$)", 19 | "", 20 | "", 21 | "^@saasfly/(.*)$", 22 | "", 23 | "^~/", 24 | "^[../]", 25 | "^[./]", 26 | ], 27 | importOrderParserPlugins: ["typescript", "jsx", "decorators-legacy"], 28 | importOrderTypeScriptVersion: "5.4.5", 29 | }; 30 | 31 | export default config; 32 | -------------------------------------------------------------------------------- /apps/nextjs/src/components/content/mdx-card.tsx: -------------------------------------------------------------------------------- 1 | import Link from "next/link"; 2 | 3 | import { cn } from "@saasfly/ui"; 4 | 5 | interface CardProps extends React.HTMLAttributes { 6 | href?: string; 7 | disabled?: boolean; 8 | } 9 | 10 | export function MdxCard({ 11 | href, 12 | className, 13 | children, 14 | disabled, 15 | ...props 16 | }: CardProps) { 17 | return ( 18 |
26 |
27 |
28 | {children} 29 |
30 |
31 | {href && ( 32 | 33 | View 34 | 35 | )} 36 |
37 | ); 38 | } 39 | -------------------------------------------------------------------------------- /packages/ui/src/callout.tsx: -------------------------------------------------------------------------------- 1 | import { cn } from "@saasfly/ui"; 2 | 3 | interface CalloutProps { 4 | icon?: string; 5 | children?: React.ReactNode; 6 | type?: "default" | "warning" | "danger" | "info"; 7 | } 8 | 9 | // ✅💡⚠️🚫🚨 10 | export function Callout({ 11 | children, 12 | icon, 13 | type = "default", 14 | ...props 15 | }: CalloutProps) { 16 | return ( 17 |
28 | {icon && {icon}} 29 |
{children}
30 |
31 | ); 32 | } 33 | -------------------------------------------------------------------------------- /apps/nextjs/src/styles/mdx.css: -------------------------------------------------------------------------------- 1 | [data-rehype-pretty-code-fragment] code { 2 | @apply grid min-w-full break-words rounded-none border-0 bg-transparent p-0 text-sm text-black; 3 | counter-reset: line; 4 | box-decoration-break: clone; 5 | } 6 | 7 | [data-rehype-pretty-code-fragment] .line { 8 | @apply px-4 py-1; 9 | } 10 | 11 | [data-rehype-pretty-code-fragment] [data-line-numbers] > .line::before { 12 | counter-increment: line; 13 | content: counter(line); 14 | display: inline-block; 15 | width: 1rem; 16 | margin-right: 1rem; 17 | text-align: right; 18 | color: gray; 19 | } 20 | 21 | [data-rehype-pretty-code-fragment] .line--highlighted { 22 | @apply bg-slate-300 bg-opacity-10; 23 | } 24 | 25 | [data-rehype-pretty-code-fragment] .line-highlighted span { 26 | @apply relative; 27 | } 28 | 29 | [data-rehype-pretty-code-fragment] .word--highlighted { 30 | @apply rounded-md bg-slate-300 bg-opacity-10 p-1; 31 | } 32 | 33 | [data-rehype-pretty-code-title] { 34 | @apply mt-4 px-4 py-2 text-sm font-medium; 35 | } 36 | 37 | [data-rehype-pretty-code-title] + pre { 38 | @apply mt-0; 39 | } 40 | -------------------------------------------------------------------------------- /packages/ui/src/animated-gradient-text.tsx: -------------------------------------------------------------------------------- 1 | import type { ReactNode } from "react"; 2 | 3 | import { cn } from "./utils/cn"; 4 | 5 | function AnimatedGradientText({ 6 | children, 7 | className, 8 | }: { 9 | children: ReactNode; 10 | className?: string; 11 | }) { 12 | return ( 13 |
19 |
22 | 23 | {children} 24 |
25 | ); 26 | } 27 | 28 | export { AnimatedGradientText }; 29 | -------------------------------------------------------------------------------- /packages/auth/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@saasfly/auth", 3 | "version": "0.1.0", 4 | "private": true, 5 | "main": "./index.ts", 6 | "types": "./index.ts", 7 | "scripts": { 8 | "clean": "rm -rf .turbo node_modules", 9 | "lint": "eslint .", 10 | "format": "prettier --check '**/*.{mjs,ts,json}' ", 11 | "typecheck": "tsc --noEmit" 12 | }, 13 | "dependencies": { 14 | "@auth/kysely-adapter": "0.4.2", 15 | "@saasfly/db": "workspace:*", 16 | "@t3-oss/env-nextjs": "0.8.0", 17 | "next": "14.2.5", 18 | "next-auth": "4.24.7", 19 | "react": "18.3.1", 20 | "react-dom": "18.3.1", 21 | "zod": "3.22.4" 22 | }, 23 | "devDependencies": { 24 | "@saasfly/eslint-config": "workspace:*", 25 | "@saasfly/prettier-config": "workspace:*", 26 | "@saasfly/typescript-config": "workspace:*", 27 | "eslint": "8.57.0", 28 | "prettier": "3.2.5", 29 | "typescript": "5.4.5" 30 | }, 31 | "eslintConfig": { 32 | "root": true, 33 | "extends": [ 34 | "@saasfly/eslint-config/base" 35 | ] 36 | }, 37 | "prettier": "@saasfly/prettier-config" 38 | } 39 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 saasfly 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /apps/nextjs/src/app/[lang]/(marketing)/pricing/loading.tsx: -------------------------------------------------------------------------------- 1 | import { Skeleton } from "@saasfly/ui/skeleton"; 2 | 3 | export default function Loading() { 4 | return ( 5 |
6 |
7 |
8 | 9 | 10 | 11 |
12 | 13 |
14 | 15 | 16 | 17 |
18 | 19 |
20 | 21 | 22 |
23 |
24 | 25 |
26 |
27 | ); 28 | } 29 | -------------------------------------------------------------------------------- /packages/common/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@saasfly/common", 3 | "version": "0.1.0", 4 | "private": true, 5 | "exports": { 6 | ".": "./src/index.ts", 7 | "./resend": "./src/email.ts", 8 | "./MagicLinkEmail": "./src/emails/magic-link-email.tsx", 9 | "./subscriptions": "./src/subscriptions.ts", 10 | "./env": "./src/env.mjs" 11 | }, 12 | "typesVersions": { 13 | "*": { 14 | "*": [ 15 | "src/*" 16 | ] 17 | } 18 | }, 19 | "scripts": { 20 | "clean": "rm -rf .turbo node_modules", 21 | "format": "prettier --check '**/*.{mjs,ts,json}' " 22 | }, 23 | "dependencies": { 24 | "@saasfly/ui": "workspace:*", 25 | "resend": "2.1.0" 26 | }, 27 | "devDependencies": { 28 | "@saasfly/eslint-config": "workspace:*", 29 | "@saasfly/prettier-config": "workspace:*", 30 | "@saasfly/typescript-config": "workspace:*", 31 | "eslint": "8.57.0", 32 | "prettier": "3.2.5", 33 | "typescript": "5.4.5" 34 | }, 35 | "eslintConfig": { 36 | "root": true, 37 | "extends": [ 38 | "@saasfly/eslint-config/base" 39 | ] 40 | }, 41 | "prettier": "@saasfly/prettier-config" 42 | } 43 | -------------------------------------------------------------------------------- /apps/nextjs/next.config.mjs: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | import "./src/env.mjs"; 3 | import "@saasfly/auth/env.mjs"; 4 | 5 | import { withNextDevtools } from "@next-devtools/core/plugin"; 6 | // import "@saasfly/api/env" 7 | import withMDX from "@next/mdx"; 8 | 9 | !process.env.SKIP_ENV_VALIDATION && (await import("./src/env.mjs")); 10 | 11 | /** @type {import("next").NextConfig} */ 12 | const config = { 13 | reactStrictMode: true, 14 | /** Enables hot reloading for local packages without a build step */ 15 | transpilePackages: [ 16 | "@saasfly/api", 17 | "@saasfly/auth", 18 | "@saasfly/db", 19 | "@saasfly/common", 20 | "@saasfly/ui", 21 | "@saasfly/stripe", 22 | ], 23 | pageExtensions: ["ts", "tsx", "mdx"], 24 | experimental: { 25 | mdxRs: true, 26 | // serverActions: true, 27 | }, 28 | images: { 29 | domains: ["images.unsplash.com", "avatars.githubusercontent.com"], 30 | }, 31 | /** We already do linting and typechecking as separate tasks in CI */ 32 | eslint: { ignoreDuringBuilds: true }, 33 | typescript: { ignoreBuildErrors: true }, 34 | output: "standalone", 35 | }; 36 | 37 | export default withNextDevtools(withMDX()(config)); 38 | -------------------------------------------------------------------------------- /apps/nextjs/src/content/docs/index.mdx: -------------------------------------------------------------------------------- 1 | --- 2 | title: Documentation 3 | description: Welcome to the repo documentation. 4 | --- 5 | 6 | This is the documentation for the Taxonomy site. 7 | 8 | This is an example of a doc site built using [ContentLayer](/docs/documentation/contentlayer) and MDX. 9 | 10 | 11 | 12 | This site is a work in progress. 13 | 14 | 15 | 16 | ## Features 17 | 18 | Select a feature below to learn more about it. 19 | 20 |
21 | 22 | 23 | 24 | ### Documentation 25 | 26 | This documentation site built using Contentlayer. 27 | 28 | 29 | 30 | 31 | 32 | ### Marketing 33 | 34 | The marketing site with landing pages. 35 | 36 | 37 | 38 | 39 | 40 | ### App 41 | 42 | The dashboard with auth and subscriptions. 43 | 44 | 45 | 46 | 47 | 48 | ### Blog 49 | 50 | The blog built using Contentlayer and MDX. 51 | 52 | 53 | 54 |
55 | -------------------------------------------------------------------------------- /packages/stripe/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@saasfly/stripe", 3 | "private": true, 4 | "version": "0.1.0", 5 | "exports": { 6 | ".": "./src/index.ts", 7 | "./plans": "./src/plans.ts", 8 | "./env": "./src/env.mjs" 9 | }, 10 | "typesVersions": { 11 | "*": { 12 | "*": [ 13 | "src/*" 14 | ] 15 | } 16 | }, 17 | "scripts": { 18 | "clean": "rm -rf .turbo node_modules", 19 | "dev": "stripe listen --forward-to localhost:3000/api/webhooks/stripe", 20 | "lint": "eslint .", 21 | "format": "prettier --check '**/*.{mjs,ts,json}' ", 22 | "typecheck": "tsc --noEmit" 23 | }, 24 | "dependencies": { 25 | "@saasfly/db": "workspace:*", 26 | "@t3-oss/env-nextjs": "0.8.0", 27 | "stripe": "14.15.0" 28 | }, 29 | "devDependencies": { 30 | "@saasfly/eslint-config": "workspace:*", 31 | "@saasfly/prettier-config": "workspace:*", 32 | "@saasfly/typescript-config": "workspace:*", 33 | "eslint": "8.57.0", 34 | "prettier": "3.2.5", 35 | "typescript": "5.4.5" 36 | }, 37 | "eslintConfig": { 38 | "extends": [ 39 | "@saasfly/eslint-config/base" 40 | ] 41 | }, 42 | "prettier": "@saasfly/prettier-config" 43 | } 44 | -------------------------------------------------------------------------------- /packages/ui/src/meteors.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | import { cn } from "./utils/cn"; 4 | 5 | export const Meteors = ({ 6 | number, 7 | className, 8 | }: { 9 | number?: number; 10 | className?: string; 11 | }) => { 12 | const meteors = new Array(number ?? 20).fill(true); 13 | return ( 14 | <> 15 | {meteors.map((el, idx) => ( 16 | 30 | ))} 31 | 32 | ); 33 | }; 34 | -------------------------------------------------------------------------------- /apps/nextjs/src/app/[lang]/(marketing)/pricing/page.tsx: -------------------------------------------------------------------------------- 1 | import { getCurrentUser } from "@saasfly/auth"; 2 | 3 | import { PricingCards } from "~/components/price/pricing-cards"; 4 | import { PricingFaq } from "~/components/price/pricing-faq"; 5 | import type { Locale } from "~/config/i18n-config"; 6 | import { getDictionary } from "~/lib/get-dictionary"; 7 | import { trpc } from "~/trpc/server"; 8 | 9 | export const metadata = { 10 | title: "Pricing", 11 | }; 12 | 13 | export default async function PricingPage({ 14 | params: { lang }, 15 | }: { 16 | params: { 17 | lang: Locale; 18 | }; 19 | }) { 20 | const user = await getCurrentUser(); 21 | const dict = await getDictionary(lang); 22 | let subscriptionPlan; 23 | 24 | if (user) { 25 | subscriptionPlan = await trpc.stripe.userPlans.query(); 26 | } 27 | return ( 28 |
29 | 35 |
36 | 37 |
38 | ); 39 | } 40 | -------------------------------------------------------------------------------- /packages/ui/src/checkbox.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import * as React from "react"; 4 | import * as CheckboxPrimitive from "@radix-ui/react-checkbox"; 5 | import { Check } from "lucide-react"; 6 | 7 | import { cn } from "./utils/cn"; 8 | 9 | const Checkbox = React.forwardRef< 10 | React.ElementRef, 11 | React.ComponentPropsWithoutRef 12 | >(({ className, ...props }, ref) => ( 13 | 21 | 24 | 25 | 26 | 27 | )); 28 | Checkbox.displayName = CheckboxPrimitive.Root.displayName; 29 | 30 | export { Checkbox }; 31 | -------------------------------------------------------------------------------- /packages/ui/src/popover.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import * as React from "react"; 4 | import * as PopoverPrimitive from "@radix-ui/react-popover"; 5 | 6 | import { cn } from "./utils/cn"; 7 | 8 | const Popover = PopoverPrimitive.Root; 9 | 10 | const PopoverTrigger = PopoverPrimitive.Trigger; 11 | 12 | const PopoverContent = React.forwardRef< 13 | React.ElementRef, 14 | React.ComponentPropsWithoutRef 15 | >(({ className, align = "center", sideOffset = 4, ...props }, ref) => ( 16 | 17 | 27 | 28 | )); 29 | PopoverContent.displayName = PopoverPrimitive.Content.displayName; 30 | 31 | export { Popover, PopoverTrigger, PopoverContent }; 32 | -------------------------------------------------------------------------------- /apps/nextjs/src/config/ui/dashboard.ts: -------------------------------------------------------------------------------- 1 | import type { Locale } from "~/config/i18n-config"; 2 | import { getDictionary } from "~/lib/get-dictionary"; 3 | import type { DashboardConfig } from "~/types"; 4 | 5 | export const getDashboardConfig = async ({ 6 | params: { lang }, 7 | }: { 8 | params: { 9 | lang: Locale; 10 | }; 11 | }): Promise => { 12 | const dict = await getDictionary(lang); 13 | 14 | return { 15 | mainNav: [ 16 | { 17 | title: dict.common.dashboard.main_nav_documentation, 18 | href: "/docs", 19 | }, 20 | { 21 | title: dict.common.dashboard.main_nav_support, 22 | href: "/support", 23 | disabled: true, 24 | }, 25 | ], 26 | sidebarNav: [ 27 | { 28 | id: "clusters", 29 | title: dict.common.dashboard.sidebar_nav_clusters, 30 | href: "/dashboard/", 31 | }, 32 | { 33 | id: "billing", 34 | title: dict.common.dashboard.sidebar_nav_billing, 35 | href: "/dashboard/billing", 36 | }, 37 | { 38 | id: "settings", 39 | title: dict.common.dashboard.sidebar_nav_settings, 40 | href: "/dashboard/settings", 41 | }, 42 | ], 43 | }; 44 | }; 45 | -------------------------------------------------------------------------------- /tooling/eslint-config/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@saasfly/eslint-config", 3 | "version": "0.0.1", 4 | "private": true, 5 | "files": [ 6 | "./base.js", 7 | "./nextjs.js", 8 | "./react.js" 9 | ], 10 | "scripts": { 11 | "clean": "rm -rf .turbo node_modules", 12 | "lint": "eslint .", 13 | "format": "prettier --check '**/*.{js,json}' ", 14 | "typecheck": "tsc --noEmit" 15 | }, 16 | "dependencies": { 17 | "@next/eslint-plugin-next": "14.0.1", 18 | "@types/eslint": "8.56.7", 19 | "@typescript-eslint/eslint-plugin": "7.5.0", 20 | "@typescript-eslint/parser": "7.5.0", 21 | "eslint-config-prettier": "9.1.0", 22 | "eslint-config-turbo": "1.13.2", 23 | "eslint-plugin-import": "2.29.1", 24 | "eslint-plugin-jsx-a11y": "6.8.0", 25 | "eslint-plugin-react": "7.34.1", 26 | "eslint-plugin-react-hooks": "4.6.0" 27 | }, 28 | "devDependencies": { 29 | "@saasfly/prettier-config": "workspace:*", 30 | "@saasfly/typescript-config": "workspace:*", 31 | "eslint": "8.57.0", 32 | "typescript": "5.4.5" 33 | }, 34 | "eslintConfig": { 35 | "root": true, 36 | "extends": [ 37 | "./base.js" 38 | ] 39 | }, 40 | "prettier": "@saasfly/prettier-config" 41 | } 42 | -------------------------------------------------------------------------------- /apps/nextjs/src/components/docs/search.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import * as React from "react"; 4 | 5 | import { cn } from "@saasfly/ui"; 6 | import { Input } from "@saasfly/ui/input"; 7 | import { toast } from "@saasfly/ui/use-toast"; 8 | 9 | interface DocsSearchProps extends React.HTMLAttributes { 10 | lang: string; 11 | } 12 | 13 | export function DocsSearch({ className, ...props }: DocsSearchProps) { 14 | function onSubmit(event: React.SyntheticEvent) { 15 | event.preventDefault(); 16 | 17 | return toast({ 18 | title: "Not implemented", 19 | description: "We're still working on the search.", 20 | }); 21 | } 22 | 23 | return ( 24 |
29 | 34 | 35 | K 36 | 37 |
38 | ); 39 | } 40 | -------------------------------------------------------------------------------- /packages/ui/src/switch.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import * as React from "react"; 4 | import * as SwitchPrimitives from "@radix-ui/react-switch"; 5 | 6 | import { cn } from "@saasfly/ui"; 7 | 8 | const Switch = React.forwardRef< 9 | React.ElementRef, 10 | React.ComponentPropsWithoutRef 11 | >(({ className, ...props }, ref) => ( 12 | 20 | 25 | 26 | )); 27 | Switch.displayName = SwitchPrimitives.Root.displayName; 28 | 29 | export { Switch }; 30 | -------------------------------------------------------------------------------- /tooling/eslint-config/base.js: -------------------------------------------------------------------------------- 1 | /** @type {import("eslint").Linter.Config} */ 2 | const config = { 3 | extends: [ 4 | "turbo", 5 | "eslint:recommended", 6 | "plugin:@typescript-eslint/recommended-type-checked", 7 | "plugin:@typescript-eslint/stylistic-type-checked", 8 | "prettier", 9 | ], 10 | env: { 11 | es2022: true, 12 | node: true, 13 | }, 14 | parser: "@typescript-eslint/parser", 15 | parserOptions: { 16 | project: true, 17 | }, 18 | plugins: ["@typescript-eslint", "import"], 19 | rules: { 20 | "turbo/no-undeclared-env-vars": "off", 21 | "import/consistent-type-specifier-style": "off", 22 | "@typescript-eslint/no-unused-vars": [ 23 | "error", 24 | { argsIgnorePattern: "^_", varsIgnorePattern: "^_" }, 25 | ], 26 | "@typescript-eslint/consistent-type-imports": [ 27 | "warn", 28 | { prefer: "type-imports", fixStyle: "separate-type-imports" }, 29 | ], 30 | "@typescript-eslint/no-misused-promises": [ 31 | 2, 32 | { checksVoidReturn: { attributes: false } }, 33 | ], 34 | }, 35 | ignorePatterns: [ 36 | "**/.eslintrc.cjs", 37 | "**/*.config.js", 38 | "**/*.config.cjs", 39 | ".next", 40 | "dist", 41 | "pnpm-lock.yaml", 42 | "bun.lockb", 43 | ], 44 | reportUnusedDisableDirectives: true, 45 | }; 46 | 47 | module.exports = config; 48 | -------------------------------------------------------------------------------- /apps/nextjs/src/trpc/server.ts: -------------------------------------------------------------------------------- 1 | "use server"; 2 | 3 | import "server-only"; 4 | 5 | import { cookies, headers } from "next/headers"; 6 | import { loggerLink } from "@trpc/client"; 7 | import { experimental_createTRPCNextAppDirServer } from "@trpc/next/app-dir/server"; 8 | 9 | import type { AppRouter } from "@saasfly/api"; 10 | 11 | import { endingLink, transformer } from "./shared"; 12 | 13 | export const trpc = experimental_createTRPCNextAppDirServer({ 14 | config() { 15 | return { 16 | ssr: true, 17 | transformer, 18 | links: [ 19 | // loggerLink({ 20 | // enabled: (opts) => 21 | // process.env.NODE_ENV === "development" || 22 | // (opts.direction === "down" && opts.result instanceof Error), 23 | // }), 24 | loggerLink({ 25 | enabled: () => true, 26 | }), 27 | endingLink({ 28 | headers: () => { 29 | const h = new Map(headers()); 30 | h.delete("connection"); 31 | h.delete("transfer-encoding"); 32 | h.set("x-trpc-source", "server"); 33 | h.set("cookie", cookies().toString()); 34 | return Object.fromEntries(h.entries()); 35 | }, 36 | }), 37 | ], 38 | }; 39 | }, 40 | }); 41 | 42 | export { type RouterInputs, type RouterOutputs } from "@saasfly/api"; 43 | -------------------------------------------------------------------------------- /packages/db/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@saasfly/db", 3 | "version": "0.1.0", 4 | "private": true, 5 | "exports": { 6 | ".": "./index.ts" 7 | }, 8 | "main": "./index.ts", 9 | "types": "./index.ts", 10 | "scripts": { 11 | "clean": "rm -rf .turbo node_modules", 12 | "lint": "eslint .", 13 | "format": "prisma format && prettier --check '**/*.{ts,json}' ", 14 | "db:generate": "prisma generate", 15 | "db:push": "bun with-env prisma db push --skip-generate", 16 | "typecheck": "tsc --noEmit", 17 | "with-env": "dotenv -e ../../.env.local --" 18 | }, 19 | "dependencies": { 20 | "kysely": "0.27.3", 21 | "@vercel/postgres-kysely": "0.8.0" 22 | }, 23 | "devDependencies": { 24 | "@saasfly/eslint-config": "workspace:*", 25 | "@saasfly/prettier-config": "workspace:*", 26 | "@saasfly/typescript-config": "workspace:*", 27 | "dotenv-cli": "7.3.0", 28 | "eslint": "8.57.0", 29 | "prettier": "3.2.5", 30 | "prisma": "5.9.1", 31 | "prisma-kysely": "1.7.1", 32 | "@types/pg": "8.11.0", 33 | "typescript": "5.4.5" 34 | }, 35 | "eslintConfig": { 36 | "root": true, 37 | "extends": [ 38 | "@saasfly/eslint-config/base" 39 | ], 40 | "rules": { 41 | "@typescript-eslint/consistent-type-definitions": "off" 42 | } 43 | }, 44 | "prettier": "@saasfly/prettier-config" 45 | } 46 | -------------------------------------------------------------------------------- /apps/nextjs/src/components/meteors-card.tsx: -------------------------------------------------------------------------------- 1 | import { Meteors } from "@saasfly/ui/meteors"; 2 | 3 | import type { Meteor } from "~/types/meteors"; 4 | 5 | export function Meteorss({ meteor }: { meteor: Meteor }) { 6 | return ( 7 |
8 |
9 |
10 |
11 |

12 | {meteor.name} 13 |

14 | 15 |

16 | {meteor.description} 17 |

18 | 19 | 22 | 23 | {/* Meaty part - Meteor effect */} 24 | 25 | 26 |
27 |
28 |
29 | ); 30 | } 31 | -------------------------------------------------------------------------------- /apps/nextjs/src/hooks/use-media-query.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from "react"; 2 | 3 | export default function useMediaQuery() { 4 | const [device, setDevice] = useState<"mobile" | "tablet" | "desktop" | null>( 5 | null, 6 | ); 7 | const [dimensions, setDimensions] = useState<{ 8 | width: number; 9 | height: number; 10 | } | null>(null); 11 | 12 | useEffect(() => { 13 | const checkDevice = () => { 14 | if (window.matchMedia("(max-width: 640px)").matches) { 15 | setDevice("mobile"); 16 | } else if ( 17 | window.matchMedia("(min-width: 641px) and (max-width: 1024px)").matches 18 | ) { 19 | setDevice("tablet"); 20 | } else { 21 | setDevice("desktop"); 22 | } 23 | setDimensions({ width: window.innerWidth, height: window.innerHeight }); 24 | }; 25 | 26 | // Initial detection 27 | checkDevice(); 28 | 29 | // Listener for windows resize 30 | window.addEventListener("resize", checkDevice); 31 | 32 | // Cleanup listener 33 | return () => { 34 | window.removeEventListener("resize", checkDevice); 35 | }; 36 | }, []); 37 | 38 | return { 39 | device, 40 | width: dimensions?.width, 41 | height: dimensions?.height, 42 | isMobile: device === "mobile", 43 | isTablet: device === "tablet", 44 | isDesktop: device === "desktop", 45 | }; 46 | } 47 | -------------------------------------------------------------------------------- /packages/ui/src/marquee.tsx: -------------------------------------------------------------------------------- 1 | import { cn } from "./utils/cn"; 2 | 3 | interface MarqueeProps { 4 | className?: string; 5 | reverse?: boolean; 6 | pauseOnHover?: boolean; 7 | children?: React.ReactNode; 8 | vertical?: boolean; 9 | repeat?: number; 10 | [key: string]: any; 11 | } 12 | 13 | export default function Marquee({ 14 | className, 15 | reverse, 16 | pauseOnHover = false, 17 | children, 18 | vertical = false, 19 | repeat = 4, 20 | ...props 21 | }: MarqueeProps) { 22 | return ( 23 |
34 | {Array(repeat) 35 | .fill(0) 36 | .map((_, i) => ( 37 |
46 | {children} 47 |
48 | ))} 49 |
50 | ); 51 | } 52 | -------------------------------------------------------------------------------- /packages/auth/env.mjs: -------------------------------------------------------------------------------- 1 | import { createEnv } from "@t3-oss/env-nextjs"; 2 | import { z } from "zod"; 3 | 4 | export const env = createEnv({ 5 | server: { 6 | // This is optional because it's only used in development. 7 | // See https://next-auth.js.org/deployment. 8 | NEXTAUTH_URL: z.string().url().optional(), 9 | NEXTAUTH_SECRET: z.string().min(1), 10 | GITHUB_CLIENT_ID: z.string().min(1), 11 | GITHUB_CLIENT_SECRET: z.string().min(1), 12 | STRIPE_API_KEY: z.string().min(1), 13 | STRIPE_WEBHOOK_SECRET: z.string().min(1), 14 | RESEND_API_KEY: z.string().min(1), 15 | RESEND_FROM: z.string().min(1), 16 | ADMIN_EMAIL: z.string().optional(), 17 | IS_DEBUG: z.string().optional(), 18 | }, 19 | client: { 20 | NEXT_PUBLIC_APP_URL: z.string().min(1), 21 | }, 22 | runtimeEnv: { 23 | NEXTAUTH_URL: process.env.NEXTAUTH_URL, 24 | NEXTAUTH_SECRET: process.env.NEXTAUTH_SECRET, 25 | GITHUB_CLIENT_ID: process.env.GITHUB_CLIENT_ID, 26 | GITHUB_CLIENT_SECRET: process.env.GITHUB_CLIENT_SECRET, 27 | STRIPE_API_KEY: process.env.STRIPE_API_KEY, 28 | STRIPE_WEBHOOK_SECRET: process.env.STRIPE_WEBHOOK_SECRET, 29 | NEXT_PUBLIC_APP_URL: process.env.NEXT_PUBLIC_APP_URL, 30 | RESEND_API_KEY: process.env.RESEND_API_KEY, 31 | RESEND_FROM: process.env.RESEND_FROM, 32 | ADMIN_EMAIL: process.env.ADMIN_EMAIL, 33 | IS_DEBUG: process.env.IS_DEBUG, 34 | }, 35 | }); 36 | -------------------------------------------------------------------------------- /apps/nextjs/src/components/site-footer.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import Image from "next/image"; 3 | 4 | import { cn } from "@saasfly/ui"; 5 | 6 | import { ModeToggle } from "~/components/mode-toggle"; 7 | 8 | function getCopyrightText( 9 | dict: Record>, 10 | ) { 11 | const currentYear = new Date().getFullYear(); 12 | const copyrightTemplate = String(dict.copyright); 13 | return copyrightTemplate?.replace("${currentYear}", String(currentYear)); 14 | } 15 | 16 | export function SiteFooter({ 17 | className, 18 | dict, 19 | }: { 20 | className?: string; 21 | params: { 22 | lang: string; 23 | }; 24 | 25 | dict: Record>; 26 | }) { 27 | return ( 28 |
29 |
30 |
31 | 37 |

38 | {getCopyrightText(dict)} 39 |

40 |
41 | 42 |
43 |
44 | ); 45 | } 46 | -------------------------------------------------------------------------------- /apps/nextjs/src/components/price/pricing-faq.tsx: -------------------------------------------------------------------------------- 1 | import Balancer from "react-wrap-balancer"; 2 | 3 | import { 4 | Accordion, 5 | AccordionContent, 6 | AccordionItem, 7 | AccordionTrigger, 8 | } from "@saasfly/ui/accordion"; 9 | 10 | import type { Locale } from "~/config/i18n-config"; 11 | import { priceFaqDataMap } from "~/config/price/price-faq-data"; 12 | 13 | export function PricingFaq({ 14 | params: { lang }, 15 | dict, 16 | }: { 17 | params: { 18 | lang: Locale; 19 | }; 20 | dict: Record; 21 | }) { 22 | const pricingFaqData = priceFaqDataMap[lang]; 23 | return ( 24 |
25 |
26 |

27 | {dict.faq} 28 |

29 |

30 | {dict.faq_detail} 31 |

32 |
33 | 34 | {pricingFaqData?.map((faqItem) => ( 35 | 36 | {faqItem.question} 37 | {faqItem.answer} 38 | 39 | ))} 40 | 41 |
42 | ); 43 | } 44 | -------------------------------------------------------------------------------- /apps/nextjs/src/config/providers.tsx: -------------------------------------------------------------------------------- 1 | // app/providers.tsx 2 | "use client"; 3 | 4 | import { useEffect } from "react"; 5 | import { usePathname, useSearchParams } from "next/navigation"; 6 | import posthog from "posthog-js"; 7 | import { PostHogProvider } from "posthog-js/react"; 8 | 9 | import { env } from "~/env.mjs"; 10 | 11 | if (typeof window !== "undefined") { 12 | const posthogKey = env.NEXT_PUBLIC_POSTHOG_KEY + ""; 13 | const posthogHost = env.NEXT_PUBLIC_POSTHOG_HOST + ""; 14 | 15 | // 你也可以先检查这些变量是否存在 16 | if (posthogKey && posthogHost) { 17 | posthog.init(posthogKey, { 18 | api_host: posthogHost, 19 | capture_pageview: false, 20 | }); 21 | } else { 22 | console.error("PostHog environment variables are missing"); 23 | } 24 | } 25 | 26 | export function PostHogPageview() { 27 | const pathname = usePathname(); 28 | const searchParams = useSearchParams(); 29 | 30 | useEffect(() => { 31 | if (pathname) { 32 | let url = window.origin + pathname; 33 | if (searchParams?.toString()) { 34 | url = url + `?${searchParams.toString()}`; 35 | } 36 | posthog.capture("$pageview", { 37 | $current_url: url, 38 | }); 39 | } 40 | }, [pathname, searchParams]); 41 | 42 | return <>; 43 | } 44 | 45 | export function PHProvider({ children }: { children: React.ReactNode }) { 46 | return {children}; 47 | } 48 | -------------------------------------------------------------------------------- /apps/nextjs/src/components/locale-change.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import * as React from "react"; 4 | import { useRouter } from "next/navigation"; 5 | 6 | import { Button } from "@saasfly/ui/button"; 7 | import { 8 | DropdownMenu, 9 | DropdownMenuContent, 10 | DropdownMenuItem, 11 | DropdownMenuTrigger, 12 | } from "@saasfly/ui/dropdown-menu"; 13 | import * as Icons from "@saasfly/ui/icons"; 14 | 15 | import { i18n, localeMap } from "~/config/i18n-config"; 16 | 17 | export function LocaleChange({ url }: { url: string }) { 18 | const router = useRouter(); 19 | 20 | function onClick(locale: string) { 21 | // console.log(url); 22 | router.push(`/${locale}/` + url); 23 | } 24 | 25 | return ( 26 | 27 | 28 | 32 | 33 | 34 |
35 | {i18n.locales.map((locale) => { 36 | return ( 37 | // {locale} 38 | onClick(locale)}> 39 | {localeMap[locale]} 40 | 41 | ); 42 | })} 43 |
44 |
45 |
46 | ); 47 | } 48 | -------------------------------------------------------------------------------- /apps/nextjs/src/app/[lang]/(editor)/editor/cluster/[clusterId]/page.tsx: -------------------------------------------------------------------------------- 1 | import { notFound, redirect } from "next/navigation"; 2 | import type { User } from "next-auth"; 3 | 4 | import { authOptions, getCurrentUser } from "@saasfly/auth"; 5 | import { db } from "@saasfly/db"; 6 | 7 | import { ClusterConfig } from "~/components/k8s/cluster-config"; 8 | import type { Cluster } from "~/types/k8s"; 9 | 10 | async function getClusterForUser(clusterId: Cluster["id"], userId: User["id"]) { 11 | return await db 12 | .selectFrom("K8sClusterConfig") 13 | .selectAll() 14 | .where("id", "=", Number(clusterId)) 15 | .where("authUserId", "=", userId) 16 | .executeTakeFirst(); 17 | } 18 | 19 | interface EditorClusterProps { 20 | params: { 21 | clusterId: number; 22 | lang: string; 23 | }; 24 | } 25 | 26 | export default async function EditorClusterPage({ 27 | params, 28 | }: EditorClusterProps) { 29 | const user = await getCurrentUser(); 30 | if (!user) { 31 | redirect(authOptions?.pages?.signIn ?? "/login"); 32 | } 33 | 34 | // console.log("EditorClusterPage user:" + user.id + "params:", params); 35 | const cluster = await getClusterForUser(params.clusterId, user.id); 36 | 37 | if (!cluster) { 38 | notFound(); 39 | } 40 | return ( 41 | 49 | ); 50 | } 51 | -------------------------------------------------------------------------------- /apps/nextjs/src/trpc/shared.ts: -------------------------------------------------------------------------------- 1 | import { 2 | httpBatchLink, 3 | type HTTPBatchLinkOptions, 4 | type HTTPHeaders, 5 | type TRPCLink, 6 | } from "@trpc/client"; 7 | 8 | import type { AppRouter } from "@saasfly/api"; 9 | 10 | import { env } from "~/env.mjs"; 11 | 12 | export { transformer } from "@saasfly/api/transformer"; 13 | const getBaseUrl = () => { 14 | if (typeof window !== "undefined") return ""; 15 | const vc = env.NEXT_PUBLIC_APP_URL; 16 | if (vc) return vc; 17 | return `http://localhost:3000`; 18 | }; 19 | 20 | const lambdas = [""]; 21 | 22 | export const endingLink = (opts?: { 23 | headers?: HTTPHeaders | (() => HTTPHeaders); 24 | }) => 25 | ((runtime) => { 26 | const sharedOpts = { 27 | headers: opts?.headers, 28 | } satisfies Partial; 29 | 30 | const edgeLink = httpBatchLink({ 31 | ...sharedOpts, 32 | url: `${getBaseUrl()}/api/trpc/edge`, 33 | })(runtime); 34 | const lambdaLink = httpBatchLink({ 35 | ...sharedOpts, 36 | url: `${getBaseUrl()}/api/trpc/lambda`, 37 | })(runtime); 38 | 39 | return (ctx) => { 40 | const path = ctx.op.path.split(".") as [string, ...string[]]; 41 | const endpoint = lambdas.includes(path[0]) ? "lambda" : "edge"; 42 | 43 | const newCtx = { 44 | ...ctx, 45 | op: { ...ctx.op, path: path.join(".") }, 46 | }; 47 | return endpoint === "edge" ? edgeLink(newCtx) : lambdaLink(newCtx); 48 | }; 49 | }) satisfies TRPCLink; 50 | -------------------------------------------------------------------------------- /apps/nextjs/src/app/[lang]/(marketing)/layout.tsx: -------------------------------------------------------------------------------- 1 | import { Suspense } from "react"; 2 | 3 | import { getCurrentUser } from "@saasfly/auth"; 4 | 5 | import { ModalProvider } from "~/components/modal-provider"; 6 | import { NavBar } from "~/components/navbar"; 7 | import { SiteFooter } from "~/components/site-footer"; 8 | import type { Locale } from "~/config/i18n-config"; 9 | import { getMarketingConfig } from "~/config/ui/marketing"; 10 | import { getDictionary } from "~/lib/get-dictionary"; 11 | 12 | export default async function MarketingLayout({ 13 | children, 14 | params: { lang }, 15 | }: { 16 | children: React.ReactNode; 17 | params: { 18 | lang: Locale; 19 | }; 20 | }) { 21 | const dict = await getDictionary(lang); 22 | const user = await getCurrentUser(); 23 | return ( 24 |
25 | 26 | 36 | 37 | 38 |
{children}
39 | 44 |
45 | ); 46 | } 47 | -------------------------------------------------------------------------------- /apps/nextjs/src/app/[lang]/(docs)/layout.tsx: -------------------------------------------------------------------------------- 1 | import { Suspense } from "react"; 2 | 3 | import { getCurrentUser } from "@saasfly/auth"; 4 | 5 | import { NavBar } from "~/components/navbar"; 6 | import { SiteFooter } from "~/components/site-footer"; 7 | import type { Locale } from "~/config/i18n-config"; 8 | import { getMarketingConfig } from "~/config/ui/marketing"; 9 | import { getDictionary } from "~/lib/get-dictionary"; 10 | 11 | interface DocsLayoutProps { 12 | children: React.ReactNode; 13 | params: { 14 | lang: Locale; 15 | }; 16 | } 17 | 18 | export default async function DocsLayout({ 19 | children, 20 | params: { lang }, 21 | }: DocsLayoutProps) { 22 | // const dashboardConfig = await getDashboardConfig({ params: { lang } }); 23 | const dict = await getDictionary(lang); 24 | const user = await getCurrentUser(); 25 | 26 | return ( 27 |
28 | 29 | 39 | 40 |
{children}
41 | 46 |
47 | ); 48 | } 49 | -------------------------------------------------------------------------------- /apps/nextjs/src/styles/theme/default.css: -------------------------------------------------------------------------------- 1 | .theme-dracula.light { 2 | --background: 231, 15%, 100%; 3 | --foreground: 60, 30%, 10%; 4 | 5 | --muted: 232, 14%, 98%; 6 | --muted-foreground: 60, 30%, 20%; 7 | 8 | --popover: 231, 15%, 94%; 9 | --popover-foreground: 60, 30%, 20%; 10 | 11 | --border: 232, 14%, 31%; 12 | --input: 225, 27%, 51%; 13 | 14 | --card: 232, 14%, 98%; 15 | --card-foreground: 60, 30%, 5%; 16 | 17 | --primary: 265, 89%, 78%; 18 | --primary-foreground: 60, 30%, 96%; 19 | 20 | --secondary: 326, 100%, 74%; 21 | --secondary-foreground: 60, 30%, 96%; 22 | 23 | --accent: 225, 27%, 70%; 24 | --accent-foreground: 60, 30%, 10%; 25 | 26 | --destructive: 0, 100%, 67%; 27 | --destructive-foreground: 60, 30%, 96%; 28 | 29 | --ring: 225, 27%, 51%; 30 | } 31 | 32 | .theme-dracula.dark { 33 | --background: 231, 15%, 18%; 34 | --foreground: 60, 30%, 96%; 35 | 36 | --muted: 232, 14%, 31%; 37 | --muted-foreground: 60, 30%, 96%; 38 | 39 | --popover: 231, 15%, 18%; 40 | --popover-foreground: 60, 30%, 96%; 41 | 42 | --border: 232, 14%, 31%; 43 | --input: 225, 27%, 51%; 44 | 45 | --card: 232, 14%, 31%; 46 | --card-foreground: 60, 30%, 96%; 47 | 48 | --primary: 265, 89%, 78%; 49 | --primary-foreground: 60, 30%, 96%; 50 | 51 | --secondary: 326, 100%, 74%; 52 | --secondary-foreground: 60, 30%, 96%; 53 | 54 | --accent: 225, 27%, 51%; 55 | --accent-foreground: 60, 30%, 96%; 56 | 57 | --destructive: 0, 100%, 67%; 58 | --destructive-foreground: 60, 30%, 96%; 59 | 60 | --ring: 225, 27%, 51%; 61 | } 62 | -------------------------------------------------------------------------------- /packages/api/src/trpc.ts: -------------------------------------------------------------------------------- 1 | import type { NextRequest } from "next/server"; 2 | import { initTRPC } from "@trpc/server"; 3 | import { getToken, type JWT } from "next-auth/jwt"; 4 | import { ZodError } from "zod"; 5 | 6 | import { transformer } from "./transformer"; 7 | 8 | interface CreateContextOptions { 9 | req?: NextRequest; 10 | } 11 | 12 | export const createInnerTRPCContext = (opts: CreateContextOptions) => { 13 | return { 14 | ...opts, 15 | }; 16 | }; 17 | 18 | export const createTRPCContext = (opts: { req: NextRequest }) => { 19 | return createInnerTRPCContext({ 20 | req: opts.req, 21 | }); 22 | }; 23 | 24 | export const t = initTRPC.context().create({ 25 | transformer, 26 | errorFormatter({ shape, error }) { 27 | return { 28 | ...shape, 29 | data: { 30 | ...shape.data, 31 | zodError: 32 | error.cause instanceof ZodError ? error.cause.flatten() : null, 33 | }, 34 | }; 35 | }, 36 | }); 37 | 38 | export const createTRPCRouter = t.router; 39 | export const procedure = t.procedure; 40 | export const mergeRouters = t.mergeRouters; 41 | 42 | export const protectedProcedure = procedure.use(async (opts) => { 43 | const { req } = opts.ctx; 44 | const nreq = req!; 45 | const jwt = await handler(nreq); 46 | return opts.next({ ctx: { req, userId: jwt?.id } }); 47 | }); 48 | 49 | async function handler(req: NextRequest): Promise { 50 | // if using `NEXTAUTH_SECRET` env variable, we detect it, and you won't actually need to `secret` 51 | return await getToken({ req }); 52 | } 53 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "nextjs-template", 3 | "private": true, 4 | "scripts": { 5 | "build": "turbo build ", 6 | "clean": "git clean -xdf node_modules", 7 | "clean:workspaces": "turbo clean", 8 | "db:push": "cd ./packages/db/ && bun db:push", 9 | "dev": "turbo dev --parallel", 10 | "dev:web": "turbo dev --parallel --filter !stripe", 11 | "format": "turbo format --continue -- --cache --cache-location='node_modules/.cache/.prettiercache' --ignore-path='../../.gitignore'", 12 | "format:fix": "turbo format --continue -- --write --cache --cache-location='node_modules/.cache/.prettiercache' --ignore-path='../../.gitignore'", 13 | "lint": "turbo lint -- --quiet -- --cache --cache-location 'node_modules/.cache/.eslintcache' && manypkg check", 14 | "lint:fix": "turbo lint --continue -- --fix --cache --cache-location 'node_modules/.cache/.eslintcache' ", 15 | "typecheck": "turbo typecheck", 16 | "postinstall": "bun run check-deps", 17 | "check-deps": "check-dependency-version-consistency .", 18 | "gen": "turbo gen --config 'turbo/generators/config.ts'" 19 | }, 20 | "devDependencies": { 21 | "@turbo/gen": "1.13.3", 22 | "check-dependency-version-consistency": "4.1.0", 23 | "prettier": "3.2.5", 24 | "tailwind-config-viewer": "^2.0.4", 25 | "turbo": "1.13.3", 26 | "typescript": "5.4.5" 27 | }, 28 | "engines": { 29 | "node": ">=18" 30 | }, 31 | "prettier": "@saasfly/prettier-config", 32 | "workspaces": [ 33 | "apps/*", 34 | "packages/*", 35 | "tooling/*" 36 | ], 37 | "packageManager": "bun@v1.1.10" 38 | } 39 | -------------------------------------------------------------------------------- /packages/api/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@saasfly/api", 3 | "version": "0.1.0", 4 | "private": true, 5 | "exports": { 6 | ".": "./src/index.ts", 7 | "./env": "./src/env.mjs", 8 | "./edge": "./src/edge.ts", 9 | "./lambda": "./src/lambda.ts", 10 | "./transformer": "./src/transformer.ts", 11 | "./resend": "./src/email.ts", 12 | "./MagicLinkEmail": "./src/emails/magic-link-email.tsx", 13 | "./subscriptions": "./src/subscriptions.ts" 14 | }, 15 | "typesVersions": { 16 | "*": { 17 | "*": [ 18 | "src/*" 19 | ] 20 | } 21 | }, 22 | "scripts": { 23 | "clean": "rm -rf .turbo node_modules", 24 | "format": "prettier --check '**/*.{ts,mjs}' ", 25 | "typecheck": "tsc --noEmit" 26 | }, 27 | "dependencies": { 28 | "@saasfly/auth": "workspace:*", 29 | "@saasfly/db": "workspace:*", 30 | "@trpc/client": "10.44.1", 31 | "@trpc/server": "10.44.1", 32 | "@t3-oss/env-nextjs": "0.8.0", 33 | "superjson": "2.2.1", 34 | "dinero.js": "2.0.0-alpha.14", 35 | "@dinero.js/currencies": "2.0.0-alpha.14", 36 | "zod": "3.22.4", 37 | "zod-form-data": "2.0.2" 38 | }, 39 | "devDependencies": { 40 | "@saasfly/eslint-config": "workspace:*", 41 | "@saasfly/prettier-config": "workspace:*", 42 | "@saasfly/typescript-config": "workspace:*", 43 | "eslint": "8.57.0", 44 | "prettier": "3.2.5", 45 | "typescript": "5.4.5" 46 | }, 47 | "eslintConfig": { 48 | "root": true, 49 | "extends": [ 50 | "@saasfly/eslint-config/base" 51 | ] 52 | }, 53 | "prettier": "@saasfly/prettier-config" 54 | } 55 | -------------------------------------------------------------------------------- /packages/ui/src/text-generate-effect.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { useEffect } from "react"; 4 | import dynamic from "next/dynamic"; 5 | import { motion, stagger, useAnimate } from "framer-motion"; 6 | 7 | import { cn } from "./utils/cn"; 8 | 9 | const TextGenerateEffectImpl = ({ 10 | words, 11 | className, 12 | }: { 13 | words: string; 14 | className?: string; 15 | }) => { 16 | const [scope, animate] = useAnimate(); 17 | const wordsArray = words.split(" "); 18 | 19 | useEffect(() => { 20 | void animate( 21 | "span", 22 | { 23 | opacity: 1, 24 | }, 25 | { 26 | duration: 2, 27 | delay: stagger(0.1), 28 | }, 29 | ); 30 | }, [scope.current, words]); 31 | 32 | const renderWords = () => { 33 | return ( 34 | 35 | {wordsArray.map((word, idx) => { 36 | return ( 37 | 41 | {word}{" "} 42 | 43 | ); 44 | })} 45 | 46 | ); 47 | }; 48 | 49 | return ( 50 |
51 |
52 |
53 | {renderWords()} 54 |
55 |
56 |
57 | ); 58 | }; 59 | 60 | export const TextGenerateEffect = dynamic( 61 | () => Promise.resolve(TextGenerateEffectImpl), 62 | { 63 | ssr: false, 64 | }, 65 | ); 66 | -------------------------------------------------------------------------------- /SECURITY.md: -------------------------------------------------------------------------------- 1 | # Security Policy 2 | 3 | ## Reporting a Vulnerability 4 | 5 | We greatly value the security community's efforts in helping keep our project safe. If you've discovered a security vulnerability, your responsible disclosure is crucial to us. Here's how you can report it: 6 | 7 | 1. **Contact Method**: Please send an email to [contact@nextify.ltd](mailto:contact@nextify.ltd). 8 | 2. **Email Subject**: Please use a concise yet descriptive subject, such as "Security Vulnerability Discovered". 9 | 3. **Vulnerability Details**: Provide a comprehensive description of the vulnerability. Include reproduction steps and any other information that might help us effectively understand and resolve the issue. 10 | 4. **Proof of Concept**: If possible, please attach any proof of concept or sample code. Please ensure that your research does not involve destructive testing or violate any laws. 11 | 5. **Response Time**: We will acknowledge receipt of your report within [e.g., 24 hours] and will keep you informed of our progress. 12 | 6. **Investigation and Remediation**: Our team will promptly investigate and work on resolving the issue. We will maintain communication with you throughout the process. 13 | 7. **Disclosure Policy**: Please refrain from public disclosure until we have mitigated the vulnerability. We will collaborate with you to determine an appropriate disclosure timeline based on the severity of the issue. 14 | 15 | We appreciate your contributions to the security of our project. Contributors who help improve our security may be publicly acknowledged (with consent). 16 | 17 | Note: Our security policy may be updated periodically. -------------------------------------------------------------------------------- /apps/nextjs/src/components/mode-toggle.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import * as React from "react"; 4 | import { useTheme } from "next-themes"; 5 | 6 | import { Button } from "@saasfly/ui/button"; 7 | import { 8 | DropdownMenu, 9 | DropdownMenuContent, 10 | DropdownMenuItem, 11 | DropdownMenuTrigger, 12 | } from "@saasfly/ui/dropdown-menu"; 13 | import * as Icons from "@saasfly/ui/icons"; 14 | 15 | export function ModeToggle() { 16 | const { setTheme } = useTheme(); 17 | 18 | return ( 19 | 20 | 21 | 26 | 27 | 28 | setTheme("light")}> 29 | 30 | Light 31 | 32 | setTheme("dark")}> 33 | 34 | Dark 35 | 36 | setTheme("system")}> 37 | 38 | System 39 | 40 | 41 | 42 | ); 43 | } 44 | -------------------------------------------------------------------------------- /apps/nextjs/src/content/docs/documentation/index.mdx: -------------------------------------------------------------------------------- 1 | --- 2 | title: Documentation 3 | description: Build your documentation site using Contentlayer and MDX. 4 | --- 5 | 6 | Taxonomy includes a documentation site built using [Contentlayer](https://contentlayer.dev) and [MDX](http://mdxjs.com). 7 | 8 | ## Features 9 | 10 | It comes with the following features out of the box: 11 | 12 | 1. Write content using MDX. 13 | 2. Transform and validate content using Contentlayer. 14 | 3. MDX components such as `` and ``. 15 | 4. Support for table of contents. 16 | 5. Custom navigation with prev and next pager. 17 | 6. Beautiful code blocks using `rehype-pretty-code`. 18 | 7. Syntax highlighting using `shiki`. 19 | 8. Built-in search (_in progress_). 20 | 9. Dark mode (_in progress_). 21 | 22 | ## How is it built 23 | 24 | Click on a section below to learn how the documentation site built. 25 | 26 |
27 | 28 | 29 | 30 | ### Contentlayer 31 | 32 | Learn how to use MDX with Contentlayer. 33 | 34 | 35 | 36 | 37 | 38 | ### Components 39 | 40 | Using React components in Mardown. 41 | 42 | 43 | 44 | 45 | 46 | ### Code Blocks 47 | 48 | Beautiful code blocks with syntax highlighting. 49 | 50 | 51 | 52 | 53 | 54 | ### Style Guide 55 | 56 | View a sample page with all the styles. 57 | 58 | 59 | 60 |
61 | -------------------------------------------------------------------------------- /packages/ui/src/avatar.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import * as React from "react"; 4 | import * as AvatarPrimitive from "@radix-ui/react-avatar"; 5 | 6 | import { cn } from "./utils/cn"; 7 | 8 | const Avatar = React.forwardRef< 9 | React.ElementRef, 10 | React.ComponentPropsWithoutRef 11 | >(({ className, ...props }, ref) => ( 12 | 20 | )); 21 | Avatar.displayName = AvatarPrimitive.Root.displayName; 22 | 23 | const AvatarImage = React.forwardRef< 24 | React.ElementRef, 25 | React.ComponentPropsWithoutRef 26 | >(({ className, ...props }, ref) => ( 27 | 32 | )); 33 | AvatarImage.displayName = AvatarPrimitive.Image.displayName; 34 | 35 | const AvatarFallback = React.forwardRef< 36 | React.ElementRef, 37 | React.ComponentPropsWithoutRef 38 | >(({ className, ...props }, ref) => ( 39 | 47 | )); 48 | AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName; 49 | 50 | export { Avatar, AvatarImage, AvatarFallback }; 51 | -------------------------------------------------------------------------------- /apps/nextjs/src/components/infiniteMovingCards.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import React from "react"; 4 | 5 | import { InfiniteMovingCards } from "@saasfly/ui/infinite-moving-cards"; 6 | 7 | export function InfiniteMovingCardss() { 8 | return ( 9 |
10 | 11 |
12 | ); 13 | } 14 | 15 | const reviews = [ 16 | { 17 | quote: 18 | "这款 SaaS 服务简直是办公利器!它的功能非常强大,界面也十分友好。自从使用它以后,我的工作效率提高了很多。我真的很庆幸选择了这个服务。", 19 | name: "王伟", 20 | title: "高级用户", 21 | }, 22 | { 23 | quote: 24 | "I've tried many SaaS services before, but this one really stands out. It offers a wide range of features and integrates seamlessly with other tools I use. The customer support is also top-notch. Highly recommended!", 25 | name: "John Smith", 26 | title: "Power User", 27 | }, 28 | { 29 | quote: 30 | "このSaaSサービスには本当に感謝しています。おかげで業務の効率が大幅に向上しました。機能が豊富で、使いやすいインターフェースも魅力的です。これからもずっと使い続けたいと思います。", 31 | name: "山田太郎", 32 | title: "ゴールドユーザー", 33 | }, 34 | { 35 | quote: 36 | "저는 이 SaaS 서비스에 매우 만족하고 있습니다. 기능이 다양하고 강력할 뿐만 아니라, 고객 지원도 훌륭합니다. 이 서비스 덕분에 업무 성과가 크게 향상되었어요. 강력히 추천합니다!", 37 | name: "김민수", 38 | title: "VIP 사용자", 39 | }, 40 | { 41 | quote: 42 | "This SaaS service has revolutionized the way our team works. It's feature-rich, user-friendly, and the pricing is quite competitive. We've seen a significant boost in our productivity since we started using it.", 43 | name: "Emily Johnson", 44 | title: "Verified Buyer", 45 | }, 46 | ]; 47 | -------------------------------------------------------------------------------- /apps/nextjs/src/components/k8s/cluster-item.tsx: -------------------------------------------------------------------------------- 1 | import Link from "next/link"; 2 | 3 | import { TableBody, TableCell, TableRow } from "@saasfly/ui/table"; 4 | 5 | import { ClusterOperations } from "~/components/k8s/cluster-operation"; 6 | import { formatDate } from "~/lib/utils"; 7 | import type { Cluster } from "~/types/k8s"; 8 | 9 | // import { ClusterOperations } from "~/components/k8s/cluster-operation"; 10 | // import { formatDate } from "~/lib/utils"; 11 | 12 | interface ClusterItemProps { 13 | cluster: Pick; 14 | } 15 | 16 | export function ClusterItem({ cluster }: ClusterItemProps) { 17 | return ( 18 | 19 | 20 | 21 | 25 | {cluster.name} 26 | 27 | 28 | {cluster.location} 29 | 30 | {formatDate(cluster.updatedAt?.toDateString())} 31 | 32 | {cluster.plan} 33 | RUNNING 34 | 35 | {/**/} 36 | 37 | 38 | 39 | 40 | ); 41 | } 42 | -------------------------------------------------------------------------------- /apps/nextjs/src/components/price/billing-form-button.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { useTransition } from "react"; 4 | 5 | import { Button } from "@saasfly/ui/button"; 6 | import * as Icons from "@saasfly/ui/icons"; 7 | 8 | import { trpc } from "~/trpc/client"; 9 | import type { SubscriptionPlan, UserSubscriptionPlan } from "~/types"; 10 | 11 | interface BillingFormButtonProps { 12 | offer: SubscriptionPlan; 13 | subscriptionPlan: UserSubscriptionPlan; 14 | year: boolean; 15 | dict: Record; 16 | } 17 | 18 | export function BillingFormButton({ 19 | year, 20 | offer, 21 | dict, 22 | subscriptionPlan, 23 | }: BillingFormButtonProps) { 24 | const [isPending, startTransition] = useTransition(); 25 | 26 | async function createSession(planId: string) { 27 | const res = await trpc.stripe.createSession.mutate({ planId: planId }); 28 | if (res?.url) window.location.href = res?.url; 29 | } 30 | 31 | const stripePlanId = year 32 | ? offer?.stripeIds?.yearly 33 | : offer?.stripeIds?.monthly; 34 | 35 | const stripeSessionAction = () => 36 | startTransition(async () => await createSession(stripePlanId!)); 37 | 38 | return ( 39 | 57 | ); 58 | } 59 | -------------------------------------------------------------------------------- /packages/stripe/src/env.mjs: -------------------------------------------------------------------------------- 1 | import { createEnv } from "@t3-oss/env-nextjs"; 2 | import * as z from "zod"; 3 | 4 | export const env = createEnv({ 5 | shared: {}, 6 | server: { 7 | STRIPE_API_KEY: z.string(), 8 | }, 9 | client: { 10 | NEXT_PUBLIC_STRIPE_PRO_PRODUCT_ID: z.string().optional(), 11 | NEXT_PUBLIC_STRIPE_PRO_MONTHLY_PRICE_ID: z.string().optional(), 12 | NEXT_PUBLIC_STRIPE_PRO_YEARLY_PRICE_ID: z.string().optional(), 13 | NEXT_PUBLIC_STRIPE_BUSINESS_PRODUCT_ID: z.string().optional(), 14 | NEXT_PUBLIC_STRIPE_BUSINESS_MONTHLY_PRICE_ID: z.string().optional(), 15 | NEXT_PUBLIC_STRIPE_BUSINESS_YEARLY_PRICE_ID: z.string().optional(), 16 | }, 17 | // Client side variables gets destructured here due to Next.js static analysis 18 | // Shared ones are also included here for good measure since the behavior has been inconsistent 19 | experimental__runtimeEnv: { 20 | NEXT_PUBLIC_STRIPE_PRO_PRODUCT_ID: 21 | process.env.NEXT_PUBLIC_STRIPE_PRO_PRODUCT_ID, 22 | NEXT_PUBLIC_STRIPE_PRO_MONTHLY_PRICE_ID: 23 | process.env.NEXT_PUBLIC_STRIPE_PRO_MONTHLY_PRICE_ID, 24 | NEXT_PUBLIC_STRIPE_PRO_YEARLY_PRICE_ID: 25 | process.env.NEXT_PUBLIC_STRIPE_PRO_YEARLY_PRICE_ID, 26 | NEXT_PUBLIC_STRIPE_BUSINESS_PRODUCT_ID: 27 | process.env.NEXT_PUBLIC_STRIPE_BUSINESS_PRODUCT_ID, 28 | NEXT_PUBLIC_STRIPE_BUSINESS_MONTHLY_PRICE_ID: 29 | process.env.NEXT_PUBLIC_STRIPE_BUSINESS_MONTHLY_PRICE_ID, 30 | NEXT_PUBLIC_STRIPE_BUSINESS_YEARLY_PRICE_ID: 31 | process.env.NEXT_PUBLIC_STRIPE_BUSINESS_YEARLY_PRICE_ID, 32 | }, 33 | skipValidation: 34 | !!process.env.SKIP_ENV_VALIDATION || 35 | process.env.npm_lifecycle_event === "lint", 36 | }); 37 | -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | # ----------------------------------------------------------------------------- 2 | # App 3 | # ----------------------------------------------------------------------------- 4 | NEXT_PUBLIC_APP_URL='http://localhost:3000' 5 | # ----------------------------------------------------------------------------- 6 | # Authentication (NextAuth.js) 7 | # openssl rand -base64 32 8 | # ----------------------------------------------------------------------------- 9 | NEXTAUTH_URL='http://localhost:3000' 10 | NEXTAUTH_SECRET='1' 11 | 12 | GITHUB_CLIENT_ID='1' 13 | GITHUB_CLIENT_SECRET='1' 14 | 15 | # ----------------------------------------------------------------------------- 16 | # Email (RESEND) 17 | # ----------------------------------------------------------------------------- 18 | RESEND_API_KEY='1' 19 | RESEND_FROM='1' 20 | 21 | # ----------------------------------------------------------------------------- 22 | # Subscriptions (Stripe) 23 | # ----------------------------------------------------------------------------- 24 | # Stripe 25 | STRIPE_API_KEY="1" 26 | STRIPE_WEBHOOK_SECRET="1" 27 | NEXT_PUBLIC_STRIPE_STD_PRODUCT_ID="prod_" 28 | NEXT_PUBLIC_STRIPE_STD_MONTHLY_PRICE_ID="price_" 29 | 30 | NEXT_PUBLIC_STRIPE_PRO_PRODUCT_ID="prod_" 31 | NEXT_PUBLIC_STRIPE_PRO_MONTHLY_PRICE_ID="price_" 32 | NEXT_PUBLIC_STRIPE_PRO_YEARLY_PRICE_ID="price_" 33 | NEXT_PUBLIC_STRIPE_BUSINESS_PRODUCT_ID="prod_" 34 | NEXT_PUBLIC_STRIPE_BUSINESS_MONTHLY_PRICE_ID="price_" 35 | NEXT_PUBLIC_STRIPE_BUSINESS_YEARLY_PRICE_ID="price_" 36 | 37 | # posthog 38 | NEXT_PUBLIC_POSTHOG_KEY=" " 39 | NEXT_PUBLIC_POSTHOG_HOST=https://app.posthog.com 40 | 41 | # admin account 42 | ADMIN_EMAIL="admin@saasfly.io,root@saasfly.io" 43 | 44 | # next auth debug 45 | IS_DEBUG=false -------------------------------------------------------------------------------- /apps/nextjs/src/components/modal.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { Drawer } from "vaul"; 4 | 5 | import { cn } from "@saasfly/ui"; 6 | import { Dialog, DialogContent, DialogTitle } from "@saasfly/ui/dialog"; 7 | 8 | import useMediaQuery from "~/hooks/use-media-query"; 9 | 10 | interface ModalProps { 11 | children: React.ReactNode; 12 | className?: string; 13 | showModal: boolean; 14 | setShowModal: () => void; 15 | } 16 | 17 | export function Modal({ 18 | children, 19 | className, 20 | showModal, 21 | setShowModal, 22 | }: ModalProps) { 23 | const { isMobile } = useMediaQuery(); 24 | 25 | if (isMobile) { 26 | return ( 27 | 28 | 29 | 30 | 36 |
37 |
38 |
39 | {children} 40 | 41 | 42 | 43 | 44 | ); 45 | } 46 | return ( 47 | 48 | 49 | 50 | {children} 51 | 52 | 53 | ); 54 | } 55 | -------------------------------------------------------------------------------- /apps/nextjs/src/components/nav.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import Link from "next/link"; 4 | import { usePathname } from "next/navigation"; 5 | 6 | import { cn } from "@saasfly/ui"; 7 | import * as Icons from "@saasfly/ui/icons"; 8 | 9 | import type { SidebarNavItem } from "~/types"; 10 | 11 | interface DashboardNavProps { 12 | items: SidebarNavItem[]; 13 | params: { 14 | lang: string; 15 | }; 16 | } 17 | 18 | const iconMapObj = new Map([ 19 | ["clusters", Icons.Cluster], 20 | ["billing", Icons.Billing], 21 | ["settings", Icons.Settings], 22 | ]); 23 | 24 | export function DashboardNav({ items, params: { lang } }: DashboardNavProps) { 25 | const path = usePathname(); 26 | 27 | if (!items?.length) { 28 | return null; 29 | } 30 | 31 | return ( 32 | 57 | ); 58 | } 59 | -------------------------------------------------------------------------------- /turbo/generators/config.ts: -------------------------------------------------------------------------------- 1 | import { execSync } from "node:child_process"; 2 | import type { PlopTypes } from "@turbo/gen"; 3 | const rootPath = process.cwd(); 4 | export default function generator(plop: PlopTypes.NodePlopAPI): void { 5 | plop.setGenerator("init", { 6 | description: "Generate a new package for the Monorepo", 7 | prompts: [ 8 | { 9 | type: "input", 10 | name: "name", 11 | message: 12 | "What is the name of the package? ", 13 | } 14 | ], 15 | actions: [ 16 | (answers) => { 17 | if ("name" in answers && typeof answers.name === "string") { 18 | if (answers.name.startsWith("@saasfly/")) { 19 | answers.name = answers.name.replace("@saasfly/", ""); 20 | } 21 | } 22 | return "Config sanitized"; 23 | }, 24 | { 25 | type: "add", 26 | path: rootPath+"/packages/{{ name }}/package.json", 27 | templateFile: "templates/package.json.hbs", 28 | }, 29 | { 30 | type: "add", 31 | path: rootPath+"/packages/{{ name }}/tsconfig.json", 32 | templateFile: "templates/tsconfig.json.hbs", 33 | }, 34 | { 35 | type: "add", 36 | path: rootPath+"/packages/{{ name }}/index.ts", 37 | template: "export * from './src';", 38 | }, 39 | { 40 | type: "add", 41 | path: rootPath+"/packages/{{ name }}/src/index.ts", 42 | template: "export const name = '{{ name }}';", 43 | } 44 | ], 45 | }); 46 | } 47 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | pull_request: 5 | branches: [ "*" ] 6 | push: 7 | branches: [ "main" ] 8 | merge_group: 9 | 10 | # You can leverage Vercel Remote Caching with Turbo to speed up your builds 11 | # @link https://turborepo.org/docs/core-concepts/remote-caching#remote-caching-on-vercel-builds 12 | # env: 13 | # TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }} 14 | # TURBO_TEAM: ${{ secrets.TURBO_TEAM }} 15 | 16 | jobs: 17 | build-lint: 18 | runs-on: ubuntu-latest 19 | 20 | services: 21 | postgres: 22 | image: postgres:16.1 23 | env: 24 | POSTGRES_USER: default 25 | POSTGRES_PASSWORD: default 26 | POSTGRES_DB: verceldb 27 | ports: 28 | - 5432:5432 29 | options: >- 30 | --health-cmd pg_isready 31 | --health-interval 10s 32 | --health-timeout 5s 33 | --health-retries 5 34 | 35 | steps: 36 | - name: Checkout repo 37 | uses: actions/checkout@v4 38 | 39 | - name: Copy env 40 | shell: bash 41 | run: cp .env.example .env.local 42 | 43 | - name: Setup bun 44 | uses: oven-sh/setup-bun@v1 45 | 46 | - name: Install lib 47 | run: bun i 48 | 49 | - name: Build 50 | run: bun run build 51 | env: 52 | # The hostname used to communicate with the PostgreSQL service container 53 | POSTGRES_HOST: postgres 54 | # The default PostgreSQL port 55 | POSTGRES_PORT: 5432 56 | POSTGRES_USER: default 57 | POSTGRES_PASSWORD: default 58 | POSTGRES_DB: verceldb 59 | POSTGRES_URL: postgres://default:default@localhost:5432/verceldb 60 | 61 | 62 | - name: lint and type-check 63 | run: bun run build lint format typecheck -------------------------------------------------------------------------------- /apps/nextjs/src/components/mobile-nav.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import Link from "next/link"; 3 | 4 | import { cn } from "@saasfly/ui"; 5 | import * as Icons from "@saasfly/ui/icons"; 6 | 7 | import { siteConfig } from "~/config/site"; 8 | import { useLockBody } from "~/hooks/use-lock-body"; 9 | import type { MainNavItem } from "~/types"; 10 | 11 | interface MobileNavProps { 12 | items: MainNavItem[]; 13 | children?: React.ReactNode; 14 | menuItemClick?: () => void; 15 | } 16 | 17 | export function MobileNav({ items, children, menuItemClick }: MobileNavProps) { 18 | useLockBody(); 19 | return ( 20 |
25 |
26 | 27 | 28 | {siteConfig.name} 29 | 30 | 45 | {children} 46 |
47 |
48 | ); 49 | } 50 | -------------------------------------------------------------------------------- /packages/common/src/env.mjs: -------------------------------------------------------------------------------- 1 | import { createEnv } from "@t3-oss/env-nextjs"; 2 | import * as z from "zod"; 3 | 4 | export const env = createEnv({ 5 | shared: { 6 | NEXT_PUBLIC_STRIPE_PRO_PRODUCT_ID: z.string().optional(), 7 | NEXT_PUBLIC_STRIPE_PRO_MONTHLY_PRICE_ID: z.string().optional(), 8 | NEXT_PUBLIC_STRIPE_PRO_YEARLY_PRICE_ID: z.string().optional(), 9 | NEXT_PUBLIC_STRIPE_BUSINESS_PRODUCT_ID: z.string().optional(), 10 | NEXT_PUBLIC_STRIPE_BUSINESS_MONTHLY_PRICE_ID: z.string().optional(), 11 | NEXT_PUBLIC_STRIPE_BUSINESS_YEARLY_PRICE_ID: z.string().optional(), 12 | }, 13 | server: { 14 | NEXTAUTH_SECRET: z.string().min(1), 15 | RESEND_API_KEY: z.string().optional(), 16 | }, 17 | // Client side variables gets destructured here due to Next.js static analysis 18 | // Shared ones are also included here for good measure since the behavior has been inconsistent 19 | runtimeEnv: { 20 | NEXTAUTH_SECRET: process.env.NEXTAUTH_SECRET, 21 | RESEND_API_KEY: process.env.RESEND_API_KEY, 22 | NEXT_PUBLIC_STRIPE_PRO_PRODUCT_ID: 23 | process.env.NEXT_PUBLIC_STRIPE_PRO_PRODUCT_ID, 24 | NEXT_PUBLIC_STRIPE_PRO_MONTHLY_PRICE_ID: 25 | process.env.NEXT_PUBLIC_STRIPE_PRO_MONTHLY_PRICE_ID, 26 | NEXT_PUBLIC_STRIPE_PRO_YEARLY_PRICE_ID: 27 | process.env.NEXT_PUBLIC_STRIPE_PRO_YEARLY_PRICE_ID, 28 | NEXT_PUBLIC_STRIPE_BUSINESS_PRODUCT_ID: 29 | process.env.NEXT_PUBLIC_STRIPE_BUSINESS_PRODUCT_ID, 30 | NEXT_PUBLIC_STRIPE_BUSINESS_MONTHLY_PRICE_ID: 31 | process.env.NEXT_PUBLIC_STRIPE_BUSINESS_MONTHLY_PRICE_ID, 32 | NEXT_PUBLIC_STRIPE_BUSINESS_YEARLY_PRICE_ID: 33 | process.env.NEXT_PUBLIC_STRIPE_BUSINESS_YEARLY_PRICE_ID, 34 | }, 35 | skipValidation: 36 | !!process.env.SKIP_ENV_VALIDATION || 37 | process.env.npm_lifecycle_event === "lint", 38 | }); 39 | -------------------------------------------------------------------------------- /apps/nextjs/src/lib/toc.ts: -------------------------------------------------------------------------------- 1 | // @ts-nocheck 2 | import { toc } from "mdast-util-toc"; 3 | import { remark } from "remark"; 4 | import { visit } from "unist-util-visit"; 5 | 6 | const textTypes = ["text", "emphasis", "strong", "inlineCode"]; 7 | 8 | function flattenNode(node) { 9 | const p = []; 10 | visit(node, (node) => { 11 | if (!textTypes.includes(node.type)) return; 12 | p.push(node.value); 13 | }); 14 | return p.join(``); 15 | } 16 | 17 | interface Item { 18 | title: string; 19 | url: string; 20 | items?: Item[]; 21 | } 22 | 23 | interface Items { 24 | items?: Item[]; 25 | } 26 | 27 | function getItems(node, current): Items { 28 | if (!node) { 29 | return {}; 30 | } 31 | 32 | if (node.type === "paragraph") { 33 | visit(node, (item) => { 34 | if (item.type === "link") { 35 | current.url = item.url; 36 | current.title = flattenNode(node); 37 | } 38 | 39 | if (item.type === "text") { 40 | current.title = flattenNode(node); 41 | } 42 | }); 43 | 44 | return current; 45 | } 46 | 47 | if (node.type === "list") { 48 | current.items = node.children.map((i) => getItems(i, {})); 49 | 50 | return current; 51 | } else if (node.type === "listItem") { 52 | const heading = getItems(node.children[0], {}); 53 | 54 | if (node.children.length > 1) { 55 | getItems(node.children[1], heading); 56 | } 57 | 58 | return heading; 59 | } 60 | 61 | return {}; 62 | } 63 | 64 | const getToc = () => (node, file) => { 65 | const table = toc(node); 66 | file.data = getItems(table.map, {}); 67 | }; 68 | 69 | export type TableOfContents = Items; 70 | 71 | export async function getTableOfContents( 72 | content: string, 73 | ): Promise { 74 | const result = await remark().use(getToc).process(content); 75 | 76 | return result.data; 77 | } 78 | -------------------------------------------------------------------------------- /packages/ui/src/scroll-area.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import * as React from "react"; 4 | import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area"; 5 | 6 | import { cn } from "./utils/cn"; 7 | 8 | const ScrollArea = React.forwardRef< 9 | React.ElementRef, 10 | React.ComponentPropsWithoutRef 11 | >(({ className, children, ...props }, ref) => ( 12 | 17 | 18 | {children} 19 | 20 | 21 | 22 | 23 | )); 24 | ScrollArea.displayName = ScrollAreaPrimitive.Root.displayName; 25 | 26 | const ScrollBar = React.forwardRef< 27 | React.ElementRef, 28 | React.ComponentPropsWithoutRef 29 | >(({ className, orientation = "vertical", ...props }, ref) => ( 30 | 43 | 44 | 45 | )); 46 | ScrollBar.displayName = ScrollAreaPrimitive.ScrollAreaScrollbar.displayName; 47 | 48 | export { ScrollArea, ScrollBar }; 49 | -------------------------------------------------------------------------------- /apps/nextjs/src/types/index.d.ts: -------------------------------------------------------------------------------- 1 | import type * as Lucide from "lucide-react"; 2 | 3 | import type { Customer } from "@saasfly/db"; 4 | 5 | export interface NavItem { 6 | title: string; 7 | href: string; 8 | disabled?: boolean; 9 | } 10 | 11 | export type MainNavItem = NavItem; 12 | 13 | export interface DocsConfig { 14 | mainNav: MainNavItem[]; 15 | sidebarNav: SidebarNavItem[]; 16 | } 17 | 18 | export type SidebarNavItem = { 19 | id: string; 20 | title: string; 21 | disabled?: boolean; 22 | external?: boolean; 23 | icon?: Lucide.LucideIcon; 24 | } & ( 25 | | { 26 | href: string; 27 | items?: never; 28 | } 29 | | { 30 | href?: string; 31 | items: NavLink[]; 32 | } 33 | ); 34 | 35 | export interface SiteConfig { 36 | name: string; 37 | description: string; 38 | url: string; 39 | ogImage: string; 40 | links: { 41 | github: string; 42 | }; 43 | } 44 | 45 | export interface DocsConfig { 46 | mainNav: MainNavItem[]; 47 | sidebarNav: SidebarNavItem[]; 48 | } 49 | 50 | export interface MarketingConfig { 51 | mainNav: MainNavItem[]; 52 | } 53 | 54 | export interface DashboardConfig { 55 | mainNav: MainNavItem[]; 56 | sidebarNav: SidebarNavItem[]; 57 | } 58 | 59 | export interface SubscriptionPlan { 60 | title?: string; 61 | description?: string; 62 | benefits?: string[]; 63 | limitations?: string[]; 64 | prices?: { 65 | monthly: number; 66 | yearly: number; 67 | }; 68 | stripeIds?: { 69 | monthly: string | null; 70 | yearly: string | null; 71 | }; 72 | } 73 | 74 | export type UserSubscriptionPlan = SubscriptionPlan & 75 | Pick< 76 | Customer, 77 | "stripeCustomerId" | "stripeSubscriptionId" | "stripePriceId" 78 | > & { 79 | stripeCurrentPeriodEnd: number; 80 | isPaid: boolean | "" | null; 81 | interval: string | null; 82 | isCanceled?: boolean; 83 | }; 84 | -------------------------------------------------------------------------------- /packages/ui/src/button.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { cva, type VariantProps } from "class-variance-authority"; 3 | 4 | import { cn } from "@saasfly/ui"; 5 | 6 | const buttonVariants = cva( 7 | "inline-flex items-center justify-center rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:opacity-50 disabled:pointer-events-none ring-offset-background", 8 | { 9 | variants: { 10 | variant: { 11 | default: "bg-primary text-primary-foreground hover:bg-primary/90", 12 | destructive: 13 | "bg-destructive text-destructive-foreground hover:bg-destructive/90", 14 | outline: 15 | "border border-input hover:bg-accent hover:text-accent-foreground", 16 | secondary: 17 | "bg-secondary text-secondary-foreground hover:bg-secondary/80", 18 | ghost: "hover:bg-accent hover:text-accent-foreground", 19 | link: "underline-offset-4 hover:underline text-primary", 20 | }, 21 | size: { 22 | default: "h-10 py-2 px-4", 23 | sm: "h-9 px-3 rounded-md", 24 | lg: "h-11 px-8 rounded-md", 25 | }, 26 | }, 27 | defaultVariants: { 28 | variant: "default", 29 | size: "default", 30 | }, 31 | }, 32 | ); 33 | 34 | export interface ButtonProps 35 | extends React.ButtonHTMLAttributes, 36 | VariantProps {} 37 | 38 | const Button = React.forwardRef( 39 | ({ className, variant, size, ...props }, ref) => { 40 | return ( 41 | 39 | 40 | 41 | setTheme("light")}> 42 | 43 | Light 44 | 45 | setTheme("dark")}> 46 | 47 | Dark 48 | 49 | setTheme("system")}> 50 | 51 | System 52 | 53 | 54 | 55 | ); 56 | } 57 | -------------------------------------------------------------------------------- /apps/nextjs/src/components/sparkles.tsx: -------------------------------------------------------------------------------- 1 | // "use client"; 2 | 3 | import React from "react"; 4 | 5 | // import { useTheme } from "next-themes"; 6 | 7 | // import { SparklesCore } from "@saasfly/ui/sparkles"; 8 | 9 | export function Sparkless() { 10 | // const { theme } = useTheme(); 11 | // let color = "#FFFFFF"; 12 | // if (theme == "light") { 13 | // color = "#000000"; 14 | // } 15 | return ( 16 |
17 |

18 | Saasfly: A new SaaS player? 19 |

20 | {/*
*/} 21 | {/* /!* Gradients *!/*/} 22 | {/*
*/} 23 | {/*
*/} 24 | {/*
*/} 25 | {/*
*/} 26 | 27 | {/* /!* Core component *!/*/} 28 | {/* */} 36 | 37 | {/* /!* Radial Gradient to prevent sharp edges *!/*/} 38 | {/*
*/} 39 | {/*
*/} 40 |
41 | ); 42 | } 43 | -------------------------------------------------------------------------------- /packages/ui/src/animated-tooltip.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import React, { useState } from "react"; 4 | import Image from "next/image"; 5 | import { motion } from "framer-motion"; 6 | 7 | export const AnimatedTooltip = ({ 8 | items, 9 | }: { 10 | items: { 11 | id: number; 12 | name: string; 13 | designation: string; 14 | image: string; 15 | }[]; 16 | }) => { 17 | const [hoveredIndex, setHoveredIndex] = useState(null); 18 | const springConfig = { stiffness: 100, damping: 5 }; 19 | 20 | return ( 21 | <> 22 | {items.map((item, index) => ( 23 |
setHoveredIndex(index)} 27 | onMouseLeave={() => setHoveredIndex(null)} 28 | > 29 | {hoveredIndex === index && ( 30 | 41 |
{item.name}
42 |
{item.designation}
43 |
44 | )} 45 | {item.name} 52 |
53 | ))} 54 | 55 | ); 56 | }; 57 | -------------------------------------------------------------------------------- /apps/nextjs/src/components/docs/pager.tsx: -------------------------------------------------------------------------------- 1 | import Link from "next/link"; 2 | import { Doc } from "contentlayer/generated"; 3 | 4 | import { cn } from "@saasfly/ui"; 5 | import { buttonVariants } from "@saasfly/ui/button"; 6 | import * as Icons from "@saasfly/ui/icons"; 7 | 8 | import { getDocsConfig } from "~/config/ui/docs"; 9 | 10 | interface DocsPagerProps { 11 | doc: Doc; 12 | } 13 | 14 | export function DocsPager({ doc }: DocsPagerProps) { 15 | const pager = getPagerForDoc(doc); 16 | 17 | if (!pager) { 18 | return null; 19 | } 20 | 21 | return ( 22 |
23 | {pager?.prev && ( 24 | 28 | 29 | {pager.prev.title} 30 | 31 | )} 32 | {pager?.next && ( 33 | 37 | {pager.next.title} 38 | 39 | 40 | )} 41 |
42 | ); 43 | } 44 | 45 | export function getPagerForDoc(doc: Doc) { 46 | const flattenedLinks = [ 47 | null, 48 | ...flatten(getDocsConfig("en").sidebarNav), 49 | null, 50 | ]; 51 | const activeIndex = flattenedLinks.findIndex( 52 | (link) => doc.slug === link?.href, 53 | ); 54 | const prev = activeIndex !== 0 ? flattenedLinks[activeIndex - 1] : null; 55 | const next = 56 | activeIndex !== flattenedLinks.length - 1 57 | ? flattenedLinks[activeIndex + 1] 58 | : null; 59 | return { 60 | prev, 61 | next, 62 | }; 63 | } 64 | 65 | // @ts-ignore 66 | export function flatten( 67 | links: { 68 | items?: { items?: any }[]; 69 | }[], 70 | ) { 71 | return links.reduce((flat, link) => { 72 | return flat.concat(link.items ? flatten(link.items) : link); 73 | }, []); 74 | } 75 | -------------------------------------------------------------------------------- /apps/nextjs/src/styles/globals.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | @layer base { 6 | :root { 7 | --background: 0deg 0% 100%; 8 | --foreground: 222.2deg 47.4% 11.2%; 9 | 10 | --muted: 210deg 40% 96.1%; 11 | --muted-foreground: 215.4deg 16.3% 46.9%; 12 | 13 | --popover: 0deg 0% 100%; 14 | --popover-foreground: 222.2deg 47.4% 11.2%; 15 | 16 | --border: 214.3deg 31.8% 91.4%; 17 | --input: 214.3deg 31.8% 91.4%; 18 | 19 | --card: 0deg 0% 100%; 20 | --card-foreground: 222.2deg 47.4% 11.2%; 21 | 22 | --primary: 222.2deg 47.4% 11.2%; 23 | --primary-foreground: 210deg 40% 98%; 24 | 25 | --secondary: 210deg 40% 96.1%; 26 | --secondary-foreground: 222.2deg 47.4% 11.2%; 27 | 28 | --accent: 210deg 40% 96.1%; 29 | --accent-foreground: 222.2deg 47.4% 11.2%; 30 | 31 | --destructive: 0deg 100% 50%; 32 | --destructive-foreground: 210deg 40% 98%; 33 | 34 | --ring: 215deg 20.2% 65.1%; 35 | 36 | --radius: 0.5rem; 37 | } 38 | 39 | .dark { 40 | --background: 0 0 0; 41 | /* --background: 224 71% 4%; */ 42 | --foreground: 213 31% 91%; 43 | 44 | --muted: 223 47% 11%; 45 | --muted-foreground: 215.4 16.3% 56.9%; 46 | 47 | --accent: 216 34% 17%; 48 | --accent-foreground: 210 40% 98%; 49 | 50 | --popover: 224 71% 4%; 51 | --popover-foreground: 215 20.2% 65.1%; 52 | 53 | --border: 0, 0%, 100%, .1; 54 | --input: 0, 0%, 100%, .1; 55 | 56 | --card: 224 71% 4%; 57 | --card-foreground: 213 31% 91%; 58 | 59 | --primary: 210 40% 98%; 60 | --primary-foreground: 222.2 47.4% 1.2%; 61 | 62 | --secondary: 222.2 47.4% 11.2%; 63 | --secondary-foreground: 210 40% 98%; 64 | 65 | --destructive: 0 63% 31%; 66 | --destructive-foreground: 210 40% 98%; 67 | 68 | --ring: 216 34% 17%; 69 | 70 | --radius: 0.5rem; 71 | } 72 | } 73 | 74 | @layer base { 75 | body { 76 | @apply bg-background text-foreground; 77 | font-feature-settings: 78 | "rlig" 1, 79 | "calt" 1; 80 | } 81 | 82 | .container { 83 | @apply max-sm:px-4; 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /packages/ui/src/tabs.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import * as React from "react"; 4 | import * as TabsPrimitive from "@radix-ui/react-tabs"; 5 | 6 | import { cn } from "./utils/cn"; 7 | 8 | const Tabs = TabsPrimitive.Root; 9 | 10 | const TabsList = React.forwardRef< 11 | React.ElementRef, 12 | React.ComponentPropsWithoutRef 13 | >(({ className, ...props }, ref) => ( 14 | 22 | )); 23 | TabsList.displayName = TabsPrimitive.List.displayName; 24 | 25 | const TabsTrigger = React.forwardRef< 26 | React.ElementRef, 27 | React.ComponentPropsWithoutRef 28 | >(({ className, ...props }, ref) => ( 29 | 37 | )); 38 | TabsTrigger.displayName = TabsPrimitive.Trigger.displayName; 39 | 40 | const TabsContent = React.forwardRef< 41 | React.ElementRef, 42 | React.ComponentPropsWithoutRef 43 | >(({ className, ...props }, ref) => ( 44 | 52 | )); 53 | TabsContent.displayName = TabsPrimitive.Content.displayName; 54 | 55 | export { Tabs, TabsList, TabsTrigger, TabsContent }; 56 | -------------------------------------------------------------------------------- /apps/nextjs/src/components/docs/sidebar-nav.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import Link from "next/link"; 4 | import { usePathname } from "next/navigation"; 5 | 6 | import { cn } from "@saasfly/ui"; 7 | 8 | import type { SidebarNavItem } from "~/types"; 9 | 10 | export interface DocsSidebarNavProps { 11 | items: SidebarNavItem[]; 12 | } 13 | 14 | export function DocsSidebarNav({ items }: DocsSidebarNavProps) { 15 | const pathname = usePathname(); 16 | 17 | return items.length ? ( 18 |
19 | {items.map((item) => ( 20 |
21 |

22 | {item.title} 23 |

24 | {item.items ? ( 25 | 26 | ) : null} 27 |
28 | ))} 29 |
30 | ) : null; 31 | } 32 | 33 | interface DocsSidebarNavItemsProps { 34 | items: SidebarNavItem[]; 35 | pathname: string | null; 36 | } 37 | 38 | export function DocsSidebarNavItems({ 39 | items, 40 | pathname, 41 | }: DocsSidebarNavItemsProps) { 42 | return items?.length ? ( 43 |
44 | {items.map((item) => 45 | !item.disabled && item.href ? ( 46 | 58 | {item.title} 59 | 60 | ) : ( 61 | 65 | {item.title} 66 | 67 | ), 68 | )} 69 |
70 | ) : null; 71 | } 72 | -------------------------------------------------------------------------------- /packages/ui/src/text-reveal.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { FC, ReactNode, useRef } from "react"; 4 | import { motion, useScroll, useTransform } from "framer-motion"; 5 | 6 | import { cn } from "./utils/cn"; 7 | 8 | interface TextRevealByWordProps { 9 | text: string; 10 | className?: string; 11 | } 12 | 13 | export const TextRevealByWord: FC = ({ 14 | text, 15 | className, 16 | }) => { 17 | const targetRef = useRef(null); 18 | 19 | const { scrollYProgress } = useScroll({ 20 | target: targetRef, 21 | }); 22 | const words = text.split(" "); 23 | 24 | return ( 25 |
26 |
31 |

37 | {words.map((word, i) => { 38 | const start = i / words.length; 39 | const end = start + 1 / words.length; 40 | return ( 41 | 42 | {word} 43 | 44 | ); 45 | })} 46 |

47 |
48 |
49 | ); 50 | }; 51 | 52 | interface WordProps { 53 | children: ReactNode; 54 | progress: any; 55 | range: [number, number]; 56 | } 57 | 58 | const Word: FC = ({ children, progress, range }) => { 59 | const opacity = useTransform(progress, range, [0, 1]); 60 | return ( 61 | 62 | {children} 63 | 67 | {children} 68 | 69 | 70 | ); 71 | }; 72 | 73 | export default TextRevealByWord; 74 | -------------------------------------------------------------------------------- /packages/db/prisma/types.ts: -------------------------------------------------------------------------------- 1 | import type { ColumnType } from "kysely"; 2 | 3 | import type { Status, SubscriptionPlan } from "./enums"; 4 | 5 | export type Generated = 6 | T extends ColumnType 7 | ? ColumnType 8 | : ColumnType; 9 | export type Timestamp = ColumnType; 10 | 11 | export type Account = { 12 | id: Generated; 13 | userId: string; 14 | type: string; 15 | provider: string; 16 | providerAccountId: string; 17 | refresh_token: string | null; 18 | access_token: string | null; 19 | expires_at: number | null; 20 | token_type: string | null; 21 | scope: string | null; 22 | id_token: string | null; 23 | session_state: string | null; 24 | }; 25 | export type Customer = { 26 | id: Generated; 27 | authUserId: string; 28 | name: string | null; 29 | plan: SubscriptionPlan | null; 30 | stripeCustomerId: string | null; 31 | stripeSubscriptionId: string | null; 32 | stripePriceId: string | null; 33 | stripeCurrentPeriodEnd: Timestamp | null; 34 | createdAt: Generated; 35 | updatedAt: Generated; 36 | }; 37 | export type K8sClusterConfig = { 38 | id: Generated; 39 | name: string; 40 | location: string; 41 | authUserId: string; 42 | plan: Generated; 43 | network: string | null; 44 | createdAt: Generated; 45 | updatedAt: Generated; 46 | status: Generated; 47 | delete: Generated; 48 | }; 49 | export type Session = { 50 | id: Generated; 51 | sessionToken: string; 52 | userId: string; 53 | expires: Timestamp; 54 | }; 55 | export type User = { 56 | id: Generated; 57 | name: string | null; 58 | email: string | null; 59 | emailVerified: Timestamp | null; 60 | image: string | null; 61 | }; 62 | export type VerificationToken = { 63 | identifier: string; 64 | token: string; 65 | expires: Timestamp; 66 | }; 67 | export type DB = { 68 | Account: Account; 69 | Customer: Customer; 70 | K8sClusterConfig: K8sClusterConfig; 71 | Session: Session; 72 | User: User; 73 | VerificationToken: VerificationToken; 74 | }; 75 | -------------------------------------------------------------------------------- /packages/common/src/emails/magic-link-email.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | Body, 3 | Button, 4 | Container, 5 | Head, 6 | Hr, 7 | Html, 8 | Preview, 9 | Section, 10 | Tailwind, 11 | Text, 12 | } from "@react-email/components"; 13 | 14 | import * as Icons from "@saasfly/ui/icons"; 15 | 16 | interface MagicLinkEmailProps { 17 | actionUrl: string; 18 | firstName: string; 19 | mailType: "login" | "register"; 20 | siteName: string; 21 | } 22 | 23 | export const MagicLinkEmail = ({ 24 | firstName = "", 25 | actionUrl, 26 | mailType, 27 | siteName, 28 | }: MagicLinkEmailProps) => ( 29 | 30 | 31 | 32 | Click to {mailType === "login" ? "sign in" : "activate"} your {siteName}{" "} 33 | account. 34 | 35 | 36 | 37 | 38 | 39 | Hi {firstName}, 40 | 41 | Welcome to {siteName} ! Click the link below to{" "} 42 | {mailType === "login" ? "sign in to" : "activate"} your account. 43 | 44 |
45 | 51 |
52 | 53 | This link expires in 24 hours and can only be used once. 54 | 55 | {mailType === "login" ? ( 56 | 57 | If you did not try to log into your account, you can safely ignore 58 | it. 59 | 60 | ) : null} 61 |
62 | saasfly.io 63 |
64 | 65 |
66 | 67 | ); 68 | 69 | export default MagicLinkEmail; 70 | -------------------------------------------------------------------------------- /apps/nextjs/src/components/empty-placeholder.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | 3 | import { cn } from "@saasfly/ui"; 4 | import * as Icons from "@saasfly/ui/icons"; 5 | 6 | type EmptyPlaceholderProps = React.HTMLAttributes; 7 | 8 | export function EmptyPlaceholder({ 9 | className, 10 | children, 11 | ...props 12 | }: EmptyPlaceholderProps) { 13 | return ( 14 |
21 |
22 | {children} 23 |
24 |
25 | ); 26 | } 27 | 28 | interface EmptyPlaceholderIconProps 29 | extends Partial> { 30 | name: keyof typeof Icons; 31 | } 32 | 33 | EmptyPlaceholder.Icon = function EmptyPlaceHolderIcon({ 34 | name, 35 | className, // ...props 36 | }: EmptyPlaceholderIconProps) { 37 | const Icon = Icons[name]; 38 | 39 | if (!Icon) { 40 | return null; 41 | } 42 | 43 | return ( 44 |
45 | 46 |
47 | ); 48 | }; 49 | 50 | type EmptyPlacholderTitleProps = React.HTMLAttributes; 51 | 52 | EmptyPlaceholder.Title = function EmptyPlaceholderTitle({ 53 | className, 54 | ...props 55 | }: EmptyPlacholderTitleProps) { 56 | return ( 57 | // eslint-disable-next-line jsx-a11y/heading-has-content 58 |

59 | ); 60 | }; 61 | 62 | type EmptyPlacholderDescriptionProps = 63 | React.HTMLAttributes; 64 | 65 | EmptyPlaceholder.Description = function EmptyPlaceholderDescription({ 66 | className, 67 | ...props 68 | }: EmptyPlacholderDescriptionProps) { 69 | return ( 70 |

77 | ); 78 | }; 79 | -------------------------------------------------------------------------------- /apps/nextjs/src/components/shimmer-button.tsx: -------------------------------------------------------------------------------- 1 | import React, { type CSSProperties } from "react"; 2 | 3 | import { cn } from "@saasfly/ui"; 4 | 5 | interface ShimmerButtonProps { 6 | shimmerColor?: string; 7 | shimmerSize?: string; 8 | borderRadius?: string; 9 | shimmerDuration?: string; 10 | background?: string; 11 | className?: string; 12 | children?: React.ReactNode; 13 | } 14 | 15 | const ShimmerButton = ({ 16 | shimmerColor = "#ffffff", 17 | shimmerSize = "0.1em", 18 | shimmerDuration = "1.5s", 19 | borderRadius = "100px", 20 | background = "radial-gradient(ellipse 80% 50% at 50% 120%,rgba(62, 61, 117),rgba(18, 18, 38))", 21 | className, 22 | children, 23 | ...props 24 | }: ShimmerButtonProps) => { 25 | return ( 26 | 57 | ); 58 | }; 59 | 60 | export default ShimmerButton; 61 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | ## Can I create a pull request for saasfly? 2 | 3 | Yes or no, it depends on what you will try to do. Since I don't want to waste your time, be sure to **create an empty draft pull request or open an issue, so we can have a discussion first**. Especially for a large pull request or you don't know if it will be merged or not. 4 | 5 | Here are some references: 6 | 7 | ### ✅ Usually accepted 8 | 9 | - Bug fix 10 | - Security fix 11 | - Adding notification providers 12 | - Adding new language keys 13 | 14 | ### ⚠️ Discussion required 15 | 16 | - Large pull requests 17 | - New features 18 | 19 | ### ❌ Won't be merged 20 | 21 | - Do not pass the auto-test(we dont have auto-test now) 22 | - Any breaking changes 23 | - Duplicated pull requests 24 | - Buggy 25 | - UI/UX is not close to saasfly 26 | - Modifications or deletions of existing logic without a valid reason. 27 | - Adding functions that is completely out of scope 28 | - Converting existing code into other programming languages 29 | - Unnecessarily large code changes that are hard to review and cause conflicts with other PRs. 30 | 31 | The above cases may not cover all possible situations. 32 | 33 | If your pull request does not meet my expectations, I will reject it, no matter how much time you spent on it. Therefore, it is essential to have a discussion beforehand. 34 | 35 | I will assign your pull request to a [milestone](https://github.com/saasfly/saasfly/milestones), if I plan to review and merge it. 36 | 37 | Also, please don't rush or ask for an ETA, because I have to understand the pull request, make sure it is no breaking changes and stick to my vision of this project, especially for large pull requests. 38 | 39 | ### Recommended Pull Request Guideline 40 | 41 | Before deep into coding, discussion first is preferred. Creating an empty pull request for discussion would be recommended. 42 | 43 | 1. Fork the project 44 | 2. Clone your fork repo to local 45 | 3. Create a new branch 46 | 4. Create an empty commit: `git commit -m "" --allow-empty` 47 | 5. Push to your fork repo 48 | 6. Prepare a pull request: https://github.com/saasfly/saasfly/compare 49 | 7. Write a proper description. You can mention @tianzx in it, so @tianzx will get the notification. 50 | 8. Create your pull request as a Draft 51 | 9. Wait for the discussion -------------------------------------------------------------------------------- /packages/ui/src/accordion.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import * as React from "react"; 4 | import * as AccordionPrimitive from "@radix-ui/react-accordion"; 5 | import { ChevronDown } from "lucide-react"; 6 | 7 | import { cn } from "@saasfly/ui"; 8 | 9 | const Accordion = AccordionPrimitive.Root; 10 | 11 | const AccordionItem = React.forwardRef< 12 | React.ElementRef, 13 | React.ComponentPropsWithoutRef 14 | >(({ className, ...props }, ref) => ( 15 | 20 | )); 21 | AccordionItem.displayName = "AccordionItem"; 22 | 23 | const AccordionTrigger = React.forwardRef< 24 | React.ElementRef, 25 | React.ComponentPropsWithoutRef 26 | >(({ className, children, ...props }, ref) => ( 27 | 28 | svg]:rotate-180", 32 | className, 33 | )} 34 | {...props} 35 | > 36 | {children} 37 | 38 | 39 | 40 | )); 41 | AccordionTrigger.displayName = AccordionPrimitive.Trigger.displayName; 42 | 43 | const AccordionContent = React.forwardRef< 44 | React.ElementRef, 45 | React.ComponentPropsWithoutRef 46 | >(({ className, children, ...props }, ref) => ( 47 | 55 |

{children}
56 | 57 | )); 58 | AccordionContent.displayName = AccordionPrimitive.Content.displayName; 59 | 60 | export { Accordion, AccordionItem, AccordionTrigger, AccordionContent }; 61 | -------------------------------------------------------------------------------- /packages/api/src/router/customer.ts: -------------------------------------------------------------------------------- 1 | import { unstable_noStore as noStore } from "next/cache"; 2 | import { getServerSession } from "next-auth/next"; 3 | import { z } from "zod"; 4 | 5 | import { authOptions } from "@saasfly/auth"; 6 | import { db, SubscriptionPlan } from "@saasfly/db"; 7 | 8 | import { createTRPCRouter, protectedProcedure } from "../trpc"; 9 | 10 | const updateUserNameSchema = z.object({ 11 | name: z.string(), 12 | userId: z.string(), 13 | }); 14 | const insertCustomerSchema = z.object({ 15 | userId: z.string(), 16 | }); 17 | z.object({ 18 | userId: z.string(), 19 | }); 20 | export const customerRouter = createTRPCRouter({ 21 | updateUserName: protectedProcedure 22 | .input(updateUserNameSchema) 23 | .mutation(async ({ input }) => { 24 | const { userId } = input; 25 | const session = await getServerSession(authOptions); 26 | if (!session?.user || userId !== session?.user.id) { 27 | return { success: false, reason: "no auth" }; 28 | } 29 | await db 30 | .updateTable("User") 31 | .set({ 32 | name: input.name, 33 | }) 34 | .where("id", "=", userId) 35 | .execute(); 36 | return { success: true, reason: "" }; 37 | }), 38 | 39 | insertCustomer: protectedProcedure 40 | .input(insertCustomerSchema) 41 | .mutation(async ({ input }) => { 42 | const { userId } = input; 43 | await db 44 | .insertInto("Customer") 45 | .values({ 46 | authUserId: userId, 47 | plan: SubscriptionPlan.FREE, 48 | }) 49 | .executeTakeFirst(); 50 | }), 51 | 52 | queryCustomer: protectedProcedure 53 | .input(insertCustomerSchema) 54 | .query(async ({ input }) => { 55 | noStore(); 56 | const { userId } = input; 57 | console.log("userId:", userId); 58 | try { 59 | console.log( 60 | "result:", 61 | await db 62 | .selectFrom("Customer") 63 | .where("authUserId", "=", userId) 64 | .executeTakeFirst(), 65 | ); 66 | } catch (e) { 67 | console.error("e:", e); 68 | } 69 | 70 | return await db 71 | .selectFrom("Customer") 72 | .where("authUserId", "=", userId) 73 | .executeTakeFirst(); 74 | }), 75 | }); 76 | -------------------------------------------------------------------------------- /packages/ui/src/card.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | 3 | import { cn } from "./utils/cn"; 4 | 5 | const Card = React.forwardRef< 6 | HTMLDivElement, 7 | React.HTMLAttributes 8 | >(({ className, ...props }, ref) => ( 9 |
17 | )); 18 | Card.displayName = "Card"; 19 | 20 | const CardHeader = React.forwardRef< 21 | HTMLDivElement, 22 | React.HTMLAttributes 23 | >(({ className, ...props }, ref) => ( 24 |
29 | )); 30 | CardHeader.displayName = "CardHeader"; 31 | 32 | const CardTitle = React.forwardRef< 33 | HTMLParagraphElement, 34 | React.HTMLAttributes 35 | >(({ className, ...props }, ref) => ( 36 |

44 | {props.children} 45 |

46 | )); 47 | CardTitle.displayName = "CardTitle"; 48 | 49 | const CardDescription = React.forwardRef< 50 | HTMLParagraphElement, 51 | React.HTMLAttributes 52 | >(({ className, ...props }, ref) => ( 53 |

58 | )); 59 | CardDescription.displayName = "CardDescription"; 60 | 61 | const CardContent = React.forwardRef< 62 | HTMLDivElement, 63 | React.HTMLAttributes 64 | >(({ className, ...props }, ref) => ( 65 |

66 | )); 67 | CardContent.displayName = "CardContent"; 68 | 69 | const CardFooter = React.forwardRef< 70 | HTMLDivElement, 71 | React.HTMLAttributes 72 | >(({ className, ...props }, ref) => ( 73 |
78 | )); 79 | CardFooter.displayName = "CardFooter"; 80 | 81 | export { 82 | Card, 83 | CardHeader, 84 | CardFooter, 85 | CardTitle, 86 | CardDescription, 87 | CardContent, 88 | }; 89 | -------------------------------------------------------------------------------- /apps/nextjs/src/app/[lang]/(auth)/login/page.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import type { Metadata } from "next"; 3 | import Image from "next/image"; 4 | import Link from "next/link"; 5 | 6 | import { cn } from "@saasfly/ui"; 7 | import { buttonVariants } from "@saasfly/ui/button"; 8 | import * as Icons from "@saasfly/ui/icons"; 9 | 10 | import { UserAuthForm } from "~/components/user-auth-form"; 11 | import type { Locale } from "~/config/i18n-config"; 12 | import { getDictionary } from "~/lib/get-dictionary"; 13 | 14 | export const metadata: Metadata = { 15 | title: "Login", 16 | description: "Login to your account", 17 | }; 18 | 19 | export default async function LoginPage({ 20 | params: { lang }, 21 | }: { 22 | params: { 23 | lang: Locale; 24 | }; 25 | }) { 26 | const dict = await getDictionary(lang); 27 | return ( 28 |
29 | 36 | <> 37 | 38 | {dict.login.back} 39 | 40 | 41 |
42 |
43 | 50 |

51 | {dict.login.welcome_back} 52 |

53 |

54 | {dict.login.signin_title} 55 |

56 |
57 | 58 | {/*

59 | 63 | {dict.login.singup_title} 64 | 65 |

*/} 66 |
67 |
68 | ); 69 | } 70 | -------------------------------------------------------------------------------- /apps/nextjs/src/app/[lang]/(editor)/editor/layout.tsx: -------------------------------------------------------------------------------- 1 | import { notFound } from "next/navigation"; 2 | 3 | import { getCurrentUser } from "@saasfly/auth"; 4 | 5 | import { MainNav } from "~/components/main-nav"; 6 | import { DashboardNav } from "~/components/nav"; 7 | import { SiteFooter } from "~/components/site-footer"; 8 | import { UserAccountNav } from "~/components/user-account-nav"; 9 | import type { Locale } from "~/config/i18n-config"; 10 | import { getDashboardConfig } from "~/config/ui/dashboard"; 11 | import { getDictionary } from "~/lib/get-dictionary"; 12 | 13 | interface EditLayoutProps { 14 | children?: React.ReactNode; 15 | params: { 16 | lang: Locale; 17 | }; 18 | } 19 | 20 | export default async function DashboardLayout({ 21 | children, 22 | params: { lang }, 23 | }: EditLayoutProps) { 24 | const user = await getCurrentUser(); 25 | const dict = await getDictionary(lang); 26 | 27 | const dashboardConfig = await getDashboardConfig({ params: { lang } }); 28 | if (!user) { 29 | return notFound(); 30 | } 31 | 32 | return ( 33 |
34 |
35 |
36 | 40 | 49 |
50 |
51 |
52 | 58 |
59 | {children} 60 |
61 |
62 | 67 |
68 | ); 69 | } 70 | -------------------------------------------------------------------------------- /packages/ui/src/data-table.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { 4 | flexRender, 5 | getCoreRowModel, 6 | useReactTable, 7 | type ColumnDef, 8 | } from "@tanstack/react-table"; 9 | 10 | import { 11 | Table, 12 | TableBody, 13 | TableCell, 14 | TableHead, 15 | TableHeader, 16 | TableRow, 17 | } from "./table"; 18 | 19 | interface DataTableProps { 20 | columns: ColumnDef[]; 21 | data: TData[]; 22 | } 23 | 24 | export function DataTable({ 25 | columns, 26 | data, 27 | }: DataTableProps) { 28 | const table = useReactTable({ 29 | data, 30 | columns, 31 | getCoreRowModel: getCoreRowModel(), 32 | }); 33 | 34 | return ( 35 |
36 | 37 | 38 | {table.getHeaderGroups().map((headerGroup) => ( 39 | 40 | {headerGroup.headers.map((header) => { 41 | return ( 42 | 43 | {header.isPlaceholder 44 | ? null 45 | : flexRender( 46 | header.column.columnDef.header, 47 | header.getContext(), 48 | )} 49 | 50 | ); 51 | })} 52 | 53 | ))} 54 | 55 | 56 | {table.getRowModel().rows?.length ? ( 57 | table.getRowModel().rows.map((row) => ( 58 | 62 | {row.getVisibleCells().map((cell) => ( 63 | 64 | {flexRender(cell.column.columnDef.cell, cell.getContext())} 65 | 66 | ))} 67 | 68 | )) 69 | ) : ( 70 | 71 | 72 | No results. 73 | 74 | 75 | )} 76 | 77 |
78 |
79 | ); 80 | } 81 | -------------------------------------------------------------------------------- /apps/nextjs/src/components/sign-in-modal.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import React, { useState } from "react"; 4 | import Image from "next/image"; 5 | import { signIn } from "next-auth/react"; 6 | 7 | import { Button } from "@saasfly/ui/button"; 8 | import * as Icons from "@saasfly/ui/icons"; 9 | 10 | import { Modal } from "~/components/modal"; 11 | import { siteConfig } from "~/config/site"; 12 | import { useSigninModal } from "~/hooks/use-signin-modal"; 13 | 14 | export const SignInModal = ({ dict }: { dict: Record }) => { 15 | const signInModal = useSigninModal(); 16 | const [signInClicked, setSignInClicked] = useState(false); 17 | 18 | return ( 19 | 20 |
21 |
22 | 23 | 30 | 31 |

{dict.signup}

32 |

{dict.privacy}

33 |
34 | 35 |
36 | 59 |
60 |
61 |
62 | ); 63 | }; 64 | -------------------------------------------------------------------------------- /apps/nextjs/src/env.mjs: -------------------------------------------------------------------------------- 1 | import { createEnv } from "@t3-oss/env-nextjs"; 2 | import { z } from "zod"; 3 | 4 | export const env = createEnv({ 5 | server: { 6 | // This is optional because it's only used in development. 7 | // See https://next-auth.js.org/deployment. 8 | NEXTAUTH_URL: z.string().url().optional(), 9 | NEXTAUTH_SECRET: z.string().min(1), 10 | GITHUB_CLIENT_ID: z.string().min(1), 11 | GITHUB_CLIENT_SECRET: z.string().min(1), 12 | STRIPE_API_KEY: z.string().min(1), 13 | STRIPE_WEBHOOK_SECRET: z.string().min(1), 14 | }, 15 | client: { 16 | NEXT_PUBLIC_APP_URL: z.string().min(1), 17 | NEXT_PUBLIC_STRIPE_PRO_PRODUCT_ID: z.string().optional(), 18 | NEXT_PUBLIC_STRIPE_PRO_MONTHLY_PRICE_ID: z.string().optional(), 19 | NEXT_PUBLIC_STRIPE_PRO_YEARLY_PRICE_ID: z.string().optional(), 20 | NEXT_PUBLIC_STRIPE_BUSINESS_PRODUCT_ID: z.string().optional(), 21 | NEXT_PUBLIC_STRIPE_BUSINESS_MONTHLY_PRICE_ID: z.string().optional(), 22 | NEXT_PUBLIC_STRIPE_BUSINESS_YEARLY_PRICE_ID: z.string().optional(), 23 | NEXT_PUBLIC_POSTHOG_KEY: z.string().optional(), 24 | NEXT_PUBLIC_POSTHOG_HOST: z.string().optional(), 25 | }, 26 | runtimeEnv: { 27 | NEXTAUTH_URL: process.env.NEXTAUTH_URL, 28 | NEXTAUTH_SECRET: process.env.NEXTAUTH_SECRET, 29 | GITHUB_CLIENT_ID: process.env.GITHUB_CLIENT_ID, 30 | GITHUB_CLIENT_SECRET: process.env.GITHUB_CLIENT_SECRET, 31 | STRIPE_API_KEY: process.env.STRIPE_API_KEY, 32 | STRIPE_WEBHOOK_SECRET: process.env.STRIPE_WEBHOOK_SECRET, 33 | NEXT_PUBLIC_APP_URL: process.env.NEXT_PUBLIC_APP_URL, 34 | NEXT_PUBLIC_STRIPE_PRO_PRODUCT_ID: 35 | process.env.NEXT_PUBLIC_STRIPE_PRO_PRODUCT_ID, 36 | NEXT_PUBLIC_STRIPE_PRO_MONTHLY_PRICE_ID: 37 | process.env.NEXT_PUBLIC_STRIPE_PRO_MONTHLY_PRICE_ID, 38 | NEXT_PUBLIC_STRIPE_PRO_YEARLY_PRICE_ID: 39 | process.env.NEXT_PUBLIC_STRIPE_PRO_YEARLY_PRICE_ID, 40 | NEXT_PUBLIC_STRIPE_BUSINESS_PRODUCT_ID: 41 | process.env.NEXT_PUBLIC_STRIPE_BUSINESS_PRODUCT_ID, 42 | NEXT_PUBLIC_STRIPE_BUSINESS_MONTHLY_PRICE_ID: 43 | process.env.NEXT_PUBLIC_STRIPE_BUSINESS_MONTHLY_PRICE_ID, 44 | NEXT_PUBLIC_STRIPE_BUSINESS_YEARLY_PRICE_ID: 45 | process.env.NEXT_PUBLIC_STRIPE_BUSINESS_YEARLY_PRICE_ID, 46 | NEXT_PUBLIC_POSTHOG_KEY: process.env.NEXT_PUBLIC_POSTHOG_KEY, 47 | NEXT_PUBLIC_POSTHOG_HOST: process.env.NEXT_PUBLIC_POSTHOG_HOST, 48 | }, 49 | }); 50 | -------------------------------------------------------------------------------- /packages/common/src/subscriptions.ts: -------------------------------------------------------------------------------- 1 | import { env } from "./env.mjs"; 2 | 3 | export interface SubscriptionPlan { 4 | title: string; 5 | description: string; 6 | benefits: string[]; 7 | limitations: string[]; 8 | prices: { 9 | monthly: number; 10 | yearly: number; 11 | }; 12 | stripeIds: { 13 | monthly: string | null; 14 | yearly: string | null; 15 | }; 16 | } 17 | 18 | export const pricingData: SubscriptionPlan[] = [ 19 | { 20 | title: "Starter", 21 | description: "For Beginners", 22 | benefits: [ 23 | "Up to 100 monthly posts", 24 | "Basic analytics and reporting", 25 | "Access to standard templates", 26 | ], 27 | limitations: [ 28 | "No priority access to new features.", 29 | "Limited customer support", 30 | "No custom branding", 31 | "Limited access to business resources.", 32 | ], 33 | prices: { 34 | monthly: 0, 35 | yearly: 0, 36 | }, 37 | stripeIds: { 38 | monthly: null, 39 | yearly: null, 40 | }, 41 | }, 42 | { 43 | title: "Pro", 44 | description: "Unlock Advanced Features", 45 | benefits: [ 46 | "Up to 500 monthly posts", 47 | "Advanced analytics and reporting", 48 | "Access to business templates", 49 | "Priority customer support", 50 | "Exclusive webinars and training.", 51 | ], 52 | limitations: [ 53 | "No custom branding", 54 | "Limited access to business resources.", 55 | ], 56 | prices: { 57 | monthly: 15, 58 | yearly: 144, 59 | }, 60 | stripeIds: { 61 | // @ts-ignore 62 | monthly: env.NEXT_PUBLIC_STRIPE_PRO_MONTHLY_PRICE_ID, 63 | // @ts-ignore 64 | yearly: env.NEXT_PUBLIC_STRIPE_BUSINESS_MONTHLY_PRICE_ID, 65 | }, 66 | }, 67 | { 68 | title: "Business", 69 | description: "For Power Users", 70 | benefits: [ 71 | "Unlimited posts", 72 | "Real-time analytics and reporting", 73 | "Access to all templates, including custom branding", 74 | "24/7 business customer support", 75 | "Personalized onboarding and account management.", 76 | ], 77 | limitations: [], 78 | prices: { 79 | monthly: 30, 80 | yearly: 300, 81 | }, 82 | stripeIds: { 83 | // @ts-ignore 84 | monthly: env.NEXT_PUBLIC_STRIPE_PRO_YEARLY_PRICE_ID, 85 | // @ts-ignore 86 | yearly: env.NEXT_PUBLIC_STRIPE_BUSINESS_YEARLY_PRICE_ID, 87 | }, 88 | }, 89 | ]; 90 | -------------------------------------------------------------------------------- /apps/nextjs/src/app/[lang]/(auth)/register/page.tsx: -------------------------------------------------------------------------------- 1 | import Link from "next/link"; 2 | 3 | import { cn } from "@saasfly/ui"; 4 | import { buttonVariants } from "@saasfly/ui/button"; 5 | 6 | import { UserAuthForm } from "~/components/user-auth-form"; 7 | import type { Locale } from "~/config/i18n-config"; 8 | import { getDictionary } from "~/lib/get-dictionary"; 9 | 10 | export const metadata = { 11 | title: "Create an account", 12 | description: "Create an account to get started.", 13 | }; 14 | 15 | export default async function RegisterPage({ 16 | params: { lang }, 17 | }: { 18 | params: { 19 | lang: Locale; 20 | }; 21 | }) { 22 | const dict = await getDictionary(lang); 23 | 24 | return ( 25 |
26 | 33 | {dict.marketing.login} 34 | 35 |
36 |
37 |
38 |
39 | {/**/} 40 |

41 | Create an account 42 |

43 |

44 | Enter your email below to create your account 45 |

46 |
47 | 48 |

49 | By clicking continue, you agree to our{" "} 50 | 54 | Terms of Service 55 | {" "} 56 | and{" "} 57 | 61 | Privacy Policy 62 | 63 | . 64 |

65 |
66 |
67 |
68 | ); 69 | } 70 | -------------------------------------------------------------------------------- /apps/nextjs/src/components/k8s/cluster-create-button.tsx: -------------------------------------------------------------------------------- 1 | // eslint-disable-next-line @typescript-eslint/ban-ts-comment 2 | // @ts-nocheck 3 | "use client"; 4 | 5 | import * as React from "react"; 6 | //navigate to new page 7 | import { useRouter } from "next/navigation"; 8 | 9 | import { cn } from "@saasfly/ui"; 10 | //button self design 11 | import { buttonVariants, type ButtonProps } from "@saasfly/ui/button"; 12 | import * as Icons from "@saasfly/ui/icons"; 13 | import { toast } from "@saasfly/ui/use-toast"; 14 | 15 | import { trpc } from "~/trpc/client"; 16 | 17 | interface K8sCreateButtonProps extends ButtonProps { 18 | customProp?: string; 19 | dict: Record; 20 | } 21 | 22 | export function K8sCreateButton({ 23 | className, 24 | variant, 25 | dict, 26 | ...props 27 | }: K8sCreateButtonProps) { 28 | const router = useRouter(); 29 | const [isLoading, setIsLoading] = React.useState(false); 30 | 31 | async function onClick() { 32 | const res = await trpc.k8s.createCluster.mutate({ 33 | name: "Default Cluster", 34 | location: "Hong Kong", 35 | }); 36 | setIsLoading(false); 37 | 38 | if (!res?.success) { 39 | // if (response.status === 402) { 40 | // return toast({ 41 | // title: "Limit of 1 cluster reached.", 42 | // description: "Please upgrade to the PROD plan.", 43 | // variant: "destructive", 44 | // }); 45 | // } 46 | return toast({ 47 | title: "Something went wrong.", 48 | description: "Your cluster was not created. Please try again.", 49 | variant: "destructive", 50 | }); 51 | } 52 | if (res) { 53 | const cluster = res; 54 | 55 | // This forces a cache invalidation. 56 | router.refresh(); 57 | 58 | if (cluster?.id) { 59 | router.push(`/editor/cluster/${cluster.id}`); 60 | } 61 | } else { 62 | // console.log("error "); 63 | } 64 | } 65 | 66 | return ( 67 | 86 | ); 87 | } 88 | -------------------------------------------------------------------------------- /apps/nextjs/src/components/user-account-nav.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import Link from "next/link"; 4 | import type { User } from "next-auth"; 5 | import { signOut } from "next-auth/react"; 6 | 7 | import { 8 | DropdownMenu, 9 | DropdownMenuContent, 10 | DropdownMenuItem, 11 | DropdownMenuSeparator, 12 | DropdownMenuTrigger, 13 | } from "@saasfly/ui/dropdown-menu"; 14 | 15 | import { UserAvatar } from "~/components/user-avatar"; 16 | 17 | interface UserAccountNavProps extends React.HTMLAttributes { 18 | user: Pick; 19 | params: { 20 | lang: string; 21 | }; 22 | dict: Record; 23 | } 24 | 25 | export function UserAccountNav({ 26 | user, 27 | params: { lang }, 28 | dict, 29 | }: UserAccountNavProps) { 30 | return ( 31 | 32 | 33 | 37 | 38 | 39 |
40 |
41 | {user.name &&

{user.name}

} 42 | {user.email && ( 43 |

44 | {user.email} 45 |

46 | )} 47 |
48 |
49 | 50 | 51 | {dict.dashboard} 52 | 53 | 54 | {dict.billing} 55 | 56 | 57 | {dict.settings} 58 | 59 | 60 | { 63 | event.preventDefault(); 64 | signOut({ 65 | callbackUrl: `${window.location.origin}/${lang}/login`, 66 | }).catch((error) => { 67 | console.error("Error during sign out:", error); 68 | }); 69 | }} 70 | > 71 | {dict.sign_out} 72 | 73 |
74 |
75 | ); 76 | } 77 | -------------------------------------------------------------------------------- /apps/nextjs/src/app/[lang]/(dashboard)/dashboard/billing/page.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | Card, 3 | CardContent, 4 | CardFooter, 5 | CardHeader, 6 | CardTitle, 7 | } from "@saasfly/ui/card"; 8 | 9 | import { DashboardShell } from "~/components/shell"; 10 | import type { Locale } from "~/config/i18n-config"; 11 | import { getDictionary } from "~/lib/get-dictionary"; 12 | import { trpc } from "~/trpc/server"; 13 | import { SubscriptionForm } from "./subscription-form"; 14 | 15 | export const metadata = { 16 | title: "Billing", 17 | description: "Manage billing and your subscription plan.", 18 | }; 19 | 20 | interface Subscription { 21 | plan: string | null; 22 | endsAt: Date | null; 23 | } 24 | 25 | export default async function BillingPage({ 26 | params: { lang }, 27 | }: { 28 | params: { 29 | lang: Locale; 30 | }; 31 | }) { 32 | const dict = await getDictionary(lang); 33 | return ( 34 | 39 | 40 | 41 | 42 | 43 | ); 44 | } 45 | 46 | function generateSubscriptionMessage( 47 | dict: Record, 48 | subscription: Subscription, 49 | ): string { 50 | const content = String(dict.subscriptionInfo); 51 | if (subscription.plan && subscription.endsAt) { 52 | return content 53 | .replace("{plan}", subscription.plan) 54 | .replace("{date}", subscription.endsAt.toLocaleDateString()); 55 | } 56 | return ""; 57 | } 58 | 59 | async function SubscriptionCard({ dict }: { dict: Record }) { 60 | const subscription = (await trpc.auth.mySubscription.query()) as Subscription; 61 | const content = generateSubscriptionMessage(dict, subscription); 62 | return ( 63 | 64 | 65 | Subscription 66 | 67 | 68 | {subscription ? ( 69 |

70 | ) : ( 71 |

{dict.noSubscription}

72 | )} 73 |
74 | 75 | 76 | 77 |
78 | ); 79 | } 80 | 81 | function UsageCard() { 82 | return ( 83 | 84 | 85 | Usage 86 | 87 | None 88 | 89 | ); 90 | } 91 | --------------------------------------------------------------------------------