This commit is contained in:
giancarlo
2024-03-24 02:23:22 +08:00
parent 648d77b430
commit bce3479368
589 changed files with 37067 additions and 9596 deletions

View File

@@ -1,4 +0,0 @@
{
"12bb71342c6255bbf50437ec8f4441c083f47cdb74bd89160c15e4f43e52a1cb": true,
"40b842e832070c58deac6aa9e08fa459302ee3f9da492c7e77d93d2fbf4a56fd": true
}

View File

@@ -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;

Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

View File

@@ -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"),
],
};
};

View File

@@ -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": {}
}
}

View File

@@ -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;

View File

@@ -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",
});

View File

@@ -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"
}

View File

@@ -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 (
<TRPCProvider>
{/*
The Stack component displays the current page.
It also allows you to configure your screens
*/}
<Stack
screenOptions={{
headerStyle: {
backgroundColor: "#f472b6",
},
}}
/>
<StatusBar />
</TRPCProvider>
);
}

View File

@@ -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 (
// <View className="flex flex-row rounded-lg bg-white/10 p-4">
// <View className="flex-grow">
// <Link
// asChild
// href={{
// pathname: "/post/[id]",
// params: { id: props.post.id },
// }}
// >
// <Pressable>
// <Text className="text-xl font-semibold text-pink-400">
// {props.post.title}
// </Text>
// <Text className="mt-2 text-white">{props.post.content}</Text>
// </Pressable>
// </Link>
// </View>
// <Pressable onPress={props.onDelete}>
// <Text className="font-bold uppercase text-pink-400">Delete</Text>
// </Pressable>
// </View>
// );
// }
// 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 (
// <View className="mt-4">
// <TextInput
// className="mb-2 rounded bg-white/10 p-2 text-white"
// placeholderTextColor="rgba(255, 255, 255, 0.5)"
// value={title}
// onChangeText={setTitle}
// placeholder="Title"
// />
// {error?.data?.zodError?.fieldErrors.title && (
// <Text className="mb-2 text-red-500">
// {error.data.zodError.fieldErrors.title}
// </Text>
// )}
// <TextInput
// className="mb-2 rounded bg-white/10 p-2 text-white"
// placeholderTextColor="rgba(255, 255, 255, 0.5)"
// value={content}
// onChangeText={setContent}
// placeholder="Content"
// />
// {error?.data?.zodError?.fieldErrors.content && (
// <Text className="mb-2 text-red-500">
// {error.data.zodError.fieldErrors.content}
// </Text>
// )}
// <Pressable
// className="rounded bg-pink-400 p-2"
// onPress={() => {
// mutate({
// title,
// content,
// });
// }}
// >
// <Text className="font-semibold text-white">Publish post</Text>
// </Pressable>
// {error?.data?.code === "UNAUTHORIZED" && (
// <Text className="mt-2 text-red-500">
// You need to be logged in to create a post
// </Text>
// )}
// </View>
// );
// }
// 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 (
// <SafeAreaView className="bg-[#1F104A]">
// {/* Changes page title visible on the header */}
// <Stack.Screen options={{ title: "Home Page" }} />
// <View className="h-full w-full p-4">
// <Text className="pb-2 text-center text-5xl font-bold text-white">
// Create <Text className="text-pink-400">T3</Text> Turbo
// </Text>
// <Button
// onPress={() => void utils.post.all.invalidate()}
// title="Refresh posts"
// color={"#f472b6"}
// />
// <View className="py-2">
// <Text className="font-semibold italic text-white">
// Press on a post
// </Text>
// </View>
// <FlashList
// data={postQuery.data}
// estimatedItemSize={20}
// ItemSeparatorComponent={() => <View className="h-2" />}
// renderItem={(p) => (
// <PostCard
// post={p.item}
// onDelete={() => deletePostMutation.mutate(p.item.id)}
// />
// )}
// />
// <CreatePost />
// </View>
// </SafeAreaView>
// );
// }

View File

@@ -1,22 +0,0 @@
// import { SafeAreaView, Text, View } from "react-native";
// import { Stack, useGlobalSearchParams } from "expo-router";
// import { api } from "~/utils/api";
// export default function Post() {
// const { id } = useGlobalSearchParams();
// if (!id || typeof id !== "string") throw new Error("unreachable");
// const { data } = api.post.byId.useQuery({ id: parseInt(id) });
// if (!data) return null;
// return (
// <SafeAreaView className="bg-[#1F104A]">
// <Stack.Screen options={{ title: data.title }} />
// <View className="h-full w-full p-4">
// <Text className="py-2 text-3xl font-bold text-white">{data.title}</Text>
// <Text className="py-4 text-white">{data.content}</Text>
// </View>
// </SafeAreaView>
// );
// }

View File

@@ -1,3 +0,0 @@
@tailwind base;
@tailwind components;
@tailwind utilities;

View File

@@ -1 +0,0 @@
/// <reference types="nativewind/types" />

View File

@@ -1,76 +0,0 @@
import { useState } from "react";
import Constants from "expo-constants";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { httpBatchLink, loggerLink } from "@trpc/client";
import { createTRPCReact } from "@trpc/react-query";
import superjson from "superjson";
import type { AppRouter } from "@acme/api";
/**
* A set of typesafe hooks for consuming your API.
*/
export const api = createTRPCReact<AppRouter>();
export { type RouterInputs, type RouterOutputs } from "@acme/api";
/**
* Extend this function when going to production by
* setting the baseUrl to your production API URL.
*/
const getBaseUrl = () => {
/**
* Gets the IP address of your host-machine. If it cannot automatically find it,
* you'll have to manually set it. NOTE: Port 3000 should work for most but confirm
* you don't have anything else running on it, or you'd have to change it.
*
* **NOTE**: This is only for development. In production, you'll want to set the
* baseUrl to your production API URL.
*/
const debuggerHost = Constants.expoConfig?.hostUri;
const localhost = debuggerHost?.split(":")[0];
if (!localhost) {
// return "https://turbo.t3.gg";
throw new Error(
"Failed to get localhost. Please point to your production server.",
);
}
return `http://${localhost}:3000`;
};
/**
* A wrapper for your app that provides the TRPC context.
* Use only in _app.tsx
*/
export function TRPCProvider(props: { children: React.ReactNode }) {
const [queryClient] = useState(() => new QueryClient());
const [trpcClient] = useState(() =>
api.createClient({
transformer: superjson, // TODO: Add transforming for Dinero.js
links: [
httpBatchLink({
url: `${getBaseUrl()}/api/trpc`,
headers() {
const headers = new Map<string, string>();
headers.set("x-trpc-source", "expo-react");
return Object.fromEntries(headers);
},
}),
loggerLink({
enabled: (opts) =>
process.env.NODE_ENV === "development" ||
(opts.direction === "down" && opts.result instanceof Error),
colorMode: "ansi",
}),
],
}),
);
return (
<api.Provider client={trpcClient} queryClient={queryClient}>
<QueryClientProvider client={queryClient}>
{props.children}
</QueryClientProvider>
</api.Provider>
);
}

View File

@@ -1,10 +0,0 @@
// @ts-expect-error - no types
import nativewind from "nativewind/preset";
import type { Config } from "tailwindcss";
import baseConfig from "@acme/tailwind-config";
export default {
content: ["./src/**/*.{ts,tsx}"],
presets: [baseConfig, nativewind],
} satisfies Config;

View File

@@ -1,21 +0,0 @@
{
"extends": "@acme/tsconfig/base.json",
"compilerOptions": {
"baseUrl": ".",
"paths": {
"~/*": ["./src/*"],
},
"jsx": "react-native",
"tsBuildInfoFile": "node_modules/.cache/tsbuildinfo.json",
"types": ["nativewind/types"],
},
"include": [
"src",
"*.ts",
"index.tsx",
"*.js",
".expo/types/**/*.ts",
"expo-env.d.ts",
],
"exclude": ["node_modules"],
}

View File

@@ -1,4 +0,0 @@
{
"typescript.tsdk": "../../node_modules/typescript/lib",
"typescript.enablePromptUseWorkspaceTsdk": true
}

View File

@@ -1,22 +0,0 @@
import "./src/env.mjs";
import "@acme/api/env";
import "@acme/stripe/env";
import withMDX from "@next/mdx";
/** @type {import("next").NextConfig} */
const config = {
reactStrictMode: true,
/** Enables hot reloading for local packages without a build step */
transpilePackages: ["@acme/api", "@acme/db", "@acme/stripe", "@acme/ui"],
pageExtensions: ["ts", "tsx", "mdx"],
experimental: {
mdxRs: true,
},
/** We already do linting and typechecking as separate tasks in CI */
eslint: { ignoreDuringBuilds: true },
typescript: { ignoreBuildErrors: true },
};
export default withMDX()(config);

View File

@@ -1,73 +0,0 @@
{
"name": "@acme/nextjs",
"version": "0.1.0",
"private": true,
"scripts": {
"build": "pnpm with-env next build",
"clean": "git clean -xdf .next .turbo node_modules",
"dev": "pnpm with-env next dev",
"lint": "next lint",
"format": "prettier --check \"**/*.{js,cjs,mjs,ts,tsx,md,json}\"",
"start": "pnpm with-env next start",
"typecheck": "tsc --noEmit",
"with-env": "dotenv -e ../../.env.local --"
},
"dependencies": {
"@acme/api": "^0.1.0",
"@acme/db": "^0.1.0",
"@acme/stripe": "^0.1.0",
"@acme/ui": "^0.1.0",
"@clerk/nextjs": "^4.29.4",
"@dinero.js/currencies": "2.0.0-alpha.14",
"@hookform/resolvers": "^3.3.4",
"@next/mdx": "^14.1.0",
"@t3-oss/env-nextjs": "^0.7.3",
"@tanstack/react-query": "^5.17.15",
"@tanstack/react-table": "^8.11.3",
"@trpc/client": "next",
"@trpc/next": "next",
"@trpc/react-query": "next",
"@trpc/server": "next",
"@vercel/analytics": "^1.1.2",
"date-fns": "^3.2.0",
"dinero.js": "2.0.0-alpha.14",
"framer-motion": "^10.18.0",
"next": "^14.1.0",
"next-themes": "^0.2.1",
"react": "18.2.0",
"react-day-picker": "^8.10.0",
"react-dom": "18.2.0",
"react-hook-form": "^7.49.2",
"react-image-crop": "^11.0.4",
"react-wrap-balancer": "^1.1.0",
"recharts": "^2.10.3",
"superjson": "2.2.1",
"tailwindcss-animate": "^1.0.7",
"zod": "^3.22.4"
},
"devDependencies": {
"@acme/eslint-config": "^0.2.0",
"@acme/prettier-config": "^0.1.0",
"@acme/tailwind-config": "^0.1.0",
"@acme/tsconfig": "^0.1.0",
"@types/mdx": "^2.0.10",
"@types/node": "^20.11.5",
"@types/react": "^18.2.48",
"@types/react-dom": "^18.2.18",
"autoprefixer": "^10.4.17",
"dotenv-cli": "^7.3.0",
"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/nextjs",
"@acme/eslint-config/react"
]
},
"prettier": "@acme/prettier-config"
}

