diff --git a/.env.example b/.env.example deleted file mode 100644 index f48ad36ae..000000000 --- a/.env.example +++ /dev/null @@ -1,24 +0,0 @@ -# Since .env.local is gitignored, you can use .env.example to build a new `.env` file when you clone the repo. -# Keep this file up-to-date when you add new variables to \`.env\`. - -# This file will be committed to version control, so make sure not to have any secrets in it. -# If you are cloning this repo, create a copy of this file named `.env` and populate it with your secrets. - -# We use dotenv to load Prisma from Next.js' .env.local file -# @see https://www.prisma.io/docs/reference/database-reference/connection-urls -DATABASE_URL='mysql://user:password@host/db?sslaccept=strict' - -# Clerk -NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY="pk_test_" -CLERK_SECRET_KEY="sk_test_" - -# Stripe -STRIPE_API_KEY="sk_test_" -STRIPE_WEBHOOK_SECRET="whsec_" -NEXT_PUBLIC_STRIPE_STD_PRODUCT_ID="prod_" -NEXT_PUBLIC_STRIPE_STD_MONTHLY_PRICE_ID="price_" -NEXT_PUBLIC_STRIPE_PRO_PRODUCT_ID="prod_" -NEXT_PUBLIC_STRIPE_PRO_MONTHLY_PRICE_ID="price_" - -# Misc -NEXTJS_URL="http://localhost:3000" \ No newline at end of file diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml deleted file mode 100644 index 043f0f9bc..000000000 --- a/.github/FUNDING.yml +++ /dev/null @@ -1,3 +0,0 @@ -# These are supported funding model platforms - -github: juliusmarminge diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml deleted file mode 100644 index 54199a8d4..000000000 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ /dev/null @@ -1,37 +0,0 @@ -name: 🐞 Bug Report -description: Create a bug report to help us improve -title: "bug: " -labels: ["🐞❔ unconfirmed bug"] -body: - - type: textarea - attributes: - label: Provide environment information - description: | - Run this command in your project root and paste the results in a code block: - ```bash - npx envinfo --system --binaries - ``` - validations: - required: true - - type: textarea - attributes: - label: Describe the bug - description: A clear and concise description of the bug, as well as what you expected to happen when encountering it. - validations: - required: true - - type: input - attributes: - label: Link to reproduction - description: Please provide a link to a reproduction of the bug. Issues without a reproduction repo may be ignored. - validations: - required: true - - type: textarea - attributes: - label: To reproduce - description: Describe how to reproduce your bug. Steps, code snippets, reproduction repos etc. - validations: - required: true - - type: textarea - attributes: - label: Additional information - description: Add any other information related to the bug here, screenshots if applicable. diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml deleted file mode 100644 index 97fa0cbab..000000000 --- a/.github/ISSUE_TEMPLATE/feature_request.yml +++ /dev/null @@ -1,29 +0,0 @@ -# This template is heavily inspired by the Next.js's template: -# See here: https://github.com/vercel/next.js/blob/canary/.github/ISSUE_TEMPLATE/3.feature_request.yml - -name: 🛠 Feature Request -description: Create a feature request for the core packages -title: 'feat: ' -labels: ['✨ enhancement'] -body: - - type: markdown - attributes: - value: | - Thank you for taking the time to file a feature request. Please fill out this form as completely as possible. - - type: textarea - attributes: - label: Describe the feature you'd like to request - description: Please describe the feature as clear and concise as possible. Remember to add context as to why you believe this feature is needed. - validations: - required: true - - type: textarea - attributes: - label: Describe the solution you'd like to see - description: Please describe the solution you would like to see. Adding example usage is a good way to provide context. - validations: - required: true - - type: textarea - attributes: - label: Additional information - description: Add any other information related to the feature here. If your feature request is related to any issues or discussions, link them here. - diff --git a/.github/renovate.json b/.github/renovate.json index 86b84d771..04cd9171c 100644 --- a/.github/renovate.json +++ b/.github/renovate.json @@ -3,7 +3,7 @@ "extends": ["config:base"], "packageRules": [ { - "matchPackagePatterns": ["^@acme/"], + "matchPackagePatterns": ["^@kit/"], "enabled": false } ], diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml deleted file mode 100644 index 7b78ce656..000000000 --- a/.github/workflows/ci.yml +++ /dev/null @@ -1,56 +0,0 @@ -name: CI - -on: - pull_request: - branches: ["*"] - push: - branches: ["main"] - merge_group: - -# You can leverage Vercel Remote Caching with Turbo to speed up your builds -# @link https://turborepo.org/docs/core-concepts/remote-caching#remote-caching-on-vercel-builds -env: - TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }} - TURBO_TEAM: ${{ secrets.TURBO_TEAM }} - -jobs: - build-lint: - env: - DATABASE_URL: file:./db.sqlite - runs-on: ubuntu-latest - - steps: - - name: Checkout repo - uses: actions/checkout@v4 - - - name: Setup pnpm - uses: pnpm/action-setup@v2.4.0 - - - name: Setup Node 20 - uses: actions/setup-node@v4 - with: - node-version: 20 - - - name: Get pnpm store directory - id: pnpm-cache - run: | - echo "pnpm_cache_dir=$(pnpm store path)" >> $GITHUB_OUTPUT - - - name: Setup pnpm cache - uses: actions/cache@v4 - with: - path: ${{ steps.pnpm-cache.outputs.pnpm_cache_dir }} - key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }} - restore-keys: | - ${{ runner.os }}-pnpm-store- - - - name: Install deps (with cache) - run: pnpm install - - - run: cp .env.example .env.local - - - name: Build, lint and type-check - run: pnpm turbo build lint typecheck format - - - name: Check workspaces - run: pnpm manypkg check diff --git a/.gitignore b/.gitignore index 06fdf8cb2..88fda4f86 100644 --- a/.gitignore +++ b/.gitignore @@ -8,20 +8,11 @@ node_modules # testing coverage -# database -**/prisma/db.sqlite -**/prisma/db.sqlite-journal - # next.js .next/ out/ next-env.d.ts -# expo -.expo/ -dist/ -expo-env.d.ts - # production build @@ -47,3 +38,10 @@ yarn-error.log* # turbo .turbo + +# ide +.idea/ +.vscode/ + +# contentlayer +.contentlayer/ \ No newline at end of file diff --git a/.npmrc b/.npmrc index db24b7f36..464b0c0dd 100644 --- a/.npmrc +++ b/.npmrc @@ -1,18 +1,2 @@ -# Expo doesn't play nice with pnpm by default. -# The symbolic links of pnpm break the rules of Expo monorepos. -# @link https://docs.expo.dev/guides/monorepos/#common-issues -node-linker=hoisted - -# In order to cache Prisma correctly -public-hoist-pattern[]=*prisma* - -# FIXME: @prisma/client is required by the @acme/auth, -# but we don't want it installed there since it's already -# installed in the @acme/db package -strict-peer-dependencies=false - -# Prevent pnpm from adding the "workspace:"" prefix to local -# packages as it casues issues with manypkg -# @link https://pnpm.io/npmrc#prefer-workspace-packages -save-workspace-protocol=false -prefer-workspace-packages=true \ No newline at end of file +shamefully-hoist=true +peer-legacy-deps=true \ No newline at end of file diff --git a/.vscode/extensions.json b/.vscode/extensions.json deleted file mode 100644 index 4487d7109..000000000 --- a/.vscode/extensions.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "recommendations": [ - "esbenp.prettier-vscode", - "dbaeumer.vscode-eslint", - "bradlc.vscode-tailwindcss", - "Prisma.prisma" - ] -} diff --git a/.vscode/launch.json b/.vscode/launch.json deleted file mode 100644 index 5fcd84524..000000000 --- a/.vscode/launch.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "version": "0.2.0", - "configurations": [ - { - "name": "Next.js", - "type": "node-terminal", - "request": "launch", - "command": "pnpm dev", - "cwd": "${workspaceFolder}/apps/nextjs/", - "skipFiles": ["/**"] - } - ] -} diff --git a/.vscode/settings.json b/.vscode/settings.json deleted file mode 100644 index 877f87244..000000000 --- a/.vscode/settings.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "[prisma]": { - "editor.defaultFormatter": "Prisma.prisma" - }, - "editor.codeActionsOnSave": { - "source.fixAll.eslint": "explicit" - }, - "editor.defaultFormatter": "esbenp.prettier-vscode", - "editor.formatOnSave": true, - "eslint.rules.customizations": [{ "rule": "*", "severity": "warn" }], - "eslint.workingDirectories": [ - { "pattern": "apps/*/" }, - { "pattern": "packages/*/" }, - { "pattern": "tooling/*/" } - ], - "tailwindCSS.experimental.configFile": "./tooling/tailwind/index.ts", - "typescript.tsdk": "node_modules/typescript/lib", - "typescript.enablePromptUseWorkspaceTsdk": true, - "typescript.preferences.autoImportFileExcludePatterns": [ - "next/router.d.ts", - "next/dist/client/router.d.ts" - ] -} diff --git a/LICENSE b/LICENSE deleted file mode 100644 index 435503eb6..000000000 --- a/LICENSE +++ /dev/null @@ -1,21 +0,0 @@ -MIT License - -Copyright (c) 2023 Julius Marminge - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. diff --git a/README.md b/README.md index 9c9f2ba97..c472757ad 100644 --- a/README.md +++ b/README.md @@ -1,96 +1,15 @@ -# Acme Corp - -> **Warning** -> This is a work-in-progress and not the finished product. -> -> Feel free to leave feature suggestions but please don't open issues for bugs or support requests just yet. - -## About - -This project features the next-generation stack for building fullstack application. It's structured as a monorepo with a shared API using tRPC. Built on the new app router in Next.js 13 with React Server Components. - -- For database querying, [Kysely](https://kysely.dev) is used as a query builder whilst remaining [Prisma](https://prisma.io) as a schema management tool. (This means it's fully edge-ready!). To keep a good DX, we use a custom setup with kysely-prisma-generator to pull out all the prisma types, and then a [post-generate script](./packages/db/prisma/postgenerate.ts) to create a fully typesafe db client using database.js from [PlanetScale](https://planetscale.com). -- This project uses [Clerk](https://clerk.com) as it's authentication provider. -- Awesome UI components from [shadcn/ui](https://ui.shadcn.com) - -## Installation - -There are two ways of initializing an app using the `acme-corp` starter. You can either use this repository as a template: - -![use-as-template](https://github.com/t3-oss/create-t3-turbo/assets/51714798/bb6c2e5d-d8b6-416e-aeb3-b3e50e2ca994) - -or use Turbo's CLI to init your project: - -```bash -npx create-turbo@latest -e https://github.com/juliusmarminge/acme-corp -``` - -## Quick Start - -> **Note** -> The [db](./packages/db) package is preconfigured to use PlanetScale and is edge-ready with the [database.js](https://github.com/planetscale/database-js) driver. If you're using something else, make the necesary modifications to the [Kysely client](./packages/db/index.ts). - -To get it running, follow the steps below: +# Makerkit - Supabase SaaS Starter Kit - Turbo Edition ### 1. Setup dependencies ```bash # Install dependencies pnpm i - -# Configure environment variables -# There is an `.env.example` in the root directory you can use for reference -cp .env.example .env.local - -# Push the Prisma schema to the database -pnpm db:push ``` -### 2. Configure Expo `dev`-script +### 2. Start the development server -> **Warning** -> The Expo app is still stock from `create-t3-turbo` and haven't yet gotten any attention. -> -> We will get their in due time! - -#### Use iOS Simulator - -1. Make sure you have XCode and XCommand Line Tools installed [as shown on expo docs](https://docs.expo.dev/workflow/ios-simulator). - - > **NOTE:** If you just installed XCode, or if you have updated it, you need to open the simulator manually once. Run `npx expo start` in the root dir, and then enter `I` to launch Expo Go. After the manual launch, you can run `pnpm dev` in the root directory. - - ```diff - + "dev": "expo start --ios", - ``` - -2. Run `pnpm dev` at the project root folder. - -#### Use Android Emulator - -1. Install Android Studio tools [as shown on expo docs](https://docs.expo.dev/workflow/android-studio-emulator). - -2. Change the `dev` script at `apps/expo/package.json` to open the Android emulator. - - ```diff - + "dev": "expo start --android", - ``` - -3. Run `pnpm dev` at the project root folder. - -> **TIP:** It might be easier to run each app in separate terminal windows so you get the logs from each app separately. This is also required if you want your terminals to be interactive, e.g. to access the Expo QR code. You can run `pnpm --filter expo dev` and `pnpm --filter nextjs dev` to run each app in a separate terminal window. - -### 3. When it's time to add a new package - -To add a new package, simply run `pnpm turbo gen init` in the monorepo root. This will prompt you for a package name as well as if you want to install any dependencies to the new package (of course you can also do this yourself later). - -The generator sets up the `package.json`, `tsconfig.json` and a `index.ts`, as well as configures all the necessary configurations for tooling around your package such as formatting, linting and typechecking. When the package is created, you're ready to go build out the package. - -## References - -The stack originates from [create-t3-app](https://github.com/t3-oss/create-t3-app). - -A [blog post](https://jumr.dev/blog/t3-turbo) where I wrote how to migrate a T3 app into this. - -## Questions? - -Book us with Cal.com +```bash +# Start the development server +pnpm dev +``` \ No newline at end of file diff --git a/apps/expo/.expo-shared/assets.json b/apps/expo/.expo-shared/assets.json deleted file mode 100644 index 1e6decfbb..000000000 --- a/apps/expo/.expo-shared/assets.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "12bb71342c6255bbf50437ec8f4441c083f47cdb74bd89160c15e4f43e52a1cb": true, - "40b842e832070c58deac6aa9e08fa459302ee3f9da492c7e77d93d2fbf4a56fd": true -} diff --git a/apps/expo/app.config.ts b/apps/expo/app.config.ts deleted file mode 100644 index f6c21c940..000000000 --- a/apps/expo/app.config.ts +++ /dev/null @@ -1,43 +0,0 @@ -import type { ExpoConfig } from "@expo/config"; - -const defineConfig = (): ExpoConfig => ({ - name: "expo", - slug: "expo", - scheme: "expo", - version: "0.1.0", - orientation: "portrait", - icon: "./assets/icon.png", - userInterfaceStyle: "light", - splash: { - image: "./assets/icon.png", - resizeMode: "contain", - backgroundColor: "#1F104A", - }, - updates: { - fallbackToCacheTimeout: 0, - }, - assetBundlePatterns: ["**/*"], - ios: { - bundleIdentifier: "your.bundle.identifier", - supportsTablet: true, - }, - android: { - package: "your.bundle.identifier", - adaptiveIcon: { - foregroundImage: "./assets/icon.png", - backgroundColor: "#1F104A", - }, - }, - // extra: { - // eas: { - // projectId: "your-eas-project-id", - // }, - // }, - experiments: { - tsconfigPaths: true, - typedRoutes: true, - }, - plugins: ["expo-router", "./expo-plugins/with-modify-gradle.js"], -}); - -export default defineConfig; diff --git a/apps/expo/assets/icon.png b/apps/expo/assets/icon.png deleted file mode 100644 index 67917f52a..000000000 Binary files a/apps/expo/assets/icon.png and /dev/null differ diff --git a/apps/expo/babel.config.js b/apps/expo/babel.config.js deleted file mode 100644 index 143cb0858..000000000 --- a/apps/expo/babel.config.js +++ /dev/null @@ -1,15 +0,0 @@ -/** @type {import("@babel/core").ConfigFunction} */ -module.exports = function (api) { - api.cache.forever(); - - return { - presets: [ - ["babel-preset-expo", { jsxImportSource: "nativewind" }], - "nativewind/babel", - ], - plugins: [ - require.resolve("expo-router/babel"), - require.resolve("react-native-reanimated/plugin"), - ], - }; -}; diff --git a/apps/expo/eas.json b/apps/expo/eas.json deleted file mode 100644 index 607de32ef..000000000 --- a/apps/expo/eas.json +++ /dev/null @@ -1,31 +0,0 @@ -{ - "cli": { - "version": ">= 4.1.2" - }, - "build": { - "base": { - "node": "18.16.1", - "ios": { - "resourceClass": "m-medium" - } - }, - "development": { - "extends": "base", - "developmentClient": true, - "distribution": "internal" - }, - "preview": { - "extends": "base", - "distribution": "internal", - "ios": { - "simulator": true - } - }, - "production": { - "extends": "base" - } - }, - "submit": { - "production": {} - } -} diff --git a/apps/expo/expo-plugins/with-modify-gradle.js b/apps/expo/expo-plugins/with-modify-gradle.js deleted file mode 100644 index 343c579b3..000000000 --- a/apps/expo/expo-plugins/with-modify-gradle.js +++ /dev/null @@ -1,44 +0,0 @@ -// This plugin is required for fixing `.apk` build issue -// It appends Expo and RN versions into the `build.gradle` file -// References: -// https://github.com/t3-oss/create-t3-turbo/issues/120 -// https://github.com/expo/expo/issues/18129 - -/** @type {import("@expo/config-plugins").ConfigPlugin} */ -const defineConfig = (config) => { - // eslint-disable-next-line @typescript-eslint/no-var-requires - return require("@expo/config-plugins").withProjectBuildGradle( - config, - (config) => { - if (!config.modResults.contents.includes("ext.getPackageJsonVersion =")) { - config.modResults.contents = config.modResults.contents.replace( - "buildscript {", - `buildscript { - ext.getPackageJsonVersion = { packageName -> - new File(['node', '--print', "JSON.parse(require('fs').readFileSync(require.resolve('\${packageName}/package.json'), 'utf-8')).version"].execute(null, rootDir).text.trim()) - }`, - ); - } - - if (!config.modResults.contents.includes("reactNativeVersion =")) { - config.modResults.contents = config.modResults.contents.replace( - "ext {", - `ext { - reactNativeVersion = "\${ext.getPackageJsonVersion('react-native')}"`, - ); - } - - if (!config.modResults.contents.includes("expoPackageVersion =")) { - config.modResults.contents = config.modResults.contents.replace( - "ext {", - `ext { - expoPackageVersion = "\${ext.getPackageJsonVersion('expo')}"`, - ); - } - - return config; - }, - ); -}; - -module.exports = defineConfig; diff --git a/apps/expo/metro.config.js b/apps/expo/metro.config.js deleted file mode 100644 index 3754383a1..000000000 --- a/apps/expo/metro.config.js +++ /dev/null @@ -1,29 +0,0 @@ -// Learn more: https://docs.expo.dev/guides/monorepos/ -const { getDefaultConfig } = require("@expo/metro-config"); -const { withNativeWind } = require("nativewind/metro"); - -const path = require("path"); - -const projectRoot = __dirname; -const workspaceRoot = path.resolve(projectRoot, "../.."); - -// Create the default Metro config -const config = getDefaultConfig(projectRoot, { isCSSEnabled: true }); - -if (config.resolver) { - // 1. Watch all files within the monorepo - config.watchFolders = [workspaceRoot]; - // 2. Let Metro know where to resolve packages and in what order - config.resolver.nodeModulesPaths = [ - path.resolve(projectRoot, "node_modules"), - path.resolve(workspaceRoot, "node_modules"), - ]; - // 3. Force Metro to resolve (sub)dependencies only from the `nodeModulesPaths` - config.resolver.disableHierarchicalLookup = true; -} - -// @ts-expect-error - FIXME: type is mismatching? -module.exports = withNativeWind(config, { - input: "./src/styles.css", - configPath: "./tailwind.config.ts", -}); diff --git a/apps/expo/package.json b/apps/expo/package.json deleted file mode 100644 index fd17321d6..000000000 --- a/apps/expo/package.json +++ /dev/null @@ -1,68 +0,0 @@ -{ - "name": "@acme/expo", - "version": "0.1.0", - "private": true, - "main": "expo-router/entry", - "scripts": { - "clean": "git clean -xdf .expo .turbo node_modules", - "dev": "expo start --ios", - "dev:android": "expo start --android", - "dev:ios": "expo start --ios", - "android": "expo run:android", - "ios": "expo run:ios", - "format": "prettier --check . --ignore-path ../../.gitignore", - "lint": "eslint .", - "typecheck": "tsc --noEmit" - }, - "dependencies": { - "@expo/metro-config": "^0.10.7", - "@shopify/flash-list": "1.4.3", - "@tanstack/react-query": "^5.17.15", - "@trpc/client": "next", - "@trpc/react-query": "next", - "@trpc/server": "next", - "expo": "^49.0.22", - "expo-constants": "~14.4.2", - "expo-linking": "~5.0.2", - "expo-router": "2.0.14", - "expo-splash-screen": "~0.22.0", - "expo-status-bar": "~1.7.1", - "nativewind": "^4.0.23", - "react": "18.2.0", - "react-dom": "18.2.0", - "react-native": "0.73.1", - "react-native-gesture-handler": "~2.12.0", - "react-native-reanimated": "~3.3.0", - "react-native-safe-area-context": "4.6.3", - "react-native-screens": "~3.22.1", - "superjson": "2.2.1" - }, - "devDependencies": { - "@acme/api": "0.1.0", - "@acme/eslint-config": "0.2.0", - "@acme/prettier-config": "0.1.0", - "@acme/tailwind-config": "0.1.0", - "@acme/tsconfig": "0.1.0", - "@babel/core": "^7.23.7", - "@babel/preset-env": "^7.23.8", - "@babel/runtime": "^7.23.8", - "@expo/config-plugins": "^7.8.4", - "@types/babel__core": "^7.20.5", - "@types/react": "^18.2.48", - "eslint": "^8.56.0", - "prettier": "^3.2.4", - "tailwindcss": "3.4.1", - "typescript": "^5.3.3" - }, - "eslintConfig": { - "root": true, - "extends": [ - "@acme/eslint-config/base", - "@acme/eslint-config/react" - ], - "ignorePatterns": [ - "expo-plugins/**" - ] - }, - "prettier": "@acme/prettier-config" -} diff --git a/apps/expo/src/app/_layout.tsx b/apps/expo/src/app/_layout.tsx deleted file mode 100644 index 23ff5a630..000000000 --- a/apps/expo/src/app/_layout.tsx +++ /dev/null @@ -1,27 +0,0 @@ -import { Stack } from "expo-router"; -import { StatusBar } from "expo-status-bar"; - -import { TRPCProvider } from "~/utils/api"; - -import "../styles.css"; - -// This is the main layout of the app -// It wraps your pages with the providers they need -export default function RootLayout() { - return ( - - {/* - The Stack component displays the current page. - It also allows you to configure your screens - */} - - - - ); -} diff --git a/apps/expo/src/app/index.tsx b/apps/expo/src/app/index.tsx deleted file mode 100644 index fd0622f5b..000000000 --- a/apps/expo/src/app/index.tsx +++ /dev/null @@ -1,145 +0,0 @@ -// import { useState } from "react"; -// import { Button, Pressable, Text, TextInput, View } from "react-native"; -// import { SafeAreaView } from "react-native-safe-area-context"; -// import { Link, Stack } from "expo-router"; -// import { FlashList } from "@shopify/flash-list"; - -// import type { RouterOutputs } from "~/utils/api"; -// import { api } from "~/utils/api"; - -// function PostCard(props: { -// post: RouterOutputs["post"]["all"][number]; -// onDelete: () => void; -// }) { -// return ( -// -// -// -// -// -// {props.post.title} -// -// {props.post.content} -// -// -// -// -// Delete -// -// -// ); -// } - -// function CreatePost() { -// const utils = api.useUtils(); - -// const [title, setTitle] = useState(""); -// const [content, setContent] = useState(""); - -// const { mutate, error } = api.post.create.useMutation({ -// async onSuccess() { -// setTitle(""); -// setContent(""); -// await utils.post.all.invalidate(); -// }, -// }); - -// return ( -// -// -// {error?.data?.zodError?.fieldErrors.title && ( -// -// {error.data.zodError.fieldErrors.title} -// -// )} -// -// {error?.data?.zodError?.fieldErrors.content && ( -// -// {error.data.zodError.fieldErrors.content} -// -// )} -// { -// mutate({ -// title, -// content, -// }); -// }} -// > -// Publish post -// -// {error?.data?.code === "UNAUTHORIZED" && ( -// -// You need to be logged in to create a post -// -// )} -// -// ); -// } - -// export default function Index() { -// const utils = api.useUtils(); - -// const postQuery = api.post.all.useQuery(); - -// const deletePostMutation = api.post.delete.useMutation({ -// onSettled: () => utils.post.all.invalidate(), -// }); - -// return ( -// -// {/* Changes page title visible on the header */} -// -// -// -// Create T3 Turbo -// - -// - - ); -} diff --git a/apps/nextjs/src/app/(auth)/signin/oauth-signin.tsx b/apps/nextjs/src/app/(auth)/signin/oauth-signin.tsx deleted file mode 100644 index 0335b4e87..000000000 --- a/apps/nextjs/src/app/(auth)/signin/oauth-signin.tsx +++ /dev/null @@ -1,64 +0,0 @@ -"use client"; - -import * as React from "react"; -import { useSignIn } from "@clerk/nextjs"; -import type { OAuthStrategy } from "@clerk/types"; - -import { Button } from "@acme/ui/button"; -import * as Icons from "@acme/ui/icons"; -import { useToast } from "@acme/ui/use-toast"; - -export function OAuthSignIn() { - const [isLoading, setIsLoading] = React.useState(null); - const { signIn, isLoaded: signInLoaded } = useSignIn(); - const { toast } = useToast(); - - const oauthSignIn = async (provider: OAuthStrategy) => { - if (!signInLoaded) return null; - try { - setIsLoading(provider); - await signIn.authenticateWithRedirect({ - strategy: provider, - redirectUrl: "/sso-callback", - redirectUrlComplete: "/dashboard", - }); - } catch (cause) { - console.error(cause); - setIsLoading(null); - toast({ - variant: "destructive", - title: "Error", - description: "Something went wrong, please try again.", - }); - } - }; - - return ( -
- - -
- ); -} diff --git a/apps/nextjs/src/app/(auth)/signin/page.tsx b/apps/nextjs/src/app/(auth)/signin/page.tsx deleted file mode 100644 index 80647e276..000000000 --- a/apps/nextjs/src/app/(auth)/signin/page.tsx +++ /dev/null @@ -1,56 +0,0 @@ -import type { Route } from "next"; -import Link from "next/link"; - -import { EmailSignIn } from "./email-signin"; -import { OAuthSignIn } from "./oauth-signin"; - -export const runtime = "edge"; - -export default function AuthenticationPage() { - return ( -
-
-

- Create an account -

-

- Enter your email below to create your account -

-
-
- - -
-
- -
-
- - Or continue with - -
-
- - -
- -

- By clicking continue, you agree to our{" "} - - Terms of Service - {" "} - and{" "} - - Privacy Policy - - . -

-
- ); -} diff --git a/apps/nextjs/src/app/(auth)/signout/page.tsx b/apps/nextjs/src/app/(auth)/signout/page.tsx deleted file mode 100644 index df5f30c07..000000000 --- a/apps/nextjs/src/app/(auth)/signout/page.tsx +++ /dev/null @@ -1,26 +0,0 @@ -"use client"; - -import { useRouter } from "next/navigation"; -import { SignOutButton } from "@clerk/nextjs"; - -import { Button } from "@acme/ui/button"; - -export const runtime = "edge"; - -export default function AuthenticationPage() { - const router = useRouter(); - - return ( -
-
-

Sign Out

-

- Are you sure you want to sign out? -

- router.push("/?redirect=false")}> - - -
-
- ); -} diff --git a/apps/nextjs/src/app/(auth)/sso-callback/page.tsx b/apps/nextjs/src/app/(auth)/sso-callback/page.tsx deleted file mode 100644 index 5b4fce241..000000000 --- a/apps/nextjs/src/app/(auth)/sso-callback/page.tsx +++ /dev/null @@ -1,25 +0,0 @@ -"use client"; - -import { useEffect } from "react"; -import { useClerk } from "@clerk/nextjs"; -import type { HandleOAuthCallbackParams } from "@clerk/types"; - -import * as Icons from "@acme/ui/icons"; - -export const runtime = "edge"; - -export default function SSOCallback(props: { - searchParams: HandleOAuthCallbackParams; -}) { - const { handleRedirectCallback } = useClerk(); - - useEffect(() => { - void handleRedirectCallback(props.searchParams); - }, [props.searchParams, handleRedirectCallback]); - - return ( -
- -
- ); -} diff --git a/apps/nextjs/src/app/(dashboard)/[workspaceId]/[projectId]/_components/loading-card.tsx b/apps/nextjs/src/app/(dashboard)/[workspaceId]/[projectId]/_components/loading-card.tsx deleted file mode 100644 index 17ff5ef7a..000000000 --- a/apps/nextjs/src/app/(dashboard)/[workspaceId]/[projectId]/_components/loading-card.tsx +++ /dev/null @@ -1,26 +0,0 @@ -import { - Card, - CardContent, - CardDescription, - CardHeader, - CardTitle, -} from "@acme/ui/card"; -import * as Icons from "@acme/ui/icons"; - -export function LoadingCard(props: { - title: string; - description: string; - className?: string; -}) { - return ( - - - {props.title} - {props.description} - - - - - - ); -} diff --git a/apps/nextjs/src/app/(dashboard)/[workspaceId]/[projectId]/_components/overview.tsx b/apps/nextjs/src/app/(dashboard)/[workspaceId]/[projectId]/_components/overview.tsx deleted file mode 100644 index 0ac3fb299..000000000 --- a/apps/nextjs/src/app/(dashboard)/[workspaceId]/[projectId]/_components/overview.tsx +++ /dev/null @@ -1,78 +0,0 @@ -"use client"; - -import { Bar, BarChart, ResponsiveContainer, XAxis, YAxis } from "recharts"; - -const data = [ - { - name: "Jan", - total: Math.floor(Math.random() * 5000) + 1000, - }, - { - name: "Feb", - total: Math.floor(Math.random() * 5000) + 1000, - }, - { - name: "Mar", - total: Math.floor(Math.random() * 5000) + 1000, - }, - { - name: "Apr", - total: Math.floor(Math.random() * 5000) + 1000, - }, - { - name: "May", - total: Math.floor(Math.random() * 5000) + 1000, - }, - { - name: "Jun", - total: Math.floor(Math.random() * 5000) + 1000, - }, - { - name: "Jul", - total: Math.floor(Math.random() * 5000) + 1000, - }, - { - name: "Aug", - total: Math.floor(Math.random() * 5000) + 1000, - }, - { - name: "Sep", - total: Math.floor(Math.random() * 5000) + 1000, - }, - { - name: "Oct", - total: Math.floor(Math.random() * 5000) + 1000, - }, - { - name: "Nov", - total: Math.floor(Math.random() * 5000) + 1000, - }, - { - name: "Dec", - total: Math.floor(Math.random() * 5000) + 1000, - }, -]; - -export function Overview() { - return ( - - - - `$${value}`} - /> - - - - ); -} diff --git a/apps/nextjs/src/app/(dashboard)/[workspaceId]/[projectId]/api-keys/data-table.tsx b/apps/nextjs/src/app/(dashboard)/[workspaceId]/[projectId]/api-keys/data-table.tsx deleted file mode 100644 index c4150cadb..000000000 --- a/apps/nextjs/src/app/(dashboard)/[workspaceId]/[projectId]/api-keys/data-table.tsx +++ /dev/null @@ -1,357 +0,0 @@ -"use client"; - -import { useState } from "react"; -import { useRouter } from "next/navigation"; -import { - createColumnHelper, - flexRender, - getCoreRowModel, - useReactTable, -} from "@tanstack/react-table"; -import { format, formatRelative } from "date-fns"; -import { Eye, EyeOff } from "lucide-react"; - -import type { RouterOutputs } from "@acme/api"; -import { cn } from "@acme/ui"; -import { Button } from "@acme/ui/button"; -import { Checkbox } from "@acme/ui/checkbox"; -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuTrigger, -} from "@acme/ui/dropdown-menu"; -import * as Icons from "@acme/ui/icons"; -import { Label } from "@acme/ui/label"; -import { - Table, - TableBody, - TableCell, - TableHead, - TableHeader, - TableRow, -} from "@acme/ui/table"; -import { useToast } from "@acme/ui/use-toast"; - -import { api } from "~/trpc/client"; - -export type ApiKeyColumn = RouterOutputs["project"]["listApiKeys"][number]; - -const columnHelper = createColumnHelper(); - -const columns = [ - columnHelper.display({ - id: "select", - header: ({ table }) => ( - row.original.revokedAt !== null) - } - onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)} - aria-label="Select all" - /> - ), - cell: ({ row }) => ( - row.toggleSelected(!!value)} - aria-label="Select" - /> - ), - }), - columnHelper.accessor("key", { - cell: function Key(t) { - const [show, setShow] = useState(false); - const [copied, setCopied] = useState(false); - - const key = t.getValue(); - - const displayText = show ? key : "sk_live_****************"; - return ( -
- - {displayText} - -
- - -
-
- ); - }, - header: "Key", - }), - columnHelper.accessor("createdAt", { - cell: (t) => format(t.getValue(), "yyyy-MM-dd"), - header: "Created At", - }), - columnHelper.accessor("expiresAt", { - cell: (t) => { - if (t.row.original.revokedAt !== null) { - return ( -
- Revoked - {format(t.row.original.revokedAt, "yyyy-MM-dd")} -
- ); - } - - const value = t.getValue(); - if (value === null) { - return "Never expires"; - } - - if (value < new Date()) { - return ( -
- Expired - {format(value, "yyyy-MM-dd")} -
- ); - } - return format(value, "yyyy-MM-dd"); - }, - header: "Expires At", - }), - columnHelper.accessor("lastUsed", { - cell: (t) => { - const value = t.getValue(); - if (value === null) { - return "Never used"; - } - return formatRelative(value, new Date()); - }, - header: "Last Used At", - }), - columnHelper.display({ - id: "actions", - header: function ActionsHeader(t) { - const router = useRouter(); - const toaster = useToast(); - - const { rows } = t.table.getSelectedRowModel(); - const ids = rows.map((row) => row.original.id); - - return ( - - - - - - { - try { - const res = await api.project.revokeApiKeys.mutate({ ids }); - router.refresh(); - toaster.toast({ - title: `Revoked ${res.numRevoked} API keys`, - }); - t.table.toggleAllRowsSelected(false); - } catch { - toaster.toast({ - title: "Failed to revoke API Keys", - variant: "destructive", - }); - } - }} - className="text-destructive" - > - Revoke {ids.length} API key{ids.length > 1 ? "s" : ""} - - - - ); - }, - cell: function Actions(t) { - const apiKey = t.row.original; - const router = useRouter(); - const toaster = useToast(); - - return ( - - - - - - { - try { - await api.project.revokeApiKeys.mutate({ ids: [apiKey.id] }); - t.row.toggleSelected(false); - router.refresh(); - toaster.toast({ title: "API Key revoked" }); - } catch { - toaster.toast({ - title: "Failed to revoke API Key", - variant: "destructive", - }); - } - }} - className="text-destructive" - > - Revoke Key - - - { - try { - await api.project.rollApiKey.mutate({ id: apiKey.id }); - router.refresh(); - toaster.toast({ title: "API Key rolled" }); - } catch { - toaster.toast({ - title: "Failed to roll API Key", - variant: "destructive", - }); - } - }} - className="text-destructive" - > - Roll Key - - - - ); - }, - }), -]; - -export function DataTable(props: { data: ApiKeyColumn[] }) { - const [rowSelection, setRowSelection] = useState({}); - const [showRevoked, setShowRevoked] = useState(true); - - const table = useReactTable({ - data: props.data, - columns, - getCoreRowModel: getCoreRowModel(), - onRowSelectionChange: setRowSelection, - enableRowSelection: (row) => { - return row.original.revokedAt === null; - }, - state: { - rowSelection, - }, - }); - - const filteredRows = showRevoked - ? table.getRowModel().rows - : table - .getRowModel() - ?.rows.filter((row) => row.original.revokedAt === null); - - return ( -
-
- - setShowRevoked(!!c)} - className="max-w-sm" - /> -
-
- - - {table.getHeaderGroups().map((headerGroup) => ( - - {headerGroup.headers.map((header) => { - return ( - - {header.isPlaceholder - ? null - : flexRender( - header.column.columnDef.header, - header.getContext(), - )} - - ); - })} - - ))} - - - {filteredRows.length ? ( - filteredRows.map((row) => ( - { - if (row.original.revokedAt !== null) { - return true; - } - if (row.original.expiresAt !== null) { - return row.original.expiresAt < new Date(); - } - return false; - })()} - className={cn("group")} - > - {row.getVisibleCells().map((cell) => ( - - {flexRender( - cell.column.columnDef.cell, - cell.getContext(), - )} - - ))} - - )) - ) : ( - - - No results. - - - )} - -
-
-
- ); -} diff --git a/apps/nextjs/src/app/(dashboard)/[workspaceId]/[projectId]/api-keys/loading.tsx b/apps/nextjs/src/app/(dashboard)/[workspaceId]/[projectId]/api-keys/loading.tsx deleted file mode 100644 index b143fc96f..000000000 --- a/apps/nextjs/src/app/(dashboard)/[workspaceId]/[projectId]/api-keys/loading.tsx +++ /dev/null @@ -1,15 +0,0 @@ -import { DashboardShell } from "~/app/(dashboard)/_components/dashboard-shell"; -import { DataTable } from "./data-table"; -import { NewApiKeyDialog } from "./new-api-key-dialog"; - -export default function Loading() { - return ( - } - > - - - ); -} diff --git a/apps/nextjs/src/app/(dashboard)/[workspaceId]/[projectId]/api-keys/new-api-key-dialog.tsx b/apps/nextjs/src/app/(dashboard)/[workspaceId]/[projectId]/api-keys/new-api-key-dialog.tsx deleted file mode 100644 index 55c81edf5..000000000 --- a/apps/nextjs/src/app/(dashboard)/[workspaceId]/[projectId]/api-keys/new-api-key-dialog.tsx +++ /dev/null @@ -1,45 +0,0 @@ -"use client"; - -import * as React from "react"; -import { useRouter } from "next/navigation"; - -import { Button } from "@acme/ui/button"; -import { - Dialog, - DialogContent, - DialogDescription, - DialogHeader, - DialogTitle, - DialogTrigger, -} from "@acme/ui/dialog"; - -import { CreateApiKeyForm } from "../../_components/create-api-key-form"; - -export function NewApiKeyDialog(props: { projectId: string }) { - const router = useRouter(); - - const [dialogOpen, setDialogOpen] = React.useState(false); - - return ( - - - - - - - Create API Key - - Fill out the form to create an API key. - - - { - setDialogOpen(false); - router.refresh(); - }} - /> - - - ); -} diff --git a/apps/nextjs/src/app/(dashboard)/[workspaceId]/[projectId]/api-keys/page.tsx b/apps/nextjs/src/app/(dashboard)/[workspaceId]/[projectId]/api-keys/page.tsx deleted file mode 100644 index 093b51efa..000000000 --- a/apps/nextjs/src/app/(dashboard)/[workspaceId]/[projectId]/api-keys/page.tsx +++ /dev/null @@ -1,27 +0,0 @@ -import { DashboardShell } from "~/app/(dashboard)/_components/dashboard-shell"; -import { userCanAccess } from "~/lib/project-guard"; -import { api } from "~/trpc/server"; -import { DataTable } from "./data-table"; -import { NewApiKeyDialog } from "./new-api-key-dialog"; - -export const runtime = "edge"; - -export default async function ApiKeysPage(props: { - params: { projectId: string; workspaceId: string }; -}) { - await userCanAccess(props.params.projectId); - - const apiKeys = await api.project.listApiKeys.query({ - projectId: props.params.projectId, - }); - - return ( - } - > - - - ); -} diff --git a/apps/nextjs/src/app/(dashboard)/[workspaceId]/[projectId]/danger/delete-project.tsx b/apps/nextjs/src/app/(dashboard)/[workspaceId]/[projectId]/danger/delete-project.tsx deleted file mode 100644 index eaf4b7405..000000000 --- a/apps/nextjs/src/app/(dashboard)/[workspaceId]/[projectId]/danger/delete-project.tsx +++ /dev/null @@ -1,89 +0,0 @@ -"use client"; - -import { useParams, useRouter } from "next/navigation"; - -import { Button } from "@acme/ui/button"; -import { - Card, - CardDescription, - CardFooter, - CardHeader, - CardTitle, -} from "@acme/ui/card"; -import { - Dialog, - DialogClose, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle, - DialogTrigger, -} from "@acme/ui/dialog"; -import * as Icons from "@acme/ui/icons"; -import { useToast } from "@acme/ui/use-toast"; - -import { api } from "~/trpc/client"; - -export function DeleteProject() { - const { projectId } = useParams<{ projectId: string }>(); - const toaster = useToast(); - const router = useRouter(); - - const title = "Delete project"; - const description = "This will delete the project and all of its data."; - - return ( - - - {title} - - {description} - - - - - - - - - - {title} - {description} - -
- -

This action can not be reverted

-
- - - - - - -
-
-
-
- ); -} diff --git a/apps/nextjs/src/app/(dashboard)/[workspaceId]/[projectId]/danger/loading.tsx b/apps/nextjs/src/app/(dashboard)/[workspaceId]/[projectId]/danger/loading.tsx deleted file mode 100644 index d35ba8f57..000000000 --- a/apps/nextjs/src/app/(dashboard)/[workspaceId]/[projectId]/danger/loading.tsx +++ /dev/null @@ -1,62 +0,0 @@ -import { Button } from "@acme/ui/button"; -import { - Card, - CardDescription, - CardFooter, - CardHeader, - CardTitle, -} from "@acme/ui/card"; - -import { DashboardShell } from "~/app/(dashboard)/_components/dashboard-shell"; - -export default function Loading() { - return ( - - - - Transfer to Organization - - Transfer this project to an organization - - - - - - - - - - Transfer to Personal - - Transfer this project to your personal workspace - - - - - - - - - - Delete project - - This will delete the project and all of its data. - - - - - - - - ); -} diff --git a/apps/nextjs/src/app/(dashboard)/[workspaceId]/[projectId]/danger/page.tsx b/apps/nextjs/src/app/(dashboard)/[workspaceId]/[projectId]/danger/page.tsx deleted file mode 100644 index d88493d4d..000000000 --- a/apps/nextjs/src/app/(dashboard)/[workspaceId]/[projectId]/danger/page.tsx +++ /dev/null @@ -1,55 +0,0 @@ -import { Suspense } from "react"; - -import { Button } from "@acme/ui/button"; -import { - Card, - CardDescription, - CardFooter, - CardHeader, - CardTitle, -} from "@acme/ui/card"; - -import { DashboardShell } from "~/app/(dashboard)/_components/dashboard-shell"; -import { userCanAccess } from "~/lib/project-guard"; -import { api } from "~/trpc/server"; -import { DeleteProject } from "./delete-project"; -import { TransferProjectToOrganization } from "./transfer-to-organization"; -import { TransferProjectToPersonal } from "./transfer-to-personal"; - -export const runtime = "edge"; - -export default async function DangerZonePage(props: { - params: { projectId: string; workspaceId: string }; -}) { - await userCanAccess(props.params.projectId); - - return ( - - - - Transfer to Organization - - Transfer this project to an organization - - - - - - - } - > - - - - - - ); -} diff --git a/apps/nextjs/src/app/(dashboard)/[workspaceId]/[projectId]/danger/transfer-to-organization.tsx b/apps/nextjs/src/app/(dashboard)/[workspaceId]/[projectId]/danger/transfer-to-organization.tsx deleted file mode 100644 index e2d27d96e..000000000 --- a/apps/nextjs/src/app/(dashboard)/[workspaceId]/[projectId]/danger/transfer-to-organization.tsx +++ /dev/null @@ -1,153 +0,0 @@ -"use client"; - -import { use } from "react"; -import { useParams, useRouter } from "next/navigation"; - -import type { TransferToOrg } from "@acme/api/validators"; -import { transferToOrgSchema } from "@acme/api/validators"; -import { Button } from "@acme/ui/button"; -import { - Card, - CardDescription, - CardFooter, - CardHeader, - CardTitle, -} from "@acme/ui/card"; -import { - Dialog, - DialogClose, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle, - DialogTrigger, -} from "@acme/ui/dialog"; -import { - Form, - FormControl, - FormField, - FormItem, - FormLabel, - FormMessage, -} from "@acme/ui/form"; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "@acme/ui/select"; -import { useToast } from "@acme/ui/use-toast"; - -import { useZodForm } from "~/lib/zod-form"; -import type { RouterOutputs } from "~/trpc/client"; -import { api } from "~/trpc/client"; - -export function TransferProjectToOrganization(props: { - orgsPromise: Promise; -}) { - const { workspaceId, projectId } = useParams<{ - workspaceId: string; - projectId: string; - }>(); - const orgs = use(props.orgsPromise); - - const toaster = useToast(); - const router = useRouter(); - - const form = useZodForm({ - schema: transferToOrgSchema, - defaultValues: { - projectId, - }, - }); - - async function onSubmit(data: TransferToOrg) { - try { - if (!projectId) throw new Error("No project ID"); - - await api.project.transferToOrganization.mutate(data); - toaster.toast({ title: "Project transferred" }); - router.push(`/${data.orgId}/${projectId}`); - } catch { - toaster.toast({ - title: "Project could not be transferred", - variant: "destructive", - }); - } - } - - const title = "Transfer to Organization"; - const description = "Transfer this project to an organization"; - - return ( - - - {title} - - {description} - - - - - - - - -
- - - {title} - {description} - - - ( - - Organization - - - - )} - /> - - - - - - - - - -
-
-
-
- ); -} diff --git a/apps/nextjs/src/app/(dashboard)/[workspaceId]/[projectId]/danger/transfer-to-personal.tsx b/apps/nextjs/src/app/(dashboard)/[workspaceId]/[projectId]/danger/transfer-to-personal.tsx deleted file mode 100644 index 81c25ae2d..000000000 --- a/apps/nextjs/src/app/(dashboard)/[workspaceId]/[projectId]/danger/transfer-to-personal.tsx +++ /dev/null @@ -1,87 +0,0 @@ -"use client"; - -import { useParams, useRouter } from "next/navigation"; -import { useAuth } from "@clerk/nextjs"; - -import { Button } from "@acme/ui/button"; -import { - Card, - CardDescription, - CardFooter, - CardHeader, - CardTitle, -} from "@acme/ui/card"; -import { - Dialog, - DialogClose, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle, - DialogTrigger, -} from "@acme/ui/dialog"; -import { useToast } from "@acme/ui/use-toast"; - -import { api } from "~/trpc/client"; - -export function TransferProjectToPersonal() { - const { projectId } = useParams<{ projectId: string }>(); - const { userId } = useAuth(); - const toaster = useToast(); - const router = useRouter(); - - const title = "Transfer to Personal"; - const description = "Transfer this project to your personal workspace"; - - return ( - - - {title} - - {description} - - - - - - - - - - {title} - {description} - - - - - - - - - - - - - ); -} diff --git a/apps/nextjs/src/app/(dashboard)/[workspaceId]/[projectId]/error.tsx b/apps/nextjs/src/app/(dashboard)/[workspaceId]/[projectId]/error.tsx deleted file mode 100644 index efd83b68c..000000000 --- a/apps/nextjs/src/app/(dashboard)/[workspaceId]/[projectId]/error.tsx +++ /dev/null @@ -1,50 +0,0 @@ -"use client"; - -import * as React from "react"; -import { usePathname } from "next/navigation"; - -import { Button } from "@acme/ui/button"; - -import { DashboardShell } from "../../_components/dashboard-shell"; - -export default function Error(props: { error: Error; reset: () => void }) { - React.useEffect(() => { - // Log the error to an error reporting service - console.error(props.error); - }, [props.error]); - - // This should prob go in some config to make sure it's synced between loading.tsx, page.tsx and error.tsx etc - const pathname = usePathname(); - const path = pathname.split("/")[3]; - const { title, description } = (() => { - switch (path) { - case "ingestions": - return { - title: "Ingestions", - description: "Ingestion details", - }; - case "pulls": - return { - title: "Pull Request", - description: "Browse pull requests changes", - }; - default: - return { - title: "Overview", - description: "Get an overview of how the project is going", - }; - } - })(); - - return ( - -
-

Something went wrong!

-

- {`We're sorry, something went wrong. Please try again.`} -

- -
-
- ); -} diff --git a/apps/nextjs/src/app/(dashboard)/[workspaceId]/[projectId]/ingestions/[ingestionId]/page.tsx b/apps/nextjs/src/app/(dashboard)/[workspaceId]/[projectId]/ingestions/[ingestionId]/page.tsx deleted file mode 100644 index 7816dc34d..000000000 --- a/apps/nextjs/src/app/(dashboard)/[workspaceId]/[projectId]/ingestions/[ingestionId]/page.tsx +++ /dev/null @@ -1,61 +0,0 @@ -import { format } from "date-fns"; - -import { - Table, - TableBody, - TableCell, - TableHead, - TableHeader, - TableRow, -} from "@acme/ui/table"; - -import { DashboardShell } from "~/app/(dashboard)/_components/dashboard-shell"; -import { userCanAccess } from "~/lib/project-guard"; -import { api } from "~/trpc/server"; - -export const runtime = "edge"; - -export default async function IngestionPage(props: { - params: { workspaceId: string; projectId: string; ingestionId: string }; -}) { - await userCanAccess(props.params.projectId); - - const ingestion = await api.ingestion.byId.query({ - id: props.params.ingestionId, - }); - - return ( - - - - - Id - Created At - Commit - Origin - Parent - - - - - {ingestion.id} - - {format(ingestion.createdAt, "yyyy-MM-dd HH:mm:ss")} - - {ingestion.hash} - {ingestion.origin} - {ingestion.parent} - - -
-

Schema

-
-        {JSON.stringify(ingestion.schema, null, 4)}
-      
-
- ); -} diff --git a/apps/nextjs/src/app/(dashboard)/[workspaceId]/[projectId]/overview/loading.tsx b/apps/nextjs/src/app/(dashboard)/[workspaceId]/[projectId]/overview/loading.tsx deleted file mode 100644 index ad51ac4d2..000000000 --- a/apps/nextjs/src/app/(dashboard)/[workspaceId]/[projectId]/overview/loading.tsx +++ /dev/null @@ -1,13 +0,0 @@ -import { DashboardShell } from "../../../_components/dashboard-shell"; - -export default function Loading() { - return ( - -
-
- ); -} diff --git a/apps/nextjs/src/app/(dashboard)/[workspaceId]/[projectId]/overview/page.tsx b/apps/nextjs/src/app/(dashboard)/[workspaceId]/[projectId]/overview/page.tsx deleted file mode 100644 index 8c078c5dc..000000000 --- a/apps/nextjs/src/app/(dashboard)/[workspaceId]/[projectId]/overview/page.tsx +++ /dev/null @@ -1,200 +0,0 @@ -import { Suspense } from "react"; -import Link from "next/link"; -import { formatRelative } from "date-fns"; -import { Activity, CreditCard, DollarSign, Users } from "lucide-react"; - -import { cn } from "@acme/ui"; -import { Button } from "@acme/ui/button"; -import { - Card, - CardContent, - CardDescription, - CardFooter, - CardHeader, - CardTitle, -} from "@acme/ui/card"; -import * as Icons from "@acme/ui/icons"; - -import { Overview } from "~/app/(dashboard)/[workspaceId]/[projectId]/_components/overview"; -import { userCanAccess } from "~/lib/project-guard"; -import type { RouterOutputs } from "~/trpc/server"; -import { api } from "~/trpc/server"; -import { LoadingCard } from "../_components/loading-card"; -import { DashboardShell } from "../../../_components/dashboard-shell"; - -export const runtime = "edge"; - -export default async function DashboardPage(props: { - params: { workspaceId: string; projectId: string }; -}) { - const { projectId, workspaceId } = props.params; - await userCanAccess(projectId); - - return ( - -
- - - Total Revenue - - - -
$45,231.89
-

- +20.1% from last month -

-
-
- - - Subscriptions - - - -
+2350
-

- +180.1% from last month -

-
-
- - - Sales - - - -
+12,234
-

- +19% from last month -

-
-
- - - Active Now - - - -
+573
-

- +201 since last hour -

-
-
-
-
- - - Overview - - - - - - - - } - > - - -
-
- ); -} - -function IngestionCard(props: { - projectId: string; - workspaceId: string; - ingestion: RouterOutputs["ingestion"]["list"][number]; -}) { - const { ingestion } = props; - const { adds, subs } = ingestion; - - const N_SQUARES = 5; - const addSquares = Math.round((adds / (adds + subs)) * N_SQUARES); - - const truncatedHash = ingestion.hash.slice(0, 15); - - return ( - -
-
-

{truncatedHash}

-

- {formatRelative(ingestion.createdAt, new Date())} -

-
-
-
- +{adds} -{subs} -
-
- {new Array(N_SQUARES).fill(null).map((_, i) => ( - - ))} -
-
- - -
- - ); -} - -async function RecentIngestions(props: { - projectId: string; - workspaceId: string; -}) { - const ingestions = await api.ingestion.list.query({ - projectId: props.projectId, - limit: 5, - }); - - return ( - - - Recent Ingestions - - {ingestions.length} ingestion{ingestions.length > 1 ? "s" : null}{" "} - recorded this period. - - - - {ingestions.map((ingestion) => ( - - ))} - - - - - - ); -} diff --git a/apps/nextjs/src/app/(dashboard)/[workspaceId]/[projectId]/page.tsx b/apps/nextjs/src/app/(dashboard)/[workspaceId]/[projectId]/page.tsx deleted file mode 100644 index 81571b2e7..000000000 --- a/apps/nextjs/src/app/(dashboard)/[workspaceId]/[projectId]/page.tsx +++ /dev/null @@ -1,12 +0,0 @@ -import { redirect } from "next/navigation"; - -export const runtime = "edge"; - -/** - * Suboptimal, would be better off doing this in middleware - */ -export default function ProjectPage(props: { - params: { workspaceId: string; projectId: string }; -}) { - redirect(`/${props.params.workspaceId}/${props.params.projectId}/overview`); -} diff --git a/apps/nextjs/src/app/(dashboard)/[workspaceId]/[projectId]/settings/_components/rename-project.tsx b/apps/nextjs/src/app/(dashboard)/[workspaceId]/[projectId]/settings/_components/rename-project.tsx deleted file mode 100644 index 1de52448a..000000000 --- a/apps/nextjs/src/app/(dashboard)/[workspaceId]/[projectId]/settings/_components/rename-project.tsx +++ /dev/null @@ -1,87 +0,0 @@ -"use client"; - -import * as React from "react"; - -import type { RenameProject } from "@acme/api/validators"; -import { renameProjectSchema } from "@acme/api/validators"; -import { Button } from "@acme/ui/button"; -import { - Card, - CardContent, - CardDescription, - CardFooter, - CardHeader, - CardTitle, -} from "@acme/ui/card"; -import { - Form, - FormControl, - FormField, - FormItem, - FormLabel, - FormMessage, -} from "@acme/ui/form"; -import { Input } from "@acme/ui/input"; -import { useToast } from "@acme/ui/use-toast"; - -import { useZodForm } from "~/lib/zod-form"; -import { api } from "~/trpc/client"; - -export function RenameProject(props: { - currentName: string; - projectId: string; -}) { - const { toast } = useToast(); - - const form = useZodForm({ - schema: renameProjectSchema, - defaultValues: { - projectId: props.projectId, - name: props.currentName, - }, - }); - - async function onSubmit(data: RenameProject) { - await api.project.rename.mutate(data); - toast({ - title: "Project name updated", - description: "Your project's name has been updated.", - }); - } - - return ( - - - Project name - - Change the display name of your project - - - -
- - - ( - - Name - - - - - - )} - /> - - - - -
- -
- ); -} diff --git a/apps/nextjs/src/app/(dashboard)/[workspaceId]/[projectId]/settings/loading.tsx b/apps/nextjs/src/app/(dashboard)/[workspaceId]/[projectId]/settings/loading.tsx deleted file mode 100644 index b341ac681..000000000 --- a/apps/nextjs/src/app/(dashboard)/[workspaceId]/[projectId]/settings/loading.tsx +++ /dev/null @@ -1,14 +0,0 @@ -import { DashboardShell } from "~/app/(dashboard)/_components/dashboard-shell"; -import { RenameProject } from "./_components/rename-project"; - -export default function Loading() { - return ( - - - - ); -} diff --git a/apps/nextjs/src/app/(dashboard)/[workspaceId]/[projectId]/settings/page.tsx b/apps/nextjs/src/app/(dashboard)/[workspaceId]/[projectId]/settings/page.tsx deleted file mode 100644 index c181691a6..000000000 --- a/apps/nextjs/src/app/(dashboard)/[workspaceId]/[projectId]/settings/page.tsx +++ /dev/null @@ -1,22 +0,0 @@ -import { DashboardShell } from "~/app/(dashboard)/_components/dashboard-shell"; -import { api } from "~/trpc/server"; -import { RenameProject } from "./_components/rename-project"; - -export const runtime = "edge"; - -export default async function ProjectSettingsPage(props: { - params: { workspaceId: string; projectId: string }; -}) { - const { projectId } = props.params; - const project = await api.project.byId.query({ id: projectId }); - - return ( - - - - ); -} diff --git a/apps/nextjs/src/app/(dashboard)/[workspaceId]/_components/create-api-key-form.tsx b/apps/nextjs/src/app/(dashboard)/[workspaceId]/_components/create-api-key-form.tsx deleted file mode 100644 index e78e65764..000000000 --- a/apps/nextjs/src/app/(dashboard)/[workspaceId]/_components/create-api-key-form.tsx +++ /dev/null @@ -1,141 +0,0 @@ -"use client"; - -import React from "react"; -import { add, format } from "date-fns"; -import { Calendar as CalendarIcon } from "lucide-react"; - -import type { CreateApiKey } from "@acme/api/validators"; -import { createApiKeySchema } from "@acme/api/validators"; -import { Button } from "@acme/ui/button"; -import { Calendar } from "@acme/ui/calendar"; -import { - Form, - FormControl, - FormDescription, - FormField, - FormItem, - FormLabel, - FormMessage, -} from "@acme/ui/form"; -import { Input } from "@acme/ui/input"; -import { Popover, PopoverContent, PopoverTrigger } from "@acme/ui/popover"; -import { useToast } from "@acme/ui/use-toast"; - -import { useZodForm } from "~/lib/zod-form"; -import { api } from "~/trpc/client"; - -export function CreateApiKeyForm(props: { - projectId: string; - onSuccess?: (key: string) => void; -}) { - const toaster = useToast(); - - const [datePickerOpen, setDatePickerOpen] = React.useState(false); - - const form = useZodForm({ - schema: createApiKeySchema, - defaultValues: { projectId: props.projectId }, - }); - - async function onSubmit(data: CreateApiKey) { - try { - const apiKey = await api.project.createApiKey.mutate(data); - form.reset(); - props.onSuccess?.(apiKey); - toaster.toast({ - title: "API Key Created", - description: `Project ${data.name} created successfully.`, - }); - } catch (error) { - toaster.toast({ - title: "Error creating API Key", - variant: "destructive", - description: - "An issue occurred while creating your key. Please try again.", - }); - } - } - - return ( -
- - ( - - Name * - - - - - Enter a unique name for your token to differentiate it from - other tokens. - - - - )} - /> - - ( - - Exiration date - - - - - - - - { - field.onChange(date); - setDatePickerOpen(false); - }} - disabled={(date) => - // future dates up to 1 year only - date < new Date() || date > add(new Date(), { years: 1 }) - } - initialFocus - /> - - - - We strongly recommend you setting an expiration date for - your API key, but you can also leave it blank to create a - permanent key. - - - - )} - /> - - - - - ); -} diff --git a/apps/nextjs/src/app/(dashboard)/[workspaceId]/_components/create-project-form.tsx b/apps/nextjs/src/app/(dashboard)/[workspaceId]/_components/create-project-form.tsx deleted file mode 100644 index 4043c2547..000000000 --- a/apps/nextjs/src/app/(dashboard)/[workspaceId]/_components/create-project-form.tsx +++ /dev/null @@ -1,97 +0,0 @@ -"use client"; - -import { useRouter } from "next/navigation"; - -import type { CreateProject } from "@acme/api/validators"; -import { createProjectSchema } from "@acme/api/validators"; -import { Button } from "@acme/ui/button"; -import { - Form, - FormControl, - FormDescription, - FormField, - FormItem, - FormLabel, - FormMessage, -} from "@acme/ui/form"; -import { Input } from "@acme/ui/input"; -import { useToast } from "@acme/ui/use-toast"; - -import { useZodForm } from "~/lib/zod-form"; -import { api } from "~/trpc/client"; - -export const CreateProjectForm = (props: { - workspaceId: string; - // defaults to redirecting to the project page - onSuccess?: (project: CreateProject & { id: string }) => void; -}) => { - const router = useRouter(); - const toaster = useToast(); - - const form = useZodForm({ schema: createProjectSchema }); - - async function onSubmit(data: CreateProject) { - try { - const projectId = await api.project.create.mutate(data); - if (props.onSuccess) { - props.onSuccess({ - ...data, - id: projectId, - }); - } else { - router.push(`/${props.workspaceId}/${projectId}/overview`); - } - toaster.toast({ - title: "Project created", - description: `Project ${data.name} created successfully.`, - }); - } catch (error) { - toaster.toast({ - title: "Error creating project", - variant: "destructive", - description: - "An issue occurred while creating your project. Please try again.", - }); - } - } - - return ( -
- - ( - - Name * - - - - - A name to identify your app in the dashboard. - - - - )} - /> - - ( - - URL - - - - The URL of your app - - - )} - /> - - - - - ); -}; diff --git a/apps/nextjs/src/app/(dashboard)/[workspaceId]/_components/project-card.tsx b/apps/nextjs/src/app/(dashboard)/[workspaceId]/_components/project-card.tsx deleted file mode 100644 index 09ab5a05e..000000000 --- a/apps/nextjs/src/app/(dashboard)/[workspaceId]/_components/project-card.tsx +++ /dev/null @@ -1,65 +0,0 @@ -import Link from "next/link"; - -import type { RouterOutputs } from "@acme/api"; -import { ProjectTier } from "@acme/db"; -import { cn } from "@acme/ui"; -import { Card, CardDescription, CardHeader, CardTitle } from "@acme/ui/card"; - -import { getRandomPatternStyle } from "~/lib/generate-pattern"; - -function ProjectTierIndicator(props: { tier: ProjectTier }) { - return ( - - {props.tier} - - ); -} - -export function ProjectCard(props: { - workspaceId: string; - project: RouterOutputs["project"]["listByActiveWorkspace"]["projects"][number]; -}) { - const { project } = props; - return ( - - -
- - - {project.name} - - - {project.url}  - - - - ); -} - -ProjectCard.Skeleton = function ProjectCardSkeleton(props: { - pulse?: boolean; -}) { - const { pulse = true } = props; - return ( - -
- - - -   - - - - -   - - - - ); -}; diff --git a/apps/nextjs/src/app/(dashboard)/[workspaceId]/_components/sidebar.tsx b/apps/nextjs/src/app/(dashboard)/[workspaceId]/_components/sidebar.tsx deleted file mode 100644 index 23d48e092..000000000 --- a/apps/nextjs/src/app/(dashboard)/[workspaceId]/_components/sidebar.tsx +++ /dev/null @@ -1,107 +0,0 @@ -"use client"; - -import Link from "next/link"; -import { useParams, usePathname } from "next/navigation"; - -import { cn } from "@acme/ui"; -import * as Icons from "@acme/ui/icons"; - -const workspaceItems = [ - { - title: "Projects", - href: "/", - icon: Icons.Post, - }, - { - title: "Billing", - href: "/billing", - icon: Icons.Billing, - }, - { - title: "Danger Zone", - href: "/danger", - icon: Icons.Warning, - }, - { - title: "Settings", - href: "/settings", - icon: Icons.Settings, - }, -] as const; - -const projectItems = [ - { - title: "Dashboard", - href: "/", - icon: Icons.Dashboard, - }, - { - title: "API Keys", - href: "/api-keys", - icon: Icons.Key, - }, - { - title: "Danger Zone", - href: "/danger", - icon: Icons.Warning, - }, - { - title: "Settings", - href: "/settings", - icon: Icons.Settings, - }, -] as const; - -export function SidebarNav() { - const params = useParams<{ - workspaceId: string; - projectId?: string; - }>(); - const path = usePathname(); - - // remove the workspaceId and projectId from the path when comparing active links in sidebar - const pathname = - path - .replace(`/${params.workspaceId}`, "") - .replace(`/${params.projectId}`, "") || "/"; - - const items = params.projectId ? projectItems : workspaceItems; - if (!items?.length) { - return null; - } - - return ( - - ); -} diff --git a/apps/nextjs/src/app/(dashboard)/[workspaceId]/billing/loading.tsx b/apps/nextjs/src/app/(dashboard)/[workspaceId]/billing/loading.tsx deleted file mode 100644 index f5160ee49..000000000 --- a/apps/nextjs/src/app/(dashboard)/[workspaceId]/billing/loading.tsx +++ /dev/null @@ -1,29 +0,0 @@ -import { Card, CardContent, CardHeader, CardTitle } from "@acme/ui/card"; - -import { DashboardShell } from "../../_components/dashboard-shell"; - -export default function Loading() { - return ( - - - - - ); -} - -function LoadingCard(props: { title: string }) { - return ( - - - {props.title} - - -
- - - ); -} diff --git a/apps/nextjs/src/app/(dashboard)/[workspaceId]/billing/page.tsx b/apps/nextjs/src/app/(dashboard)/[workspaceId]/billing/page.tsx deleted file mode 100644 index 5f0e6f56d..000000000 --- a/apps/nextjs/src/app/(dashboard)/[workspaceId]/billing/page.tsx +++ /dev/null @@ -1,64 +0,0 @@ -import { - Card, - CardContent, - CardFooter, - CardHeader, - CardTitle, -} from "@acme/ui/card"; - -import { api } from "~/trpc/server"; -import { DashboardShell } from "../../_components/dashboard-shell"; -import { SubscriptionForm } from "./subscription-form"; - -export const runtime = "edge"; - -export default function BillingPage() { - return ( - - - - - - ); -} - -async function SubscriptionCard() { - const subscription = await api.auth.mySubscription.query(); - - return ( - - - Subscription - - - {subscription ? ( -

- You are currently on the {subscription.plan} plan. - Your subscription will renew on{" "} - {subscription.endsAt?.toLocaleDateString()}. -

- ) : ( -

You are not subscribed to any plan.

- )} -
- - - -
- ); -} - -function UsageCard() { - return ( - - - Usage - - TODO - - ); -} diff --git a/apps/nextjs/src/app/(dashboard)/[workspaceId]/billing/subscription-form.tsx b/apps/nextjs/src/app/(dashboard)/[workspaceId]/billing/subscription-form.tsx deleted file mode 100644 index 119cbf937..000000000 --- a/apps/nextjs/src/app/(dashboard)/[workspaceId]/billing/subscription-form.tsx +++ /dev/null @@ -1,18 +0,0 @@ -"use client"; - -import { Button } from "@acme/ui/button"; - -import { api } from "~/trpc/client"; - -export function SubscriptionForm(props: { hasSubscription: boolean }) { - async function createSession() { - const { url } = await api.stripe.createSession.mutate({ planId: "" }); - if (url) window.location.href = url; - } - - return ( - - ); -} diff --git a/apps/nextjs/src/app/(dashboard)/[workspaceId]/danger/delete-workspace.tsx b/apps/nextjs/src/app/(dashboard)/[workspaceId]/danger/delete-workspace.tsx deleted file mode 100644 index 6de11b7e0..000000000 --- a/apps/nextjs/src/app/(dashboard)/[workspaceId]/danger/delete-workspace.tsx +++ /dev/null @@ -1,91 +0,0 @@ -"use client"; - -import { useRouter } from "next/navigation"; -import { useAuth } from "@clerk/nextjs"; - -import { Button } from "@acme/ui/button"; -import { - Card, - CardDescription, - CardFooter, - CardHeader, - CardTitle, -} from "@acme/ui/card"; -import { - Dialog, - DialogClose, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle, - DialogTrigger, -} from "@acme/ui/dialog"; -import * as Icons from "@acme/ui/icons"; -import { useToast } from "@acme/ui/use-toast"; - -import { api } from "~/trpc/client"; - -export function DeleteWorkspace() { - const toaster = useToast(); - const router = useRouter(); - const { orgId } = useAuth(); - - const title = "Delete workspace"; - const description = "This will delete the workspace and all of its data."; - - return ( - - - {title} - - {description} - - - - - - - - {!orgId && ( - - You can not delete your personal workspace - - )} - - - {title} - {description} - -
- -

This action can not be reverted

-
- - - - - - -
-
-
-
- ); -} diff --git a/apps/nextjs/src/app/(dashboard)/[workspaceId]/danger/loading.tsx b/apps/nextjs/src/app/(dashboard)/[workspaceId]/danger/loading.tsx deleted file mode 100644 index b74669eaf..000000000 --- a/apps/nextjs/src/app/(dashboard)/[workspaceId]/danger/loading.tsx +++ /dev/null @@ -1,34 +0,0 @@ -import { Button } from "@acme/ui/button"; -import { - Card, - CardDescription, - CardFooter, - CardHeader, - CardTitle, -} from "@acme/ui/card"; - -import { DashboardShell } from "../../_components/dashboard-shell"; - -export default function Loading() { - return ( - - - - Delete workspace - - This will delete the workspace and all of its data. - - - - - - - - ); -} diff --git a/apps/nextjs/src/app/(dashboard)/[workspaceId]/danger/page.tsx b/apps/nextjs/src/app/(dashboard)/[workspaceId]/danger/page.tsx deleted file mode 100644 index 8747451bc..000000000 --- a/apps/nextjs/src/app/(dashboard)/[workspaceId]/danger/page.tsx +++ /dev/null @@ -1,16 +0,0 @@ -import { DashboardShell } from "../../_components/dashboard-shell"; -import { DeleteWorkspace } from "./delete-workspace"; - -export const runtime = "edge"; - -export default function DangerZonePage() { - return ( - - - - ); -} diff --git a/apps/nextjs/src/app/(dashboard)/[workspaceId]/layout.tsx b/apps/nextjs/src/app/(dashboard)/[workspaceId]/layout.tsx deleted file mode 100644 index 8b3ebc3a3..000000000 --- a/apps/nextjs/src/app/(dashboard)/[workspaceId]/layout.tsx +++ /dev/null @@ -1,22 +0,0 @@ -import { SidebarNav } from "./_components/sidebar"; -import { SyncActiveOrgFromUrl } from "./sync-active-org-from-url"; - -export default function WorkspaceLayout(props: { - children: React.ReactNode; - params: { workspaceId: string }; -}) { - return ( - <> - {/* TODO: Nuke it when we can do it serverside in Clerk! */} - -
- -
- {props.children} -
-
- - ); -} diff --git a/apps/nextjs/src/app/(dashboard)/[workspaceId]/loading.tsx b/apps/nextjs/src/app/(dashboard)/[workspaceId]/loading.tsx deleted file mode 100644 index 23b1f9f38..000000000 --- a/apps/nextjs/src/app/(dashboard)/[workspaceId]/loading.tsx +++ /dev/null @@ -1,20 +0,0 @@ -import { Button } from "@acme/ui/button"; - -import { DashboardShell } from "../_components/dashboard-shell"; -import { ProjectCard } from "./_components/project-card"; - -export default function Loading() { - return ( - Create a new project} - > -
    - - - -
-
- ); -} diff --git a/apps/nextjs/src/app/(dashboard)/[workspaceId]/page.tsx b/apps/nextjs/src/app/(dashboard)/[workspaceId]/page.tsx deleted file mode 100644 index d069b012b..000000000 --- a/apps/nextjs/src/app/(dashboard)/[workspaceId]/page.tsx +++ /dev/null @@ -1,62 +0,0 @@ -import Link from "next/link"; -import { Balancer } from "react-wrap-balancer"; - -import { Button } from "@acme/ui/button"; - -import { api } from "~/trpc/server"; -import { DashboardShell } from "../_components/dashboard-shell"; -import { ProjectCard } from "./_components/project-card"; - -export const runtime = "edge"; - -export default async function Page(props: { params: { workspaceId: string } }) { - const { projects, limitReached } = - await api.project.listByActiveWorkspace.query(); - - return ( - Project limit reached - ) : ( - - ) - } - > -
    - {projects.map((project) => ( -
  • - -
  • - ))} -
