├── .eslintrc.cjs ├── .github └── workflows │ ├── check.yaml │ └── deploy.yml ├── .gitignore ├── LICENSE ├── README.md ├── apps ├── docs │ ├── .gitignore │ ├── astro.config.mjs │ ├── package.json │ ├── public │ │ └── favicon.svg │ ├── src │ │ ├── components │ │ │ ├── links.astro │ │ │ ├── navbar.astro │ │ │ ├── search.astro │ │ │ └── sidebar.astro │ │ ├── content │ │ │ ├── config.ts │ │ │ └── getting-started │ │ │ │ └── intro.md │ │ ├── env.d.ts │ │ ├── layouts │ │ │ ├── DocsLayout.astro │ │ │ ├── Layout.astro │ │ │ └── ProseLayout.astro │ │ ├── pages │ │ │ ├── [collection] │ │ │ │ └── [slug] │ │ │ │ │ └── index.astro │ │ │ ├── index.astro │ │ │ └── search.astro │ │ └── utils │ │ │ └── cn.ts │ ├── tailwind.config.mjs │ └── tsconfig.json └── web │ ├── .env.example │ ├── .eslintrc.cjs │ ├── next-env.d.ts │ ├── next.config.js │ ├── package.json │ ├── postcss.config.cjs │ ├── public │ └── images │ │ ├── hero-pattern_dark.svg │ │ └── hero-pattern_light.svg │ ├── reset.d.ts │ ├── src │ ├── app │ │ ├── (auth) │ │ │ ├── layout.tsx │ │ │ ├── login │ │ │ │ ├── github │ │ │ │ │ ├── callback │ │ │ │ │ │ └── route.ts │ │ │ │ │ └── route.ts │ │ │ │ ├── login.tsx │ │ │ │ └── page.tsx │ │ │ ├── reset-password │ │ │ │ ├── [token] │ │ │ │ │ ├── page.tsx │ │ │ │ │ └── reset-password.tsx │ │ │ │ ├── page.tsx │ │ │ │ └── send-reset-email.tsx │ │ │ ├── signup │ │ │ │ ├── page.tsx │ │ │ │ └── signup.tsx │ │ │ └── verify-email │ │ │ │ ├── page.tsx │ │ │ │ └── verify-code.tsx │ │ ├── (landing) │ │ │ ├── _components │ │ │ │ ├── copy-to-clipboard.tsx │ │ │ │ ├── feature-icons.tsx │ │ │ │ ├── header.tsx │ │ │ │ ├── mobile-hamburger.tsx │ │ │ │ ├── mobile-navigation.tsx │ │ │ │ ├── site-footer.tsx │ │ │ │ └── theme-toggle.tsx │ │ │ ├── layout.tsx │ │ │ └── page.tsx │ │ ├── (main) │ │ │ ├── _actions │ │ │ │ └── revalidateDashboard.ts │ │ │ ├── _components │ │ │ │ ├── header.tsx │ │ │ │ └── user-dropdown.tsx │ │ │ ├── dashboard │ │ │ │ ├── _components │ │ │ │ │ ├── dashboard-nav.tsx │ │ │ │ │ ├── empty-state.tsx │ │ │ │ │ ├── form-card.tsx │ │ │ │ │ ├── formed-dialog.tsx │ │ │ │ │ ├── forms.tsx │ │ │ │ │ ├── new-form-dialog.tsx │ │ │ │ │ ├── post-card-skeleton.tsx │ │ │ │ │ └── posts-skeleton.tsx │ │ │ │ ├── billing │ │ │ │ │ ├── _components │ │ │ │ │ │ ├── billing-skeleton.tsx │ │ │ │ │ │ ├── billing.tsx │ │ │ │ │ │ └── manage-subscription-form.tsx │ │ │ │ │ └── page.tsx │ │ │ │ ├── layout.tsx │ │ │ │ ├── page.tsx │ │ │ │ └── settings │ │ │ │ │ ├── layout.tsx │ │ │ │ │ ├── page.tsx │ │ │ │ │ ├── profile-form.tsx │ │ │ │ │ └── sidebar-nav.tsx │ │ │ ├── form │ │ │ │ └── [id] │ │ │ │ │ ├── copy-button.tsx │ │ │ │ │ ├── delete-form-dialog.tsx │ │ │ │ │ ├── export-submissions-button.tsx │ │ │ │ │ ├── form-settings.tsx │ │ │ │ │ ├── image-preview-dialog.tsx │ │ │ │ │ ├── layout.tsx │ │ │ │ │ ├── page.tsx │ │ │ │ │ └── submissions-table.tsx │ │ │ ├── layout.tsx │ │ │ └── onboarding │ │ │ │ ├── form │ │ │ │ ├── code-example-step.tsx │ │ │ │ ├── create-form-dialog.tsx │ │ │ │ ├── create-form-step.tsx │ │ │ │ └── send-submission-button.tsx │ │ │ │ ├── page.tsx │ │ │ │ └── stepper.tsx │ │ ├── api │ │ │ ├── health │ │ │ │ └── route.ts │ │ │ ├── mail │ │ │ │ └── route.ts │ │ │ ├── s │ │ │ │ └── [id] │ │ │ │ │ └── route.ts │ │ │ └── trpc │ │ │ │ └── [trpc] │ │ │ │ └── route.ts │ │ ├── icon.tsx │ │ ├── layout.tsx │ │ ├── robots.ts │ │ ├── s │ │ │ └── [formId] │ │ │ │ └── page.tsx │ │ └── sitemap.ts │ ├── components │ │ ├── copy-button.tsx │ │ ├── icons.tsx │ │ ├── loading-button.tsx │ │ ├── password-input.tsx │ │ ├── responsive-dialog.tsx │ │ ├── submit-button.tsx │ │ └── theme-provider.tsx │ ├── lib │ │ ├── email │ │ │ ├── index.ts │ │ │ ├── mailer.ts │ │ │ └── templates │ │ │ │ ├── email-verification.tsx │ │ │ │ ├── new-submission.tsx │ │ │ │ └── reset-password.tsx │ │ ├── highlight-code.ts │ │ ├── hooks │ │ │ ├── use-copy-to-clipboard.ts │ │ │ ├── use-debounce.ts │ │ │ └── use-media-query.ts │ │ ├── themes │ │ │ └── dark.ts │ │ ├── trpc │ │ │ ├── react.tsx │ │ │ └── server.ts │ │ ├── upload-file.ts │ │ └── verify-request.ts │ ├── middleware.ts │ └── styles │ │ └── globals.css │ ├── tailwind.config.ts │ └── tsconfig.json ├── docker └── docker-compose.yml ├── package.json ├── packages ├── api │ ├── .eslintrc.cjs │ ├── index.ts │ ├── package.json │ ├── routers │ │ ├── form.ts │ │ ├── formData.ts │ │ ├── stripe.ts │ │ └── user.ts │ ├── trpc.ts │ └── tsconfig.json ├── auth │ ├── .eslintrc.cjs │ ├── actions │ │ ├── index.ts │ │ ├── login.ts │ │ ├── logout.ts │ │ ├── resend-verification-email.ts │ │ ├── reset-password.ts │ │ ├── send-password-reset-link.ts │ │ ├── signup.ts │ │ ├── utils.ts │ │ └── verify-email.ts │ ├── auth.ts │ ├── index.ts │ ├── lucia.ts │ ├── package.json │ ├── providers │ │ └── github.ts │ ├── tsconfig.json │ └── validators │ │ └── auth.ts ├── config │ ├── eslint │ │ ├── .eslintrc.cjs │ │ ├── base.js │ │ ├── next.js │ │ ├── package.json │ │ ├── react.js │ │ └── tsconfig.json │ ├── tailwind │ │ ├── .eslintrc.cjs │ │ ├── package.json │ │ ├── src │ │ │ └── preset.ts │ │ └── tsconfig.json │ └── tsconfig │ │ ├── base.json │ │ ├── next.json │ │ ├── package.json │ │ └── react.json ├── core │ ├── .eslintrc.cjs │ ├── package.json │ ├── src │ │ └── index.ts │ ├── tsconfig.json │ └── tsup.config.ts ├── db │ ├── .eslintrc.cjs │ ├── drizzle.config.ts │ ├── drizzle │ │ ├── 0000_known_luckman.sql │ │ ├── 0001_military_rictor.sql │ │ └── meta │ │ │ ├── 0000_snapshot.json │ │ │ ├── 0001_snapshot.json │ │ │ └── _journal.json │ ├── index.ts │ ├── migrate.ts │ ├── package.json │ ├── schema │ │ ├── email-verification.ts │ │ ├── form-data.ts │ │ ├── forms.ts │ │ ├── index.ts │ │ ├── oauth.ts │ │ ├── onboarding-forms.ts │ │ ├── password-reset-tokens.ts │ │ ├── relations.ts │ │ ├── sessions.ts │ │ └── users.ts │ └── tsconfig.json ├── env │ ├── .eslintrc.cjs │ ├── env.d.ts │ ├── index.ts │ ├── package.json │ └── tsconfig.json ├── ui │ ├── .eslintrc.cjs │ ├── package.json │ ├── postcss.config.cjs │ ├── primitives │ │ ├── accordion.tsx │ │ ├── alert-dialog.tsx │ │ ├── alert.tsx │ │ ├── aspect-ratio.tsx │ │ ├── avatar.tsx │ │ ├── badge.tsx │ │ ├── breadcrumb.tsx │ │ ├── button.tsx │ │ ├── calendar.tsx │ │ ├── card.tsx │ │ ├── carousel.tsx │ │ ├── checkbox.tsx │ │ ├── collapsible.tsx │ │ ├── command.tsx │ │ ├── context-menu.tsx │ │ ├── dialog.tsx │ │ ├── drawer.tsx │ │ ├── dropdown-menu.tsx │ │ ├── form.tsx │ │ ├── hover-card.tsx │ │ ├── input-otp.tsx │ │ ├── input.tsx │ │ ├── label.tsx │ │ ├── menubar.tsx │ │ ├── navigation-menu.tsx │ │ ├── pagination.tsx │ │ ├── popover.tsx │ │ ├── progress.tsx │ │ ├── radio-group.tsx │ │ ├── resizable.tsx │ │ ├── scroll-area.tsx │ │ ├── select.tsx │ │ ├── separator.tsx │ │ ├── sheet.tsx │ │ ├── skeleton.tsx │ │ ├── slider.tsx │ │ ├── switch.tsx │ │ ├── table.tsx │ │ ├── tabs.tsx │ │ ├── textarea.tsx │ │ ├── toast │ │ │ ├── index.tsx │ │ │ ├── toast.tsx │ │ │ ├── toaster.tsx │ │ │ └── use-toast.tsx │ │ ├── toggle-group.tsx │ │ ├── toggle.tsx │ │ ├── tooltip.tsx │ │ └── typography.tsx │ ├── tailwind.config.ts │ ├── tsconfig.json │ └── utils │ │ └── cn.ts └── utils │ ├── .eslintrc.cjs │ ├── flatten-object.ts │ ├── generate-id.ts │ ├── index.ts │ ├── package.json │ ├── tsconfig.json │ └── url.ts ├── pnpm-lock.yaml ├── pnpm-workspace.yaml ├── prettier.config.js ├── tsconfig.json └── turbo.json /.eslintrc.cjs: -------------------------------------------------------------------------------- 1 | /** @type {import('eslint').Linter.Config} */ 2 | const config = { 3 | ignorePatterns: ['apps/**', 'packages/**'], 4 | extends: ['formbase/base'], 5 | }; 6 | 7 | module.exports = config; 8 | -------------------------------------------------------------------------------- /.github/workflows/check.yaml: -------------------------------------------------------------------------------- 1 | name: Type check and lint 2 | 3 | on: [push, pull_request] 4 | 5 | permissions: 6 | contents: read 7 | 8 | jobs: 9 | typecheck-and-lint: 10 | runs-on: ubuntu-latest 11 | 12 | steps: 13 | - uses: actions/checkout@v2 14 | - uses: pnpm/action-setup@v2 15 | name: Install pnpm 16 | with: 17 | version: 9.0.6 18 | run_install: false 19 | - uses: actions/setup-node@v3 20 | with: 21 | node-version: 20.x 22 | 23 | - name: Install dependencies 24 | run: pnpm install 25 | 26 | - name: Type check and lint 27 | run: pnpm typecheck && pnpm lint 28 | env: 29 | # use dummy env variables to bypass t3-env check 30 | DATABASE_URL: postgresql://test:xxxx@xxxxxxxxx:3306/test 31 | SMTP_HOST: host 32 | SMTP_PORT: 587 33 | SMTP_USER: user 34 | SMTP_PASSWORD: password 35 | NEXT_PUBLIC_APP_URL: http://localhost:3000 36 | ALLOW_SIGNIN_SIGNUP: true 37 | AUTH_GITHUB_ID: client_id 38 | AUTH_GITHUB_SECRET: client_secret 39 | STRIPE_API_KEY: stripe_api_key 40 | STRIPE_WEBHOOK_SECRET: stripe_webhook_secret 41 | STRIPE_PRO_MONTHLY_PLAN_ID: stripe_pro_monthly_plan_id 42 | MINIO_ENDPOINT: minio_endpoint 43 | MINIO_ACCESS_KEY: minio_access_key 44 | MINIO_SECRET_KEY: minio_secret_key 45 | MINIO_BUCKET: minio_bucket 46 | MINIO_PORT: 9000 47 | MINIO_USESSL: false 48 | -------------------------------------------------------------------------------- /.github/workflows/deploy.yml: -------------------------------------------------------------------------------- 1 | name: Deploy to Production 2 | on: 3 | push: 4 | branches: ['release'] 5 | 6 | jobs: 7 | deploy: 8 | runs-on: ubuntu-latest 9 | steps: 10 | - name: Deploy to Coolify 11 | run: | 12 | curl --request GET '${{ secrets.COOLIFY_WEBHOOK }}' --header 'Authorization: Bearer ${{ secrets.COOLIFY_TOKEN }}' 13 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