View File

@@ -1,2 +0,0 @@
// @ts-expect-error - No types for postcss
module.exports = require("@acme/tailwind-config/postcss");

Binary file not shown.

Before

Width:  |  Height:  |  Size: 203 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 313 KiB

View File

@@ -1,13 +0,0 @@
<svg width="258" height="198" viewBox="0 0 258 198" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_1_12)">
<path d="M165.269 24.0976L188.481 -0.000411987H0V24.0976H165.269Z" fill="black"/>
<path d="M163.515 95.3516L253.556 2.71059H220.74L145.151 79.7886L163.515 95.3516Z" fill="black"/>
<path d="M233.192 130.446C233.192 154.103 214.014 173.282 190.357 173.282C171.249 173.282 155.047 160.766 149.534 143.467L146.159 132.876L126.863 152.171L128.626 156.364C138.749 180.449 162.568 197.382 190.357 197.382C227.325 197.382 257.293 167.414 257.293 130.446C257.293 105.965 243.933 84.7676 224.49 73.1186L219.929 70.3856L202.261 88.2806L210.322 92.5356C223.937 99.7236 233.192 114.009 233.192 130.446Z" fill="black"/>
<path d="M87.797 191.697V44.6736H63.699V191.697H87.797Z" fill="black"/>
</g>
<defs>
<clipPath id="clip0_1_12">
<rect width="258" height="198" fill="white"/>
</clipPath>
</defs>
</svg>

Before

Width:  |  Height:  |  Size: 923 B

View File

@@ -1,37 +0,0 @@
import Link from "next/link";
import * as Icons from "@acme/ui/icons";
import { siteConfig } from "~/app/config";
import { SiteFooter } from "~/components/footer";
export default function AuthLayout(props: { children: React.ReactNode }) {
return (
<>
<div className="relative grid min-h-screen grid-cols-1 overflow-hidden md:grid-cols-3 lg:grid-cols-2">
<div className="relative">
<div
className="absolute inset-0 bg-cover"
style={{
backgroundImage:
"url(https://images.unsplash.com/photo-1590069261209-f8e9b8642343?ixlib=rb-4.0.3&ixid=MnwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8&auto=format&fit=crop&w=1376&q=80)",
}}
/>
<div className="absolute inset-0 bg-gradient-to-t from-background to-background/60 md:to-background/40" />
<Link
href="/"
className="absolute left-8 top-8 z-20 flex items-center text-lg font-bold tracking-tight"
>
<Icons.Logo className="mr-2 h-6 w-6" />
<span>{siteConfig.name}</span>
</Link>
</div>
<div className="container absolute top-1/2 col-span-1 flex -translate-y-1/2 items-center md:static md:top-0 md:col-span-2 md:flex md:translate-y-0 lg:col-span-1">
{props.children}
</div>
</div>
<SiteFooter className="border-none" />
</>
);
}

View File

@@ -1,130 +0,0 @@
"use client";
import * as React from "react";
import { useRouter } from "next/navigation";
import { useSignIn, useSignUp } from "@clerk/nextjs";
import { Button } from "@acme/ui/button";
import * as Icons from "@acme/ui/icons";
import { Input } from "@acme/ui/input";
import { useToast } from "@acme/ui/use-toast";
export function EmailSignIn() {
const [isLoading, setIsLoading] = React.useState(false);
const { signIn, isLoaded: signInLoaded, setActive } = useSignIn();
const { signUp, isLoaded: signUpLoaded } = useSignUp();
const router = useRouter();
const { toast } = useToast();
const signInWithLink = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
const email = new FormData(e.currentTarget).get("email");
if (!signInLoaded || typeof email !== "string") return null;
// the catch here prints out the error.
// if the user doesn't exist we will return a 422 in the network response.
// so push that to the sign up.
setIsLoading(true);
await signIn
.create({
identifier: email,
})
.catch((error) => {
console.log("sign-in error", JSON.stringify(error));
});
const firstFactor = signIn.supportedFirstFactors.find(
(f) => f.strategy === "email_link",
// This cast shouldn't be necessary but because TypeScript is dumb and can't infer it.
) as { emailAddressId: string } | undefined;
if (firstFactor) {
const magicFlow = signIn.createMagicLinkFlow();
setIsLoading(false);
toast({
title: "Email Sent",
description: "Check your inbox for a verification email.",
});
const response = await magicFlow
.startMagicLinkFlow({
emailAddressId: firstFactor.emailAddressId,
redirectUrl: `${window.location.origin}/`,
})
.catch(() => {
toast({
variant: "destructive",
title: "Error",
description: "Something went wrong, please try again.",
});
});
const verification = response?.firstFactorVerification;
if (verification?.status === "expired") {
toast({
variant: "destructive",
title: "Link Expired",
description: "Link expired, please try again.",
});
}
magicFlow.cancelMagicLinkFlow();
if (response?.status === "complete") {
await setActive({ session: response.createdSessionId }).then(() =>
router.push(`/dashboard`),
);
}
} else {
if (!signUpLoaded) return null;
await signUp.create({
emailAddress: email,
});
const { startMagicLinkFlow } = signUp.createMagicLinkFlow();
setIsLoading(false);
toast({
title: "Email Sent",
description: "Check your inbox for a verification email.",
});
const response = await startMagicLinkFlow({
redirectUrl: `${window.location.origin}/`,
})
.catch(() => {
toast({
variant: "destructive",
title: "Error",
description: "Something went wrong, please try again.",
});
})
.then((res) => res);
if (response?.status === "complete") {
await setActive({ session: response.createdSessionId }).then(() =>
router.push(`/dashboard`),
);
return;
}
}
};
return (
<form className="grid gap-2" onSubmit={signInWithLink}>
<div className="grid gap-1">
<Input
name="email"
placeholder="name@example.com"
type="email"
autoCapitalize="none"
autoComplete="email"
autoCorrect="off"
className="bg-background"
/>
</div>
<Button disabled={isLoading}>
{isLoading && <Icons.Spinner className="mr-2 h-4 w-4 animate-spin" />}
Sign In with Email
</Button>
</form>
);
}

View File

@@ -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<OAuthStrategy | null>(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 (
<div className="flex flex-col gap-2">
<Button
variant="outline"
className="bg-background"
onClick={() => oauthSignIn("oauth_github")}
>
{isLoading === "oauth_github" ? (
<Icons.Spinner className="mr-2 h-4 w-4 animate-spin" />
) : (
<Icons.GitHub className="mr-2 h-4 w-4" />
)}
Github
</Button>
<Button
variant="outline"
className="bg-background"
onClick={() => oauthSignIn("oauth_google")}
>
{isLoading === "oauth_google" ? (
<Icons.Spinner className="mr-2 h-4 w-4 animate-spin" />
) : (
<Icons.Google className="mr-2 h-4 w-4" />
)}
Google
</Button>
</div>
);
}

View File

@@ -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 (
<div className="mx-auto flex w-full flex-col justify-center space-y-6 sm:w-[350px]">
<div className="flex flex-col space-y-2 text-center">
<h1 className="text-2xl font-semibold tracking-tight">
Create an account
</h1>
<p className="text-sm text-muted-foreground">
Enter your email below to create your account
</p>
</div>
<div className="grid gap-6">
<EmailSignIn />
<div className="relative">
<div className="absolute inset-0 flex items-center">
<span className="w-full border-t" />
</div>
<div className="relative flex justify-center text-xs uppercase">
<span className="bg-background px-2 text-muted-foreground">
Or continue with
</span>
</div>
</div>
<OAuthSignIn />
</div>
<p className="px-8 text-center text-sm text-muted-foreground">
By clicking continue, you agree to our{" "}
<Link
href={"/terms" as Route}
className="underline underline-offset-4 hover:text-primary"
>
Terms of Service
</Link>{" "}
and{" "}
<Link
href={"/privacy" as Route}
className="underline underline-offset-4 hover:text-primary"
>
Privacy Policy
</Link>
.
</p>
</div>
);
}

View File

@@ -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 (
<div className="mx-auto flex w-full flex-col justify-center space-y-6 sm:w-[350px]">
<div className="flex flex-col space-y-2 text-center">
<h1 className="text-2xl font-semibold tracking-tight">Sign Out</h1>
<p className="text-sm text-muted-foreground">
Are you sure you want to sign out?
</p>
<SignOutButton signOutCallback={() => router.push("/?redirect=false")}>
<Button>Confirm</Button>
</SignOutButton>
</div>
</div>
);
}

View File

@@ -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 (
<div className="flex items-center justify-center">
<Icons.Spinner className="mr-2 h-16 w-16 animate-spin" />
</div>
);
}

View File

@@ -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 (
<Card className={props.className}>
<CardHeader>
<CardTitle>{props.title}</CardTitle>
<CardDescription>{props.description}</CardDescription>
</CardHeader>
<CardContent className="flex items-center justify-center">
<Icons.Spinner className="m-6 h-16 w-16 animate-spin" />
</CardContent>
</Card>
);
}

View File

@@ -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 (
<ResponsiveContainer width="100%" height={400}>
<BarChart data={data}>
<XAxis
dataKey="name"
stroke="#888888"
fontSize={12}
tickLine={false}
axisLine={false}
/>
<YAxis
stroke="#888888"
fontSize={12}
tickLine={false}
axisLine={false}
tickFormatter={(value) => `$${value}`}
/>
<Bar dataKey="total" fill="#adfa1d" radius={[4, 4, 0, 0]} />
</BarChart>
</ResponsiveContainer>
);
}

View File

