├── .dockerignore ├── .vscode ├── settings.json └── launch.json ├── .husky └── pre-commit ├── env.d.ts ├── .gitignore ├── public ├── favicon.ico ├── og-image.png ├── login-dark.jpeg └── login-light.jpeg ├── app ├── routes │ ├── dashboard │ │ ├── test │ │ │ └── route.tsx │ │ ├── sidebar.context.ts │ │ ├── route.tsx │ │ ├── theme-toggle.tsx │ │ ├── shell.tsx │ │ ├── sidebar.tsx │ │ ├── user-nav.tsx │ │ └── plans │ │ │ └── route.tsx │ ├── _themeswitcher+ │ │ └── action.set-theme.ts │ ├── _auth+ │ │ ├── auth.logout.ts │ │ ├── google.callback.ts │ │ ├── auth.google.ts │ │ ├── _layout.tsx │ │ ├── forgot-password.tsx │ │ ├── reset-password.tsx │ │ ├── login.tsx │ │ ├── verify-email.tsx │ │ └── signup.tsx │ ├── robots[.txt].tsx │ ├── _index │ │ ├── discount-badge.tsx │ │ ├── faq.tsx │ │ ├── social-proof.tsx │ │ ├── route.tsx │ │ ├── feature-with-image.tsx │ │ ├── hero-section.tsx │ │ ├── pricing.tsx │ │ ├── footer.tsx │ │ ├── features-variant-b.tsx │ │ └── feature-section.tsx │ ├── sitemap[.]xml.tsx │ ├── resources+ │ │ ├── stripe.create-customer.ts │ │ └── stripe.create-subscription.ts │ └── api+ │ │ └── webhook.ts ├── lib │ ├── server │ │ ├── robots │ │ │ ├── types.ts │ │ │ ├── robots-utils.ts │ │ │ └── robots.server.ts │ │ ├── sitemap │ │ │ ├── types.ts │ │ │ ├── sitemap.server.ts │ │ │ └── sitemap-utils.server.ts │ │ ├── csrf.server.ts │ │ ├── seo │ │ │ └── seo-helpers.ts │ │ └── auth-utils.sever.ts │ ├── utils.ts │ ├── brand │ │ ├── config.ts │ │ └── logo.tsx │ └── assets │ │ └── logos │ │ └── google.tsx ├── services │ ├── stripe │ │ ├── setup.server.ts │ │ ├── stripe.server.ts │ │ └── plans.config.ts │ ├── email │ │ └── resend.server.ts │ ├── db │ │ └── db.server.ts │ ├── session.server.ts │ └── auth.server.ts ├── utils │ └── currency.ts ├── components │ ├── pricing │ │ ├── pricing-switch.tsx │ │ ├── containers.tsx │ │ └── feature.tsx │ ├── ui │ │ ├── label.tsx │ │ ├── input.tsx │ │ ├── checkbox.tsx │ │ ├── avatar.tsx │ │ ├── alert.tsx │ │ ├── button.tsx │ │ ├── tabs.tsx │ │ ├── accordion.tsx │ │ ├── card.tsx │ │ ├── sheet.tsx │ │ └── dropdown-menu.tsx │ ├── error-boundry.tsx │ └── email │ │ ├── verify-email-template.tsx │ │ └── reset-password-template.tsx ├── entry.client.tsx ├── models │ ├── user.ts │ ├── checkout.ts │ ├── plan.ts │ └── subscription.ts ├── tailwind.css ├── root.tsx └── entry.server.tsx ├── postcss.config.js ├── .eslintrc.cjs ├── .prettierignore ├── .env.example ├── components.json ├── README.md ├── .github └── workflows │ └── deploy_prod.yaml ├── fly.toml ├── remix.config.js ├── tsconfig.json ├── vite.config.ts ├── prettier.config.cjs ├── LICENSE ├── Dockerfile ├── prisma ├── seed.ts └── schema.prisma ├── tailwind.config.js └── package.json /.dockerignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | 3 | /.cache 4 | /build 5 | /public/build 6 | .env 7 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "typescript.tsdk": "node_modules/typescript/lib" 3 | } -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | . "$(dirname -- "$0")/_/husky.sh" 3 | 4 | npx lint-staged -------------------------------------------------------------------------------- /env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | /// 3 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | 3 | /.cache 4 | /build 5 | /public/build 6 | .env 7 | 8 | .DS_Store 9 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/saas-kits/remix-boilerplate/HEAD/public/favicon.ico -------------------------------------------------------------------------------- /public/og-image.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/saas-kits/remix-boilerplate/HEAD/public/og-image.png -------------------------------------------------------------------------------- /public/login-dark.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/saas-kits/remix-boilerplate/HEAD/public/login-dark.jpeg -------------------------------------------------------------------------------- /app/routes/dashboard/test/route.tsx: -------------------------------------------------------------------------------- 1 | export default function TestPage() { 2 | return <>Test Page 3 | } 4 | -------------------------------------------------------------------------------- /public/login-light.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/saas-kits/remix-boilerplate/HEAD/public/login-light.jpeg -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | export default { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | } 7 | -------------------------------------------------------------------------------- /.eslintrc.cjs: -------------------------------------------------------------------------------- 1 | /** @type {import('eslint').Linter.Config} */ 2 | module.exports = { 3 | extends: ["@remix-run/eslint-config", "@remix-run/eslint-config/node"], 4 | } 5 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | .vscode/ 2 | pnpm-lock.yaml 3 | package-lock.json 4 | yarn.lock 5 | .DS_Store 6 | node_modules/ 7 | 8 | /.cache 9 | /build 10 | /server-build 11 | /public/build 12 | /coverage -------------------------------------------------------------------------------- /app/routes/_themeswitcher+/action.set-theme.ts: -------------------------------------------------------------------------------- 1 | import { createThemeAction } from "remix-themes" 2 | 3 | import { themeSessionResolver } from "@/services/session.server" 4 | 5 | export const action = createThemeAction(themeSessionResolver) 6 | -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | DATABASE_URL= 2 | GOOGLE_CLIENT_ID= 3 | GOOGLE_CLIENT_SECRET= 4 | RESEND_API_KEY= 5 | FROM_EMAIL= 6 | HOST_URL= 7 | SESSION_SECRET= 8 | 9 | 10 | ## Stripe 11 | STRIPE_WEBHOOK_SECRET= 12 | STRIPE_PUBLIC_KEY= 13 | STRIPE_SECRET_KEY= 14 | 15 | 16 | 17 | -------------------------------------------------------------------------------- /app/lib/server/robots/types.ts: -------------------------------------------------------------------------------- 1 | export type RobotsPolicy = { 2 | type: "allow" | "disallow" | "sitemap" | "crawlDelay" | "userAgent" 3 | value: string 4 | } 5 | 6 | export type RobotsConfig = { 7 | appendOnDefaultPolicies?: boolean 8 | headers?: HeadersInit 9 | } 10 | -------------------------------------------------------------------------------- /app/routes/dashboard/sidebar.context.ts: -------------------------------------------------------------------------------- 1 | import { createContext } from "react" 2 | 3 | type SidebarContextType = { 4 | onNavLinkClick?: () => void 5 | } 6 | 7 | export const SidebarContext = createContext({ 8 | onNavLinkClick: () => null, 9 | }) 10 | -------------------------------------------------------------------------------- /app/services/stripe/setup.server.ts: -------------------------------------------------------------------------------- 1 | import Stripe from "stripe" 2 | 3 | if (!process.env.STRIPE_SECRET_KEY) throw new Error("Missing Stripe secret key") 4 | 5 | export const stripe = new Stripe(process.env.STRIPE_SECRET_KEY, { 6 | apiVersion: "2023-10-16", 7 | typescript: true, 8 | }) 9 | -------------------------------------------------------------------------------- /app/routes/_auth+/auth.logout.ts: -------------------------------------------------------------------------------- 1 | import type { ActionFunctionArgs } from "@remix-run/node" 2 | 3 | import { authenticator } from "@/services/auth.server" 4 | 5 | export async function action({ request }: ActionFunctionArgs) { 6 | await authenticator.logout(request, { redirectTo: "/login" }) 7 | } 8 | -------------------------------------------------------------------------------- /app/routes/robots[.txt].tsx: -------------------------------------------------------------------------------- 1 | import { generateRobotsTxt } from "@/lib/server/robots/robots.server" 2 | 3 | export function loader() { 4 | return generateRobotsTxt([ 5 | { type: "sitemap", value: `${process.env.HOST_URL}/sitemap.xml` }, 6 | { type: "disallow", value: "/dashboard" }, 7 | ]) 8 | } 9 | -------------------------------------------------------------------------------- /app/routes/_auth+/google.callback.ts: -------------------------------------------------------------------------------- 1 | import type { LoaderFunctionArgs } from "@remix-run/node" 2 | 3 | import { authenticator } from "@/services/auth.server" 4 | 5 | export let loader = ({ request }: LoaderFunctionArgs) => { 6 | return authenticator.authenticate("google", request, { 7 | successRedirect: "/dashboard", 8 | failureRedirect: "/login", 9 | }) 10 | } 11 | -------------------------------------------------------------------------------- /app/routes/_auth+/auth.google.ts: -------------------------------------------------------------------------------- 1 | import type { ActionFunctionArgs } from "@remix-run/node" 2 | import { redirect } from "@remix-run/node" 3 | 4 | import { authenticator } from "@/services/auth.server" 5 | 6 | export let loader = () => redirect("/login") 7 | 8 | export let action = ({ request }: ActionFunctionArgs) => { 9 | return authenticator.authenticate("google", request) 10 | } 11 | -------------------------------------------------------------------------------- /components.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://ui.shadcn.com/schema.json", 3 | "style": "default", 4 | "rsc": false, 5 | "tsx": true, 6 | "tailwind": { 7 | "config": "tailwind.config.js", 8 | "css": "app/tailwind.css", 9 | "baseColor": "zinc", 10 | "cssVariables": true 11 | }, 12 | "aliases": { 13 | "components": "@/components", 14 | "utils": "@/lib/utils" 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Welcome to Remix SaaSKits Boilerplate 2 | 3 | Head over to the [Boilerplate Docs](https://docs.saaskits.dev) to get started. 4 | 5 | ## Love the project? Please consider donating 6 | 7 | Buy Me A Coffee -------------------------------------------------------------------------------- /app/services/email/resend.server.ts: -------------------------------------------------------------------------------- 1 | import { Resend } from "resend" 2 | 3 | export const resend = new Resend(process.env.RESEND_API_KEY) 4 | 5 | export const sendEmail = async ( 6 | to: string, 7 | subject: string, 8 | react: JSX.Element 9 | ) => { 10 | const receipt = await resend.emails.send({ 11 | from: process.env.FROM_EMAIL || "", 12 | to, 13 | subject, 14 | react, 15 | }) 16 | 17 | return receipt 18 | } 19 | -------------------------------------------------------------------------------- /app/routes/_index/discount-badge.tsx: -------------------------------------------------------------------------------- 1 | export function Discountbadge() { 2 | return ( 3 |
4 |
5 | 6 | 24 Hour Flash Sale -{" "} 7 | 8 | 70% off for first 30 customers 9 |
10 |
11 | ) 12 | } 13 | -------------------------------------------------------------------------------- /app/utils/currency.ts: -------------------------------------------------------------------------------- 1 | import { getClientLocales } from "remix-utils/locales/server" 2 | 3 | import { CURRENCIES } from "@/services/stripe/plans.config" 4 | 5 | export const getUserCurrencyFromRequest = (request: Request) => { 6 | const locales = getClientLocales(request) 7 | 8 | if (!locales) return CURRENCIES.USD 9 | const locale = locales[0] ?? "en-US" 10 | 11 | const currency = locale == "en-US" ? CURRENCIES.USD : CURRENCIES.EUR 12 | 13 | return currency 14 | } 15 | -------------------------------------------------------------------------------- /app/lib/server/robots/robots-utils.ts: -------------------------------------------------------------------------------- 1 | import { RobotsPolicy } from "./types" 2 | 3 | const typeTextMap = { 4 | userAgent: "User-agent", 5 | allow: "Allow", 6 | disallow: "Disallow", 7 | sitemap: "Sitemap", 8 | crawlDelay: "Crawl-delay", 9 | } 10 | 11 | export function getRobotsText(policies: RobotsPolicy[]): string { 12 | return policies.reduce((acc, policy) => { 13 | const { type, value } = policy 14 | return `${acc}${typeTextMap[type]}: ${value}\n` 15 | }, "") 16 | } 17 | -------------------------------------------------------------------------------- /app/components/pricing/pricing-switch.tsx: -------------------------------------------------------------------------------- 1 | import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs" 2 | 3 | type PricingSwitchProps = { 4 | onSwitch: (value: string) => void 5 | } 6 | 7 | export const PricingSwitch = ({ onSwitch }: PricingSwitchProps) => ( 8 | 9 | 10 | Monthly 11 | Yearly 12 | 13 | 14 | ) 15 | -------------------------------------------------------------------------------- /app/lib/utils.ts: -------------------------------------------------------------------------------- 1 | import { clsx, type ClassValue } from "clsx" 2 | import { twMerge } from "tailwind-merge" 3 | 4 | import { CURRENCIES } from "@/services/stripe/plans.config" 5 | 6 | export function cn(...inputs: ClassValue[]) { 7 | return twMerge(clsx(inputs)) 8 | } 9 | 10 | export const getformattedCurrency = ( 11 | amount: number, 12 | defaultCurrency: CURRENCIES 13 | ) => { 14 | return new Intl.NumberFormat("en-US", { 15 | style: "currency", 16 | currency: defaultCurrency, 17 | }).format(amount) 18 | } 19 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "0.2.0", 3 | "configurations": [ 4 | { 5 | "command": "npm run dev", 6 | "name": "Run npm run dev", 7 | "request": "launch", 8 | "type": "node-terminal", 9 | "cwd": "${workspaceFolder}" 10 | }, 11 | { 12 | "name": "Attach by Process ID", 13 | "processId": "${command:PickProcess}", 14 | "request": "attach", 15 | "skipFiles": ["/**"], 16 | "type": "node" 17 | } 18 | ] 19 | } -------------------------------------------------------------------------------- /.github/workflows/deploy_prod.yaml: -------------------------------------------------------------------------------- 1 | # generate yaml config for deploying to FlyCtl when pushed to main 2 | 3 | name: Deploy to Fly 4 | on: 5 | push: 6 | branches: 7 | - main 8 | jobs: 9 | deploy: 10 | name: Deploy to Fly 11 | runs-on: ubuntu-latest 12 | steps: 13 | # This step checks out a copy of your repository. 14 | - uses: actions/checkout@v2 15 | - uses: superfly/flyctl-actions/setup-flyctl@master 16 | - run: flyctl deploy --remote-only 17 | env: 18 | FLY_API_TOKEN: ${{ secrets.FLY_API_TOKEN }} 19 | -------------------------------------------------------------------------------- /fly.toml: -------------------------------------------------------------------------------- 1 | # fly.toml app configuration file generated for remix-boilerplate on 2023-12-06T12:17:11+05:30 2 | # 3 | # See https://fly.io/docs/reference/configuration/ for information about how to use this file. 4 | # 5 | 6 | app = "remix-boilerplate" 7 | primary_region = "sin" 8 | 9 | [build] 10 | 11 | [http_service] 12 | internal_port = 3000 13 | force_https = true 14 | auto_stop_machines = true 15 | auto_start_machines = true 16 | min_machines_running = 0 17 | processes = ["app"] 18 | 19 | [[vm]] 20 | cpu_kind = "shared" 21 | cpus = 1 22 | memory_mb = 1024 23 | -------------------------------------------------------------------------------- /app/services/db/db.server.ts: -------------------------------------------------------------------------------- 1 | import { PrismaClient } from "@prisma/client" 2 | 3 | let prisma: PrismaClient 4 | 5 | declare global { 6 | var __db: PrismaClient | undefined 7 | } 8 | 9 | // this is needed because in development we don't want to restart 10 | // the server with every change, but we want to make sure we don't 11 | // create a new connection to the DB with every change either. 12 | if (process.env.NODE_ENV === "production") { 13 | prisma = new PrismaClient() 14 | } else { 15 | if (!global.__db) { 16 | global.__db = new PrismaClient() 17 | } 18 | prisma = global.__db 19 | } 20 | 21 | export { prisma } 22 | -------------------------------------------------------------------------------- /app/entry.client.tsx: -------------------------------------------------------------------------------- 1 | /** 2 | * By default, Remix will handle hydrating your app on the client for you. 3 | * You are free to delete this file if you'd like to, but if you ever want it revealed again, you can run `npx remix reveal` ✨ 4 | * For more information, see https://remix.run/file-conventions/entry.client 5 | */ 6 | 7 | import { startTransition, StrictMode } from "react" 8 | import { RemixBrowser } from "@remix-run/react" 9 | import { hydrateRoot } from "react-dom/client" 10 | 11 | startTransition(() => { 12 | hydrateRoot( 13 | document, 14 | 15 | 16 | 17 | ) 18 | }) 19 | -------------------------------------------------------------------------------- /app/routes/sitemap[.]xml.tsx: -------------------------------------------------------------------------------- 1 | // import { routes } from "@remix-run/dev/server-build" 2 | import type { LoaderFunctionArgs } from "@remix-run/node" 3 | 4 | import { generateSitemap } from "@/lib/server/sitemap/sitemap.server" 5 | 6 | export async function loader({ request }: LoaderFunctionArgs) { 7 | 8 | let build = await ( 9 | import.meta.env.DEV 10 | ? import("../../build/server/index.js") 11 | : import( 12 | /* @vite-ignore */ 13 | import.meta.resolve("../../build/server/index.js" 14 | ))) 15 | 16 | return generateSitemap(request, build.routes, { 17 | siteUrl: process.env.HOST_URL || "", 18 | }) 19 | } 20 | -------------------------------------------------------------------------------- /app/models/user.ts: -------------------------------------------------------------------------------- 1 | import type { User } from "@prisma/client" 2 | 3 | import { prisma } from "@/services/db/db.server" 4 | 5 | export const getUserById = async (id: User["id"]) => { 6 | return await prisma.user.findUnique({ 7 | where: { id }, 8 | }) 9 | } 10 | 11 | export const updateUserById = async (id: User["id"], data: Partial) => { 12 | return await prisma.user.update({ 13 | where: { id }, 14 | data, 15 | }) 16 | } 17 | 18 | export const getUserByStripeCustomerId = async ( 19 | customerId: User["customerId"] 20 | ) => { 21 | return await prisma.user.findFirst({ 22 | where: { customerId }, 23 | }) 24 | } 25 | -------------------------------------------------------------------------------- /remix.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('@remix-run/dev').AppConfig} */ 2 | import { flatRoutes } from "remix-flat-routes" 3 | 4 | export default { 5 | tailwind: true, 6 | postcss: true, 7 | ignoredRouteFiles: ["**/*"], 8 | routes: async (defineRoutes) => { 9 | return flatRoutes("routes", defineRoutes, { 10 | ignoredRouteFiles: [ 11 | ".*", 12 | "**/*.css", 13 | "**/*.test.{js,jsx,ts,tsx}", 14 | "**/__*.*", 15 | ], 16 | }) 17 | }, 18 | // appDirectory: "app", 19 | // assetsBuildDirectory: "public/build", 20 | // publicPath: "/build/", 21 | // serverBuildPath: "build/index.js", 22 | } 23 | -------------------------------------------------------------------------------- /app/lib/server/sitemap/types.ts: -------------------------------------------------------------------------------- 1 | export type SitemapEntry = { 2 | route: string 3 | lastmod?: string 4 | changefreq?: 5 | | "always" 6 | | "hourly" 7 | | "daily" 8 | | "weekly" 9 | | "monthly" 10 | | "yearly" 11 | | "never" 12 | priority?: 0.0 | 0.1 | 0.2 | 0.3 | 0.4 | 0.5 | 0.6 | 0.7 | 0.8 | 0.9 | 1.0 13 | } 14 | 15 | export type SEOHandle = { 16 | getSitemapEntries?: ( 17 | request: Request 18 | ) => 19 | | Promise | null> 20 | | Array 21 | | null 22 | } 23 | 24 | export type SEOOptions = { 25 | siteUrl: string 26 | headers?: HeadersInit 27 | } 28 | -------------------------------------------------------------------------------- /app/routes/_auth+/_layout.tsx: -------------------------------------------------------------------------------- 1 | import { Outlet } from "@remix-run/react" 2 | 3 | import { Logo } from "@/lib/brand/logo" 4 | 5 | export default function Layout() { 6 | return ( 7 | <> 8 |
9 |
10 | {/* TODO: figure out better way to use light and dark logos */} 11 |
12 | 13 |
14 | 15 | 16 |
17 |
18 | 19 | ) 20 | } 21 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "include": ["env.d.ts", "**/*.ts", "**/*.tsx"], 3 | "compilerOptions": { 4 | "skipLibCheck": true, 5 | "lib": ["DOM", "DOM.Iterable", "ES2022"], 6 | "isolatedModules": true, 7 | "esModuleInterop": true, 8 | "jsx": "react-jsx", 9 | "module": "ESNext", 10 | "moduleResolution": "Bundler", 11 | "resolveJsonModule": true, 12 | "target": "ES2022", 13 | "strict": true, 14 | "allowJs": true, 15 | "forceConsistentCasingInFileNames": true, 16 | "baseUrl": ".", 17 | "paths": { 18 | "@/*": ["./app/*"] 19 | }, 20 | 21 | // Remix takes care of building everything in `remix build`. 22 | "noEmit": true 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /app/models/checkout.ts: -------------------------------------------------------------------------------- 1 | import type { Price, User } from "@prisma/client" 2 | 3 | import { stripe } from "@/services/stripe/setup.server" 4 | 5 | export const createCheckoutSession = async ( 6 | customerId: User["customerId"], 7 | priceId: Price["stripePriceId"], 8 | successUrl: string, 9 | cancelUrl: string 10 | ) => { 11 | const session = await stripe.checkout.sessions.create({ 12 | customer: customerId as string, 13 | payment_method_types: ["card"], 14 | mode: "subscription", 15 | line_items: [ 16 | { 17 | price: priceId, 18 | quantity: 1, 19 | }, 20 | ], 21 | success_url: successUrl, 22 | cancel_url: cancelUrl, 23 | }) 24 | return session 25 | } 26 | -------------------------------------------------------------------------------- /app/lib/server/sitemap/sitemap.server.ts: -------------------------------------------------------------------------------- 1 | import type { ServerBuild } from "@remix-run/server-runtime" 2 | 3 | import { getSitemapXml } from "./sitemap-utils.server" 4 | import type { SEOOptions } from "./types" 5 | 6 | export async function generateSitemap( 7 | request: Request, 8 | routes: ServerBuild["routes"], 9 | options: SEOOptions 10 | ) { 11 | const { siteUrl, headers } = options 12 | const sitemap = await getSitemapXml(request, routes, { siteUrl }) 13 | const bytes = new TextEncoder().encode(sitemap).byteLength 14 | 15 | return new Response(sitemap, { 16 | headers: { 17 | ...headers, 18 | "Content-Type": "application/xml", 19 | "Content-Length": String(bytes), 20 | }, 21 | }) 22 | } 23 | -------------------------------------------------------------------------------- /vite.config.ts: -------------------------------------------------------------------------------- 1 | import { vitePlugin as remix } from "@remix-run/dev" 2 | import { installGlobals } from "@remix-run/node" 3 | import { flatRoutes } from "remix-flat-routes" 4 | import { defineConfig } from "vite" 5 | import tsconfigPaths from "vite-tsconfig-paths" 6 | 7 | installGlobals() 8 | 9 | export default defineConfig({ 10 | server: { 11 | port: 3000, 12 | }, 13 | plugins: [ 14 | remix({ 15 | ignoredRouteFiles: ["**/*"], 16 | routes: async (defineRoutes) => { 17 | return flatRoutes("routes", defineRoutes, { 18 | ignoredRouteFiles: [ 19 | ".*", 20 | "**/*.css", 21 | "**/*.test.{js,jsx,ts,tsx}", 22 | "**/__*.*", 23 | ], 24 | }) 25 | }, 26 | }), 27 | tsconfigPaths(), 28 | ], 29 | }) 30 | -------------------------------------------------------------------------------- /app/components/ui/label.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import * as LabelPrimitive from "@radix-ui/react-label" 3 | import { cva, type VariantProps } from "class-variance-authority" 4 | 5 | import { cn } from "@/lib/utils" 6 | 7 | const labelVariants = cva( 8 | "text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70" 9 | ) 10 | 11 | const Label = React.forwardRef< 12 | React.ElementRef, 13 | React.ComponentPropsWithoutRef & 14 | VariantProps 15 | >(({ className, ...props }, ref) => ( 16 | 21 | )) 22 | Label.displayName = LabelPrimitive.Root.displayName 23 | 24 | export { Label } 25 | -------------------------------------------------------------------------------- /app/lib/server/robots/robots.server.ts: -------------------------------------------------------------------------------- 1 | import { getRobotsText } from "./robots-utils" 2 | import { RobotsConfig, RobotsPolicy } from "./types" 3 | 4 | const defaultPolicies: RobotsPolicy[] = [ 5 | { 6 | type: "userAgent", 7 | value: "*", 8 | }, 9 | { 10 | type: "allow", 11 | value: "/", 12 | }, 13 | ] 14 | 15 | export async function generateRobotsTxt( 16 | policies: RobotsPolicy[] = [], 17 | { appendOnDefaultPolicies = true, headers }: RobotsConfig = {} 18 | ) { 19 | const policiesToUse = appendOnDefaultPolicies 20 | ? [...defaultPolicies, ...policies] 21 | : policies 22 | const robotText = await getRobotsText(policiesToUse) 23 | const bytes = new TextEncoder().encode(robotText).byteLength 24 | 25 | return new Response(robotText, { 26 | headers: { 27 | ...headers, 28 | "Content-Type": "text/plain", 29 | "Content-Length": String(bytes), 30 | }, 31 | }) 32 | } 33 | -------------------------------------------------------------------------------- /prettier.config.cjs: -------------------------------------------------------------------------------- 1 | /** @type {import('prettier').Config} */ 2 | module.exports = { 3 | endOfLine: "lf", 4 | semi: false, 5 | singleQuote: false, 6 | tabWidth: 2, 7 | trailingComma: "es5", 8 | importOrder: [ 9 | "^(react/(.*)$)|^(react$)", 10 | "^(@remix-run/(.*)$)|^(@remix-run$)", 11 | "", 12 | "", 13 | "^types$", 14 | "^@/routes/(.*)$", 15 | "^@/lib/(.*)$", 16 | "^@/services/(.*)$", 17 | "^@/models/(.*)$", 18 | "^@/utils/(.*)$", 19 | "^@/components/(.*)$", 20 | "", 21 | "^[./]", 22 | ], 23 | importOrderSeparation: false, 24 | importOrderSortSpecifiers: true, 25 | importOrderBuiltinModulesToTop: true, 26 | importOrderParserPlugins: ["typescript", "jsx", "decorators-legacy"], 27 | importOrderMergeDuplicateImports: true, 28 | importOrderCombineTypeAndValueImports: true, 29 | plugins: [ 30 | "@ianvs/prettier-plugin-sort-imports", 31 | "prettier-plugin-tailwindcss", 32 | ], 33 | } 34 | -------------------------------------------------------------------------------- /app/services/session.server.ts: -------------------------------------------------------------------------------- 1 | // app/services/session.server.ts 2 | import { createCookieSessionStorage } from "@remix-run/node" 3 | import { createThemeSessionResolver } from "remix-themes" 4 | 5 | // export the whole sessionStorage object 6 | export let sessionStorage = createCookieSessionStorage({ 7 | cookie: { 8 | name: "_session", // use any name you want here 9 | sameSite: "lax", // this helps with CSRF 10 | path: "/", // remember to add this so the cookie will work in all routes 11 | httpOnly: true, // for security reasons, make this cookie http only 12 | secrets: [process.env.SESSION_SECRET!], // replace this with an actual secret 13 | secure: process.env.NODE_ENV === "production", // enable this in prod only 14 | }, 15 | }) 16 | 17 | // you can also export the methods individually for your own usage 18 | export let { getSession, commitSession, destroySession } = sessionStorage 19 | 20 | export const themeSessionResolver = createThemeSessionResolver(sessionStorage) 21 | -------------------------------------------------------------------------------- /app/components/error-boundry.tsx: -------------------------------------------------------------------------------- 1 | import { useRouteError } from "@remix-run/react" 2 | import { ExclamationTriangleIcon } from "@radix-ui/react-icons" 3 | 4 | import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert" 5 | 6 | type Props = { 7 | renderError?: (error: unknown) => React.ReactNode 8 | } 9 | 10 | export const CommonErrorBoundary = ({ renderError }: Props) => { 11 | const error = useRouteError() 12 | 13 | // Capture error and send to Sentry if needed 14 | 15 | return ( 16 | <> 17 | {renderError ? ( 18 | renderError(error) 19 | ) : ( 20 |
21 | 22 | 23 | Error 24 | 25 | Something went wrong. Please try refreshing page 26 | 27 | 28 |
29 | )} 30 | 31 | ) 32 | } 33 | -------------------------------------------------------------------------------- /app/lib/server/csrf.server.ts: -------------------------------------------------------------------------------- 1 | // app/utils/csrf.server.ts 2 | import { createCookie } from "@remix-run/node" // or cloudflare/deno 3 | import { CSRF, CSRFError } from "remix-utils/csrf/server" 4 | 5 | export const cookie = createCookie("csrf", { 6 | path: "/", 7 | httpOnly: true, 8 | secure: process.env.NODE_ENV === "production", 9 | sameSite: "lax", 10 | secrets: [process.env.SESSION_SECRET!], 11 | }) 12 | 13 | export const csrf = new CSRF({ 14 | cookie, 15 | // what key in FormData objects will be used for the token, defaults to `csrf` 16 | formDataKey: "csrf", 17 | // an optional secret used to sign the token, recommended for extra safety 18 | secret: process.env.SESSION_SECRET!, 19 | }) 20 | 21 | export async function validateCsrfToken(request: Request) { 22 | try { 23 | await csrf.validate(request) 24 | } catch (error) { 25 | if (error instanceof CSRFError) { 26 | throw new Response("Invalid CSRF token", { status: 403 }) 27 | } 28 | throw error 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /app/lib/brand/config.ts: -------------------------------------------------------------------------------- 1 | import type { DefaultSeoProps } from "../server/seo/types" 2 | 3 | export const siteConfig = { 4 | title: "Remix SaaSkit", 5 | description: 6 | "Remix SaaSkit is a starter kit for building SaaS applications with Remix.", 7 | baseUrl: "https://demo.saaskits.dev", 8 | ogImage: "https://demo.saaskits.dev/og-image.png", 9 | } as const 10 | 11 | export const seoConfig: DefaultSeoProps = { 12 | title: siteConfig.title, 13 | description: 14 | "Remix SaaSkit is a starter kit for building SaaS applications with Remix.", 15 | openGraph: { 16 | type: "website", 17 | locale: "en_US", 18 | url: siteConfig.baseUrl, 19 | title: siteConfig.title, 20 | description: siteConfig.description, 21 | siteName: siteConfig.title, 22 | images: [ 23 | { 24 | url: siteConfig.ogImage, 25 | width: 1200, 26 | height: 630, 27 | alt: siteConfig.title, 28 | }, 29 | ], 30 | }, 31 | twitter: { 32 | handle: "@SaaSKits", 33 | site: siteConfig.baseUrl, 34 | cardType: "summary_large_image", 35 | }, 36 | } 37 | -------------------------------------------------------------------------------- /app/routes/dashboard/route.tsx: -------------------------------------------------------------------------------- 1 | import { redirect, type LoaderFunctionArgs } from "@remix-run/node" 2 | import { Outlet } from "@remix-run/react" 3 | 4 | import { authenticator } from "@/services/auth.server" 5 | import { getSubscriptionByUserId } from "@/models/subscription" 6 | import { getUserById } from "@/models/user" 7 | 8 | import { Shell } from "./shell" 9 | 10 | export const loader = async ({ request }: LoaderFunctionArgs) => { 11 | const session = await authenticator.isAuthenticated(request, { 12 | failureRedirect: "/login", 13 | }) 14 | 15 | const user = await getUserById(session.id) 16 | 17 | if (!user) { 18 | return redirect("/login") 19 | } 20 | 21 | if (!user.customerId) { 22 | return redirect("/resources/stripe/create-customer") 23 | } 24 | 25 | const subscription = await getSubscriptionByUserId(user.id) 26 | 27 | if (!subscription) { 28 | return redirect("/resources/stripe/create-subscription") 29 | } 30 | 31 | return { user } 32 | } 33 | export default function Dashboard() { 34 | return ( 35 | 36 | 37 | 38 | ) 39 | } 40 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 SaaS Kits 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 | -------------------------------------------------------------------------------- /app/models/plan.ts: -------------------------------------------------------------------------------- 1 | import type { Plan, Prisma } from "@prisma/client" 2 | 3 | import { prisma } from "@/services/db/db.server" 4 | 5 | export const getPlanById = async ( 6 | id: Plan["id"], 7 | options?: Prisma.PlanInclude 8 | ) => { 9 | return await prisma.plan.findUnique({ 10 | where: { id }, 11 | }) 12 | } 13 | 14 | export const getAllPlans = async () => { 15 | return await prisma.plan.findMany({ 16 | where: { isActive: true }, 17 | include: { prices: true }, 18 | }) 19 | } 20 | 21 | export const getPlanByIdWithPrices = async (id: Plan["id"]) => { 22 | return await prisma.plan.findUnique({ 23 | where: { id }, 24 | include: { 25 | prices: true, 26 | }, 27 | }) 28 | } 29 | 30 | export const getFreePlan = async () => { 31 | return await prisma.plan.findFirst({ 32 | where: { name: "Free", isActive: true }, 33 | include: { 34 | prices: true, 35 | }, 36 | }) 37 | } 38 | 39 | export const getPlanByStripeId = async (stripePlanId: Plan["stripePlanId"]) => { 40 | return await prisma.plan.findFirst({ 41 | where: { stripePlanId }, 42 | include: { 43 | prices: true, 44 | }, 45 | }) 46 | } 47 | -------------------------------------------------------------------------------- /app/components/ui/input.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | 3 | import { cn } from "@/lib/utils" 4 | 5 | export interface InputProps 6 | extends React.InputHTMLAttributes { 7 | error?: JSX.Element | string 8 | } 9 | 10 | const Input = React.forwardRef( 11 | ({ className, type, error, ...props }, ref) => { 12 | return ( 13 | <> 14 | 24 | {error &&
{error}
} 25 | 26 | ) 27 | } 28 | ) 29 | Input.displayName = "Input" 30 | 31 | export { Input } 32 | -------------------------------------------------------------------------------- /app/components/ui/checkbox.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import * as CheckboxPrimitive from "@radix-ui/react-checkbox" 3 | import { Check } from "lucide-react" 4 | 5 | import { cn } from "@/lib/utils" 6 | 7 | const Checkbox = React.forwardRef< 8 | React.ElementRef, 9 | React.ComponentPropsWithoutRef 10 | >(({ className, ...props }, ref) => ( 11 | 19 | 22 | 23 | 24 | 25 | )) 26 | Checkbox.displayName = CheckboxPrimitive.Root.displayName 27 | 28 | export { Checkbox } 29 | -------------------------------------------------------------------------------- /app/routes/resources+/stripe.create-customer.ts: -------------------------------------------------------------------------------- 1 | import type { LoaderFunctionArgs } from "@remix-run/node" 2 | import { redirect } from "@remix-run/node" 3 | 4 | import { authenticator } from "@/services/auth.server" 5 | import { createStripeCustomer } from "@/services/stripe/stripe.server" 6 | import { getUserById, updateUserById } from "@/models/user" 7 | 8 | export async function loader({ request }: LoaderFunctionArgs) { 9 | const session = await authenticator.isAuthenticated(request, { 10 | failureRedirect: "/login", 11 | }) 12 | 13 | const user = await getUserById(session.id) 14 | if (!user) return redirect("/login") 15 | if (!user.email || !user.fullName) return redirect("/login") 16 | if (user.customerId) return redirect("/dashboard") 17 | 18 | const stripeCustomer = await createStripeCustomer( 19 | { 20 | email: user.email, 21 | fullName: user.fullName, 22 | }, 23 | { 24 | // send user id to stripe so we can match it later in webhook 25 | userId: user.id, 26 | } 27 | ) 28 | if (!stripeCustomer) throw new Error("Unable to create Stripe Customer.") 29 | 30 | // Update user. 31 | await updateUserById(user.id, { customerId: stripeCustomer.id }) 32 | 33 | return redirect("/dashboard") 34 | } 35 | -------------------------------------------------------------------------------- /app/routes/dashboard/theme-toggle.tsx: -------------------------------------------------------------------------------- 1 | import { Moon, Sun } from "lucide-react" 2 | import { Theme, useTheme } from "remix-themes" 3 | 4 | import { Button } from "@/components/ui/button" 5 | import { 6 | DropdownMenu, 7 | DropdownMenuContent, 8 | DropdownMenuItem, 9 | DropdownMenuTrigger, 10 | } from "@/components/ui/dropdown-menu" 11 | 12 | export function ThemeToggle() { 13 | const [, setTheme] = useTheme() 14 | 15 | return ( 16 | 17 | 18 | 23 | 24 | 25 | setTheme(Theme.LIGHT)}> 26 | Light 27 | 28 | setTheme(Theme.DARK)}> 29 | Dark 30 | 31 | 32 | 33 | ) 34 | } 35 | -------------------------------------------------------------------------------- /app/lib/assets/logos/google.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | 3 | const GoogleLogo = (props: React.SVGProps) => ( 4 | 10 | 11 | 15 | 19 | 23 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | ) 35 | export default GoogleLogo 36 | -------------------------------------------------------------------------------- /app/models/subscription.ts: -------------------------------------------------------------------------------- 1 | import type { Prisma, Subscription, User } from "@prisma/client" 2 | 3 | import { prisma } from "@/services/db/db.server" 4 | 5 | export const getSubscriptionById = async ( 6 | id: Subscription["id"], 7 | options?: Prisma.SubscriptionInclude 8 | ) => { 9 | return await prisma.subscription.findUnique({ 10 | where: { id }, 11 | include: options, 12 | }) 13 | } 14 | 15 | export const getSubscriptionByStripeId = async ( 16 | subscriptionId: Subscription["subscriptionId"] 17 | ) => { 18 | return await prisma.subscription.findFirst({ 19 | where: { subscriptionId }, 20 | }) 21 | } 22 | 23 | export const getSubscriptionByUserId = async (userId: User["id"]) => { 24 | return await prisma.subscription.findFirst({ 25 | where: { userId }, 26 | }) 27 | } 28 | 29 | export const createSubscription = async ( 30 | data: Prisma.SubscriptionUncheckedCreateInput 31 | ) => { 32 | return await prisma.subscription.create({ 33 | data, 34 | }) 35 | } 36 | 37 | export const updateSubscription = async ( 38 | id: Subscription["id"], 39 | data: Prisma.SubscriptionUncheckedUpdateInput 40 | ) => { 41 | return await prisma.subscription.update({ 42 | where: { id: id }, 43 | data, 44 | }) 45 | } 46 | 47 | export const deleteSubscriptionByCustomerId = async ( 48 | customerId: User["customerId"] 49 | ) => { 50 | if (!customerId) throw new Error("User does not have a Stripe Customer ID.") 51 | return await prisma.subscription.deleteMany({ 52 | where: { customerId }, 53 | }) 54 | } 55 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # syntax = docker/dockerfile:1 2 | 3 | # Adjust NODE_VERSION as desired 4 | ARG NODE_VERSION=18.16.0 5 | FROM node:${NODE_VERSION}-slim as base 6 | 7 | LABEL fly_launch_runtime="Remix/Prisma" 8 | 9 | # Remix/Prisma app lives here 10 | WORKDIR /app 11 | 12 | # Set production environment 13 | ENV NODE_ENV="production" 14 | 15 | # Install pnpm 16 | ARG PNPM_VERSION=8.11.0 17 | RUN npm install -g pnpm@$PNPM_VERSION 18 | 19 | 20 | # Throw-away build stage to reduce size of final image 21 | FROM base as build 22 | 23 | # Install packages needed to build node modules 24 | RUN apt-get update -qq && \ 25 | apt-get install -y build-essential openssl pkg-config python-is-python3 26 | 27 | # Install node modules 28 | COPY --link package.json pnpm-lock.yaml ./ 29 | RUN pnpm install --frozen-lockfile --prod=false 30 | 31 | # Generate Prisma Client 32 | COPY --link prisma . 33 | RUN npx prisma generate 34 | 35 | # Copy application code 36 | COPY --link . . 37 | 38 | # Build application 39 | RUN pnpm run build 40 | 41 | # Remove development dependencies 42 | RUN pnpm prune --prod --config.ignore-scripts=true 43 | 44 | 45 | # Final stage for app image 46 | FROM base 47 | 48 | # Install packages needed for deployment 49 | RUN apt-get update -qq && \ 50 | apt-get install --no-install-recommends -y openssl && \ 51 | rm -rf /var/lib/apt/lists /var/cache/apt/archives 52 | 53 | # Copy built application 54 | COPY --from=build /app /app 55 | 56 | # Start the server by default, this can be overwritten at runtime 57 | EXPOSE 3000 58 | CMD [ "pnpm", "run", "start" ] 59 | -------------------------------------------------------------------------------- /app/components/ui/avatar.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import * as AvatarPrimitive from "@radix-ui/react-avatar" 3 | 4 | import { cn } from "@/lib/utils" 5 | 6 | const Avatar = React.forwardRef< 7 | React.ElementRef, 8 | React.ComponentPropsWithoutRef 9 | >(({ className, ...props }, ref) => ( 10 | 18 | )) 19 | Avatar.displayName = AvatarPrimitive.Root.displayName 20 | 21 | const AvatarImage = React.forwardRef< 22 | React.ElementRef, 23 | React.ComponentPropsWithoutRef 24 | >(({ className, ...props }, ref) => ( 25 | 30 | )) 31 | AvatarImage.displayName = AvatarPrimitive.Image.displayName 32 | 33 | const AvatarFallback = React.forwardRef< 34 | React.ElementRef, 35 | React.ComponentPropsWithoutRef 36 | >(({ className, ...props }, ref) => ( 37 | 45 | )) 46 | AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName 47 | 48 | export { Avatar, AvatarImage, AvatarFallback } 49 | -------------------------------------------------------------------------------- /app/lib/server/seo/seo-helpers.ts: -------------------------------------------------------------------------------- 1 | import type { MetaDescriptor, MetaFunction } from "@remix-run/node" 2 | 3 | import { seoConfig } from "@/lib/brand/config" 4 | 5 | export const getDefaultSeoTags = () => { 6 | return seoConfig 7 | } 8 | 9 | /** 10 | * How to user mergeMeta function 11 | * https://gist.github.com/ryanflorence/ec1849c6d690cfbffcb408ecd633e069#file-usage-ts 12 | */ 13 | 14 | export const mergeMeta = ( 15 | overrideFn: MetaFunction, 16 | appendFn?: MetaFunction 17 | ): MetaFunction => { 18 | return (arg) => { 19 | // get meta from parent routes 20 | let mergedMeta = arg.matches.reduceRight((acc, match) => { 21 | if (match.meta.length > 0 && acc.length == 0) 22 | return acc.concat(match.meta || []) 23 | return acc 24 | }, [] as MetaDescriptor[]) 25 | 26 | // replace any parent meta with the same name or property with the override 27 | let overrides = overrideFn(arg) 28 | for (let override of overrides) { 29 | let index = mergedMeta.findIndex( 30 | (meta) => 31 | ("name" in meta && 32 | "name" in override && 33 | meta.name === override.name) || 34 | ("property" in meta && 35 | "property" in override && 36 | meta.property === override.property) || 37 | ("title" in meta && "title" in override) 38 | ) 39 | if (index !== -1) { 40 | mergedMeta.splice(index, 1, override) 41 | } 42 | } 43 | 44 | // append any additional meta 45 | if (appendFn) { 46 | mergedMeta = mergedMeta.concat(appendFn(arg)) 47 | } 48 | 49 | return mergedMeta 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /app/routes/_index/faq.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | Accordion, 3 | AccordionContent, 4 | AccordionItem, 5 | AccordionTrigger, 6 | } from "@/components/ui/accordion" 7 | 8 | export default function Faqs() { 9 | return ( 10 |
11 |

12 | Frequently Asked{" "} 13 | 14 | Questions 15 | 16 |

17 | 22 | 23 | Is it accessible? 24 | 25 | Yes. It adheres to the WAI-ARIA design pattern. 26 | 27 | 28 | 29 | Is it styled? 30 | 31 | Yes. It comes with default styles that matches the other 32 | components' aesthetic. 33 | 34 | 35 | 36 | Is it animated? 37 | 38 | Yes. It's animated by default, but you can disable it if you 39 | prefer. 40 | 41 | 42 | 43 |
44 | ) 45 | } 46 | -------------------------------------------------------------------------------- /app/components/pricing/containers.tsx: -------------------------------------------------------------------------------- 1 | import { cn } from "@/lib/utils" 2 | 3 | type PricingCardProps = { 4 | children: React.ReactNode 5 | isFeatured?: boolean 6 | } 7 | export const PricingCard = ({ children, isFeatured }: PricingCardProps) => { 8 | return ( 9 |
16 |
17 | {children} 18 |
19 |
20 | ) 21 | } 22 | 23 | type FeatureListContainerProps = { 24 | children: React.ReactNode 25 | } 26 | 27 | export const FeatureListContainer = ({ 28 | children, 29 | }: FeatureListContainerProps) => { 30 | return ( 31 |
    {children}
32 | ) 33 | } 34 | 35 | export const CTAContainer = ({ children }: FeatureListContainerProps) => { 36 | return ( 37 |
38 | {children} 39 |
40 | ) 41 | } 42 | 43 | type FeaturedBadgeContainerProps = { 44 | children: React.ReactNode 45 | } 46 | 47 | export const FeaturedBadgeContainer = ({ 48 | children, 49 | }: FeaturedBadgeContainerProps) => { 50 | return ( 51 |
52 | 53 | {children} 54 | 55 |
56 | ) 57 | } 58 | -------------------------------------------------------------------------------- /app/components/email/verify-email-template.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | Body, 3 | Container, 4 | Head, 5 | Heading, 6 | Hr, 7 | Html, 8 | Preview, 9 | } from "@react-email/components" 10 | 11 | import { siteConfig } from "@/lib/brand/config" 12 | 13 | interface VerificationEmailProps { 14 | validationCode?: string 15 | } 16 | 17 | export const VerificationEmailTemplate = ({ 18 | validationCode = "tt226-5398x", 19 | }: VerificationEmailProps) => ( 20 | 21 | 22 | Your verification code for {siteConfig.title} 23 | 24 | 25 | 26 | Your verification code for {siteConfig.title} 27 | 28 | {validationCode} 29 |
30 |
31 | 32 | 33 | ) 34 | 35 | export default VerificationEmailTemplate 36 | 37 | const main = { 38 | backgroundColor: "#ffffff", 39 | fontFamily: 40 | '-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Oxygen-Sans,Ubuntu,Cantarell,"Helvetica Neue",sans-serif', 41 | } 42 | 43 | const container = { 44 | margin: "0 auto", 45 | padding: "20px 0 48px", 46 | width: "560px", 47 | } 48 | 49 | const heading = { 50 | fontSize: "24px", 51 | letterSpacing: "-0.5px", 52 | lineHeight: "1.3", 53 | fontWeight: "400", 54 | color: "#484848", 55 | padding: "17px 0 0", 56 | } 57 | 58 | const hr = { 59 | borderColor: "#dfe1e4", 60 | margin: "42px 0 26px", 61 | } 62 | 63 | const code = { 64 | fontFamily: "monospace", 65 | fontWeight: "700", 66 | padding: "1px 4px", 67 | backgroundColor: "#dfe1e4", 68 | letterSpacing: "-0.3px", 69 | fontSize: "21px", 70 | borderRadius: "4px", 71 | color: "#3c4149", 72 | } 73 | -------------------------------------------------------------------------------- /app/routes/_index/social-proof.tsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable jsx-a11y/img-redundant-alt */ 2 | export function SocialProof() { 3 | return ( 4 |
5 |
6 | Image Description 11 | Image Description 16 | Image Description 21 | Image Description 26 |
27 |
28 | Used by 20+ makers 29 |
30 |
31 | ) 32 | } 33 | -------------------------------------------------------------------------------- /app/components/pricing/feature.tsx: -------------------------------------------------------------------------------- 1 | import { CheckIcon, Cross2Icon } from "@radix-ui/react-icons" 2 | import clsx from "clsx" 3 | 4 | export type FeatureType = { 5 | name: string 6 | isAvailable: boolean 7 | inProgress: boolean 8 | } 9 | 10 | export const Feature = ({ name, isAvailable, inProgress }: FeatureType) => ( 11 |
  • 17 | {/* If in progress return disabled */} 18 | {!isAvailable ? ( 19 |
  • 30 | ) 31 | 32 | type FeatureTitleProps = { 33 | children: React.ReactNode 34 | } 35 | 36 | export const FeatureTitle = ({ children }: FeatureTitleProps) => { 37 | return
    {children}
    38 | } 39 | 40 | type FeatureDescriptionProps = { 41 | children: React.ReactNode 42 | } 43 | 44 | export const FeatureDescription = ({ children }: FeatureDescriptionProps) => { 45 | return ( 46 |

    47 | {children} 48 |

    49 | ) 50 | } 51 | 52 | type FeaturePriceProps = { 53 | interval: string 54 | price: string 55 | } 56 | 57 | export const FeaturePrice = ({ interval, price }: FeaturePriceProps) => { 58 | return ( 59 |

    60 | {price} 61 | 62 | /{interval} 63 | 64 |

    65 | ) 66 | } 67 | -------------------------------------------------------------------------------- /app/routes/dashboard/shell.tsx: -------------------------------------------------------------------------------- 1 | import { useState } from "react" 2 | import { HamburgerMenuIcon } from "@radix-ui/react-icons" 3 | 4 | import { Sheet, SheetContent, SheetTrigger } from "@/components/ui/sheet" 5 | 6 | import { Sidebar } from "./sidebar" 7 | import { SidebarContext } from "./sidebar.context" 8 | import { ThemeToggle } from "./theme-toggle" 9 | import { UserNav } from "./user-nav" 10 | 11 | type Props = { 12 | children: React.ReactNode 13 | } 14 | 15 | export function Shell({ children }: Props) { 16 | const [hamMenuOpen, setHamMenuOpen] = useState(false) 17 | 18 | const closeHamMenu = () => { 19 | setHamMenuOpen(false) 20 | } 21 | 22 | return ( 23 |
    24 |
    25 |
    26 | 27 |
    28 |
    29 |
    30 |
    31 | 32 | 33 | 34 | 35 | 36 | 39 | 40 | 41 | 42 | 43 |
    44 | 45 |
    46 | 47 | 48 |
    49 |
    50 | 51 | {/* content */} 52 |
    {children}
    53 |
    54 |
    55 |
    56 | ) 57 | } 58 | -------------------------------------------------------------------------------- /app/components/ui/alert.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import { cva, type VariantProps } from "class-variance-authority" 3 | 4 | import { cn } from "@/lib/utils" 5 | 6 | const alertVariants = cva( 7 | "relative w-full rounded-lg border p-4 [&>svg~*]:pl-7 [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground", 8 | { 9 | variants: { 10 | variant: { 11 | default: "bg-background text-foreground", 12 | destructive: 13 | "border-error/50 text-error dark:border-error [&>svg]:text-error", 14 | success: 15 | "border-green-700/50 dark:border-green-500/50 text-green-700 dark:text-green-500 dark:border-green-500 [&>svg]:text-green-700 dark:[&>svg]:text-green-500", 16 | }, 17 | }, 18 | defaultVariants: { 19 | variant: "default", 20 | }, 21 | } 22 | ) 23 | 24 | const Alert = React.forwardRef< 25 | HTMLDivElement, 26 | React.HTMLAttributes & VariantProps 27 | >(({ className, variant, ...props }, ref) => ( 28 |
    34 | )) 35 | Alert.displayName = "Alert" 36 | 37 | const AlertTitle = React.forwardRef< 38 | HTMLParagraphElement, 39 | React.HTMLAttributes 40 | >(({ className, ...props }, ref) => ( 41 |
    46 | )) 47 | AlertTitle.displayName = "AlertTitle" 48 | 49 | const AlertDescription = React.forwardRef< 50 | HTMLParagraphElement, 51 | React.HTMLAttributes 52 | >(({ className, ...props }, ref) => ( 53 |
    58 | )) 59 | AlertDescription.displayName = "AlertDescription" 60 | 61 | export { Alert, AlertTitle, AlertDescription } 62 | -------------------------------------------------------------------------------- /app/components/ui/button.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import { Slot } from "@radix-ui/react-slot" 3 | import { cva, type VariantProps } from "class-variance-authority" 4 | 5 | import { cn } from "@/lib/utils" 6 | 7 | const buttonVariants = cva( 8 | "inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50", 9 | { 10 | variants: { 11 | variant: { 12 | default: "bg-primary text-primary-foreground hover:bg-primary/90", 13 | destructive: 14 | "bg-destructive text-destructive-foreground hover:bg-destructive/90", 15 | outline: 16 | "border border-input bg-background hover:bg-accent hover:text-accent-foreground", 17 | secondary: 18 | "bg-secondary text-secondary-foreground hover:bg-secondary/80", 19 | ghost: "hover:bg-accent hover:text-accent-foreground", 20 | link: "text-primary underline-offset-4 hover:underline", 21 | }, 22 | size: { 23 | default: "h-10 px-4 py-2", 24 | sm: "h-9 rounded-md px-3", 25 | lg: "h-11 rounded-md px-8", 26 | icon: "h-10 w-10", 27 | }, 28 | }, 29 | defaultVariants: { 30 | variant: "default", 31 | size: "default", 32 | }, 33 | } 34 | ) 35 | 36 | export interface ButtonProps 37 | extends React.ButtonHTMLAttributes, 38 | VariantProps { 39 | asChild?: boolean 40 | } 41 | 42 | const Button = React.forwardRef( 43 | ({ className, variant, size, asChild = false, ...props }, ref) => { 44 | const Comp = asChild ? Slot : "button" 45 | return ( 46 | 51 | ) 52 | } 53 | ) 54 | Button.displayName = "Button" 55 | 56 | export { Button, buttonVariants } 57 | -------------------------------------------------------------------------------- /app/components/ui/tabs.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import * as TabsPrimitive from "@radix-ui/react-tabs" 3 | 4 | import { cn } from "@/lib/utils" 5 | 6 | const Tabs = TabsPrimitive.Root 7 | 8 | const TabsList = React.forwardRef< 9 | React.ElementRef, 10 | React.ComponentPropsWithoutRef 11 | >(({ className, ...props }, ref) => ( 12 | 20 | )) 21 | TabsList.displayName = TabsPrimitive.List.displayName 22 | 23 | const TabsTrigger = React.forwardRef< 24 | React.ElementRef, 25 | React.ComponentPropsWithoutRef 26 | >(({ className, ...props }, ref) => ( 27 | 35 | )) 36 | TabsTrigger.displayName = TabsPrimitive.Trigger.displayName 37 | 38 | const TabsContent = React.forwardRef< 39 | React.ElementRef, 40 | React.ComponentPropsWithoutRef 41 | >(({ className, ...props }, ref) => ( 42 | 50 | )) 51 | TabsContent.displayName = TabsPrimitive.Content.displayName 52 | 53 | export { Tabs, TabsList, TabsTrigger, TabsContent } 54 | -------------------------------------------------------------------------------- /app/tailwind.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | /* ShadCN theme starts */ 6 | @layer base { 7 | :root { 8 | --background: 0 0% 100%; 9 | --foreground: 240 10% 3.9%; 10 | 11 | --card: 0 0% 100%; 12 | --card-foreground: 240 10% 3.9%; 13 | 14 | --popover: 0 0% 100%; 15 | --popover-foreground: 240 10% 3.9%; 16 | 17 | --primary: 240 5.9% 10%; 18 | --primary-foreground: 0 0% 98%; 19 | 20 | --secondary: 240 4.8% 95.9%; 21 | --secondary-foreground: 240 5.9% 10%; 22 | 23 | --muted: 240 4.8% 95.9%; 24 | --muted-foreground: 240 3.8% 46.1%; 25 | 26 | --accent: 240 4.8% 95.9%; 27 | --accent-foreground: 240 5.9% 10%; 28 | 29 | --destructive: 0 84.2% 60.2%; 30 | --destructive-foreground: 0 0% 98%; 31 | 32 | --border: 240 5.9% 90%; 33 | --input: 240 5.9% 90%; 34 | --ring: 240 10% 3.9%; 35 | 36 | --radius: 0.5rem; 37 | } 38 | 39 | .dark, 40 | :root[class~="dark"] { 41 | --background: 240 10% 3.9%; 42 | --foreground: 0 0% 98%; 43 | 44 | --card: 240 10% 3.9%; 45 | --card-foreground: 0 0% 98%; 46 | 47 | --popover: 240 10% 3.9%; 48 | --popover-foreground: 0 0% 98%; 49 | 50 | --primary: 0 0% 98%; 51 | --primary-foreground: 240 5.9% 10%; 52 | 53 | --secondary: 240 3.7% 15.9%; 54 | --secondary-foreground: 0 0% 98%; 55 | 56 | --muted: 240 3.7% 15.9%; 57 | --muted-foreground: 240 5% 64.9%; 58 | 59 | --accent: 240 3.7% 15.9%; 60 | --accent-foreground: 0 0% 98%; 61 | 62 | --destructive: 0 62.8% 30.6%; 63 | --destructive-foreground: 0 0% 98%; 64 | 65 | --border: 240 3.7% 15.9%; 66 | --input: 240 3.7% 15.9%; 67 | --ring: 240 4.9% 83.9%; 68 | } 69 | } 70 | 71 | /* ShadCN theme ends */ 72 | 73 | @layer base { 74 | * { 75 | @apply border-border; 76 | } 77 | body { 78 | @apply bg-background text-foreground; 79 | } 80 | 81 | .wrap-balance { 82 | text-wrap: balance; 83 | } 84 | } 85 | 86 | @layer base { 87 | :root { 88 | --error: 0 84.24% 60.2%; 89 | } 90 | 91 | .dark, 92 | :root[class~="dark"] { 93 | --error: 0 84.24% 60.2%; 94 | } 95 | } 96 | -------------------------------------------------------------------------------- /app/routes/dashboard/sidebar.tsx: -------------------------------------------------------------------------------- 1 | import { useContext } from "react" 2 | import { NavLink } from "@remix-run/react" 3 | import { CreditCard, Globe, Layers2 } from "lucide-react" 4 | 5 | import { Logo } from "@/lib/brand/logo" 6 | import { cn } from "@/lib/utils" 7 | import { Button } from "@/components/ui/button" 8 | 9 | import { SidebarContext } from "./sidebar.context" 10 | 11 | type SidebarProps = { 12 | className?: string 13 | } 14 | 15 | export function Sidebar({ className }: SidebarProps) { 16 | return ( 17 |
    18 |
    19 | {/* TODO: drive this logo using brand config */} 20 | 21 |
    22 |
    23 |
    24 |
    25 | 26 | 27 | Dashboard 28 | 29 | 30 | 31 | Analytics 32 | 33 | 34 | 35 | Billing 36 | 37 |
    38 |
    39 |
    40 |
    41 | ) 42 | } 43 | 44 | type NavigationLinkProps = { 45 | to: string 46 | children: React.ReactNode 47 | } 48 | 49 | const NavigationLink = ({ to, children }: NavigationLinkProps) => { 50 | const { onNavLinkClick } = useContext(SidebarContext) 51 | return ( 52 | { 57 | onNavLinkClick?.() 58 | }} 59 | > 60 | {({ isActive }) => ( 61 | 69 | )} 70 | 71 | ) 72 | } 73 | -------------------------------------------------------------------------------- /app/components/ui/accordion.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import * as AccordionPrimitive from "@radix-ui/react-accordion" 3 | import { ChevronDown } from "lucide-react" 4 | 5 | import { cn } from "@/lib/utils" 6 | 7 | const Accordion = AccordionPrimitive.Root 8 | 9 | const AccordionItem = React.forwardRef< 10 | React.ElementRef, 11 | React.ComponentPropsWithoutRef 12 | >(({ className, ...props }, ref) => ( 13 | 18 | )) 19 | AccordionItem.displayName = "AccordionItem" 20 | 21 | const AccordionTrigger = React.forwardRef< 22 | React.ElementRef, 23 | React.ComponentPropsWithoutRef 24 | >(({ className, children, ...props }, ref) => ( 25 | 26 | svg]:rotate-180", 30 | className 31 | )} 32 | {...props} 33 | > 34 | {children} 35 | 36 | 37 | 38 | )) 39 | AccordionTrigger.displayName = AccordionPrimitive.Trigger.displayName 40 | 41 | const AccordionContent = React.forwardRef< 42 | React.ElementRef, 43 | React.ComponentPropsWithoutRef 44 | >(({ className, children, ...props }, ref) => ( 45 | 50 |
    {children}
    51 |
    52 | )) 53 | 54 | AccordionContent.displayName = AccordionPrimitive.Content.displayName 55 | 56 | export { Accordion, AccordionItem, AccordionTrigger, AccordionContent } 57 | -------------------------------------------------------------------------------- /app/components/ui/card.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | 3 | import { cn } from "@/lib/utils" 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 | )) 45 | CardTitle.displayName = "CardTitle" 46 | 47 | const CardDescription = React.forwardRef< 48 | HTMLParagraphElement, 49 | React.HTMLAttributes 50 | >(({ className, ...props }, ref) => ( 51 |

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

    64 | )) 65 | CardContent.displayName = "CardContent" 66 | 67 | const CardFooter = React.forwardRef< 68 | HTMLDivElement, 69 | React.HTMLAttributes 70 | >(({ className, ...props }, ref) => ( 71 |
    76 | )) 77 | CardFooter.displayName = "CardFooter" 78 | 79 | export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent } 80 | -------------------------------------------------------------------------------- /app/components/email/reset-password-template.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | Body, 3 | Container, 4 | Head, 5 | Heading, 6 | Hr, 7 | Html, 8 | Link, 9 | Preview, 10 | Text, 11 | } from "@react-email/components" 12 | 13 | import { siteConfig } from "@/lib/brand/config" 14 | 15 | interface ResetpasswordEmailProps { 16 | resetLink?: string 17 | } 18 | 19 | // TODO: make ui consistent with verify email 20 | export const ResetPasswordEmailTemplate = ({ 21 | resetLink = "#", 22 | }: ResetpasswordEmailProps) => ( 23 | 24 | 25 | Your verification code for {siteConfig.title} 26 | 27 | 28 | 29 | Your verification code for {siteConfig.title} 30 | 31 | 32 | This link will only be valid for the next 20 minutes. 33 | 34 | 35 | 👉 Click here to reset password in 👈 36 | 37 |
    38 |
    39 | 40 | Log in with this magic link. 41 | 42 | ) 43 | 44 | export default ResetPasswordEmailTemplate 45 | 46 | const link = { 47 | color: "#2754C5", 48 | fontFamily: 49 | "-apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif", 50 | fontSize: "14px", 51 | textDecoration: "underline", 52 | } 53 | 54 | const main = { 55 | backgroundColor: "#ffffff", 56 | fontFamily: 57 | '-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Oxygen-Sans,Ubuntu,Cantarell,"Helvetica Neue",sans-serif', 58 | } 59 | 60 | const container = { 61 | margin: "0 auto", 62 | padding: "20px 0 48px", 63 | width: "560px", 64 | } 65 | 66 | const heading = { 67 | fontSize: "24px", 68 | letterSpacing: "-0.5px", 69 | lineHeight: "1.3", 70 | fontWeight: "400", 71 | color: "#484848", 72 | padding: "17px 0 0", 73 | } 74 | 75 | const paragraph = { 76 | margin: "0 0 15px", 77 | fontSize: "15px", 78 | lineHeight: "1.4", 79 | color: "#3c4149", 80 | } 81 | 82 | const hr = { 83 | borderColor: "#dfe1e4", 84 | margin: "42px 0 26px", 85 | } 86 | -------------------------------------------------------------------------------- /app/routes/dashboard/user-nav.tsx: -------------------------------------------------------------------------------- 1 | import { useFetcher, useRouteLoaderData } from "@remix-run/react" 2 | 3 | import { Avatar, AvatarFallback } from "@/components/ui/avatar" 4 | import { Button } from "@/components/ui/button" 5 | import { 6 | DropdownMenu, 7 | DropdownMenuContent, 8 | DropdownMenuGroup, 9 | DropdownMenuItem, 10 | DropdownMenuLabel, 11 | DropdownMenuSeparator, 12 | DropdownMenuTrigger, 13 | } from "@/components/ui/dropdown-menu" 14 | 15 | import { type loader as dashboardLoader } from "./route" 16 | 17 | export function UserNav() { 18 | const fetcher = useFetcher() 19 | const data = useRouteLoaderData( 20 | "routes/dashboard/route" 21 | ) 22 | return ( 23 | <> 24 | 25 | 26 | 33 | 34 | 35 | 36 |
    37 |

    38 | {data?.user.fullName} 39 |

    40 |

    41 | {data?.user.email} 42 |

    43 |
    44 |
    45 | 46 | 47 | Profile 48 | Billing 49 | Settings 50 | 51 | 52 | 53 | 55 | fetcher.submit({}, { method: "post", action: "/auth/logout" }) 56 | } 57 | > 58 | Log out 59 | 60 |
    61 |
    62 | 63 | ) 64 | } 65 | -------------------------------------------------------------------------------- /app/routes/_index/route.tsx: -------------------------------------------------------------------------------- 1 | import { type LoaderFunctionArgs, type MetaFunction } from "@remix-run/node" 2 | 3 | import { mergeMeta } from "@/lib/server/seo/seo-helpers" 4 | import { authenticator } from "@/services/auth.server" 5 | import { getAllPlans } from "@/models/plan" 6 | import { getUserCurrencyFromRequest } from "@/utils/currency" 7 | 8 | import Faqs from "./faq" 9 | import { FeatureSection } from "./feature-section" 10 | import FeatureWithImage from "./feature-with-image" 11 | import FeaturesVariantB from "./features-variant-b" 12 | import Footer from "./footer" 13 | import { HeroSection } from "./hero-section" 14 | import { LogoCloud } from "./logo-cloud" 15 | import { Pricing } from "./pricing" 16 | 17 | const loginFeatures = [ 18 | "Lorem ipsum, dolor sit amet consectetur adipisicing elit aute id magna.", 19 | "Anim aute id magna aliqua ad ad non deserunt sunt. Qui irure qui lorem cupidatat commodo.", 20 | "Ac tincidunt sapien vehicula erat auctor pellentesque rhoncus.", 21 | ] 22 | 23 | export const loader = async ({ request }: LoaderFunctionArgs) => { 24 | await authenticator.isAuthenticated(request, { 25 | successRedirect: "/dashboard", 26 | }) 27 | 28 | let plans = await getAllPlans() 29 | 30 | const defaultCurrency = getUserCurrencyFromRequest(request) 31 | 32 | plans = plans 33 | .map((plan) => { 34 | return { 35 | ...plan, 36 | prices: plan.prices 37 | .filter((price) => price.currency === defaultCurrency) 38 | .map((price) => ({ 39 | ...price, 40 | amount: price.amount / 100, 41 | })), 42 | } 43 | }) 44 | .sort((a, b) => a.prices[0].amount - b.prices[0].amount) 45 | 46 | return { 47 | plans, 48 | defaultCurrency, 49 | } 50 | } 51 | 52 | export const meta: MetaFunction = mergeMeta( 53 | // these will override the parent meta 54 | () => { 55 | return [{ title: "Home Page" }] 56 | } 57 | ) 58 | 59 | export default function Index() { 60 | return ( 61 |
    62 | 63 |
    64 | 65 | 66 | 72 | 73 | 74 | 75 |
    76 |
    77 |
    78 | ) 79 | } 80 | -------------------------------------------------------------------------------- /app/routes/resources+/stripe.create-subscription.ts: -------------------------------------------------------------------------------- 1 | import type { LoaderFunctionArgs } from "@remix-run/node" 2 | import { redirect } from "@remix-run/node" 3 | 4 | import { authenticator } from "@/services/auth.server" 5 | import { PLAN_INTERVALS } from "@/services/stripe/plans.config" 6 | import { createStripeSubscription } from "@/services/stripe/stripe.server" 7 | import { getFreePlan } from "@/models/plan" 8 | import { 9 | createSubscription, 10 | getSubscriptionByUserId, 11 | } from "@/models/subscription" 12 | import { getUserById } from "@/models/user" 13 | import { getUserCurrencyFromRequest } from "@/utils/currency" 14 | 15 | export async function loader({ request }: LoaderFunctionArgs) { 16 | const session = await authenticator.isAuthenticated(request, { 17 | failureRedirect: "/login", 18 | }) 19 | 20 | const user = await getUserById(session.id) 21 | if (!user) return redirect("/login") 22 | 23 | const subscription = await getSubscriptionByUserId(user.id) 24 | if (subscription?.id) return redirect("/dashboard") 25 | if (!user.customerId) 26 | throw new Error("User does not have a Stripe Customer ID.") 27 | 28 | // Get client's currency and Free Plan price ID. 29 | const currency = getUserCurrencyFromRequest(request) 30 | const planWithPrices = await getFreePlan() 31 | const freePlanPrice = planWithPrices?.prices.find( 32 | (price) => 33 | price.interval === PLAN_INTERVALS.MONTHLY && price.currency === currency 34 | ) 35 | if (!freePlanPrice) throw new Error("Unable to find Free Plan Price") 36 | 37 | const stripeSubscription = await createStripeSubscription( 38 | user.customerId, 39 | freePlanPrice.stripePriceId 40 | ) 41 | if (!stripeSubscription) 42 | throw new Error("Unable to create Stripe Subscription.") 43 | 44 | const storedSubscription = await createSubscription({ 45 | customerId: user.customerId || "", 46 | userId: user.id, 47 | isActive: true, 48 | subscriptionId: String(stripeSubscription.id), 49 | planId: String(freePlanPrice.planId), 50 | priceId: String(freePlanPrice.id), 51 | interval: String(stripeSubscription.items.data[0].plan.interval), 52 | status: stripeSubscription.status, 53 | currentPeriodStart: stripeSubscription.current_period_start, 54 | currentPeriodEnd: stripeSubscription.current_period_end, 55 | cancelAtPeriodEnd: stripeSubscription.cancel_at_period_end, 56 | }) 57 | if (!storedSubscription) throw new Error("Unable to create Subscription.") 58 | 59 | return redirect("/dashboard") 60 | } 61 | -------------------------------------------------------------------------------- /prisma/seed.ts: -------------------------------------------------------------------------------- 1 | import { PrismaClient } from "@prisma/client" 2 | import type { Stripe } from "stripe" 3 | 4 | import { DEFAULT_PLANS } from "@/services/stripe/plans.config" 5 | import { 6 | createStripePrice, 7 | createStripeProduct, 8 | } from "@/services/stripe/stripe.server" 9 | 10 | const prisma = new PrismaClient() 11 | 12 | const seed = async () => { 13 | const plans = await prisma.plan.findMany() 14 | 15 | if (plans.length) { 16 | console.log("Plans already seeded") 17 | return 18 | } 19 | 20 | const planPromises = Object.values(DEFAULT_PLANS).map(async (plan) => { 21 | const { limits, prices, name, description, isActive, listOfFeatures } = plan 22 | 23 | const pricesByInterval = Object.entries(prices).flatMap( 24 | ([interval, price]) => { 25 | return Object.entries(price).map(([currency, amount]) => ({ 26 | interval, 27 | currency, 28 | amount, 29 | })) 30 | } 31 | ) 32 | 33 | const stripeProduct = await createStripeProduct({ 34 | name, 35 | description, 36 | }) 37 | 38 | const stripePrices = await Promise.all( 39 | pricesByInterval.map((price) => 40 | createStripePrice(stripeProduct.id, { 41 | ...price, 42 | amount: price.amount * 100, 43 | nickname: name, 44 | }) 45 | ) 46 | ) 47 | 48 | await prisma.plan.create({ 49 | data: { 50 | name, 51 | description, 52 | isActive, 53 | listOfFeatures: listOfFeatures as any, 54 | limits: { 55 | create: limits, 56 | }, 57 | stripePlanId: stripeProduct.id, 58 | prices: { 59 | create: stripePrices.map((price) => ({ 60 | interval: price?.recurring?.interval || "month", 61 | currency: price.currency, 62 | amount: price.unit_amount || 0, 63 | nickname: price.nickname, 64 | isActive: true, 65 | stripePriceId: price.id, 66 | })), 67 | }, 68 | }, 69 | }) 70 | 71 | return { 72 | product: stripeProduct.id, 73 | prices: stripePrices.map((price) => price.id), 74 | } 75 | }) 76 | 77 | const products: Stripe.BillingPortal.ConfigurationCreateParams.Features.SubscriptionUpdate.Product[] = 78 | await Promise.all(planPromises) 79 | 80 | //await setupStripeCustomerPortal(products); 81 | } 82 | 83 | seed() 84 | .catch((e) => { 85 | console.error(e) 86 | process.exit(1) 87 | }) 88 | .finally(async () => { 89 | console.log("Seeding done") 90 | await prisma.$disconnect() 91 | }) 92 | -------------------------------------------------------------------------------- /tailwind.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('tailwindcss').Config} */ 2 | 3 | const defaultTheme = require("tailwindcss/defaultTheme") 4 | 5 | module.exports = { 6 | darkMode: ["class"], 7 | content: [ 8 | "./pages/**/*.{ts,tsx}", 9 | "./components/**/*.{ts,tsx}", 10 | "./app/**/*.{ts,tsx}", 11 | "./src/**/*.{ts,tsx}", 12 | ], 13 | theme: { 14 | container: { 15 | center: true, 16 | padding: "2rem", 17 | screens: { 18 | "2xl": "1400px", 19 | }, 20 | }, 21 | extend: { 22 | fontFamily: { 23 | sans: ["Inter", ...defaultTheme.fontFamily.sans], 24 | }, 25 | colors: { 26 | border: "hsl(var(--border))", 27 | input: "hsl(var(--input))", 28 | ring: "hsl(var(--ring))", 29 | error: "hsl(var(--error))", 30 | background: "hsl(var(--background))", 31 | foreground: "hsl(var(--foreground))", 32 | primary: { 33 | DEFAULT: "hsl(var(--primary))", 34 | foreground: "hsl(var(--primary-foreground))", 35 | }, 36 | secondary: { 37 | DEFAULT: "hsl(var(--secondary))", 38 | foreground: "hsl(var(--secondary-foreground))", 39 | }, 40 | destructive: { 41 | DEFAULT: "hsl(var(--destructive))", 42 | foreground: "hsl(var(--destructive-foreground))", 43 | }, 44 | muted: { 45 | DEFAULT: "hsl(var(--muted))", 46 | foreground: "hsl(var(--muted-foreground))", 47 | }, 48 | accent: { 49 | DEFAULT: "hsl(var(--accent))", 50 | foreground: "hsl(var(--accent-foreground))", 51 | }, 52 | popover: { 53 | DEFAULT: "hsl(var(--popover))", 54 | foreground: "hsl(var(--popover-foreground))", 55 | }, 56 | card: { 57 | DEFAULT: "hsl(var(--card))", 58 | foreground: "hsl(var(--card-foreground))", 59 | }, 60 | }, 61 | borderRadius: { 62 | lg: "var(--radius)", 63 | md: "calc(var(--radius) - 2px)", 64 | sm: "calc(var(--radius) - 4px)", 65 | }, 66 | keyframes: { 67 | "accordion-down": { 68 | from: { height: 0 }, 69 | to: { height: "var(--radix-accordion-content-height)" }, 70 | }, 71 | "accordion-up": { 72 | from: { height: "var(--radix-accordion-content-height)" }, 73 | to: { height: 0 }, 74 | }, 75 | }, 76 | animation: { 77 | "accordion-down": "accordion-down 0.2s ease-out", 78 | "accordion-up": "accordion-up 0.2s ease-out", 79 | }, 80 | }, 81 | }, 82 | plugins: [require("tailwindcss-animate")], 83 | } 84 | -------------------------------------------------------------------------------- /app/root.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | json, 3 | type LinksFunction, 4 | type LoaderFunctionArgs, 5 | type MetaFunction, 6 | } from "@remix-run/node" 7 | import { 8 | Links, 9 | Meta, 10 | Outlet, 11 | Scripts, 12 | ScrollRestoration, 13 | useLoaderData, 14 | } from "@remix-run/react" 15 | import clsx from "clsx" 16 | import { PreventFlashOnWrongTheme, ThemeProvider, useTheme } from "remix-themes" 17 | import { AuthenticityTokenProvider } from "remix-utils/csrf/react" 18 | 19 | import { csrf } from "./lib/server/csrf.server" 20 | import { getDefaultSeoTags } from "./lib/server/seo/seo-helpers" 21 | import buildTags from "./lib/server/seo/seo-utils" 22 | import { themeSessionResolver } from "./services/session.server" 23 | import styles from "./tailwind.css?url" 24 | 25 | 26 | export const links:LinksFunction = () => { 27 | return [ 28 | { rel: "stylesheet", href: styles } 29 | ]; 30 | } 31 | 32 | export const meta: MetaFunction = ({ data }) => { 33 | return buildTags(getDefaultSeoTags()) 34 | } 35 | 36 | export async function loader({ request }: LoaderFunctionArgs) { 37 | const { getTheme } = await themeSessionResolver(request) 38 | let [token, cookieHeader] = await csrf.commitToken() 39 | 40 | return json( 41 | { token, theme: getTheme() }, 42 | { 43 | headers: { 44 | "Set-Cookie": cookieHeader!, 45 | }, 46 | } 47 | ) 48 | } 49 | 50 | export default function AppWithProviders() { 51 | const data = useLoaderData() 52 | return ( 53 | 54 | 55 | 56 | ) 57 | } 58 | 59 | export function App() { 60 | const data = useLoaderData() 61 | const [theme] = useTheme() 62 | return ( 63 | 64 | 65 | 66 | 67 | 68 | 69 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | ) 86 | } 87 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "remix-saaskit", 3 | "private": true, 4 | "sideEffects": false, 5 | "type": "module", 6 | "scripts": { 7 | "build": "prisma generate && remix vite:build", 8 | "migrate": "prisma generate && prisma db push", 9 | "generate": "prisma generate", 10 | "dev": "remix vite:dev", 11 | "start": "remix-serve ./build/server/index.js", 12 | "typecheck": "tsc", 13 | "seed": "tsx prisma/seed.ts", 14 | "prepare": "husky install" 15 | }, 16 | "dependencies": { 17 | "@conform-to/react": "^0.9.1", 18 | "@conform-to/zod": "^0.9.1", 19 | "@prisma/client": "^5.6.0", 20 | "@radix-ui/react-accordion": "^1.1.2", 21 | "@radix-ui/react-avatar": "^1.0.4", 22 | "@radix-ui/react-checkbox": "^1.0.4", 23 | "@radix-ui/react-dialog": "^1.0.5", 24 | "@radix-ui/react-dropdown-menu": "^2.0.6", 25 | "@radix-ui/react-icons": "^1.3.0", 26 | "@radix-ui/react-label": "^2.0.2", 27 | "@radix-ui/react-slot": "^1.0.2", 28 | "@radix-ui/react-tabs": "^1.0.4", 29 | "@react-email/components": "0.0.11", 30 | "@remix-run/node": "^2.8.1", 31 | "@remix-run/react": "^2.8.1", 32 | "@remix-run/serve": "^2.8.1", 33 | "@remix-run/server-runtime": "^2.8.1", 34 | "class-variance-authority": "^0.7.0", 35 | "clsx": "^2.0.0", 36 | "crypto-js": "^4.2.0", 37 | "intl-parse-accept-language": "^1.0.0", 38 | "invariant": "^2.2.4", 39 | "isbot": "^3.6.8", 40 | "lodash-es": "^4.17.21", 41 | "lucide-react": "^0.293.0", 42 | "prisma": "^5.6.0", 43 | "react": "^18.2.0", 44 | "react-dom": "^18.2.0", 45 | "react-email": "1.9.5", 46 | "remix-auth": "^3.6.0", 47 | "remix-auth-form": "^1.4.0", 48 | "remix-auth-google": "^2.0.0", 49 | "remix-themes": "^1.2.2", 50 | "remix-utils": "^7.3.0", 51 | "resend": "^2.0.0", 52 | "stripe": "^14.5.0", 53 | "tailwind-merge": "^2.0.0", 54 | "tailwindcss-animate": "^1.0.7", 55 | "zod": "^3.22.4" 56 | }, 57 | "devDependencies": { 58 | "@flydotio/dockerfile": "^0.4.11", 59 | "@ianvs/prettier-plugin-sort-imports": "^4.1.1", 60 | "@remix-run/dev": "^2.8.1", 61 | "@remix-run/eslint-config": "^2.8.1", 62 | "@types/lodash": "^4.14.202", 63 | "@types/lodash-es": "^4.17.12", 64 | "@types/react": "^18.2.20", 65 | "@types/react-dom": "^18.2.7", 66 | "autoprefixer": "^10.4.16", 67 | "eslint": "^8.38.0", 68 | "husky": "^8.0.0", 69 | "lint-staged": "^15.2.0", 70 | "prettier": "3.1.1", 71 | "prettier-plugin-tailwindcss": "^0.5.9", 72 | "remix-flat-routes": "^0.6.2", 73 | "tailwindcss": "^3.3.5", 74 | "tsx": "^4.6.2", 75 | "typescript": "^5.1.6", 76 | "vite": "^5.2.8", 77 | "vite-tsconfig-paths": "^4.3.2" 78 | }, 79 | "engines": { 80 | "node": ">=18.0.0" 81 | }, 82 | "lint-staged": { 83 | "**/*.{js,jsx,ts,tsx}": [ 84 | "npx prettier --write" 85 | ] 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /app/routes/_index/feature-with-image.tsx: -------------------------------------------------------------------------------- 1 | import { CheckIcon } from "lucide-react" 2 | 3 | import { cn } from "@/lib/utils" 4 | 5 | type Props = { 6 | features: string[] 7 | title: string 8 | imagePosition?: "left" | "right" 9 | lightFeatureImage: string 10 | darkFeatureImage: string 11 | } 12 | 13 | export default function FeatureWithImage({ 14 | features, 15 | title, 16 | imagePosition = "right", 17 | lightFeatureImage, 18 | darkFeatureImage, 19 | }: Props) { 20 | return ( 21 |
    22 |
    23 |
    24 | {/*
    */} 25 |
    26 |
    32 |
    33 |

    34 | {title} 35 |

    36 | 37 |
    38 | {features.map((feature) => ( 39 |
    43 | 44 | {feature} 45 |
    46 | ))} 47 |
    48 |
    49 |
    50 | 61 | Product screenshot 72 |
    73 |
    74 |
    75 |
    76 | ) 77 | } 78 | -------------------------------------------------------------------------------- /app/routes/_index/hero-section.tsx: -------------------------------------------------------------------------------- 1 | import { NavLink } from "@remix-run/react" 2 | 3 | import { Logo } from "@/lib/brand/logo" 4 | import { Button } from "@/components/ui/button" 5 | 6 | import { Discountbadge } from "./discount-badge" 7 | import { SocialProof } from "./social-proof" 8 | 9 | export function HeroSection() { 10 | return ( 11 |
    12 | 27 |
    28 | 32 | 33 | 40 | 41 | 48 | 49 | 50 | 56 | 57 | 58 |
    59 |
    60 |
    61 | 62 |
    63 |

    64 | Your tagline goes here 65 |

    66 |

    67 | Lorem, ipsum dolor sit amet consectetur adipisicing elit. Rerum 68 | quisquam, iusto voluptatem dolore voluptas non laboriosam soluta 69 | quos quod eos! Sapiente archit 70 |

    71 |
    72 | 73 | 76 | 77 |
    78 |
    79 | 80 |
    81 |
    82 |
    83 |
    84 |
    85 | ) 86 | } 87 | -------------------------------------------------------------------------------- /prisma/schema.prisma: -------------------------------------------------------------------------------- 1 | // This is your Prisma schema file, 2 | // learn more about it in the docs: https://pris.ly/d/prisma-schema 3 | 4 | generator client { 5 | provider = "prisma-client-js" 6 | } 7 | 8 | datasource db { 9 | provider = "mongodb" 10 | url = env("DATABASE_URL") 11 | } 12 | 13 | model User { 14 | id String @id @default(auto()) @map("_id") @db.ObjectId 15 | email String @unique 16 | fullName String 17 | password String? 18 | isGoogleSignUp Boolean @default(false) 19 | emailVerified Boolean @default(false) 20 | createdAt DateTime @default(now()) 21 | updatedAt DateTime @updatedAt 22 | verificationCodeId String? @db.ObjectId 23 | customerId String? 24 | PasswordResetToken PasswordResetToken[] 25 | VerificationCode VerificationCode[] 26 | Subscription Subscription[] 27 | 28 | @@index([customerId], name: "customerId") 29 | } 30 | 31 | model PasswordResetToken { 32 | id String @id @default(auto()) @map("_id") @db.ObjectId 33 | token String @unique 34 | expires BigInt 35 | userId String @db.ObjectId 36 | user User @relation(fields: [userId], references: [id]) 37 | } 38 | 39 | model VerificationCode { 40 | id String @id @default(auto()) @map("_id") @db.ObjectId 41 | code String 42 | expires BigInt 43 | userId String @db.ObjectId 44 | user User @relation(fields: [userId], references: [id]) 45 | } 46 | 47 | // Prisma schema for stripe plan, price, subscription 48 | 49 | model Plan { 50 | id String @id @default(auto()) @map("_id") @db.ObjectId 51 | name String 52 | description String? 53 | prices Price[] 54 | subcriptions Subscription[] 55 | isActive Boolean 56 | stripePlanId String 57 | limits PlanLimit[] 58 | listOfFeatures Json[] 59 | 60 | createdAt DateTime @default(now()) 61 | updatedAt DateTime @updatedAt 62 | } 63 | 64 | model PlanLimit { 65 | id String @id @default(auto()) @map("_id") @db.ObjectId 66 | plan Plan @relation(fields: [planId], references: [id]) 67 | planId String @db.ObjectId 68 | 69 | allowedUsersCount Int 70 | allowedProjectsCount Int 71 | allowedStorageSize Int 72 | } 73 | 74 | model Price { 75 | id String @id @default(auto()) @map("_id") @db.ObjectId 76 | isActive Boolean 77 | currency String 78 | interval String 79 | nickname String? 80 | amount Int 81 | stripePriceId String 82 | planId String @db.ObjectId 83 | plan Plan @relation(fields: [planId], references: [id]) 84 | subscriptions Subscription[] 85 | 86 | createdAt DateTime @default(now()) 87 | updatedAt DateTime @updatedAt 88 | } 89 | 90 | model Subscription { 91 | id String @id @default(auto()) @map("_id") @db.ObjectId 92 | isActive Boolean 93 | status String 94 | cancelAtPeriodEnd Boolean 95 | currentPeriodEnd BigInt 96 | currentPeriodStart BigInt 97 | interval String 98 | customerId String 99 | subscriptionId String 100 | planId String @db.ObjectId 101 | plan Plan @relation(fields: [planId], references: [id]) 102 | userId String @db.ObjectId 103 | user User @relation(fields: [userId], references: [id]) 104 | priceId String @db.ObjectId 105 | price Price @relation(fields: [priceId], references: [id]) 106 | 107 | @@index([customerId], name: "customerId") 108 | @@index([subscriptionId], name: "subscriptionId") 109 | } 110 | -------------------------------------------------------------------------------- /app/routes/_index/pricing.tsx: -------------------------------------------------------------------------------- 1 | import { useState } from "react" 2 | import { NavLink, useLoaderData } from "@remix-run/react" 3 | 4 | import { getformattedCurrency } from "@/lib/utils" 5 | import { 6 | CTAContainer, 7 | FeaturedBadgeContainer, 8 | FeatureListContainer, 9 | PricingCard, 10 | } from "@/components/pricing/containers" 11 | import { 12 | Feature, 13 | FeatureDescription, 14 | FeaturePrice, 15 | FeatureTitle, 16 | FeatureType, 17 | } from "@/components/pricing/feature" 18 | import { PricingSwitch } from "@/components/pricing/pricing-switch" 19 | import { Button } from "@/components/ui/button" 20 | 21 | import type { loader } from "./route" 22 | 23 | export const Pricing = () => { 24 | const { plans, defaultCurrency } = useLoaderData() 25 | const [interval, setInterval] = useState<"month" | "year">("month") 26 | 27 | return ( 28 |
    29 |
    30 |
    31 |

    32 | Pricing Plans 33 |

    34 |
    35 |

    36 | {/* TODO: add content here @keyur */} 37 | Lorem, ipsum dolor sit amet consectetur adipisicing elit. Rerum 38 | quisquam, iusto voluptatem dolore voluptas non laboriosam soluta quos 39 | quod eos! Sapiente archit 40 |

    41 |
    42 | setInterval(value === "0" ? "month" : "year")} 44 | /> 45 | 46 |
    47 | {plans.map((plan) => { 48 | const discount = plan.prices[0].amount * 12 - plan.prices[1].amount 49 | const showDiscount = 50 | interval === "year" && plan.prices[0].amount !== 0 51 | const planPrice = plan.prices.find( 52 | (p) => p.currency === defaultCurrency && p.interval == interval 53 | )?.amount as number 54 | 55 | return ( 56 | 57 | {showDiscount && discount > 0 && ( 58 | 59 | Save {getformattedCurrency(discount, defaultCurrency)} 60 | 61 | )} 62 | {plan.name} 63 | {plan.description} 64 | 68 | 69 | {(plan.listOfFeatures as FeatureType[]).map( 70 | (feature, index) => ( 71 | 77 | ) 78 | )} 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | ) 87 | })} 88 |
    89 |
    90 |
    91 | ) 92 | } 93 | -------------------------------------------------------------------------------- /app/routes/api+/webhook.ts: -------------------------------------------------------------------------------- 1 | // generate action for remix which accepets stripe webhook events 2 | 3 | import { json, type ActionFunctionArgs } from "@remix-run/node" 4 | import type { Subscription } from "@prisma/client" 5 | 6 | import { stripe } from "@/services/stripe/setup.server" 7 | import { getPlanByStripeId } from "@/models/plan" 8 | import { 9 | deleteSubscriptionByCustomerId, 10 | getSubscriptionById, 11 | getSubscriptionByStripeId, 12 | updateSubscription, 13 | } from "@/models/subscription" 14 | import { getUserByStripeCustomerId } from "@/models/user" 15 | 16 | const getStripeWebhookEvent = async (request: Request) => { 17 | const sig = request.headers.get("stripe-signature") 18 | if (!sig) return json({}, { status: 400 }) 19 | 20 | const endpointSecret = process.env.STRIPE_WEBHOOK_SECRET 21 | if (!endpointSecret) return json({}, { status: 400 }) 22 | 23 | try { 24 | const payload = await request.text() 25 | const event = stripe.webhooks.constructEvent(payload, sig, endpointSecret) 26 | return event 27 | } catch (err) { 28 | return json({}, { status: 400 }) 29 | } 30 | } 31 | 32 | const sendPlanEndNotification = async (id: Subscription["id"]) => { 33 | const subscription = await getSubscriptionById(id, { 34 | user: true, 35 | plan: true, 36 | price: true, 37 | }) 38 | if (!subscription) return 39 | if (subscription.status == "trialing") { 40 | // TODO: send trial ending soon email 41 | console.log("Trial ending soon") 42 | } else { 43 | // TODO: send trial ending soon email 44 | console.log("Plan ending soon") 45 | } 46 | } 47 | 48 | export async function action({ request }: ActionFunctionArgs) { 49 | const event = await getStripeWebhookEvent(request) 50 | 51 | // Handle the event 52 | switch (event.type) { 53 | case "customer.subscription.created": 54 | // Update subscription in database. 55 | // We have handled this in the create-subscription route. 56 | break 57 | case "customer.subscription.updated": 58 | // Update subscription in database. 59 | const stripeSubscription = event.data.object 60 | const customerId = stripeSubscription.customer 61 | 62 | if (!customerId) return new Response("Success", { status: 200 }) 63 | 64 | const user = await getUserByStripeCustomerId(customerId as string) 65 | 66 | if (!user) return new Response("Success", { status: 200 }) 67 | 68 | const subscriptionByStripeId = await getSubscriptionByStripeId( 69 | stripeSubscription.id 70 | ) 71 | 72 | console.log(subscriptionByStripeId) 73 | 74 | if (!subscriptionByStripeId?.id) return json({}, { status: 200 }) 75 | 76 | const dbPlan = await getPlanByStripeId( 77 | stripeSubscription.items.data[0].plan.product as string 78 | ) 79 | const dbPrice = dbPlan?.prices.find( 80 | (price) => 81 | price.stripePriceId === stripeSubscription.items.data[0].price.id 82 | ) 83 | 84 | await updateSubscription(subscriptionByStripeId.id, { 85 | isActive: 86 | stripeSubscription.status === "active" || 87 | stripeSubscription.status === "trialing", 88 | status: stripeSubscription.status, 89 | planId: dbPlan?.id, 90 | priceId: dbPrice?.id, 91 | interval: String(stripeSubscription.items.data[0].plan.interval), 92 | currentPeriodStart: stripeSubscription.current_period_start, 93 | currentPeriodEnd: stripeSubscription.current_period_end, 94 | cancelAtPeriodEnd: stripeSubscription.cancel_at_period_end, 95 | }) 96 | 97 | break 98 | case "customer.subscription.deleted": 99 | // Delete subscription from database. 100 | const subscriptionId = event.data.object.id 101 | const subscription = await getSubscriptionByStripeId(subscriptionId) 102 | if (!subscription) return json({}, { status: 200 }) 103 | await sendPlanEndNotification(subscription.id) 104 | 105 | await deleteSubscriptionByCustomerId( 106 | event.data.object.customer.toString() 107 | ) 108 | break 109 | default: 110 | return json({}, { status: 200 }) 111 | } 112 | 113 | return json({}, { status: 200 }) 114 | } 115 | -------------------------------------------------------------------------------- /app/entry.server.tsx: -------------------------------------------------------------------------------- 1 | /** 2 | * By default, Remix will handle generating the HTTP Response for you. 3 | * You are free to delete this file if you'd like to, but if you ever want it revealed again, you can run `npx remix reveal` ✨ 4 | * For more information, see https://remix.run/file-conventions/entry.server 5 | */ 6 | 7 | import { PassThrough } from "node:stream" 8 | import type { AppLoadContext, EntryContext } from "@remix-run/node" 9 | import { createReadableStreamFromReadable } from "@remix-run/node" 10 | import { RemixServer } from "@remix-run/react" 11 | import isbot from "isbot" 12 | import { renderToPipeableStream } from "react-dom/server" 13 | 14 | const ABORT_DELAY = 5_000 15 | 16 | export default function handleRequest( 17 | request: Request, 18 | responseStatusCode: number, 19 | responseHeaders: Headers, 20 | remixContext: EntryContext, 21 | loadContext: AppLoadContext 22 | ) { 23 | return isbot(request.headers.get("user-agent")) 24 | ? handleBotRequest( 25 | request, 26 | responseStatusCode, 27 | responseHeaders, 28 | remixContext 29 | ) 30 | : handleBrowserRequest( 31 | request, 32 | responseStatusCode, 33 | responseHeaders, 34 | remixContext 35 | ) 36 | } 37 | 38 | function handleBotRequest( 39 | request: Request, 40 | responseStatusCode: number, 41 | responseHeaders: Headers, 42 | remixContext: EntryContext 43 | ) { 44 | return new Promise((resolve, reject) => { 45 | let shellRendered = false 46 | const { pipe, abort } = renderToPipeableStream( 47 | , 52 | { 53 | onAllReady() { 54 | shellRendered = true 55 | const body = new PassThrough() 56 | const stream = createReadableStreamFromReadable(body) 57 | 58 | responseHeaders.set("Content-Type", "text/html") 59 | 60 | resolve( 61 | new Response(stream, { 62 | headers: responseHeaders, 63 | status: responseStatusCode, 64 | }) 65 | ) 66 | 67 | pipe(body) 68 | }, 69 | onShellError(error: unknown) { 70 | reject(error) 71 | }, 72 | onError(error: unknown) { 73 | responseStatusCode = 500 74 | // Log streaming rendering errors from inside the shell. Don't log 75 | // errors encountered during initial shell rendering since they'll 76 | // reject and get logged in handleDocumentRequest. 77 | if (shellRendered) { 78 | console.error(error) 79 | } 80 | }, 81 | } 82 | ) 83 | 84 | setTimeout(abort, ABORT_DELAY) 85 | }) 86 | } 87 | 88 | function handleBrowserRequest( 89 | request: Request, 90 | responseStatusCode: number, 91 | responseHeaders: Headers, 92 | remixContext: EntryContext 93 | ) { 94 | return new Promise((resolve, reject) => { 95 | let shellRendered = false 96 | const { pipe, abort } = renderToPipeableStream( 97 | , 102 | { 103 | onShellReady() { 104 | shellRendered = true 105 | const body = new PassThrough() 106 | const stream = createReadableStreamFromReadable(body) 107 | 108 | responseHeaders.set("Content-Type", "text/html") 109 | 110 | resolve( 111 | new Response(stream, { 112 | headers: responseHeaders, 113 | status: responseStatusCode, 114 | }) 115 | ) 116 | 117 | pipe(body) 118 | }, 119 | onShellError(error: unknown) { 120 | reject(error) 121 | }, 122 | onError(error: unknown) { 123 | responseStatusCode = 500 124 | // Log streaming rendering errors from inside the shell. Don't log 125 | // errors encountered during initial shell rendering since they'll 126 | // reject and get logged in handleDocumentRequest. 127 | if (shellRendered) { 128 | console.error(error) 129 | } 130 | }, 131 | } 132 | ) 133 | 134 | setTimeout(abort, ABORT_DELAY) 135 | }) 136 | } 137 | -------------------------------------------------------------------------------- /app/services/stripe/stripe.server.ts: -------------------------------------------------------------------------------- 1 | import type { Plan, Price, User } from "@prisma/client" 2 | import type { Stripe } from "stripe" 3 | 4 | import { siteConfig } from "@/lib/brand/config" 5 | 6 | import { PLAN_TYPES, type PLAN_INTERVALS } from "./plans.config" 7 | import { stripe } from "./setup.server" 8 | 9 | export const createStripeProduct = async (plan: Partial) => { 10 | const product = await stripe.products.create({ 11 | name: plan.name || "", 12 | description: plan.description || undefined, 13 | }) 14 | 15 | return product 16 | } 17 | 18 | export const createStripePrice = async ( 19 | id: Plan["id"], 20 | price: Partial 21 | ) => { 22 | return await stripe.prices.create({ 23 | nickname: price.nickname || undefined, 24 | product: id, 25 | currency: price.currency || "usd", 26 | unit_amount: price.amount || 0, 27 | tax_behavior: "inclusive", 28 | recurring: { 29 | interval: (price.interval as PLAN_INTERVALS) || "month", 30 | }, 31 | }) 32 | } 33 | 34 | export const createStripeCustomer = async ( 35 | { email, fullName }: Partial, 36 | metadata: Stripe.MetadataParam 37 | ) => { 38 | return await stripe.customers.create({ 39 | email, 40 | name: fullName, 41 | metadata: metadata, 42 | }) 43 | } 44 | 45 | export const createStripeSubscription = async ( 46 | customerId: User["customerId"], 47 | priceId: Price["id"] 48 | ) => { 49 | if (!customerId) { 50 | throw new Error("Stripe Customer ID is required") 51 | } 52 | return await stripe.subscriptions.create({ 53 | customer: customerId, 54 | items: [ 55 | { 56 | price: priceId, 57 | }, 58 | ], 59 | }) 60 | } 61 | 62 | export const setupStripeCustomerPortal = async ( 63 | products: Stripe.BillingPortal.ConfigurationCreateParams.Features.SubscriptionUpdate.Product[] 64 | ) => { 65 | const portal = await stripe.billingPortal.configurations.create({ 66 | features: { 67 | customer_update: { 68 | allowed_updates: ["address", "tax_id", "phone", "shipping"], 69 | enabled: true, 70 | }, 71 | invoice_history: { 72 | enabled: true, 73 | }, 74 | payment_method_update: { 75 | enabled: true, 76 | }, 77 | subscription_cancel: { 78 | enabled: true, 79 | }, 80 | subscription_pause: { 81 | enabled: false, 82 | }, 83 | subscription_update: { 84 | default_allowed_updates: ["price"], 85 | enabled: true, 86 | proration_behavior: "always_invoice", 87 | products: products.filter(({ product }) => product !== PLAN_TYPES.FREE), 88 | }, 89 | }, 90 | business_profile: { 91 | headline: `${siteConfig.title} - Manage your subscription`, 92 | }, 93 | }) 94 | 95 | return portal 96 | } 97 | 98 | export const createStripeCheckoutSession = async ( 99 | customerId: User["customerId"], 100 | priceId: Price["id"], 101 | successUrl: string, 102 | cancelUrl: string 103 | ) => { 104 | const session = await stripe.checkout.sessions.create({ 105 | customer: customerId as string, 106 | payment_method_types: ["card"], 107 | mode: "subscription", 108 | line_items: [ 109 | { 110 | price: priceId, 111 | quantity: 1, 112 | }, 113 | ], 114 | success_url: successUrl, 115 | cancel_url: cancelUrl, 116 | }) 117 | 118 | return session 119 | } 120 | 121 | export const createStripeCustomerPortalSession = async ( 122 | customerId: User["customerId"], 123 | returnUrl: string 124 | ) => { 125 | const session = await stripe.billingPortal.sessions.create({ 126 | customer: customerId as string, 127 | return_url: returnUrl, 128 | }) 129 | 130 | return session 131 | } 132 | 133 | export const createSingleStripeCheckoutSession = async ( 134 | customerId: User["customerId"], 135 | priceId: Price["id"], 136 | successUrl: string, 137 | cancelUrl: string 138 | ) => { 139 | const session = await stripe.checkout.sessions.create({ 140 | customer: customerId as string, 141 | payment_method_types: ["card"], 142 | mode: "payment", 143 | line_items: [ 144 | { 145 | price: priceId, 146 | quantity: 1, 147 | }, 148 | ], 149 | success_url: successUrl, 150 | cancel_url: cancelUrl, 151 | }) 152 | 153 | return session 154 | } 155 | -------------------------------------------------------------------------------- /app/lib/server/sitemap/sitemap-utils.server.ts: -------------------------------------------------------------------------------- 1 | // This is adapted from https://github.com/kentcdodds/kentcdodds.com 2 | 3 | import type { ServerBuild } from "@remix-run/server-runtime" 4 | import isEqual from "lodash-es/isEqual.js" 5 | 6 | import type { SEOHandle, SitemapEntry } from "./types.ts" 7 | 8 | type Options = { 9 | siteUrl: string 10 | } 11 | 12 | function typedBoolean( 13 | value: T 14 | ): value is Exclude { 15 | return Boolean(value) 16 | } 17 | 18 | function removeTrailingSlash(s: string) { 19 | return s.endsWith("/") ? s.slice(0, -1) : s 20 | } 21 | 22 | async function getSitemapXml( 23 | request: Request, 24 | routes: ServerBuild["routes"], 25 | options: Options 26 | ) { 27 | const { siteUrl } = options 28 | 29 | function getEntry({ 30 | route, 31 | lastmod, 32 | changefreq, 33 | priority = 0.7, 34 | }: SitemapEntry) { 35 | return ` 36 | 37 | ${siteUrl}${route} 38 | ${lastmod ? `${lastmod}` : ""} 39 | ${changefreq ? `${changefreq}` : ""} 40 | ${typeof priority === "number" ? `${priority}` : ""} 41 | 42 | `.trim() 43 | } 44 | 45 | const rawSitemapEntries = ( 46 | await Promise.all( 47 | Object.entries(routes).map(async ([id, { module: mod }]) => { 48 | if (id === "root") return 49 | 50 | const handle = mod.handle as SEOHandle | undefined 51 | if (handle?.getSitemapEntries) { 52 | return handle.getSitemapEntries(request) 53 | } 54 | 55 | // exclude resource routes from the sitemap 56 | // (these are an opt-in via the getSitemapEntries method) 57 | if (!("default" in mod)) return 58 | 59 | const manifestEntry = routes[id] 60 | 61 | if (!manifestEntry) { 62 | console.warn(`Could not find a manifest entry for ${id}`) 63 | return 64 | } 65 | let parentId = manifestEntry.parentId 66 | let parent = parentId ? routes[parentId] : null 67 | 68 | let path 69 | if (manifestEntry.path) { 70 | path = removeTrailingSlash(manifestEntry.path) 71 | } else if (manifestEntry.index) { 72 | path = "" 73 | } else { 74 | return 75 | } 76 | 77 | while (parent) { 78 | // the root path is '/', so it messes things up if we add another '/' 79 | const parentPath = parent.path ? removeTrailingSlash(parent.path) : "" 80 | 81 | if (parent.path) { 82 | path = `${parentPath}/${path}` 83 | parentId = parent.parentId 84 | parent = parentId ? routes[parentId] : null 85 | } else { 86 | path = `${parentPath}/${path}` 87 | parent = null 88 | } 89 | } 90 | 91 | // we can't handle dynamic routes, so if the handle doesn't have a 92 | // getSitemapEntries function, we just 93 | if (path.includes(":")) return 94 | if (id === "root") return 95 | 96 | const entry: SitemapEntry = { route: removeTrailingSlash(path) } 97 | return entry 98 | }) 99 | ) 100 | ) 101 | .flatMap((z) => z) 102 | .filter(typedBoolean) 103 | 104 | const sitemapEntries: Array = [] 105 | for (const entry of rawSitemapEntries) { 106 | const existingEntryForRoute = sitemapEntries.find( 107 | (e) => e.route === entry.route 108 | ) 109 | if (existingEntryForRoute) { 110 | if (!isEqual(existingEntryForRoute, entry)) { 111 | // if (false) { 112 | console.warn( 113 | `Duplicate route for ${entry.route} with different sitemap data`, 114 | { entry, existingEntryForRoute } 115 | ) 116 | } 117 | } else { 118 | sitemapEntries.push(entry) 119 | } 120 | } 121 | 122 | return ` 123 | 124 | 129 | ${sitemapEntries.map((entry) => getEntry(entry)).join("")} 130 | 131 | `.trim() 132 | } 133 | 134 | export { getSitemapXml } 135 | -------------------------------------------------------------------------------- /app/routes/_index/footer.tsx: -------------------------------------------------------------------------------- 1 | import React from "react" 2 | 3 | import { Logo } from "@/lib/brand/logo" 4 | 5 | export default function Footer() { 6 | return ( 7 |
    8 |
    9 |
    10 |
    11 |
    12 |
    13 | 14 |
    15 |

    16 | 476 Shantivan Soc. Rajkot, India 17 |

    18 |
    19 |
    20 |
    21 |
    Links
    22 | 42 |
    43 |
    44 |
    Links
    45 | 65 |
    66 |
    67 |
    Links
    68 | 88 |
    89 |
    90 |
    Links
    91 | 111 |
    112 |
    113 |
    114 |
    115 |
    116 | ) 117 | } 118 | -------------------------------------------------------------------------------- /app/lib/server/auth-utils.sever.ts: -------------------------------------------------------------------------------- 1 | import * as crypto from "crypto" 2 | import type { User } from "@prisma/client" 3 | 4 | import { prisma } from "@/services/db/db.server" 5 | import { sendEmail } from "@/services/email/resend.server" 6 | import ResetPasswordEmailTemplate from "@/components/email/reset-password-template" 7 | import VerificationEmailTemplate from "@/components/email/verify-email-template" 8 | 9 | import { siteConfig } from "../brand/config" 10 | 11 | const EXPIRES_IN = 1000 * 60 * 20 // 20 mins 12 | 13 | const DEFAULT_ALPHABET = "abcdefghijklmnopqrstuvwxyz1234567890" 14 | 15 | export const generateRandomString = ( 16 | length: number, 17 | alphabet: string = DEFAULT_ALPHABET 18 | ) => { 19 | const randomUint32Values = new Uint32Array(length) 20 | crypto.getRandomValues(randomUint32Values) 21 | const u32Max = 0xffffffff 22 | let result = "" 23 | for (let i = 0; i < randomUint32Values.length; i++) { 24 | const rand = randomUint32Values[i] / (u32Max + 1) 25 | result += alphabet[Math.floor(alphabet.length * rand)] 26 | } 27 | return result 28 | } 29 | 30 | export const isWithinExpiration = (expiresInMs: number | bigint): boolean => { 31 | const currentTime = Date.now() 32 | if (currentTime > expiresInMs) return false 33 | return true 34 | } 35 | 36 | // TODO: decide where this util should reside here or auth.server.ts 37 | export const sendResetPasswordLink = async (user: User) => { 38 | async function emailResetLink(code: string) { 39 | // TODO: user env variable for url of reset link 40 | const url = process.env.HOST_URL 41 | ? `${process.env.HOST_URL}/reset-password?code=${code}` 42 | : `http://localhost:3000/reset-password?code=${code}` 43 | await sendEmail( 44 | `${user.fullName} <${user.email}>`, 45 | `Password reset - ${siteConfig.title}`, 46 | ResetPasswordEmailTemplate({ resetLink: url }) 47 | ) 48 | } 49 | 50 | const storedUserTokens = await prisma.passwordResetToken.findMany({ 51 | where: { 52 | userId: user.id, 53 | }, 54 | }) 55 | if (storedUserTokens.length > 0) { 56 | const reusableStoredToken = storedUserTokens.find((token) => { 57 | // check if expiration is within 1 hour 58 | // and reuse the token if true 59 | return isWithinExpiration(Number(token.expires) - EXPIRES_IN / 2) 60 | }) 61 | if (reusableStoredToken) { 62 | await emailResetLink(reusableStoredToken.token) 63 | return 64 | } 65 | } 66 | const token = generateRandomString(63) 67 | await prisma.passwordResetToken.create({ 68 | data: { 69 | token, 70 | expires: new Date().getTime() + EXPIRES_IN, 71 | userId: user.id, 72 | }, 73 | }) 74 | 75 | await emailResetLink(token) 76 | } 77 | 78 | export const sendVerificationCode = async (user: User) => { 79 | const code = generateRandomString(8, "0123456789") 80 | 81 | await prisma.$transaction(async (trx) => { 82 | await trx.verificationCode 83 | .deleteMany({ 84 | where: { 85 | userId: user.id, 86 | }, 87 | }) 88 | .catch() 89 | 90 | await trx.verificationCode.create({ 91 | data: { 92 | code, 93 | userId: user.id, 94 | expires: Date.now() + 1000 * 60 * 20, // 10 minutes 95 | }, 96 | }) 97 | }) 98 | 99 | if (process.env.NODE_ENV === "development") { 100 | console.log(`verification for ${user.email} code is: ${code}`) 101 | } else { 102 | await sendEmail( 103 | `${user.fullName} <${user.email}>`, 104 | `Verification code - ${siteConfig.title}`, 105 | VerificationEmailTemplate({ validationCode: code }) 106 | ) 107 | } 108 | } 109 | 110 | export const validatePasswordResetToken = async (token: string) => { 111 | const storedToken = await prisma.$transaction(async (trx) => { 112 | const storedToken = await trx.passwordResetToken.findFirst({ 113 | where: { 114 | token, 115 | }, 116 | }) 117 | 118 | if (!storedToken) { 119 | throw new Error("Invalid token") 120 | } 121 | 122 | await trx.passwordResetToken.delete({ 123 | where: { 124 | token, 125 | }, 126 | }) 127 | 128 | return storedToken 129 | }) 130 | const tokenExpires = Number(storedToken.expires) 131 | if (!isWithinExpiration(tokenExpires)) { 132 | throw new Error("Expired token") 133 | } 134 | return storedToken.userId 135 | } 136 | -------------------------------------------------------------------------------- /app/services/auth.server.ts: -------------------------------------------------------------------------------- 1 | // app/services/auth.server.ts 2 | import { randomBytes, scrypt, timingSafeEqual } from "crypto" 3 | import type { User } from "@prisma/client" 4 | import { Authenticator } from "remix-auth" 5 | import { FormStrategy } from "remix-auth-form" 6 | import { GoogleStrategy } from "remix-auth-google" 7 | import { z } from "zod" 8 | 9 | import { sendVerificationCode } from "@/lib/server/auth-utils.sever" 10 | import { sessionStorage } from "@/services/session.server" 11 | 12 | import { prisma } from "./db/db.server" 13 | 14 | const payloadSchema = z.object({ 15 | email: z.string(), 16 | password: z.string(), 17 | fullName: z.string().optional(), 18 | tocAccepted: z.literal(true).optional(), 19 | type: z.enum(["login", "signup"]), 20 | }) 21 | 22 | // Create an instance of the authenticator, pass a generic with what 23 | // strategies will return and will store in the session 24 | export let authenticator = new Authenticator(sessionStorage) 25 | 26 | const keyLength = 32 27 | 28 | export const hash = async (password: string): Promise => { 29 | return new Promise((resolve, reject) => { 30 | // generate random 16 bytes long salt - recommended by NodeJS Docs 31 | const salt = randomBytes(16).toString("hex") 32 | 33 | scrypt(password, salt, keyLength, (err, derivedKey) => { 34 | if (err) reject(err) 35 | // derivedKey is of type Buffer 36 | resolve(`${salt}.${derivedKey.toString("hex")}`) 37 | }) 38 | }) 39 | } 40 | 41 | export const compare = async ( 42 | password: string, 43 | hash: string 44 | ): Promise => { 45 | return new Promise((resolve, reject) => { 46 | const [salt, hashKey] = hash.split(".") 47 | // we need to pass buffer values to timingSafeEqual 48 | const hashKeyBuff = Buffer.from(hashKey, "hex") 49 | scrypt(password, salt, keyLength, (err, derivedKey) => { 50 | if (err) reject(err) 51 | // compare the new supplied password with the hashed password using timeSafeEqual 52 | resolve(timingSafeEqual(hashKeyBuff, derivedKey)) 53 | }) 54 | }) 55 | } 56 | 57 | const formStrategy = new FormStrategy(async ({ form, context }) => { 58 | const parsedData = payloadSchema.safeParse(context) 59 | 60 | if (parsedData.success) { 61 | const { email, password, type, fullName } = parsedData.data 62 | 63 | if (type === "login") { 64 | // let user = await login(email, password); 65 | // the type of this user must match the type you pass to the Authenticator 66 | // the strategy will automatically inherit the type if you instantiate 67 | // directly inside the `use` method 68 | const user = await prisma.user.findFirst({ 69 | where: { 70 | email, 71 | }, 72 | }) 73 | 74 | if (user) { 75 | if (user.isGoogleSignUp && !user.password) { 76 | throw new Error("GOOGLE_SIGNUP") 77 | } 78 | 79 | const isPasswordCorrect = await compare(password, user?.password || "") 80 | if (isPasswordCorrect) { 81 | return user 82 | } else { 83 | // TODO: type errors well 84 | throw new Error("INVALID_PASSWORD") 85 | } 86 | } 87 | } else { 88 | const hashedPassword = await hash(password) 89 | const user = await prisma.user.create({ 90 | data: { 91 | email: email, 92 | password: hashedPassword, 93 | fullName: fullName || "", 94 | }, 95 | }) 96 | 97 | sendVerificationCode(user) 98 | 99 | return user 100 | } 101 | } else { 102 | throw new Error("Parsing Failed", { cause: parsedData.error.flatten() }) 103 | } 104 | 105 | throw new Error("Login failed") 106 | }) 107 | 108 | const googleStrategy = new GoogleStrategy( 109 | { 110 | clientID: process.env.GOOGLE_CLIENT_ID || "", 111 | clientSecret: process.env.GOOGLE_CLIENT_SECRET || "", 112 | callbackURL: process.env.HOST_URL 113 | ? `${process.env.HOST_URL}/google/callback` 114 | : "http://localhost:3000/google/callback", 115 | }, 116 | async ({ accessToken, refreshToken, extraParams, profile }) => { 117 | return await prisma.user.upsert({ 118 | where: { 119 | email: profile.emails[0].value, 120 | }, 121 | create: { 122 | email: profile.emails[0].value, 123 | emailVerified: true, 124 | fullName: profile.displayName, 125 | isGoogleSignUp: true, 126 | }, 127 | update: {}, 128 | }) 129 | } 130 | ) 131 | 132 | authenticator.use(formStrategy, "user-pass").use(googleStrategy, "google") 133 | -------------------------------------------------------------------------------- /app/services/stripe/plans.config.ts: -------------------------------------------------------------------------------- 1 | import type { Plan, PlanLimit } from "@prisma/client" 2 | 3 | export const enum PLAN_TYPES { 4 | FREE = "free", 5 | BASIC = "basic", 6 | PRO = "pro", 7 | // ENTERPRISE = 'enterprise', 8 | } 9 | 10 | export const enum PLAN_INTERVALS { 11 | MONTHLY = "month", 12 | YEARLY = "year", 13 | } 14 | 15 | export const enum CURRENCIES { 16 | USD = "usd", 17 | EUR = "eur", 18 | } 19 | 20 | export const DEFAULT_PLANS: { 21 | [key in PLAN_TYPES]: Plan & { 22 | limits: { 23 | [key in Exclude]: number 24 | } 25 | prices: { 26 | [key in PLAN_INTERVALS]: { 27 | [key in CURRENCIES]: number 28 | } 29 | } 30 | } 31 | } = { 32 | [PLAN_TYPES.FREE]: { 33 | id: "free", 34 | name: "Free", 35 | description: "Free plan", 36 | isActive: true, 37 | stripePlanId: "free", 38 | listOfFeatures: [ 39 | { 40 | name: "1 user", 41 | isAvailable: true, 42 | inProgress: false, 43 | }, 44 | { 45 | name: "1 project", 46 | isAvailable: true, 47 | inProgress: false, 48 | }, 49 | { 50 | name: "1GB storage", 51 | isAvailable: true, 52 | inProgress: false, 53 | }, 54 | ], 55 | limits: { 56 | allowedUsersCount: 1, 57 | allowedProjectsCount: 1, 58 | allowedStorageSize: 1, 59 | }, 60 | prices: { 61 | [PLAN_INTERVALS.MONTHLY]: { 62 | [CURRENCIES.USD]: 0, 63 | [CURRENCIES.EUR]: 0, 64 | }, 65 | [PLAN_INTERVALS.YEARLY]: { 66 | [CURRENCIES.USD]: 0, 67 | [CURRENCIES.EUR]: 0, 68 | }, 69 | }, 70 | createdAt: new Date(), 71 | updatedAt: new Date(), 72 | }, 73 | [PLAN_TYPES.BASIC]: { 74 | id: "basic", 75 | name: "Basic", 76 | description: "Basic plan", 77 | isActive: true, 78 | stripePlanId: "basic", 79 | listOfFeatures: [ 80 | { 81 | name: "5 users", 82 | isAvailable: true, 83 | inProgress: false, 84 | }, 85 | { 86 | name: "5 projects", 87 | isAvailable: true, 88 | inProgress: false, 89 | }, 90 | { 91 | name: "5GB storage", 92 | isAvailable: true, 93 | inProgress: false, 94 | }, 95 | ], 96 | limits: { 97 | allowedUsersCount: 5, 98 | allowedProjectsCount: 5, 99 | allowedStorageSize: 5, 100 | }, 101 | prices: { 102 | [PLAN_INTERVALS.MONTHLY]: { 103 | [CURRENCIES.USD]: 10, 104 | [CURRENCIES.EUR]: 10, 105 | }, 106 | [PLAN_INTERVALS.YEARLY]: { 107 | [CURRENCIES.USD]: 100, 108 | [CURRENCIES.EUR]: 100, 109 | }, 110 | }, 111 | createdAt: new Date(), 112 | updatedAt: new Date(), 113 | }, 114 | [PLAN_TYPES.PRO]: { 115 | id: "pro", 116 | name: "Pro", 117 | description: "Pro plan", 118 | isActive: true, 119 | stripePlanId: "pro", 120 | listOfFeatures: [ 121 | { 122 | name: "10 users", 123 | isAvailable: true, 124 | inProgress: false, 125 | }, 126 | { 127 | name: "10 projects", 128 | isAvailable: true, 129 | inProgress: false, 130 | }, 131 | { 132 | name: "10GB storage", 133 | isAvailable: true, 134 | inProgress: false, 135 | }, 136 | ], 137 | limits: { 138 | allowedUsersCount: 10, 139 | allowedProjectsCount: 10, 140 | allowedStorageSize: 10, 141 | }, 142 | prices: { 143 | [PLAN_INTERVALS.MONTHLY]: { 144 | [CURRENCIES.USD]: 20, 145 | [CURRENCIES.EUR]: 20, 146 | }, 147 | [PLAN_INTERVALS.YEARLY]: { 148 | [CURRENCIES.USD]: 200, 149 | [CURRENCIES.EUR]: 200, 150 | }, 151 | }, 152 | createdAt: new Date(), 153 | updatedAt: new Date(), 154 | }, 155 | // [PLAN_TYPES.ENTERPRISE]: { 156 | // id: 'enterprise', 157 | // name: 'Enterprise', 158 | // description: 'Enterprise plan', 159 | // isActive: true, 160 | // listOfFeatures: [ 161 | // 'Unlimited users', 162 | // 'Unlimited projects', 163 | // 'Unlimited storage', 164 | // ], 165 | // limits: { 166 | // allowedUsersCount: 0, 167 | // allowedProjectsCount: 0, 168 | // allowedStorageSize: 0, 169 | // }, 170 | // prices: { 171 | // [PLAN_INTERVALS.MONTHLY]: { 172 | // [CURRENCIES.USD]: 50, 173 | // [CURRENCIES.EUR]: 50, 174 | // }, 175 | // [PLAN_INTERVALS.YEARLY]: { 176 | // [CURRENCIES.USD]: 500, 177 | // [CURRENCIES.EUR]: 500, 178 | // }, 179 | // }, 180 | // createdAt: new Date(), 181 | // updatedAt: new Date() 182 | // }, 183 | } 184 | -------------------------------------------------------------------------------- /app/routes/_auth+/forgot-password.tsx: -------------------------------------------------------------------------------- 1 | import { useId } from "react" 2 | import { 3 | json, 4 | type ActionFunctionArgs, 5 | type LoaderFunctionArgs, 6 | } from "@remix-run/node" 7 | import type { MetaFunction } from "@remix-run/react" 8 | import { Form, useActionData, useNavigation } from "@remix-run/react" 9 | import { conform, useForm } from "@conform-to/react" 10 | import { parse } from "@conform-to/zod" 11 | import { ReloadIcon } from "@radix-ui/react-icons" 12 | import { AuthenticityTokenInput } from "remix-utils/csrf/react" 13 | import { z } from "zod" 14 | 15 | import { sendResetPasswordLink } from "@/lib/server/auth-utils.sever" 16 | import { validateCsrfToken } from "@/lib/server/csrf.server" 17 | import { mergeMeta } from "@/lib/server/seo/seo-helpers" 18 | import { authenticator } from "@/services/auth.server" 19 | import { prisma } from "@/services/db/db.server" 20 | import { CommonErrorBoundary } from "@/components/error-boundry" 21 | import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert" 22 | import { Button } from "@/components/ui/button" 23 | import { Input } from "@/components/ui/input" 24 | import { Label } from "@/components/ui/label" 25 | 26 | const schema = z.object({ 27 | email: z 28 | .string({ required_error: "Email is required" }) 29 | .email("Email is invalid"), 30 | }) 31 | 32 | export async function loader({ request }: LoaderFunctionArgs) { 33 | // If the user is already authenticated redirect to /dashboard directly 34 | return await authenticator.isAuthenticated(request, { 35 | successRedirect: "/", 36 | }) 37 | } 38 | 39 | export const meta: MetaFunction = mergeMeta( 40 | // these will override the parent meta 41 | () => { 42 | return [{ title: "Forgot Password" }] 43 | } 44 | ) 45 | 46 | export const action = async ({ request }: ActionFunctionArgs) => { 47 | await validateCsrfToken(request) 48 | const formData = await request.formData() 49 | 50 | const submission = await parse(formData, { 51 | schema, 52 | }) 53 | 54 | if (!submission.value || submission.intent !== "submit") { 55 | return json({ ...submission, emailSent: false }) 56 | } 57 | 58 | const user = await prisma.user.findFirst({ 59 | where: { 60 | email: submission.value.email, 61 | }, 62 | }) 63 | 64 | if (user) { 65 | await sendResetPasswordLink(user) 66 | return json({ ...submission, emailSent: true } as const) 67 | } 68 | } 69 | 70 | export default function ForgotPassword() { 71 | const navigation = useNavigation() 72 | const isFormSubmitting = navigation.state === "submitting" 73 | const lastSubmission = useActionData() 74 | const id = useId() 75 | 76 | const [form, { email }] = useForm({ 77 | id, 78 | lastSubmission, 79 | shouldValidate: "onBlur", 80 | shouldRevalidate: "onInput", 81 | onValidate({ formData }) { 82 | return parse(formData, { schema }) 83 | }, 84 | }) 85 | 86 | return ( 87 | <> 88 |

    89 | Get password reset link 90 |

    91 | {!lastSubmission?.emailSent ? ( 92 |
    93 |
    94 | 95 |
    96 | 97 |
    98 | 106 |
    107 |
    108 | 109 |
    110 | 120 |
    121 | 122 |
    123 | ) : ( 124 |
    125 | 126 | Link sent successfully! 127 | 128 | Password reset link has been sent to your email. Please check spam 129 | folder as well 130 | 131 | 132 |
    133 | )} 134 | 135 | ) 136 | } 137 | 138 | export function ErrorBoundary() { 139 | return 140 | } 141 | -------------------------------------------------------------------------------- /app/components/ui/sheet.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import * as SheetPrimitive from "@radix-ui/react-dialog" 3 | import { cva, type VariantProps } from "class-variance-authority" 4 | import { X } from "lucide-react" 5 | 6 | import { cn } from "@/lib/utils" 7 | 8 | const Sheet = SheetPrimitive.Root 9 | 10 | const SheetTrigger = SheetPrimitive.Trigger 11 | 12 | const SheetClose = SheetPrimitive.Close 13 | 14 | const SheetPortal = SheetPrimitive.Portal 15 | 16 | const SheetOverlay = React.forwardRef< 17 | React.ElementRef, 18 | React.ComponentPropsWithoutRef 19 | >(({ className, ...props }, ref) => ( 20 | 28 | )) 29 | SheetOverlay.displayName = SheetPrimitive.Overlay.displayName 30 | 31 | const sheetVariants = cva( 32 | "fixed z-50 gap-4 bg-background p-6 shadow-lg transition ease-in-out data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:duration-300 data-[state=open]:duration-500", 33 | { 34 | variants: { 35 | side: { 36 | top: "inset-x-0 top-0 border-b data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top", 37 | bottom: 38 | "inset-x-0 bottom-0 border-t data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom", 39 | left: "inset-y-0 left-0 h-full w-3/4 border-r data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left sm:max-w-sm", 40 | right: 41 | "inset-y-0 right-0 h-full w-3/4 border-l data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right sm:max-w-sm", 42 | }, 43 | }, 44 | defaultVariants: { 45 | side: "right", 46 | }, 47 | } 48 | ) 49 | 50 | interface SheetContentProps 51 | extends React.ComponentPropsWithoutRef, 52 | VariantProps {} 53 | 54 | const SheetContent = React.forwardRef< 55 | React.ElementRef, 56 | SheetContentProps 57 | >(({ side = "right", className, children, ...props }, ref) => ( 58 | 59 | 60 | 65 | {children} 66 | 67 | 68 | Close 69 | 70 | 71 | 72 | )) 73 | SheetContent.displayName = SheetPrimitive.Content.displayName 74 | 75 | const SheetHeader = ({ 76 | className, 77 | ...props 78 | }: React.HTMLAttributes) => ( 79 |
    86 | ) 87 | SheetHeader.displayName = "SheetHeader" 88 | 89 | const SheetFooter = ({ 90 | className, 91 | ...props 92 | }: React.HTMLAttributes) => ( 93 |
    100 | ) 101 | SheetFooter.displayName = "SheetFooter" 102 | 103 | const SheetTitle = React.forwardRef< 104 | React.ElementRef, 105 | React.ComponentPropsWithoutRef 106 | >(({ className, ...props }, ref) => ( 107 | 112 | )) 113 | SheetTitle.displayName = SheetPrimitive.Title.displayName 114 | 115 | const SheetDescription = React.forwardRef< 116 | React.ElementRef, 117 | React.ComponentPropsWithoutRef 118 | >(({ className, ...props }, ref) => ( 119 | 124 | )) 125 | SheetDescription.displayName = SheetPrimitive.Description.displayName 126 | 127 | export { 128 | Sheet, 129 | SheetPortal, 130 | SheetOverlay, 131 | SheetTrigger, 132 | SheetClose, 133 | SheetContent, 134 | SheetHeader, 135 | SheetFooter, 136 | SheetTitle, 137 | SheetDescription, 138 | } 139 | -------------------------------------------------------------------------------- /app/routes/_index/features-variant-b.tsx: -------------------------------------------------------------------------------- 1 | import React from "react" 2 | import { UserIcon } from "lucide-react" 3 | 4 | export default function FeaturesVariantB() { 5 | return ( 6 |
    7 |

    8 | Features that help you get going 9 |

    10 |
    11 |
    12 |
    13 |
    14 | 18 |
    19 | Best feature everyday 20 |
    21 |
    22 |

    23 | Lorem ipsum dolor, sit amet consectetur adipisicing elit. 24 | Consequatur inventore neque aut voluptas, rem debitis quisquam 25 | perferendis odit officia. Deserunt. 26 |

    27 |
    28 |
    29 |
    30 |
    31 |
    32 | 36 |
    37 | Best feature everyday 38 |
    39 |
    40 |

    41 | Lorem ipsum dolor, sit amet consectetur adipisicing elit. 42 | Consequatur inventore neque aut voluptas, rem debitis quisquam 43 | perferendis odit officia. Deserunt. 44 |

    45 |
    46 |
    47 |
    48 |
    49 |
    50 | 54 |
    55 | Best feature everyday 56 |
    57 |
    58 |

    59 | Lorem ipsum dolor, sit amet consectetur adipisicing elit. 60 | Consequatur inventore neque aut voluptas, rem debitis quisquam 61 | perferendis odit officia. Deserunt. 62 |

    63 |
    64 |
    65 |
    66 |
    67 |
    68 | 72 |
    73 | Best feature everyday 74 |
    75 |
    76 |

    77 | Lorem ipsum dolor, sit amet consectetur adipisicing elit. 78 | Consequatur inventore neque aut voluptas, rem debitis quisquam 79 | perferendis odit officia. Deserunt. 80 |

    81 |
    82 |
    83 |
    84 |
    85 |
    86 | 90 |
    91 | Best feature everyday 92 |
    93 |
    94 |

    95 | Lorem ipsum dolor, sit amet consectetur adipisicing elit. 96 | Consequatur inventore neque aut voluptas, rem debitis quisquam 97 | perferendis odit officia. Deserunt. 98 |

    99 |
    100 |
    101 |
    102 |
    103 |
    104 | 108 |
    109 | Best feature everyday 110 |
    111 |
    112 |

    113 | Lorem ipsum dolor, sit amet consectetur adipisicing elit. 114 | Consequatur inventore neque aut voluptas, rem debitis quisquam 115 | perferendis odit officia. Deserunt. 116 |

    117 |
    118 |
    119 |
    120 |
    121 | ) 122 | } 123 | -------------------------------------------------------------------------------- /app/routes/dashboard/plans/route.tsx: -------------------------------------------------------------------------------- 1 | import { useState } from "react" 2 | import { redirect } from "@remix-run/node" 3 | import type { ActionFunctionArgs, LoaderFunctionArgs } from "@remix-run/node" 4 | import { Form, useLoaderData } from "@remix-run/react" 5 | 6 | import { getformattedCurrency } from "@/lib/utils" 7 | import { authenticator } from "@/services/auth.server" 8 | import { createCheckoutSession } from "@/models/checkout" 9 | import { getAllPlans, getPlanByIdWithPrices } from "@/models/plan" 10 | import { getSubscriptionByUserId } from "@/models/subscription" 11 | import { getUserById } from "@/models/user" 12 | import { getUserCurrencyFromRequest } from "@/utils/currency" 13 | import { 14 | CTAContainer, 15 | FeaturedBadgeContainer, 16 | FeatureListContainer, 17 | PricingCard, 18 | } from "@/components/pricing/containers" 19 | import type { FeatureType } from "@/components/pricing/feature" 20 | import { 21 | Feature, 22 | FeatureDescription, 23 | FeaturePrice, 24 | FeatureTitle, 25 | } from "@/components/pricing/feature" 26 | import { PricingSwitch } from "@/components/pricing/pricing-switch" 27 | import { Button } from "@/components/ui/button" 28 | 29 | // TODO: to be discussed with Keyur 30 | declare global { 31 | interface BigInt { 32 | toJSON(): string 33 | } 34 | } 35 | 36 | BigInt.prototype.toJSON = function () { 37 | return this.toString() 38 | } 39 | 40 | export const loader = async ({ request }: LoaderFunctionArgs) => { 41 | const session = await authenticator.isAuthenticated(request, { 42 | failureRedirect: "/login", 43 | }) 44 | 45 | const subscription = await getSubscriptionByUserId(session.id) 46 | 47 | const defaultCurrency = getUserCurrencyFromRequest(request) 48 | 49 | let plans = await getAllPlans() 50 | 51 | plans = plans 52 | .map((plan) => { 53 | return { 54 | ...plan, 55 | prices: plan.prices 56 | .filter((price) => price.currency === defaultCurrency) 57 | .map((price) => ({ 58 | ...price, 59 | amount: price.amount / 100, 60 | })), 61 | } 62 | }) 63 | .sort((a, b) => a.prices[0].amount - b.prices[0].amount) 64 | 65 | return { 66 | plans, 67 | subscription, 68 | defaultCurrency, 69 | } 70 | } 71 | 72 | export const action = async ({ request }: ActionFunctionArgs) => { 73 | const formData = await request.formData() 74 | const planId = formData.get("planId") 75 | const interval = formData.get("interval") as "month" | "year" 76 | const currency = formData.get("currency") as string 77 | 78 | const session = await authenticator.isAuthenticated(request, { 79 | failureRedirect: "/login", 80 | }) 81 | 82 | if (!planId || !interval) { 83 | return redirect("/dashboard/plans") 84 | } 85 | 86 | const dbPlan = await getPlanByIdWithPrices(planId as string) 87 | 88 | if (!dbPlan) { 89 | return redirect("/dashboard/plans") 90 | } 91 | 92 | const user = await getUserById(session.id) 93 | 94 | const price = dbPlan.prices.find( 95 | (p) => p.interval === interval && p.currency === currency 96 | ) 97 | 98 | if (!price) { 99 | return redirect("/dashboard/plans") 100 | } 101 | 102 | const checkout = await createCheckoutSession( 103 | user?.customerId as string, 104 | price.stripePriceId, 105 | `${process.env.HOST_URL}/dashboard/plans`, 106 | `${process.env.HOST_URL}/dashboard/plans` 107 | ) 108 | 109 | if (!checkout) { 110 | return redirect("/dashboard/plans") 111 | } 112 | 113 | return redirect(checkout.url as string) 114 | } 115 | 116 | export default function PlansPage() { 117 | const { plans, subscription, defaultCurrency } = 118 | useLoaderData() 119 | const [interval, setInterval] = useState<"month" | "year">("month") 120 | // render shadcn ui pricing table using Card 121 | 122 | return ( 123 |
    124 |
    125 |
    126 |

    127 | Pricing Plans 128 |

    129 |
    130 |

    131 | {/* TODO: add content here @keyur */} 132 | Lorem, ipsum dolor sit amet consectetur adipisicing elit. Rerum 133 | quisquam, iusto voluptatem dolore voluptas non laboriosam soluta quos 134 | quod eos! Sapiente archit 135 |

    136 |
    137 | setInterval(value === "0" ? "month" : "year")} 139 | /> 140 | 141 |
    142 | {plans.map((plan) => { 143 | const discount = plan.prices[0].amount * 12 - plan.prices[1].amount 144 | const showDiscount = 145 | interval === "year" && plan.prices[0].amount !== 0 146 | const planPrice = plan.prices.find( 147 | (p) => p.currency === defaultCurrency && p.interval == interval 148 | )?.amount as number 149 | 150 | return ( 151 | 152 | {showDiscount && discount > 0 && ( 153 | 154 | Save {getformattedCurrency(discount, defaultCurrency)} 155 | 156 | )} 157 | {plan.name} 158 | {plan.description} 159 | 163 | 164 | {(plan.listOfFeatures as FeatureType[]).map( 165 | (feature, index) => ( 166 | 172 | ) 173 | )} 174 | 175 | 176 |
    177 | 178 | 179 | 184 | 185 | 194 |
    195 |
    196 |
    197 | ) 198 | })} 199 |
    200 |
    201 |
    202 | ) 203 | } 204 | -------------------------------------------------------------------------------- /app/routes/_auth+/reset-password.tsx: -------------------------------------------------------------------------------- 1 | import { useId } from "react" 2 | import { json } from "@remix-run/node" 3 | import type { 4 | ActionFunctionArgs, 5 | LoaderFunctionArgs, 6 | MetaFunction, 7 | } from "@remix-run/node" 8 | import { Form, NavLink, useActionData, useNavigation } from "@remix-run/react" 9 | import { conform, useForm } from "@conform-to/react" 10 | import { parse } from "@conform-to/zod" 11 | import { ReloadIcon } from "@radix-ui/react-icons" 12 | import { AlertCircle } from "lucide-react" 13 | import { AuthenticityTokenInput } from "remix-utils/csrf/react" 14 | import { z } from "zod" 15 | 16 | import { validatePasswordResetToken } from "@/lib/server/auth-utils.sever" 17 | import { validateCsrfToken } from "@/lib/server/csrf.server" 18 | import { mergeMeta } from "@/lib/server/seo/seo-helpers" 19 | import { authenticator, hash } from "@/services/auth.server" 20 | import { prisma } from "@/services/db/db.server" 21 | import { CommonErrorBoundary } from "@/components/error-boundry" 22 | import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert" 23 | import { Button } from "@/components/ui/button" 24 | import { Input } from "@/components/ui/input" 25 | import { Label } from "@/components/ui/label" 26 | 27 | const schema = z 28 | .object({ 29 | password: z.string().min(8, "Password must be at least 8 characters"), 30 | confirmPassword: z 31 | .string() 32 | .min(8, "Password must be at least 8 characters"), 33 | }) 34 | .refine((data) => data.password === data.confirmPassword, { 35 | message: "Please make sure your passwords match", 36 | path: ["confirmPassword"], 37 | }) 38 | 39 | export async function loader({ request }: LoaderFunctionArgs) { 40 | // If the user is already authenticated redirect to /dashboard directly 41 | return await authenticator.isAuthenticated(request, { 42 | successRedirect: "/dashboard", 43 | }) 44 | } 45 | 46 | export const meta: MetaFunction = mergeMeta( 47 | // these will override the parent meta 48 | () => { 49 | return [{ title: "Reset Password" }] 50 | } 51 | ) 52 | 53 | export const action = async ({ request }: ActionFunctionArgs) => { 54 | await validateCsrfToken(request) 55 | const url = new URL(request.url) 56 | 57 | const code = url.searchParams.get("code") 58 | 59 | const formData = await request.formData() 60 | 61 | const submission = await parse(formData, { 62 | schema, 63 | }) 64 | 65 | if (!code) { 66 | return json({ ...submission, isLinkExpired: true, resetSuccess: false }) 67 | } 68 | 69 | if (!submission.value || submission.intent !== "submit") { 70 | return json({ ...submission, isLinkExpired: false, resetSuccess: false }) 71 | } 72 | 73 | // TODO: check naming conventions 74 | const resetToken = await prisma.passwordResetToken.findFirst({ 75 | // TODO: confirm if we should keep it code or rename to token 76 | where: { 77 | token: code, 78 | }, 79 | }) 80 | 81 | if (resetToken) { 82 | try { 83 | const userId = await validatePasswordResetToken(resetToken?.token) 84 | 85 | const hashedPassword = await hash(submission.value.password) 86 | 87 | await prisma.user.update({ 88 | where: { 89 | id: userId, 90 | }, 91 | data: { 92 | password: hashedPassword, 93 | }, 94 | }) 95 | 96 | return json({ ...submission, isLinkExpired: false, resetSuccess: true }) 97 | } catch (error) { 98 | return json({ ...submission, isLinkExpired: true, resetSuccess: false }) 99 | } 100 | } else { 101 | return json({ ...submission, isLinkExpired: true, resetSuccess: false }) 102 | } 103 | } 104 | 105 | export default function ForgotPassword() { 106 | const navigation = useNavigation() 107 | const isFormSubmitting = navigation.state === "submitting" 108 | const lastSubmission = useActionData() 109 | const id = useId() 110 | 111 | const [form, { password, confirmPassword }] = useForm({ 112 | id, 113 | lastSubmission, 114 | shouldValidate: "onBlur", 115 | shouldRevalidate: "onInput", 116 | onValidate({ formData }) { 117 | return parse(formData, { schema }) 118 | }, 119 | }) 120 | 121 | if (lastSubmission?.isLinkExpired) { 122 | return ( 123 | <> 124 |

    125 | Reset Link expired 126 |

    127 |
    128 | 129 | 130 | Error 131 | 132 | Reset link has expired please request new one{" "} 133 | 134 | Request new link 135 | 136 | 137 | 138 |
    139 | 140 | ) 141 | } 142 | if (!lastSubmission?.isLinkExpired && !lastSubmission?.resetSuccess) { 143 | return ( 144 | <> 145 |

    146 | Reset password 147 |

    148 |
    149 |
    150 | 151 |
    152 | 153 |
    154 | 161 |
    162 |
    163 | 164 |
    165 | 166 |
    167 | 174 |
    175 |
    176 | 177 |
    178 | 188 |
    189 | 190 |
    191 | 192 | ) 193 | } 194 | 195 | if (lastSubmission?.resetSuccess) { 196 | return ( 197 | <> 198 |

    199 | Reset Successful 200 |

    201 |
    202 | 203 | 204 | Success 205 | 206 | Your password has been reset successfully.{" "} 207 | 208 | Login 209 | 210 | 211 | 212 |
    213 | 214 | ) 215 | } 216 | } 217 | 218 | export function ErrorBoundary() { 219 | return 220 | } 221 | -------------------------------------------------------------------------------- /app/routes/_auth+/login.tsx: -------------------------------------------------------------------------------- 1 | import { useId } from "react" 2 | import { 3 | json, 4 | redirect, 5 | type ActionFunctionArgs, 6 | type LoaderFunctionArgs, 7 | } from "@remix-run/node" 8 | import type { MetaFunction } from "@remix-run/react" 9 | import { Form, NavLink, useActionData, useNavigation } from "@remix-run/react" 10 | import { conform, useForm } from "@conform-to/react" 11 | import { parse } from "@conform-to/zod" 12 | import { ReloadIcon } from "@radix-ui/react-icons" 13 | import { AuthenticityTokenInput } from "remix-utils/csrf/react" 14 | import { z } from "zod" 15 | 16 | import GoogleLogo from "@/lib/assets/logos/google" 17 | import { sendVerificationCode } from "@/lib/server/auth-utils.sever" 18 | import { validateCsrfToken } from "@/lib/server/csrf.server" 19 | import { mergeMeta } from "@/lib/server/seo/seo-helpers" 20 | import { authenticator } from "@/services/auth.server" 21 | import { prisma } from "@/services/db/db.server" 22 | import { commitSession, getSession } from "@/services/session.server" 23 | import { CommonErrorBoundary } from "@/components/error-boundry" 24 | import { Button } from "@/components/ui/button" 25 | import { Input } from "@/components/ui/input" 26 | import { Label } from "@/components/ui/label" 27 | 28 | const schema = z.object({ 29 | email: z 30 | .string({ required_error: "Please enter email to continue" }) 31 | .email("Please enter a valid email"), 32 | password: z.string().min(8, "Password must be at least 8 characters"), 33 | }) 34 | 35 | export async function loader({ request }: LoaderFunctionArgs) { 36 | // If the user is already authenticated redirect to /dashboard directly 37 | return await authenticator.isAuthenticated(request, { 38 | successRedirect: "/dashboard", 39 | }) 40 | } 41 | 42 | export const meta: MetaFunction = mergeMeta( 43 | // these will override the parent meta 44 | () => { 45 | return [{ title: "Login" }] 46 | } 47 | ) 48 | 49 | export const action = async ({ request }: ActionFunctionArgs) => { 50 | await validateCsrfToken(request) 51 | const clonedRequest = request.clone() 52 | const formData = await clonedRequest.formData() 53 | 54 | const submission = await parse(formData, { 55 | schema: schema.superRefine(async (data, ctx) => { 56 | const existingUser = await prisma.user.findFirst({ 57 | where: { 58 | email: data.email, 59 | }, 60 | select: { id: true }, 61 | }) 62 | 63 | if (!existingUser) { 64 | ctx.addIssue({ 65 | path: ["email"], 66 | code: z.ZodIssueCode.custom, 67 | message: "Either email or password is incorrect", 68 | }) 69 | return 70 | } 71 | }), 72 | async: true, 73 | }) 74 | 75 | if (!submission.value || submission.intent !== "submit") { 76 | return json(submission) 77 | } 78 | 79 | try { 80 | const user = await authenticator.authenticate("user-pass", request, { 81 | throwOnError: true, 82 | context: { 83 | ...submission.value, 84 | type: "login", 85 | }, 86 | }) 87 | 88 | let session = await getSession(request.headers.get("cookie")) 89 | // and store the user data 90 | session.set(authenticator.sessionKey, user) 91 | 92 | let headers = new Headers({ "Set-Cookie": await commitSession(session) }) 93 | 94 | // Todo: make redirect config driven e.g add login success route 95 | if (user.emailVerified) { 96 | return redirect("/dashboard", { headers }) 97 | } 98 | await sendVerificationCode(user) 99 | return redirect("/verify-email", { headers }) 100 | } catch (error) { 101 | // TODO: fix type here 102 | // TODO: create constant for message type of auth errors 103 | const typedError = error as any 104 | 105 | switch (typedError.message) { 106 | case "INVALID_PASSWORD": 107 | return { 108 | ...submission, 109 | error: { email: ["Either email or password is incorrect"] }, 110 | } 111 | case "GOOGLE_SIGNUP": 112 | return { 113 | ...submission, 114 | error: { 115 | email: [ 116 | "You have already signed up with google. Please use google to login", 117 | ], 118 | }, 119 | } 120 | default: 121 | return null 122 | } 123 | } 124 | } 125 | 126 | export default function Login() { 127 | const navigation = useNavigation() 128 | const isFormSubmitting = navigation.state === "submitting" 129 | const isSigningInWithEmail = 130 | isFormSubmitting && navigation.formAction !== "/auth/google" 131 | const isSigningInWithGoogle = 132 | isFormSubmitting && navigation.formAction === "/auth/google" 133 | const lastSubmission = useActionData() 134 | const id = useId() 135 | 136 | const [form, { email, password }] = useForm({ 137 | id, 138 | lastSubmission, 139 | shouldValidate: "onBlur", 140 | shouldRevalidate: "onInput", 141 | onValidate({ formData }) { 142 | return parse(formData, { schema }) 143 | }, 144 | }) 145 | return ( 146 | <> 147 |

    148 | Sign in to your account 149 |

    150 |
    151 |
    152 | 153 |
    154 | 155 |
    156 | 164 |
    165 |
    166 | 167 |
    168 |
    169 | 170 |
    171 |
    172 | 180 |
    181 |
    182 | 183 | 193 | 194 |
    195 | 205 |
    206 |
    207 |

    208 | Not a member?{" "} 209 | 210 | 213 | 214 |

    215 | 216 | 219 | 220 |
    221 |
    222 | 223 | ) 224 | } 225 | 226 | export function ErrorBoundary() { 227 | return 228 | } 229 | -------------------------------------------------------------------------------- /app/components/ui/dropdown-menu.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu" 3 | import { Check, ChevronRight, Circle } from "lucide-react" 4 | 5 | import { cn } from "@/lib/utils" 6 | 7 | const DropdownMenu = DropdownMenuPrimitive.Root 8 | 9 | const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger 10 | 11 | const DropdownMenuGroup = DropdownMenuPrimitive.Group 12 | 13 | const DropdownMenuPortal = DropdownMenuPrimitive.Portal 14 | 15 | const DropdownMenuSub = DropdownMenuPrimitive.Sub 16 | 17 | const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup 18 | 19 | const DropdownMenuSubTrigger = React.forwardRef< 20 | React.ElementRef, 21 | React.ComponentPropsWithoutRef & { 22 | inset?: boolean 23 | } 24 | >(({ className, inset, children, ...props }, ref) => ( 25 | 34 | {children} 35 | 36 | 37 | )) 38 | DropdownMenuSubTrigger.displayName = 39 | DropdownMenuPrimitive.SubTrigger.displayName 40 | 41 | const DropdownMenuSubContent = React.forwardRef< 42 | React.ElementRef, 43 | React.ComponentPropsWithoutRef 44 | >(({ className, ...props }, ref) => ( 45 | 53 | )) 54 | DropdownMenuSubContent.displayName = 55 | DropdownMenuPrimitive.SubContent.displayName 56 | 57 | const DropdownMenuContent = React.forwardRef< 58 | React.ElementRef, 59 | React.ComponentPropsWithoutRef 60 | >(({ className, sideOffset = 4, ...props }, ref) => ( 61 | 62 | 71 | 72 | )) 73 | DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName 74 | 75 | const DropdownMenuItem = React.forwardRef< 76 | React.ElementRef, 77 | React.ComponentPropsWithoutRef & { 78 | inset?: boolean 79 | } 80 | >(({ className, inset, ...props }, ref) => ( 81 | 90 | )) 91 | DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName 92 | 93 | const DropdownMenuCheckboxItem = React.forwardRef< 94 | React.ElementRef, 95 | React.ComponentPropsWithoutRef 96 | >(({ className, children, checked, ...props }, ref) => ( 97 | 106 | 107 | 108 | 109 | 110 | 111 | {children} 112 | 113 | )) 114 | DropdownMenuCheckboxItem.displayName = 115 | DropdownMenuPrimitive.CheckboxItem.displayName 116 | 117 | const DropdownMenuRadioItem = React.forwardRef< 118 | React.ElementRef, 119 | React.ComponentPropsWithoutRef 120 | >(({ className, children, ...props }, ref) => ( 121 | 129 | 130 | 131 | 132 | 133 | 134 | {children} 135 | 136 | )) 137 | DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName 138 | 139 | const DropdownMenuLabel = React.forwardRef< 140 | React.ElementRef, 141 | React.ComponentPropsWithoutRef & { 142 | inset?: boolean 143 | } 144 | >(({ className, inset, ...props }, ref) => ( 145 | 154 | )) 155 | DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName 156 | 157 | const DropdownMenuSeparator = React.forwardRef< 158 | React.ElementRef, 159 | React.ComponentPropsWithoutRef 160 | >(({ className, ...props }, ref) => ( 161 | 166 | )) 167 | DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName 168 | 169 | const DropdownMenuShortcut = ({ 170 | className, 171 | ...props 172 | }: React.HTMLAttributes) => { 173 | return ( 174 | 178 | ) 179 | } 180 | DropdownMenuShortcut.displayName = "DropdownMenuShortcut" 181 | 182 | export { 183 | DropdownMenu, 184 | DropdownMenuTrigger, 185 | DropdownMenuContent, 186 | DropdownMenuItem, 187 | DropdownMenuCheckboxItem, 188 | DropdownMenuRadioItem, 189 | DropdownMenuLabel, 190 | DropdownMenuSeparator, 191 | DropdownMenuShortcut, 192 | DropdownMenuGroup, 193 | DropdownMenuPortal, 194 | DropdownMenuSub, 195 | DropdownMenuSubContent, 196 | DropdownMenuSubTrigger, 197 | DropdownMenuRadioGroup, 198 | } 199 | -------------------------------------------------------------------------------- /app/routes/_index/feature-section.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | CheckIcon, 3 | CreditCard, 4 | Database, 5 | Layers2Icon, 6 | MailIcon, 7 | SearchIcon, 8 | User2Icon, 9 | } from "lucide-react" 10 | 11 | export function FeatureSection() { 12 | return ( 13 | <> 14 |
    15 |
    16 |
    17 |

    18 | All your features in one place 19 |

    20 |

    21 | Lorem ipsum dolor sit amet, consectetur adipisicing elit. Hic 22 | aliquid voluptas saepe maxime asperiores totam fuga assumenda iure 23 | repudiandae. Ab, ipsum vitae! 24 |

    25 |
    26 | 27 |
    28 |
    29 |
    30 |
    31 | 32 |
    33 |
    34 |

    35 | Lorem ipsum dolor sit amet. 36 |

    37 |
    38 |
    39 | 40 |
    Example 1
    41 |
    42 |
    43 | 44 |
    Example 2
    45 |
    46 |
    47 | 48 |
    Example 3
    49 |
    50 |
    51 |
    52 |
    53 |
    54 |
    55 | 56 |
    57 |
    58 |

    59 | Second Feature 60 |

    61 |
    62 |
    63 | 64 |
    Example 1
    65 |
    66 |
    67 | 68 |
    Example 2
    69 |
    70 |
    71 | 72 |
    Example 3
    73 |
    74 |
    75 |
    76 |
    77 |
    78 |
    79 | 80 |
    81 |
    82 |

    83 | Third Feature 84 |

    85 |
    86 |
    87 | 88 |
    Example 1
    89 |
    90 |
    91 | 92 |
    Example 2
    93 |
    94 |
    95 | 96 |
    Example 3
    97 |
    98 |
    99 |
    100 |
    101 |
    102 |
    103 | 104 |
    105 |
    106 |

    107 | Fourth Feature 108 |

    109 |
    110 |
    111 | 112 |
    Example 1
    113 |
    114 |
    115 | 116 |
    Example 2
    117 |
    118 |
    119 | 120 |
    Example 3
    121 |
    122 |
    123 |
    124 |
    125 |
    126 |
    127 | 128 |
    129 |
    130 |

    131 | Fifth Feature 132 |

    133 |
    134 |
    135 | 136 |
    Example 1
    137 |
    138 |
    139 | 140 |
    Example 2
    141 |
    142 |
    143 | 144 |
    Example 3
    145 |
    146 |
    147 |
    148 |
    149 |
    150 |
    151 | 152 |
    153 |
    154 |

    155 | Sixth Feature 156 |

    157 |
    158 |
    159 | 160 |
    Example 1
    161 |
    162 |
    163 | 164 |
    Example 2
    165 |
    166 |
    167 | 168 |
    Example 3
    169 |
    170 |
    171 |
    172 |
    173 |
    174 |
    175 | 176 | ) 177 | } 178 | -------------------------------------------------------------------------------- /app/lib/brand/logo.tsx: -------------------------------------------------------------------------------- 1 | import { cn } from "../utils" 2 | 3 | type Props = React.SVGProps 4 | export const Logo = ({ className, ...props }: Props) => { 5 | return ( 6 | 13 | 17 | 21 | 25 | 29 | 33 | 37 | 41 | 45 | 49 | 50 | ) 51 | } 52 | -------------------------------------------------------------------------------- /app/routes/_auth+/verify-email.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | json, 3 | redirect, 4 | type ActionFunctionArgs, 5 | type LoaderFunctionArgs, 6 | } from "@remix-run/node" 7 | import type { MetaFunction } from "@remix-run/react" 8 | import { 9 | Form, 10 | useActionData, 11 | useLoaderData, 12 | useNavigation, 13 | } from "@remix-run/react" 14 | import { ReloadIcon } from "@radix-ui/react-icons" 15 | import { z } from "zod" 16 | 17 | import { 18 | isWithinExpiration, 19 | sendVerificationCode, 20 | } from "@/lib/server/auth-utils.sever" 21 | import { mergeMeta } from "@/lib/server/seo/seo-helpers" 22 | import buildTags from "@/lib/server/seo/seo-utils" 23 | import { authenticator } from "@/services/auth.server" 24 | import { prisma } from "@/services/db/db.server" 25 | import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert" 26 | import { Button } from "@/components/ui/button" 27 | import { Input } from "@/components/ui/input" 28 | import { Label } from "@/components/ui/label" 29 | 30 | const requestCodeSchema = z.object({ 31 | email: z 32 | .string({ required_error: "Please enter email to continue" }) 33 | .email("Please enter a valid email"), 34 | }) 35 | 36 | const codeVerificationSchema = z.object({ 37 | code: z.string({ required_error: "Please enter a verification code" }), 38 | }) 39 | 40 | export async function loader({ request }: LoaderFunctionArgs) { 41 | const user = await authenticator.isAuthenticated(request) 42 | 43 | if (user) { 44 | if (user.emailVerified) { 45 | return redirect("/dashboard") 46 | } 47 | 48 | const result = await prisma.verificationCode.findFirst({ 49 | where: { 50 | userId: user.id, 51 | }, 52 | include: { 53 | user: true, 54 | }, 55 | }) 56 | 57 | if (!result) { 58 | return json({ 59 | codeAvailableWithUser: false, 60 | email: user.email, 61 | }) 62 | } 63 | 64 | if (!isWithinExpiration(result.expires)) { 65 | await prisma.verificationCode.deleteMany({ 66 | where: { 67 | userId: user.id, 68 | }, 69 | }) 70 | return json({ 71 | codeAvailableWithUser: false, 72 | email: user.email, 73 | }) 74 | } 75 | 76 | return json({ 77 | codeAvailableWithUser: true, 78 | email: user.email, 79 | }) 80 | } 81 | 82 | return redirect("/login") 83 | } 84 | 85 | export const meta: MetaFunction = mergeMeta( 86 | // these will override the parent meta 87 | () => { 88 | return buildTags({ 89 | title: "Verify Email", 90 | description: "Verify your email", 91 | }) 92 | } 93 | ) 94 | 95 | type FormDataType = { 96 | intent: "requestCode" | "verifyCode" 97 | } & z.infer & 98 | z.infer 99 | 100 | export const action = async ({ request }: ActionFunctionArgs) => { 101 | const clonedRequest = request.clone() 102 | const user = await authenticator.isAuthenticated(request, { 103 | failureRedirect: "/login", 104 | }) 105 | 106 | const formData = Object.fromEntries( 107 | await clonedRequest.formData() 108 | ) as unknown as FormDataType 109 | 110 | switch (formData.intent) { 111 | case "verifyCode": 112 | const requestCodeSubmission = await codeVerificationSchema 113 | .superRefine(async (data, ctx) => { 114 | const verificationCode = await prisma.verificationCode.findFirst({ 115 | where: { 116 | userId: user.id, 117 | }, 118 | }) 119 | 120 | if (verificationCode?.code !== formData.code) { 121 | ctx.addIssue({ 122 | path: ["code"], 123 | code: z.ZodIssueCode.custom, 124 | message: "Please enter a valid code", 125 | }) 126 | return 127 | } 128 | }) 129 | .safeParseAsync(formData) 130 | 131 | if (requestCodeSubmission.success) { 132 | const updatedUser = await prisma.user.update({ 133 | where: { 134 | id: user.id, 135 | }, 136 | data: { 137 | emailVerified: true, 138 | }, 139 | }) 140 | 141 | await prisma.verificationCode.deleteMany({ 142 | where: { 143 | userId: user.id, 144 | }, 145 | }) 146 | return json({ verified: true }) 147 | } else { 148 | return json({ 149 | errors: requestCodeSubmission.error.flatten().fieldErrors, 150 | }) 151 | } 152 | case "requestCode": 153 | const verifyCodeSubmission = requestCodeSchema.safeParse(formData) 154 | if (verifyCodeSubmission.success) { 155 | await sendVerificationCode(user) 156 | return json({ verified: false }) 157 | } else { 158 | return json({ 159 | errors: verifyCodeSubmission.error.flatten().fieldErrors, 160 | }) 161 | } 162 | default: 163 | break 164 | } 165 | } 166 | 167 | export default function VerifyEmail() { 168 | const navigation = useNavigation() 169 | const data = useLoaderData() 170 | const isFormSubmitting = navigation.state === "submitting" 171 | const actionData = useActionData<{ 172 | errors: { code: Array; email: Array } 173 | verified?: boolean 174 | }>() 175 | 176 | const isVerifiying = 177 | navigation.state === "submitting" && 178 | navigation.formData?.get("intent") === "verifyCode" 179 | 180 | if (actionData?.verified) { 181 | return ( 182 |
    183 | 184 | Email successfully! 185 | 186 | Email has been verified successfully! 187 | 188 | 189 |
    190 | ) 191 | } 192 | 193 | if (data.codeAvailableWithUser) { 194 | return ( 195 | <> 196 |

    197 | Enter a verification code 198 |

    199 | {data.codeAvailableWithUser && ( 200 |

    201 | You must have recieved and email verification code on {data?.email} 202 |

    203 | )} 204 |
    205 |
    206 | 207 |
    208 | 209 |
    210 | 217 |
    218 |
    219 | 220 |
    221 | 227 |
    228 |
    229 |
    230 |

    231 | Did not recieve code? 232 |

    233 | 239 | 248 | 251 |
    252 |

    253 |
    254 |
    255 | 256 | ) 257 | } 258 | 259 | if (!data.codeAvailableWithUser) { 260 | return ( 261 |
    262 |
    263 |
    264 | 270 | 271 |
    272 | 281 |
    282 |
    283 | 284 |
    285 | 295 |
    296 |
    297 |
    298 | ) 299 | } 300 | } 301 | -------------------------------------------------------------------------------- /app/routes/_auth+/signup.tsx: -------------------------------------------------------------------------------- 1 | import { useId, useRef } from "react" 2 | import { 3 | json, 4 | type ActionFunctionArgs, 5 | type LoaderFunctionArgs, 6 | } from "@remix-run/node" 7 | import type { MetaFunction } from "@remix-run/react" 8 | import { Form, NavLink, useActionData, useNavigation } from "@remix-run/react" 9 | import type { FieldConfig } from "@conform-to/react" 10 | import { conform, useForm, useInputEvent } from "@conform-to/react" 11 | import { parse } from "@conform-to/zod" 12 | import { ReloadIcon } from "@radix-ui/react-icons" 13 | import { AuthenticityTokenInput } from "remix-utils/csrf/react" 14 | import { z } from "zod" 15 | 16 | import GoogleLogo from "@/lib/assets/logos/google" 17 | import { validateCsrfToken } from "@/lib/server/csrf.server" 18 | import { mergeMeta } from "@/lib/server/seo/seo-helpers" 19 | import { authenticator } from "@/services/auth.server" 20 | import { prisma } from "@/services/db/db.server" 21 | import { CommonErrorBoundary } from "@/components/error-boundry" 22 | import { Button } from "@/components/ui/button" 23 | import { Checkbox } from "@/components/ui/checkbox" 24 | import { Input } from "@/components/ui/input" 25 | import { Label } from "@/components/ui/label" 26 | 27 | export async function loader({ request }: LoaderFunctionArgs) { 28 | // If the user is already authenticated redirect to /dashboard directly 29 | return await authenticator.isAuthenticated(request, { 30 | successRedirect: "/dashboard", 31 | }) 32 | } 33 | 34 | export const meta: MetaFunction = mergeMeta( 35 | // these will override the parent meta 36 | () => { 37 | return [{ title: "Sign up" }] 38 | } 39 | ) 40 | 41 | const schema = z 42 | .object({ 43 | email: z 44 | .string({ required_error: "Please enter email to continue" }) 45 | .email("Please enter a valid email"), 46 | fullName: z.string({ required_error: "Please enter name to continue" }), 47 | password: z.string().min(8, "Password must be at least 8 characters"), 48 | confirmPassword: z 49 | .string() 50 | .min(8, "Password must be at least 8 characters"), 51 | tocAccepted: z.literal("on", { 52 | errorMap: () => ({ message: "You must accept the terms & conditions" }), 53 | }), 54 | // tocAccepted: z.string({ 55 | // required_error: "You must accept the terms & conditions", 56 | // }), 57 | }) 58 | .refine((data) => data.password === data.confirmPassword, { 59 | message: "Please make sure your passwords match", 60 | path: ["confirmPassword"], 61 | }) 62 | 63 | export const action = async ({ request }: ActionFunctionArgs) => { 64 | await validateCsrfToken(request) 65 | 66 | const clonedRequest = request.clone() 67 | const formData = await clonedRequest.formData() 68 | 69 | const submission = await parse(formData, { 70 | schema: schema.superRefine(async (data, ctx) => { 71 | const existingUser = await prisma.user.findFirst({ 72 | where: { 73 | email: data.email, 74 | }, 75 | select: { id: true }, 76 | }) 77 | 78 | if (existingUser) { 79 | ctx.addIssue({ 80 | path: ["email"], 81 | code: z.ZodIssueCode.custom, 82 | message: "A user with this email already exists", 83 | }) 84 | return 85 | } 86 | }), 87 | async: true, 88 | }) 89 | 90 | if (!submission.value || submission.intent !== "submit") { 91 | return json(submission) 92 | } 93 | 94 | return authenticator.authenticate("user-pass", request, { 95 | successRedirect: "/verify-email", 96 | throwOnError: true, 97 | context: { ...submission.value, type: "signup", tocAccepted: true }, 98 | }) 99 | } 100 | 101 | export default function Signup() { 102 | const navigation = useNavigation() 103 | const isFormSubmitting = navigation.state === "submitting" 104 | const isSigningUpWithEmail = 105 | isFormSubmitting && navigation.formAction !== "/auth/google" 106 | const isSigningUpWithGoogle = 107 | isFormSubmitting && navigation.formAction === "/auth/google" 108 | const lastSubmission = useActionData() 109 | const id = useId() 110 | 111 | const [form, { email, fullName, password, confirmPassword, tocAccepted }] = 112 | useForm({ 113 | id, 114 | lastSubmission, 115 | shouldValidate: "onBlur", 116 | shouldRevalidate: "onInput", 117 | onValidate({ formData }) { 118 | return parse(formData, { schema }) 119 | }, 120 | }) 121 | 122 | return ( 123 | <> 124 |

    125 | Create new account 126 |

    127 |
    128 |
    129 | 130 |
    131 |
    132 | 133 |
    134 | 142 |
    143 |
    144 |
    145 | 146 |
    147 | 155 |
    156 |
    157 | 158 |
    159 |
    160 | 161 |
    162 |
    163 | 171 |
    172 |
    173 | 174 |
    175 |
    176 | 177 |
    178 |
    179 | 187 |
    188 |
    189 | 190 |
    191 |
    192 | 197 |
    198 |
    {tocAccepted.error}
    199 |
    200 | 201 | 211 |
    212 | 213 |
    214 | 224 |
    225 | 226 |
    227 |

    228 | Already a member?{" "} 229 | 230 | 233 | 234 |

    235 |
    236 |
    237 | 238 | ) 239 | } 240 | 241 | function CustomCheckbox({ 242 | label, 243 | ...config 244 | }: FieldConfig & { label: string }) { 245 | const shadowInputRef = useRef(null) 246 | const control = useInputEvent({ 247 | ref: shadowInputRef, 248 | }) 249 | // The type of the ref might be different depends on the UI library 250 | const customInputRef = useRef(null) 251 | 252 | return ( 253 |
    254 | 261 | customInputRef.current?.focus()} 268 | /> 269 | 275 |
    276 | ) 277 | } 278 | 279 | export function ErrorBoundary() { 280 | return 281 | } 282 | --------------------------------------------------------------------------------