- - {projects.length === 0 && ( -
-
    - - - -
-
- -

- This workspace has no projects yet -

-

- Create your first project to get started -

-
-
-
- )} -
- ); -} diff --git a/apps/nextjs/src/app/(dashboard)/[workspaceId]/settings/_components/invite-member-dialog.tsx b/apps/nextjs/src/app/(dashboard)/[workspaceId]/settings/_components/invite-member-dialog.tsx deleted file mode 100644 index 34f3728ef..000000000 --- a/apps/nextjs/src/app/(dashboard)/[workspaceId]/settings/_components/invite-member-dialog.tsx +++ /dev/null @@ -1,108 +0,0 @@ -"use client"; - -import type { InviteOrgMember } from "@acme/api/validators"; -import { inviteOrgMemberSchema, MEMBERSHIP } from "@acme/api/validators"; -import { Button } from "@acme/ui/button"; -import { - Form, - FormControl, - FormDescription, - FormField, - FormItem, - FormLabel, - FormMessage, -} from "@acme/ui/form"; -import { Input } from "@acme/ui/input"; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "@acme/ui/select"; -import { useToast } from "@acme/ui/use-toast"; - -import { useZodForm } from "~/lib/zod-form"; -import { api } from "~/trpc/client"; - -export const InviteMemberForm = () => { - const toaster = useToast(); - - const form = useZodForm({ - schema: inviteOrgMemberSchema, - }); - - async function onSubmit(data: InviteOrgMember) { - try { - const member = await api.organization.inviteMember.mutate(data); - toaster.toast({ - title: "Member invited", - description: `An invitation to ${member.name} has been sent.`, - }); - } catch (error) { - toaster.toast({ - title: "Invitation failed", - variant: "destructive", - description: `An issue occured while inviting ${data.email}. Make sure they have an account, and try again.`, - }); - } - } - - return ( -
- - ( - - Email * - - - - - The email address of the person you want to invite. They must - have an account on this app. - - - - )} - /> - - ( - - Role * - - - - )} - /> - - - - - ); -}; diff --git a/apps/nextjs/src/app/(dashboard)/[workspaceId]/settings/_components/organization-image.tsx b/apps/nextjs/src/app/(dashboard)/[workspaceId]/settings/_components/organization-image.tsx deleted file mode 100644 index 1cb4ed312..000000000 --- a/apps/nextjs/src/app/(dashboard)/[workspaceId]/settings/_components/organization-image.tsx +++ /dev/null @@ -1,191 +0,0 @@ -"use client"; - -import * as React from "react"; -import { useRouter } from "next/navigation"; -import { useOrganization } from "@clerk/nextjs"; -import type { Crop, PixelCrop } from "react-image-crop"; -import ReactCrop from "react-image-crop"; - -import { Avatar, AvatarFallback, AvatarImage } from "@acme/ui/avatar"; -import { Button } from "@acme/ui/button"; -import { - Card, - CardContent, - CardDescription, - CardFooter, - CardHeader, - CardTitle, -} from "@acme/ui/card"; -import { - Dialog, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle, -} from "@acme/ui/dialog"; -import { Input } from "@acme/ui/input"; -import { useToast } from "@acme/ui/use-toast"; - -export function OrganizationImage(props: { - name: string; - image: string; - orgId: string; -}) { - const [imgSrc, setImgSrc] = React.useState(""); - const [cropModalOpen, setCropModalOpen] = React.useState(false); - - return ( - - - Organization Image - - Change your organization's avatar image - - - - - - - {props.name.substring(0, 2)} - - - - - - { - const file = e.target.files?.[0]; - if (!file) return; - - setCropModalOpen(true); - - const reader = new FileReader(); - reader.addEventListener("load", () => { - setImgSrc(reader.result?.toString() ?? ""); - }); - reader.readAsDataURL(file); - }} - /> - setCropModalOpen(false)} - /> - - - - ); -} - -function CropImageDialog(props: { imgSrc: string; close: () => void }) { - const [crop, setCrop] = React.useState(); - const [storedCrop, setStoredCrop] = React.useState(); - const imageRef = React.useRef(null); - - const [isUploading, setIsUploading] = React.useState(false); - const { organization } = useOrganization(); - const { toast } = useToast(); - const router = useRouter(); - - async function saveImage() { - if (!imageRef.current || !storedCrop) return; - setIsUploading(true); - const canvas = cropImage(imageRef.current, storedCrop); - - const blob = await new Promise((res, rej) => { - canvas.toBlob((blob) => { - blob ? res(blob) : rej("No blob"); - }); - }); - - await organization?.setLogo({ file: blob }); - toast({ - title: "Image updated", - description: "Your organization image has been updated.", - }); - - setIsUploading(false); - router.refresh(); - props.close(); - } - - return ( - - - Edit Image - - Select the area of the image you would like to use - - - - setCrop(percent)} - onComplete={(c) => setStoredCrop(c)} - > - {props.imgSrc && ( - // eslint-disable-next-line @next/next/no-img-element - Crop me - )} - - - - - - - ); -} - -function cropImage(image: HTMLImageElement, crop: PixelCrop) { - const canvas = document.createElement("canvas"); - const ctx = canvas.getContext("2d"); - if (!ctx) throw new Error("No 2d context"); - - const scaleX = image.naturalWidth / image.width; - const scaleY = image.naturalHeight / image.height; - const pixelRatio = window.devicePixelRatio; - - canvas.width = Math.floor(crop.width * scaleX * pixelRatio); - canvas.height = Math.floor(crop.height * scaleY * pixelRatio); - - ctx.scale(pixelRatio, pixelRatio); - ctx.imageSmoothingQuality = "high"; - - const cropX = crop.x * scaleX; - const cropY = crop.y * scaleY; - - const centerX = image.naturalWidth / 2; - const centerY = image.naturalHeight / 2; - - ctx.save(); - - ctx.translate(-cropX, -cropY); - ctx.translate(centerX, centerY); - ctx.translate(-centerX, -centerY); - ctx.drawImage( - image, - 0, - 0, - image.naturalWidth, - image.naturalHeight, - 0, - 0, - image.naturalWidth, - image.naturalHeight, - ); - - ctx.restore(); - - return canvas; -} diff --git a/apps/nextjs/src/app/(dashboard)/[workspaceId]/settings/_components/organization-members.tsx b/apps/nextjs/src/app/(dashboard)/[workspaceId]/settings/_components/organization-members.tsx deleted file mode 100644 index a9686a3c0..000000000 --- a/apps/nextjs/src/app/(dashboard)/[workspaceId]/settings/_components/organization-members.tsx +++ /dev/null @@ -1,116 +0,0 @@ -"use client"; - -import { use } from "react"; -import { useRouter } from "next/navigation"; -import { useAuth } from "@clerk/nextjs"; -import { formatRelative } from "date-fns"; - -import type { RouterOutputs } from "@acme/api"; -import { MEMBERSHIP } from "@acme/api/validators"; -import { Avatar, AvatarFallback, AvatarImage } from "@acme/ui/avatar"; -import { Button } from "@acme/ui/button"; -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuTrigger, -} from "@acme/ui/dropdown-menu"; -import * as Icons from "@acme/ui/icons"; -import { - Table, - TableBody, - TableCell, - TableHead, - TableHeader, - TableRow, -} from "@acme/ui/table"; -import { useToast } from "@acme/ui/use-toast"; - -import { api } from "~/trpc/client"; - -function formatMemberRole(role: string) { - for (const [key, value] of Object.entries(MEMBERSHIP)) { - if (value === role) { - return key; - } - } - return role; -} - -export function OrganizationMembers(props: { - membersPromise: Promise; -}) { - const members = use(props.membersPromise); - const toaster = useToast(); - const router = useRouter(); - - const { orgRole } = useAuth(); - - // TODO: DataTable with actions - return ( - - - - Name - Joined at - Role - - - - - {members.map((member) => ( - - - - - {member.name[0]} - -
- {member.name} - - {member.email} - -
-
- {formatRelative(member.joinedAt, new Date())} - {formatMemberRole(member.role)} - - - - - - - { - try { - const res = await api.organization.deleteMember.mutate({ - userId: member.id, - }); - router.refresh(); - toaster.toast({ - title: `Deleted ${res.memberName} from the organization`, - }); - } catch { - toaster.toast({ - title: "Failed to delete member", - variant: "destructive", - }); - } - }} - className="text-destructive" - > - Delete member - - - - -
- ))} -
-
- ); -} diff --git a/apps/nextjs/src/app/(dashboard)/[workspaceId]/settings/_components/organization-name.tsx b/apps/nextjs/src/app/(dashboard)/[workspaceId]/settings/_components/organization-name.tsx deleted file mode 100644 index 26c61247a..000000000 --- a/apps/nextjs/src/app/(dashboard)/[workspaceId]/settings/_components/organization-name.tsx +++ /dev/null @@ -1,63 +0,0 @@ -"use client"; - -import * as React from "react"; -import { useOrganization } from "@clerk/nextjs"; - -import { Button } from "@acme/ui/button"; -import { - Card, - CardContent, - CardDescription, - CardFooter, - CardHeader, - CardTitle, -} from "@acme/ui/card"; -import { Input } from "@acme/ui/input"; -import { Label } from "@acme/ui/label"; -import { useToast } from "@acme/ui/use-toast"; - -export function OrganizationName(props: { name: string; orgId: string }) { - const { organization } = useOrganization(); - const [updating, setUpdating] = React.useState(false); - const { toast } = useToast(); - - return ( - - - Organization Name - Change the name of your organization - - -
{ - e.preventDefault(); - const name = new FormData(e.currentTarget).get("name"); - if (!name || typeof name !== "string") return; - setUpdating(true); - await organization?.update({ name, slug: props.orgId }); - setUpdating(false); - toast({ - title: "Organization name updated", - description: "Your organization name has been updated.", - }); - }} - > - - - - - - - -
-
- ); -} diff --git a/apps/nextjs/src/app/(dashboard)/[workspaceId]/settings/page.tsx b/apps/nextjs/src/app/(dashboard)/[workspaceId]/settings/page.tsx deleted file mode 100644 index c647b213a..000000000 --- a/apps/nextjs/src/app/(dashboard)/[workspaceId]/settings/page.tsx +++ /dev/null @@ -1,121 +0,0 @@ -import { Suspense } from "react"; -import { notFound } from "next/navigation"; -import { auth, clerkClient, UserProfile } from "@clerk/nextjs"; - -import { Button } from "@acme/ui/button"; -import { Dialog, DialogContent, DialogTrigger } from "@acme/ui/dialog"; -import { Tabs, TabsContent, TabsList, TabsTrigger } from "@acme/ui/tabs"; - -import { api } from "~/trpc/server"; -import { DashboardShell } from "../../_components/dashboard-shell"; -import { LoadingCard } from "../[projectId]/_components/loading-card"; -import { InviteMemberForm } from "./_components/invite-member-dialog"; -import { OrganizationImage } from "./_components/organization-image"; -import { OrganizationMembers } from "./_components/organization-members"; -import { OrganizationName } from "./_components/organization-name"; - -export const runtime = "edge"; - -export default function WorkspaceSettingsPage(props: { - params: { workspaceId: string }; -}) { - const { workspaceId } = props.params; - const isOrg = workspaceId.startsWith("org_"); - - if (isOrg) - return ( - - - - General - Members - - - - - - - - } - > - - - ); - - return ; -} - -async function OrganizationSettingsPage() { - const { orgId } = auth(); - if (!orgId) notFound(); - - const org = await clerkClient.organizations.getOrganization({ - organizationId: orgId, - }); - - await new Promise((resolve) => setTimeout(resolve, 1000)); - - return ( - - {/* TODO: Use URL instead of clientside tabs */} - - - General - Members - - - - - - - - - - - - - - - - }> - - - - - - ); -} - -function UserSettingsPage() { - return ( - - - - ); -} diff --git a/apps/nextjs/src/app/(dashboard)/[workspaceId]/sync-active-org-from-url.tsx b/apps/nextjs/src/app/(dashboard)/[workspaceId]/sync-active-org-from-url.tsx deleted file mode 100644 index b1e6fc956..000000000 --- a/apps/nextjs/src/app/(dashboard)/[workspaceId]/sync-active-org-from-url.tsx +++ /dev/null @@ -1,41 +0,0 @@ -"use client"; - -import * as React from "react"; -import { useParams } from "next/navigation"; -import { useOrganizationList } from "@clerk/nextjs"; - -/** - * I couldn't find a way to do this on the server :thinking: Clerk is adding support for this soon. - * If I go to /[workspaceId]/**, I want to set the active organization to the workspaceId, - * If it's a personal worksapce, set the organization to null, else find the organization by id - * and set it to that. - */ -export function SyncActiveOrgFromUrl() { - const { workspaceId } = useParams<{ workspaceId: string }>(); - const { setActive, userMemberships, isLoaded } = useOrganizationList({ - userMemberships: { - infinite: true, - }, - }); - - React.useEffect(() => { - if (!isLoaded || userMemberships.isLoading) return; - - if (!workspaceId?.startsWith("org_")) { - void setActive({ organization: null }); - return; - } - - const org = userMemberships?.data?.find( - ({ organization }) => organization.id === workspaceId, - ); - - if (org) { - void setActive(org); - } - - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [workspaceId, isLoaded]); - - return null; -} diff --git a/apps/nextjs/src/app/(dashboard)/_components/breadcrumbs.tsx b/apps/nextjs/src/app/(dashboard)/_components/breadcrumbs.tsx deleted file mode 100644 index 9284b3a75..000000000 --- a/apps/nextjs/src/app/(dashboard)/_components/breadcrumbs.tsx +++ /dev/null @@ -1,41 +0,0 @@ -"use client"; - -import Link from "next/link"; -import { usePathname } from "next/navigation"; - -import { cn } from "@acme/ui"; - -const items = { - overview: "Overview", - analytics: "Analytics", - reports: "Reports", - notifications: "Notifications", -}; - -export function Breadcrumbs() { - const pathname = usePathname(); - const [_, workspaceId, projectId, ...rest] = pathname.split("/"); - const baseUrl = `/${workspaceId}/${projectId}`; - const restAsString = rest.join("/"); - - return ( -
- {Object.entries(items).map(([key, value]) => { - const isActive = - key === restAsString || (key !== "" && restAsString.startsWith(key)); - return ( - - {value} - - ); - })} -
- ); -} diff --git a/apps/nextjs/src/app/(dashboard)/_components/dashboard-shell.tsx b/apps/nextjs/src/app/(dashboard)/_components/dashboard-shell.tsx deleted file mode 100644 index 9a8b80318..000000000 --- a/apps/nextjs/src/app/(dashboard)/_components/dashboard-shell.tsx +++ /dev/null @@ -1,34 +0,0 @@ -import React from "react"; - -import { Breadcrumbs } from "./breadcrumbs"; - -export function DashboardShell(props: { - title: string; - description: React.ReactNode; - breadcrumb?: boolean; - headerAction?: React.ReactNode; - children: React.ReactNode; - className?: string; -}) { - return ( -
-
-
-

- {props.title} -

- {typeof props.description === "string" ? ( -

- {props.description} -

- ) : ( - props.description - )} -
- {props.headerAction} -
- {props.breadcrumb && } -
{props.children}
-
- ); -} diff --git a/apps/nextjs/src/app/(dashboard)/_components/date-range-picker.tsx b/apps/nextjs/src/app/(dashboard)/_components/date-range-picker.tsx deleted file mode 100644 index e122642b2..000000000 --- a/apps/nextjs/src/app/(dashboard)/_components/date-range-picker.tsx +++ /dev/null @@ -1,65 +0,0 @@ -"use client"; - -import * as React from "react"; -import { addDays, format } from "date-fns"; -import { Calendar as CalendarIcon } from "lucide-react"; - -import { cn } from "@acme/ui"; -import { Button } from "@acme/ui/button"; -import { Calendar } from "@acme/ui/calendar"; -import type { DateRange } from "@acme/ui/calendar"; -import { Popover, PopoverContent, PopoverTrigger } from "@acme/ui/popover"; - -export function CalendarDateRangePicker({ - className, - align = "end", -}: React.HTMLAttributes & { - align?: "center" | "end" | "start"; -}) { - const [date, setDate] = React.useState({ - from: new Date(2023, 0, 20), - to: addDays(new Date(2023, 0, 20), 20), - }); - - return ( -
- - - - - - - - -
- ); -} diff --git a/apps/nextjs/src/app/(dashboard)/_components/main-nav.tsx b/apps/nextjs/src/app/(dashboard)/_components/main-nav.tsx deleted file mode 100644 index 688e38241..000000000 --- a/apps/nextjs/src/app/(dashboard)/_components/main-nav.tsx +++ /dev/null @@ -1,35 +0,0 @@ -import Link from "next/link"; - -import { cn } from "@acme/ui"; - -import { navItems } from "~/app/config"; - -// TODO: idx not needed as key when all items have unique hrefs -// also, the active link should be filtered by href and not idx -export function MainNav({ - className, - ...props -}: React.HTMLAttributes) { - return ( - - ); -} diff --git a/apps/nextjs/src/app/(dashboard)/_components/project-switcher.tsx b/apps/nextjs/src/app/(dashboard)/_components/project-switcher.tsx deleted file mode 100644 index f3730253b..000000000 --- a/apps/nextjs/src/app/(dashboard)/_components/project-switcher.tsx +++ /dev/null @@ -1,126 +0,0 @@ -"use client"; - -import * as React from "react"; -import { useParams, useRouter } from "next/navigation"; -import { Check, ChevronsUpDown, LayoutGrid } from "lucide-react"; - -import type { RouterOutputs } from "@acme/api"; -import { cn } from "@acme/ui"; -import { Button } from "@acme/ui/button"; -import { - Command, - CommandGroup, - CommandInput, - CommandItem, - CommandList, - CommandSeparator, -} from "@acme/ui/command"; -import { Popover, PopoverContent, PopoverTrigger } from "@acme/ui/popover"; - -import { getRandomPatternStyle } from "~/lib/generate-pattern"; - -export function ProjectSwitcher(props: { - projectsPromise: Promise; -}) { - const router = useRouter(); - - const { projects } = React.use(props.projectsPromise); - - const [switcherOpen, setSwitcherOpen] = React.useState(false); - - const { workspaceId, projectId } = useParams<{ - workspaceId: string; - projectId: string; - }>(); - const activeProject = projects.find((p) => p.id === projectId); - - if (!projectId) return null; - if (!activeProject) { - return ( - - ); - } - - return ( - <> - / - - - - - - - - - - - {projects.map((project) => ( - { - setSwitcherOpen(false); - router.push(`/${workspaceId}/${project.id}`); - }} - className="text-sm font-semibold" - > -
- {project.name} - - - ))} - - - - - { - router.push(`/${workspaceId}`); - setSwitcherOpen(false); - }} - className="cursor-pointer" - > - - Browse projects - - - - - - - - ); -} diff --git a/apps/nextjs/src/app/(dashboard)/_components/search.tsx b/apps/nextjs/src/app/(dashboard)/_components/search.tsx deleted file mode 100644 index 348b1d541..000000000 --- a/apps/nextjs/src/app/(dashboard)/_components/search.tsx +++ /dev/null @@ -1,13 +0,0 @@ -import { Input } from "@acme/ui/input"; - -export function Search() { - return ( -
- -
- ); -} diff --git a/apps/nextjs/src/app/(dashboard)/_components/workspace-switcher.tsx b/apps/nextjs/src/app/(dashboard)/_components/workspace-switcher.tsx deleted file mode 100644 index 3e7eab0bf..000000000 --- a/apps/nextjs/src/app/(dashboard)/_components/workspace-switcher.tsx +++ /dev/null @@ -1,325 +0,0 @@ -"use client"; - -import * as React from "react"; -import Link from "next/link"; -import { useRouter } from "next/navigation"; -import { useOrganization, useOrganizationList, useUser } from "@clerk/nextjs"; -import { toDecimal } from "dinero.js"; -import { Check, ChevronsUpDown, PlusCircle } from "lucide-react"; - -import type { PurchaseOrg } from "@acme/api/validators"; -import { purchaseOrgSchema } from "@acme/api/validators"; -import { cn } from "@acme/ui"; -import { Avatar, AvatarFallback, AvatarImage } from "@acme/ui/avatar"; -import { Button } from "@acme/ui/button"; -import { - Command, - CommandGroup, - CommandInput, - CommandItem, - CommandList, - CommandSeparator, -} from "@acme/ui/command"; -import { - Dialog, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle, - DialogTrigger, -} from "@acme/ui/dialog"; -import { - Form, - FormControl, - FormField, - FormItem, - FormLabel, - FormMessage, -} from "@acme/ui/form"; -import { Input } from "@acme/ui/input"; -import { Popover, PopoverContent, PopoverTrigger } from "@acme/ui/popover"; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "@acme/ui/select"; -import { useToast } from "@acme/ui/use-toast"; - -import { currencySymbol } from "~/lib/currency"; -import { useZodForm } from "~/lib/zod-form"; -import { api } from "~/trpc/client"; - -export function WorkspaceSwitcher() { - const router = useRouter(); - - const [switcherOpen, setSwitcherOpen] = React.useState(false); - const [newOrgDialogOpen, setNewOrgDialogOpen] = React.useState(false); - - const orgs = useOrganizationList({ - userMemberships: { - infinite: true, - }, - }); - const org = useOrganization(); - - const { user, isSignedIn, isLoaded } = useUser(); - if (isLoaded && !isSignedIn) throw new Error("How did you get here???"); - - const activeOrg = org.organization ?? user; - if ( - !orgs.isLoaded || - !org.isLoaded || - !activeOrg || - orgs.userMemberships.isLoading - ) { - // Skeleton loader - return ( - - ); - } - - const normalizedObject = { - id: activeOrg.id, - name: "name" in activeOrg ? activeOrg.name : activeOrg.fullName, - image: activeOrg.imageUrl, - }; - - return ( - - - - - - - - - - - { - if (!user?.id) return; - normalizedObject.id = user.id ?? ""; - - await orgs.setActive?.({ organization: null }); - setSwitcherOpen(false); - router.push(`/${user.id}`); - }} - className="cursor-pointer text-sm" - > - - - - {`${user?.firstName?.[0]}${user?.lastName?.[0]}` ?? "JD"} - - - {user?.fullName} - - - - - - {orgs.userMemberships.data?.map(({ organization: org }) => ( - { - await orgs.setActive({ organization: org }); - setSwitcherOpen(false); - router.push(`/${org.id}`); - }} - className="cursor-pointer text-sm" - > - - - - {org.name.substring(0, 2)} - - - {org.name} - - - ))} - - - - - - - { - setSwitcherOpen(false); - setNewOrgDialogOpen(true); - }} - className="cursor-pointer" - > - - Create Organization - - - - - - - - - - setNewOrgDialogOpen(false)} /> - - - ); -} - -function NewOrganizationDialog(props: { closeDialog: () => void }) { - const plans = React.use(api.stripe.plans.query()); - - const form = useZodForm({ schema: purchaseOrgSchema }); - - const toaster = useToast(); - - async function handleCreateOrg(data: PurchaseOrg) { - const response = await api.stripe.purchaseOrg - .mutate(data) - .catch(() => ({ success: false as const })); - - if (response.success) window.location.href = response.url; - else - toaster.toast({ - title: "Error", - description: - "There was an error setting up your organization. Please try again.", - variant: "destructive", - }); - } - - return ( - -
- - - Create organization - - Add a new organization to manage products and customers. - - - - ( - - Organization name * - - - - - - )} - /> - - ( - -
- Subscription plan * - - What's included in each plan? - -
- - -
- )} - /> - - - - - - - -
- ); -} diff --git a/apps/nextjs/src/app/(dashboard)/layout.tsx b/apps/nextjs/src/app/(dashboard)/layout.tsx deleted file mode 100644 index e91af47ce..000000000 --- a/apps/nextjs/src/app/(dashboard)/layout.tsx +++ /dev/null @@ -1,42 +0,0 @@ -import { Suspense } from "react"; -import Link from "next/link"; - -import * as Icons from "@acme/ui/icons"; - -import { SiteFooter } from "~/components/footer"; -import { UserNav } from "~/components/user-nav"; -import { api } from "~/trpc/server"; -import { ProjectSwitcher } from "./_components/project-switcher"; -import { Search } from "./_components/search"; -import { WorkspaceSwitcher } from "./_components/workspace-switcher"; - -export default function DashboardLayout(props: { children: React.ReactNode }) { - return ( -
- -
- {props.children} -
- -
- ); -} diff --git a/apps/nextjs/src/app/(dashboard)/onboarding/create-api-key.tsx b/apps/nextjs/src/app/(dashboard)/onboarding/create-api-key.tsx deleted file mode 100644 index 3067bfb34..000000000 --- a/apps/nextjs/src/app/(dashboard)/onboarding/create-api-key.tsx +++ /dev/null @@ -1,74 +0,0 @@ -import { useEffect } from "react"; -import { useRouter, useSearchParams } from "next/navigation"; -import { motion } from "framer-motion"; -import { Balancer } from "react-wrap-balancer"; - -import { CreateApiKeyForm } from "../[workspaceId]/_components/create-api-key-form"; - -export function CreateApiKey() { - const router = useRouter(); - const projectId = useSearchParams().get("projectId"); - - useEffect(() => { - if (!projectId) { - router.push(`/onboarding`); - } - }, [projectId, router]); - - return ( - - - - - {`Next, let's create an API key for your project`} - - - - { - const searchParams = new URLSearchParams(window.location.search); - searchParams.set("step", "done"); - searchParams.set("apiKey", apiKey); - router.push(`/onboarding?${searchParams.toString()}`); - }} - /> - - - - ); -} diff --git a/apps/nextjs/src/app/(dashboard)/onboarding/create-project.tsx b/apps/nextjs/src/app/(dashboard)/onboarding/create-project.tsx deleted file mode 100644 index 6d5d386df..000000000 --- a/apps/nextjs/src/app/(dashboard)/onboarding/create-project.tsx +++ /dev/null @@ -1,68 +0,0 @@ -"use client"; - -import { useRouter } from "next/navigation"; -import { motion } from "framer-motion"; -import { Balancer } from "react-wrap-balancer"; - -import { CreateProjectForm } from "../[workspaceId]/_components/create-project-form"; - -export function CreateProject(props: { workspaceId: string }) { - const router = useRouter(); - - return ( - - - - - {`Let's start off by creating your first project`} - - - - { - const searchParams = new URLSearchParams(window.location.search); - searchParams.set("step", "create-api-key"); - searchParams.set("projectId", id); - router.push(`/onboarding?${searchParams.toString()}`); - }} - /> - - - - ); -} diff --git a/apps/nextjs/src/app/(dashboard)/onboarding/done.tsx b/apps/nextjs/src/app/(dashboard)/onboarding/done.tsx deleted file mode 100644 index b6138a913..000000000 --- a/apps/nextjs/src/app/(dashboard)/onboarding/done.tsx +++ /dev/null @@ -1,60 +0,0 @@ -import { useEffect, useTransition } from "react"; -import Link from "next/link"; -import { useRouter, useSearchParams } from "next/navigation"; -import { motion } from "framer-motion"; - -export function Done(props: { workspaceId: string }) { - const router = useRouter(); - const search = useSearchParams(); - const step = search.get("step"); - const projectId = search.get("projectId"); - const apiKey = search.get("apiKey"); - - const [_, startTransition] = useTransition(); - useEffect(() => { - if (step === "done") { - setTimeout(() => { - startTransition(() => { - router.push(`${props.workspaceId}/${projectId}/overview`); - router.refresh(); - }); - }, 2000); - } - }, [projectId, props.workspaceId, router, step, apiKey]); - - return ( - - -

- You are all set! -

-

- Congratulations, you have successfully created your first project. - Check out the docs to learn more on how to - use the platform. -

-

- You will be redirected to your project momentarily. -

-
-
- ); -} diff --git a/apps/nextjs/src/app/(dashboard)/onboarding/intro.tsx b/apps/nextjs/src/app/(dashboard)/onboarding/intro.tsx deleted file mode 100644 index cff630f7c..000000000 --- a/apps/nextjs/src/app/(dashboard)/onboarding/intro.tsx +++ /dev/null @@ -1,83 +0,0 @@ -"use client"; - -import { useRouter } from "next/navigation"; -import { motion } from "framer-motion"; -import { Balancer } from "react-wrap-balancer"; - -import { Button } from "@acme/ui/button"; - -import { useDebounce } from "~/lib/use-debounce"; - -export default function Intro() { - const router = useRouter(); - - const showText = useDebounce(true, 800); - - return ( - - {showText && ( - - - Welcome to Acme Corp - - - Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do - eiusmod tempor incididunt. - - - - - - )} - - ); -} diff --git a/apps/nextjs/src/app/(dashboard)/onboarding/multi-step-form.tsx b/apps/nextjs/src/app/(dashboard)/onboarding/multi-step-form.tsx deleted file mode 100644 index 21509a260..000000000 --- a/apps/nextjs/src/app/(dashboard)/onboarding/multi-step-form.tsx +++ /dev/null @@ -1,27 +0,0 @@ -"use client"; - -import { useSearchParams } from "next/navigation"; -import { AnimatePresence } from "framer-motion"; - -import { CreateApiKey } from "./create-api-key"; -import { CreateProject } from "./create-project"; -import { Done } from "./done"; -import Intro from "./intro"; - -export function Onboarding(props: { workspaceId: string }) { - const search = useSearchParams(); - const step = search.get("step"); - - return ( -
- - {!step && } - {step === "create-project" && ( - - )} - {step === "create-api-key" && } - {step === "done" && } - -
- ); -} diff --git a/apps/nextjs/src/app/(dashboard)/onboarding/page.tsx b/apps/nextjs/src/app/(dashboard)/onboarding/page.tsx deleted file mode 100644 index edc9fb422..000000000 --- a/apps/nextjs/src/app/(dashboard)/onboarding/page.tsx +++ /dev/null @@ -1,17 +0,0 @@ -import { auth } from "@clerk/nextjs"; - -import { Onboarding } from "./multi-step-form"; - -export const runtime = "edge"; - -export default function OnboardingPage() { - const { orgId, userId } = auth(); - - return ( - <> - - -
- - ); -} diff --git a/apps/nextjs/src/app/(marketing)/layout.tsx b/apps/nextjs/src/app/(marketing)/layout.tsx deleted file mode 100644 index 702fca750..000000000 --- a/apps/nextjs/src/app/(marketing)/layout.tsx +++ /dev/null @@ -1,60 +0,0 @@ -import { Suspense } from "react"; -import type { ReactNode } from "react"; -import Link from "next/link"; -import { auth } from "@clerk/nextjs"; - -import { buttonVariants } from "@acme/ui/button"; -import * as Icons from "@acme/ui/icons"; - -import { siteConfig } from "~/app/config"; -import { SiteFooter } from "~/components/footer"; -import { MobileDropdown } from "~/components/mobile-nav"; -import { MainNav } from "../(dashboard)/_components/main-nav"; - -export default function MarketingLayout(props: { children: ReactNode }) { - return ( -
- - -
{props.children}
- -
- ); -} - -function DashboardLink() { - const { userId, orgId } = auth(); - - if (!userId) { - return ( - - Sign In - - - ); - } - - return ( - - Dashboard - - - ); -} diff --git a/apps/nextjs/src/app/(marketing)/page.tsx b/apps/nextjs/src/app/(marketing)/page.tsx deleted file mode 100644 index dc36a9c0e..000000000 --- a/apps/nextjs/src/app/(marketing)/page.tsx +++ /dev/null @@ -1,93 +0,0 @@ -import { Balancer } from "react-wrap-balancer"; - -import { cn } from "@acme/ui"; -import { buttonVariants } from "@acme/ui/button"; -import { - Card, - CardContent, - CardDescription, - CardHeader, - CardTitle, -} from "@acme/ui/card"; -import * as Icons from "@acme/ui/icons"; - -import { marketingFeatures, siteConfig } from "~/app/config"; - -export const runtime = "edge"; - -export default function Home() { - return ( -
-
- {/* - -

- Introducing Acme Corp -

-
*/} -

- Your all-in-one, enterprise ready starting point -

-

- - Acme Corp is a Next.js starter kit that includes everything you need - to build a modern web application. Mobile application preconfigured, - ready to go. - -

- -
-
-

- What's included? -

- -

- - This repo comes fully stacked with everything you need for your - enterprise startup. Stop worrying about boilerplate integrations and - start building your product today! - -

- -
- {marketingFeatures.map((feature) => ( - - {feature.icon} - - {feature.title} - - {feature.body} - - - - ))} -
-
-
- ); -} diff --git a/apps/nextjs/src/app/(marketing)/pricing/page.tsx b/apps/nextjs/src/app/(marketing)/pricing/page.tsx deleted file mode 100644 index 623d90a7e..000000000 --- a/apps/nextjs/src/app/(marketing)/pricing/page.tsx +++ /dev/null @@ -1,76 +0,0 @@ -import { toDecimal } from "dinero.js"; -import { CheckCircle2 } from "lucide-react"; -import { Balancer } from "react-wrap-balancer"; - -import { - Card, - CardDescription, - CardFooter, - CardHeader, - CardTitle, -} from "@acme/ui/card"; - -import { currencySymbol } from "~/lib/currency"; -import type { RouterOutputs } from "~/trpc/server"; -import { api } from "~/trpc/server"; -import { SubscribeNow } from "./subscribe-now"; - -// FIXME: Run this in Edge runtime - currently got some weird transforming error with Dinero.js + Superjson -// export const runtime = "edge"; - -export default async function PricingPage() { - const plans = await api.stripe.plans.query(); - - return ( -
-
-