@@ -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<ApiKeyColumn>();
const columns = [
columnHelper.display({
id: "select",
header: ({ table }) => (
<Checkbox
checked={table.getIsAllRowsSelected()}
disabled={
table.getRowModel().rows.length === 0 ||
table
.getRowModel()
.rows.every((row) => row.original.revokedAt !== null)
}
onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)}
aria-label="Select all"
/>
),
cell: ({ row }) => (
<Checkbox
checked={row.getIsSelected()}
disabled={row.original.revokedAt !== null}
onCheckedChange={(value) => 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 (
<div className="flex items-center justify-between">
<span
className={cn(
"font-mono",
t.row.original.revokedAt !== null && "line-through",
)}
>
{displayText}
</span>
<div className="invisible flex items-center gap-2 group-hover:visible">
<Button
variant="ghost"
className="h-4 w-4 p-0 opacity-50"
disabled={show}
onClick={() => {
setShow(true);
setTimeout(() => {
setShow(false);
}, 1500);
}}
>
<span className="sr-only">Toggle key visibility</span>
{show ? <EyeOff /> : <Eye />}
</Button>
<Button
variant="ghost"
className="h-4 w-4 p-0 opacity-50"
onClick={async () => {
setCopied(true);
await Promise.all([
navigator.clipboard.writeText(key),
new Promise((resolve) => setTimeout(resolve, 1500)),
]);
setCopied(false);
}}
>
<span className="sr-only">Copy key</span>
{copied ? <Icons.CopyDone /> : <Icons.Copy />}
</Button>
</div>
</div>
);
},
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 (
<div className="flex flex-col text-destructive">
<span>Revoked</span>
<span>{format(t.row.original.revokedAt, "yyyy-MM-dd")}</span>
</div>
);
}
const value = t.getValue();
if (value === null) {
return "Never expires";
}
if (value < new Date()) {
return (
<div className="flex flex-col text-destructive">
<span>Expired</span>
<span>{format(value, "yyyy-MM-dd")}</span>
</div>
);
}
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 (
<DropdownMenu>
<DropdownMenuTrigger asChild disabled={ids.length < 1}>
<Button variant="ghost" className="h-8 w-8 p-0">
<span className="sr-only">Open menu</span>
<Icons.Ellipsis className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
onClick={async () => {
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" : ""}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
},
cell: function Actions(t) {
const apiKey = t.row.original;
const router = useRouter();
const toaster = useToast();
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" className="h-8 w-8 p-0">
<span className="sr-only">Open menu</span>
<Icons.Ellipsis className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
onClick={async () => {
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
</DropdownMenuItem>
<DropdownMenuItem
onClick={async () => {
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
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
},
}),
];
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 (
<div>
<div className="flex items-center gap-2 py-2">
<Label>Show revoked</Label>
<Checkbox
checked={showRevoked}
onCheckedChange={(c) => setShowRevoked(!!c)}
className="max-w-sm"
/>
</div>
<div className="rounded-md border">
<Table>
<TableHeader>
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}>
{headerGroup.headers.map((header) => {
return (
<TableHead key={header.id}>
{header.isPlaceholder
? null
: flexRender(
header.column.columnDef.header,
header.getContext(),
)}
</TableHead>
);
})}
</TableRow>
))}
</TableHeader>
<TableBody>
{filteredRows.length ? (
filteredRows.map((row) => (
<TableRow
key={row.id}
data-state={row.getIsSelected() && "selected"}
disabled={(() => {
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) => (
<TableCell key={cell.id}>
{flexRender(
cell.column.columnDef.cell,
cell.getContext(),
)}
</TableCell>
))}
</TableRow>
))
) : (
<TableRow>
<TableCell
colSpan={columns.length}
className="h-24 text-center"
>
No results.
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
</div>
);
}

View File

@@ -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 (
<DashboardShell
title="API Keys"
description="Manage your API Keys"
headerAction={<NewApiKeyDialog projectId="" />}
>
<DataTable data={[]} />
</DashboardShell>
);
}

View File

@@ -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 (
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
<DialogTrigger asChild>
<Button>Create API Key</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Create API Key</DialogTitle>
<DialogDescription>
Fill out the form to create an API key.
</DialogDescription>
</DialogHeader>
<CreateApiKeyForm
projectId={props.projectId}
onSuccess={() => {
setDialogOpen(false);
router.refresh();
}}
/>
</DialogContent>
</Dialog>
);
}

View File

@@ -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 (
<DashboardShell
title="API Keys"
description="Manage your API Keys"
headerAction={<NewApiKeyDialog projectId={props.params.projectId} />}
>
<DataTable data={apiKeys} />
</DashboardShell>
);
}

View File

@@ -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 (
<Card>
<CardHeader>
<CardTitle>{title}</CardTitle>
<CardDescription className="flex items-center">
{description}
</CardDescription>
</CardHeader>
<CardFooter className="flex justify-between">
<Dialog>
<DialogTrigger asChild>
<Button variant="destructive">{title}</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>{title}</DialogTitle>
<DialogDescription>{description}</DialogDescription>
</DialogHeader>
<div className="flex items-center font-bold text-destructive">
<Icons.Warning className="mr-2 h-6 w-6" />
<p>This action can not be reverted</p>
</div>
<DialogFooter>
<DialogClose asChild>
<Button variant="secondary">Cancel</Button>
</DialogClose>
<Button
variant="destructive"
onClick={async () => {
try {
if (!projectId) throw new Error("No project ID");
await api.project.delete.mutate({
id: projectId,
});
toaster.toast({ title: "Project deleted" });
router.push(`/dashboard`);
} catch {
toaster.toast({
title: "Project could not be deleted",
variant: "destructive",
});
}
}}
>
{`I'm sure. Delete this project`}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</CardFooter>
</Card>
);
}

View File

@@ -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 (
<DashboardShell
title="Danger Zone"
description="Do dangerous stuff here"
className="space-y-6"
>
<Card>
<CardHeader>
<CardTitle>Transfer to Organization</CardTitle>
<CardDescription className="flex items-center">
Transfer this project to an organization
</CardDescription>
</CardHeader>
<CardFooter className="flex justify-between">
<Button variant="destructive" disabled>
Transfer to Organization
</Button>
</CardFooter>
</Card>
<Card>
<CardHeader>
<CardTitle>Transfer to Personal</CardTitle>
<CardDescription className="flex items-center">
Transfer this project to your personal workspace
</CardDescription>
</CardHeader>
<CardFooter className="flex justify-between">
<Button variant="destructive" disabled>
Transfer to Personal
</Button>
</CardFooter>
</Card>
<Card>
<CardHeader>
<CardTitle>Delete project</CardTitle>
<CardDescription className="flex items-center">
This will delete the project and all of its data.
</CardDescription>
</CardHeader>
<CardFooter className="flex justify-between">
<Button variant="destructive" disabled>
Delete project
</Button>
</CardFooter>
</Card>
</DashboardShell>
);
}

View File

@@ -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 (
<DashboardShell
title="Danger Zone"
description="Do dangerous stuff here"
className="space-y-4"
>
<Suspense
fallback={
<Card>
<CardHeader>
<CardTitle>Transfer to Organization</CardTitle>
<CardDescription className="flex items-center">
Transfer this project to an organization
</CardDescription>
</CardHeader>
<CardFooter className="flex justify-between">
<Button variant="destructive">Transfer to Organization</Button>
</CardFooter>
</Card>
}
>
<TransferProjectToOrganization
orgsPromise={api.auth.listOrganizations.query()}
/>
</Suspense>
<TransferProjectToPersonal />
<DeleteProject />
</DashboardShell>
);
}

View File

@@ -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<RouterOutputs["auth"]["listOrganizations"]>;
}) {
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 (
<Card>
<CardHeader>
<CardTitle>{title}</CardTitle>
<CardDescription className="flex items-center">
{description}
</CardDescription>
</CardHeader>
<CardFooter className="flex justify-between">
<Dialog>
<DialogTrigger asChild>
<Button variant="destructive">{title}</Button>
</DialogTrigger>
<DialogContent>
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
className="space-y-4"
>
<DialogHeader>
<DialogTitle>{title}</DialogTitle>
<DialogDescription>{description}</DialogDescription>
</DialogHeader>
<FormField
control={form.control}
name="orgId"
render={({ field }) => (
<FormItem>
<FormLabel>Organization</FormLabel>
<Select
onValueChange={field.onChange}
defaultValue={field.value}
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select a plan" />
</SelectTrigger>
</FormControl>
<SelectContent>
{orgs
.filter((org) => org.id !== workspaceId)
.map((org) => (
<SelectItem key={org.id} value={org.id}>
{org.name}
</SelectItem>
))}
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<DialogFooter>
<DialogClose asChild>
<Button variant="secondary">Cancel</Button>
</DialogClose>
<Button variant="destructive" type="submit">
{`I'm sure. Transfer this project`}
</Button>
</DialogFooter>
</form>
</Form>
</DialogContent>
</Dialog>
</CardFooter>
</Card>
);
}

View File

@@ -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 (
<Card>
<CardHeader>
<CardTitle>{title}</CardTitle>
<CardDescription className="flex items-center">
{description}
</CardDescription>
</CardHeader>
<CardFooter className="flex justify-between">
<Dialog>
<DialogTrigger asChild>
<Button variant="destructive">{title}</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>{title}</DialogTitle>
<DialogDescription>{description}</DialogDescription>
</DialogHeader>
<DialogFooter>
<DialogClose asChild>
<Button variant="secondary">Cancel</Button>
</DialogClose>
<Button
variant="destructive"
onClick={async () => {
try {
if (!projectId) throw new Error("No project ID");
await api.project.transferToPersonal.mutate({
id: projectId,
});
toaster.toast({ title: "Project transferred" });
router.push(`/${userId}/${projectId}`);
} catch {
toaster.toast({
title: "Project could not be transferred",
variant: "destructive",
});
}
}}
>
{`I'm sure. Transfer this project`}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</CardFooter>
</Card>
);
}

View File

@@ -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 (
<DashboardShell title={title} description={description} breadcrumb>
<div className="flex h-[600px] flex-col items-center justify-center gap-4 rounded-lg border border-dashed">
<h2 className="text-xl font-bold">Something went wrong!</h2>
<p className="text-base text-muted-foreground">
{`We're sorry, something went wrong. Please try again.`}
</p>
<Button onClick={() => props.reset()}>Try again</Button>
</div>
</DashboardShell>
);
}

View File

@@ -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 (
<DashboardShell
title="Ingestion"
description="Ingestion details"
className="space-y-4"
>
<Table>
<TableHeader>
<TableRow className="pointer-events-none bg-muted">
<TableHead>Id</TableHead>
<TableHead>Created At</TableHead>
<TableHead>Commit</TableHead>
<TableHead>Origin</TableHead>
<TableHead>Parent</TableHead>
</TableRow>
</TableHeader>
<TableBody>
<TableRow>
<TableCell>{ingestion.id}</TableCell>
<TableCell>
{format(ingestion.createdAt, "yyyy-MM-dd HH:mm:ss")}
</TableCell>
<TableCell>{ingestion.hash}</TableCell>
<TableCell>{ingestion.origin}</TableCell>
<TableCell>{ingestion.parent}</TableCell>
</TableRow>
</TableBody>
</Table>
<h3 className="text-lg font-medium">Schema</h3>
<pre className="relative rounded bg-muted px-[0.3rem] py-[0.2rem] font-mono text-sm font-semibold">
{JSON.stringify(ingestion.schema, null, 4)}
</pre>
</DashboardShell>
);
}

View File

@@ -1,13 +0,0 @@
import { DashboardShell } from "../../../_components/dashboard-shell";
export default function Loading() {
return (
<DashboardShell
title="Overview"
description="Get an overview of how the project is going"
breadcrumb
>
<div className="h-[600px] animate-pulse rounded-lg bg-muted"></div>
</DashboardShell>
);
}

View File