Formbase

2 | 3 |

4 | An open-source form backend for form handling, notifications, secure file uploads, and integrations. 5 |

6 | 7 |

8 | Introduction · 9 | Tech Stack · 10 | Self-hosting · 11 | Contributing 12 |

13 | 14 |
15 | 16 | ## Introduction 17 | 18 | Formbase is an open-source form backend for handling forms, notifications, secure file uploads, and integrations. 19 | 20 | ## Tech Stack 21 | 22 | - [Next.js](https://nextjs.org/) – Framework 23 | - [TypeScript](https://www.typescriptlang.org/) – Language 24 | - [Tailwind](https://tailwindcss.com/) – Styling 25 | - [PostgresSQL](https://postgresql.org/) – Database 26 | - [Lucia Auth](https://lucia-auth.com/) – Authentication 27 | - [Turborepo](https://turbo.build) - Monorepo 28 | 29 | ## Contributing 30 | 31 | Here's how you can contribute: 32 | 33 | - [Open an issue](https://github.com/eight-labs/formbase/issues) if you believe you've encountered a bug. 34 | - Make a [pull request](https://github.com/eight-labs/formbase/pull) to add new features/make quality-of-life improvements/fix bugs. 35 | -------------------------------------------------------------------------------- /apps/docs/.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | dist/ 3 | *.tsbuildinfo 4 | .DS_Store 5 | .vercel 6 | .netlify 7 | _site/ 8 | scripts/smoke/*-main/ 9 | scripts/memory/project/src/pages/ 10 | benchmark/projects/ 11 | benchmark/results/ 12 | test-results/ 13 | *.log 14 | package-lock.json 15 | .turbo/ 16 | .eslintcache 17 | .pnpm-store 18 | 19 | # ignore top-level vscode settings 20 | /.vscode/settings.json 21 | 22 | # do not commit .env files or any files that end with `.env` 23 | *.env 24 | 25 | packages/astro/src/**/*.prebuilt.ts 26 | packages/astro/src/**/*.prebuilt-dev.ts 27 | !packages/astro/vendor/vite/dist 28 | packages/integrations/**/.netlify/ 29 | 30 | # exclude IntelliJ/WebStorm stuff 31 | .idea 32 | 33 | # ignore content collection generated files 34 | packages/**/test/**/fixtures/**/.astro/ 35 | packages/**/test/**/fixtures/**/env.d.ts 36 | packages/**/e2e/**/fixtures/**/.astro/ 37 | packages/**/e2e/**/fixtures/**/env.d.ts 38 | examples/**/.astro/ 39 | examples/**/env.d.ts 40 | -------------------------------------------------------------------------------- /apps/docs/astro.config.mjs: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "astro/config"; 2 | import cloudflare from "@astrojs/cloudflare"; 3 | import tailwind from "@astrojs/tailwind"; 4 | import { 5 | transformerNotationDiff, 6 | transformerNotationHighlight, 7 | transformerNotationWordHighlight, 8 | transformerNotationFocus, 9 | transformerNotationErrorLevel, 10 | transformerRenderWhitespace, 11 | transformerMetaHighlight, 12 | transformerMetaWordHighlight, 13 | transformerCompactLineOptions, 14 | } from "@shikijs/transformers"; 15 | import mdx from "@astrojs/mdx"; 16 | 17 | // https://astro.build/config 18 | export default defineConfig({ 19 | output: "server", 20 | devToolbar: { 21 | enabled: false, 22 | }, 23 | markdown: { 24 | shikiConfig: { 25 | theme: "vitesse-dark", 26 | transformers: [ 27 | transformerNotationDiff(), 28 | transformerNotationFocus(), 29 | transformerMetaHighlight(), 30 | transformerMetaWordHighlight(), 31 | transformerNotationHighlight(), 32 | transformerNotationWordHighlight(), 33 | transformerNotationErrorLevel(), 34 | transformerRenderWhitespace(), 35 | transformerCompactLineOptions(), 36 | ], 37 | }, 38 | }, 39 | integrations: [tailwind(), mdx()], 40 | adapter: cloudflare(), 41 | }); 42 | -------------------------------------------------------------------------------- /apps/docs/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@formbase/docs", 3 | "version": "0.0.1", 4 | "type": "module", 5 | "scripts": { 6 | "astro": "astro", 7 | "build": "astro check && astro build", 8 | "dev": "astro dev", 9 | "preview": "astro preview", 10 | "start": "astro dev" 11 | }, 12 | "dependencies": { 13 | "@astrojs/check": "^0.7.0", 14 | "@astrojs/cloudflare": "^10.4.1", 15 | "@astrojs/mdx": "^3.1.1", 16 | "@astrojs/tailwind": "^5.1.0", 17 | "@logsnag/astro": "^1.0.0-beta.1", 18 | "@shikijs/transformers": "^1.6.5", 19 | "@tailwindcss/typography": "^0.5.13", 20 | "astro": "^4.16.18", 21 | "astro-seo": "^0.8.4", 22 | "clsx": "^2.1.1", 23 | "minisearch": "^6.3.0", 24 | "tailwind-merge": "^2.3.0", 25 | "tailwindcss": "^3.4.4", 26 | "typescript": "^5.4.5" 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /apps/docs/public/favicon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /apps/docs/src/components/links.astro: -------------------------------------------------------------------------------- 1 | --- 2 | 3 | --- 4 | 5 | 14 | 15 |
16 | 21 | 31 | 32 | 47 |
48 | -------------------------------------------------------------------------------- /apps/docs/src/components/navbar.astro: -------------------------------------------------------------------------------- 1 | --- 2 | import Links from './links.astro'; 3 | import Search from './search.astro'; 4 | --- 5 | 6 |
9 | 20 | 21 | 22 | 23 |
24 | 25 | 39 | -------------------------------------------------------------------------------- /apps/docs/src/components/search.astro: -------------------------------------------------------------------------------- 1 | --- 2 | const search = Astro.url.searchParams.get('search') || ''; 3 | --- 4 | 5 |
6 |
7 |
8 | 13 | 17 | 18 | 28 |
29 |
30 |
31 | -------------------------------------------------------------------------------- /apps/docs/src/content/config.ts: -------------------------------------------------------------------------------- 1 | import { defineCollection, z } from 'astro:content'; 2 | 3 | export const TypeEnum = z.enum(['base', 'database']); 4 | 5 | export const ServiceName = z.enum(['base', 'Getting Started']); 6 | 7 | const baseSchema = z.object({ 8 | type: z.literal('base').optional().default('base'), 9 | name: ServiceName.optional().default('base'), 10 | shortTitle: z.string(), 11 | order: z.number().optional().default(Infinity), 12 | title: z.string(), 13 | description: z.string(), 14 | lastModifiedAt: z.coerce.date().optional(), 15 | publishedAt: z.coerce.date(), 16 | }); 17 | 18 | const GettingStarted = defineCollection({ 19 | type: 'content', 20 | schema: baseSchema.extend({ 21 | type: z.literal(TypeEnum.enum.database).default(TypeEnum.enum.database), 22 | name: z.literal('Getting Started').default('Getting Started'), 23 | }), 24 | }); 25 | 26 | export const collections = { 27 | 'getting-started': GettingStarted, 28 | }; 29 | -------------------------------------------------------------------------------- /apps/docs/src/env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | /// 3 | 4 | type Runtime = import("@astrojs/cloudflare").Runtime; 5 | 6 | declare namespace App { 7 | interface Locals extends Runtime {} 8 | } 9 | -------------------------------------------------------------------------------- /apps/docs/src/layouts/Layout.astro: -------------------------------------------------------------------------------- 1 | --- 2 | import { SEO, type SEOProps } from "astro-seo"; 3 | 4 | interface Props extends SEOProps { 5 | frontmatter?: any; 6 | } 7 | 8 | const props = { 9 | ...Astro.props, 10 | ...Astro.props.frontmatter, 11 | }; 12 | --- 13 | 14 | 15 | 16 | 17 | 18 | {/* Google Fonts */} 19 | 20 | 21 | 22 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | -------------------------------------------------------------------------------- /apps/docs/src/layouts/ProseLayout.astro: -------------------------------------------------------------------------------- 1 | --- 2 | import DocsLayout from "./DocsLayout.astro"; 3 | --- 4 | 5 | 6 |
7 | 8 |
9 |
10 | -------------------------------------------------------------------------------- /apps/docs/src/pages/[collection]/[slug]/index.astro: -------------------------------------------------------------------------------- 1 | --- 2 | import ProseLayout from "../../../layouts/ProseLayout.astro"; 3 | import { getEntry } from "astro:content"; 4 | 5 | const { collection, slug } = Astro.params; 6 | // @ts-ignore 7 | const entry = await getEntry(collection, slug); 8 | if (!entry) { 9 | return Astro.redirect("/"); 10 | } 11 | 12 | Astro.response.headers.set( 13 | "Cache-Control", 14 | "public, max-age=3600, s-maxage=3600, stale-while-revalidate=3600" 15 | ); 16 | 17 | const { Content } = await entry.render(); 18 | --- 19 | 20 | 21 | 22 | 23 | -------------------------------------------------------------------------------- /apps/docs/src/pages/index.astro: -------------------------------------------------------------------------------- 1 | --- 2 | import ProseLayout from '../layouts/ProseLayout.astro'; 3 | --- 4 | 5 | 6 |