Pricing

- - Simple pricing for all your needs. No hidden fees, no surprises. - - -
- {plans.map((plan) => ( - - ))} -
-
-
- ); -} - -function PricingCard(props: { - plan: RouterOutputs["stripe"]["plans"][number]; -}) { - return ( - - - {props.plan.name} -
- {toDecimal( - props.plan.price, - ({ value, currency }) => `${currencySymbol(currency.code)}${value}`, - )} - / month -
{" "} - {props.plan.description} -
- -
    - {props.plan.preFeatures && ( -
  • {props.plan.preFeatures}
  • - )} - {props.plan.features.map((feature) => ( -
  • - - {feature} -
  • - ))} -
- - - - -
- ); -} diff --git a/apps/nextjs/src/app/(marketing)/pricing/subscribe-now.tsx b/apps/nextjs/src/app/(marketing)/pricing/subscribe-now.tsx deleted file mode 100644 index 809a0da67..000000000 --- a/apps/nextjs/src/app/(marketing)/pricing/subscribe-now.tsx +++ /dev/null @@ -1,28 +0,0 @@ -"use client"; - -import { useRouter } from "next/navigation"; -import { useSession } from "@clerk/nextjs"; - -import { Button } from "@acme/ui/button"; - -import { api } from "~/trpc/client"; - -export function SubscribeNow(props: { planId: string }) { - const router = useRouter(); - const session = useSession(); - - return ( - - ); -} diff --git a/apps/nextjs/src/app/(marketing)/privacy/page.mdx b/apps/nextjs/src/app/(marketing)/privacy/page.mdx deleted file mode 100644 index 1a92bfbef..000000000 --- a/apps/nextjs/src/app/(marketing)/privacy/page.mdx +++ /dev/null @@ -1,17 +0,0 @@ -export const runtime = "edge"; - -
- -# Privacy - -Lorem ipsum dolor sit amet, consectetur adipiscing elit. Donec bibendum pretium congue. Vivamus hendrerit, sem id fringilla accumsan, ex ex suscipit tellus, vitae volutpat enim velit vel erat. Quisque scelerisque maximus rutrum. Curabitur non suscipit augue, sit amet lobortis libero. Sed semper justo sit amet congue semper. Cras imperdiet ultricies faucibus. Sed placerat, urna ut vehicula aliquet, libero urna imperdiet odio, in condimentum felis arcu sit amet nibh. Curabitur eu lectus sit amet est iaculis posuere at a orci. In gravida nunc vel orci pharetra, id vulputate ligula placerat. Praesent fringilla massa ac urna tempus, eu venenatis turpis aliquam. Pellentesque nec dapibus velit. Aenean sagittis nisi purus, vitae vehicula sapien mattis sit amet. Etiam commodo sit amet quam eleifend commodo. Nunc blandit commodo urna at porta. - -Sed sed turpis justo. Aenean congue nisi ligula, id vulputate nibh ullamcorper ac. Maecenas molestie cursus blandit. In dolor erat, venenatis rutrum porta nec, placerat ut velit. Vestibulum ante ipsum primis in faucibus orci luctus et ultrices posuere cubilia curae; Fusce placerat, purus sit amet accumsan condimentum, lorem tortor semper felis, vel maximus risus nibh vel nisi. Nam elit diam, sagittis id egestas non, volutpat vel arcu. Ut congue at urna vitae vehicula. Curabitur nec maximus magna. - -Ut sed elementum nisi. Morbi vitae faucibus nisi. Nulla magna purus, blandit sed congue et, posuere ut est. Ut scelerisque risus est, ac tincidunt risus dignissim et. Praesent in elementum sapien. Proin tortor augue, tempus vel sapien non, commodo laoreet ante. Integer id semper turpis. Sed a lobortis orci, sed vulputate odio. Suspendisse eget eros a nibh dictum euismod. Sed congue, dolor vel finibus malesuada, risus massa accumsan nisi, in tincidunt ante nibh et velit. - -Praesent scelerisque lorem quis erat tempus rutrum vitae at enim. Nam vestibulum diam nec euismod sagittis. Mauris ac metus congue, aliquam nibh sit amet, accumsan ex. Nulla vitae efficitur felis, quis hendrerit lacus. Donec id arcu fermentum, commodo enim in, accumsan nisl. Fusce euismod faucibus velit, ac vulputate ex faucibus et. Donec imperdiet egestas ornare. Aenean sit amet varius erat, vel ullamcorper dui. Curabitur nisl mauris, egestas eget ipsum eget, semper aliquet justo. Donec a commodo nunc. Duis ac ullamcorper arcu, tristique molestie enim. Donec tincidunt gravida eros, ut viverra nunc tristique ut. Vestibulum ante ipsum primis in faucibus orci luctus et ultrices posuere cubilia curae; - -Morbi rutrum libero vel suscipit dapibus. Suspendisse pellentesque et mauris vel mattis. Nunc non suscipit est. Nullam id enim accumsan, commodo magna vel, elementum lectus. Integer ut orci dolor. Interdum et malesuada fames ac ante ipsum primis in faucibus. Etiam rutrum dignissim metus, in eleifend sapien dictum at. Praesent ac libero purus. Cras vel tortor in lectus placerat porttitor in in massa. Vestibulum dui turpis, cursus vitae posuere eget, accumsan ac nisi. Duis suscipit tortor augue, at iaculis metus consequat eget. Duis id sapien arcu. - -
diff --git a/apps/nextjs/src/app/(marketing)/terms/page.mdx b/apps/nextjs/src/app/(marketing)/terms/page.mdx deleted file mode 100644 index 4fb7ac4eb..000000000 --- a/apps/nextjs/src/app/(marketing)/terms/page.mdx +++ /dev/null @@ -1,17 +0,0 @@ -export const runtime = "edge"; - -
- -# Terms - -Lorem ipsum dolor sit amet, consectetur adipiscing elit. Donec bibendum pretium congue. Vivamus hendrerit, sem id fringilla accumsan, ex ex suscipit tellus, vitae volutpat enim velit vel erat. Quisque scelerisque maximus rutrum. Curabitur non suscipit augue, sit amet lobortis libero. Sed semper justo sit amet congue semper. Cras imperdiet ultricies faucibus. Sed placerat, urna ut vehicula aliquet, libero urna imperdiet odio, in condimentum felis arcu sit amet nibh. Curabitur eu lectus sit amet est iaculis posuere at a orci. In gravida nunc vel orci pharetra, id vulputate ligula placerat. Praesent fringilla massa ac urna tempus, eu venenatis turpis aliquam. Pellentesque nec dapibus velit. Aenean sagittis nisi purus, vitae vehicula sapien mattis sit amet. Etiam commodo sit amet quam eleifend commodo. Nunc blandit commodo urna at porta. - -Sed sed turpis justo. Aenean congue nisi ligula, id vulputate nibh ullamcorper ac. Maecenas molestie cursus blandit. In dolor erat, venenatis rutrum porta nec, placerat ut velit. Vestibulum ante ipsum primis in faucibus orci luctus et ultrices posuere cubilia curae; Fusce placerat, purus sit amet accumsan condimentum, lorem tortor semper felis, vel maximus risus nibh vel nisi. Nam elit diam, sagittis id egestas non, volutpat vel arcu. Ut congue at urna vitae vehicula. Curabitur nec maximus magna. - -Ut sed elementum nisi. Morbi vitae faucibus nisi. Nulla magna purus, blandit sed congue et, posuere ut est. Ut scelerisque risus est, ac tincidunt risus dignissim et. Praesent in elementum sapien. Proin tortor augue, tempus vel sapien non, commodo laoreet ante. Integer id semper turpis. Sed a lobortis orci, sed vulputate odio. Suspendisse eget eros a nibh dictum euismod. Sed congue, dolor vel finibus malesuada, risus massa accumsan nisi, in tincidunt ante nibh et velit. - -Praesent scelerisque lorem quis erat tempus rutrum vitae at enim. Nam vestibulum diam nec euismod sagittis. Mauris ac metus congue, aliquam nibh sit amet, accumsan ex. Nulla vitae efficitur felis, quis hendrerit lacus. Donec id arcu fermentum, commodo enim in, accumsan nisl. Fusce euismod faucibus velit, ac vulputate ex faucibus et. Donec imperdiet egestas ornare. Aenean sit amet varius erat, vel ullamcorper dui. Curabitur nisl mauris, egestas eget ipsum eget, semper aliquet justo. Donec a commodo nunc. Duis ac ullamcorper arcu, tristique molestie enim. Donec tincidunt gravida eros, ut viverra nunc tristique ut. Vestibulum ante ipsum primis in faucibus orci luctus et ultrices posuere cubilia curae; - -Morbi rutrum libero vel suscipit dapibus. Suspendisse pellentesque et mauris vel mattis. Nunc non suscipit est. Nullam id enim accumsan, commodo magna vel, elementum lectus. Integer ut orci dolor. Interdum et malesuada fames ac ante ipsum primis in faucibus. Etiam rutrum dignissim metus, in eleifend sapien dictum at. Praesent ac libero purus. Cras vel tortor in lectus placerat porttitor in in massa. Vestibulum dui turpis, cursus vitae posuere eget, accumsan ac nisi. Duis suscipit tortor augue, at iaculis metus consequat eget. Duis id sapien arcu. - -
diff --git a/apps/nextjs/src/app/api/trpc/edge/[trpc]/route.ts b/apps/nextjs/src/app/api/trpc/edge/[trpc]/route.ts deleted file mode 100644 index 84ab5c6a3..000000000 --- a/apps/nextjs/src/app/api/trpc/edge/[trpc]/route.ts +++ /dev/null @@ -1,30 +0,0 @@ -import type { NextRequest } from "next/server"; -import { getAuth } from "@clerk/nextjs/server"; -import { fetchRequestHandler } from "@trpc/server/adapters/fetch"; - -import { createTRPCContext } from "@acme/api"; -import { edgeRouter } from "@acme/api/edge"; - -export const runtime = "edge"; - -const createContext = async (req: NextRequest) => { - return createTRPCContext({ - headers: req.headers, - auth: getAuth(req), - req, - }); -}; - -const handler = (req: NextRequest) => - fetchRequestHandler({ - endpoint: "/api/trpc/edge", - router: edgeRouter, - req: req, - createContext: () => createContext(req), - onError: ({ error, path }) => { - console.log("Error in tRPC handler (edge) on path", path); - console.error(error); - }, - }); - -export { handler as GET, handler as POST }; diff --git a/apps/nextjs/src/app/api/trpc/lambda/[trpc]/route.ts b/apps/nextjs/src/app/api/trpc/lambda/[trpc]/route.ts deleted file mode 100644 index d171e2f9c..000000000 --- a/apps/nextjs/src/app/api/trpc/lambda/[trpc]/route.ts +++ /dev/null @@ -1,31 +0,0 @@ -import type { NextRequest } from "next/server"; -import { getAuth } from "@clerk/nextjs/server"; -import { fetchRequestHandler } from "@trpc/server/adapters/fetch"; - -import { createTRPCContext } from "@acme/api"; -import { lambdaRouter } from "@acme/api/lambda"; - -// Stripe is incompatible with Edge runtimes due to using Node.js events -// export const runtime = "edge"; - -const createContext = async (req: NextRequest) => { - return createTRPCContext({ - headers: req.headers, - auth: getAuth(req), - req, - }); -}; - -const handler = (req: NextRequest) => - fetchRequestHandler({ - endpoint: "/api/trpc/lambda", - router: lambdaRouter, - req: req, - createContext: () => createContext(req), - onError: ({ error, path }) => { - console.log("Error in tRPC handler (lambda) on path", path); - console.error(error); - }, - }); - -export { handler as GET, handler as POST }; diff --git a/apps/nextjs/src/app/api/webhooks/stripe/route.ts b/apps/nextjs/src/app/api/webhooks/stripe/route.ts deleted file mode 100644 index 84f86d131..000000000 --- a/apps/nextjs/src/app/api/webhooks/stripe/route.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { NextResponse } from "next/server"; -import type { NextRequest } from "next/server"; - -import { handleEvent, stripe } from "@acme/stripe"; - -import { env } from "~/env.mjs"; - -export async function POST(req: NextRequest) { - const payload = await req.text(); - const signature = req.headers.get("Stripe-Signature")!; - - try { - const event = stripe.webhooks.constructEvent( - payload, - signature, - env.STRIPE_WEBHOOK_SECRET, - ); - - await handleEvent(event); - - console.log("✅ Handled Stripe Event", event.type); - return NextResponse.json({ received: true }, { status: 200 }); - } catch (error) { - const message = error instanceof Error ? error.message : "Unknown error"; - console.log(`❌ Error when handling Stripe Event: ${message}`); - return NextResponse.json({ error: message }, { status: 400 }); - } -} diff --git a/apps/nextjs/src/app/config.tsx b/apps/nextjs/src/app/config.tsx deleted file mode 100644 index 1a65541c2..000000000 --- a/apps/nextjs/src/app/config.tsx +++ /dev/null @@ -1,167 +0,0 @@ -import type { Route } from "next"; -import { Component, CreditCard, Globe } from "lucide-react"; - -import * as Icons from "@acme/ui/icons"; - -export const siteConfig = { - name: "Acme Corp", - description: - "Next.js starter kit that includes everything you need to build a modern web application. Mobile application preconfigured, ready to go.", - github: "https://github.com/juliusmarminge/acme-corp", - twitter: "https://twitter.com/jullerino", -}; - -export const navItems = [ - { - href: "/dashboard", - title: "Overview", - }, - { - href: "/pricing", - title: "Pricing", - }, - { - href: "/dashboard", - title: "Products", - }, - { - href: "/dashboard", - title: "Settings", - }, -] satisfies { href: Route; title: string }[]; - -export const marketingFeatures = [ - { - icon: , - title: "UI Package", - body: ( - <> - A UI package with all the components you need for your next application. - Built by the wonderful{" "} - - Shadcn - - . - - ), - }, - { - icon: , - title: "Authentication", - body: ( - <> - Protect pages and API routes throughout your entire app using{" "} - - Clerk - - . - - ), - }, - { - icon: , - title: "MDX", - body: ( - <> - Preconfigured MDX as Server Components. MDX is the best way to write - contentful pages. - - ), - }, - { - icon: ( -
- - -
- ), - title: "Next.js 13 & React 18", - body: ( - <> - Latest features from Next 13 using the brand new App Router with full - React 18 support including streaming. - - ), - }, - - { - icon: ( -
- - - -
- ), - title: "Full-stack Typesafety", - body: ( - <> - Full-stack Typesafety with{" "} - - tRPC - - . Typesafe database querying using{" "} - - Kysely - {" "} - and{" "} - - Prisma - - . - - ), - }, - { - icon: , - title: "Edge Compute", - body: ( - <> - Ready to deploy on Edge functions to ensure a blazingly fast application - with optimal UX. - - ), - }, - { - icon: , - title: "Payments", - body: ( - <> - Accept payments with{" "} - - Stripe - - . - - ), - }, -]; diff --git a/apps/nextjs/src/app/layout.tsx b/apps/nextjs/src/app/layout.tsx deleted file mode 100644 index b29ebdc3b..000000000 --- a/apps/nextjs/src/app/layout.tsx +++ /dev/null @@ -1,70 +0,0 @@ -import "react-image-crop/dist/ReactCrop.css"; -import "~/styles/globals.css"; - -import { Inter } from "next/font/google"; -import LocalFont from "next/font/local"; -import { ClerkProvider } from "@clerk/nextjs"; -import { Analytics } from "@vercel/analytics/react"; - -import { cn } from "@acme/ui"; -import { Toaster } from "@acme/ui/toaster"; - -import { TailwindIndicator } from "~/components/tailwind-indicator"; -import { ThemeProvider } from "~/components/theme-provider"; -import { siteConfig } from "./config"; - -const fontSans = Inter({ - subsets: ["latin"], - variable: "--font-sans", -}); -const fontCal = LocalFont({ - src: "../styles/calsans.ttf", - variable: "--font-cal", -}); - -export const metadata = { - title: { - default: siteConfig.name, - template: `%s - ${siteConfig.name}`, - }, - description: siteConfig.description, - icons: { - icon: "/favicon.ico", - }, - openGraph: { - images: [{ url: "/opengraph-image.png" }], - }, - twitter: { - card: "summary_large_image", - title: siteConfig.name, - description: siteConfig.description, - images: [{ url: "https://acme-corp-lib.vercel.app/opengraph-image.png" }], - creator: "@jullerino", - }, - metadataBase: new URL("https://acme-corp.jumr.dev"), -}; - -export default function RootLayout(props: { children: React.ReactNode }) { - return ( - <> - - - - - {props.children} - - - - - - - - - ); -} diff --git a/apps/nextjs/src/components/footer.tsx b/apps/nextjs/src/components/footer.tsx deleted file mode 100644 index 6d2cdae9e..000000000 --- a/apps/nextjs/src/components/footer.tsx +++ /dev/null @@ -1,82 +0,0 @@ -import dynamic from "next/dynamic"; -import Link from "next/link"; - -import { cn } from "@acme/ui"; -import { Button } from "@acme/ui/button"; -import * as Icons from "@acme/ui/icons"; - -import { siteConfig } from "~/app/config"; - -const ThemeToggle = dynamic(() => import("~/components/theme-toggle"), { - ssr: false, - loading: () => ( - - ), -}); - -export function SiteFooter(props: { className?: string }) { - return ( - - ); -} diff --git a/apps/nextjs/src/components/mobile-nav.tsx b/apps/nextjs/src/components/mobile-nav.tsx deleted file mode 100644 index 32139339d..000000000 --- a/apps/nextjs/src/components/mobile-nav.tsx +++ /dev/null @@ -1,59 +0,0 @@ -"use client"; - -import * as React from "react"; -import Link from "next/link"; - -import { Button } from "@acme/ui/button"; -import * as Icons from "@acme/ui/icons"; -import { Popover, PopoverContent, PopoverTrigger } from "@acme/ui/popover"; -import { ScrollArea } from "@acme/ui/scroll-area"; - -import { Search } from "~/app/(dashboard)/_components/search"; -import { navItems, siteConfig } from "~/app/config"; -import ThemeToggle from "./theme-toggle"; - -export function MobileDropdown() { - const [isOpen, setIsOpen] = React.useState(false); - - React.useEffect(() => { - if (isOpen) { - document.body.classList.add("overflow-hidden"); - } else { - document.body.classList.remove("overflow-hidden"); - } - }, [isOpen]); - - return ( - - - - - - - - {navItems.map((item) => ( - - {item.title} - - ))} - -
- -
-
-
- ); -} diff --git a/apps/nextjs/src/components/tailwind-indicator.tsx b/apps/nextjs/src/components/tailwind-indicator.tsx deleted file mode 100644 index 5644c1b00..000000000 --- a/apps/nextjs/src/components/tailwind-indicator.tsx +++ /dev/null @@ -1,16 +0,0 @@ -export function TailwindIndicator() { - if (process.env.NODE_ENV === "production") return null; - - return ( -
-
xs
-
- sm -
-
md
-
lg
-
xl
-
2xl
-
- ); -} diff --git a/apps/nextjs/src/components/theme-provider.tsx b/apps/nextjs/src/components/theme-provider.tsx deleted file mode 100644 index 9adba3436..000000000 --- a/apps/nextjs/src/components/theme-provider.tsx +++ /dev/null @@ -1,5 +0,0 @@ -"use client"; - -import { ThemeProvider as NextThemeProvider } from "next-themes"; - -export const ThemeProvider = NextThemeProvider; diff --git a/apps/nextjs/src/components/theme-toggle.tsx b/apps/nextjs/src/components/theme-toggle.tsx deleted file mode 100644 index 0cff42477..000000000 --- a/apps/nextjs/src/components/theme-toggle.tsx +++ /dev/null @@ -1,56 +0,0 @@ -"use client"; - -import * as React from "react"; -import { useTheme } from "next-themes"; - -import { Button } from "@acme/ui/button"; -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuTrigger, -} from "@acme/ui/dropdown-menu"; -import * as Icons from "@acme/ui/icons"; - -export default function ThemeToggle(props: { - align?: "center" | "start" | "end"; - side?: "top" | "bottom"; -}) { - const { setTheme, theme } = useTheme(); - - const triggerIcon = { - light: , - dark: , - system: , - }[theme as "light" | "dark" | "system"]; - - return ( - - - - - - setTheme("light")}> - - Light - - setTheme("dark")}> - - Dark - - setTheme("system")}> - - System - - - - ); -} diff --git a/apps/nextjs/src/components/user-nav.tsx b/apps/nextjs/src/components/user-nav.tsx deleted file mode 100644 index 175264f76..000000000 --- a/apps/nextjs/src/components/user-nav.tsx +++ /dev/null @@ -1,110 +0,0 @@ -import Link from "next/link"; -import { currentUser } from "@clerk/nextjs"; -import { - CreditCard, - LogIn, - LogOut, - PlusCircle, - Settings, - User, -} from "lucide-react"; - -import { Avatar, AvatarFallback, AvatarImage } from "@acme/ui/avatar"; -import { Button } from "@acme/ui/button"; -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuGroup, - DropdownMenuItem, - DropdownMenuLabel, - DropdownMenuSeparator, - DropdownMenuShortcut, - DropdownMenuTrigger, -} from "@acme/ui/dropdown-menu"; - -export async function UserNav() { - const user = await currentUser(); - // if (!user) redirect("/signin"); - - if (!user) { - return ( - - - - ); - } - - const fullname = `${user.firstName} ${user.lastName}`; - const initials = fullname - .split(" ") - .map((n) => n[0]) - .join(""); - const email = user.emailAddresses.find( - (e) => e.id === user.primaryEmailAddressId, - )?.emailAddress; - - return ( - - - - - - -
-

- {user.firstName} {user.lastName} -

-

- {email} -

-
-
- - - - - - Profile - ⇧⌘P - - - - - - Billing - ⌘B - - - - - Settings - ⌘S - - - - New Team - - - - - - - Log out - ⇧⌘Q - - -
-
- ); -} diff --git a/apps/nextjs/src/env.mjs b/apps/nextjs/src/env.mjs deleted file mode 100644 index e73d783a5..000000000 --- a/apps/nextjs/src/env.mjs +++ /dev/null @@ -1,27 +0,0 @@ -import { createEnv } from "@t3-oss/env-nextjs"; -import { z } from "zod"; - -export const env = createEnv({ - shared: { - NODE_ENV: z.enum(["development", "test", "production"]), - }, - server: { - DATABASE_URL: z.string().url(), - CLERK_SECRET_KEY: z.string().optional(), - STRIPE_WEBHOOK_SECRET: z.string(), - }, - client: { - NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY: z.string().min(1), - }, - // Client side variables gets destructured here due to Next.js static analysis - // Shared ones are also included here for good measure since the behavior has been inconsistent - experimental__runtimeEnv: { - NODE_ENV: process.env.NODE_ENV, - - NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY: - process.env.NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY, - }, - skipValidation: - !!process.env.SKIP_ENV_VALIDATION || - process.env.npm_lifecycle_event === "lint", -}); diff --git a/apps/nextjs/src/lib/currency.ts b/apps/nextjs/src/lib/currency.ts deleted file mode 100644 index 1cc93111a..000000000 --- a/apps/nextjs/src/lib/currency.ts +++ /dev/null @@ -1,6 +0,0 @@ -export const currencySymbol = (curr: string) => - ({ - USD: "$", - EUR: "€", - GBP: "£", - })[curr] ?? curr; diff --git a/apps/nextjs/src/lib/generate-pattern.ts b/apps/nextjs/src/lib/generate-pattern.ts deleted file mode 100644 index 0313adae1..000000000 --- a/apps/nextjs/src/lib/generate-pattern.ts +++ /dev/null @@ -1,500 +0,0 @@ -/** - * Patterns from Hero Patterns - * Licence: CC BY 4.0 - * https://www.heropatterns.com/ - */ -const patterns = [ - { - name: "Jigsaw", - image: - '', - }, - { - name: "Overcast", - image: - '', - }, - { - name: "Formal Invitation", - image: - '', - }, - { - name: "Topography", - image: - '', - }, - { - name: "Texture", - image: - '', - }, - { - name: "Jupiter", - image: - '', - }, - { - name: "Architect", - image: - '', - }, - { - name: "Cutout", - image: - '', - }, - { - name: "Hideout", - image: - '', - }, - { - name: "Graph Paper", - image: - '', - }, - { - name: "YYY", - image: - '', - }, - { - name: "Squares", - image: - '', - }, - { - name: "Falling Triangles", - image: - '', - }, - { - name: "Piano Man", - image: - '', - }, - { - name: "Pie Factory", - image: - '', - }, - { - name: "Dominos", - image: - '', - }, - { - name: "Hexagons", - image: - '', - }, - { - name: "Charlie Brown", - image: - '', - }, - { - name: "Autumn", - image: - '', - }, - { - name: "Temple", - image: - '', - }, - { - name: "Stamp Collection", - image: - '', - }, - { - name: "Death Star", - image: - '', - }, - { - name: "Church on Sunday", - image: - '', - }, - { - name: "I Like Food", - image: - '', - }, - { - name: "Overlapping Hexagons", - image: - '', - }, - { - name: "4 Point Stars", - image: - '', - }, - { - name: "Bamboo", - image: - '', - }, - { - name: "Bathroom Floor", - image: - '', - }, - { - name: "Cork Screw", - image: - '', - }, - { - name: "Happy Intersection", - image: - '', - }, - { - name: "Kiwi", - image: - '', - }, - { - name: "Lips", - image: - '', - }, - { - name: "Lisbon", - image: - '', - }, - { - name: "Random Shapes", - image: - '', - }, - { - name: "Steel Beams", - image: - '', - }, - { - name: "Tiny Checkers", - image: - '', - }, - { - name: "X Equals", - image: - '', - }, - { - name: "Anchors Away", - image: - '', - }, - { - name: "Bevel Circle", - image: - '', - }, - { - name: "Brick Wall", - image: - '', - }, - { - name: "Fancy Rectangles", - image: - '', - }, - { - name: "Heavy Rain", - image: - '', - }, - { - name: "Overlapping Circles", - image: - '', - }, - { - name: "Plus", - image: - '', - }, - { - name: "Rounded Plus Connected", - image: - '', - }, - { - name: "Volcano Lamp", - image: - '', - }, - { - name: "Wiggle", - image: - '', - }, - { - name: "Bubbles", - image: - '', - }, - { - name: "Cage", - image: - '', - }, - { - name: "Connections", - image: - '', - }, - { - name: "Current", - image: - '', - }, - { - name: "Diagonal Stripes", - image: - '', - }, - { - name: "Flipped Diamonds", - image: - '', - }, - { - name: "Floating Cogs", - image: - '', - }, - { - name: "Glamorous", - image: - '', - }, - { - name: "Houndstooth", - image: - 'houndstooth', - }, - { - name: "Leaf", - image: - '', - }, - { - name: "Lines in Motion", - image: - '', - }, - { - name: "Moroccan", - image: - '', - }, - { - name: "Morphing Diamonds", - image: - '', - }, - { - name: "Rails", - image: - '', - }, - { - name: "Rain", - image: - '', - }, - { - name: "Skulls", - image: - '', - }, - { - name: "Squares in Squares", - image: - '', - }, - { - name: "Stripes", - image: - '', - }, - { - name: "Tic Tac Toe", - image: - '', - }, - { - name: "Zig Zag", - image: - '', - }, - { - name: "Aztec", - image: - '', - }, - { - name: "Bank Note", - image: - '', - }, - { - name: "Boxes", - image: - '', - }, - { - name: "Circles & Squares", - image: - '', - }, - { - name: "Circuit Board", - image: - '', - }, - { - name: "Curtain", - image: - '', - }, - { - name: "Diagonal Lines", - image: - '', - }, - { - name: "Endless Clouds", - image: - '', - }, - { - name: "Eyes", - image: - '', - }, - { - name: "Floor Tile", - image: - '', - }, - { - name: "Groovy", - image: - '', - }, - { - name: "Intersecting Circles", - image: - '', - }, - { - name: "Melt", - image: - '', - }, - { - name: "Overlapping Diamonds", - image: - '', - }, - { - name: "Parkay Floor", - image: - '', - }, - { - name: "Pixel Dots", - image: - '', - }, - { - name: "Polka Dots", - image: - '', - }, - { - name: "Signal", - image: - '', - }, - { - name: "Slanted Stars", - image: - '', - }, - { - name: "Wallpaper", - image: - '', - }, -]; - -const bgPattern = ( - pattern: (typeof patterns)[number], - fgColor: string, - opacity: number, -) => { - const svg = pattern.image - .replace('fill="#000"', `fill="${fgColor}" fill-opacity="${opacity}"`) - .replace(/"/g, "'") - .replace(//g, "%3E") - .replace(/&/g, "%26") - .replace(/#/g, "%23"); - return 'url("data:image/svg+xml,' + svg + '")'; -}; - -const colors = [ - "#e51c23", - "#e91e63", - "#9c27b0", - "#673ab7", - "#3f51b5", - "#5677fc", - "#03a9f4", - "#00bcd4", - "#009688", - "#259b24", - "#8bc34a", - "#afb42b", - "#ff9800", - "#ff5722", - "#795548", - "#607d8b", -]; - -export const getRandomPatternStyle = (seed: string) => { - // Generate a 32-bit hash based on the seed, - // then use it to pick a pattern and a color. - let hash = 0; - if (seed.length !== 0) { - for (let i = 0; i < seed.length; i++) { - hash = seed.charCodeAt(i) + ((hash << 5) - hash); - hash = hash & hash; - } - } - const [nPatterns, nColors] = [patterns.length, colors.length]; - const pattern = patterns[((hash % nPatterns) + nPatterns) % nPatterns]; - const fgColor = colors[((hash % nColors) + nColors) % nColors]; - const opacity = 0.4; - - if (!pattern || !fgColor) { - throw new Error("Something went wrong trying to pick a pattern..."); - } - - return { - backgroundImage: bgPattern(pattern, fgColor, opacity), - }; -}; diff --git a/apps/nextjs/src/lib/project-guard.ts b/apps/nextjs/src/lib/project-guard.ts deleted file mode 100644 index 8b150846a..000000000 --- a/apps/nextjs/src/lib/project-guard.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { notFound } from "next/navigation"; - -import { db } from "@acme/db"; - -export async function userCanAccess(projectId: string) { - if (!projectId.startsWith("project_")) { - notFound(); - } - - // see if project exists - const project = await db - .selectFrom("Project") - .select("id") - .where("id", "=", projectId) - .executeTakeFirst(); - - if (!project) { - notFound(); - } -} diff --git a/apps/nextjs/src/lib/use-debounce.tsx b/apps/nextjs/src/lib/use-debounce.tsx deleted file mode 100644 index 6b6d32125..000000000 --- a/apps/nextjs/src/lib/use-debounce.tsx +++ /dev/null @@ -1,17 +0,0 @@ -import { useEffect, useState } from "react"; - -export function useDebounce(value: T, delay: number) { - const [debouncedValue, setDebouncedValue] = useState(value); - - useEffect(() => { - const timeoutId = setTimeout(() => { - setDebouncedValue(value); - }, delay); - - return () => { - clearTimeout(timeoutId); - }; - }, [value, delay]); - - return debouncedValue; -} diff --git a/apps/nextjs/src/lib/zod-form.tsx b/apps/nextjs/src/lib/zod-form.tsx deleted file mode 100644 index b59ef3cdd..000000000 --- a/apps/nextjs/src/lib/zod-form.tsx +++ /dev/null @@ -1,17 +0,0 @@ -import { zodResolver } from "@hookform/resolvers/zod"; -import { useForm } from "react-hook-form"; -import type { UseFormProps } from "react-hook-form"; -import type { ZodType } from "zod"; - -export function useZodForm( - props: Omit, "resolver"> & { - schema: TSchema; - }, -) { - const form = useForm({ - ...props, - resolver: zodResolver(props.schema, undefined), - }); - - return form; -} diff --git a/apps/nextjs/src/mdx-components.tsx b/apps/nextjs/src/mdx-components.tsx deleted file mode 100644 index eb7a3357e..000000000 --- a/apps/nextjs/src/mdx-components.tsx +++ /dev/null @@ -1,61 +0,0 @@ -import * as React from "react"; -import type { Route } from "next"; -import Link from "next/link"; -import type { MDXComponents } from "mdx/types"; - -// This file is required to use MDX in `app` directory. -export function useMDXComponents(components: MDXComponents): MDXComponents { - return { - // Allows customizing built-in components, e.g. to add styling. - h1: (props) => ( -

- {props.children} -

- ), - h2: (props) => ( -

- {props.children} -

- ), - h3: (props) => ( -

- {props.children} -

- ), - h4: (props) => ( -

- {props.children} -

- ), - p: (props) => ( -

- ), - a: ({ children, href }) => { - const isExternal = href?.startsWith("http"); - const Component = isExternal ? "a" : Link; - return ( - - {children} - - ); - }, - ul: (props) =>

    , - code: (props) => ( - - ), - // eslint-disable-next-line @next/next/no-img-element - img: (props) => {props.alt}, - - // Pass through all other components. - ...components, - }; -} diff --git a/apps/nextjs/src/middleware.ts b/apps/nextjs/src/middleware.ts deleted file mode 100644 index 8df07cfc7..000000000 --- a/apps/nextjs/src/middleware.ts +++ /dev/null @@ -1,83 +0,0 @@ -import { NextResponse } from "next/server"; -import { authMiddleware, clerkClient } from "@clerk/nextjs"; - -export default authMiddleware({ - signInUrl: "/signin", - publicRoutes: [ - "/", - "/opengraph-image.png", - "/signin(.*)", - "/sso-callback(.*)", - "/terms(.*)", - "/pricing(.*)", - "/privacy(.*)", - "/api(.*)", - ], - async afterAuth(auth, req) { - if (auth.isPublicRoute) { - // Don't do anything for public routes - return NextResponse.next(); - } - - const url = new URL(req.nextUrl.origin); - const parts = req.nextUrl.pathname.split("/").filter(Boolean); - - if (!auth.userId) { - // User is not signed in - url.pathname = "/signin"; - return NextResponse.redirect(url); - } - - if (req.nextUrl.pathname === "/dashboard") { - // /dashboard should redirect to the user's dashboard - // use their current workspace, i.e. /:orgId or /:userId - url.pathname = `/${auth.orgId ?? auth.userId}`; - return NextResponse.redirect(url); - } - - /** - * TODO: I'd prefer not showing the ID in the URL but - * a more friendly looking slug. For example, - * /org_foo34213 -> /foo - * /user_bar123/project_acm234231sfsdfa -> /bar/baz - */ - - /** - * TODO: Decide if redirects should 404 or redirect to / - */ - - const workspaceId = parts[0]; - const isOrg = workspaceId?.startsWith("org_"); - if (isOrg && auth.orgId !== workspaceId) { - // User is accessing an org that's not their active one - // Check if they have access to it - const orgs = await clerkClient.users.getOrganizationMembershipList({ - userId: auth.userId, - }); - const hasAccess = orgs.some((org) => org.id === workspaceId); - if (!hasAccess) { - url.pathname = `/`; - return NextResponse.redirect(url); - } - - // User has access to the org, let them pass. - // TODO: Set the active org to the one they're accessing - // so that we don't need to do this client-side. - // This is currently not possible with Clerk but will be. - return NextResponse.next(); - } - - const isUser = workspaceId?.startsWith("user_"); - if (isUser && auth.userId !== workspaceId) { - // User is accessing a user that's not them - url.pathname = `/`; - return NextResponse.redirect(url); - } - - return NextResponse.next(); - }, -}); - -export const config = { - matcher: ["/((?!.*\\..*|_next).*)", "/", "/(api|trpc)(.*)"], -}; diff --git a/apps/nextjs/src/styles/calsans.ttf b/apps/nextjs/src/styles/calsans.ttf deleted file mode 100644 index 4a2950a04..000000000 Binary files a/apps/nextjs/src/styles/calsans.ttf and /dev/null differ diff --git a/apps/nextjs/src/trpc/client.ts b/apps/nextjs/src/trpc/client.ts deleted file mode 100644 index 015191fe1..000000000 --- a/apps/nextjs/src/trpc/client.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { createTRPCClient, loggerLink } from "@trpc/client"; - -import type { AppRouter } from "@acme/api"; - -import { endingLink, transformer } from "./shared"; - -export const api = createTRPCClient({ - transformer: transformer, - links: [ - loggerLink({ - enabled: (opts) => - process.env.NODE_ENV === "development" || - (opts.direction === "down" && opts.result instanceof Error), - }), - endingLink({ - headers: { - "x-trpc-source": "client", - }, - }), - ], -}); - -export { type RouterInputs, type RouterOutputs } from "@acme/api"; diff --git a/apps/nextjs/src/trpc/server.ts b/apps/nextjs/src/trpc/server.ts deleted file mode 100644 index ff6fd5e53..000000000 --- a/apps/nextjs/src/trpc/server.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { headers } from "next/headers"; -import { createTRPCClient, loggerLink } from "@trpc/client"; - -import type { AppRouter } from "@acme/api"; - -import { endingLink, transformer } from "./shared"; - -export const api = createTRPCClient({ - transformer: transformer, - links: [ - loggerLink({ - enabled: (opts) => - process.env.NODE_ENV === "development" || - (opts.direction === "down" && opts.result instanceof Error), - }), - endingLink({ - headers: () => { - const h = new Map(headers()); - h.delete("connection"); - h.delete("transfer-encoding"); - h.set("x-trpc-source", "server"); - return Object.fromEntries(h.entries()); - }, - }), - ], -}); - -export { type RouterInputs, type RouterOutputs } from "@acme/api"; diff --git a/apps/nextjs/src/trpc/shared.ts b/apps/nextjs/src/trpc/shared.ts deleted file mode 100644 index 0c80bb260..000000000 --- a/apps/nextjs/src/trpc/shared.ts +++ /dev/null @@ -1,44 +0,0 @@ -import type { HTTPBatchLinkOptions, HTTPHeaders, TRPCLink } from "@trpc/client"; -import { httpBatchLink } from "@trpc/client"; - -import type { AppRouter } from "@acme/api"; - -export { transformer } from "@acme/api/transformer"; - -const getBaseUrl = () => { - if (typeof window !== "undefined") return ""; - const vc = process.env.VERCEL_URL; - if (vc) return `https://${vc}`; - return `http://localhost:3000`; -}; - -const lambdas = ["ingestion"]; - -export const endingLink = (opts?: { - headers?: HTTPHeaders | (() => HTTPHeaders); -}) => - ((runtime) => { - const sharedOpts = { - headers: opts?.headers, - } satisfies Partial; - - const edgeLink = httpBatchLink({ - ...sharedOpts, - url: `${getBaseUrl()}/api/trpc/edge`, - })(runtime); - const lambdaLink = httpBatchLink({ - ...sharedOpts, - url: `${getBaseUrl()}/api/trpc/lambda`, - })(runtime); - - return (ctx) => { - const path = ctx.op.path.split(".") as [string, ...string[]]; - const endpoint = lambdas.includes(path[0]) ? "lambda" : "edge"; - - const newCtx = { - ...ctx, - op: { ...ctx.op, path: path.join(".") }, - }; - return endpoint === "edge" ? edgeLink(newCtx) : lambdaLink(newCtx); - }; - }) satisfies TRPCLink; diff --git a/apps/nextjs/tsconfig.json b/apps/nextjs/tsconfig.json deleted file mode 100644 index 55e606524..000000000 --- a/apps/nextjs/tsconfig.json +++ /dev/null @@ -1,17 +0,0 @@ -{ - "extends": "@acme/tsconfig/base.json", - "compilerOptions": { - "baseUrl": ".", - "paths": { - "~/*": ["./src/*"], - }, - "plugins": [ - { - "name": "next", - }, - ], - "tsBuildInfoFile": "node_modules/.cache/tsbuildinfo.json", - }, - "include": ["next-env.d.ts", ".next/types/**/*.ts", "*.ts", "*.mjs", "src"], - "exclude": ["node_modules"], -} diff --git a/apps/web/.env.development b/apps/web/.env.development new file mode 100644 index 000000000..087d17390 --- /dev/null +++ b/apps/web/.env.development @@ -0,0 +1,36 @@ +NEXT_PUBLIC_SITE_URL=http://localhost:3000 +NEXT_PUBLIC_PRODUCT_NAME=Makerkit + +# SUPABASE +NEXT_PUBLIC_SUPABASE_URL=http://127.0.0.1:54321 +NEXT_PUBLIC_SUPABASE_ANON_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6ImFub24iLCJleHAiOjE5ODM4MTI5OTZ9.CRXP1A7WOeoJeXxjNni43kdQwgnWNReilDMblYTn_I0 +SUPABASE_SERVICE_ROLE_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6InNlcnZpY2Vfcm9sZSIsImV4cCI6MTk4MzgxMjk5Nn0.EGIM96RAZx35lJzdJsyH-qQwv8Hdp7fsn3W0YpN81IU + +NEXT_PUBLIC_REQUIRE_EMAIL_CONFIRMATION=true + +EMAIL_SENDER=test@makerkit.dev +EMAIL_PORT=54325 +EMAIL_HOST=localhost +EMAIL_TLS=false +EMAIL_USER=user +EMAIL_PASSWORD=password + +# STRIPE +NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY= + +# FEATURE FLAGS +NEXT_PUBLIC_ENABLE_ACCOUNT_DELETION=true +NEXT_PUBLIC_ENABLE_PERSONAL_ACCOUNT_BILLING=true +NEXT_PUBLIC_ENABLE_ORGANIZATION_DELETION=true +NEXT_PUBLIC_ENABLE_ORGANIZATION_INVITATIONS=true +NEXT_PUBLIC_ENABLE_ORGANIZATION_BILLING=true + +# LOCALES +NEXT_PUBLIC_LOCALES_PATH=apps/web/public/locales + +# PATHS +# Please make sure to update these in the app's paths configuration as well +SIGN_IN_PATH=/auth/sign-in +SIGN_UP_PATH=/auth/sign-up +ORGANIZATION_ACCOUNTS_PATH=/home +INVITATION_PAGE_PATH=/invite diff --git a/apps/nextjs/README.md b/apps/web/README.md similarity index 100% rename from apps/nextjs/README.md rename to apps/web/README.md diff --git a/apps/web/app/(dashboard)/home/(user)/account/actions.server.ts b/apps/web/app/(dashboard)/home/(user)/account/actions.server.ts new file mode 100644 index 000000000..e175d3e75 --- /dev/null +++ b/apps/web/app/(dashboard)/home/(user)/account/actions.server.ts @@ -0,0 +1,12 @@ +'use server'; + +import { getSupabaseServerActionClient } from '@kit/supabase/server-actions-client'; + +/** + * Refreshes the user session on the server when updating the user profile. + */ +export async function refreshSessionAction() { + const supabase = getSupabaseServerActionClient(); + + await supabase.auth.refreshSession(); +} diff --git a/apps/web/app/(dashboard)/home/(user)/account/layout.tsx b/apps/web/app/(dashboard)/home/(user)/account/layout.tsx new file mode 100644 index 000000000..fed85d6ff --- /dev/null +++ b/apps/web/app/(dashboard)/home/(user)/account/layout.tsx @@ -0,0 +1,19 @@ +import { withI18n } from '~/lib/i18n/with-i18n'; + +import { PageBody, PageHeader } from '@kit/ui/page'; +import { Trans } from '@kit/ui/trans'; + +function UserSettingsLayout(props: React.PropsWithChildren) { + return ( + <> + } + description={'Manage your account settings'} + /> + + {props.children} + + ); +} + +export default withI18n(UserSettingsLayout); diff --git a/apps/web/app/(dashboard)/home/(user)/account/loading.tsx b/apps/web/app/(dashboard)/home/(user)/account/loading.tsx new file mode 100644 index 000000000..4ea53181d --- /dev/null +++ b/apps/web/app/(dashboard)/home/(user)/account/loading.tsx @@ -0,0 +1,3 @@ +import { GlobalLoader } from '@kit/ui/global-loader'; + +export default GlobalLoader; diff --git a/apps/web/app/(dashboard)/home/(user)/account/page.tsx b/apps/web/app/(dashboard)/home/(user)/account/page.tsx new file mode 100644 index 000000000..151fe13b0 --- /dev/null +++ b/apps/web/app/(dashboard)/home/(user)/account/page.tsx @@ -0,0 +1,26 @@ +import featureFlagsConfig from '~/config/feature-flags.config'; +import pathsConfig from '~/config/paths.config'; +import { withI18n } from '~/lib/i18n/with-i18n'; + +import { PersonalAccountSettingsContainer } from '@kit/accounts/personal-account-settings'; + +function PersonalAccountSettingsPage() { + return ( +
    + +
    + ); +} + +export default withI18n(PersonalAccountSettingsPage); diff --git a/apps/web/app/(dashboard)/home/(user)/billing/layout.tsx b/apps/web/app/(dashboard)/home/(user)/billing/layout.tsx new file mode 100644 index 000000000..bea46991d --- /dev/null +++ b/apps/web/app/(dashboard)/home/(user)/billing/layout.tsx @@ -0,0 +1,15 @@ +import { notFound } from 'next/navigation'; + +import featureFlagsConfig from '~/config/feature-flags.config'; + +function UserBillingLayout(props: React.PropsWithChildren) { + const isEnabled = featureFlagsConfig.enablePersonalAccountBilling; + + if (!isEnabled) { + notFound(); + } + + return <>{props.children}; +} + +export default UserBillingLayout; diff --git a/apps/web/app/(dashboard)/home/(user)/billing/page.tsx b/apps/web/app/(dashboard)/home/(user)/billing/page.tsx new file mode 100644 index 000000000..9a4864673 --- /dev/null +++ b/apps/web/app/(dashboard)/home/(user)/billing/page.tsx @@ -0,0 +1,19 @@ +import { withI18n } from '~/lib/i18n/with-i18n'; + +import { PageBody, PageHeader } from '@kit/ui/page'; +import { Trans } from '@kit/ui/trans'; + +function PersonalAccountBillingPage() { + return ( + <> + } + description={} + /> + + + + ); +} + +export default withI18n(PersonalAccountBillingPage); diff --git a/apps/web/app/(dashboard)/home/(user)/layout.tsx b/apps/web/app/(dashboard)/home/(user)/layout.tsx new file mode 100644 index 000000000..5c740d79a --- /dev/null +++ b/apps/web/app/(dashboard)/home/(user)/layout.tsx @@ -0,0 +1,10 @@ +import { HomeSidebar } from '~/(dashboard)/home/components/home-sidebar'; +import { withI18n } from '~/lib/i18n/with-i18n'; + +import { Page } from '@kit/ui/page'; + +function UserHomeLayout({ children }: React.PropsWithChildren) { + return }>{children}; +} + +export default withI18n(UserHomeLayout); diff --git a/apps/web/app/(dashboard)/home/(user)/loading.tsx b/apps/web/app/(dashboard)/home/(user)/loading.tsx new file mode 100644 index 000000000..4ea53181d --- /dev/null +++ b/apps/web/app/(dashboard)/home/(user)/loading.tsx @@ -0,0 +1,3 @@ +import { GlobalLoader } from '@kit/ui/global-loader'; + +export default GlobalLoader; diff --git a/apps/web/app/(dashboard)/home/(user)/page.tsx b/apps/web/app/(dashboard)/home/(user)/page.tsx new file mode 100644 index 000000000..c98360d9f --- /dev/null +++ b/apps/web/app/(dashboard)/home/(user)/page.tsx @@ -0,0 +1,24 @@ +import { withI18n } from '~/lib/i18n/with-i18n'; + +import { PageBody, PageHeader } from '@kit/ui/page'; +import { Trans } from '@kit/ui/trans'; + +function UserHomePage() { + return ( + <> + } + description={ + + } + /> + + + + ); +} + +export default withI18n(UserHomePage); diff --git a/apps/web/app/(dashboard)/home/[account]/(components)/app-header.tsx b/apps/web/app/(dashboard)/home/[account]/(components)/app-header.tsx new file mode 100644 index 000000000..c1e27fbd0 --- /dev/null +++ b/apps/web/app/(dashboard)/home/[account]/(components)/app-header.tsx @@ -0,0 +1,24 @@ +import { MobileAppNavigation } from '~/(dashboard)/home/[account]/(components)/mobile-app-navigation'; + +import { PageHeader } from '@kit/ui/page'; + +export function AppHeader({ + children, + title, + description, + account, +}: React.PropsWithChildren<{ + title: string | React.ReactNode; + description?: string | React.ReactNode; + account: string; +}>) { + return ( + } + > + {children} + + ); +} diff --git a/apps/web/app/(dashboard)/home/[account]/(components)/app-sidebar-navigation.tsx b/apps/web/app/(dashboard)/home/[account]/(components)/app-sidebar-navigation.tsx new file mode 100644 index 000000000..5a4ccc1dc --- /dev/null +++ b/apps/web/app/(dashboard)/home/[account]/(components)/app-sidebar-navigation.tsx @@ -0,0 +1,61 @@ +'use client'; + +import { getOrganizationAccountSidebarConfig } from '~/config/organization-account-sidebar.config'; + +import { SidebarDivider, SidebarGroup, SidebarItem } from '@kit/ui/sidebar'; +import { Trans } from '@kit/ui/trans'; + +export function AppSidebarNavigation({ + account, +}: React.PropsWithChildren<{ + account: string; +}>) { + return ( + <> + {getOrganizationAccountSidebarConfig(account).routes.map( + (item, index) => { + if ('divider' in item) { + return ; + } + + if ('children' in item) { + return ( + } + collapsible={item.collapsible} + collapsed={item.collapsed} + > + {item.children.map((child) => { + return ( + + + + ); + })} + + ); + } + + return ( + + + + ); + }, + )} + + ); +} + +export default AppSidebarNavigation; diff --git a/apps/web/app/(dashboard)/home/[account]/(components)/app-sidebar.tsx b/apps/web/app/(dashboard)/home/[account]/(components)/app-sidebar.tsx new file mode 100644 index 000000000..e426e1aa1 --- /dev/null +++ b/apps/web/app/(dashboard)/home/[account]/(components)/app-sidebar.tsx @@ -0,0 +1,155 @@ +'use client'; + +import { useRouter } from 'next/navigation'; + +import { ArrowLeftCircleIcon, ArrowRightCircleIcon } from 'lucide-react'; +import { ProfileDropdownContainer } from '~/(dashboard)/home/components/personal-account-dropdown'; +import featureFlagsConfig from '~/config/feature-flags.config'; +import pathsConfig from '~/config/paths.config'; + +import { AccountSelector } from '@kit/accounts/account-selector'; +import { Sidebar, SidebarContent } from '@kit/ui/sidebar'; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from '@kit/ui/tooltip'; +import { Trans } from '@kit/ui/trans'; +import { cn } from '@kit/ui/utils'; + +import { AppSidebarNavigation } from './app-sidebar-navigation'; + +type AccountModel = { + label: string | null; + value: string | null; + image: string | null; +}; + +const features = { + enableOrganizationAccounts: featureFlagsConfig.enableOrganizationAccounts, + enableOrganizationCreation: featureFlagsConfig.enableOrganizationCreation, +}; + +export function AppSidebar(props: { + account: string; + accounts: AccountModel[]; + collapsed: boolean; +}) { + return ( + + {({ collapsed, setCollapsed }) => ( + + )} + + ); +} + +function SidebarContainer(props: { + account: string; + accounts: AccountModel[]; + collapsed: boolean; + setCollapsed: (collapsed: boolean) => void; +}) { + const { account, accounts } = props; + const router = useRouter(); + + return ( + <> + + { + const path = value + ? pathsConfig.app.accountHome.replace('[account]', value) + : pathsConfig.app.home; + + router.replace(path); + }} + /> + + + + + + +
    + + + + + +
    + + ); +} + +function AppSidebarFooterMenu(props: { + collapsed: boolean; + setCollapsed: (collapsed: boolean) => void; +}) { + return ( + + ); +} + +function CollapsibleButton({ + collapsed, + onClick, +}: React.PropsWithChildren<{ + collapsed: boolean; + onClick: (collapsed: boolean) => void; +}>) { + const className = cn( + `bg-background absolute -right-[10.5px] bottom-4 cursor-pointer block`, + ); + + const iconClassName = + 'bg-background text-gray-300 dark:text-gray-600 h-5 w-5'; + + return ( + + + onClick(!collapsed)} + > + + + + + + + + + + + ); +} diff --git a/apps/web/app/(dashboard)/home/[account]/(components)/dashboard-demo.tsx b/apps/web/app/(dashboard)/home/[account]/(components)/dashboard-demo.tsx new file mode 100644 index 000000000..a2967be45 --- /dev/null +++ b/apps/web/app/(dashboard)/home/[account]/(components)/dashboard-demo.tsx @@ -0,0 +1,349 @@ +'use client'; + +import { useMemo } from 'react'; + +import { ArrowDownIcon, ArrowUpIcon, MenuIcon } from 'lucide-react'; +import { Line, LineChart, ResponsiveContainer, XAxis } from 'recharts'; + +import { Badge } from '@kit/ui/badge'; +import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card'; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from '@kit/ui/table'; + +export default function DashboardDemo() { + const mrr = useMemo(() => generateDemoData(), []); + const visitors = useMemo(() => generateDemoData(), []); + const returningVisitors = useMemo(() => generateDemoData(), []); + const churn = useMemo(() => generateDemoData(), []); + const netRevenue = useMemo(() => generateDemoData(), []); + const fees = useMemo(() => generateDemoData(), []); + const newCustomers = useMemo(() => generateDemoData(), []); + const tickets = useMemo(() => generateDemoData(), []); + const activeUsers = useMemo(() => generateDemoData(), []); + + return ( +
    +
    + + + Monthly Recurring Revenue + + + +
    +
    {`$${mrr[1]}`}
    + 20% +
    + + +
    +
    + + + + Revenue + + + +
    +
    {`$${netRevenue[1]}`}
    + 12% +
    + + +
    +
    + + + + Fees + + + +
    +
    {`$${fees[1]}`}
    + 9% +
    + + +
    +
    + + + + New Customers + + + +
    +
    {`${newCustomers[1]}`}
    + -25% +
    + + +
    +
    + + + + Visitors + + + +
    +
    {visitors[1]}
    + -4.3% +
    + + +
    +
    + + + + Returning Visitors + + + +
    +
    {returningVisitors[1]}
    + 10% +
    + + +
    +
    + + + + Churn + + + +
    +
    {churn[1]}%
    + -10% +
    + + +
    +
    + + + + Support Tickets + + + +
    +
    {tickets[1]}
    + -30% +
    + + +
    +
    +
    + +
    + + + Active Users + + + +
    +
    {activeUsers[1]}
    + 10% +
    + + +
    +
    +
    + +
    + + + Customers + + + + + + +
    +
    + ); +} + +function generateDemoData() { + const today = new Date(); + const formatter = new Intl.DateTimeFormat('en-us', { + month: 'long', + year: '2-digit', + }); + + const data: { value: string; name: string }[] = []; + + for (let n = 8; n > 0; n -= 1) { + const date = new Date(today.getFullYear(), today.getMonth() - n, 1); + + data.push({ + name: formatter.format(date), + value: (Math.random() * 10).toFixed(1), + }); + } + + return [data, data[data.length - 1].value] as [typeof data, string]; +} + +function Chart( + props: React.PropsWithChildren<{ data: { value: string; name: string }[] }>, +) { + return ( +
    + + + + + + + +
    + ); +} + +function CustomersTable() { + return ( + + + + Customer + Plan + MRR + Logins + Status + + + + + + Pippin Oddo + Pro + $100.2 + 920 + + Healthy + + + + + Väinö Pánfilo + Basic + $40.6 + 300 + + Possible Churn + + + + + Giorgos Quinten + Pro + $2004.3 + 1000 + + Healthy + + + + + Adhelm Otis + Basic + $0 + 10 + + Churned + + + +
    + ); +} + +function BadgeWithTrend(props: React.PropsWithChildren<{ trend: string }>) { + const className = useMemo(() => { + switch (props.trend) { + case 'up': + return 'text-green-500'; + case 'down': + return 'text-destructive'; + case 'stale': + return 'text-orange-500'; + } + }, [props.trend]); + + return ( + + {props.children} + + ); +} + +function Figure(props: React.PropsWithChildren) { + return
    {props.children}
    ; +} + +function Trend( + props: React.PropsWithChildren<{ + trend: 'up' | 'down' | 'stale'; + }>, +) { + const Icon = useMemo(() => { + switch (props.trend) { + case 'up': + return ; + case 'down': + return ; + case 'stale': + return ; + } + }, [props.trend]); + + return ( +
    + + + {Icon} + {props.children} + + +
    + ); +} diff --git a/apps/web/app/(dashboard)/home/[account]/(components)/mobile-app-navigation.tsx b/apps/web/app/(dashboard)/home/[account]/(components)/mobile-app-navigation.tsx new file mode 100644 index 000000000..fdb839c7c --- /dev/null +++ b/apps/web/app/(dashboard)/home/[account]/(components)/mobile-app-navigation.tsx @@ -0,0 +1,174 @@ +'use client'; + +import Link from 'next/link'; +import { useRouter } from 'next/navigation'; + +import { HomeIcon, LogOutIcon, MenuIcon } from 'lucide-react'; +import featureFlagsConfig from '~/config/feature-flags.config'; +import { getOrganizationAccountSidebarConfig } from '~/config/organization-account-sidebar.config'; +import pathsConfig from '~/config/paths.config'; + +import { AccountSelector } from '@kit/accounts/account-selector'; +import { useSignOut } from '@kit/supabase/hooks/use-sign-out'; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogTrigger, +} from '@kit/ui/dialog'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from '@kit/ui/dropdown-menu'; +import { Trans } from '@kit/ui/trans'; + +export const MobileAppNavigation = ( + props: React.PropsWithChildren<{ + slug: string; + }>, +) => { + const signOut = useSignOut(); + + const Links = getOrganizationAccountSidebarConfig(props.slug).routes.map( + (item, index) => { + if ('children' in item) { + return item.children.map((child) => { + return ( + + ); + }); + } + + if ('divider' in item) { + return ; + } + + return ( + + ); + }, + ); + + return ( + + + + + + + + + {Links} + + + signOut.mutateAsync()} /> + + + ); +}; + +function DropdownLink( + props: React.PropsWithChildren<{ + path: string; + label: string; + Icon: React.ReactNode; + }>, +) { + return ( + + + {props.Icon} + + + + + + + ); +} + +function SignOutDropdownItem( + props: React.PropsWithChildren<{ + onSignOut: () => unknown; + }>, +) { + return ( + + + + + + + + ); +} + +function OrganizationsModal() { + const router = useRouter(); + + return ( + + + e.preventDefault()} + > + + + + + + + + + + + +
    + { + const path = value + ? pathsConfig.app.accountHome.replace('[account]', value) + : pathsConfig.app.home; + + router.replace(path); + }} + accounts={[]} + features={{ + enableOrganizationAccounts: + featureFlagsConfig.enableOrganizationAccounts, + enableOrganizationCreation: + featureFlagsConfig.enableOrganizationCreation, + }} + /> +
    +
    +
    + ); +} diff --git a/apps/web/app/(dashboard)/home/[account]/(lib)/load-workspace.ts b/apps/web/app/(dashboard)/home/[account]/(lib)/load-workspace.ts new file mode 100644 index 000000000..0fc2475a3 --- /dev/null +++ b/apps/web/app/(dashboard)/home/[account]/(lib)/load-workspace.ts @@ -0,0 +1,64 @@ +import { cache } from 'react'; + +import { redirect } from 'next/navigation'; + +import 'server-only'; +import pathsConfig from '~/config/paths.config'; + +import { getSupabaseServerComponentClient } from '@kit/supabase/server-component-client'; + +/** + * Load the organization workspace data. + * We place this function into a separate file so it can be reused in multiple places across the server components. + * + * This function is used in the layout component for the organization workspace. + * It is cached so that the data is only fetched once per request. + * + * @param accountSlug + */ +export const loadOrganizationWorkspace = cache(async (accountSlug: string) => { + const client = getSupabaseServerComponentClient(); + + const accountPromise = client.rpc('organization_account_workspace', { + account_slug: accountSlug, + }); + + const accountsPromise = client.from('user_accounts').select('*'); + const userSessionPromise = client.auth.getSession(); + + const [accountResult, accountsResult, sessionResult] = await Promise.all([ + accountPromise, + accountsPromise, + userSessionPromise, + ]); + + if (accountResult.error) { + throw accountResult.error; + } + + // we cannot find any record for the selected organization + // so we redirect the user to the home page + if (!accountResult.data.length) { + return redirect(pathsConfig.app.home); + } + + const accountData = accountResult.data[0]; + + if (!accountData) { + return redirect(pathsConfig.app.home); + } + + if (accountsResult.error) { + throw accountsResult.error; + } + + if (sessionResult.error ?? !sessionResult.data.session?.user) { + throw new Error('User session not found'); + } + + return { + account: accountData, + accounts: accountsResult.data, + user: sessionResult.data.session.user, + }; +}); diff --git a/apps/web/app/(dashboard)/home/[account]/billing/layout.tsx b/apps/web/app/(dashboard)/home/[account]/billing/layout.tsx new file mode 100644 index 000000000..36bd91250 --- /dev/null +++ b/apps/web/app/(dashboard)/home/[account]/billing/layout.tsx @@ -0,0 +1,15 @@ +import { notFound } from 'next/navigation'; + +import featureFlagsConfig from '~/config/feature-flags.config'; + +function OrganizationAccountBillingLayout(props: React.PropsWithChildren) { + const isEnabled = featureFlagsConfig.enableOrganizationBilling; + + if (!isEnabled) { + notFound(); + } + + return <>{props.children}; +} + +export default OrganizationAccountBillingLayout; diff --git a/apps/web/app/(dashboard)/home/[account]/billing/page.tsx b/apps/web/app/(dashboard)/home/[account]/billing/page.tsx new file mode 100644 index 000000000..999fd08ee --- /dev/null +++ b/apps/web/app/(dashboard)/home/[account]/billing/page.tsx @@ -0,0 +1,19 @@ +import { withI18n } from '~/lib/i18n/with-i18n'; + +import { PageBody, PageHeader } from '@kit/ui/page'; +import { Trans } from '@kit/ui/trans'; + +function OrganizationAccountBillingPage() { + return ( + <> + } + description={} + /> + + + + ); +} + +export default withI18n(OrganizationAccountBillingPage); diff --git a/apps/web/app/(dashboard)/home/[account]/layout.tsx b/apps/web/app/(dashboard)/home/[account]/layout.tsx new file mode 100644 index 000000000..4d67abd03 --- /dev/null +++ b/apps/web/app/(dashboard)/home/[account]/layout.tsx @@ -0,0 +1,50 @@ +import { withI18n } from '~/lib/i18n/with-i18n'; + +import { parseSidebarStateCookie } from '@kit/shared/cookies/sidebar-state.cookie'; +import { parseThemeCookie } from '@kit/shared/cookies/theme.cookie'; +import { Page } from '@kit/ui/page'; + +import { AppSidebar } from './(components)/app-sidebar'; +import { loadOrganizationWorkspace } from './(lib)/load-workspace'; + +interface Params { + account: string; +} + +async function OrganizationWorkspaceLayout({ + children, + params, +}: React.PropsWithChildren<{ + params: Params; +}>) { + const data = await loadOrganizationWorkspace(params.account); + const ui = getUIStateCookies(); + const sidebarCollapsed = ui.sidebarState === 'collapsed'; + + return ( + ({ + label: name, + value: slug, + image: picture_url, + }))} + /> + } + > + {children} + + ); +} + +export default withI18n(OrganizationWorkspaceLayout); + +function getUIStateCookies() { + return { + theme: parseThemeCookie(), + sidebarState: parseSidebarStateCookie(), + }; +} diff --git a/apps/web/app/(dashboard)/home/[account]/loading.tsx b/apps/web/app/(dashboard)/home/[account]/loading.tsx new file mode 100644 index 000000000..4ea53181d --- /dev/null +++ b/apps/web/app/(dashboard)/home/[account]/loading.tsx @@ -0,0 +1,3 @@ +import { GlobalLoader } from '@kit/ui/global-loader'; + +export default GlobalLoader; diff --git a/apps/web/app/(dashboard)/home/[account]/members/page.tsx b/apps/web/app/(dashboard)/home/[account]/members/page.tsx new file mode 100644 index 000000000..49d8a4259 --- /dev/null +++ b/apps/web/app/(dashboard)/home/[account]/members/page.tsx @@ -0,0 +1,144 @@ +import { PlusCircledIcon } from '@radix-ui/react-icons'; +import { loadOrganizationWorkspace } from '~/(dashboard)/home/[account]/(lib)/load-workspace'; +import { withI18n } from '~/lib/i18n/with-i18n'; + +import { getSupabaseServerComponentClient } from '@kit/supabase/server-component-client'; +import { + AccountInvitationsTable, + AccountMembersTable, + InviteMembersDialogContainer, +} from '@kit/team-accounts/components'; +import { Button } from '@kit/ui/button'; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from '@kit/ui/card'; +import { PageBody, PageHeader } from '@kit/ui/page'; +import { Trans } from '@kit/ui/trans'; + +interface Params { + params: { + account: string; + }; +} + +async function loadAccountMembers(account: string) { + const client = getSupabaseServerComponentClient(); + + const { data, error } = await client.rpc('get_account_members', { + account_slug: account, + }); + + if (error) { + console.error(error); + throw error; + } + + return data ?? []; +} + +async function loadInvitations(account: string) { + const client = getSupabaseServerComponentClient(); + + const { data, error } = await client.rpc('get_account_invitations', { + account_slug: account, + }); + + if (error) { + console.error(error); + throw error; + } + + return data ?? []; +} + +async function OrganizationAccountMembersPage({ params }: Params) { + const slug = params.account; + + const [{ account, user }, members, invitations] = await Promise.all([ + loadOrganizationWorkspace(slug), + loadAccountMembers(slug), + loadInvitations(slug), + ]); + + const canManageRoles = account.permissions.includes('roles.manage'); + const isPrimaryOwner = account.primary_owner_user_id === user.id; + + const permissions = { + canUpdateRole: canManageRoles, + canRemoveFromAccount: canManageRoles, + canTransferOwnership: isPrimaryOwner, + }; + + return ( + <> + } + description={} + /> + + +
    + + +
    + + + + + + Here you can manage the members of your organization. + +
    + + + + +
    + + + + +
    + + + +
    + Pending Invitations + + + Here you can manage the pending invitations to your + organization. + +
    +
    + + + + +
    +
    +
    + + ); +} + +export default withI18n(OrganizationAccountMembersPage); diff --git a/apps/web/app/(dashboard)/home/[account]/page.tsx b/apps/web/app/(dashboard)/home/[account]/page.tsx new file mode 100644 index 000000000..64332296d --- /dev/null +++ b/apps/web/app/(dashboard)/home/[account]/page.tsx @@ -0,0 +1,64 @@ +import loadDynamic from 'next/dynamic'; + +import { PlusIcon } from 'lucide-react'; +import { AppHeader } from '~/(dashboard)/home/[account]/(components)/app-header'; +import { withI18n } from '~/lib/i18n/with-i18n'; + +import { Button } from '@kit/ui/button'; +import { PageBody } from '@kit/ui/page'; +import Spinner from '@kit/ui/spinner'; +import { Trans } from '@kit/ui/trans'; + +const DashboardDemo = loadDynamic( + () => import('~/(dashboard)/home/[account]/(components)/dashboard-demo'), + { + ssr: false, + loading: () => ( +
    + + +
    + +
    +
    + ), + }, +); + +export const metadata = { + title: 'Organization Account Home', +}; + +function OrganizationAccountHomePage({ + params, +}: { + params: { + account: string; + }; +}) { + return ( + <> + } + description={} + account={params.account} + > + + + + + + + + ); +} + +export default withI18n(OrganizationAccountHomePage); diff --git a/apps/web/app/(dashboard)/home/[account]/settings/loading.tsx b/apps/web/app/(dashboard)/home/[account]/settings/loading.tsx new file mode 100644 index 000000000..4ea53181d --- /dev/null +++ b/apps/web/app/(dashboard)/home/[account]/settings/loading.tsx @@ -0,0 +1,3 @@ +import { GlobalLoader } from '@kit/ui/global-loader'; + +export default GlobalLoader; diff --git a/apps/web/app/(dashboard)/home/[account]/settings/page.tsx b/apps/web/app/(dashboard)/home/[account]/settings/page.tsx new file mode 100644 index 000000000..0d80b9653 --- /dev/null +++ b/apps/web/app/(dashboard)/home/[account]/settings/page.tsx @@ -0,0 +1,87 @@ +import { use } from 'react'; + +import { loadOrganizationWorkspace } from '~/(dashboard)/home/[account]/(lib)/load-workspace'; +import featureFlagsConfig from '~/config/feature-flags.config'; +import { withI18n } from '~/lib/i18n/with-i18n'; + +import { + TeamAccountDangerZone, + UpdateOrganizationForm, +} from '@kit/team-accounts/components'; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from '@kit/ui/card'; +import { If } from '@kit/ui/if'; +import { PageBody, PageHeader } from '@kit/ui/page'; +import { Trans } from '@kit/ui/trans'; + +export const metadata = { + title: 'Organization Settings', +}; + +const allowOrganizationDelete = featureFlagsConfig.enableOrganizationDeletion; + +interface Params { + params: { + account: string; + }; +} + +function OrganizationSettingsPage({ params }: Params) { + const { account, user } = use(loadOrganizationWorkspace(params.account)); + + return ( + <> + } + description={} + /> + + +
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    +
    + + ); +} + +export default withI18n(OrganizationSettingsPage); diff --git a/apps/web/app/(dashboard)/home/[account]/settings/subscription/components/billing-redirect-button.tsx b/apps/web/app/(dashboard)/home/[account]/settings/subscription/components/billing-redirect-button.tsx new file mode 100644 index 000000000..d8e6fce0e --- /dev/null +++ b/apps/web/app/(dashboard)/home/[account]/settings/subscription/components/billing-redirect-button.tsx @@ -0,0 +1,32 @@ +'use client'; + +import { ArrowUpRightIcon } from 'lucide-react'; + +import { Button } from '@kit/ui/button'; + +export function BillingPortalRedirectButton({ + children, + customerId, + className, +}: React.PropsWithChildren<{ + customerId: string; + className?: string; +}>) { + return ( +
    + + + +
    + ); +} diff --git a/apps/web/app/(dashboard)/home/[account]/settings/subscription/components/checkout-redirect-button.tsx b/apps/web/app/(dashboard)/home/[account]/settings/subscription/components/checkout-redirect-button.tsx new file mode 100644 index 000000000..17d340d3b --- /dev/null +++ b/apps/web/app/(dashboard)/home/[account]/settings/subscription/components/checkout-redirect-button.tsx @@ -0,0 +1,101 @@ +'use client'; + +import { useEffect } from 'react'; + +import { useFormState, useFormStatus } from 'react-dom'; + +import { ChevronRightIcon } from 'lucide-react'; + +import { isBrowser } from '@kit/shared/utils'; +import { Button } from '@kit/ui/button'; +import { cn } from '@kit/ui/utils'; + +export function CheckoutRedirectButton({ + children, + onCheckoutCreated, + ...props +}): React.PropsWithChildren<{ + disabled?: boolean; + stripePriceId?: string; + recommended?: boolean; + organizationUid: string; + onCheckoutCreated?: (clientSecret: string) => void; +}> { + const [state, formAction] = useFormState(createCheckoutAction, { + clientSecret: '', + }); + + useEffect(() => { + if (state.clientSecret && onCheckoutCreated) { + onCheckoutCreated(state.clientSecret); + } + }, [state.clientSecret, onCheckoutCreated]); + + return ( +
    + + + + {children} + + + ); +} + +function SubmitCheckoutButton( + props: React.PropsWithChildren<{ + recommended?: boolean; + disabled?: boolean; + }>, +) { + const { pending } = useFormStatus(); + + return ( + + ); +} + +function CheckoutFormData( + props: React.PropsWithChildren<{ + organizationUid: string | undefined; + priceId: string | undefined; + }>, +) { + return ( + <> + + + + + + ); +} + +function getReturnUrl() { + return isBrowser() + ? [window.location.origin, window.location.pathname].join('') + : undefined; +} diff --git a/apps/web/app/(dashboard)/home/[account]/settings/subscription/components/embedded-stripe-checkout.tsx b/apps/web/app/(dashboard)/home/[account]/settings/subscription/components/embedded-stripe-checkout.tsx new file mode 100644 index 000000000..ee68b79cb --- /dev/null +++ b/apps/web/app/(dashboard)/home/[account]/settings/subscription/components/embedded-stripe-checkout.tsx @@ -0,0 +1,137 @@ +'use client'; + +import { useState } from 'react'; + +import { Button } from '@/components/ui/button'; +import { Dialog, DialogContent } from '@/components/ui/dialog'; +import { Close as DialogPrimitiveClose } from '@radix-ui/react-dialog'; +import { + EmbeddedCheckout, + EmbeddedCheckoutProvider, +} from '@stripe/react-stripe-js'; +import { loadStripe } from '@stripe/stripe-js'; +import { XIcon } from 'lucide-react'; + +import pricingConfig, { + StripeCheckoutDisplayMode, +} from '@/config/pricing.config'; + +import { cn } from '@/lib/utils'; + +import If from '@/components/app/If'; +import LogoImage from '@/components/app/Logo/LogoImage'; +import Trans from '@/components/app/Trans'; + +const STRIPE_PUBLISHABLE_KEY = process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY; + +if (!STRIPE_PUBLISHABLE_KEY) { + throw new Error( + 'Missing NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY environment variable. Did you forget to add it to your .env file?', + ); +} + +const stripePromise = loadStripe(STRIPE_PUBLISHABLE_KEY); + +export default function EmbeddedStripeCheckout({ + clientSecret, + onClose, +}: React.PropsWithChildren<{ + clientSecret: string; + onClose?: () => void; +}>) { + return ( + + + + + + ); +} + +function EmbeddedCheckoutPopup({ + onClose, + children, +}: React.PropsWithChildren<{ + onClose?: () => void; +}>) { + const [open, setOpen] = useState(true); + + const displayMode = pricingConfig.displayMode; + const isPopup = displayMode === StripeCheckoutDisplayMode.Popup; + const isOverlay = displayMode === StripeCheckoutDisplayMode.Overlay; + + const className = cn({ + [`bg-white p-4 max-h-[98vh] overflow-y-auto shadow-transparent border border-gray-200 dark:border-dark-700`]: + isPopup, + [`bg-background !flex flex-col flex-1 fixed top-0 !max-h-full !max-w-full left-0 w-screen h-screen border-transparent shadow-transparent py-4 px-8`]: + isOverlay, + }); + + const close = () => { + setOpen(false); + + if (onClose) { + onClose(); + } + }; + + return ( + { + if (!open && onClose) { + onClose(); + } + + setOpen(open); + }} + > + e.preventDefault()} + onInteractOutside={(e) => e.preventDefault()} + > + +
    +
    + + + +
    +
    +
    + + + + + + + +
    + {children} +
    +
    +
    + ); +} diff --git a/apps/web/app/(dashboard)/home/[account]/settings/subscription/components/plan-selection-form.tsx b/apps/web/app/(dashboard)/home/[account]/settings/subscription/components/plan-selection-form.tsx new file mode 100644 index 000000000..fd2acfcb2 --- /dev/null +++ b/apps/web/app/(dashboard)/home/[account]/settings/subscription/components/plan-selection-form.tsx @@ -0,0 +1,112 @@ +'use client'; + +import React, { useState } from 'react'; + +import dynamic from 'next/dynamic'; + +import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'; +import { Button } from '@/components/ui/button'; + +import type Organization from '@/lib/organizations/types/organization'; + +import ErrorBoundary from '@/components/app/ErrorBoundary'; +import If from '@/components/app/If'; +import PricingTable from '@/components/app/PricingTable'; +import Trans from '@/components/app/Trans'; + +import BillingPortalRedirectButton from './billing-redirect-button'; +import CheckoutRedirectButton from './checkout-redirect-button'; + +const EmbeddedStripeCheckout = dynamic( + () => import('./embedded-stripe-checkout'), + { + ssr: false, + }, +); + +const PlanSelectionForm: React.FC<{ + organization: WithId; + customerId: Maybe; +}> = ({ organization, customerId }) => { + const [clientSecret, setClientSecret] = useState(); + const [retry, setRetry] = useState(0); + + return ( +
    + + + + +
    + { + return ( + setRetry((retry) => retry + 1)} + /> + } + > + + + + + ); + }} + /> + + +
    + + + + + + + +
    +
    +
    +
    + ); +}; + +export default PlanSelectionForm; + +function NoPermissionsAlert() { + return ( + + + + + + + + + + ); +} + +function CheckoutErrorMessage({ onRetry }: { onRetry: () => void }) { + return ( +
    + + + + + +
    + ); +} diff --git a/apps/web/app/(dashboard)/home/[account]/settings/subscription/components/plan-status-alert-container.tsx b/apps/web/app/(dashboard)/home/[account]/settings/subscription/components/plan-status-alert-container.tsx new file mode 100644 index 000000000..027ae129f --- /dev/null +++ b/apps/web/app/(dashboard)/home/[account]/settings/subscription/components/plan-status-alert-container.tsx @@ -0,0 +1,99 @@ +'use client'; + +import React from 'react'; + +import type { ReadonlyURLSearchParams } from 'next/navigation'; +import { useSearchParams } from 'next/navigation'; + +import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'; + +import Trans from '@/components/app/Trans'; + +enum SubscriptionStatusQueryParams { + Success = 'success', + Cancel = 'cancel', + Error = 'error', +} + +function PlansStatusAlertContainer() { + const status = useSubscriptionStatus(); + + if (status === undefined) { + return null; + } + + return ; +} + +export default PlansStatusAlertContainer; + +function PlansStatusAlert({ + status, +}: { + status: SubscriptionStatusQueryParams; +}) { + switch (status) { + case SubscriptionStatusQueryParams.Cancel: + return ( + + + + + + + + + + ); + + case SubscriptionStatusQueryParams.Error: + return ( + + + + + + + + + + ); + + case SubscriptionStatusQueryParams.Success: + return ( + + + + + + + + + + ); + } +} + +function useSubscriptionStatus() { + const params = useSearchParams(); + + return getStatus(params); +} + +function getStatus(params: ReadonlyURLSearchParams | null) { + if (!params) { + return; + } + + const error = params.has(SubscriptionStatusQueryParams.Error); + const canceled = params.has(SubscriptionStatusQueryParams.Cancel); + const success = params.has(SubscriptionStatusQueryParams.Success); + + if (canceled) { + return SubscriptionStatusQueryParams.Cancel; + } else if (success) { + return SubscriptionStatusQueryParams.Success; + } else if (error) { + return SubscriptionStatusQueryParams.Error; + } +} diff --git a/apps/web/app/(dashboard)/home/[account]/settings/subscription/components/plans-container.tsx b/apps/web/app/(dashboard)/home/[account]/settings/subscription/components/plans-container.tsx new file mode 100644 index 000000000..9527e86a6 --- /dev/null +++ b/apps/web/app/(dashboard)/home/[account]/settings/subscription/components/plans-container.tsx @@ -0,0 +1,57 @@ +'use client'; + +import useCurrentOrganization from '@/lib/organizations/hooks/use-current-organization'; + +import If from '@/components/app/If'; +import Trans from '@/components/app/Trans'; + +import BillingPortalRedirectButton from './billing-redirect-button'; +import PlanSelectionForm from './plan-selection-form'; +import SubscriptionCard from './subscription-card'; + +const PlansContainer: React.FC = () => { + const organization = useCurrentOrganization(); + + if (!organization) { + return null; + } + + const customerId = organization.subscription?.customerId; + const subscription = organization.subscription?.data; + + if (!subscription) { + return ( + + ); + } + + return ( +
    +
    +
    +
    + +
    + + +
    +
    + + + + + + + +
    +
    +
    +
    +
    +
    + ); +}; + +export default PlansContainer; diff --git a/apps/web/app/(dashboard)/home/[account]/settings/subscription/components/subscription-card.tsx b/apps/web/app/(dashboard)/home/[account]/settings/subscription/components/subscription-card.tsx new file mode 100644 index 000000000..fb65517f7 --- /dev/null +++ b/apps/web/app/(dashboard)/home/[account]/settings/subscription/components/subscription-card.tsx @@ -0,0 +1,141 @@ +import React, { useMemo } from 'react'; + +import Heading from '@/components/ui/heading'; +import { CheckCircleIcon, XCircleIcon } from 'lucide-react'; +import { getI18n } from 'react-i18next'; +import SubscriptionStatusBadge from '~/(dashboard)/home/[account]/(components)/organizations/SubscriptionStatusBadge'; + +import pricingConfig from '@/config/pricing.config'; + +import type { OrganizationSubscription } from '@/lib/organizations/types/organization-subscription'; + +import If from '@/components/app/If'; +import PricingTable from '@/components/app/PricingTable'; +import Trans from '@/components/app/Trans'; + +import SubscriptionStatusAlert from './subscription-status-alert'; + +const SubscriptionCard: React.FC<{ + subscription: OrganizationSubscription; +}> = ({ subscription }) => { + const details = useSubscriptionDetails(subscription.priceId); + const cancelAtPeriodEnd = subscription.cancelAtPeriodEnd; + const isActive = subscription.status === 'active'; + const language = getI18n().language; + + const dates = useMemo(() => { + const endDate = new Date(subscription.periodEndsAt); + const trialEndDate = + subscription.trialEndsAt && new Date(subscription.trialEndsAt); + + return { + endDate: endDate.toLocaleDateString(language), + trialEndDate: trialEndDate + ? trialEndDate.toLocaleDateString(language) + : null, + }; + }, [language, subscription]); + + if (!details) { + return null; + } + + return ( +
    +
    +
    +
    + + + {details.product.name} + + + +
    + +
    +
    + + + {details.product.description} + +
    + + + + + + +
    + +
    + + {details.plan.price} + + + /{details.plan.name} + + +
    +
    + ); +}; + +function RenewStatusDescription( + props: React.PropsWithChildren<{ + cancelAtPeriodEnd: boolean; + dates: { + endDate: string; + trialEndDate: string | null; + }; + }>, +) { + return ( + + + + + + + + + + + + + + + + + + ); +} + +function useSubscriptionDetails(priceId: string) { + const products = pricingConfig.products; + + return useMemo(() => { + for (const product of products) { + for (const plan of product.plans) { + if (plan.stripePriceId === priceId) { + return { plan, product }; + } + } + } + }, [products, priceId]); +} + +export default SubscriptionCard; diff --git a/apps/web/app/(dashboard)/home/[account]/settings/subscription/components/subscription-status-alert.tsx b/apps/web/app/(dashboard)/home/[account]/settings/subscription/components/subscription-status-alert.tsx new file mode 100644 index 000000000..492e98364 --- /dev/null +++ b/apps/web/app/(dashboard)/home/[account]/settings/subscription/components/subscription-status-alert.tsx @@ -0,0 +1,68 @@ +import classNames from 'clsx'; + +import type { OrganizationSubscription } from '@/lib/organizations/types/organization-subscription'; + +import Trans from '@/components/app/Trans'; + +function SubscriptionStatusAlert( + props: React.PropsWithChildren<{ + subscription: OrganizationSubscription; + values: { + endDate: string; + trialEndDate: string | null; + }; + }>, +) { + const status = props.subscription.status; + + let message = ''; + let type: 'success' | 'error' | 'warn'; + + switch (status) { + case 'active': + message = 'subscription:status.active.description'; + type = 'success'; + break; + case 'trialing': + message = 'subscription:status.trialing.description'; + type = 'success'; + break; + case 'canceled': + message = 'subscription:status.canceled.description'; + type = 'warn'; + break; + case 'incomplete': + message = 'subscription:status.incomplete.description'; + type = 'warn'; + break; + case 'incomplete_expired': + message = 'subscription:status.incomplete_expired.description'; + type = 'error'; + break; + case 'unpaid': + message = 'subscription:status.unpaid.description'; + type = 'error'; + break; + case 'past_due': + message = 'subscription:status.past_due.description'; + type = 'error'; + + break; + default: + return null; + } + + return ( + + + + ); +} + +export default SubscriptionStatusAlert; diff --git a/apps/web/app/(dashboard)/home/[account]/settings/subscription/page.tsx b/apps/web/app/(dashboard)/home/[account]/settings/subscription/page.tsx new file mode 100644 index 000000000..a54ae5929 --- /dev/null +++ b/apps/web/app/(dashboard)/home/[account]/settings/subscription/page.tsx @@ -0,0 +1,34 @@ +import Heading from '@/components/ui/heading'; + +import { withI18n } from '@packages/i18n/with-i18n'; + +import Trans from '@/components/app/Trans'; + +import PlansStatusAlertContainer from './components/plan-status-alert-container'; +import PlansContainer from './components/plans-container'; + +export const metadata = { + title: 'Subscription', +}; + +const SubscriptionSettingsPage = () => { + return ( +
    +
    + + + + + + + +
    + + + + +
    + ); +}; + +export default withI18n(SubscriptionSettingsPage); diff --git a/apps/web/app/(dashboard)/home/[account]/settings/subscription/return/components/billing-session-status.tsx b/apps/web/app/(dashboard)/home/[account]/settings/subscription/return/components/billing-session-status.tsx new file mode 100644 index 000000000..16e071c72 --- /dev/null +++ b/apps/web/app/(dashboard)/home/[account]/settings/subscription/return/components/billing-session-status.tsx @@ -0,0 +1,91 @@ +'use client'; + +import Link from 'next/link'; + +import { CheckIcon, ChevronRightIcon } from 'lucide-react'; +import type { Stripe } from 'stripe'; +import pathsConfig from '~/config/paths.config'; + +import { Button } from '@kit/ui/button'; +import { Heading } from '@kit/ui/heading'; +import { Trans } from '@kit/ui/trans'; + +/** + * Retrieves the session status for a Stripe checkout session. + * Since we should only arrive here for a successful checkout, we only check + * for the `paid` status. + * + * @param {Stripe.Checkout.Session['status']} status - The status of the Stripe checkout session. + * @param {string} customerEmail - The email address of the customer associated with the session. + * + * @returns {ReactElement} - The component to render based on the session status. + */ +export function BillingSessionStatus({ + customerEmail, +}: React.PropsWithChildren<{ + status: Stripe.Checkout.Session['status']; + customerEmail: string; +}>) { + return ; +} + +function SuccessSessionStatus({ + customerEmail, +}: React.PropsWithChildren<{ + customerEmail: string; +}>) { + return ( +
    +
    + + + + + + + 🎉 + + +
    +

    + +

    +
    + + +
    +
    + ); +} diff --git a/apps/web/app/(dashboard)/home/[account]/settings/subscription/return/components/recover-checkout.tsx b/apps/web/app/(dashboard)/home/[account]/settings/subscription/return/components/recover-checkout.tsx new file mode 100644 index 000000000..03fbff1a6 --- /dev/null +++ b/apps/web/app/(dashboard)/home/[account]/settings/subscription/return/components/recover-checkout.tsx @@ -0,0 +1,26 @@ +import dynamic from 'next/dynamic'; +import { useRouter } from 'next/navigation'; + +const EmbeddedStripeCheckout = dynamic( + () => { + return import('../../components/embedded-stripe-checkout'); + }, + { + ssr: false, + }, +); + +function RecoverCheckout({ clientSecret }: { clientSecret: string }) { + const router = useRouter(); + + return ( + { + return router.replace('/settings/subscription'); + }} + /> + ); +} + +export default RecoverCheckout; diff --git a/apps/web/app/(dashboard)/home/[account]/settings/subscription/return/page.tsx b/apps/web/app/(dashboard)/home/[account]/settings/subscription/return/page.tsx new file mode 100644 index 000000000..30f51601f --- /dev/null +++ b/apps/web/app/(dashboard)/home/[account]/settings/subscription/return/page.tsx @@ -0,0 +1,81 @@ +import { notFound, redirect } from 'next/navigation'; + +import { withI18n } from '@packages/i18n/with-i18n'; +import getSupabaseServerComponentClient from '@packages/supabase/server-component-client'; + +import createStripeClient from '@kit/stripe/get-stripe'; + +import requireSession from '@/lib/user/require-session'; + +import { BillingSessionStatus } from './components/billing-session-status'; +import RecoverCheckout from './components/recover-checkout'; + +interface SessionPageProps { + searchParams: { + session_id: string; + }; +} + +async function ReturnStripeSessionPage({ searchParams }: SessionPageProps) { + const { status, customerEmail, clientSecret } = await loadStripeSession( + searchParams.session_id, + ); + + if (clientSecret) { + return ; + } + + return ( + <> +
    + +
    + +
    + + ); +} + +export default withI18n(ReturnStripeSessionPage); + +export async function loadStripeSession(sessionId: string) { + await requireSession(getSupabaseServerComponentClient()); + + // now we fetch the session from Stripe + // and check if it's still open + const stripe = await createStripeClient(); + + const session = await stripe.checkout.sessions + .retrieve(sessionId) + .catch(() => undefined); + + if (!session) { + notFound(); + } + + const isSessionOpen = session.status === 'open'; + const clientSecret = isSessionOpen ? session.client_secret : null; + const isEmbeddedMode = session.ui_mode === 'embedded'; + + // if the session is still open, we redirect the user to the checkout page + // in Stripe self hosted mode + if (isSessionOpen && !isEmbeddedMode && session.url) { + redirect(session.url); + } + + // otherwise - we show the user the return page + // and display the details of the session + return { + status: session.status, + customerEmail: session.customer_details?.email, + clientSecret, + }; +} diff --git a/apps/web/app/(dashboard)/home/components/home-sidebar-account-selector.tsx b/apps/web/app/(dashboard)/home/components/home-sidebar-account-selector.tsx new file mode 100644 index 000000000..693284ada --- /dev/null +++ b/apps/web/app/(dashboard)/home/components/home-sidebar-account-selector.tsx @@ -0,0 +1,39 @@ +'use client'; + +import { useRouter } from 'next/navigation'; + +import featureFlagsConfig from '~/config/feature-flags.config'; +import pathsConfig from '~/config/paths.config'; + +import { AccountSelector } from '@kit/accounts/account-selector'; + +const features = { + enableOrganizationAccounts: featureFlagsConfig.enableOrganizationAccounts, + enableOrganizationCreation: featureFlagsConfig.enableOrganizationCreation, +}; + +export function HomeSidebarAccountSelector(props: { + accounts: Array<{ + label: string | null; + value: string | null; + image: string | null; + }>; + + collapsed: boolean; +}) { + const router = useRouter(); + + return ( + { + if (value) { + const path = pathsConfig.app.accountHome.replace('[account]', value); + router.replace(path); + } + }} + /> + ); +} diff --git a/apps/web/app/(dashboard)/home/components/home-sidebar.tsx b/apps/web/app/(dashboard)/home/components/home-sidebar.tsx new file mode 100644 index 000000000..be4986139 --- /dev/null +++ b/apps/web/app/(dashboard)/home/components/home-sidebar.tsx @@ -0,0 +1,57 @@ +import { use } from 'react'; + +import { cookies } from 'next/headers'; + +import { HomeSidebarAccountSelector } from '~/(dashboard)/home/components/home-sidebar-account-selector'; +import { ProfileDropdownContainer } from '~/(dashboard)/home/components/personal-account-dropdown'; +import { personalAccountSidebarConfig } from '~/config/personal-account-sidebar.config'; + +import { getSupabaseServerComponentClient } from '@kit/supabase/server-component-client'; +import { Sidebar, SidebarContent, SidebarNavigation } from '@kit/ui/sidebar'; + +export function HomeSidebar() { + const collapsed = getSidebarCollapsed(); + const accounts = use(loadUserAccounts()); + + return ( + + + + + + + + + +
    + + + +
    +
    + ); +} + +function getSidebarCollapsed() { + return cookies().get('sidebar-collapsed')?.value === 'true'; +} + +async function loadUserAccounts() { + const client = getSupabaseServerComponentClient(); + + const { data: accounts, error } = await client + .from('user_accounts') + .select('*'); + + if (error) { + throw error; + } + + return accounts.map(({ name, slug, picture_url }) => { + return { + label: name, + value: slug, + image: picture_url, + }; + }); +} diff --git a/apps/web/app/(dashboard)/home/components/personal-account-dropdown.tsx b/apps/web/app/(dashboard)/home/components/personal-account-dropdown.tsx new file mode 100644 index 000000000..9c5870ea1 --- /dev/null +++ b/apps/web/app/(dashboard)/home/components/personal-account-dropdown.tsx @@ -0,0 +1,27 @@ +'use client'; + +import pathsConfig from '~/config/paths.config'; + +import { PersonalAccountDropdown } from '@kit/accounts/personal-account-dropdown'; +import { useSignOut } from '@kit/supabase/hooks/use-sign-out'; +import { useUserSession } from '@kit/supabase/hooks/use-user-session'; + +export function ProfileDropdownContainer(props: { collapsed: boolean }) { + const userSession = useUserSession(); + const signOut = useSignOut(); + const session = userSession?.data ?? undefined; + + return ( +
    + signOut.mutateAsync()} + /> +
    + ); +} diff --git a/apps/web/app/(dashboard)/home/loading.tsx b/apps/web/app/(dashboard)/home/loading.tsx new file mode 100644 index 000000000..4ea53181d --- /dev/null +++ b/apps/web/app/(dashboard)/home/loading.tsx @@ -0,0 +1,3 @@ +import { GlobalLoader } from '@kit/ui/global-loader'; + +export default GlobalLoader; diff --git a/apps/web/app/(marketing)/about/page.tsx b/apps/web/app/(marketing)/about/page.tsx new file mode 100644 index 000000000..703fc95c0 --- /dev/null +++ b/apps/web/app/(marketing)/about/page.tsx @@ -0,0 +1,93 @@ +import { Heading } from '@kit/ui/heading'; + +export const metadata = { + title: 'About', +}; + +const AboutPage = () => { + return ( +
    +
    +
    +
    + About us + + + We are a team of passionate developers and designers who love to + build great products. + +
    + +
    +
    + We are a team of visionaries, dreamers, and doers who are on a + mission to change the world for the better +
    + +
    + With a passion for innovation and a commitment to excellence, we + are dedicated to creating products and services that will improve + people's lives and make a positive impact on society. +
    + +
    + It all started with a simple idea: to use technology to solve some + of the biggest challenges facing humanity. We realized that with + the right team and the right approach, we could make a difference + and leave a lasting legacy. And so, with a lot of hard work and + determination, we set out on a journey to turn our vision into + reality. +
    + +
    + Today, we are proud to be a leader in our field, and our products + and services are used by millions of people all over the world. + But we're not done yet. We still have big dreams and even + bigger plans, and we're always looking for ways to push the + boundaries of what's possible. +
    + +
    + Our Values: At the heart of everything we do is a set of core + values that guide us in all that we do. These values are what make + us who we are, and they are what set us apart from the rest. +
    + +
    +
      +
    • + Innovation: We are always looking for new and better ways to + do things. +
    • + +
    • + Excellence: We strive for excellence in all that we do, and we + never settle for less. +
    • + +
    • + Responsibility: We take our responsibilities seriously, and we + always act with integrity. +
    • + +
    • + Collaboration: We believe that by working together, we can + achieve more than we can on our own. +
    • +
    +
    + +
    Yes, this was generated with ChatGPT
    +
    +
    +
    +
    + ); +}; + +export default AboutPage; diff --git a/apps/web/app/(marketing)/blog/[slug]/page.tsx b/apps/web/app/(marketing)/blog/[slug]/page.tsx new file mode 100644 index 000000000..55f6a2b88 --- /dev/null +++ b/apps/web/app/(marketing)/blog/[slug]/page.tsx @@ -0,0 +1,70 @@ +import type { Metadata } from 'next'; + +import { notFound } from 'next/navigation'; +import Script from 'next/script'; + +import { allPosts } from 'contentlayer/generated'; +import appConfig from '~/config/app.config'; +import { withI18n } from '~/lib/i18n/with-i18n'; + +import Post from '../components/post'; + +export async function generateMetadata({ + params, +}: { + params: { slug: string }; +}): Promise { + const post = allPosts.find((post) => post.slug === params.slug); + + if (!post) { + return; + } + + const { title, date, description, image, slug } = post; + const url = [appConfig.url, 'blog', slug].join('/'); + + return { + title, + description, + openGraph: { + title, + description, + type: 'article', + publishedTime: date, + url, + images: image + ? [ + { + url: image, + }, + ] + : [], + }, + twitter: { + card: 'summary_large_image', + title, + description, + images: image ? [image] : [], + }, + }; +} + +async function BlogPost({ params }: { params: { slug: string } }) { + const post = allPosts.find((post) => post.slug === params.slug); + + if (!post) { + notFound(); + } + + return ( +
    + + + +
    + ); +} + +export default withI18n(BlogPost); diff --git a/apps/web/app/(marketing)/blog/components/cover-image.tsx b/apps/web/app/(marketing)/blog/components/cover-image.tsx new file mode 100644 index 000000000..d924df05f --- /dev/null +++ b/apps/web/app/(marketing)/blog/components/cover-image.tsx @@ -0,0 +1,33 @@ +import Image from 'next/image'; + +import { cn } from '@kit/ui/utils'; + +type Props = { + title: string; + src: string; + preloadImage?: boolean; + className?: string; +}; + +export const CoverImage: React.FC = ({ + title, + src, + preloadImage, + className, +}) => { + return ( + {`Cover + ); +}; diff --git a/apps/web/app/(marketing)/blog/components/date-formatter.tsx b/apps/web/app/(marketing)/blog/components/date-formatter.tsx new file mode 100644 index 000000000..d561532d2 --- /dev/null +++ b/apps/web/app/(marketing)/blog/components/date-formatter.tsx @@ -0,0 +1,11 @@ +import { format, parseISO } from 'date-fns'; + +type Props = { + dateString: string; +}; + +export const DateFormatter = ({ dateString }: Props) => { + const date = parseISO(dateString); + + return ; +}; diff --git a/apps/web/app/(marketing)/blog/components/draft-post-badge.tsx b/apps/web/app/(marketing)/blog/components/draft-post-badge.tsx new file mode 100644 index 000000000..4044fb46b --- /dev/null +++ b/apps/web/app/(marketing)/blog/components/draft-post-badge.tsx @@ -0,0 +1,9 @@ +import React from 'react'; + +export function DraftPostBadge({ children }: React.PropsWithChildren) { + return ( + + {children} + + ); +} diff --git a/apps/web/app/(marketing)/blog/components/post-header.tsx b/apps/web/app/(marketing)/blog/components/post-header.tsx new file mode 100644 index 000000000..5247073ad --- /dev/null +++ b/apps/web/app/(marketing)/blog/components/post-header.tsx @@ -0,0 +1,56 @@ +import type { Post } from 'contentlayer/generated'; +import { CoverImage } from '~/(marketing)/blog/components/cover-image'; +import { DateFormatter } from '~/(marketing)/blog/components/date-formatter'; + +import { Heading } from '@kit/ui/heading'; +import { If } from '@kit/ui/if'; + +const PostHeader: React.FC<{ + post: Post; +}> = ({ post }) => { + const { title, date, readingTime, description, image } = post; + + // NB: change this to display the post's image + const displayImage = true; + const preloadImage = true; + + return ( +
    +
    + {title} + + + + {description} + + +
    + +
    +
    +
    + +
    + + · + {readingTime} minutes reading +
    +
    + + + {(imageUrl) => ( +
    + +
    + )} +
    +
    + ); +}; + +export default PostHeader; diff --git a/apps/web/app/(marketing)/blog/components/post-preview.tsx b/apps/web/app/(marketing)/blog/components/post-preview.tsx new file mode 100644 index 000000000..a151f1056 --- /dev/null +++ b/apps/web/app/(marketing)/blog/components/post-preview.tsx @@ -0,0 +1,70 @@ +import Link from 'next/link'; + +import type { Post } from 'contentlayer/generated'; +import { CoverImage } from '~/(marketing)/blog/components/cover-image'; +import { DateFormatter } from '~/(marketing)/blog/components/date-formatter'; + +import { If } from '@kit/ui/if'; + +type Props = { + post: Post; + preloadImage?: boolean; + imageHeight?: string | number; +}; + +const DEFAULT_IMAGE_HEIGHT = 250; + +function PostPreview({ + post, + preloadImage, + imageHeight, +}: React.PropsWithChildren) { + const { title, image, date, readingTime, description } = post; + const height = imageHeight ?? DEFAULT_IMAGE_HEIGHT; + + return ( +
    + + {(imageUrl) => ( +
    + + + +
    + )} +
    + +
    +
    +

    + + {title} + +

    +
    + +
    +
    + +
    + + · + + + {readingTime} mins reading + +
    + +

    + {description} +

    +
    +
    + ); +} + +export default PostPreview; diff --git a/apps/web/app/(marketing)/blog/components/post.tsx b/apps/web/app/(marketing)/blog/components/post.tsx new file mode 100644 index 000000000..54135fb19 --- /dev/null +++ b/apps/web/app/(marketing)/blog/components/post.tsx @@ -0,0 +1,24 @@ +import React from 'react'; + +import type { Post as PostType } from 'contentlayer/generated'; + +import { Mdx } from '@kit/ui/mdx'; + +import PostHeader from './post-header'; + +export const Post: React.FC<{ + post: PostType; + content: string; +}> = ({ post, content }) => { + return ( +
    + + +
    + +
    +
    + ); +}; + +export default Post; diff --git a/apps/web/app/(marketing)/blog/page.tsx b/apps/web/app/(marketing)/blog/page.tsx new file mode 100644 index 000000000..38f1976f4 --- /dev/null +++ b/apps/web/app/(marketing)/blog/page.tsx @@ -0,0 +1,41 @@ +import type { Metadata } from 'next'; + +import { allPosts } from 'contentlayer/generated'; +import PostPreview from '~/(marketing)/blog/components/post-preview'; +import { SitePageHeader } from '~/(marketing)/components/site-page-header'; +import appConfig from '~/config/app.config'; +import { withI18n } from '~/lib/i18n/with-i18n'; + +import { GridList } from '../components/grid-list'; + +export const metadata: Metadata = { + title: `Blog - ${appConfig.name}`, + description: `Tutorials, Guides and Updates from our team`, +}; + +async function BlogPage() { + const livePosts = allPosts.filter((post) => { + const isProduction = appConfig.production; + + return isProduction ? post.live : true; + }); + + return ( +
    +
    + + + + {livePosts.map((post, idx) => { + return ; + })} + +
    +
    + ); +} + +export default withI18n(BlogPage); diff --git a/apps/web/app/(marketing)/components/grid-list.tsx b/apps/web/app/(marketing)/components/grid-list.tsx new file mode 100644 index 000000000..a8421bf4b --- /dev/null +++ b/apps/web/app/(marketing)/components/grid-list.tsx @@ -0,0 +1,7 @@ +export function GridList({ children }: React.PropsWithChildren) { + return ( +
    + {children} +
    + ); +} diff --git a/apps/web/app/(marketing)/components/site-footer.tsx b/apps/web/app/(marketing)/components/site-footer.tsx new file mode 100644 index 000000000..99bb86119 --- /dev/null +++ b/apps/web/app/(marketing)/components/site-footer.tsx @@ -0,0 +1,132 @@ +import Link from 'next/link'; + +import { AppLogo } from '~/components/app-logo'; +import appConfig from '~/config/app.config'; + +const YEAR = new Date().getFullYear(); + +export function SiteFooter() { + return ( +
    +
    +
    +
    +
    +
    + +
    + +
    +

    + Add a short tagline about your product +

    +
    + +
    +

    + © Copyright {YEAR} {appConfig.name}. All Rights Reserved. +

    +
    +
    +
    + +
    +
    +
    + About + + + + Who we are + + + Blog + + + Contact + + +
    +
    + +
    +
    + Product + + + + Documentation + + + Help Center + + + Changelog + + +
    +
    + +
    +
    + Legal + + + + Terms of Service + + + Privacy Policy + + + Cookie Policy + + +
    +
    +
    +
    +
    +
    + ); +} + +function FooterSectionHeading(props: React.PropsWithChildren) { + return ( +

    + {props.children} +

    + ); +} + +function FooterSectionList(props: React.PropsWithChildren) { + return ( +
      + {props.children} +
    + ); +} + +function FooterLink(props: React.PropsWithChildren) { + return ( +
  • a]:transition-colors [&>a]:hover:text-gray-800' + + ' dark:[&>a]:hover:text-white' + } + > + {props.children} +
  • + ); +} diff --git a/apps/web/app/(marketing)/components/site-header-account-section.tsx b/apps/web/app/(marketing)/components/site-header-account-section.tsx new file mode 100644 index 000000000..1d8d3af28 --- /dev/null +++ b/apps/web/app/(marketing)/components/site-header-account-section.tsx @@ -0,0 +1,47 @@ +'use client'; + +import Link from 'next/link'; + +import { ChevronRightIcon } from 'lucide-react'; +import pathsConfig from '~/config/paths.config'; + +import { PersonalAccountDropdown } from '@kit/accounts/personal-account-dropdown'; +import { useSignOut } from '@kit/supabase/hooks/use-sign-out'; +import { useUserSession } from '@kit/supabase/hooks/use-user-session'; +import { Button } from '@kit/ui/button'; + +export function SiteHeaderAccountSection() { + const signOut = useSignOut(); + const userSession = useUserSession(); + + if (userSession.data) { + return ( + signOut.mutateAsync()} + /> + ); + } + + return ; +} + +function AuthButtons() { + return ( +
    + + + + + +
    + ); +} diff --git a/apps/web/app/(marketing)/components/site-header.tsx b/apps/web/app/(marketing)/components/site-header.tsx new file mode 100644 index 000000000..47426d6ef --- /dev/null +++ b/apps/web/app/(marketing)/components/site-header.tsx @@ -0,0 +1,29 @@ +import { SiteHeaderAccountSection } from '~/(marketing)/components/site-header-account-section'; +import { SiteNavigation } from '~/(marketing)/components/site-navigation'; +import { AppLogo } from '~/components/app-logo'; + +export function SiteHeader() { + return ( +
    +
    +
    + +
    + +
    + +
    + +
    +
    + + + +
    + +
    +
    +
    +
    + ); +} diff --git a/apps/web/app/(marketing)/components/site-navigation.tsx b/apps/web/app/(marketing)/components/site-navigation.tsx new file mode 100644 index 000000000..4fe26e15b --- /dev/null +++ b/apps/web/app/(marketing)/components/site-navigation.tsx @@ -0,0 +1,102 @@ +import Link from 'next/link'; + +import { MenuIcon } from 'lucide-react'; + +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from '@kit/ui/dropdown-menu'; +import { + NavigationMenu, + NavigationMenuItem, + NavigationMenuList, +} from '@kit/ui/navigation-menu'; + +const links = { + SignIn: { + label: 'Sign In', + path: '/auth/sign-in', + }, + Blog: { + label: 'Blog', + path: '/blog', + }, + Docs: { + label: 'Documentation', + path: '/docs', + }, + Pricing: { + label: 'Pricing', + path: '/pricing', + }, + FAQ: { + label: 'FAQ', + path: '/faq', + }, +}; + +export function SiteNavigation() { + const className = `hover:underline text-sm`; + + return ( + <> +
    + + + + + {links.Blog.label} + + + + + {links.Docs.label} + + + + + {links.Pricing.label} + + + + + + {links.FAQ.label} + + + + +
    + +
    + +
    + + ); +} + +function MobileDropdown() { + return ( + + + + + + + {Object.values(links).map((item) => { + const className = 'flex w-full h-full items-center'; + + return ( + + + {item.label} + + + ); + })} + + + ); +} diff --git a/apps/web/app/(marketing)/components/site-page-header.tsx b/apps/web/app/(marketing)/components/site-page-header.tsx new file mode 100644 index 000000000..b3b4a6d85 --- /dev/null +++ b/apps/web/app/(marketing)/components/site-page-header.tsx @@ -0,0 +1,20 @@ +import { Heading } from '@kit/ui/heading'; +import { cn } from '@kit/ui/utils'; + +export function SitePageHeader(props: { + title: string; + subtitle: string; + className?: string; +}) { + return ( +
    + {props.title} + + + {props.subtitle} + +
    + ); +} diff --git a/apps/web/app/(marketing)/docs/[...slug]/page.tsx b/apps/web/app/(marketing)/docs/[...slug]/page.tsx new file mode 100644 index 000000000..5d9c4f567 --- /dev/null +++ b/apps/web/app/(marketing)/docs/[...slug]/page.tsx @@ -0,0 +1,101 @@ +import { cache } from 'react'; + +import { notFound } from 'next/navigation'; + +import { allDocumentationPages } from 'contentlayer/generated'; +import { ChevronLeftIcon, ChevronRightIcon } from 'lucide-react'; +import { SitePageHeader } from '~/(marketing)/components/site-page-header'; +import { DocsCards } from '~/(marketing)/docs/components/docs-cards'; +import { DocumentationPageLink } from '~/(marketing)/docs/components/documentation-page-link'; +import { getDocumentationPageTree } from '~/(marketing)/docs/utils/get-documentation-page-tree'; +import { withI18n } from '~/lib/i18n/with-i18n'; + +import { If } from '@kit/ui/if'; +import { Mdx } from '@kit/ui/mdx'; + +const getPageBySlug = cache((slug: string) => { + return allDocumentationPages.find((post) => post.resolvedPath === slug); +}); + +interface PageParams { + params: { + slug: string[]; + }; +} + +export const generateMetadata = ({ params }: PageParams) => { + const page = getPageBySlug(params.slug.join('/')); + + if (!page) { + notFound(); + } + + const { title, description } = page; + + return { + title, + description, + }; +}; + +function DocumentationPage({ params }: PageParams) { + const page = getPageBySlug(params.slug.join('/')); + + if (!page) { + notFound(); + } + + const { nextPage, previousPage, children } = + getDocumentationPageTree(page.resolvedPath) ?? {}; + + const description = page?.description ?? ''; + + return ( +
    +
    + + + + + + + + +
    +
    + + {(page) => ( + } + /> + )} + +
    + +
    + + {(page) => ( + } + /> + )} + +
    +
    +
    +
    + ); +} + +export default withI18n(DocumentationPage); diff --git a/apps/web/app/(marketing)/docs/components/docs-card.tsx b/apps/web/app/(marketing)/docs/components/docs-card.tsx new file mode 100644 index 000000000..e57733685 --- /dev/null +++ b/apps/web/app/(marketing)/docs/components/docs-card.tsx @@ -0,0 +1,45 @@ +import Link from 'next/link'; + +import { ChevronRightIcon } from 'lucide-react'; + +export const DocsCard: React.FC< + React.PropsWithChildren<{ + label: string; + subtitle?: string | null; + link?: { url: string; label: string }; + }> +> = ({ label, subtitle, children, link }) => { + return ( +
    +
    +

    {label}

    + + {subtitle && ( +
    +

    {subtitle}

    +
    + )} + + {children &&
    {children}
    } +
    + + {link && ( +
    + + + {link.label} + + + + +
    + )} +
    + ); +}; diff --git a/apps/web/app/(marketing)/docs/components/docs-cards.tsx b/apps/web/app/(marketing)/docs/components/docs-cards.tsx new file mode 100644 index 000000000..0f017967d --- /dev/null +++ b/apps/web/app/(marketing)/docs/components/docs-cards.tsx @@ -0,0 +1,23 @@ +import type { DocumentationPage } from 'contentlayer/generated'; + +import { DocsCard } from './docs-card'; + +export function DocsCards({ pages }: { pages: DocumentationPage[] }) { + return ( +
    + {pages.map((item) => { + return ( + + ); + })} +
    + ); +} diff --git a/apps/web/app/(marketing)/docs/components/docs-navigation.tsx b/apps/web/app/(marketing)/docs/components/docs-navigation.tsx new file mode 100644 index 000000000..b76fdefc7 --- /dev/null +++ b/apps/web/app/(marketing)/docs/components/docs-navigation.tsx @@ -0,0 +1,225 @@ +'use client'; + +import { useEffect, useMemo, useState } from 'react'; + +import Link from 'next/link'; +import { usePathname } from 'next/navigation'; + +import { ChevronDownIcon, MenuIcon } from 'lucide-react'; + +import { isBrowser } from '@kit/shared/utils'; +import { Button } from '@kit/ui/button'; +import { Heading } from '@kit/ui/heading'; +import { If } from '@kit/ui/if'; +import { cn } from '@kit/ui/utils'; + +import type { ProcessedDocumentationPage } from '../utils/build-documentation-tree'; + +const DocsNavLink: React.FC<{ + label: string; + url: string; + level: number; + activePath: string; + collapsible: boolean; + collapsed: boolean; + toggleCollapsed: () => void; +}> = ({ + label, + url, + level, + activePath, + collapsible, + collapsed, + toggleCollapsed, +}) => { + const isCurrent = url == activePath; + const isFirstLevel = level === 0; + + return ( +
    + + {label} + + + {collapsible && ( + + )} +
    + ); +}; + +const Node: React.FC<{ + node: ProcessedDocumentationPage; + level: number; + activePath: string; +}> = ({ node, level, activePath }) => { + const [collapsed, setCollapsed] = useState(node.collapsed ?? false); + const toggleCollapsed = () => setCollapsed(!collapsed); + + useEffect(() => { + if ( + activePath == node.resolvedPath || + node.children.map((_) => _.resolvedPath).includes(activePath) + ) { + setCollapsed(false); + } + }, [activePath, node.children, node.resolvedPath]); + + return ( + <> + + + {node.children.length > 0 && !collapsed && ( + + )} + + ); +}; + +function Tree({ + tree, + level, + activePath, +}: { + tree: ProcessedDocumentationPage[]; + level: number; + activePath: string; +}) { + return ( +
    0 ? 'border-l' : '')}> + {tree.map((treeNode, index) => ( + + ))} +
    + ); +} + +export default function DocsNavigation({ + tree, +}: { + tree: ProcessedDocumentationPage[]; +}) { + const activePath = usePathname().replace('/docs/', ''); + + return ( + <> + + +
    + +
    + + ); +} + +function getNavLinkClassName(isCurrent: boolean, isFirstLevel: boolean) { + return cn( + 'group flex h-8 items-center justify-between space-x-2 whitespace-nowrap rounded-md px-3 text-sm leading-none transition-colors', + { + [`bg-muted`]: isCurrent, + [`hover:bg-muted`]: !isCurrent, + [`font-semibold`]: isFirstLevel, + [`font-normal`]: !isFirstLevel && isCurrent, + [`hover:text-foreground-muted`]: !isFirstLevel && !isCurrent, + }, + ); +} + +function FloatingDocumentationNavigation({ + tree, + activePath, +}: React.PropsWithChildren<{ + tree: ProcessedDocumentationPage[]; + activePath: string; +}>) { + const body = useMemo(() => { + return isBrowser() ? document.body : null; + }, []); + + const [isVisible, setIsVisible] = useState(false); + + const enableScrolling = (element: HTMLElement) => + (element.style.overflowY = ''); + + const disableScrolling = (element: HTMLElement) => + (element.style.overflowY = 'hidden'); + + // enable/disable body scrolling when the docs are toggled + useEffect(() => { + if (!body) { + return; + } + + if (isVisible) { + disableScrolling(body); + } else { + enableScrolling(body); + } + }, [isVisible, body]); + + // hide docs when navigating to another page + useEffect(() => { + setIsVisible(false); + }, [activePath]); + + const onClick = () => { + setIsVisible(!isVisible); + }; + + return ( + <> + +
    + Table of Contents + + +
    +
    + + + + ); +} diff --git a/apps/web/app/(marketing)/docs/components/documentation-page-link.tsx b/apps/web/app/(marketing)/docs/components/documentation-page-link.tsx new file mode 100644 index 000000000..449cf9db3 --- /dev/null +++ b/apps/web/app/(marketing)/docs/components/documentation-page-link.tsx @@ -0,0 +1,47 @@ +import Link from 'next/link'; + +import type { DocumentationPage } from 'contentlayer/generated'; + +import { If } from '@kit/ui/if'; +import { cn } from '@kit/ui/utils'; + +export function DocumentationPageLink({ + page, + before, + after, +}: React.PropsWithChildren<{ + page: DocumentationPage; + before?: React.ReactNode; + after?: React.ReactNode; +}>) { + return ( + + {(node) => <>{node}} + + + + {before ? `Previous` : ``} + {after ? `Next` : ``} + + + {page.title} + + + {(node) => <>{node}} + + ); +} diff --git a/apps/web/app/(marketing)/docs/layout.tsx b/apps/web/app/(marketing)/docs/layout.tsx new file mode 100644 index 000000000..18d0c2727 --- /dev/null +++ b/apps/web/app/(marketing)/docs/layout.tsx @@ -0,0 +1,21 @@ +import type { DocumentationPage } from 'contentlayer/generated'; +import { allDocumentationPages } from 'contentlayer/generated'; + +import DocsNavigation from './components/docs-navigation'; +import { buildDocumentationTree } from './utils/build-documentation-tree'; + +function DocsLayout({ children }: React.PropsWithChildren) { + const tree = buildDocumentationTree(allDocumentationPages); + + return ( +
    +
    + + +
    {children}
    +
    +
    + ); +} + +export default DocsLayout; diff --git a/apps/web/app/(marketing)/docs/page.tsx b/apps/web/app/(marketing)/docs/page.tsx new file mode 100644 index 000000000..7338a4d84 --- /dev/null +++ b/apps/web/app/(marketing)/docs/page.tsx @@ -0,0 +1,30 @@ +import { allDocumentationPages } from 'contentlayer/generated'; +import appConfig from '~/config/app.config'; +import { withI18n } from '~/lib/i18n/with-i18n'; + +import { SitePageHeader } from '../components/site-page-header'; +import { DocsCards } from './components/docs-cards'; +import { buildDocumentationTree } from './utils/build-documentation-tree'; + +export const metadata = { + title: `Documentation - ${appConfig.name}`, +}; + +function DocsPage() { + const tree = buildDocumentationTree(allDocumentationPages); + + return ( +
    + + +
    + +
    +
    + ); +} + +export default withI18n(DocsPage); diff --git a/apps/web/app/(marketing)/docs/utils/build-documentation-tree.ts b/apps/web/app/(marketing)/docs/utils/build-documentation-tree.ts new file mode 100644 index 000000000..f0d654616 --- /dev/null +++ b/apps/web/app/(marketing)/docs/utils/build-documentation-tree.ts @@ -0,0 +1,53 @@ +import { cache } from 'react'; + +import type { DocumentationPage } from 'contentlayer/generated'; + +export interface ProcessedDocumentationPage extends DocumentationPage { + collapsible: boolean; + pathSegments: string[]; + nextPage: ProcessedDocumentationPage | DocumentationPage | null; + previousPage: ProcessedDocumentationPage | DocumentationPage | null; + children: DocsTree; +} + +export type DocsTree = ProcessedDocumentationPage[]; + +/** + * Build a tree of documentation pages from a flat list of pages with path segments + * @param docs + * @param parentPathNames + */ +export const buildDocumentationTree = cache( + (docs: DocumentationPage[], parentPathNames: string[] = []): DocsTree => { + const level = parentPathNames.length; + + const pages = docs + .filter( + (_) => + _.pathSegments.length === level + 1 && + _.pathSegments + .map(({ pathName }: { pathName: string }) => pathName) + .join('/') + .startsWith(parentPathNames.join('/')), + ) + .sort( + (a, b) => a.pathSegments[level].order - b.pathSegments[level].order, + ); + + return pages.map((doc, index) => { + const children = buildDocumentationTree( + docs, + doc.pathSegments.map(({ pathName }: { pathName: string }) => pathName), + ); + + return { + ...doc, + pathSegments: doc.pathSegments || ([] as string[]), + collapsible: children.length > 0, + nextPage: children[0] || pages[index + 1], + previousPage: pages[index - 1], + children, + }; + }); + }, +); diff --git a/apps/web/app/(marketing)/docs/utils/get-documentation-page-tree.ts b/apps/web/app/(marketing)/docs/utils/get-documentation-page-tree.ts new file mode 100644 index 000000000..ee7bc4ccc --- /dev/null +++ b/apps/web/app/(marketing)/docs/utils/get-documentation-page-tree.ts @@ -0,0 +1,45 @@ +import { cache } from 'react'; + +import type { DocumentationPage } from 'contentlayer/generated'; +import { allDocumentationPages } from 'contentlayer/generated'; + +import { buildDocumentationTree } from './build-documentation-tree'; + +/** + * Retrieves a specific documentation page from the page tree by its path. + * + * @param {string} pagePath - The path of the documentation page to retrieve. + * @returns {DocumentationPageWithChildren | undefined} The documentation page found in the tree, if any. + */ +export const getDocumentationPageTree = cache((pagePath: string) => { + const tree = buildDocumentationTree(allDocumentationPages); + + type DocumentationPageWithChildren = DocumentationPage & { + previousPage?: DocumentationPage | null; + nextPage?: DocumentationPage | null; + children?: DocumentationPage[]; + }; + + const findPageInTree = ( + pages: DocumentationPageWithChildren[], + path: string, + ): DocumentationPageWithChildren | undefined => { + for (const page of pages) { + if (page.resolvedPath === path) { + return page; + } + + const hasChildren = page.children && page.children.length > 0; + + if (hasChildren) { + const foundPage = findPageInTree(page.children ?? [], path); + + if (foundPage) { + return foundPage; + } + } + } + }; + + return findPageInTree(tree, pagePath); +}); diff --git a/apps/web/app/(marketing)/faq/page.tsx b/apps/web/app/(marketing)/faq/page.tsx new file mode 100644 index 000000000..59f9a2a57 --- /dev/null +++ b/apps/web/app/(marketing)/faq/page.tsx @@ -0,0 +1,123 @@ +import { ChevronDownIcon } from 'lucide-react'; +import { withI18n } from '~/lib/i18n/with-i18n'; + +import { SitePageHeader } from '../components/site-page-header'; + +export const metadata = { + title: 'FAQ', +}; + +const faqItems = [ + { + question: `Do you offer a free trial?`, + answer: `Yes, we offer a 14-day free trial. You can cancel at any time during the trial period and you won't be charged.`, + }, + { + question: `Can I cancel my subscription?`, + answer: `You can cancel your subscription at any time. You can do this from your account settings.`, + }, + { + question: `Where can I find my invoices?`, + answer: `You can find your invoices in your account settings.`, + }, + { + question: `What payment methods do you accept?`, + answer: `We accept all major credit cards and PayPal.`, + }, + { + question: `Can I upgrade or downgrade my plan?`, + answer: `Yes, you can upgrade or downgrade your plan at any time. You can do this from your account settings.`, + }, + { + question: `Do you offer discounts for non-profits?`, + answer: `Yes, we offer a 50% discount for non-profits. Please contact us to learn more.`, + }, +]; + +const FAQPage = () => { + const structuredData = { + '@context': 'https://schema.org', + '@type': 'FAQPage', + mainEntity: faqItems.map((item) => { + return { + '@type': 'Question', + name: item.question, + acceptedAnswer: { + '@type': 'Answer', + text: item.answer, + }, + }; + }), + }; + + return ( +
    +