@@ -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 (
<DashboardShell
title="Overview"
description="Get an overview of how the project is going"
breadcrumb
className="space-y-4"
>
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Total Revenue</CardTitle>
<DollarSign className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">$45,231.89</div>
<p className="text-xs text-muted-foreground">
+20.1% from last month
</p>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Subscriptions</CardTitle>
<Users className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">+2350</div>
<p className="text-xs text-muted-foreground">
+180.1% from last month
</p>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Sales</CardTitle>
<CreditCard className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">+12,234</div>
<p className="text-xs text-muted-foreground">
+19% from last month
</p>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Active Now</CardTitle>
<Activity className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">+573</div>
<p className="text-xs text-muted-foreground">
+201 since last hour
</p>
</CardContent>
</Card>
</div>
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-7">
<Card className="col-span-7 md:col-span-2 lg:col-span-4">
<CardHeader>
<CardTitle>Overview</CardTitle>
</CardHeader>
<CardContent className="pl-2">
<Overview />
</CardContent>
</Card>
<Suspense
fallback={
<LoadingCard
title="Recent Ingestions"
description="Loading recent ingestions..."
className="col-span-7 md:col-span-2 lg:col-span-3"
/>
}
>
<RecentIngestions projectId={projectId} workspaceId={workspaceId} />
</Suspense>
</div>
</DashboardShell>
);
}
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 (
<Link
href={`/${props.workspaceId}/${props.projectId}/ingestions/${ingestion.id}`}
>
<div className="flex items-center rounded p-1 hover:bg-muted">
<div className="space-y-1">
<p className="text-sm font-medium leading-none">{truncatedHash}</p>
<p className="text-sm text-muted-foreground">
{formatRelative(ingestion.createdAt, new Date())}
</p>
</div>
<div className="ml-auto flex flex-col items-center text-sm">
<div>
+{adds} -{subs}
</div>
<div className="flex gap-[2px]">
{new Array(N_SQUARES).fill(null).map((_, i) => (
<span
key={i}
className={cn(
"inline-block h-2 w-2",
i < addSquares ? "bg-green-500" : "bg-red-500",
adds + subs === 0 && "bg-gray-200",
)}
/>
))}
</div>
</div>
<Icons.ChevronRight className="ml-2 h-4 w-4" />
</div>
</Link>
);
}
async function RecentIngestions(props: {
projectId: string;
workspaceId: string;
}) {
const ingestions = await api.ingestion.list.query({
projectId: props.projectId,
limit: 5,
});
return (
<Card className="col-span-7 md:col-span-2 lg:col-span-3">
<CardHeader>
<CardTitle>Recent Ingestions</CardTitle>
<CardDescription>
{ingestions.length} ingestion{ingestions.length > 1 ? "s" : null}{" "}
recorded this period.
</CardDescription>
</CardHeader>
<CardContent>
{ingestions.map((ingestion) => (
<IngestionCard
key={ingestion.id}
ingestion={ingestion}
projectId={props.projectId}
workspaceId={props.workspaceId}
/>
))}
</CardContent>
<CardFooter>
<Button size="sm" className="ml-auto">
View all
<Icons.ChevronRight className="ml-1 h-4 w-4" />
</Button>
</CardFooter>
</Card>
);
}

View File

@@ -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`);
}

View File

@@ -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 (
<Card>
<CardHeader>
<CardTitle>Project name</CardTitle>
<CardDescription>
Change the display name of your project
</CardDescription>
</CardHeader>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-2">
<CardContent>
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>Name</FormLabel>
<FormControl>
<Input {...field} placeholder="my-project" />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</CardContent>
<CardFooter>
<Button type="submit" className="ml-auto">
Save
</Button>
</CardFooter>
</form>
</Form>
</Card>
);
}

View File

@@ -1,14 +0,0 @@
import { DashboardShell } from "~/app/(dashboard)/_components/dashboard-shell";
import { RenameProject } from "./_components/rename-project";
export default function Loading() {
return (
<DashboardShell
title="Project"
description="Manage your project"
className="space-y-4"
>
<RenameProject currentName="" projectId="" />
</DashboardShell>
);
}

View File

@@ -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 (
<DashboardShell
title="Project"
description="Manage your project"
className="space-y-4"
>
<RenameProject currentName={project.name} projectId={projectId} />
</DashboardShell>
);
}

View File

@@ -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 (
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>Name *</FormLabel>
<FormControl>
<Input {...field} placeholder="New Token" />
</FormControl>
<FormDescription>
Enter a unique name for your token to differentiate it from
other tokens.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="expiresAt"
render={({ field }) => (
<FormItem className="flex flex-col">
<FormLabel>Exiration date</FormLabel>
<Popover open={datePickerOpen} onOpenChange={setDatePickerOpen}>
<PopoverTrigger asChild>
<FormControl>
<Button
variant={"outline"}
className="pl-3 text-left font-normal"
>
{field.value ? (
format(field.value, "PPP")
) : (
<span className="text-muted-foreground">
Pick a date
</span>
)}
<CalendarIcon className="ml-auto h-4 w-4 opacity-50" />
</Button>
</FormControl>
</PopoverTrigger>
<PopoverContent className="w-auto p-0" align="start">
<Calendar
mode="single"
selected={field.value}
onSelect={(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
/>
</PopoverContent>
</Popover>
<FormDescription>
We <b>strongly recommend</b> you setting an expiration date for
your API key, but you can also leave it blank to create a
permanent key.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<Button type="submit">
{form.formState.isSubmitting && (
<div className="mr-1" role="status">
<div className="h-3 w-3 animate-spin rounded-full border-2 border-background border-r-transparent" />
</div>
)}
Create Key
</Button>
</form>
</Form>
);
}

View File

@@ -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 (
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>Name *</FormLabel>
<FormControl>
<Input {...field} placeholder="Acme Corp" />
</FormControl>
<FormDescription>
A name to identify your app in the dashboard.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="url"
render={({ field }) => (
<FormItem>
<FormLabel>URL</FormLabel>
<FormControl>
<Input {...field} placeholder="https://acme-corp.com" />
</FormControl>
<FormDescription>The URL of your app</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<Button type="submit">Create Project</Button>
</form>
</Form>
);
};

View File

@@ -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 (
<span
className={cn(
"ml-2 rounded-md px-2 py-1 text-xs no-underline group-hover:no-underline",
props.tier === ProjectTier.FREE && "bg-teal-100 dark:bg-teal-600",
props.tier === ProjectTier.PRO && "bg-red-100 dark:bg-red-800",
)}
>
{props.tier}
</span>
);
}
export function ProjectCard(props: {
workspaceId: string;
project: RouterOutputs["project"]["listByActiveWorkspace"]["projects"][number];
}) {
const { project } = props;
return (
<Link href={`/${props.workspaceId}/${project.id}/overview`}>
<Card className="overflow-hidden">
<div className="h-32" style={getRandomPatternStyle(project.id)} />
<CardHeader>
<CardTitle className="flex items-center justify-between">
<span>{project.name}</span>
<ProjectTierIndicator tier={project.tier} />
</CardTitle>
<CardDescription>{project.url}&nbsp;</CardDescription>
</CardHeader>
</Card>
</Link>
);
}
ProjectCard.Skeleton = function ProjectCardSkeleton(props: {
pulse?: boolean;
}) {
const { pulse = true } = props;
return (
<Card>
<div className={cn("h-32 bg-muted", pulse && "animate-pulse")} />
<CardHeader>
<CardTitle className="flex items-center justify-between">
<span className={cn("flex-1 bg-muted", pulse && "animate-pulse")}>
&nbsp;
</span>
<ProjectTierIndicator tier={ProjectTier.FREE} />
</CardTitle>
<CardDescription className={cn("bg-muted", pulse && "animate-pulse")}>
&nbsp;
</CardDescription>
</CardHeader>
</Card>
);
};

View File

@@ -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 (
<nav className="grid items-start gap-2">
{items.map((item, index) => {
const Icon = item.icon;
let fullPath = `/${params.workspaceId}`;
if (params.projectId) {
fullPath += `/${params.projectId}`;
}
fullPath += item.href;
return (
item.href && (
<Link
key={index}
href={fullPath}
aria-disabled={"disabled" in item}
>
<span
className={cn(
"group flex items-center rounded-md px-3 py-2 text-sm font-medium hover:bg-accent hover:text-accent-foreground",
pathname === item.href ? "bg-accent" : "transparent",
"disabled" in item && "cursor-not-allowed opacity-80",
)}
>
<Icon className="mr-2 h-4 w-4" />
<span>{item.title}</span>
</span>
</Link>
)
);
})}
</nav>
);
}

View File

@@ -1,29 +0,0 @@
import { Card, CardContent, CardHeader, CardTitle } from "@acme/ui/card";
import { DashboardShell } from "../../_components/dashboard-shell";
export default function Loading() {
return (
<DashboardShell
title="Billing"
description="Manage your subscription and billing details"
className="space-y-4"
>
<LoadingCard title="Subscription" />
<LoadingCard title="Usage" />
</DashboardShell>
);
}
function LoadingCard(props: { title: string }) {
return (
<Card className="mt-4">
<CardHeader>
<CardTitle>{props.title}</CardTitle>
</CardHeader>
<CardContent>
<div className="h-24 animate-pulse rounded bg-muted" />
</CardContent>
</Card>
);
}

View File

@@ -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 (
<DashboardShell
title="Billing"
description="Manage your subscription and billing details"
className="space-y-4"
>
<SubscriptionCard />
<UsageCard />
</DashboardShell>
);
}
async function SubscriptionCard() {
const subscription = await api.auth.mySubscription.query();
return (
<Card>
<CardHeader>
<CardTitle>Subscription</CardTitle>
</CardHeader>
<CardContent>
{subscription ? (
<p>
You are currently on the <strong>{subscription.plan}</strong> plan.
Your subscription will renew on{" "}
<strong>{subscription.endsAt?.toLocaleDateString()}</strong>.
</p>
) : (
<p>You are not subscribed to any plan.</p>
)}
</CardContent>
<CardFooter>
<SubscriptionForm hasSubscription={!!subscription} />
</CardFooter>
</Card>
);
}
function UsageCard() {
return (
<Card className="mt-4">
<CardHeader>
<CardTitle>Usage</CardTitle>
</CardHeader>
<CardContent>TODO</CardContent>
</Card>
);
}

View File

@@ -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 (
<Button onClick={createSession}>
{props.hasSubscription ? "Manage Subscription" : "Upgrade"}
</Button>
);
}

View File

@@ -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 (
<Card>
<CardHeader>
<CardTitle>{title}</CardTitle>
<CardDescription className="flex items-center">
{description}
</CardDescription>
</CardHeader>
<CardFooter className="flex justify-between">
<Dialog>
<DialogTrigger asChild disabled={!orgId}>
<Button variant="destructive">{title}</Button>
</DialogTrigger>
{!orgId && (
<span className="mr-auto px-2 text-sm text-muted-foreground">
You can not delete your personal workspace
</span>
)}
<DialogContent>
<DialogHeader>
<DialogTitle>{title}</DialogTitle>
<DialogDescription>{description}</DialogDescription>
</DialogHeader>
<div className="flex items-center font-bold text-destructive">
<Icons.Warning className="mr-2 h-6 w-6" />
<p>This action can not be reverted</p>
</div>
<DialogFooter>
<DialogClose asChild>
<Button variant="secondary">Cancel</Button>
</DialogClose>
<Button
variant="destructive"
onClick={async () => {
try {
await api.organization.deleteOrganization.mutate();
toaster.toast({ title: "Workspace deleted" });
router.push(`/dashboard`);
} catch {
toaster.toast({
title: "The workspace could not be deleted",
variant: "destructive",
});
}
}}
>
{`I'm sure. Delete this workspace`}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</CardFooter>
</Card>
);
}