Introduction

7 |

8 | Formbase makes it easy to handle your HTML form submissions. Simplify your 9 | work with easy form management, automatic notifications, secure file 10 | uploads, and smooth integrations. Create a form and start accepting 11 | submissions right away. 12 |

13 | 14 |

Features

15 |
    16 |
  • 17 |

    Effortless Form Handling

    18 |

    19 | Create a form and start accepting submissions instantly without any 20 | configuration. 21 |

    22 |
  • 23 | 24 |
  • 25 |

    Email Notifications

    26 |

    27 | Get real-time notifications every time your form receives a new entry. 28 |

    29 |
  • 30 | 31 |
  • 32 |

    Team Collaboration (Coming Soon)

    33 |

    34 | Build and manage a team to work together seamlessly on form projects. 35 |

    36 |
  • 37 |
38 | 39 |

40 |

41 | This documentation site is adapted from the SelfhostHQ by Shayan. 45 |

46 |

47 |
48 | -------------------------------------------------------------------------------- /apps/docs/src/pages/search.astro: -------------------------------------------------------------------------------- 1 | --- 2 | import { getCollection } from 'astro:content'; 3 | import MiniSearch from 'minisearch'; 4 | 5 | import { collections } from '../content/config'; 6 | import DocsLayout from '../layouts/DocsLayout.astro'; 7 | 8 | const query = Astro.url.searchParams.get('q') ?? ''; 9 | 10 | Astro.response.headers.set( 11 | 'Cache-Control', 12 | 'public, max-age=3600, s-maxage=3600, stale-while-revalidate=3600', 13 | ); 14 | 15 | const allCollections = await Promise.all( 16 | // @ts-ignore 17 | Object.keys(collections).map(getCollection), 18 | ); 19 | 20 | const result = allCollections.flatMap((c) => { 21 | return c.map((e) => { 22 | return { 23 | id: `${e.collection}/${e.slug}`, 24 | title: e.data.title, 25 | shortTitle: e.data.shortTitle, 26 | description: e.data.description, 27 | body: e.body, 28 | }; 29 | }); 30 | }); 31 | 32 | const minisearch = new MiniSearch<{ 33 | title: string; 34 | shortTitle: string; 35 | description: string; 36 | body: string; 37 | }>({ 38 | fields: ['title', 'shortTitle', 'description', 'body'], 39 | storeFields: ['title', 'href', 'description'], 40 | }); 41 | 42 | minisearch.addAll(result); 43 | const suggestions = minisearch.autoSuggest(query); 44 | // search for query and all suggestions 45 | const searchResult = minisearch.search({ 46 | queries: [query, ...suggestions.map((e) => e.terms)].flatMap((e) => e), 47 | }); 48 | --- 49 | 50 | 51 |
52 | 73 |
74 |
75 | -------------------------------------------------------------------------------- /apps/docs/src/utils/cn.ts: -------------------------------------------------------------------------------- 1 | import { clsx, type ClassValue } from "clsx" 2 | import { twMerge } from "tailwind-merge" 3 | 4 | export function cn(...inputs: ClassValue[]) { 5 | return twMerge(clsx(inputs)) 6 | } 7 | -------------------------------------------------------------------------------- /apps/docs/tailwind.config.mjs: -------------------------------------------------------------------------------- 1 | import colors from 'tailwindcss/colors'; 2 | 3 | /** @type {import('tailwindcss').Config} */ 4 | export default { 5 | darkMode: 'class', 6 | content: ['./src/**/*.{astro,html,js,jsx,md,mdx,svelte,ts,tsx,vue}'], 7 | theme: { 8 | fontFamily: { 9 | mono: ['JetBrains Mono', 'monospace'], 10 | }, 11 | extend: { 12 | colors: { 13 | gray: colors.neutral, 14 | }, 15 | }, 16 | }, 17 | plugins: [require('@tailwindcss/typography')], 18 | }; 19 | -------------------------------------------------------------------------------- /apps/docs/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "astro/tsconfigs/strict" 3 | } -------------------------------------------------------------------------------- /apps/web/.env.example: -------------------------------------------------------------------------------- 1 | # Since the ".env" file is gitignored, you can use the ".env.example" file to 2 | # build a new ".env" file when you clone the repo. Keep this file up-to-date 3 | # when you add new variables to `.env`. 4 | 5 | # This file will be committed to version control, so make sure not to have any 6 | # secrets in it. If you are cloning this repo, create a copy of this file named 7 | # ".env" and populate it with your secrets. 8 | 9 | # When adding additional environment variables, the schema in "/src/env.js" 10 | # should be updated accordingly. 11 | 12 | # Drizzle 13 | # Get the Database URL from the "prisma" dropdown selector in PlanetScale. 14 | # Change the query params at the end of the URL to "?ssl={"rejectUnauthorized":true}" 15 | DATABASE_URL='postgresql://formbase:password@127.0.0.1:5432/formbase' 16 | SMTP_HOST=127.0.0.1 17 | SMTP_PORT=2500 18 | SMTP_USER="formbase" 19 | SMTP_PASSWORD="formbase" 20 | NEXT_PUBLIC_APP_URL='http://localhost:3000' 21 | 22 | # The callback URL for githubs Oauth is localhost:3000/login/github/callback 23 | # Obviously replace localhost and port of you are using this in a production environment. 24 | AUTH_GITHUB_ID="github_client_id" 25 | AUTH_GITHUB_SECRET="github_client_secret" 26 | 27 | # Formbase 28 | ALLOW_SIGNIN_SIGNUP=true 29 | 30 | # Stripe 31 | # Stripe Secret Key found at https://dashboard.stripe.com/test/apikeys 32 | STRIPE_API_KEY='sk_test_' 33 | # Stripe Webhook Secret found at https://dashboard.stripe.com/test/webhooks/create?endpoint_location=local 34 | # This need to replaced with the webhook secret for your webhook endpoint in production 35 | STRIPE_WEBHOOK_SECRET='whsec_' 36 | # Stripe Product and Price IDs for your created products 37 | # found at https://dashboard.stripe.com/test/products 38 | STRIPE_PRO_MONTHLY_PLAN_ID='price_' 39 | 40 | # Minio 41 | MINIO_ENDPOINT='localhost' 42 | MINIO_PORT=90 43 | MINIO_USESSL=false 44 | MINIO_ACCESSKEY='A2J2' 45 | MINIO_SECRETKEY='S2J2' 46 | MINIO_BUCKET='bucketname' 47 | -------------------------------------------------------------------------------- /apps/web/.eslintrc.cjs: -------------------------------------------------------------------------------- 1 | /** @type {import('eslint').Linter.Config} */ 2 | const config = { 3 | root: true, 4 | extends: ['formbase/base', 'formbase/next', 'formbase/react'], 5 | }; 6 | 7 | module.exports = config; 8 | -------------------------------------------------------------------------------- /apps/web/next-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | /// 3 | 4 | // NOTE: This file should not be edited 5 | // see https://nextjs.org/docs/basic-features/typescript for more information. 6 | -------------------------------------------------------------------------------- /apps/web/next.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('next').NextConfig} */ 2 | const nextConfig = { 3 | reactStrictMode: true, 4 | images: { 5 | remotePatterns: [ 6 | { 7 | protocol: 'https', 8 | hostname: 'pbs.twimg.com', 9 | }, 10 | { 11 | protocol: 'http', 12 | hostname: 'localhost', 13 | }, 14 | ], 15 | }, 16 | transpilePackages: [ 17 | '@formbase/api', 18 | '@formbase/auth', 19 | '@formbase/db', 20 | '@formbase/env', 21 | '@formbase/ui', 22 | '@formbase/utils', 23 | "@formbase/tailwind", 24 | ], 25 | eslint: { 26 | ignoreDuringBuilds: true, 27 | }, 28 | typescript: { 29 | ignoreBuildErrors: true, 30 | }, 31 | }; 32 | 33 | export default nextConfig; 34 | -------------------------------------------------------------------------------- /apps/web/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@formbase/web", 3 | "version": "0.1.0", 4 | "private": true, 5 | "type": "module", 6 | "scripts": { 7 | "build": "next build", 8 | "dev": "next dev -p 3000", 9 | "lint": "eslint . --cache --max-warnings 0", 10 | "start": "next start", 11 | "typecheck": "tsc --noEmit --tsBuildInfoFile .tsbuildinfo" 12 | }, 13 | "dependencies": { 14 | "@documenso/nodemailer-resend": "^3.0.0", 15 | "@formbase/api": "workspace:^", 16 | "@formbase/auth": "workspace:^", 17 | "@formbase/db": "workspace:^", 18 | "@formbase/env": "workspace:^", 19 | "@formbase/ui": "workspace:^", 20 | "@formbase/utils": "workspace:^", 21 | "@hookform/resolvers": "^3.4.2", 22 | "@radix-ui/react-icons": "^1.3.0", 23 | "@react-email/components": "^0.0.19", 24 | "@react-email/render": "^0.0.15", 25 | "@tailwindcss/typography": "^0.5.13", 26 | "@tanstack/react-query": "^5.37.1", 27 | "@tanstack/react-table": "^8.17.3", 28 | "@total-typescript/ts-reset": "^0.5.1", 29 | "@trpc/client": "next", 30 | "@trpc/react-query": "next", 31 | "@trpc/server": "next", 32 | "date-fns": "^3.6.0", 33 | "framer-motion": "^11.2.10", 34 | "lucide-react": "^0.379.0", 35 | "minio": "^8.0.0", 36 | "next": "14.2.25", 37 | "next-themes": "^0.3.0", 38 | "nodemailer": "^6.9.13", 39 | "react": "^18.3.1", 40 | "react-dom": "^18.3.1", 41 | "react-hook-form": "^7.51.5", 42 | "server-only": "^0.0.1", 43 | "sonner": "^1.4.41", 44 | "superjson": "^2.2.1", 45 | "ts-pattern": "^5.1.2", 46 | "zod": "^3.23.8" 47 | }, 48 | "devDependencies": { 49 | "@formbase/tailwind": "workspace:^", 50 | "@formbase/tsconfig": "workspace:^", 51 | "@types/node": "^20.12.12", 52 | "@types/nodemailer": "^6.4.15", 53 | "@types/react": "^18.3.2", 54 | "@types/react-dom": "^18.3.0", 55 | "autoprefixer": "^10.4.19", 56 | "dotenv": "^16.4.5", 57 | "eslint-config-formbase": "workspace:^", 58 | "postcss": "^8.4.38", 59 | "shiki": "^1.6.3", 60 | "tailwindcss": "^3.4.3" 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /apps/web/postcss.config.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | }; 7 | -------------------------------------------------------------------------------- /apps/web/reset.d.ts: -------------------------------------------------------------------------------- 1 | import '@total-typescript/ts-reset'; 2 | -------------------------------------------------------------------------------- /apps/web/src/app/(auth)/layout.tsx: -------------------------------------------------------------------------------- 1 | import type { ReactNode } from 'react'; 2 | 3 | const AuthLayout = ({ children }: { children: ReactNode }) => { 4 | return ( 5 |
{children}
6 | ); 7 | }; 8 | 9 | export default AuthLayout; 10 | -------------------------------------------------------------------------------- /apps/web/src/app/(auth)/login/github/callback/route.ts: -------------------------------------------------------------------------------- 1 | import { validateGithubCallback } from '@formbase/auth/providers/github'; 2 | 3 | export { validateGithubCallback as GET }; 4 | -------------------------------------------------------------------------------- /apps/web/src/app/(auth)/login/github/route.ts: -------------------------------------------------------------------------------- 1 | import { createGithubAuthorizationURL } from '@formbase/auth/providers/github'; 2 | 3 | export { createGithubAuthorizationURL as GET }; 4 | -------------------------------------------------------------------------------- /apps/web/src/app/(auth)/login/page.tsx: -------------------------------------------------------------------------------- 1 | import { redirect } from 'next/navigation'; 2 | 3 | import { validateRequest } from '@formbase/auth'; 4 | 5 | import { Login } from './login'; 6 | 7 | export const metadata = { 8 | title: 'Login', 9 | description: 'Login Page', 10 | }; 11 | 12 | export default async function LoginPage() { 13 | const { user } = await validateRequest(); 14 | 15 | if (user) { 16 | redirect('/dashboard'); 17 | } 18 | 19 | return ; 20 | } 21 | -------------------------------------------------------------------------------- /apps/web/src/app/(auth)/reset-password/[token]/page.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | Card, 3 | CardContent, 4 | CardDescription, 5 | CardHeader, 6 | CardTitle, 7 | } from '@formbase/ui/primitives/card'; 8 | 9 | import { ResetPassword } from './reset-password'; 10 | 11 | export const metadata = { 12 | title: 'Reset Password', 13 | description: 'Reset Password Page', 14 | }; 15 | 16 | export default function ResetPasswordPage({ 17 | params, 18 | }: { 19 | params: { token: string }; 20 | }) { 21 | return ( 22 | 23 | 24 | Reset password 25 | Enter your email to get reset link. 26 | 27 | 28 | 29 | 30 | 31 | ); 32 | } 33 | -------------------------------------------------------------------------------- /apps/web/src/app/(auth)/reset-password/[token]/reset-password.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { useEffect } from 'react'; 4 | 5 | import { ExclamationTriangleIcon } from '@radix-ui/react-icons'; 6 | import { useFormState } from 'react-dom'; 7 | import { toast } from 'sonner'; 8 | 9 | import { resetPassword } from '@formbase/auth/actions'; 10 | import { Label } from '@formbase/ui/primitives/label'; 11 | 12 | import { PasswordInput } from '~/components/password-input'; 13 | import { SubmitButton } from '~/components/submit-button'; 14 | 15 | export function ResetPassword({ token }: { token: string }) { 16 | const [state, formAction] = useFormState(resetPassword, null); 17 | 18 | useEffect(() => { 19 | if (state?.error) { 20 | toast(state.error, { 21 | icon: , 22 | }); 23 | } 24 | }, [state?.error]); 25 | 26 | return ( 27 |
28 | 29 |
30 | 31 | 37 |
38 | Reset Password 39 |
40 | ); 41 | } 42 | -------------------------------------------------------------------------------- /apps/web/src/app/(auth)/reset-password/page.tsx: -------------------------------------------------------------------------------- 1 | import { redirect } from 'next/navigation'; 2 | 3 | import { validateRequest } from '@formbase/auth'; 4 | import { 5 | Card, 6 | CardContent, 7 | CardDescription, 8 | CardHeader, 9 | CardTitle, 10 | } from '@formbase/ui/primitives/card'; 11 | 12 | import { SendResetEmail } from './send-reset-email'; 13 | 14 | export const metadata = { 15 | title: 'Forgot Password', 16 | description: 'Forgot Password Page', 17 | }; 18 | 19 | export default async function ForgotPasswordPage() { 20 | const { user } = await validateRequest(); 21 | 22 | if (user) { 23 | redirect('/dashboard'); 24 | } 25 | 26 | return ( 27 | 28 | 29 | Forgot password? 30 | 31 | Password reset link will be sent to your email. 32 | 33 | 34 | 35 | 36 | 37 | 38 | ); 39 | } 40 | -------------------------------------------------------------------------------- /apps/web/src/app/(auth)/reset-password/send-reset-email.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { useEffect } from 'react'; 4 | import Link from 'next/link'; 5 | import { useRouter } from 'next/navigation'; 6 | 7 | import { useFormState } from 'react-dom'; 8 | import { toast } from 'sonner'; 9 | 10 | import { sendPasswordResetLink } from '@formbase/auth/actions'; 11 | import { Button } from '@formbase/ui/primitives/button'; 12 | import { Input } from '@formbase/ui/primitives/input'; 13 | import { Label } from '@formbase/ui/primitives/label'; 14 | 15 | import { SubmitButton } from '~/components/submit-button'; 16 | 17 | export function SendResetEmail() { 18 | const [state, formAction] = useFormState(sendPasswordResetLink, null); 19 | const router = useRouter(); 20 | 21 | useEffect(() => { 22 | if (state?.success) { 23 | toast('A password reset link has been sent to your email.'); 24 | router.push('/login'); 25 | } 26 | if (state?.error) { 27 | toast(state.error); 28 | router.push('/reset-password'); 29 | } 30 | }, [state?.error, state?.success]); 31 | 32 | return ( 33 |
34 |
35 | 36 | 43 |
44 | 45 |
46 | 47 | 50 | 51 |
52 | 53 | Reset Password 54 | 57 |
58 | ); 59 | } 60 | -------------------------------------------------------------------------------- /apps/web/src/app/(auth)/signup/page.tsx: -------------------------------------------------------------------------------- 1 | import { redirect } from 'next/navigation'; 2 | 3 | import { validateRequest } from '@formbase/auth'; 4 | 5 | import { Signup } from './signup'; 6 | 7 | export const metadata = { 8 | title: 'Sign Up', 9 | description: 'Signup Page', 10 | }; 11 | 12 | export default async function SignupPage() { 13 | const { user } = await validateRequest(); 14 | 15 | if (user) { 16 | redirect('/dashboard'); 17 | } 18 | 19 | return ; 20 | } 21 | -------------------------------------------------------------------------------- /apps/web/src/app/(auth)/verify-email/page.tsx: -------------------------------------------------------------------------------- 1 | import { redirect } from 'next/navigation'; 2 | 3 | import { validateRequest } from '@formbase/auth'; 4 | import { type User } from '@formbase/db/schema'; 5 | import { 6 | Card, 7 | CardContent, 8 | CardDescription, 9 | CardHeader, 10 | CardTitle, 11 | } from '@formbase/ui/primitives/card'; 12 | 13 | import { VerifyCode } from './verify-code'; 14 | 15 | export const metadata = { 16 | title: 'Verify Email', 17 | description: 'Verify your email address to continue.', 18 | }; 19 | 20 | export default async function ForgotPasswordPage() { 21 | const { user } = (await validateRequest()) as { user: User | null }; 22 | 23 | if (!user) { 24 | redirect('/login'); 25 | } 26 | 27 | if (user.emailVerified) { 28 | redirect('/dashboard'); 29 | } 30 | 31 | return ( 32 | 33 | 34 | Verify Email 35 | 36 | Verification code was sent to {user.email}. Check 37 | your spam folder if you can't find the email. 38 | 39 | 40 | 41 | 42 | 43 | 44 | ); 45 | } 46 | -------------------------------------------------------------------------------- /apps/web/src/app/(auth)/verify-email/verify-code.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { useEffect, useRef } from 'react'; 4 | 5 | import { ExclamationTriangleIcon } from '@radix-ui/react-icons'; 6 | import { useFormState } from 'react-dom'; 7 | import { toast } from 'sonner'; 8 | 9 | import { 10 | logout, 11 | resendVerificationEmail as resendEmail, 12 | verifyEmail, 13 | } from '@formbase/auth/actions'; 14 | import { Input } from '@formbase/ui/primitives/input'; 15 | import { Label } from '@formbase/ui/primitives/label'; 16 | 17 | import { SubmitButton } from '~/components/submit-button'; 18 | 19 | export const VerifyCode = () => { 20 | const [verifyEmailState, verifyEmailAction] = useFormState(verifyEmail, null); 21 | const [resendState, resendAction] = useFormState(resendEmail, null); 22 | const codeFormRef = useRef(null); 23 | 24 | useEffect(() => { 25 | if (resendState?.success) { 26 | toast('Email sent!'); 27 | } 28 | if (resendState?.error) { 29 | toast(resendState.error, { 30 | icon: , 31 | }); 32 | } 33 | }, [resendState?.error, resendState?.success]); 34 | 35 | useEffect(() => { 36 | if (verifyEmailState?.error) { 37 | toast(verifyEmailState.error, { 38 | icon: , 39 | }); 40 | } 41 | }, [verifyEmailState?.error]); 42 | 43 | return ( 44 |
45 |
46 | 47 | 48 | Verify 49 |
50 |
51 | 52 | Resend Code 53 | 54 |
55 |
56 | 57 | Want to use another email? Log out now. 58 | 59 |
60 |
61 | ); 62 | }; 63 | -------------------------------------------------------------------------------- /apps/web/src/app/(landing)/_components/copy-to-clipboard.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { CopyIcon } from '@radix-ui/react-icons'; 4 | import { toast } from 'sonner'; 5 | 6 | import { Button } from '@formbase/ui/primitives/button'; 7 | import { Input } from '@formbase/ui/primitives/input'; 8 | 9 | export const CopyToClipboard = ({ text }: { text: string }) => { 10 | const copyToClipboard = async () => { 11 | await navigator.clipboard.writeText(text); 12 | toast('Copied to clipboard', { 13 | icon: , 14 | }); 15 | }; 16 | 17 | return ( 18 |
19 | 24 | 27 |
28 | ); 29 | }; 30 | -------------------------------------------------------------------------------- /apps/web/src/app/(landing)/_components/mobile-hamburger.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { Menu, X } from 'lucide-react'; 4 | 5 | import { Button } from '@formbase/ui/primitives/button'; 6 | 7 | export interface HamburgerMenuProps { 8 | isMenuOpen: boolean; 9 | onToggleMenuOpen?: () => void; 10 | } 11 | 12 | export const HamburgerMenu = ({ 13 | isMenuOpen, 14 | onToggleMenuOpen, 15 | }: HamburgerMenuProps) => { 16 | return ( 17 |
18 | 25 |
26 | ); 27 | }; 28 | -------------------------------------------------------------------------------- /apps/web/src/app/(landing)/_components/mobile-navigation.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { useState } from 'react'; 4 | import Link from 'next/link'; 5 | 6 | import { FunctionSquare } from 'lucide-react'; 7 | 8 | import { Sheet, SheetContent } from '@formbase/ui/primitives/sheet'; 9 | 10 | import { HamburgerMenu } from './mobile-hamburger'; 11 | import { ThemeToggle } from './theme-toggle'; 12 | 13 | export type MobileNavigationProps = { 14 | isMenuOpen: boolean; 15 | onMenuOpenChange: (open: boolean) => void; 16 | }; 17 | 18 | const MobileNavigationSheet = ({ 19 | isMenuOpen, 20 | onMenuOpenChange, 21 | }: MobileNavigationProps) => { 22 | const handleMenuItemClick = () => { 23 | onMenuOpenChange(false); 24 | }; 25 | 26 | const menuNavigationLinks = [ 27 | { 28 | name: 'Docs', 29 | href: 'https://docs.formbase.dev', 30 | }, 31 | ] as const; 32 | 33 | return ( 34 | 35 | 36 | 41 | Formbase 42 | 43 | 44 |
45 | {menuNavigationLinks.map(({ href, name }) => ( 46 | { 51 | handleMenuItemClick(); 52 | }} 53 | > 54 | {name} 55 | 56 | ))} 57 |
58 | 59 |
60 |
61 | 62 |
63 | 64 |

65 | © {new Date().getFullYear()} Eight Labs. All rights reserved. 66 |

67 |
68 |
69 |
70 | ); 71 | }; 72 | 73 | export const MobileNavigation = () => { 74 | const [isHamburgerMenuOpen, setIsHamburgerMenuOpen] = useState(false); 75 | 76 | return ( 77 | <> 78 | { 80 | setIsHamburgerMenuOpen((v) => !v); 81 | }} 82 | isMenuOpen={isHamburgerMenuOpen} 83 | /> 84 | 88 | 89 | ); 90 | }; 91 | -------------------------------------------------------------------------------- /apps/web/src/app/(landing)/_components/site-footer.tsx: -------------------------------------------------------------------------------- 1 | import { FunctionSquare } from 'lucide-react'; 2 | 3 | import { cn } from '@formbase/ui/utils/cn'; 4 | 5 | import { ThemeToggle } from './theme-toggle'; 6 | 7 | export function SiteFooter({ className }: React.HTMLAttributes) { 8 | return ( 9 | 29 | ); 30 | } 31 | -------------------------------------------------------------------------------- /apps/web/src/app/(landing)/_components/theme-toggle.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import * as React from 'react'; 4 | import { useTheme } from 'next-themes'; 5 | 6 | import { Laptop, Moon, Sun } from 'lucide-react'; 7 | 8 | import { Button } from '@formbase/ui/primitives/button'; 9 | import { 10 | DropdownMenu, 11 | DropdownMenuContent, 12 | DropdownMenuItem, 13 | DropdownMenuTrigger, 14 | } from '@formbase/ui/primitives/dropdown-menu'; 15 | 16 | export function ThemeToggle() { 17 | const { setTheme } = useTheme(); 18 | 19 | return ( 20 | 21 | 22 | 31 | 32 | 33 | { 35 | setTheme('light'); 36 | }} 37 | > 38 | 39 | Light 40 | 41 | { 43 | setTheme('dark'); 44 | }} 45 | > 46 | 47 | Dark 48 | 49 | { 51 | setTheme('system'); 52 | }} 53 | > 54 | 55 | System 56 | 57 | 58 | 59 | ); 60 | } 61 | -------------------------------------------------------------------------------- /apps/web/src/app/(landing)/layout.tsx: -------------------------------------------------------------------------------- 1 | import { type ReactNode } from 'react'; 2 | import { redirect } from 'next/navigation'; 3 | 4 | import { validateRequest } from '@formbase/auth'; 5 | 6 | import { Header } from './_components/header'; 7 | import { SiteFooter } from './_components/site-footer'; 8 | 9 | async function LandingPageLayout({ children }: { children: ReactNode }) { 10 | const { user } = await validateRequest(); 11 | 12 | if (user) { 13 | redirect('/dashboard'); 14 | } 15 | 16 | return ( 17 |
18 |
19 | {children} 20 | 21 | 22 |
23 | ); 24 | } 25 | 26 | export default LandingPageLayout; 27 | -------------------------------------------------------------------------------- /apps/web/src/app/(main)/_actions/revalidateDashboard.ts: -------------------------------------------------------------------------------- 1 | 'use server'; 2 | 3 | import { revalidatePath } from 'next/cache'; 4 | 5 | // eslint-disable-next-line @typescript-eslint/require-await 6 | export const revalidateDashboard = async () => { 7 | revalidatePath('/dashboard'); 8 | }; 9 | 10 | // eslint-disable-next-line @typescript-eslint/require-await 11 | export const revalidateFromClient = async (route: string) => { 12 | revalidatePath(route); 13 | }; 14 | -------------------------------------------------------------------------------- /apps/web/src/app/(main)/_components/header.tsx: -------------------------------------------------------------------------------- 1 | import Link from 'next/link'; 2 | 3 | import { FunctionSquare } from 'lucide-react'; 4 | 5 | import { type LuciaUser } from '@formbase/auth'; 6 | 7 | import { UserDropdown } from './user-dropdown'; 8 | 9 | const routes = [{ name: 'Dashboard', href: '/dashboard' }] as const; 10 | 11 | export const Header = ({ user }: { user: LuciaUser }) => { 12 | return ( 13 |
14 |
15 | 19 | Formbase 20 | 21 | 22 | 33 | 34 | 39 |
40 |
41 | ); 42 | }; 43 | -------------------------------------------------------------------------------- /apps/web/src/app/(main)/dashboard/_components/dashboard-nav.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import Link from 'next/link'; 4 | import { usePathname } from 'next/navigation'; 5 | 6 | import { GearIcon } from '@radix-ui/react-icons'; 7 | import { CreditCard, FileTextIcon } from 'lucide-react'; 8 | 9 | import { cn } from '@formbase/ui/utils/cn'; 10 | 11 | const items = [ 12 | { 13 | title: 'Posts', 14 | href: '/dashboard', 15 | icon: FileTextIcon, 16 | }, 17 | 18 | // { 19 | // title: 'Billing', 20 | // href: '/dashboard/billing', 21 | // icon: CreditCard, 22 | // }, 23 | { 24 | title: 'Settings', 25 | href: '/dashboard/settings', 26 | icon: GearIcon, 27 | }, 28 | { 29 | title: 'Docs', 30 | href: 'https://docs.formbase.dev/', 31 | icon: BookOpenIcon, 32 | }, 33 | ]; 34 | 35 | interface Props { 36 | className?: string; 37 | } 38 | 39 | export function DashboardNav({ className }: Props) { 40 | const path = usePathname(); 41 | 42 | return ( 43 | 58 | ); 59 | } 60 | -------------------------------------------------------------------------------- /apps/web/src/app/(main)/dashboard/_components/empty-state.tsx: -------------------------------------------------------------------------------- 1 | import { PackageOpen, Rabbit } from 'lucide-react'; 2 | import { match } from 'ts-pattern'; 3 | 4 | export type EmptyFormStateProps = { 5 | status: 'form' | 'submission'; 6 | }; 7 | 8 | export const EmptyFormState = ({ status }: EmptyFormStateProps) => { 9 | const { 10 | title, 11 | message, 12 | icon: Icon, 13 | } = match(status) 14 | .with('form', () => ({ 15 | title: 'No Forms Available', 16 | message: 17 | "You haven't created any forms yet. Your forms will appear once you've created them.", 18 | icon: Rabbit, 19 | })) 20 | .with('submission', () => ({ 21 | title: 'No Submissions Available', 22 | message: 23 | "You haven't received any submissions yet. Your submissions will appear once you've received them.", 24 | icon: PackageOpen, 25 | })) 26 | .otherwise(() => ({ 27 | title: 'Nothing to do here!', 28 | message: "You're all caught up!", 29 | icon: Rabbit, 30 | })); 31 | 32 | return ( 33 |
34 | 35 | 36 |
37 |

{title}

38 | 39 |

{message}

40 |
41 |
42 | ); 43 | }; 44 | -------------------------------------------------------------------------------- /apps/web/src/app/(main)/dashboard/_components/formed-dialog.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { zodResolver } from '@hookform/resolvers/zod'; 4 | import { useForm } from 'react-hook-form'; 5 | import { toast } from 'sonner'; 6 | import { z } from 'zod'; 7 | 8 | import { Button } from '@formbase/ui/primitives/button'; 9 | import { 10 | Form, 11 | FormControl, 12 | FormDescription, 13 | FormField, 14 | FormItem, 15 | FormLabel, 16 | FormMessage, 17 | } from '@formbase/ui/primitives/form'; 18 | import { Input } from '@formbase/ui/primitives/input'; 19 | 20 | const FormSchema = z.object({ 21 | username: z.string().min(2, { 22 | message: 'Username must be at least 2 characters.', 23 | }), 24 | }); 25 | 26 | export function InputForm() { 27 | const form = useForm>({ 28 | resolver: zodResolver(FormSchema), 29 | defaultValues: { 30 | username: '', 31 | }, 32 | }); 33 | 34 | function onSubmit(data: z.infer) { 35 | toast.message('You submitted the following values:', { 36 | description: ( 37 |
38 |           {JSON.stringify(data, null, 2)}
39 |         
40 | ), 41 | }); 42 | } 43 | 44 | return ( 45 |
46 | 47 | ( 51 | 52 | Username 53 | 54 | 55 | 56 | 57 | This is your public display name. 58 | 59 | 60 | 61 | )} 62 | /> 63 | 64 | 65 | 66 | ); 67 | } 68 | -------------------------------------------------------------------------------- /apps/web/src/app/(main)/dashboard/_components/forms.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { use } from 'react'; 4 | 5 | import { type RouterOutputs } from '@formbase/api'; 6 | 7 | import { EmptyFormState } from './empty-state'; 8 | import { FormCard } from './form-card'; 9 | 10 | interface FormsProps { 11 | promises: Promise< 12 | // [RouterOutputs['form']['userForms'], RouterOutputs['stripe']['getPlan']] 13 | [RouterOutputs['form']['userForms']] 14 | >; 15 | } 16 | 17 | export function Forms({ promises }: FormsProps) { 18 | const [forms] = use(promises); 19 | 20 | return ( 21 |
22 | {forms.length > 0 ? ( 23 |
24 | {forms.map((form) => ( 25 | 32 | ))} 33 |
34 | ) : ( 35 | 36 | )} 37 |
38 | ); 39 | } 40 | -------------------------------------------------------------------------------- /apps/web/src/app/(main)/dashboard/_components/post-card-skeleton.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | Card, 3 | CardContent, 4 | CardFooter, 5 | CardHeader, 6 | } from '@formbase/ui/primitives/card'; 7 | import { Skeleton } from '@formbase/ui/primitives/skeleton'; 8 | 9 | export function PostCardSkeleton() { 10 | return ( 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 |
22 | 23 | 24 |
25 |
26 |
27 | ); 28 | } 29 | -------------------------------------------------------------------------------- /apps/web/src/app/(main)/dashboard/_components/posts-skeleton.tsx: -------------------------------------------------------------------------------- 1 | import { PostCardSkeleton } from './post-card-skeleton'; 2 | 3 | export function FormsSkeleton() { 4 | return ( 5 |
6 | {Array.from({ length: 3 }).map((_, i) => ( 7 | 8 | ))} 9 |
10 | ); 11 | } 12 | -------------------------------------------------------------------------------- /apps/web/src/app/(main)/dashboard/billing/_components/billing-skeleton.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | Card, 3 | CardContent, 4 | CardFooter, 5 | CardHeader, 6 | } from '@formbase/ui/primitives/card'; 7 | import { Skeleton } from '@formbase/ui/primitives/skeleton'; 8 | 9 | export function BillingSkeleton() { 10 | return ( 11 | <> 12 |
13 | 14 | 15 | 16 | 17 |
18 |
19 | {Array.from({ length: 2 }).map((_, i) => ( 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 |
28 | {Array.from({ length: 2 }).map((_, i) => ( 29 |
30 | 31 | 32 |
33 | ))} 34 |
35 |
36 | 37 | 38 | 39 |
40 | ))} 41 |
42 | 43 | ); 44 | } 45 | -------------------------------------------------------------------------------- /apps/web/src/app/(main)/dashboard/billing/_components/manage-subscription-form.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import * as React from 'react'; 4 | 5 | import { toast } from 'sonner'; 6 | import { type z } from 'zod'; 7 | 8 | import { type manageSubscriptionSchema } from '@formbase/api/routers/stripe'; 9 | import { Button } from '@formbase/ui/primitives/button'; 10 | 11 | import { api } from '~/lib/trpc/react'; 12 | 13 | type ManageSubscriptionFormProps = z.infer; 14 | 15 | export function ManageSubscriptionForm({ 16 | isPro, 17 | stripeCustomerId, 18 | stripeSubscriptionId, 19 | stripePriceId, 20 | }: ManageSubscriptionFormProps) { 21 | const [isPending, startTransition] = React.useTransition(); 22 | const { mutateAsync } = api.stripe.managePlan.useMutation(); 23 | 24 | function onSubmit(e: React.FormEvent) { 25 | e.preventDefault(); 26 | 27 | startTransition(async () => { 28 | try { 29 | const session = await mutateAsync({ 30 | isPro, 31 | stripeCustomerId, 32 | stripeSubscriptionId, 33 | stripePriceId, 34 | }); 35 | 36 | window.location.href = session.url ?? '/dashboard/billing'; 37 | } catch (err) { 38 | err instanceof Error 39 | ? toast.error(err.message) 40 | : toast.error('An error occurred. Please try again.'); 41 | } 42 | }); 43 | } 44 | 45 | return ( 46 |
47 | 50 |
51 | ); 52 | } 53 | -------------------------------------------------------------------------------- /apps/web/src/app/(main)/dashboard/billing/page.tsx: -------------------------------------------------------------------------------- 1 | import { redirect } from 'next/navigation'; 2 | 3 | import type { Metadata } from 'next'; 4 | 5 | import { validateRequest } from '@formbase/auth'; 6 | import { env } from '@formbase/env'; 7 | 8 | export const metadata: Metadata = { 9 | metadataBase: new URL(env.NEXT_PUBLIC_APP_URL), 10 | title: 'Billing', 11 | description: 'Manage your billing and subscription', 12 | }; 13 | 14 | export default async function BillingPage() { 15 | const { user } = await validateRequest(); 16 | 17 | if (!user) { 18 | redirect('/signin'); 19 | } 20 | 21 | redirect('/dashboard'); 22 | 23 | // const stripePromises = Promise.all([ 24 | // api.stripe.getPlans(), 25 | // api.stripe.getPlan(), 26 | // ]); 27 | 28 | // return ( 29 | //
30 | //
31 | //

Billing

32 | //

33 | // Manage your billing and subscription 34 | //

35 | //
36 | //
37 | // 38 | // 39 | // 40 | // Formbase app is a demo app using a Stripe test environment. You can 41 | // find a list of test card numbers on the{' '} 42 | // 48 | // Stripe docs 49 | // 50 | // . 51 | // 52 | // 53 | //
54 | // }> 55 | // 56 | // 57 | //
58 | // ); 59 | } 60 | -------------------------------------------------------------------------------- /apps/web/src/app/(main)/dashboard/layout.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { Inter } from 'next/font/google'; 3 | import { redirect } from 'next/navigation'; 4 | 5 | import { validateRequest } from '@formbase/auth'; 6 | 7 | type DashboardLayoutProps = { 8 | children: React.ReactNode; 9 | }; 10 | 11 | const inter = Inter({ 12 | subsets: ['latin'], 13 | weight: ['400', '500', '600', '700'], 14 | }); 15 | 16 | export default async function DashboardLayout({ 17 | children, 18 | }: DashboardLayoutProps) { 19 | const { user } = await validateRequest(); 20 | 21 | if (!user) redirect('/login'); 22 | 23 | return ( 24 |
27 |
{children}
28 |
29 | ); 30 | } 31 | -------------------------------------------------------------------------------- /apps/web/src/app/(main)/dashboard/page.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { type Metadata } from 'next'; 3 | 4 | import { env } from '@formbase/env'; 5 | 6 | import { api } from '~/lib/trpc/server'; 7 | 8 | import { Forms } from './_components/forms'; 9 | import { CreateFormDialog } from './_components/new-form-dialog'; 10 | import { FormsSkeleton } from './_components/posts-skeleton'; 11 | 12 | export const metadata: Metadata = { 13 | metadataBase: new URL(env.NEXT_PUBLIC_APP_URL), 14 | title: 'Forms', 15 | description: 'Manage your form endpoints', 16 | }; 17 | 18 | export default function DashboardPage() { 19 | const promises = Promise.all([api.form.userForms({})]); 20 | 21 | return ( 22 |
23 |
24 |
25 |

Form Endpoints

26 |

27 | Manage your forms endpoints 28 |

29 |
30 | 31 |
32 | 33 | }> 34 | 35 | 36 |
37 | ); 38 | } 39 | -------------------------------------------------------------------------------- /apps/web/src/app/(main)/dashboard/settings/layout.tsx: -------------------------------------------------------------------------------- 1 | import { type Metadata } from 'next'; 2 | 3 | import { SidebarNav } from './sidebar-nav'; 4 | 5 | export const metadata: Metadata = { 6 | title: 'Forms', 7 | description: 'Advanced form example using react-hook-form and Zod.', 8 | }; 9 | 10 | const sidebarNavItems = [ 11 | { 12 | title: 'Profile', 13 | href: '/dashboard/settings', 14 | }, 15 | ]; 16 | 17 | interface SettingsLayoutProps { 18 | children: React.ReactNode; 19 | } 20 | 21 | export default function SettingsLayout({ children }: SettingsLayoutProps) { 22 | return ( 23 |
24 |
25 |

Settings

26 |

27 | Manage your account settings 28 |

29 |
30 |
31 | 34 |
{children}
35 |
36 |
37 | ); 38 | } 39 | -------------------------------------------------------------------------------- /apps/web/src/app/(main)/dashboard/settings/page.tsx: -------------------------------------------------------------------------------- 1 | import { redirect } from 'next/navigation'; 2 | 3 | import type { Metadata } from 'next'; 4 | 5 | import { validateRequest } from '@formbase/auth'; 6 | import { env } from '@formbase/env'; 7 | import { Separator } from '@formbase/ui/primitives/separator'; 8 | 9 | import { ProfileForm } from './profile-form'; 10 | 11 | export const metadata: Metadata = { 12 | metadataBase: new URL(env.NEXT_PUBLIC_APP_URL), 13 | title: 'Settings | Formbase', 14 | description: 'Manage your account settings', 15 | }; 16 | 17 | export default async function SettingsPage() { 18 | const { user } = await validateRequest(); 19 | 20 | if (!user) { 21 | redirect('/signin'); 22 | } 23 | 24 | return ( 25 |
26 |
27 |

Profile

28 |

29 | Manage your profile settings. 30 |

31 |
32 | 33 | 34 |
35 | ); 36 | } 37 | -------------------------------------------------------------------------------- /apps/web/src/app/(main)/dashboard/settings/sidebar-nav.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import Link from 'next/link'; 4 | import { usePathname } from 'next/navigation'; 5 | 6 | import { buttonVariants } from '@formbase/ui/primitives/button'; 7 | import { cn } from '@formbase/ui/utils/cn'; 8 | 9 | interface SidebarNavProps extends React.HTMLAttributes { 10 | items: Array<{ 11 | href: string; 12 | title: string; 13 | }>; 14 | } 15 | 16 | export function SidebarNav({ className, items, ...props }: SidebarNavProps) { 17 | const pathname = usePathname(); 18 | 19 | return ( 20 | 43 | ); 44 | } 45 | -------------------------------------------------------------------------------- /apps/web/src/app/(main)/form/[id]/copy-button.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { ClipboardIcon } from 'lucide-react'; 4 | import { toast } from 'sonner'; 5 | 6 | import { useCopyToClipboard } from '~/lib/hooks/use-copy-to-clipboard'; 7 | 8 | type CopyFormIdProps = { 9 | formId: string; 10 | }; 11 | 12 | export default function CopyFormId({ formId }: CopyFormIdProps) { 13 | const [_, copy] = useCopyToClipboard(); 14 | 15 | return ( 16 |
17 | 18 | {formId} 19 | 20 | 22 | copy(formId).then(() => { 23 | toast('Copied to Clipboard', { 24 | icon: , 25 | }); 26 | }) 27 | } 28 | className="h-4 w-4 text-muted-foreground" 29 | /> 30 |
31 | ); 32 | } 33 | -------------------------------------------------------------------------------- /apps/web/src/app/(main)/form/[id]/delete-form-dialog.tsx: -------------------------------------------------------------------------------- 1 | import { useState } from 'react'; 2 | import { useRouter } from 'next/navigation'; 3 | 4 | import { TrashIcon } from '@radix-ui/react-icons'; 5 | import { toast } from 'sonner'; 6 | 7 | import { Button } from '@formbase/ui/primitives/button'; 8 | import { 9 | Dialog, 10 | DialogContent, 11 | DialogDescription, 12 | DialogHeader, 13 | DialogTitle, 14 | DialogTrigger, 15 | } from '@formbase/ui/primitives/dialog'; 16 | 17 | import { api } from '~/lib/trpc/react'; 18 | 19 | type DeleteFormDialogProps = { 20 | formId: string; 21 | onSuccessfulDelete?: () => void; 22 | }; 23 | 24 | export function DeleteFormDialog({ 25 | formId, 26 | onSuccessfulDelete, 27 | }: DeleteFormDialogProps) { 28 | const [open, setOpen] = useState(false); 29 | 30 | const router = useRouter(); 31 | const { mutateAsync: deleteForm, isPending: isFormDeleting } = 32 | api.form.delete.useMutation(); 33 | 34 | const handleDelete = async () => { 35 | await deleteForm( 36 | { 37 | id: formId, 38 | }, 39 | { 40 | onSuccess: () => { 41 | router.refresh(); 42 | toast.success('Your form endpoint has been deleted', { 43 | icon: , 44 | }); 45 | onSuccessfulDelete?.(); 46 | }, 47 | }, 48 | ); 49 | }; 50 | 51 | return ( 52 | 53 | 54 | 55 | 56 | 57 | 58 | Delete Form 59 | 60 | Are you sure you want to delete this form endpoint? This action 61 | cannot be undone. 62 | 63 | 64 |
65 | 74 | 82 |
83 |
84 |
85 | ); 86 | } 87 | -------------------------------------------------------------------------------- /apps/web/src/app/(main)/form/[id]/image-preview-dialog.tsx: -------------------------------------------------------------------------------- 1 | import { useState } from 'react'; 2 | import Image from 'next/image'; 3 | 4 | import { DownloadIcon, EyeIcon } from 'lucide-react'; 5 | 6 | import { AspectRatio } from '@formbase/ui/primitives/aspect-ratio'; 7 | import { 8 | Dialog, 9 | DialogContent, 10 | DialogDescription, 11 | DialogHeader, 12 | DialogTitle, 13 | DialogTrigger, 14 | } from '@formbase/ui/primitives/dialog'; 15 | 16 | type ImagePreviewDialogProps = { 17 | fileName: string; 18 | imageUrl: string; 19 | }; 20 | 21 | export function ImagePreviewDialog({ 22 | fileName, 23 | imageUrl, 24 | }: ImagePreviewDialogProps) { 25 | const [open, setOpen] = useState(false); 26 | 27 | return ( 28 | 29 | 30 |
34 |
35 | 36 |
37 |
38 |
39 | 40 | 41 | 42 |
43 | {fileName} 44 | 45 | 46 | 47 |
48 |
49 |
50 | 51 | 52 | {fileName} 58 | 59 | 60 |
61 |
62 | ); 63 | } 64 | -------------------------------------------------------------------------------- /apps/web/src/app/(main)/form/[id]/layout.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { redirect } from 'next/navigation'; 3 | 4 | import { validateRequest } from '@formbase/auth'; 5 | 6 | interface Props { 7 | children: React.ReactNode; 8 | } 9 | 10 | export default async function FormLayout({ children }: Props) { 11 | const { user } = await validateRequest(); 12 | 13 | if (!user) { 14 | redirect('/login'); 15 | } 16 | 17 | return ( 18 |
19 |
{children}
20 |
21 | ); 22 | } 23 | -------------------------------------------------------------------------------- /apps/web/src/app/(main)/layout.tsx: -------------------------------------------------------------------------------- 1 | import { type ReactNode } from 'react'; 2 | import { redirect } from 'next/navigation'; 3 | 4 | import type { LuciaUser } from '@formbase/auth'; 5 | 6 | import { validateRequest } from '@formbase/auth'; 7 | 8 | import { Header } from './_components/header'; 9 | 10 | const MainLayout = async ({ children }: { children: ReactNode }) => { 11 | const { user } = (await validateRequest()) as { user: LuciaUser | null }; 12 | 13 | if (!user) { 14 | redirect('/login'); 15 | } 16 | 17 | if (!user.emailVerified) { 18 | redirect('/verify-email'); 19 | } 20 | 21 | return ( 22 | <> 23 |
24 | {children} 25 | 26 | ); 27 | }; 28 | 29 | export default MainLayout; 30 | -------------------------------------------------------------------------------- /apps/web/src/app/(main)/onboarding/form/create-form-step.tsx: -------------------------------------------------------------------------------- 1 | import { CopyButton } from '~/components/copy-button'; 2 | 3 | import { CreateFormDialog } from './create-form-dialog'; 4 | 5 | type CreateFormStepProps = { 6 | formId: string | null; 7 | }; 8 | 9 | export const CreateFormStep = ({ formId }: CreateFormStepProps) => { 10 | return ( 11 |
12 |

Add a new form endpoint

13 |
14 |
15 |

Use the new endpoint to recieve submissions

16 | 17 | {formId === null ? ( 18 | 19 | ) : ( 20 |
21 |
22 |               <>{`https://formbase.dev/s/${formId}`}
23 |               
24 |             
25 |
26 | )} 27 |
28 |
29 | ); 30 | }; 31 | -------------------------------------------------------------------------------- /apps/web/src/app/(main)/onboarding/form/send-submission-button.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import React, { useTransition } from 'react'; 4 | import { useRouter } from 'next/navigation'; 5 | 6 | import { Send } from 'lucide-react'; 7 | import { toast } from 'sonner'; 8 | 9 | import { Button } from '@formbase/ui/primitives/button'; 10 | 11 | import { revalidateFromClient } from '../../_actions/revalidateDashboard'; 12 | 13 | type SendFormSubmissionButton = { 14 | formId: string | null; 15 | }; 16 | 17 | export default function SendFormSubmissionButton({ 18 | formId, 19 | }: SendFormSubmissionButton) { 20 | const [isSubmittingForm, startFormSubmitTransition] = useTransition(); 21 | const router = useRouter(); 22 | 23 | const handleFormSubmission = () => { 24 | startFormSubmitTransition(async () => { 25 | await fetch(`/s/${formId}`, { 26 | method: 'POST', 27 | body: JSON.stringify({ 28 | message: 'Hello, welcome to formbase', 29 | }), 30 | }); 31 | 32 | toast.success('Form submission sent!'); 33 | 34 | void revalidateFromClient(`/form/${formId}`); 35 | router.push(`/form/${formId}`); 36 | }); 37 | }; 38 | return ( 39 | 48 | ); 49 | } 50 | -------------------------------------------------------------------------------- /apps/web/src/app/(main)/onboarding/page.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import Stepper from './stepper'; 4 | 5 | export default function OnboardingPage() { 6 | return ( 7 |
8 |
9 |
10 |

11 | Recieve your first submission 12 |

13 |

14 | Follow these steps to get your first form submission 15 |

16 |
17 |
18 | 19 | 20 |
21 | ); 22 | } 23 | -------------------------------------------------------------------------------- /apps/web/src/app/(main)/onboarding/stepper.tsx: -------------------------------------------------------------------------------- 1 | import { cn } from '@formbase/ui/utils/cn'; 2 | 3 | import { api } from '~/lib/trpc/server'; 4 | 5 | import { CodeExampleStep } from './form/code-example-step'; 6 | import { CreateFormStep } from './form/create-form-step'; 7 | 8 | const Stepper = async () => { 9 | const onboardingForm = await api.form.getOnboardingForm(); 10 | const form = onboardingForm[0]?.formId; 11 | const formId = form ?? null; 12 | 13 | const steps = [ 14 | { 15 | content: , 16 | }, 17 | { 18 | content: , 19 | }, 20 | ]; 21 | 22 | return ( 23 |
24 | {steps.map((step, index) => ( 25 |
26 | 35 | {index + 1} 36 | 37 | 38 | {step.content} 39 |
40 | ))} 41 |
42 | ); 43 | }; 44 | 45 | export default Stepper; 46 | -------------------------------------------------------------------------------- /apps/web/src/app/api/health/route.ts: -------------------------------------------------------------------------------- 1 | import { db, drizzlePrimitives } from '@formbase/db'; 2 | 3 | export async function GET() { 4 | try { 5 | await db.execute(drizzlePrimitives.sql`SELECT 1000`); 6 | 7 | return new Response('All systems operational', { status: 200 }); 8 | } catch (err) { 9 | return new Response(`An error occured`, { status: 500 }); 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /apps/web/src/app/api/mail/route.ts: -------------------------------------------------------------------------------- 1 | import { match } from 'ts-pattern'; 2 | 3 | import { sendResetPasswordEmail, sendVerificationEmail } from '~/lib/email'; 4 | 5 | type SendMailPayload = 6 | | { 7 | email: string; 8 | code: string; 9 | type: 'verification'; 10 | } 11 | | { 12 | email: string; 13 | link: string; 14 | type: 'reset'; 15 | }; 16 | 17 | export async function POST(request: Request) { 18 | const body = (await request.json()) as SendMailPayload; 19 | 20 | if (!body.email) { 21 | return new Response('Missing email', { status: 400 }); 22 | } 23 | 24 | try { 25 | await match(body) 26 | .with({ type: 'reset' }, async ({ email, link }) => { 27 | await sendResetPasswordEmail({ email, link }); 28 | }) 29 | .with({ type: 'verification' }, async ({ email, code }) => { 30 | await sendVerificationEmail({ email, code }); 31 | }) 32 | .exhaustive(); 33 | 34 | return new Response('Mail sent', { status: 200 }); 35 | } catch (error) { 36 | return new Response('There was an error sending the email', { 37 | status: 500, 38 | }); 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /apps/web/src/app/api/trpc/[trpc]/route.ts: -------------------------------------------------------------------------------- 1 | import { type NextRequest } from 'next/server'; 2 | 3 | import { fetchRequestHandler } from '@trpc/server/adapters/fetch'; 4 | 5 | import { appRouter, createTRPCContext } from '@formbase/api'; 6 | import { env } from '@formbase/env'; 7 | 8 | const createContext = async (req: NextRequest) => { 9 | return createTRPCContext({ 10 | headers: req.headers, 11 | }); 12 | }; 13 | 14 | const handler = (req: NextRequest) => 15 | fetchRequestHandler({ 16 | endpoint: '/api/trpc', 17 | req, 18 | router: appRouter, 19 | createContext: () => createContext(req), 20 | onError: ({ path, error }) => { 21 | env.NODE_ENV === 'development' && 22 | console.error( 23 | `❌ tRPC failed on ${path ?? ''}: ${error.message}`, 24 | ); 25 | }, 26 | }); 27 | 28 | export { handler as GET, handler as POST }; 29 | -------------------------------------------------------------------------------- /apps/web/src/app/icon.tsx: -------------------------------------------------------------------------------- 1 | import { ImageResponse } from 'next/og'; 2 | 3 | export const runtime = 'edge'; 4 | 5 | export const size = { 6 | width: 32, 7 | height: 32, 8 | }; 9 | export const contentType = 'image/png'; 10 | 11 | export default function Icon() { 12 | return new ImageResponse( 13 | ( 14 |
21 | 8{/* Formbase, Eight Labs */} 22 |
23 | ), 24 | { 25 | ...size, 26 | }, 27 | ); 28 | } 29 | -------------------------------------------------------------------------------- /apps/web/src/app/layout.tsx: -------------------------------------------------------------------------------- 1 | import '../styles/globals.css'; 2 | 3 | import { Inter as FontSans } from 'next/font/google'; 4 | import Script from 'next/script'; 5 | 6 | import type { Metadata, Viewport } from 'next'; 7 | 8 | import { Toaster } from 'sonner'; 9 | 10 | import { env } from '@formbase/env'; 11 | import { TooltipProvider } from '@formbase/ui/primitives/tooltip'; 12 | import { cn } from '@formbase/ui/utils/cn'; 13 | 14 | import { ThemeProvider } from '~/components/theme-provider'; 15 | import { TRPCReactProvider } from '~/lib/trpc/react'; 16 | 17 | const fontSans = FontSans({ 18 | subsets: ['latin'], 19 | variable: '--font-sans', 20 | }); 21 | 22 | export const metadata: Metadata = { 23 | title: { 24 | default: 'Formbase', 25 | template: `%s | Formbase`, 26 | }, 27 | description: 'Manage forms with ease', 28 | icons: [{ rel: 'icon', url: '/icon.png' }], 29 | }; 30 | 31 | export const viewport: Viewport = { 32 | themeColor: [ 33 | { media: '(prefers-color-scheme: light)', color: 'white' }, 34 | { media: '(prefers-color-scheme: dark)', color: 'black' }, 35 | ], 36 | }; 37 | 38 | export default function RootLayout({ 39 | children, 40 | }: { 41 | children: React.ReactNode; 42 | }) { 43 | return ( 44 | 45 | 51 | 57 | 58 | {children} 59 | 60 | 61 | 62 | {env.UMAMI_TRACKING_ID && ( 63 |