View File

@@ -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 (
<DashboardShell
title="Danger Zone"
description="Do dangerous stuff here"
className="space-y-6"
>
<Card>
<CardHeader>
<CardTitle>Delete workspace</CardTitle>
<CardDescription className="flex items-center">
This will delete the workspace and all of its data.
</CardDescription>
</CardHeader>
<CardFooter className="flex justify-between">
<Button variant="destructive" disabled>
Delete workspace
</Button>
</CardFooter>
</Card>
</DashboardShell>
);
}

View File

@@ -1,16 +0,0 @@
import { DashboardShell } from "../../_components/dashboard-shell";
import { DeleteWorkspace } from "./delete-workspace";
export const runtime = "edge";
export default function DangerZonePage() {
return (
<DashboardShell
title="Danger Zone"
description="Do dangerous stuff here"
className="space-y-6"
>
<DeleteWorkspace />
</DashboardShell>
);
}

View File

@@ -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! */}
<SyncActiveOrgFromUrl />
<div className="container flex flex-1 gap-12">
<aside className="hidden w-52 flex-col md:flex">
<SidebarNav />
</aside>
<main className="flex flex-1 flex-col overflow-hidden">
{props.children}
</main>
</div>
</>
);
}

View File

@@ -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 (
<DashboardShell
title="Projects"
description="Projects for this workspace will show up here"
headerAction={<Button disabled>Create a new project</Button>}
>
<ul className="grid grid-cols-1 gap-4 md:grid-cols-3">
<ProjectCard.Skeleton />
<ProjectCard.Skeleton />
<ProjectCard.Skeleton />
</ul>
</DashboardShell>
);
}

View File

@@ -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 (
<DashboardShell
title="Projects"
description="Projects for this workspace will show up here"
headerAction={
limitReached ? (
<Button className="min-w-max">Project limit reached</Button>
) : (
<Button className="min-w-max" asChild>
<Link href={`/onboarding`}>Create a new project</Link>
</Button>
)
}
>
<ul className="grid grid-cols-1 gap-4 md:grid-cols-3">
{projects.map((project) => (
<li key={project.id}>
<ProjectCard
project={project}
workspaceId={props.params.workspaceId}
/>
</li>
))}
</ul>
{projects.length === 0 && (
<div className="relative">
<ul className="grid select-none grid-cols-1 gap-4 opacity-40 md:grid-cols-3">
<ProjectCard.Skeleton pulse={false} />
<ProjectCard.Skeleton pulse={false} />
<ProjectCard.Skeleton pulse={false} />
</ul>
<div className="absolute left-1/2 top-1/2 w-full -translate-x-1/2 -translate-y-1/2 text-center">
<Balancer>
<h2 className="text-2xl font-bold">
This workspace has no projects yet
</h2>
<p className="text-lg text-muted-foreground">
Create your first project to get started
</p>
</Balancer>
</div>
</div>
)}
</DashboardShell>
);
}

View File

@@ -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 (
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
<FormField
control={form.control}
name="email"
render={({ field }) => (
<FormItem>
<FormLabel>Email *</FormLabel>
<FormControl>
<Input {...field} placeholder="john@doe.com" />
</FormControl>
<FormDescription>
The email address of the person you want to invite. They must
have an account on this app.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="role"
render={({ field }) => (
<FormItem>
<FormLabel>Role *</FormLabel>
<Select
onValueChange={(val) =>
field.onChange(
val as (typeof MEMBERSHIP)[keyof typeof MEMBERSHIP],
)
}
defaultValue={field.value}
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select a plan" />
</SelectTrigger>
</FormControl>
<SelectContent>
{Object.entries(MEMBERSHIP).map(([key, value]) => (
<SelectItem key={value} value={value}>
{key}
</SelectItem>
))}
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<Button type="submit">Create Project</Button>
</form>
</Form>
);
};

View File

@@ -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 (
<Card>
<CardHeader>
<CardTitle>Organization Image</CardTitle>
<CardDescription>
Change your organization&apos;s avatar image
</CardDescription>
</CardHeader>
<CardContent>
<Avatar className="h-32 w-32">
<AvatarImage src={props.image} />
<AvatarFallback>{props.name.substring(0, 2)}</AvatarFallback>
</Avatar>
</CardContent>
<CardFooter>
<Dialog open={cropModalOpen} onOpenChange={setCropModalOpen}>
<Input
type="file"
name="image"
accept="image/*"
onChange={(e) => {
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);
}}
/>
<CropImageDialog
imgSrc={imgSrc}
close={() => setCropModalOpen(false)}
/>
</Dialog>
</CardFooter>
</Card>
);
}
function CropImageDialog(props: { imgSrc: string; close: () => void }) {
const [crop, setCrop] = React.useState<Crop>();
const [storedCrop, setStoredCrop] = React.useState<PixelCrop>();
const imageRef = React.useRef<HTMLImageElement>(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<Blob>((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 (
<DialogContent>
<DialogHeader>
<DialogTitle>Edit Image</DialogTitle>
<DialogDescription>
Select the area of the image you would like to use
</DialogDescription>
</DialogHeader>
<ReactCrop
aspect={1}
crop={crop}
onChange={(_, percent) => setCrop(percent)}
onComplete={(c) => setStoredCrop(c)}
>
{props.imgSrc && (
// eslint-disable-next-line @next/next/no-img-element
<img ref={imageRef} src={props.imgSrc} alt="Crop me" />
)}
</ReactCrop>
<DialogFooter>
<Button onClick={saveImage}>
{isUploading && (
<div className="mr-1" role="status">
<div className="h-3 w-3 animate-spin rounded-full border-2 border-background border-r-transparent" />
</div>
)}
Save
</Button>
</DialogFooter>
</DialogContent>
);
}
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;
}

View File

@@ -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<RouterOutputs["organization"]["listMembers"]>;
}) {
const members = use(props.membersPromise);
const toaster = useToast();
const router = useRouter();
const { orgRole } = useAuth();
// TODO: DataTable with actions
return (
<Table>
<TableHeader>
<TableRow className="pointer-events-none bg-muted">
<TableHead>Name</TableHead>
<TableHead>Joined at</TableHead>
<TableHead>Role</TableHead>
<TableHead className="w-16"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{members.map((member) => (
<TableRow key={member.id}>
<TableCell className="flex items-center gap-2">
<Avatar>
<AvatarImage src={member.avatarUrl} alt={member.name} />
<AvatarFallback>{member.name[0]}</AvatarFallback>
</Avatar>
<div className="flex flex-col">
<span>{member.name}</span>
<span className="text-sm text-muted-foreground">
{member.email}
</span>
</div>
</TableCell>
<TableCell>{formatRelative(member.joinedAt, new Date())}</TableCell>
<TableCell>{formatMemberRole(member.role)}</TableCell>
<TableCell>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" className="h-8 w-8 p-0">
<span className="sr-only">Open menu</span>
<Icons.Ellipsis className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
disabled={orgRole !== "admin"}
onClick={async () => {
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
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
);
}

View File

@@ -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 (
<Card>
<CardHeader>
<CardTitle>Organization Name</CardTitle>
<CardDescription>Change the name of your organization</CardDescription>
</CardHeader>
<form
className="flex flex-col space-y-2"
onSubmit={async (e) => {
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.",
});
}}
>
<CardContent>
<Label htmlFor="name">Name</Label>
<Input id="name" name="name" defaultValue={props.name} />
</CardContent>
<CardFooter>
<Button type="submit" className="ml-auto">
{updating && (
<div className="mr-1" role="status">
<div className="h-3 w-3 animate-spin rounded-full border-2 border-background border-r-transparent" />
</div>
)}
Save
</Button>
</CardFooter>
</form>
</Card>
);
}

View File

@@ -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 (
<Suspense
fallback={
<DashboardShell
title="Organization"
description="Manage your organization"
>
<Tabs defaultValue="general">
<TabsList className="mb-2 w-full justify-start">
<TabsTrigger value="general">General</TabsTrigger>
<TabsTrigger value="members">Members</TabsTrigger>
</TabsList>
<TabsContent value="general" className="space-y-4">
<OrganizationName orgId="org_123" name="" />
<OrganizationImage orgId="org_123" name="" image="" />
</TabsContent>
</Tabs>
</DashboardShell>
}
>
<OrganizationSettingsPage />
</Suspense>
);
return <UserSettingsPage />;
}
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 (
<DashboardShell title="Organization" description="Manage your organization">
{/* TODO: Use URL instead of clientside tabs */}
<Tabs defaultValue="general">
<TabsList className="mb-2 w-full justify-start">
<TabsTrigger value="general">General</TabsTrigger>
<TabsTrigger value="members">Members</TabsTrigger>
</TabsList>
<TabsContent value="general" className="space-y-4">
<OrganizationName orgId={org.id} name={org.name} />
<OrganizationImage
orgId={org.id}
name={org.name}
image={org.imageUrl}
/>
</TabsContent>
<TabsContent value="members" className="flex flex-col space-y-4">
<Dialog>
<DialogTrigger asChild>
<Button className="self-end">Invite member</Button>
</DialogTrigger>
<DialogContent>
<InviteMemberForm />
</DialogContent>
</Dialog>
<Suspense fallback={<LoadingCard title="Members" description="" />}>
<OrganizationMembers
membersPromise={api.organization.listMembers.query()}
/>
</Suspense>
</TabsContent>
</Tabs>
</DashboardShell>
);
}
function UserSettingsPage() {
return (
<DashboardShell title="Account" description="Manage your account details">
<UserProfile
appearance={{
variables: {
borderRadius: "var(--radius)",
// colorBackground: "var(--background)",
},
elements: {
// Main card element
card: "shadow-none bg-background text-foreground",
navbar: "hidden",
navbarMobileMenuButton: "hidden",
headerTitle: "hidden",
headerSubtitle: "hidden",
},
}}
/>
</DashboardShell>
);
}

View File

@@ -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;
}

View File

@@ -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 (
<div className="mb-4 inline-flex h-10 items-center justify-center rounded-md bg-muted p-1 text-muted-foreground">
{Object.entries(items).map(([key, value]) => {
const isActive =
key === restAsString || (key !== "" && restAsString.startsWith(key));
return (
<Link
key={key}
href={`${baseUrl}/${key}`}
className={cn(
"inline-flex items-center justify-center whitespace-nowrap rounded-sm px-3 py-1.5 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
isActive && "bg-background text-foreground shadow-sm",
)}
>
{value}
</Link>
);
})}
</div>
);
}

View File

@@ -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 (
<div>
<div className="mb-4 flex items-center justify-between">
<div className="space-y-1">
<h1 className="font-cal text-xl font-semibold leading-none">
{props.title}
</h1>
{typeof props.description === "string" ? (
<h2 className="text-base text-muted-foreground">
{props.description}
</h2>
) : (
props.description
)}
</div>
{props.headerAction}
</div>
{props.breadcrumb && <Breadcrumbs />}
<div className={props.className}>{props.children}</div>
</div>
);
}

View File

@@ -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<HTMLDivElement> & {
align?: "center" | "end" | "start";
}) {
const [date, setDate] = React.useState<DateRange | undefined>({
from: new Date(2023, 0, 20),
to: addDays(new Date(2023, 0, 20), 20),
});
return (
<div className={cn("grid gap-2", className)}>
<Popover>
<PopoverTrigger asChild>
<Button
id="date"
variant={"outline"}
size="sm"
className={cn(
"w-[240px] justify-start text-left font-normal",
!date && "text-muted-foreground",
)}
>
<CalendarIcon className="mr-2 h-4 w-4" />
{date?.from ? (
date.to ? (
<>
{format(date.from, "LLL dd, y")} -{" "}
{format(date.to, "LLL dd, y")}
</>
) : (
format(date.from, "LLL dd, y")
)
) : (
<span>Pick a date</span>
)}
</Button>
</PopoverTrigger>
<PopoverContent className="w-auto p-0" align={align}>
<Calendar
initialFocus
mode="range"
defaultMonth={date?.from}
selected={date}
onSelect={setDate}
numberOfMonths={2}
/>
</PopoverContent>
</Popover>
</div>
);
}

View File

@@ -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<HTMLElement>) {
return (
<nav
className={cn(
"hidden items-center space-x-4 md:flex lg:space-x-6",
className,
)}
{...props}
>
{navItems.map((item, idx) => (
<Link
href={item.href}
key={`${item.href}-${idx}`}
className={cn(
"text-sm font-medium transition-colors hover:text-primary",
idx !== 0 && "text-muted-foreground",
)}
>
{item.title}
</Link>
))}
</nav>
);
}

View File

@@ -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<RouterOutputs["project"]["listByActiveWorkspace"]>;
}) {
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 (
<Button
variant="ghost"
size="sm"
role="combobox"
aria-expanded={switcherOpen}
aria-label="Select a workspace"
className="w-52 justify-between opacity-50"
>
Select a project
<ChevronsUpDown className="ml-auto h-4 w-4 shrink-0" />
</Button>
);
}
return (
<>
<span className="mx-2 text-lg font-bold text-muted-foreground">/</span>
<Popover open={switcherOpen} onOpenChange={setSwitcherOpen}>
<PopoverTrigger asChild>
<Button
variant="ghost"
size="sm"
role="combobox"
aria-expanded={switcherOpen}
aria-label="Select a project"
className="relative w-52 justify-between"
>
<div
style={getRandomPatternStyle(projectId)}
className="absolute inset-1 opacity-25"
/>
<span className="z-10 font-semibold">{activeProject?.name}</span>
<ChevronsUpDown className="ml-auto h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-52 p-0">
<Command>
<CommandList>
<CommandInput placeholder="Search project..." />
{projects.map((project) => (
<CommandItem
key={project.id}
onSelect={() => {
setSwitcherOpen(false);
router.push(`/${workspaceId}/${project.id}`);
}}
className="text-sm font-semibold"
>
<div
style={getRandomPatternStyle(project.id)}
className="absolute inset-1 opacity-25"
/>
{project.name}
<Check
className={cn(
"ml-auto h-4 w-4",
project.id === activeProject?.id
? "opacity-100"
: "opacity-0",
)}
/>
</CommandItem>
))}
</CommandList>
<CommandSeparator />
<CommandList>
<CommandGroup>
<CommandItem
onSelect={() => {
router.push(`/${workspaceId}`);
setSwitcherOpen(false);
}}
className="cursor-pointer"
>
<LayoutGrid className="mr-2 h-5 w-5" />
Browse projects
</CommandItem>
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
</>
);
}

View File

@@ -1,13 +0,0 @@
import { Input } from "@acme/ui/input";
export function Search() {
return (
<div>
<Input
type="search"
placeholder="Search..."
className="h-9 md:w-[100px] lg:w-[300px]"
/>
</div>
);
}

View File

@@ -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 (
<Button
variant="ghost"
size="sm"
role="combobox"
aria-expanded={switcherOpen}
aria-label="Select a workspace"
className="w-52 justify-between opacity-50"
>
<Avatar className="mr-2 h-5 w-5">
<AvatarFallback>Ac</AvatarFallback>
</Avatar>
Select a workspace
<ChevronsUpDown className="ml-auto h-4 w-4 shrink-0" />
</Button>
);
}
const normalizedObject = {
id: activeOrg.id,
name: "name" in activeOrg ? activeOrg.name : activeOrg.fullName,
image: activeOrg.imageUrl,
};
return (
<Dialog open={newOrgDialogOpen} onOpenChange={setNewOrgDialogOpen}>
<Popover open={switcherOpen} onOpenChange={setSwitcherOpen}>
<PopoverTrigger asChild>
<Button
variant="ghost"
size="sm"
role="combobox"
aria-expanded={switcherOpen}
aria-label="Select a workspace"
className="w-52 justify-between"
>
<Avatar className="mr-2 h-5 w-5">
<AvatarImage src={normalizedObject?.image ?? ""} />
<AvatarFallback>
{normalizedObject.name?.substring(0, 2)}
</AvatarFallback>
</Avatar>
{normalizedObject.name}
<ChevronsUpDown className="ml-auto h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-52 p-0">
<Command>
<CommandList>
<CommandInput placeholder="Search workspace..." />
<CommandGroup heading="Personal account">
<CommandItem
onSelect={async () => {
if (!user?.id) return;
normalizedObject.id = user.id ?? "";
await orgs.setActive?.({ organization: null });
setSwitcherOpen(false);
router.push(`/${user.id}`);
}}
className="cursor-pointer text-sm"
>
<Avatar className="mr-2 h-5 w-5">
<AvatarImage
src={user?.imageUrl}
alt={user?.fullName ?? ""}
/>
<AvatarFallback>
{`${user?.firstName?.[0]}${user?.lastName?.[0]}` ?? "JD"}
</AvatarFallback>
</Avatar>
{user?.fullName}
<Check
className={cn(
"ml-auto h-4 w-4",
org.organization === null ? "opacity-100" : "opacity-0",
)}
/>
</CommandItem>
</CommandGroup>
<CommandGroup heading="Organizations">
{orgs.userMemberships.data?.map(({ organization: org }) => (
<CommandItem
key={org.name}
onSelect={async () => {
await orgs.setActive({ organization: org });
setSwitcherOpen(false);
router.push(`/${org.id}`);
}}
className="cursor-pointer text-sm"
>
<Avatar className="mr-2 h-5 w-5">
<AvatarImage
src={org.imageUrl ?? "/images/placeholder.png"}
alt={org.name}
/>
<AvatarFallback>
{org.name.substring(0, 2)}
</AvatarFallback>
</Avatar>
{org.name}
<Check
className={cn(
"ml-auto h-4 w-4",
normalizedObject?.id === org.id
? "opacity-100"
: "opacity-0",
)}
/>
</CommandItem>
))}
</CommandGroup>
</CommandList>
<CommandSeparator />
<CommandList>
<CommandGroup>
<DialogTrigger asChild>
<CommandItem
onSelect={() => {
setSwitcherOpen(false);
setNewOrgDialogOpen(true);
}}
className="cursor-pointer"
>
<PlusCircle className="mr-2 h-5 w-5" />
Create Organization
</CommandItem>
</DialogTrigger>
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
<React.Suspense>
<NewOrganizationDialog closeDialog={() => setNewOrgDialogOpen(false)} />
</React.Suspense>
</Dialog>
);
}
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 (
<DialogContent>
<Form {...form}>
<form
onSubmit={form.handleSubmit(handleCreateOrg)}
className="space-y-4"
>
<DialogHeader>
<DialogTitle>Create organization</DialogTitle>
<DialogDescription>
Add a new organization to manage products and customers.
</DialogDescription>
</DialogHeader>
<FormField
control={form.control}
name="orgName"
render={({ field }) => (
<FormItem>
<FormLabel>Organization name *</FormLabel>
<FormControl>
<Input {...field} placeholder="Acme Inc." />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="planId"
render={({ field }) => (
<FormItem>
<div className="flex justify-between">
<FormLabel>Subscription plan *</FormLabel>
<Link
href="/pricing"
className="text-xs text-muted-foreground hover:underline"
>
What&apos;s included in each plan?
</Link>
</div>
<Select
onValueChange={field.onChange}
defaultValue={field.value}
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select a plan" />
</SelectTrigger>
</FormControl>
<SelectContent>
{plans.map((plan) => (
<SelectItem key={plan.priceId} value={plan.priceId}>
<span className="font-medium">{plan.name}</span> -{" "}
<span className="text-muted-foreground">
{toDecimal(
plan.price,
({ value, currency }) =>
`${currencySymbol(currency.code)}${value}`,
)}{" "}
per month
</span>
</SelectItem>
))}
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<DialogFooter>
<Button variant="outline" onClick={() => props.closeDialog()}>
Cancel
</Button>
<Button type="submit">Continue</Button>
</DialogFooter>
</form>
</Form>
</DialogContent>
);
}

View File

@@ -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 (
<div className="min-h-screen overflow-hidden rounded-[0.5rem]">
<nav className="border-b">
<div className="flex h-16 items-center px-4 md:px-8">
<Link href="/">
<Icons.Logo />
</Link>
<span className="mx-2 text-lg font-bold text-muted-foreground">
/
</span>
<WorkspaceSwitcher />
<Suspense>
<ProjectSwitcher
projectsPromise={api.project.listByActiveWorkspace.query()}
/>
</Suspense>
<div className="ml-auto flex items-center space-x-4">
<Search />
<UserNav />
</div>
</div>
</nav>
<main className="min-h-[calc(100vh-14rem)] flex-1 space-y-4 p-8 pt-6">
{props.children}
</main>
<SiteFooter />
</div>
);
}

View File

@@ -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 (
<motion.div
className="flex h-full w-full flex-col items-center justify-center"
exit={{ opacity: 0, scale: 0.95 }}
transition={{ duration: 0.3, type: "spring" }}
>
<motion.div
variants={{
show: {
transition: {
staggerChildren: 0.2,
},
},
}}
initial="hidden"
animate="show"
className="flex flex-col rounded-xl bg-background/60 p-8"
>
<motion.h1
className="mb-4 font-cal text-2xl font-bold transition-colors sm:text-3xl"
variants={{
hidden: { opacity: 0, x: 250 },
show: {
opacity: 1,
x: 0,
transition: { duration: 0.4, type: "spring" },
},
}}
>
<Balancer>
{`Next, let's create an API key for your project`}
</Balancer>
</motion.h1>
<motion.div
variants={{
hidden: { opacity: 0, x: 100 },
show: {
opacity: 1,
x: 0,
transition: { duration: 0.4, type: "spring" },
},
}}
>
<CreateApiKeyForm
projectId={projectId!}
onSuccess={(apiKey) => {
const searchParams = new URLSearchParams(window.location.search);
searchParams.set("step", "done");
searchParams.set("apiKey", apiKey);
router.push(`/onboarding?${searchParams.toString()}`);
}}
/>
</motion.div>
</motion.div>
</motion.div>
);
}

View File

@@ -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 (
<motion.div
className="my-auto flex h-full w-full flex-col items-center justify-center"
exit={{ opacity: 0, scale: 0.95 }}
transition={{ duration: 0.3, type: "spring" }}
>
<motion.div
variants={{
show: {
transition: {
staggerChildren: 0.2,
},
},
}}
initial="hidden"
animate="show"
className="flex flex-col rounded-xl bg-background/60 p-8"
>
<motion.h1
className="mb-4 font-cal text-2xl font-bold transition-colors sm:text-3xl"
variants={{
hidden: { opacity: 0, x: 250 },
show: {
opacity: 1,
x: 0,
transition: { duration: 0.4, type: "spring" },
},
}}
>
<Balancer>
{`Let's start off by creating your first project`}
</Balancer>
</motion.h1>
<motion.div
variants={{
hidden: { opacity: 0, x: 100 },
show: {
opacity: 1,
x: 0,
transition: { duration: 0.4, type: "spring" },
},
}}
>
<CreateProjectForm
workspaceId={props.workspaceId}
onSuccess={({ id }) => {
const searchParams = new URLSearchParams(window.location.search);
searchParams.set("step", "create-api-key");
searchParams.set("projectId", id);
router.push(`/onboarding?${searchParams.toString()}`);
}}
/>
</motion.div>
</motion.div>
</motion.div>
);
}

View File

@@ -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 (
<motion.div
className="shadox-xl flex h-full w-full flex-col items-center justify-center bg-opacity-60 p-8"
exit={{ opacity: 0, scale: 0.95 }}
initial={{ background: "transparent" }}
animate={{ background: "var(--background)" }}
transition={{ duration: 0.3, type: "spring" }}
>
<motion.div
variants={{
hidden: { opacity: 0, x: 250 },
show: {
opacity: 1,
x: 0,
transition: { duration: 0.4, type: "spring" },
},
}}
initial="hidden"
animate="show"
className="flex flex-col space-y-4 rounded-xl bg-background/60 p-8"
>
<h1 className="font-cal text-2xl font-bold transition-colors sm:text-3xl">
You are all set!
</h1>
<p className="max-w-md text-muted-foreground transition-colors sm:text-lg">
Congratulations, you have successfully created your first project.
Check out the <Link href="/docs">docs</Link> to learn more on how to
use the platform.
</p>
<p className="text-sm text-muted-foreground">
You will be redirected to your project momentarily.
</p>
</motion.div>
</motion.div>
);
}

View File

@@ -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 (
<motion.div
className="flex h-full w-full flex-col items-center justify-center"
exit={{ opacity: 0, scale: 0.95 }}
transition={{ duration: 0.3, type: "spring" }}
>
{showText && (
<motion.div
variants={{
show: {
transition: {
staggerChildren: 0.2,
},
},
}}
initial="hidden"
animate="show"
className="mx-5 flex flex-col items-center space-y-6 text-center sm:mx-auto"
>
<motion.h1
className="font-cal text-4xl font-bold transition-colors sm:text-5xl"
variants={{
hidden: { opacity: 0, y: 50 },
show: {
opacity: 1,
y: 0,
transition: { duration: 0.4, type: "spring" },
},
}}
>
<Balancer>Welcome to Acme Corp</Balancer>
</motion.h1>
<motion.p
className="max-w-md text-muted-foreground transition-colors sm:text-lg"
variants={{
hidden: { opacity: 0, y: 50 },
show: {
opacity: 1,
y: 0,
transition: { duration: 0.4, type: "spring" },
},
}}
>
Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do
eiusmod tempor incididunt.
</motion.p>
<motion.div
variants={{
hidden: { opacity: 0, y: 50 },
show: {
opacity: 1,
y: 0,
transition: { duration: 0.4, type: "spring" },
},
}}
>
<Button
size="lg"
onClick={() => router.push("/onboarding?step=create-project")}
>
Get Started
</Button>
</motion.div>
</motion.div>
)}
</motion.div>
);
}

View File

@@ -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 (
<div className="mx-auto flex h-[calc(100vh-14rem)] w-full max-w-screen-sm flex-col items-center">
<AnimatePresence mode="wait">
{!step && <Intro key="intro" />}
{step === "create-project" && (
<CreateProject workspaceId={props.workspaceId} />
)}
{step === "create-api-key" && <CreateApiKey />}
{step === "done" && <Done workspaceId={props.workspaceId} />}
</AnimatePresence>
</div>
);
}

View File

@@ -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 (
<>
<Onboarding workspaceId={orgId ?? userId!} />
<div className="absolute inset-0 top-12 -z-10 bg-cover bg-center" />
</>
);
}

View File

@@ -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 (
<div className="flex min-h-screen flex-col">
<nav className="container z-50 flex h-16 items-center border-b bg-background">
<div className="mr-8 hidden items-center md:flex">
<Icons.Logo className="mr-2 h-6 w-6" />
<span className="text-lg font-bold tracking-tight">
{siteConfig.name}
</span>
</div>
<MobileDropdown />
<MainNav />
<div className="ml-auto flex items-center space-x-4">
<Suspense>
<DashboardLink />
</Suspense>
</div>
</nav>
<main className="flex-1">{props.children}</main>
<SiteFooter />
</div>
);
}
function DashboardLink() {
const { userId, orgId } = auth();
if (!userId) {
return (
<Link href="/signin" className={buttonVariants({ variant: "outline" })}>
Sign In
<Icons.ChevronRight className="ml-1 h-4 w-4" />
</Link>
);
}
return (
<Link
href={`/${orgId ?? userId}`}
className={buttonVariants({ variant: "outline" })}
>
Dashboard
<Icons.ChevronRight className="ml-1 h-4 w-4" />
</Link>
);
}

View File

@@ -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 (
<main className="flex min-h-screen w-full flex-col items-center justify-center pt-48">
<div className="z-10 min-h-[50vh] w-full max-w-4xl px-5 xl:px-0">
{/* <a
href="https://twitter.com/steventey/status/1613928948915920896"
target="_blank"
rel="noreferrer"
className="mx-auto mb-5 flex max-w-fit animate-fade-up items-center justify-center space-x-2 overflow-hidden rounded-full bg-sky-100 px-7 py-2 transition-colors hover:bg-sky-200"
>
<Icons.twitter className="h-5 w-5 text-sky-500" />
<p className="text-sm font-semibold text-sky-500">
Introducing Acme Corp
</p>
</a> */}
<h1
className="animate-fade-up bg-gradient-to-br from-foreground to-muted-foreground bg-clip-text text-center text-4xl font-bold tracking-[-0.02em] text-transparent opacity-0 drop-shadow-sm md:text-7xl/[5rem]"
style={{ animationDelay: "0.20s", animationFillMode: "forwards" }}
>
<Balancer>Your all-in-one, enterprise ready starting point</Balancer>
</h1>
<p
className="mt-6 animate-fade-up text-center text-muted-foreground/80 opacity-0 md:text-xl"
style={{ animationDelay: "0.30s", animationFillMode: "forwards" }}
>
<Balancer>
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.
</Balancer>
</p>
<div
className="mx-auto mt-6 flex animate-fade-up items-center justify-center space-x-5 opacity-0"
style={{ animationDelay: "0.40s", animationFillMode: "forwards" }}
>
<a
className={cn(buttonVariants({ variant: "default" }))}
href={siteConfig.github}
target="_blank"
rel="noopener noreferrer"
>
<Icons.GitHub className="mr-1 h-4 w-4" />
<span>Star on GitHub</span>
</a>
</div>
</div>
<div className="my-16 w-full max-w-screen-lg animate-fade-up gap-5 border-t p-5 xl:px-0">
<h2 className="pt-4 text-center text-3xl font-bold md:text-4xl">
What&apos;s included?
</h2>
<p className="pb-8 pt-4 text-center text-lg">
<Balancer>
This repo comes fully stacked with everything you need for your
enterprise startup. Stop worrying about boilerplate integrations and
start building your product today!
</Balancer>
</p>
<div className="grid grid-cols-1 gap-5 md:grid-cols-3">
{marketingFeatures.map((feature) => (
<Card key={feature.title} className={cn("p-2")}>
<CardHeader>{feature.icon}</CardHeader>
<CardContent className="space-y-2">
<CardTitle>{feature.title}</CardTitle>
<CardDescription className="mt-2">
{feature.body}
</CardDescription>
</CardContent>
</Card>
))}
</div>
</div>
</main>
);
}

View File

@@ -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 (
<main className="flex w-full flex-col items-center justify-center pt-16">
<div className="z-10 min-h-[50vh] w-full max-w-7xl px-5 xl:px-0">
<h1 className="font-cal text-7xl/[5rem]">Pricing</h1>
<Balancer className="text-2xl">
Simple pricing for all your needs. No hidden fees, no surprises.
</Balancer>
<div className="my-8 grid grid-cols-1 gap-8 md:grid-cols-2">
{plans.map((plan) => (
<PricingCard key={plan.priceId} plan={plan} />
))}
</div>
</div>
</main>
);
}
function PricingCard(props: {
plan: RouterOutputs["stripe"]["plans"][number];
}) {
return (
<Card>
<CardHeader>
<CardTitle>{props.plan.name}</CardTitle>
<div className="text-2xl font-bold">
{toDecimal(
props.plan.price,
({ value, currency }) => `${currencySymbol(currency.code)}${value}`,
)}
<span className="text-base font-normal"> / month</span>
</div>{" "}
<CardDescription>{props.plan.description}</CardDescription>
</CardHeader>
<ul className="flex flex-col px-6 pb-6">
{props.plan.preFeatures && (
<li className="flex items-center pb-1">{props.plan.preFeatures}</li>
)}
{props.plan.features.map((feature) => (
<li key={feature} className="flex items-center">
<CheckCircle2 className="mr-2 h-6 w-6 fill-primary text-primary-foreground" />
{feature}
</li>
))}
</ul>
<CardFooter>
<SubscribeNow planId={props.plan.priceId} />
</CardFooter>
</Card>
);
}

View File

@@ -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 (
<Button
onClick={async () => {
if (!session.isSignedIn) router.push("/signin");
const billingPortal = await api.stripe.createSession.mutate({
planId: props.planId,
});
if (billingPortal.success) router.push(billingPortal.url);
}}
>
Subscribe now
</Button>
);
}

View File

@@ -1,17 +0,0 @@
export const runtime = "edge";
<main className="container">
# 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.
</main>

View File

@@ -1,17 +0,0 @@
export const runtime = "edge";
<main className="container">
# 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.
</main>

View File

@@ -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 };

View File

@@ -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 };

View File

@@ -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 });
}
}

View File

@@ -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: <Component className="h-10 w-10" />,
title: "UI Package",
body: (
<>
A UI package with all the components you need for your next application.
Built by the wonderful{" "}
<a
href="https://ui.shadcn.com"
target="_blank"
rel="noreferrer"
className="font-medium underline underline-offset-4"
>
Shadcn
</a>
.
</>
),
},
{
icon: <Icons.ClerkWide className="h-10" />,
title: "Authentication",
body: (
<>
Protect pages and API routes throughout your entire app using{" "}
<a
href="https://clerk.com"
target="_blank"
rel="noreferrer"
className="font-medium underline underline-offset-4"
>
Clerk
</a>
.
</>
),
},
{
icon: <Icons.Mdx className="h-10" />,
title: "MDX",
body: (
<>
Preconfigured MDX as Server Components. MDX is the best way to write
contentful pages.
</>
),
},
{
icon: (
<div className="flex gap-3 self-start">
<Icons.Nextjs className="h-10 w-10" />
<Icons.React className="h-10 w-10" />
</div>
),
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: (
<div className="flex gap-3 self-start">
<Icons.TRPC className="h-10 w-10" />
<Icons.Kysely className="h-10 w-10" />
<Icons.Prisma className="h-10 w-10" />
</div>
),
title: "Full-stack Typesafety",
body: (
<>
Full-stack Typesafety with{" "}
<a
href="https://trpc.io"
target="_blank"
rel="noreferrer"
className="font-medium underline underline-offset-4"
>
tRPC
</a>
. Typesafe database querying using{" "}
<a
href="https://kysely.dev"
target="_blank"
rel="noreferrer"
className="font-medium underline underline-offset-4"
>
Kysely
</a>{" "}
and{" "}
<a
href="https://prisma.io"
target="_blank"
rel="noreferrer"
className="font-medium underline underline-offset-4"
>
Prisma
</a>
.
</>
),
},
{
icon: <Globe className="h-10 w-10" />,
title: "Edge Compute",
body: (
<>
Ready to deploy on Edge functions to ensure a blazingly fast application
with optimal UX.
</>
),
},
{
icon: <CreditCard className="h-10 w-10" />,
title: "Payments",
body: (
<>
Accept payments with{" "}
<a
href="https://stripe.com"
target="_blank"
rel="noreferrer"
className="font-medium underline underline-offset-4"
>
Stripe
</a>
.
</>
),
},
];

View File

@@ -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 (
<>
<ClerkProvider>
<html lang="en" suppressHydrationWarning>
<body
className={cn(
"min-h-screen font-sans antialiased",
fontSans.variable,
fontCal.variable,
)}
>
<ThemeProvider attribute="class" defaultTheme="system" enableSystem>
{props.children}
<TailwindIndicator />
</ThemeProvider>
<Analytics />
<Toaster />
</body>
</html>
</ClerkProvider>
</>
);
}

View File

@@ -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: () => (
<Button
variant="ghost"
size="sm"
className="gap-1 px-2 text-lg font-semibold md:text-base"
>
<div className="h-6 w-6 animate-pulse rounded-full bg-muted-foreground/70" />
<span className="w-14 animate-pulse rounded bg-muted-foreground/70 capitalize">
&nbsp;
</span>
</Button>
),
});
export function SiteFooter(props: { className?: string }) {
return (
<footer className={cn("container border-t", props.className)}>
<div className="my-4 grid grid-cols-2 md:flex md:items-center">
<Link
href="/"
className="col-start-1 row-start-1 flex items-center gap-2 md:mr-2"
>
<Icons.Logo className="h-6 w-6" />
<p className="text-lg font-medium md:hidden">{siteConfig.name}</p>
</Link>
<p className="col-span-full row-start-2 text-center text-sm leading-loose text-muted-foreground md:flex-1 md:text-left">
Built by{" "}
<a
href={siteConfig.twitter}
target="_blank"
rel="noreferrer"
className="font-medium underline underline-offset-4"
>
Julius
</a>
. Inspired by{" "}
<a
href="https://tx.shadcn.com"
target="_blank"
rel="noreferrer"
className="font-medium underline underline-offset-4"
>
Taxonomy
</a>
. Components by{" "}
<a
href="https://twitter.com/shadcn"
target="_blank"
rel="noreferrer"
className="font-medium underline underline-offset-4"
>
Shadcn
</a>
. The source code is available on{" "}
<a
href={siteConfig.github}
target="_blank"
rel="noreferrer"
className="font-medium underline underline-offset-4"
>
GitHub
</a>
.
</p>
<div className="col-start-2 row-start-1 flex h-12 justify-end">
<ThemeToggle />
</div>
</div>
</footer>
);
}

View File

@@ -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 (
<Popover open={isOpen} onOpenChange={setIsOpen}>
<PopoverTrigger asChild>
<Button
variant="ghost"
className="mr-2 px-0 hover:bg-transparent focus-visible:bg-transparent focus-visible:ring-0 focus-visible:ring-offset-0 md:hidden"
>
<Icons.Logo className="mr-2 h-6 w-6" />
<span className="text-lg font-bold tracking-tight">
{siteConfig.name}
</span>
</Button>
</PopoverTrigger>
<PopoverContent className="z-40 mt-2 h-[calc(100vh-4rem)] w-screen animate-none rounded-none border-none transition-transform">
<Search />
<ScrollArea className="py-8">
{navItems.map((item) => (
<Link
key={item.href}
href={item.href}
// className="mt-2 flex items-center text-lg font-semibold sm:text-sm"
className="flex py-1 text-base font-medium text-muted-foreground transition-colors hover:text-primary"
>
{item.title}
</Link>
))}
</ScrollArea>
<div className="border-t pt-4">
<ThemeToggle side="top" align="start" />
</div>
</PopoverContent>
</Popover>
);
}

View File

@@ -1,16 +0,0 @@
export function TailwindIndicator() {
if (process.env.NODE_ENV === "production") return null;
return (
<div className="fixed bottom-1 left-1 z-50 flex h-6 w-6 items-center justify-center rounded-full bg-gray-800 p-3 font-mono text-xs text-white">
<div className="block sm:hidden">xs</div>
<div className="hidden sm:block md:hidden lg:hidden xl:hidden 2xl:hidden">
sm
</div>
<div className="hidden md:block lg:hidden xl:hidden 2xl:hidden">md</div>
<div className="hidden lg:block xl:hidden 2xl:hidden">lg</div>
<div className="hidden xl:block 2xl:hidden">xl</div>
<div className="hidden 2xl:block">2xl</div>
</div>
);
}

View File

@@ -1,5 +0,0 @@
"use client";
import { ThemeProvider as NextThemeProvider } from "next-themes";
export const ThemeProvider = NextThemeProvider;

View File

@@ -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: <Icons.Sun className="h-6 w-6" />,
dark: <Icons.Moon className="h-6 w-6" />,
system: <Icons.System className="h-6 w-6" />,
}[theme as "light" | "dark" | "system"];
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="sm"
className="gap-1 px-2 text-lg font-semibold md:text-base"
>
{triggerIcon}
<span className="capitalize">{theme}</span>
<span className="sr-only">Toggle theme</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align={props.align} side={props.side}>
<DropdownMenuItem onClick={() => setTheme("light")}>
<Icons.Sun className="mr-2 h-4 w-4" />
<span>Light</span>
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setTheme("dark")}>
<Icons.Moon className="mr-2 h-4 w-4" />
<span>Dark</span>
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setTheme("system")}>
<Icons.System className="mr-2 h-4 w-4" />
<span>System</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
}

View File

@@ -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 (
<Link href="/signin">
<Button variant="ghost" className="relative h-8 w-8 rounded">
<Avatar className="h-8 w-8">
<AvatarFallback className="bg-transparent">
<LogIn className="h-6 w-6" />
</AvatarFallback>
</Avatar>
</Button>
</Link>
);
}
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 (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" className="relative h-8 w-8 rounded-full">
<Avatar className="h-8 w-8">
<AvatarImage src={user.imageUrl} alt={user.username ?? ""} />
<AvatarFallback>{initials}</AvatarFallback>
</Avatar>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent className="w-56" align="end" forceMount>
<DropdownMenuLabel className="font-normal">
<div className="flex flex-col space-y-1">
<p className="text-sm font-medium leading-none">
{user.firstName} {user.lastName}
</p>
<p className="text-xs leading-none text-muted-foreground">
{email}
</p>
</div>
</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuGroup>
<DropdownMenuItem asChild>
<Link href={`/${user.id}/settings`}>
<User className="mr-2 h-4 w-4" />
<span>Profile</span>
<DropdownMenuShortcut>P</DropdownMenuShortcut>
</Link>
</DropdownMenuItem>
<DropdownMenuItem asChild>
<Link href={`/${user.id}/billing`}>
<CreditCard className="mr-2 h-4 w-4" />
<span>Billing</span>
<DropdownMenuShortcut>B</DropdownMenuShortcut>
</Link>
</DropdownMenuItem>
<DropdownMenuItem disabled>
<Settings className="mr-2 h-4 w-4" />
<span>Settings</span>
<DropdownMenuShortcut>S</DropdownMenuShortcut>
</DropdownMenuItem>
<DropdownMenuItem disabled>
<PlusCircle className="mr-2 h-4 w-4" />
<span>New Team</span>
</DropdownMenuItem>
</DropdownMenuGroup>
<DropdownMenuSeparator />
<DropdownMenuItem asChild>
<Link href="/signout">
<LogOut className="mr-2 h-4 w-4" />
<span>Log out</span>
<DropdownMenuShortcut>Q</DropdownMenuShortcut>
</Link>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
}

View File

@@ -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",
});

View File

@@ -1,6 +0,0 @@
export const currencySymbol = (curr: string) =>
({
USD: "$",
EUR: "€",
GBP: "£",
})[curr] ?? curr;

Some files were not shown because too many files have changed in this diff Show More