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

+ Create an account +

+

+ Enter your email below to create your account +

+
+
+ + +
+
+ +
+
+ + Or continue with + +
+
+ + +
+ +

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

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

Sign Out

+

+ Are you sure you want to sign out? +

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

This action can not be reverted

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

Something went wrong!

+

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

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

Schema

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

+ +20.1% from last month +

+
+
+ + + Subscriptions + + + +
+2350
+

+ +180.1% from last month +

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

+ +19% from last month +

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

+ +201 since last hour +

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

{truncatedHash}

+

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

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

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

+ ) : ( +

You are not subscribed to any plan.

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

This action can not be reverted

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

+ This workspace has no projects yet +

+

+ Create your first project to get started +

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

+ {props.title} +

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

+ {props.description} +

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

+ You are all set! +

+

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

+

+ You will be redirected to your project momentarily. +

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

+ Introducing Acme Corp +

+
*/} +

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

+

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

+ +
+
+

+ What's included? +

+ +

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

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

Pricing

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

+ {user.firstName} {user.lastName} +

+

+ {email} +

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

+ {props.children} +

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

+ {props.children} +

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

+ {props.children} +

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

+ {props.children} +

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

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

    , + code: (props) => ( + + ), + // eslint-disable-next-line @next/next/no-img-element + img: (props) => {props.alt}, + + // Pass through all other components. + ...components, + }; +} diff --git a/apps/nextjs/src/middleware.ts b/apps/nextjs/src/middleware.ts new file mode 100644 index 000000000..8df07cfc7 --- /dev/null +++ b/apps/nextjs/src/middleware.ts @@ -0,0 +1,83 @@ +import { NextResponse } from "next/server"; +import { authMiddleware, clerkClient } from "@clerk/nextjs"; + +export default authMiddleware({ + signInUrl: "/signin", + publicRoutes: [ + "/", + "/opengraph-image.png", + "/signin(.*)", + "/sso-callback(.*)", + "/terms(.*)", + "/pricing(.*)", + "/privacy(.*)", + "/api(.*)", + ], + async afterAuth(auth, req) { + if (auth.isPublicRoute) { + // Don't do anything for public routes + return NextResponse.next(); + } + + const url = new URL(req.nextUrl.origin); + const parts = req.nextUrl.pathname.split("/").filter(Boolean); + + if (!auth.userId) { + // User is not signed in + url.pathname = "/signin"; + return NextResponse.redirect(url); + } + + if (req.nextUrl.pathname === "/dashboard") { + // /dashboard should redirect to the user's dashboard + // use their current workspace, i.e. /:orgId or /:userId + url.pathname = `/${auth.orgId ?? auth.userId}`; + return NextResponse.redirect(url); + } + + /** + * TODO: I'd prefer not showing the ID in the URL but + * a more friendly looking slug. For example, + * /org_foo34213 -> /foo + * /user_bar123/project_acm234231sfsdfa -> /bar/baz + */ + + /** + * TODO: Decide if redirects should 404 or redirect to / + */ + + const workspaceId = parts[0]; + const isOrg = workspaceId?.startsWith("org_"); + if (isOrg && auth.orgId !== workspaceId) { + // User is accessing an org that's not their active one + // Check if they have access to it + const orgs = await clerkClient.users.getOrganizationMembershipList({ + userId: auth.userId, + }); + const hasAccess = orgs.some((org) => org.id === workspaceId); + if (!hasAccess) { + url.pathname = `/`; + return NextResponse.redirect(url); + } + + // User has access to the org, let them pass. + // TODO: Set the active org to the one they're accessing + // so that we don't need to do this client-side. + // This is currently not possible with Clerk but will be. + return NextResponse.next(); + } + + const isUser = workspaceId?.startsWith("user_"); + if (isUser && auth.userId !== workspaceId) { + // User is accessing a user that's not them + url.pathname = `/`; + return NextResponse.redirect(url); + } + + return NextResponse.next(); + }, +}); + +export const config = { + matcher: ["/((?!.*\\..*|_next).*)", "/", "/(api|trpc)(.*)"], +}; diff --git a/apps/nextjs/src/styles/calsans.ttf b/apps/nextjs/src/styles/calsans.ttf new file mode 100644 index 0000000000000000000000000000000000000000..4a2950a0451f66f8d3d77e466ce2fd3fe0e507b6 GIT binary patch literal 148964 zcmd3P34j#E)qhpbxifps9<#?RyR$p9H!OQEH^VJABA^fz77zqc2@*9iA!yWiU{pMU z#u&WN91=V*#&{y}ijqVV<7YG~Dx$#yBY3j&|Gih;Gu?CSF5*AmH@&a7r>DB=)vH&p zUcL9KT1X*89!_=<9DmH@N%$s(HsKZgcTXBWVPejsd9!e9Ail4fG;8KDj~^(w1K&3Z zG5d*0$IKl&Y-jNcLd;ntMAqJ!#|&;gW%&ue6k_pFNWW;wsokrq|Ik#9?|8=X*^)EY z25rB}T_&{7o%nsqvenB^y|FXzS)q013Xxs7y!(vRxSxyPAH;w6@>Snmw*GI8`-C`o zvygYJIr*gSrR7tOpCmM20eQ2rFX>)# z?XzzREnr6lpFOqv?A5m4XCDw+#b1y*vg#8WdafTqJ5#q+e?yOBCzZHq@^~zqX4UQL0}%&^`hE z+9`aZOuu%CJTXAOc8h#qs<6xu1)@T~_J~3e6qTo(wB|JN)RCM|?Ot<=_{));&p4yG zO}uy{XV6~*-6k%2b!g2Gv#W)3Ll1C{bYkwUbbkZw@* zYSAfXi?i{6n|M}ga-cj)E|r(aEpmt4tJ$>%ZKAeD+obK(Ubhw4CRW@#b0W&1lHB;; zO#GmIrJczoi|8)XZw36@>td7s+dljzfgNES&*5ZF^?;UbN@e6ejeomQo}VIjRq}cA zs}#?vl9z!8h#n%k5C3iA7%@l8#dl4vlk33s7s<=i?>JLk)Kkr|(&#A%`q;7JI58hR zEsnFo%PAMA=cDhKeq+4kd*UzpZ%Gu9yW~-(-=sKSTx_8T=PvIM=%19Yg{CU^4f6OD zB{TkVwOniZO^Q1O$+9@^YlxF9D~0c;YpA0OhEscHyZ9@YGk3S$B#$`3Umdfz*#jZvYm%QInnW3EY2@ z%c9!sk{?B1kOz?4!RR{8AKj$|L^g7G3FSVZbw*!`)r+XLQ_p3edhip4f>eb^)tJNc9rxb^}su14VX$FYb(P19z5yw z>HLz83nU>*1J4)iChjhr?Y~~si!r$4LBI6i|Nq)2dJud|68?YjgEkV;-O+stm;XK@ zpaAqy@P&x31%Q8Wfa8GE7M$PtuVB>5r4&TJ{I8Ujwh-|2|DjJ5TJHbC2fFY7@`vU} z|7SjCo81wJG@_b7bh=l>s6iRVsN(JxW=&p`o} zP5qQmG~@gRKhce73&>3Rfb!r8bsaw&?Wd112l1a;joxGAYT;TQ7^WUA7VJas85+>akX%T=c%(3m!B&A4LBK6;aM^ z~46HlG$O66eWlPZPz9%xCLiKzuy2HAv*DfFeVgF-PbRZkjuN54`v2h|VJQ_*?m zoDc8XOhfSh(|{ME?{F`BhwMlEiH~H-Jzw21QXW>}XoG#AfeCxA4YlmwRYu@E?kVwX zQ?)U3Ol~~Qkz5%xPRfID|i{=uwcdhcPat&kH5qpni>h3)hfT(M5cYVT3#LfFtQ^7(tDkSaPE` z8sh^!3rPjZe)u2cPFmtNY7L|3bG|XYLC(98g6uW1kC8HVqpu4O^7mkz=R6>jSPRm} zna`si^6002gRW-e9{&#% z=sFJmCt_RxDw;-mnv)nGD|MLlm zW$y7*iC6LTiPS^+yXl@v5$^%%XK>seBxAmjPjoLyDjc6uc1hfnri|z(X?{q)t-;m| zGdt|TkH|-0^?Xb|4XgNT@-^X=yXBiAQ|^IpCtL25`$fL|k30yQ{UJ??0h&#-i7L&Z zIYdZvX?Y^76~eaO3|oCrv}*NQy%?gkYHebu)}eKY;o5L*6#Nt#yl+$X)qL%TiWBlrX-yV?z zufaR84)2wFVPk$*zAFmld%!yfc<)0^-k0x-T={|gK=|NY_)wI=!|)MmvLChbqgMaH zub;?I@aqBC<4Yxc3SxjfB%`pV6LHElI0{98>s-WjE=HZ*@PK4!86r>fXdaOdPlXrP znOY{Uv$QOcsb#~y=_X(wqyAR}1^ueeAPq5toQd z#XxbHxDw}$I9kM2IDGK@Tn9cPUwiD1i*a2FkHi2OkOAB& zgI8hz`LvKSD1*3DDeG`u5AXjV*&sV`r&CTtZqwy-(Iscd8TfUkoQXTL`%s>(RV`qa4zbk(L9wi@YgZ!cVF}RjIskIzY&?zsMmm{Am zt5#XOy=(j=5H_a_Xy_i zk>Fr>%rzW39|xI_{mjQf=3_teYbEn*n^vS1iF)SO!OX8inP1Db3iS9rW z(eG=t8Zi)_^akN$4vsJfkA??th!_Sh-cZPcVcIY;S{nhL9?d)*(I#jUM3**Mn~d{f zZ836NqAeBe;C1#kx(9+io?&m}BDvaKrVeTa99PR<;ds=zYy6kb8&~H4wg>H=*pX|W zk$5;;ovrpc&Q{l3_8aZ@)3;QIL$ls>e58LL;auii?RwAkJU|_9@&AupJ?>1mA4eJL z6hLnr0)adMheS^#PyKN?+~Q0e8Dbp{5BEMVde(#R43G0Z^rI;HORMxr zANZ{V{xahBso>%1;PYwBu`}fP@bAwAe_kPGF;5;X@00h5W99wwesP?97(6+jCHDfY zL@N=;bDN&P^k0aGiaVnR;Lm%L{e3&wr?wkj!;SDYl8@{eL|Hh{*KOjrfNg-g0Cxj$ z7h~XW0i@601-Khv7bO_sOW}e00KUIZpl5v#bSQwlDuA3SfP^X#wSYQ6J)i;52p9+$ z1RziSU_djV1<(p;1GED=0G)s?z!1P_Km;%bFcvTlFdi@gFcB~ba1>xNUaaL zU642v`K@1}p|F0W1Za1Xu=G4mcUG z0&p(iJiz&Y3jh}aehjz>a4}#b;A&uc4d7b9PXU_%*8#4FXY^-)8vs8C+z9vuU^Cz* zz|DYL0Jj2e18f1@4!8sGOTe!HzXtpUa3?U_3ivHx8{jU$-GF<*llO|##CE`afZqY` z2mBuJ0N@XR2LTTukB0$|03HQA2G{|39Qg0VSQi2iE%H%PEpjaaFYVU-u6q;w+wh8u za`6OoXKRmp< zV);A+&EP}$fA>c}Mjrc+$4kg#H~6;%+>(b8CP(G~asfU-9v~l304N0b0Y!jfz_oy% z0yY7z16(h1v?YM0>~&rUxx5I_4Ok3V0$7T^kOv&6ftpJ}&848`Qc!a#sJRr>TncI~ z1vQs~noB{=R#3AQ)NBPcTS3iMP_q^K#R%vXBgAlVHMGEM0M`P33fKg=4sa**35U@>3`U@72iz;^-P1Dpf+KHvucidXm{ z{;vm|3pfw(Bf$B93jh}aehj!M`fqtL;1a;4fDM4l06)WTm=(#N18xNT0!8K%h<0s8>&13mzJ2>1xFAMi2YKY&jF2LJ~Fp8`Gud=B^m@Fn0Yz#%{n zAR7HdN`MBi0qg(=zzOICbOD9{h608Gh66?bMgm3wMgt;%F@Ujvae(oF34n=!Nr0mO zlL1ozQvuTeGXYlst^{lZTm`rW_}JwhM18yicrUt5bHj3kF`oJ+t`YZv_NQ1BiZ}U) zzimM)mZKGE1Xb}jsvnor7z$~vRfb7JsJtjV3Xz(V!SHor1ZHvMwYUK@^DWWs7_)q^ zp^%RW(H|(w1Dnzf(bwUh`+M{~SfjnsD@7P{J@SOW4}w@iKUv9<*EjNszE87B^jHyn z1+%+1qK{*)9gTj*civ%N2jOO#!(tZrFW^JIF#be8h`vP^c=jOd_67KbXQ}GfuXm0< zkD3a|iP(o{o`mU}$_j!~2xp4>(CtrL55kM6%a$&Fcm$mpR}Ve$bw9w!L<4g!sC~?K zWWKMiRhel@?&Z1UoRaUTdZKph$Hng{YshrcYtGkP55wljxmv7c(GQYK>iZ6SH`HQZ zf@jH-wln%9JUYxj_y+GC;3>3};uNwWP#ZlI-9cU-RRg4WnaYXYh3BYkVYyXyXQbTE zy(3Kvq{c3(y;4yd_9vZhVtk}>W4_Y)7Hmeh)rn;!)i^Oh^7lco zD?T56TK^#%vD(?7$XjR&NEr0{wsN8^2Hf# zt#T#`_8Yr$P<1=3Z7U#YE`@Ix{1|;X`YOtU_D&^&yC_aWMDGCyA+|O89p~(_6PPibKhvMs32!r~N=H0j<0^We?kWE_ z%jZMU9^|qM{#&LoG^_%XRhJ?J!Ldm53gf_aiRDE88YLm;vG+t#h-qV_>DT`tS=^(a zsNN7Yyyp%bBiIH^7Jd&f((S%n=fCPD#QSG!yiAp2ZxWW%I^s{LE0jz;Yj{>vTeIAu z-}Q0rAVyi@Zt{q~3cBf*Rs2V6E?VuMoR)h3aK57PnLp8w@x&(-tD)nkZufeQUXO@| z=dGT!=&w=A#^^)H=Pb~OJqEY~j^smq2S>9dAD$-M-Gh?XBbSLtcNucTtcE4-1L!GV z>fFB#c4`CDEpep4bCfstkoaBXj#wx@Cs@33kMCR3?nW*jC-yGA2N9o{yltxAn4j+J zTFD*c67vV4pM9Y}#nOdKj`2tICyWo*vWBlrwW7O;xF_dez7xF_F*Ca$`xHG( zUIrQtG$ig$!vx$qKVezqvQsC-oE+DGMMC$);6=gD8K@aaPvS6D6FvJ_?1%Ba-~WvkFdm5h2l<~5*cH85ag<6CeT{Kb z8sry<{`(5>Y4lOTnGSj$^if`pn6D%D3rP$E;_bwoZ`9PlpuhiB)E6+-d8oh7eG}=> z3TLB6JEA{D8)7Cxd4fki=avFb(H*{i1Lu3A|H3z&7m!MCIo3c6@bs%X^**CHicUea zu;56*qhsPlYVi4vA^vsrz@;-xg!} z!~I9`5vYqE4}3@xnR*Ig2w-l>=#BVK5ztDiF-3W{ z#X6AC(1Jzqj1@s5d{|{{(8hIVOuGy*w0tdur@j>f` zD9(cP?K(pf=jV9b)_>s}7>gCRsBiIeTvw0Xi{6Sd{u=!a-B9hpIKG8%#4{L2sjt&W zp*Y0gh7@fV%QJovmj`$rGTZnbPhnia9tGJ!oEe1$8TPDAz=|xC_yB`185@eKCd}SI zsyCwlzWQBf8ueuBzgcN-R|fgIEQ!eW&NG-GbM=Qp2r^<)VNl`V?KZE+Dt$)fGeFmE!Me@+NM)LX{)MzJJ0^sK(K->WR zqHH{-d~s>$_!!q%8&LWpwXlUJDd*(z(3%zH_8H)Ez(0{2csd?w0$T)Tm%xko=u^m| zPoP&*o5ggd=>I@xBka|jk7`HSoZd4yH~Q5?YsM1y(tK#%%zSR}6Q|S164T5Bbwhv3 zf^7UOc}7TlWPFV4^sShdMvMor*K4D4B)bjk&)|1ly{^8g-+&R}#hb#U?VzOL`;hR)I!VTmV(r=Me9l?3GvYa$g zi0v8BkK(mw9#Hy`m5wi`>TNow5a*hCCI!Ew=cDveO5v~Clku`(lu+SbznZb@HDT;W z(&14nvR#r-n`C@GZm1TCTWad~4)p>0g~wBRPXBOg#c6BY?$>`R&CevjXu zM(6|Vc9Ha9+PsEd{(#7VhDMSs1bjj)pOvnu;AJ*Qm(bO9 zdp7xmSf+5BGxt)$zGg-qY6p^Ttg#uf!h{W#`41`;zwXy%W)J5`_Y9y^F}n)?dVtRK ziQ{1XX0!??N;<**3hNT^d68-IK4|hbo-k?KG0QUhC}>u-}}Qks$WuiRZ_f1;#Lh&nzC>k+D5$hO(yP;_ z?Z+>=j?<5nX_L^(zW;k4gHHc9Fzanyf>o|TPV}Qq5}rBSN@|F!u_2OIh|3WDdNHD3 zuaa{RyLumDSrHW|@1jUmc{jzVB61bcrgA$)2+I2qAsCSNQ-q-W17bYuR!yA2k;!(%_XQA}L6ONC;{0mRt7{Q;W#{O}!5oe3KqS|A#2Qj0 zWG=MYnYgCNR#E826coMSSpu=Cizp_S~z{$u6h1JQ$&Q5;nq)m+R`%_SVw zT**<*0gh^}=cwiYM{kyKY%?O85!+nPvCRR*Hdi9Txk^FH*x%PImbU&Cd5DgR0QNE#K_gl z>kuIqK-}ri5HEcL;=t-y zFFpj^C{DGOqo&I_{<)lEk~=ttzMUgiJGD}+6p_OePp=}A+c*w6ORLpt5kp_6)gdyO zVv=nfX~z`rMedx(Q4acn*D?s>STJ?!kvwUd}@9n7^3=GsZj zwRYxOCvz>Lad8BgYu(JXWz4l1%(Whlt)ImFYUkMcT;|kq99y5uT$ssRn9E!^j=69m zbK!XA!imgT|Euph(`_FWudALI!8YUVH> zuQF-nRVJ+*ZJ&qrCGUa@X=O$w<>GlaZ!XCRx17q?uQl zWbrDKX45K@PVONCICg(1_mc_SPlj)3{|%M~(u!`bkl(Jt)2-K1(<#0>1yp$D%*SXOP-_fbY$RU3w1V$d&QK zFZ*+pffBI;amjC?#u9s_;Vk`peY*BCDDf7Z$$-ST+e-EuweZh0&3Zuu1N zZn=+kx0JcGyQOTW-7V#Dw7Vrm3v=FTcgrexK87ITeg>lCm%?uUBkb*W9jC|vhtQsv zA;j_z#i&0M7OazCY5y*y;N{|aJ&oE6GmKdOVbBR?A;x~0I2~&RE)Z8B?wv}?L49bC zOj_4896H0%h{0bjR*Q2W!LLMIyh@|?&aA=cJ_6EqHl*;$;@em`^#-JL zG}aT$6^q0vVlA|lOAx`oSx=JzjiCY-pbinidV_gbC$I{fy&k&QwTRnS`KUcR8{yv= zgP8GSv5w$WtOq$4K8~LvuK$+q?p14Te>;-%k|n3Ew!Lyh=QEpIY`d2n*}2tr5IG#l zxy|lD4o7lsx0fS_BRO~2Tam+&oIC9kk;B(MFI&^S#J=E2&Z|}|@3t>GqVwrXR@s+f z_up0aReWB<=d<~|j?d@w`4T=~!RKpNpZ1+o?Kd3B8N120*l#|8$0s~ z>EaXt>{>iRK9Bu8=V|k7dA5nR1-9?mHrey+)9hC}8XVIcr#Y^U9d|hHa6IGK=d5y` z=-lC=6@XLS0)$|4VH~Dw@A~ehRzNJs{&ZW=Df67;cCO?y(%P-`Y@+;CBNoSM? zv;r+(Yt#m6gS5d~vqtMAt7!&}wZ09|uV{Dc3II{Q3|A7Pb0sJ@3x^Y9=zPpWm*Q|^ z9Q_Gqrq|-|@C?+;TD*_64Ctf*tcT6V46zz>z-HNuS>Xcg*;@?VY7tgDFUFiM08M$j zXyI8^8|v^3Rt`QVUlgO{OY(o9lfDfNcM9vOGf|JP#4O;AwQs<;MjQ)#2Z;r!L5o<# zbEqZI2bTaZ;y0xaP6UpA)S(L2ye5o=!$brW`KiQu4b1!&_QGYK7#X$ z@=?ss8nh;yTQFBcPMCvqLyvz{{z*QI`CAkCi1ZxNdxr3T4Rp!Gj8Q}Vx8q!ey9Q^R zjPX9k7Zz@~7MLh*AU^o{VR6F4z?@q+)?%65R)X8g&aL6#cJKoG0l<#-9nWP-*Hc|E z$0ZsMwD5~%MI4_LVSAr4_ ze8aBhc+O}C(#nZqc8Q(NnK~pq;xx)cz7G%|`yn)iuz`zK5J{0^AI^1#m0iHoz9Z?SMM~zXbdW@N2+t0Cxhm0)7jiJ#Oy;V4r(f zK%_RAv7(jdg1IB#gca@5;DJQN8gRhhK?Myw^{136owPp8t0Fa_pMwyF<@~Mu=h9 z$$?}@3Q3Jx$7OTAE{$uIJjRm_^oJLL_}ds^GFc90L4p@xcZWvsa(+S?Z&U9{IYz`r z0z9pxf0L5`Tp#ct%6J4aSOS}OMHb6lA0%!*N(!Urm`kG8I~sBnt%sG|rNWN!<2dvd zw{{8s3o%vw#~uc)QXlJa7oW{SJQAV#xc-?t_1GEWQIYkfro9{;BC~CK#Zy6nK8p|R z4G-d9kok%@hx%6m&eS&8QQYws{$D88qcxo9(PY0}2Yc#W;(nPg8|5hMr9T~(opl&% zH)4d{Cf|gep-Y>uJ*mBFd$c^Oyt2Hyys>FTbGt^73oTZ!G^jm=!Dt zmIlj%Rl%BIbFeKqEI2YaKKPqTe`QtWbyZqbPE}r2X;pbuW7Sbri>gj)D!zTgml|ep zf@M`RT6;4rWA};&ARU_I7&!&@r;{0v%^1&jYsHMm6WU9_BeOhM9xiVvZ!eFOA78$> zd{z1S@}HD%1Rk4%u3&z!I9L`8G9InLp}`R`Jg!K>V=3^s^h=5Ld!L{!5l55i6D50} z{^@zEXG`>IAr2if?j-(y133xt#=q75H+JqWe&gS7y!gg_Z(Q}pId5dW4uA6N<6m!o zz4G;j*F&!ty>{TWe+coyD=?KnhTxg=0h_T0dqhr@Gr(dE+7#_L?L_S&$`3oQ->u!N z-AC8>hX2@!{ZVZP-gfYW_BU;hwom&&`$X%ZX16(Q_v5_7_Jr*jPHs8bh)*mZ+rvl= zz(bsKq!llzjoS zFb|&xY1<91Jr~x4^D&FQ8rFe3(ZctjjUUn+@>AH!BBHnT7~ zl*=kuCkMf{G#%E+S+GJb1Ye#FOXE6N7%#$3E)}r0je>P=A9`9Hw6S_E2p*ZMmB6== zrHzJ_Y$0~J7y;T%(2C@T+9LU&mM#AWWBAM1MJ+r@e4qc4j;iRa-{cuD*VUWGm43-O`YFFut8 zu*Bv{pLkEUqXoO<5II$j(mKG+r^sdC=M{22Y^>)&^L_<=|5@l-uVPev3qAH7jH`Pw zzP>HOu&!0ZHr64&f+qACMzTY&K7S;JOPd&m`NBx({UabF#!8QfNH=zeC=t`K!hMD; z7L#SRm;haJHZ+(y^sWHx6cG~hWi|GS2#XV7uUjmeAlsY85;<6`fal={aP z;#cxJVjEVS--i|EzY`D3i^ZezQt^nqM4Tzdiks!Bm{+_ZuE1L$ekvDh$7AMjg4PY5 zo(~KA`_NsTVx07fF)~BUm6c+FtP$Umwc>bLCl<;^u}BWYT%koQldWR8Y!esBIq-VT z6<1={mrYpzex1aQ2l5BvxAJ@9E?Bbe$NKii6ZjF?|W!S`YTF_!yS3PoPg6fUGZOzbW+Ol*+HVRRXT4qSwB0Q-}Q z`bZ7+XPeNL+oerP+p+j(BlXiJXWH#{m)(`;%XK(Q8uKfCmDRpV-x&E~PlLRtXSMd= zq46Q@%tM#pd5TxiZbF_e(6BL5=LP%8X%3t2gW$cjbUh%{{L)Z;}sB^|`Y$r&2K&T+s8#z;J8%Bgn|6%Cc?Z zvhqY%q+OMU{sK+ex!jeyR8%9!DA}D{a(a9Vy@HjF&%MJW9Q&)4adcfnbukY6r^CP8H z<6uSkfP`9S^;PS{Mod-nc#9_2-ZpG2BAMQgHflH7jJB;ys6SW7+`1{MJzZpEc)Eqh z6ZXsqM3`dscsuv;fL{NaaIpMv>z~)z*6J_R8Nt+*QqiGga8;HP30>em#!;`VX=rcJy zL(57a&(v)VkRziknPgD^IjmeFqg4i;^$Lx{&SC!iNN~^~w|h|AptgaH^|j%uiUB2N zvgf8HduNOjYr7zxI@)VORnEdtA1zTorlmD9qOGi~C?mVd=grEU^Uu`n@~e?Unp%ft zjdPTHbA4Hdiu5Kj_%wj_31U?4kejx*LgOJm%>zY;H=4=1xyWHym9vpXG-tw)qwWRf)_*LqdS!^UeqK9S(y!IhPRI|g@DHdQtaY^bZP z4h1X1RX+S-^zykYVxt#}tRg@0h#J9aYZR*#^)aknF|)H`U};{yH+1Z%;pH_YdBwTK z{(0A=AM2iSeB`lH z>1KtPu}qFkOhLgEI8GOCcSbkH&2YwyoE(xf{lYfgh?jtM;$UY+J$amCT4S$I(0WE> zD#B^(ehh2cG!M1E%VA$GoWui8*K#l2ml;-8$j^7X^Gote@_aejSrGbeFR?{#raKVh z`h4(2k%jCg-Se+B9;M`so~>J#nc2gTcV{qfRAc^eTO_YkW_Y2FW)R)$F~PAz_|b6E zA-tXph(4tCKnSjYGUm2tKmr<^lZC{Z%(PY}5b0FUWvoBE2j@qMYieo+)(or;R|N+I z!c}_T$ToF;sYnoS#J&a#y;;M3j1$zna++m~`0CSyF=9BX6BM=AnV5rmyvwuG4B4dmcj{7@A2&pKzn-RnVT5&HFb@a)%7ey(kybHIZ%2%c zDgDR%l&lSBDKK3I%P-s*edIb z%$-KbDVnd^E(8zc!}d@^lD-IL200D0M>mW46&d)hNvE|t6cmK3%F7B$3rf{c=5d$B zI(3|lW79Cpt++z|3q$vo6-xI1z__6+C`zc8Z`=+g14=sz2nFcQRpnc0lwYWHvoiCm4Jn3*&L1L4Z zP7-k-R6wW8O?|}Ww!2o4{XjS!D?F*BdLS}LC3jMn=sW%VNM1!nMRi4WUMQq^#hb|f zJX7mK1kKw<1EVv9$6XIG*HskN=Fyf8DnxTbnY zq@pg#oK#&p8%m z#7}2`ah2l&JHZ2ti;Z@y^WO3;ot5QrCESO~xRbzM+R>mq$T)qNts+0EDo?k}lvaC7KmMI)G{} zX|wYPj1YdiZ8-)non%gOFu^8G_T!RbUv7C>F@%D@Ah*a@)ELCb)|jshUoJnHznr1U zE;c!Y3iGLhg+hg``&NIiZPe_EBUexD`SP=~Mh#tc%#fB9$7l~OozpR?DF3(#!;WoU zH@~*=*K$BzvU2V@%}Yj~ICg1g*Gc0Rk6PC7 z4o}OvFYP=?Pdl4Xj{FEz>b` z^?{j*N3W@n_$N&a=HV~Di} zFE0cymrQp{aP2~1P->fzn?oamzaS@&8>p!yLsw~IeyF3(hsr0EUD#F_DqMWxiB=4* zyi$hFlpzxux1D*LJ{DXB`R0Mgy;O{f3?~f{V}V`6NDBWXOfF8H0+^mTjKLs3&+9EI z$}7z;_4>R%A1J{X#j(j(qJf8jnvjVg`;|;M-9i`JCK3i;?a|4D=o5oJ8a)0yWV-aFlRW2q(Jo$$(rpon_hP$O-z7ks zal6YcWsqgjQqFU1OJw~8#zdAe@$&Knbnh(ba>{8LZmmD%W@hr-C12IhlUTaq1PcON z6%*+7Rjm&+EcND@ENp?%sLCLTf1|+o%-KYk3NSe9% z$>IwPJiq`hR~NZl4FhDan4m}I$FQN*lz@An>?*VCzHp*Sh4|$kb zL$~ZdWz0^|vQmFRUznNg5vgc4C*Cm(7MeBdtGkVEVWWMMfGg3EG+|hB3uFdb(JR5i z87}8?;U@K*Z0Ei8m&{CmA*h&}nUk5LP=+OUfR&VSYpw;+DX>%Qw>GeD!8O`g?y{f3 zZCo#Y8_6!0-YmFPy+l*PMln)kWqLES$l0Emm65pusXb0FthfE*fB@`y{JfWZ?_VPW zgdfz`)r2c6EGP#w6X!4wSxD zUNnoIKzTXcuJuQj8D@T#d|<-=T3E4Bx402*yqfB8sInqZq8xdU@nq69a{{wKsu{QL z?Xd8IJEaRxx9A%DkiwK}@B{3(Ud$asVo0O|jR!|Ad@?I~?ah@HVK&AAbx$hP_27xP z`?{vXf^v}|bpB>s7Y&va;6u5RISgAA5FFQGmZI+8fbIrO z(L(KECd-a(DGWN$L7^MG2|gP@mfn2na1}_WgS@6LEC5f{&Mv%x%mr(2+(}UwswM3$ zQFRO@S$xgNNQSNBmiA9AyA5VcFqBeXRCAQ)z#kbR z6bYszbE%!DC%F#zfDDv;m^1vWXTS9H9Nd8HZ~cDHZsbMwuz$cdS4I9WvM|91CMiZk zHyz?m%sl7lMred1=$HX4szjwPuco@jg%T_VI+KO2v&+>{sb;feQd)HTvSWWRXz74i z%`nt;E*(4Pq|80?RL9ZREvH%hlG_(|bw^BJM;KuUbJkFEs2LGB^|jTp zx>F!RKk9C?2H^Bc3B0N^7Gl~9Rczlif^OpU*#x}m$yQhkt=FKD%{13a-J}^tlR`a( zQWC42r=OLJ+m_O|ugSoarkP>)qzN0f^$?vOYiPD+)FUlNk`V)DLNq_gz;YLO(^3uc zg7%}0T)MS3eH|uTvUo}_EfU0b3-PwN5M*o;6Cz_x_^X*W1uQC~ytQX>J*a05Y^Vvx z8lj)qC)&)?*TP~rOV<`L^I5F#Ed@q3DeFV=slC?c>$HZATp_*olT9>g_R<`-Zui+~diR;#Chg0ypb!2d2khi9#hL6lZ3xS6^M!B@wQi7A8#eU(3!<`Tk1Rbj zUWR+HW*M~=+V#jUANn#p^JG@@WM~era4e^=7MIiOT22A_WbSi1OPwHWwW6$%L^y5UEK{OVZQn zUS^>sjf$`ym1LHv_>Qa_qQ*LM|JDH%t{cK&_uwjfIpU4J9jcq_a&ch?BBUNs;Dhmz%IyNfDvm%Ns zy@T_@jG!Jx2WVpgqQ#hTfXpIZ(tjN(U z>MWDX4I92=(9$%eL}{t#leH+}X}#^5vT zmgjR!cbd$>`-bz|h|RFUL$D&^$&q$vHay10T&@bCJ9Y}I#`<2T=HL+va??zkKqRCR z+1K}yYJS9{Vs>&}H0QLmo^@unVM9xb`4~`6n2c%B*TFM+wx#{#Wg198f!S(~h5RtV zlA;Q2^gP-K>(?WV7@=(xNrQD>SgJJ@q(K+Mz(D;>BPV27n^735F&Zps0tM!7Dk&}S zX8Oay)?sDgX5xv2DHGOpf%0;!?Hu4;CLU39rU77gP*HQP5H^G{2%CeJ9vdB8pCN@X zq&OhbIjxJ+B5o!rDb0uKvDDdSvm-8*pFqPn(>)Mrq_l_)KrAXHc2F~>OHFefZyuPI z_?B%cMoXrg+-%rzeoAu`_m!0M87+E1&P97RG&m9tZUg$g9zJvQ_k! zsRSEbbT)(`$4YV-CDAH3Rhl!oG!l`f64ygiT1vIug63A`-ofP-iV=~a`S64rWkWBw zVO5{CaB_TcH49&QXy_0n5kKy5H`NwFK9p*xVy;ceT$VS| z(Q`VueaAYg10H@HJmAo0>)<%}6k}pLo_7krhG=KVR*+n8i#3&r~H6N71n)Y zjMwI1H8k-W+Jx^@IT)X<7?MD2isH7{<2#mE(`$j!KUz+^7 zkzd%(=1pU~M}7%f4)Q(UG+6Ff7a!AXcvC0JfGj7R3&miBk*1`M*{%fZU2sfLZ1d65U=l_loVnd4} zC=gOejL=7&9qSYi(cAyH%q)sTz$zmrJoDJX2f@;bx%vtVh@oIV>GHJ|b``qFz1%uw z%7zn8pr-n?eOg^4d-TRTF19=09sjP=eldFdpUxfDd4cS}+$S($UUN^W z+|xX7LeJYkmigK?5&DxKoQQSGMyY5^T06YLT6(yO%U7+IvV37ygOYHOL*h-D4lA=7 z#{HpDrwR60j5HYdnenpnRbLp|d&h8S-x$VOU$q&}BYmPg63C)yq|szF*g>ZF(O^nR zl>VZc4w{$Bnp$qFm2&O?pJV;EdTzwO^UzSIRF>3ClP92|ZmTS*p5CLjC?Q#)T@Tsg zL+`4KRO_XXc%;==iWGWjJ|C5*lxiPa=|=X(D_3zyO^RoV8ym``PwvB zEN;z$L0(>7abB@6pYWh~Eet{l^_F_zK>{jOM(jZU3e>iHS*_X{5mI_)=E%(b!@mf$(zc zBUnJuN0_%IJ?Zb+4fU+R6OGtko7B6QyHYCjR>FNhjdGt%Nn5hA4?POHBiYYNNY~GG z!5oL^G-7@=?;(BPzF280A-}H}0eOK&2iCN9=)%tzLmXOvNdCF5q31!8f;y73W)7Uy zJYJrw#Gpwu4mgIU56YLYAN~%^&P8n`Oi^du_<_TZWw#wlaZn;!^%;kR+4uE zJ`S?akY8gztPoHfx#TWwmXsmdcnWfG0(H|Pb^vIMtEDx9Pe8VW{ zB5PMHSPM%U^nPo%%Wl2Va$zBbhYl`>V0p1WH$NBgX55RtDH_fOZp%yP$@f<$Hr1)hdyA|t-Td=N}+Runi)7J*4UsIgG zrSyUV2&zD&tItKnsh`9)AVQ2BF>FXjTMD+wN}{<>gB;bmpbhGxV6YURdQC>(Vk>u?&?$9}g~MUTMUhV~BPDe~{VL4hy$c3!pV- ztOZb|Emh9wZj4IWE%IPkyP}Fk<0xG)7}=&~=(dj!d)GhxG^6qIJDq z2urQ{qe;ZOIo4#SZM0bETKWp~j5drgP`j-91;-z9lfZztJfTr^B`64<42FUj#A1Bd zFWmydK0Vs+L&T@Y7@{2J+!(I+Fb{Q#%Ocq6!0B$lT4=*_qAzStS_hp_6@9eAhF0ko zLZ^NJJ3$E1+0oY0G|G|SxUgxzPtHxpE z2+)7npXaN^U)b+jXcxdzEV^mZ0zI6TcIgX;0+FElneFrDAwQM`v4Dv92$z?kq~iQa z#3Pv&3>ML{VEEVzjOC1pW0=koU7emmp*6EcPDrD_=Y~6ib*pOIu9}mq#>e<#6W4W+ zxVV?v)(Q>r$eN4d9Vy0^mu8wP4!^1mYxe?}5GJNfK?6NCR`22!>H`}QITE9TWvO4E zbTG{m($c~-TS!k2nl$Krh4fX`{vG1Sz0ibK*(7rSSB>L(^f1~$q#-RYBqe5ww6zRs z{Mrj&}{hZ(lgG$_5$JcrebD1x}Jo@5fS>B+;A)}YHh+@B(Hk`ff&j&^L$Nisy61`A6ql%&`# zIBx@y><;V<*^ZcGk}tMt^K}6;nBsFxk;!hUq3dY~O-_(GUcKw7yxY<~mV%n`M=2J1 zsPU-g&d}WJ(ABM>&AnuR1@DZlwjMi7ALhek=79?%GJ53jA)N`lpLtmA8Rz{}V@29- zkaooArNI*h4x{bQ;kF+uSN75;8e$>GE~)YUQHVKCXj3eHWO1q-?oFwlNYNVcr)Vpp zc75$gEJhi{!C|GLv|gVjHGYf{qO7?*F3po_848CQ_mWX$Tr5!8l8JQYihtD3VEQNi^fYd6zu2!UH68U$lN{{iy(B}=?S*=B%PgEUa`aCbY zG277kqtDaWNXz2WtB>n0STx~}fL;_NsE}qIlJ=DN|7P=pu{%AkCS^V~frpsm2$=5gs7jO?IPK@CyEzipE-; z#1PNvQYK+7jy8lsdH%fGJl5h6IM!us{XniCePOibFOtgcYFJ@wr7Zf^xY>(4h9mHG z<-GB;7Y`Xtnp|m#ENUJFGwih!VUaB_>v<<@_`G$k7fnatYsYavY`bh0sdJ-dR>+gf zV3OSnm8*6o3K^OFRdP6D~fgKqQi)HLF3|IP7Fww>y!Z;3n zzB)7@fZ7#NcmY;g&_0u~IQAk03dfEPXDG2vWZ;&C3vcPZ3LD_Z7NdWE`qj_FiaBB7 z;K7T=$6XUk=JA@}xr^C5v0}`amC8Fob2#!J2jHurckIAtN}-wP_}Gr#Za22zVOMpg z*O%j^-LAqxCq@!|3mI${6Ux_jk7?a^_5~N5y=cQ*XN??ooc7?iSDm`HM}B{COY`GK zIb|p(6Mkw$Hge>!`PzeLe)rVV&N=HqM@L8d3ursktt9#++f1-41dSbKB`C>< zSNzaI1BVTU+4VHrxDYL5NHlcx#dtI*X4K?`ifSs!SzhhLR^i6(G~^s|6@>(EPE*@f z6n7A5rIV&F;{`D24MW=(ZR$A@O);=~&ZdeQb$c#5W7)N-Yqw{leW51WS1 z(49r9Ee2^1Lmm~2*2rM!An?L4g|II?&j!?{Nc!gRzTBe1++rX3(}PT>u1-cTKZNa5 z5pZto<51YT`>YF(J$mPD*JNjvZg_dhI-aLf;B@`ycd=`6LU@Mo@7?lM|OX67Z{u1f1;`paz%;s`1R7&i$3^l$I z&no=<7-8Y{@xjUt99`P9j9B1-v27|=uDf+k*6D`bny@KSQAKeD?b_taC;E9Q8X*x= z>vkAcGVV|j374m&rrI3g(5Gga!Y699ZJEHXM2e>AXvwo;eYJ}(_sFT*#L){WsoFFC zXVA4MW?+zwbe4Ryc^&zlfFbSUp!+L5(#bnKE{D6xOi%M(w1ow<5qqGdpscVA5-lBF zA=YBN8J~wBuFK2~Gf0Wqz~rqmY^RA_i+Hb|3z*0~Suu+v?Yl5lr#02(lat_iyyF_^zYs^!y9zbdq7a^V(*iVBdW{50wSh%!#3WUyEFY2lXR@07J98XHx z6S=T!V*(7@Yms7w?X`;V+Hl(b>ye8Gez)}K=H{UdV>;%XFlzPWnWxvEUD{dG*x5OB z;+&&Kt(}oIdB*hiwt?ZwhLUXm;_0InwvUZWX{c@pg+h7$n)#DQcXyy@ERrEyAveg< zF)Y#*#}*`%h(=1lI<|8VnYDDxNh$0%<|IM-#y*$)uYK2mcjc0Jzu9BrMT}DpUW);9 zC5v2%MJBu!1BOOhXv&776SXntoYS)&)wa~TSxk(KYlJk?>#pfLc%`VlzReSxW||wC z2R98GsMCPVdi|z>O$n*K$&jN2R=+605mm%{jm&$a#Q70_ZB3rfZgUJV@9ssrC0lm) zayaZ1GK#r@&8GFfyH_7c)$U%S#K`J_VSRV6ScEj~kB{BGC_21v1~+#4+H2Y9>&`j7 z@%-uCxrrNoIX3p%1?GL*Td8mUW$p*f0{ikbnfZZ364J8+9QcDx-GO%f>OU)}Wtx4n zLZu#K(XURy!4_1fA-WtIL=htKzHOC6+^5+MrHfK!wEjB1o5IKYuFDtoVOK=lW5hV`Jl;6oVjlPlMiAO>_7G&>G9LvAP0b z|CkwJW`a)2A#M1Iu|q)V)i_X`7hYfn@4$)@tdXSkKFKL~m5dOvW#I@jgUqWI;ShRd zwOSb-?}wpKM^|STjV~N}+*Vmgp~p1J$jFrNw4CbF!5t&J=3kVVwRr02OE0}x4*J!s zv29ByW#tv0lAS%QesFu%?)m8r7;_qRRq_bvs^z6+3cDJTBV7)eyLo{_ ztIS%quyax1?1r0<|K66PkH2}@J*Q5azC5evEZ^eWmzD))pE$H>!I)W#Ze70Qj>WHc zch7VQ%x16`A>L|8?{o@8ia}+e&l({J)%sHv=o`YU4yyS-p3=&m+MtbEx9-pmHHPr> z7(3~`1z7ip72CACCu_4LEs}E7-wZ|jcd1ZXJ!gFnH&uWUHp}H0J9YtA^aH}Rf}|4^ z9a~wR=5YE@4wkGqVfDcqGE4>1NW~jZux!V20JMCKDi?c}>E-JCDOMRvc61r7YU(c_ zaawMcz_lY(?HgHY#&=enkG9T%tWmV|3dHZ3P$6oXr3M8(z|q zh>8nJr9$Bt4shk-IiwYQSO-~vmvEtmSSLV*a?GV!Ma26w^FswnMdX-s+8(--g3qa+ zet7Q04M$Cybkv54=g!HR@co(c_dWBb9dpby`Kz8uGrvE9^&Kw5b`h_SrnRy_9usPP z#WlKO%ql1Z7DX2z9hIn5yW|pS6IFAmyl-l9$+l}S`O{SWY{ygm#0q7Bt4e%@n5o(^ zUu$K;`@@pc!1G=Jn@mAv0rs5UB<%78fmJJ?K)MSNey>pzF zrL?DHG57me+@cA~*tVdUV8@B76oeGWDP6#fm9arX3WTnq02-T4+cQz`3fO}og$_6K9+i$}+#Kyz+lSPb(+qt5RYw7STI zXIvGD4dW(k({ZD@pPMm^Z>o@tSbQut_*3-_w&9>_pl_$>9L#LIZGcX|bZtqlS2HR; zieyQ~0v&|RBe6bm3$kqxKZ_MF$3Kh`%6%%^75m#c7i|q&^S(zjoDR$%s!Jex~LMk433i6Tm zpN6h%VBm0KWL>YXo{BSs9eW?^$Qb+m5<>La#oiDyi%kQmWYK9tj07m*)~G4k)Y9G- z25wgBFhb^~Q)H@EyZiPBwN!n`Kru}}GT|BTO$IL;w1l=|;EM&#HphB0($GsNQV^4O z#;zy>MU56FV`OgxHFq-u+vKihLR9Z?Mtk3af?CD3k!fy1&oN#|lQ#+A?@hL!G&~ynR%> zkFIFYqOa~~rc7d|G-z}RXw)t{wyS1T^O9;BVlwa^#1)X(^!87id!>mU*sF?qs=nZ+ z5wR1r2OU3y7i!Wo*bpk+Bjjpp!5b)SHf)%p?_P((`hIW#9&EKdi2dvO@gxF6P@2O< ztw!B_ohmi{Fxs2`xP>a?ly_)M&(KQbycbM-y{H z4o-5*$Mgg>#sw^TLcAl|x?_3*TmskzJEjMw(-TObkq_QLnKaN74X=9&!H?n2GTzO> zc&GMmIYNCD>a`gMwGB}U1iERTiMnV5>mMP3ck2e>8L5N@r(v}dhkKC65n~KCuYz%z zcUHqL*SHZ9RrOGbF)5{;tFTFZ7rB_M8m7y%4TZ#>JN`L8)K&A@lbh8mTdHeb9MKwR z@J~F)qNmS0ucD@~EI;3VLMF;G^g7Z9NUwt@j0SV3=^Z{XW;C@LMW33+@pP>g7albo zhBc1n|7HrFV!Weeg7I<|E5q^3!nQ_#Q%jEVf)?Pe7{rMf}#(veDXu&kw>5}y(koW#Fm=~?M*Y? zU_$rS#i_sar+X`ECwFWa{SXAd8FNPu*)V7`5@WeCEJm~gf}eLlu(j!{yJg6C^Jbsh z?c4l|o`3%s>+r_4)YcD{4;>mWWb{)6@C-m+#WW8s@M2dCb&a59fTyE%DJ92pQ zQE~&;MPE8(`0yb;-vQfTHxMo#_7w79V(q$tL0tPXW5R2J{k-EqT3l#_bXt651A8sg zBW0|aHsMtvS|UvY5V)XlgHzf;uXn;rxvgZv#u`j1+La|OcKSk{vkGZ`{`IHXLvBxK#dYs*kR|e# zr(I(QRE>YSXF<<%?KYYX= zM)}Mc*ucfY8`;^}zHIDHMErqJB1;?5jL%Z`eDvGIiu3N+2F>pQUxdY)NNx#sY%Yeu zT$OFC@H95^La)H^g+0HN@yF64fk+t2m)ei3mt?dZ7rhG+i1T(_7R2MtXEB~w>M}UV zdXiYPqt zU%6CiNzrN$h|04&))>|(WS{nl$UsVrkS)B`o!&y}#PlkCa#F4`)n;ZOAMXm8OukgA zl!bujrJO1y5;4{O2Oe~iQ7nV8{{dbRgacdjkh98g3gMNF`b&a*1-^XR0;WrLcHu>F zg|Hi7{^60MyqP@@Yn44ayqR+Dp=Wcnyu9plat?jq%Q*-6z?Vs7=<*nrT2m>Q{jh&I zWobStk0hnFJW_6w%9Pm<(B^`b5@RmAK#Y!z$ia-qDBouCS*5Hm`eQ*ha3DNr3m7XN zy7wvxl@C?$f)$zAzAuUvN*<=71?_5>OnK~;k&+f>H6;m=9fowWpkhdAY8S{x)%GQ{ zO4FcA%GOu2LWCS)QkV`Kf2SV%SXI-Jgw2bou!#vu#V7DZDSUk5#E2)3kMc`Zq#%vi z!4`;=p$l^WR4N)O|5QpcQs`3>!apI0m6>#KThSgd-}Js_YX1@16Y0OZ50KhxJ&c&; zQqde4gh+Aq9z--hF3A)C4>i57H6F25DoT>yfmlc}TE;sNUGSclZ&`T!Z7Z9ODIe86 zZb>@=OBaszjx=6?SeSo$ubRZ{e38RSO{M_ZroJPZV2w66?Q z;+2O3bX>_e5euC5C*!JOq|y*^pdJ{N5@TJ)sP`RGyS9i6BgL`z9X6Vyp&W_PP<_0> z@UY)?*dm(y^RB~$rZNJcl3#V0#8FMdl3sLZTXIWxLPHii6W((e#vXC>UhtH>miB#t zycDEgycSn?uud~I&&@X^VM@k}ZFa7S0`!fD29LC;64xvLFIpO(i2#|0jsn@Z*BaWg*QGfem`>Aj z5}onfZmrv6B{cT;RmydWv%9OzeDwDhYDrVEe9+bz7_u-EVdcJ%U~ zwG}K6!#$VK=)Jas`B|eyh7Dw^>a0R2P4o0z7mF07HMK1JO?~EI zzuKueTjEuU4liD&nLxh;5tYzs+WYgIwY?}1bx7LBdTlP-3^Y=>rG+|9OM46YPRfSL z`C45EwxWJE(EnrZP2l9ZihJRE?(Kc{t$X(Ay=UoJdwO~{&Gcv)X=WsitX-C@VPsjG zC3%r;jAd-uvJnA8LNJgQ5*(Hg^F0-0cA=|miy zYt4+YKC}rB-Dy3dwbWXBbx)-b=6^A%`9&+0C?RC35&J60gv3MUJJV@Nn%z7#nI21{ zFhpi(pbzaylhH7|eY6%@h5O0nH0V+LK5K7G1Ak=Jb z@@uHt&M$=ISB7|a!AH{XRj7uH`T?x~e4ws?7`46QX%TR6kTI&+-CnP}?6=B#s>r2W zp1ocLl`1s|0(m$+=F7RX(&J6iYQ80p1|M3@(lxH7sRiPaD*7}v0$xKtExC^x;?i!L z3q)|~iE0w3W3E#6_zwXVUin>%DX#fD2q96Y>pqrJcQAyrU9G%TU4Z!S=R26`Lz)A8 zsLnwToz8o40PaI(y*3wtfIb&~zr0*#os0f8ZsBv{_bbar`5u~+jMHtL-}E}u+?a0# zl1!zKjzGMU3#Gl*xQFHl^?G``Vi90GI1Wmmr(!-7o%DKR{N!@2$%+AXaA@-Wc>J}c zXKd=(kDe)*G|gt$)=O5KHEn^cv0{47a&22NVwTKJ%++8C3U)d!Gkxvwd9vL|GnO^q zSZrk!-i(=Eb3GN@rOujGK*zGWiq9GyHQOi5unLk{t0~~L(u!L41xwCs)T3J+#kBa^ z3!?gbuRf10%!|OPSb1FYsUPrFr)$1+Il^BqFEHw~?6eq9>18*t);&em-LT_*Yb+uZ z*wAk?iUY546U|{e8GGHGz@sB}(HN==))5L(Xh|xEL)R+UL?be-FUO|K-}+ zQaSw5kDe)1n3|pTd3CSKv(E^P8O)-s{V>|vn|CcYb}joCeD`u|nseNt@x2R;@6C(G zwnQtfigfDco}jN+G!;|fcC<=2uHz}i7O(Ln0!-(NJZbIatUV_`BOXz936;FcEBxiB z0kjDaSJwRu2od5ilC9pFMIRJ%CY^U%Lm!mz5}Z`S+73d2j*uU9_6@E;=6Zls-zW!F z3uyoql>@Aj^nC@_6Y2*fWB5SbouPrvuuG~hjBc1^fh9y|U^7)#>icv>g}iKYE2o|O zgA$M6@>NlxENfoi!d3DO=8F3U_J?sXBce>Zx%Gi$!OvSQZi`=V^G~EDuwZ?n)rP;> zM~VTvkdG<&SNJrzXcU@3G;@sslPn*~M^Wv0LoSOX0n{6C@B*rx)naikz*)|%y3*PF zSIaz?vRsq}tL0CHp6#Zm7Soci3)@o~m%bw88ZzxAnMUbe9&PuMJmVKtp5xRN*#(%o z%LVa8Q4`JN_jn3I?VZK0<#OurnBwed%cXo$=-DPIG+mAv0Sb6@WN3g)M3XnBp@3~h zy!j-|zCRhd#cpf7M~fK7&364&?NfWG;W;&6xN^{BfA1M ziWYS~HL_HH6%R_9ujKrt^E7_2W4RgtBFN%>7f-homuFx_?S#W-0jy$41s_ueJkGn~zFmX4kq+pA*ARB`XNWN6xnv zMogb|du)eKA!mGSwG%o)CwN!R}h>r_-6Fbgftdp*26!wQNqKDM-w5qB>0o zJC?eW&qE-fENiF(0qvZ!-|g|>7>8aiDs=IwAp%UMsre~rFN6N2K0ka8?fp0R1X5V2 zM?E^?c-~MX08`Y{u$#NqDRy=-GNshpo2|aZ%J0~89iM0ACvv^ijrBFd78kD8V@s~7 z2*`e^4QehGZ>J)nRH>d)4Xs~z#21uD%)emT9sCRaS-|ocxFueYsDL0Tt}^8$&V2y@ z;;B|q)SRUh(sDrXq0~^KP`tFu`C^!IRHatj0W6WYfr#nVZ{V6xdj&r`bGRv=vtAEh zpD5y&3*w60$tQneLXswCCQy((m+kKbAG_*Mi78f>tVE?-iz02{C{yff-U#2dav>nz zP)w-75&D)86`X_!jO=N-(t2oe{(()dmHWy!X8ShP+!?;OZSqj-<(7S;561HvE)JE1 zzE-()U|_aF-RYtoBNNfY-0K&bL;cx!$KudrA~HT2iJLXRhVEQ8EqqMK!c{H{Q_>dp z#s6w)D0h~I{wh`hX{b<60{_BeEloSh3+1BHCKs8tZZk@8r3CTI!%4J1+t!>6l zx0A^_Q|xxByu7sN=nsPC0cwhZ8pgA@wp#BKAbg11O;01(v1NM8rkSY?lM^-xtp3kQ zU>(ry>{UwPS9sMX?@fy$`v|h}xH!TNqq;$R<(IfWbAoXFFpZc52&=HkS}|kXL6oTq zBL}WZ1YG5v-~}kv@-;rU;8rb|)L2cOK!amBB1c_Yst%Y`R^YYBsx&MjTx_I<}yTbJm08T5pubNMP8MZ#+iYqmbvX(dqw z)wiGtpJCyf$Dm%oSAuFq_$XFEDFFr-b#~Y^H~Z< zs1`LXI>;uimZ|eN1!$k-sfWu`MtW1el`#8VEwbCy&18448Mp5#g4@Tcal7TxRZLZg zf3ZtN71N9~gi#l%V%jHbDB1!(XvqDan+xYfx4mwwlO#`{E~r_`<{vH;?OoMTQK-}^ z3-{;isB(ox#x+@pn2(Vh=eZ@{VLntbOSvj6+?k3+O>(Z4yHZ1(1*4B4XOGTQi%LO{ zIDT_pz>s!i^lD?g4$143Hs+@-jW5;k-Wj}7EbpK{ z&7wDwcAu5ch1@FtBM>6pRKpW68RVu@ETgujx~j4wfa1#t<;aq98he(hQ8Qtca8ny^ z)U2D0p=7^|D!{2+C>Qbqp~TCK@XIK@W|}w*p}JR(bw;%dzN8`L3xl@hOA9jGYTE$yNX|jE9hc3RkVY}hGMKKV8fF!UIvqT zezOcJRA;EfD8(U+z6GIdVFQWuTdvH^&>V`*I=?gcmbaN5Ci$6(iQ_lEjCm9ea)0`= z-AN)JiOvFqjP^;7P*#H$z80fS+QpEhRD{JDOiidX3nmeoEpb$vws@ z<*77|B5j28g=}}hcA@&TsP12n09XUh`lU)$*l|Qh4U+|cF| z1-E7c+tNEZ-cO5*acJD* zcqO7dG)}SsEMv7E06348teukMeVxe1aUL`KFs~719d}atwR~kWb~|nCl)q0#Pfr>1 zVX}OfEQ;U;RO`nalQqzN`7ZiiSU<*nj@JT;5)Kmw!pU$l9wi>|OiMB2)~LFT3#uuk z`WnuRNC|U@wHFlt`G;f^t?X6Bz$OPt*hiIZ4*ck*a%`L*W(&Y^h?LBzA+7YgEYWP8 zb2wiJhc#ZHLNjo2s_a{%es)d>bHH6{35==LJxXzc&X6@eXgHjmd_7PP`S);6=&M4L zj3Ho1P&F=avV3ih=w~n}`X_GEIiufgmlGe?X zp!4eC<`ZX=9K;F|SV2v1P;dJ8IZF%xUTaDWAFKu9hYDJn?CAG9amaj=$}4JY{-5KO zc}G|m&jw6;gI((wd|NT+;VT@Y*9D9o)l}+{p3T=b1Dw}^Dk7Q=(v0m4alq^Lc~4fb z02W_>p-GZ@p2TeivnJL>ZBV~U=cInS)|Co&2Bn_svKCDwNGBzF5~xDf6mN=?em3bP zYl@j&-hfi&=6Mc%K}#*k$D=8+D5p?c<-J8UlydkZo3Jv^eDuaYs*6*s%`WBh>e}4E z>hi%Mz~cr1k27m4%DX1*t>V4zwZpxt))q+(GbYaOrq{xr=})u?srDERy;cHxw#Fk6 zzlxKR+%P#ln#~juaw}NJK0y|BX$ZOHNOoQF?sZB|DK7Z(y!Ciio5w4d$3baSnwI_f z$`L@$G|49&OAQJp3jt=;K&2s1g+c8aNVAFx^Aa!sVibhms2+OoNBLc-u!^$_LS5a| zlL%_Itu)l^rk+PgGY(xdkX!{wvzvM#iMtKD=Cve?+~knOXbtKLAEx8XbUG!+d7#iM z(yYeD03nzjqdv9NigajjpfBB-goX)=h72%tt!Y*^=`DH;q=IuxS^-~kOgB;SW+kj7 zN4Oc+Myd=0VPTHHlMU!87dl^plkW9kJ4mKcV9*=Mk8itHMo~J+_*nHUo?;2cKq@%jt;pk$i0r;9cE-cWLrUHWr;x zjTC`ZQJR61udbU}Djo~voNcJ8HSUD5N(>swW(NDx1BElu7aN=PqtVA@sWc7x#HvgGK2~U14kKY$y0F)eo{a%2yu_&m53E+M+T7& zlW$luRzPs4l9Kdpj&^D(E=mA9eNPyk6w4VvjHCfMgad}2cgr12aihXB;7!g$aKI30 z-QD_lZ?{-CRz$#pA+l_HP@(9{ml>k-Dgh=!Y>z^jUIzu=Haw^mfga6QRtJhjf6{H8 z4Q=)HuBGw~!;PsrG2NJ6U9VG5CD>&wW7QXAks(b^1+5SYfI6^KH%%SAV$PK_qff{X z8?&KLZt4MKFKsCL02>O$nTos$e}N^VqE`$G*7E>H3 zwPh9$w-;}j75O@XknD0`^F%CPdpi8OEk_>Su;roc_aPFwscekSK3P&pPk_XjqfraGpa0+eLMj}*U6*>^1?P!At za3jQ>$?iP5I2xNcbLPt2ocz#hE=)x(tlY`oaUllzrghvS4`UsNQ9Jx;;bYV`q4j4O zV{V0*frJ*0_{E2c^+Of46@fOBmlcr1Px#m}C`AMw0TJG`1HG_X@(BP8K`dP;j@s3B z!q)51E|hZ7;!#l3fmNLl{XL+}si)9L)*h}G-I!$Fa7Y|yeB+_sy|KmN_*5b^KRJ8f z#*O#QPAvqV+;Va8O$+&%?b~Pa3orI&Z|@8zW;#QuuB{j6=Pz#U>Ds!IT)cmp{m!1< z)6=_m0}>%@pJCK$2}^_={G8620CC1|<4k}!WBO3#%m6Jb8ID!C4^6b8_6fhHGRp5a zGm08Ga7GQ=Z{d8S`vl({Z#$H4i$nxOFwz(4OLuqT#6#m@1#gS61`q8;i9}0OIv^^} z(Q#91ddu3}Ro0$LW;iAbjg6DaxJ#Qel-pT=vR;irB<{5E}|;RjJ0 z+{63R@*t4TYoE`|Yib@sycPiwR0TEe;moECYQb#h`|^Ff2sygJofJ~N#tm@w&~ild ztDx|aHZh{*$XXL4A{4djlJN-ijlA&njVd%C>ej3(&8#^7Fzehd5iO}CU(5!!9}UYw@*WUo(UeleAzUJvFsP`}VV^m{S%N?<)}!mL;5fTj3K z&~?jGN2AZIL%*=Up#v99$6SX%q!IJ^@T=$f@;Z5r0%w>Z_3HJon{#hum||=H3GNwo zc`~~WE)Hu>>UvoDZi^32d)Za+lWOuCGVCKg;??wsx|{h(hnPN8bqHnna23LFz`6{3 zy=E1n38V0o`c1cRrcxa`p+TH)gAAicf}udxxL(Mx8XMBp$GT)#q&TccoBuW$|8?uo z98cc5a=)%O(Axz@L@jI-wOgbj8jeB%C<^CT6rC&ps*rc0l}ogmS6K%-4)uD2y!|N$ zA=Jb=uXJA+y>kD*-!6jp^Cw9w%v^S$GBi5 zF^QUHY8w%gPapRTW|9vwB{TD_@FWhA#g4;`- zt1)%F5$QwM&^s0bR$<0|?D9Z~dhW%A1q>jZl=m2DjC*P8ARNZ?Q>Key zRk*z;obHjSx>rqoJvC^I*2Xx`1L%oS=fHDoaT?1EeA%?M7tY3RZ6n9juYR58U-=~F zznQxGG-Bf>roe`FV`rsK9nN&39|5P17jAc;PHomMZ-o>opClj?{t)J~N1dMz zDU%-%6XZk!O$d+1#XTr2&1vBs3LI!L*O#^s^dU`C6_R9}Y!OWhDor8jh%Phaz=exa zwgjcp?t$stM!Lf7x!vhXOm{w@GO`%)RAbsg^fHIm7OXMe9970FO44T$!&T6&7acdS zjJYm@JWCQRc$oH#>Trury+A*iqpqD-u2kwA@!j}$`2(7A=6XWXImEmE5AYW#UoTAxGp&ILSrY5qxVa@?hsza723pAY^w1}<>U9EX>&Bxu z;0%`@FTYHCFPB?DPv{*1Y4#cddNQqJIx`4T-D#(H)k`wIvcK$8kmMRovBh+;y`l-L zcqhbDS^XURxuDA17Ackwb59O+uDSX$1`tQX47Fpgjl&#Pto8=Qpw4LA{-pK;AEL%~ zqsa^EU1QXrdG+&J<@f?{uW7YMmzNd^y+A0+rNRZZ5s5QZ%wSyEX&sxn!-d&Oz}WOR zGV`moWu+%9*M<{8i z%NPY-LB<}0S5>)z;K-{##=E|SoH7L~<#aw!#ateoXmU(B>GUYps*AP&8LCfqQ(-}L ze*sue;|0T#Mt(9~Dooc<>4EAdKGWR1f}6v7v|2G7+1ZM`34(O?;iAG`+{e zQ&5|+kLP=Ml!jpgSCB}Fw4*a3zDt)w0xef_bgx!CzBV4KjZ>;*Z8F8fmklu$2U{sgoq zjpgOlQRxrB>j3h66A(!ifQ^;FGemI7F%Z{Ie2vwH$tLq=hD3AgSj@Z(TbsMD>{?9c zv=z8(K<>Qp7khRp#>p4h#@|P}Js>rq+M4YhcmwrPGQB~4s`fCDtkO(6KY-ruTg4h~ zCc>!&NFm|W4jk(W3y)%Sm=9x2hdxzPQ#dNx1=ROGhMhorw*u%J_5!R6+6w~c+qC-{ zmb9Yn1uXI`h%0PPVzH%5xh z3F8DGDCP%824af7S2euvRgNc|pdMOX>|M!+rPmeIdc6+yfGEoAEUB( zUZsRiWksGyu|)3qw7YjAoQS9M9w^4sz2KqthEJezyzv&^JYJ)lscQdFe(HdX8+De}+9wx)j~T@QG$I=yw$wC*x$C z;C|S#grmkY*b|ulC_^b4!cj}la~w4@;i$b>;~&R0^d7R)G^+SsON*vm!7LhPP)L|T z4D&{B!i>pM8+7vL`7?yAXL-i5x?h`YJ@al26krmaN=Cf!u7pb!lZTrGVzLDf+F?8I&;(=IINVyB=+sXpyP6gl7hX6UX zEYha7p_YtUB?n-2uEC4oySFxf10oS^NzGV24v8etX+F;d@s~3+}3vY^hxFIzK zttM6@V&kSCI-l%%Aa|&#WzR-(x<>~xIo0#_Z9kal9~{)3@A0iGf3G{?Ab`s^=!Ok} zxcCVx2COAVnBq~ugk1CAjvE-MFz)Sql-|fVVcbX z8t-b4I!}e;b(ks2Jn%1Lm~lTh^ql9+d=>H&ZhpM3^^AA#y2h(7T%o^fA(M5|X7W;X zk*tL(ka*>%RxO%?VtafB@r^aibLdmtl@@^>hfoV9#|ZRb9&}OTaUL#T@Rk1oG=!wb zEmX8>VK%I`LVCoq51X#xW~-e%$~1&O4O>mM|Lqqbeq!~g3&+4cO7l2=!F@~HSmb{o ze}QzlI-X|f?fC%ex>9@u{U^n0Z|4O2D7(1Cu;5i7D5+glWMEP-vXT-1$2VZv@bbM2EsMQ*N(FQIxVrJLg6u+*A@nU z%)DAHt3v*ew$%xJr1H3sb|)Sa-V6~gN@sSU*c=2~n|4ba%*^ZGD@wbrT{z!)98Ac^ zV>clm+S)EQ09}quu`>7suVQKQV3~PjOTvw{t!*_{Cf4?!LXOg`M;{LzU?wGGwecSI z*b3Y@2Y0*S3)<#Kra&-l`Dp`cwXS;zJ2GIphk1Nw%AuXO9z#$9T6>uq% z(VDtA_w(H3MC-PeFj_E1c=9o}wYi(0Z3x+dPrYk^Z4ieW8ujnqvlHu^a?hqfCoFO) zm5+0o!KCn4o6=a=gSak1Ti^UV3EPwnr8h`QLAi&2)Fq#8B}A z3H}vxs50=#9J2f8UqAPQ&7IT%i~k>Qg!L|Hd70A^la30n+?>o$0&NiIfmIr&_?D5( z?|6Zl<{SacSsjGe7c!MPA=AIGl^5;0MF;XL*nx`oxWCYiuy(@NYKRG6A7xHbg3x=B0^??`ya}QCAFs7e z^y1={r6ShJ6!NBojBJqpF<&{v{AGj8=O?wG|U&eK$#b1vHbv__kLP9|Lq=Gh z9wWOHOzx|Gj>zV-JjZ?M<_Qu*GH5A$hJ?zmkb3_~XV#L9#|ywujt>v@_n}}mwt@s& zN+M-BR3{dBrGwNJMgOj2<^PGJ8K@>N72h)*bC;a-0^(a$K$`SOISII6s?LIrrs9;= zsH9ARIG+$Nk*?I447ck;_7@wn8rQUM{>ExcX3b0NGn$(3HJbvu!)0AtdVbOYlU-m` zr4}|2?Tx~|>>(_T&BEkHoH;xzQj%`UR4XO?)6QkLMUM+xG{TQ8$h%Vg zbq8a#gNjS=6dk9LGMowo!(mAZ$HKs7>cez0+w6rk>4exkRhHpBgU!h@Ip&d>>Mc*< zESYTm5$P@YKo2~tZp5Z2Iz*?iy(mBG@%kta((7}f5-9*RP<>E*7Q%>?hY1ytg$UvxKmiWSolSA@?*Sf)m_|ezE+EgW!qcmUz{@m zzem1wD^#HTBz##Bu(TrgDGI{B=4XcHrK~q2=9R{%UliWdSQoK28V(VX4k)sEZ%I$9 zHx=lO-Fq<639rk-1XUo)9DzxePqe$fiKrx8}=eu;br z+q)h!wp73NqkOgBAI8z5cneCxz*TkKi(8?aJnOuZ!p9?}?&Q*9RJ{%a%wE>|HQ`+$HB6^^@A|BPvP`Nu-cS>)4A$-3I@sFPP*W4A ztJvwir=*q8n(VHtt*q#%+ceiuTM_7}k2X2m4Qf1t$GoU!SxPz~_WURr$1Wrb^7@Td zvr%t`Vmgt_vBpP_~HCw)n|4Q1* zi0T>f+pDmb>o7*dqsc}NgJd4J#W9B2G-2@Am==%L@F$KJ@OX5%y)meVE}i1hB9+1# zA~9S4zdf(i@U~Q_FSEocd&|H$*EC3@wZ89}eHI$=d3r~YO zgp~KiO~YGurqVD{cjSk*ETjj>Ty4AB9>K}LiB38s_;^M4<~x!n#&AXupIJ_x*#LX> zrR2+%I3#!n&I%^G|F>ygUf}VXQR#-rW9sJ786f1&z;4jNlT;5K_rNTt5%SMfM7V|=2i6rT9edOddjY!;D| ziR{E!Zm_>A1=?aLeIHMjThjkEAd|i9y~7!iTJv78a`UNh`yCtY1t2%w(H=gv$)bYf zXDD}kGm1j;)3B*rZrWTJa>K~T2D5vb73t&HaOq5OuiE45wtPT_omY)sNaVfK9^1n8 z5e*$)6IJxY`66$%0%#r`E%MLRzh~v2*n7=o#kFyW`lN^mVH`}110*7_Qj|n9eddbi=tkUmq%^Fjha1=(1mver-SZoyv|QAqw`RL~v0>+p*#eu2zt?AM@jU_aM8X7=;+ zdGpvTS8(>DolTU0i3!ehRi_3+S>O~U8~fSw!NqC8{+XSjC$Fr0&s-*UM^C?~4?Ebm z_r?HwzqySD$6Wy@+lXHBGe8kw6c8Yabk{&&#M5cb(1@hSabW>xa-k{{L0TrZY7EBg{@F7RZEXp-bf;=HI*)S zx3DNWOqV#3LK#a4w=t@B?({t@e!y^vTmktsk;{Qq8p-eDpemhgHE)O0eu~pxC;cj) zbr-Qrs~-#m2nK+#2)B2N3?X8<$cacSPkKh_VN_LARMb_}0ZfI#_#h^PrP6t?fvB|c zT-E?orXg|Y6l@dV&&B*#1D#-&fEJqsSdq@E3ZXjCaL}Mac-n-4;x5iU*> zP8MOLcwP@Xjhv8jUfJe77OE0j#0e&smBr@3F?Z}mKbEjq-eZ5#`{BX?|B5(!I72%89xl6M)Qcj9k;iM{1J%G0%9QLy0hO=9v*@4>QjTq2k~{$JsMyC&iM9wP zq0Uk1OGPh=F=-Ubp-3-^R?*bqGPN~@O;*sG zqO+K+Sb9?w5f#y!qLied(O-9$X-)AW>}xW^qqV3gB_(|-iertHrR8|b*e`{SOCfwS zZCM`$T8^s>^S%in%FQbV+AvFvU<>xpl0qKFt}67OkntFh4L3o%x<%rMr6-lRNu_4q z5KE2W7VW6HRd{}n)E%#jBgK-8vY;-a zv8Xr8^}jHVYed2&?P*$!V@u6Ji#)>TGPi+`Elu7MuZ8>4cUvDlb_%$5w>mAS*pyOp}+>6;CXL%zLpxYU?*7@`(W=HWd9Kvye5*l!Am;m zC$by4$92;7Yt?Es7CL&X!*)-S2FrKZO`t9AJ%`j!xuZm=Zp!aP?CMay0z=2LsHuzA zdw6xIBu|JYgHdaf@*HyIPN6={<+}OFa%5<*sg=KfY$ngzZv6XB6t9$3T3*I;Lef(f zYT8K7mtcLg>@d;g)#wstFBQ_WUZj)h6=V`~0RW+@D)xb>S3m=zh8|%owb6(gdWEB* zC2k&345n@2sN`3;J*$oF(|Bq`dcZO^SfV(bM3uXMsB&i-pkEtc9u4cAF46}`6_xlr zG}Qw~K;>%)K-L)4Mj?u&6^_DMxXwJD3R4XmhjD#LFJhyOjpnUcdMEWH^0-Nq^c*cy zi8#~XeA?jjla`{>M_opFgcP2QS{27Wf9fTezdB@hbCj$$c7G9hRAJ=iL01Yx%3-{( z@CrGKDx=r&Y^qaz)-sbFIE+L36CD{@A z>b%>ibnElG(Xv3`L{6LLF<`7miaYg{zC&jMm4oDKvtQf*Je3=0*TQ$$@4!+;T}QY? ziD=j?2*p&DA?n&33AVdX@hHB}&zK*lW>?4SgQdoa2(W9*|R5aVruj`~*Nh>#TJ@k$Cn z*dnlK5`Wh+Dz=yhm!NAs3MO@piwREMRrF$g>|_a28(@SgwFj%3WUWnz=2eXK{7gOv0;g?x(@NP=LN1?47|Thu)9WG6XXi9Ov( zIyi^Sj}#0bMEJTH;Gi>gMmXzD6E_U)N%p;18$y=8TgVg0hZoHT?}h2*MXi4-U20kh zHetemVc04jGZN1EYLX^8055X2T}Y@gn0m2}fGZ(S!B*7!Xzgf)Q0XkyN~RZEekPr) zv$8-ho45#>f_B#Uw~F@+u`U5&t4bwhucGN0IyJ6Mp&D-zC&$`{E)9;`18kULL9$W7 z62)sMZr5^|v1AD$N8qiNP$| zg+8n1^zkagNEd1}`>n-11_gdAA3A3Hq5q+=+u!+jOIH!3nMH>>Avt_8<~l^43pnLzU+M#8IF+^j~0PWo`T`erixsp zPp&4Sgu8e(KCP(cCyD}SLnZ5X({wsLP5*5PRaG@KR8@r>ruS=8Q$0QSuPTI#fGf9niINt0<5^%ov8vxWRq69p zp8D;-dZX-H`Bt*GH_5uF$RndVq)Hc_)hI>s?e%3q@8M|#9YN5O4)zi54AumLyfCf) zME$`^G6mB#R`H%qWo~a^`@3%mc->{&-u}K{-&gMO`ggt?Z~N=sXitJAS6)i=M0;84 z#=o=9cz0iT<*#^*SbabBNN$xDKcx{MHikxXAk$0{RgKc)m*y{ceIHIQl`$?#FF8g< zY5oZZ3>BxF5JbFwEBGdQJ0HlGw;|Rih$nxU%=RWrw9iar3;cZ}d}sa!a1@kF;W`L3rQ>EXGO^nbt}s58$=tF+%HOHDj}MRP))nRz&v4jxMtVJq`D*o&H)sKr{X zIwh^ehs!V{MTf=$h+LH%&C4Q&#a4fYwH?8FETd|Seh+{M`szj`nF`?7qNJt^x~C3t z+gwJeBT0Dv`S+eV|DNZ+_jX__m_H%vFAob?ED||AR zID`F7HFJ4_1ysN>TI(pQjO!bu`e3~o#FNYEldNE;O+@QkBZ*A8_s`yc2Wgc%e&_q~ zbNl}y@R+wJ9%QfR>g8Me4r85&cfX9MHc7|w<>t4Gb<&E7QK5n+a_F=jtniI}hbSvFMA9hJzjybv~|xWfA0s&g7?z(a77$ zQb$}5J1#8}T{b8!k;_I;7x^JgA(i1Ke27)NozF)J}=+HccnFHN#4OFim3jHsN)<_+92Ek;FKjHnZQ z44K>DI6)k=pJ`>Ti(<2?b*A{0k`$ja&I~MT_mtDx1wt34el`D2mlu8Uw%{ua4{a?? z(xiTi)Yc%wB(L$x~d{+fC&ppIh|FG(XizCMEN_%MJ4hOB74 zD!Ux=4Q2KCt`1t);=Y2&11UH4GV&4B$L zYrjB;cfoh?`@B9sKVK2=S?eA(#@5$M`YRGP%>i$j>Np2Qt$62IXmyOFIM2jlK^O(T zcO^FJzs9~_A)(H(m(mfCPJ`|z_;?B1H3#@ARF4@3c};yy9a#Xmx{+Kg8)?7^LZ%^7 z`LeJ2hwKl(uT=kJWxR@=F0a44YUOS2hPz4ksLy{H&##e|@&V%wVi&fe@e5yRMdKHK z&(X$27q9hgp^G+O3vEBZo@=I0K=Lg#)&tX9Sp~(R(W&jU5pFGLxO^w(*dg~$v{qGC zR@PU7TB4Xj3|3C6kb4W!UljmUE8QyYUd-Kf@V1F6p=dN?_VgXdaURi~!;xdg`cGyp zM7-4-%5OIXY^a6m$i?6edz%N%zpMoZ9in7%eGR{T#T@b7|_8tJH2=4kB`!reYQ zW`Lyy;cmZB5oOq&8A#oACV2Qd2UX$Cp7O7L#NH3TnfhY@w{N3FC?hj& z-%1+U4qyd#Wa0{>a5GWSxJd9fCDyN~yKO*-)9nYuucp`+eZ-vgX zjL8{iZzY;M0Y0j*k0%RBl=I|x2`q`07MztwTCk1b)J{y0wIWEt1gY?P=K&51z2-bd z6;F>kh+K9C2~XdIaB!+wcDp*!y&LuUf%gVF%zvIw5OPgYZv?c>*A^glO)IwK1YZZ448j?OAVf4*qoI~QE@4{fuF zafK($QJ{tM5A1wLvH7=P=N;#kKzS}WlBH%J2!Ke~B-h+vE+yC8Uv!@NO~PMvo_W0w zn$R29A!$hoICG0^10!#2aMm%Y3l%9LQItRuV!Fm6H2aLs6d%>LKS!E>U-w+7_l3Pj z(Yr*CBB@2~X;ZW>S$oO8WV)-0Hb4VL`x;&sxUer7o@MRrft#Q+uxCiGKxe3Zk|}amY@lpXrPx?{~UN^7k|fk`jYW?74{{gmlf+v zR-wfh`8yF^0FE2``6d+6D3fCOFxpyRcUOe7Aj=$(3fwp~QdGNi=$#a6Vh8u^xpMsY z6?Bt+@IiEy29M}j&x8IlR0F`_67eWvU~h^%Tm=nN=3kL3DB(~15j`$UG!P?II=%s* zx<-rqGEnLm8^>2)l48=Od{sH~xx+y;k3|`NFPWy5AP>%?8wW)w3TYd-A5j&Yg8?|C z7g2}vNY{EVt$i`jQL!jN+UZX%>k0FA%N(Pml6)Ws7O&07?rSnbo8nbWnj+PDtx{g zI(sn=%Xlmn_5kLiMyf^!6JdaHL>$jNBt(cO_P$52jAn-WuUx#R{KD;oA>TMXwTZQ@ zeBn6N6UDhap8YbO&>-z~h_q^;_G7w9#Urh>`IsnCzvSi=(mjyv!3h$Y+)fZ#Q3g=~ zD5L_y2w|qt_GvtST0pnnZ&%uU7%sWL#^d<_`9Ym}C*1+k@Tb&aioUY&=!0Ve{zHYTdy@<4B6 z$izmmdlw8rl+iV4SKDzHl5N6bW3I1*F>O+p@IcLRT%Amnom4?N($Uh?P^Yp|*pGHP z%=@u9s%Q}QhuVIu$88H~EP|(2<-U#9zg^zUZYgYCgHC6$Giw2ZHa!WgfTRLkpT16R z73;(r)zrDCFBB2gMC!HUN~!14+E7c`Q9@Fkr(&c;r zXN^Ai8G3b3r>G?kvkYjQa*U#u+{18;S~_3Mqn1>gCfshC1LtLD$ciOmX^ijTf+%I- zXl?K_reZFsRwvG_2oi|IG!s-5c+z8P-VK&5mD!BN5Pxb0Ce|#=8hn^C0#t(101uzE z=}iJUJskcql&7WxfPtm@nQh2!5HA{8SBUU1T||Kb1V^ zHK@~QT8J7xRPg&=3%?0^O)W7g>AE0KLFXlPLshS{Cn1=o1qiWbpz502n%i3S;feHv zVq7<^Q%!8_YgHc+U$EL(sD|T!^c}TaVpnTO$W*~ifbr00oc)d z2}?wI1K2mPF)AKoA-j%gSq1{DO3`zE2U;;G-bb_>3z8a)`*`%60IHV&P+Z?VK)@hB zVW1y{s*+}@5cvtk*j8F2sGM9wD;RvWsRm&~V12#-9uEP(q^(bmUxK100Fz>AW}|O1 zu44wGh@eOsehGI0QnZenD0HdemyDYnaY!V2#@h%{IwXKkzDqZ8CoCkz11Nz1l6G@x z^pokW8h(l3#YixTo99p9mrBl`Qy(3x3jMm{xrar!8?#u5W#afzegAEejtCXoj&mKo z+Mqsjx;==%uu*BS@M~T)SAalui`Ci0(J-LKK6Sf>0Cn1n4cql9_F`^V6Q)x~Fj#C? zOt9E;|MPY=yaI%(u&htdLWnMjW2t81BM;#a5&wN$=gxVaNS4%IIqTZU}aF#z*WlN^cNdQEbjQhmB#g}rKqOKP-Uyyxyh zuw(%ri(n$%5%GRp6 zwppTB9>s!wZ2CM(pW{-xekVR}Lu?XA7IapCg&3gv0L$1{QF4yL>1exLUglS*{}yQ# zgrM5n+mr3s%351^SfMr&k5aAHTJGdfl|YnBh&E8^1hhKk|3fJSx0+|yuyg2OF0-Yn zCYSDuO`JJ%<&9V7=2#VumE-b({k=|>uck^y>WQ3f?gU&TPnEz zH0soz-@;AP8?>rJ&?slO5v?GXLDwv=s1r5%X)^fil!|RNc_f(ALnF;EYDGHtO0Y zPB)9P_wK`o?>>Lt%+~wwIB~GviK8k{*~_3OgbbCF;`||j^9My{h=M>6ASj}ZtU`S+ zNeXy4j#BTE;yRc@B`-t(uB92UOca}o)2i%61=~9J$ZI3*jNpOGgPJKW z?92x^?uUwxElN^*D}plAh6#^4OJN+ItRWG6%EU zl6Rd;&VQ6WwQ}F-;qI<%wyS%X<+4X|xg)u3@5-aSBe~nz_}FMy*Vq^m+c@1H1Km`& z7o9Hh8Bpg2JP<^oMlVX`f=b8=s3XL-QIFRXGH6q)kl9K5;L&XM=)?s7 z`?Ku9l{da7)76>DbarLfC`cPUlFc3&%^k^(-p(dQM>{)-G;pm9!dQh^UY(T4N8PlJ z`H6K6uTjDwb+xXYSOGCCU9T7YQVF<$+AKk?ZJ=nel=scc%6KSP*}G}?JroPS@NW%u z4{Y4dyoiWv@95O9p;&I}JM_iISD7e>$e%8h*RCyc+mM1!~^_Cm74>0V@Xl4buXr($i66_r+kp_8TTz+LbdE{L) zMXeZ2LsP64r`jz&IW|Id+^CLE%v8k(>6XCL)Ud{AJU1|yeOvzCty}M&Zi$|Yw*K=< zMKzo6+}Pfin;6-8ern>xRL?@sThsLy@x}mZ=KU+^NJ-bOMWBNyYFG{fF%2nITy8`b zpwFdyL75EcJc&i5W}#uEUIaV6-Z1(@h|`2#(>>VM;I5UtDc>iqrxEkQk89ijr;a+% zr=%o`l}F-{TI%78xJicRTn{I`-mIu`r}73B6;M*aOJx;2v0r`isXO~K`RvxM!;}4e z1Gy`Yo!feHWY3Y&JLfCzJ9EcSA~VL=g}$C7ypH4dpqk(5?!iaL4x>n~ECuk_DCRqW zSp(V}T8B@zydBx(;24UqN_6&(iAPSJYiC(mSq%@b*H)8lnuugsriO|d#p<%dIhdyG z$jV3A$=B}M^%QeewcPvhUiR&klXrid#g6VuG$)?s@==9(`47CajVc_~!aY)ttWgGP zJIqf)U{YZznYuRe>XLeHI&N>1T8CRzNiCkvqEt|H? zZeQFp!?u67uA-xA*PX+gA6~2obv)EJK0LA^&3^sj!)`f$W_ruHDLh=%{11XQo_mB{ z0K_QFbq3#o63_(8K$gQYmZFw>5)FCK99~qGM|OvIM9dB5MXW|4&wq0NOfdSw``;I+ z-*R$v;{)?|O=gBSY{+CbR7{=QlHQ(SeLrJkCpUwPKboHIADEpTz$>vH34iVeJwB-k zNf!8o&}Z5*#4bpgB~mVujf7Z?v2@kSXXFZb<&*COmA&;Bt8VO(Be+YE8o~2lVlA{l z#xl})K39+J1m+I-t;jCyBWekCRpF>L)+LG39kpb~v{64@uSCwZwpM4$JYe(o@<-<< za{Zm%!^da09?x`l_U9(f&7Yb~rzame$RBEsydw6X6U{mAvp|M+shi@Ir zY!5Xbn1-Bwd}5+AJvNqR-pp`kynir`d7`AL8jOeLDV7gogxG5&>^!2FIv)lURXiF> zMo6_J$XC)T{gQkjO~yB#Y-_+E7J9qNG?;4(%7|u*iW-4YM*!pK%ADC7D`R4B3W6w|T*fIS)2!}86 zD;UIELBGQOPw9~*uDzbq@8kHsoj>n|D8C-xr}^(+ZK2Bo+tsYtWJW`);{T4OlEp^=&%2=50C zImx3~|4=E263yjYuH0m9682gnRvRjj7E)-19VOyhPZ^rZMf7@(qT z6*scz^qOfBkd5WU#2=#>KLd}Zxudb8Bh=nh-_UYwPkw4wYj|c)e$zg7feXe9FDSkx zk^}akrpCtFrq(U+fpBy_v1#LAH0f_z9N4&ddgVJT@IMb8IIu6#mTGz&GGf*leneD0 zajk`xJcNQ&MW?1B4BdcQ_$Wom4HZ>EP_RPP5Pehz!$&b|zsKkI@Usb05ZEvs@1h2h zYu*5jr`HH<_VUU>$%Ie~DmI3zgmr6#51-AKR0yAiJ{Vc~v$gaAm9n6Y@C*5E6mdoC z3-YAZQ$j3!5Bb*~?~p z7NGfO)-p7dOb!kvS6&qVCWqMXX+KqD;<2HjSYqfQ{daCpZ}XYz2+Df*iU~;$^ZMSGcJo;oCc8Aex&zOqbm$ss zV&N(^7v@+(a(V&0RbSOEA>LI><{Oi4=WYdk4GjG!wfCac19qIGY4m@9_r4*MOl9cZ zqZco-OYhEP5+o2bCbIAU4mM09qM4`A`y0lKih~1H5D%Ho(OuZ?$(vIM1`HDw2e<@* z<5hSQ;xg${$?K5N9gl^eMZ=WPU29jD+_3h>QpRaxGW+H5=wx5_z|fV~pPjv9c+b(m z(fTHhee6w>eW`3;-|>5LN5^L$s6Kan<=X}?J6C7eD{@F56NQt7H%^>JgozrcBGdr0 zpK_xy9L{0o7)Hl?v0zMKu#C;W)OFS|A*d^kSEvh^<}~DTnUHx*Y*XQ&ep8#9OZG9b zbLvFVx`G7!F}td+mP&O+Rnm&WA12jG-)0@`@8M6h=YcH8)r13Nq#-1B3x2GUDwCuj zMl9ZpCw5^f7Mn^YH^gEalHZPRh{q;l_&<{cN z{$IwMK?xvO+5A8F5-~43`NY5j{kzU3F8@~g!Sv;x2h;m6B;WM@fd>|oZ%Kas^Pj)- zop*i=fAG)eS@%2N30Yx?-%Ik_K&R%L0mE(c&DbyAxzhTy{L+miUB7_qzsRo_`>{w+ z2JLtn@J)!ZgnA+{0ObCa)_1b+iQ6Fi-+=7D0sFXuo$n8DEv<)dtlrOl1^cpsJ@K0Q z*>zm^B=$*#{cq{Brt99uKl=ym-qjz0jyphy%zpZpL|46Zoei;lprH|)2y`%|dgy_> zesTHoPnV`Y{mtutH;sELaZj~+4}9jK9;|T${`h8-dgDV6Y@yrcu7C5>(@Q_SeEAnA z>7FLw?GJ&MaXJ-i#z_)i z$yu_*Ae7h_C~m}K?fjVFYjX|h?w)l0+{{85fm0Ks6Wp;9I)WA!GAX6EYAz&YJ8H}h2x^BdE173Fi&?*cKPG#aZ0 zrB$&QC?(o@L7NQPyk^S?#E2st+E&RT=JhVX5(@qHzPNASi}Fi%-Wa|U_oJVe^bh=g z*m{=1C`(k+0uO-HcqK0$LpW;QbPM6BdOYN-cglZy=P!@)b)?z1GTD|`SNNU159=z1 z-~S9x&^~WsUt#ZsY((WUl1BV}onIegFR*9P!yqb`^Uuqruj60H1^bHhbzEOZdA(({ zscwgNfO7VV~Ij?bmV<#jc2AlA1xbiwx%$VMurM4@THmgzdbE+tU4!Fog&4 zKF}fkHRz}VwgR$?w{SaM^hY3)*)l%E_S=Fm64`@?h>&Hv^_D}Q5H@_BgAJdw+3<3} zX2cWEqyf2{eMgGPRnq^&t7QC3ysXFP5_I0+8&si;}t5$lBKNp`%AHe73Qm@80!6T`Ka&j;T{L1G}sK!H|Xo!*TQ>(d9 zumDokov~<2>D-WTG|^PoleHJ3KdYh@GtG6c_^6jfcCb zYpH6Vkd>B%Mx|ea0?H)gU(I-9!=F5T`crqE`^fU}p^@&bfy$Att+!_OXEwFF?+&`E zDyt)*jTdj(eR*!~i9P-Oi4C&{65;qD4$cN2KK+UF=dPW;{R4-$+%Xc1b@UwBmN`5+ z)cuiW1bv#u`^N5_+xg_;t|xa?-QK`%OJ(EjsS)hVJdXb){HG|eAQRH<`P#wmE^e}T z{fes{E|b=YE<{lStWOF4lcKO?9FuY5q6Ox<@eMw*92fC~$+27#&axC~?K7Pi!N@lA z%De*ZwEt}~0ZFFR40C)E$`Vt}!fh+kq2Z4Et<_O7m8 z@9B-3XU-p<9_efi_eG*z=f)=|J3FIo@mO`@{`8ibu7w?Yhlj=^Z5@s6t(9`*mhs)m z#J{7hFsWlk}wi&WjwR%k%m;VZ`TZc3%9pd)VPU-+Qn3O&4A%v$8rpAScaZ;cY z0Z;@yf%1?&ULYu75FFwoM|esm7ciS4@Mt`0Y1mALmVNcvds$A&=p5`-xk37i0|*h= z%>9q^=l{pK^Z5HS=T<(y|LFq<-+o~K(+3Ycy?^h-`MHPlM`9DBTegmc2kH+kJT!lH zY~k#u&!7AB*|VQMckcPKZ2TPu57Pe+9C*jU&FAxbc4W5aay!~vcJ0fb+XUYYk&y2L z%knKg{(eNek8uthNFi?myDw!<;IR3Ui35J0%YPaQ3gKX%=cI`RXea->RfJwn~zv%$rMhnb3@tTSghfz7dCIcck^caePQ#* z2X+q*?im=^GdQ?=V9%-aP`G_tepL20=9YVUy1%~(*WJ5uBmKSUt9u6r@qb*oXYg^x zdJ^p^@9EvHsyp7%(Lu+`koP9msGNbk*TaiK#(#AMP@o9rdQspHymNPbd%J<13AJ;0V_LP3-dq~jHwv@0$CAI`r{ zSzdV$+jQ3*cgdf0?p?n zMUwz~)GT(uo^Y|V9AOfVYb}b)dW`vo`R?xd{{DsT?uGt*ytOqRYio_O%iVM7-uZNT zA)VgVb2*u4>F7+g6MvYLW6w!1fj`1$!zRolaz?(c65I5yBU=$r{V6WTzKQGEC0rks z+c?c_(g*o-hG2jGKI~76?}xluBkyC(TfEGX|=c`fMfWKr1i45TLiW?ebCtSX1`z5aP#BMaqRGZ7eve9I+x48B>7!_&`@de%=NTZofvWU1%fz9_=~;% zw!OCSd)id% zE(W0#AySc=WdzJ=4Z0Ax6C&*%;V>oJbs?5|M2*DPkMYjjX4jjepn7F z-BOx-*=@3ZUbJ2rK%5M1nRklKJ)G8_gE;|dPVGEOne!ewASp3z4M6F8Yo#Hh#&A%wBtrBE}P zrUC!Z?xu~%8}UXcqJRuoK5*=Go*0{Y({k{h@lM80OpG@yy?O86`?_~^>=~GTO*7j# zzVf$BUK#sH^Rb!1>B!yPmk(6r@0r`@udNI0o4Y4JH1wKCWG1)ITUpk)FEY3ygF!k-W{F-CfFE8@fi%&R=}v+r|faM`Ks7u$It}H&ZthKN7yRGuq$Y z5(_QFX43srZB-S=vs<&bPd#zhz|2@@cKo=vdazfnlq-ggrMoK11A##-{Z+(>lIBgEd%F(@5K2H6fJA&7%H!N)AMaaGsS5DHw)O zN#FjpU;DL--}-N_X>JAk8V~=szkK5}&ph+g-lN%Nc7a_R9B*r%=)cBR-m;uMx)%$j z2{IYydI}Ltm=jVNM6d&O;;@Xc@u8M7riGhebWoxh`f&VFN}uBaG3zklsZvyJJV_`$ z!|Dn(5RVDKO0tK4IE%fIy!H<+9r@ieeS6xcmN(ztjHK`v@Yd0lzi&RVX=%D_!@-K> z58i(K_m&&#ersbsx8GY?9^9YHXMZD%mqE9}I0TjzcosR{PrqxrOqLV;`y}+k5cGpA zCtLBozV2ih`#G-HWK@=cgu(Ye$McTz=XLq%`zUzQ3!cbwH<|nJfcCN<%2DiT$$YH% zwng(}-?JKGFFyP1efU3fpa0m$&I|v&7yaCS$Sy!9ImaMB?#~)vK?DCF3ePYMu$G6k3ml4Q?4g~$x z>_<3*gXIl6YXYG54fvnu2Uq%%L{P8%79*F%J zswK#m6h9rxnu~+=vM;lh$*0qiNSgkWwIA5R`D5vHM>w4hv$v{0K-zkx-vnR213JAD z(6$Bv*cnZrY2q>uxPeFXKfx#B)pyRHJkRHA7V|ZT7}HsSxbMP_uek0e=gohQzVDv` zA2CK?VDK*vyK(0tHM{{9_DNjohY_k;2u@ep3VL)pZxPHui!aDc+U6ubJmJg(fu(vSsz;YC3AgTq@;u)5;$F!4DYw?n*@A!n*6|(fh7nZzGY#GpnRq zFc0FH;d~o^rn~4P6L9rIZalJzeV5^>E7SauAH9Ab9*Reje9-fbVGUGC1NmMMh#7*D z2+c$YdmQB==w!xa@f8Va;e|O=D-!3(F)lTHSw73(@tr51JpbgASI=YZ2c`G3e`S9s zCFLpUZXnU7q|Zq;IOV^3H;yFfB!BgD`q^Yq`d_&AZk)$Xb8lg|9`o0L__Yjp8rdPV zauB~H`wwTbw7L+3k`?%@xF>`)L=j(d9pYlk@px0TlK56c-Y7KXrop!6=IUTu*=+;I zQ^`XH-l@)&aw+l`nUezl-&HjX)!aPoilei$LkmP%a3T6sC zw%`N>gucQ4`Jrpz7l9-JWrj1wcCY*m`|St+mwIAlCT6@cYs9|};qWH8f=$wdJ!%J4A!UF?rwDLJ7dCx0h~$0Ot(nZ>vGIe64D z?~xJu{YdsucKFupApT@;9mX$%gF^$)WO}>1doywRFOHuvJYq1;5dIK8Sgk}_t-v^i z%9WH`5|Mb=<$-YuE2NHMs9xGTsi1YNs(XWPw(P|A>B?j*;hHTwzIF57S$X=_#rwm_ z8(+8;>Va(HjmV<>8>|h)rC?G?>7QT}gj-vSs_aqd5JcC(xJ`<)O@vPlTZ zCM26ThLG$lJOYI9+6@T1c?a@<&4Z_i_y8j!Qfj?ODOE&8N>73UMa7_K5vd?5*IH`1 zEw$8Z)vL6%UQPbLnR7PTV8H%w?+wh(H#6US^UXIi-@NDCgYHA%Rf#38z=91Z}p*_Zw-E{3g%SVepiv;i#sCWWbV<>0311FJI3q)m-~~Y>@)`3 z7{Pj>msbLtDaN6p%OBH)A10Gdj1zG|l!u}#3dJ(CPVS1ZZNkfwO-$KGHZZ_-W2zmCUq`&VX+&6?+>tG9ek@|7%mRzBpvh~dp|0S-2@1stgS0e8I8Oaiiv+& z2V2d!I;i7c@Cfs0!F)K02CUnXU%;P{iYt!b?blT5en%=GfS(IUArrA@wGVef!plOi z9fY9^AGweoynlu>JlN}*z%GyDRST+J)TP>}>ZT9TkipMptsiWqkKDoZXCZOoy9Sk@ zfqhWE+QDx8VCe&0`h8Fm0^jvxnvMu_!qLH@lL<>vO(>)jsP+I|ukG?0{G*+U2L+3E z@LmkAuGr}~FRn*%j$xh9B5Vb1$bqS5T8Ww74EEV@&`0VX{c=b+8(;Yd6AHcZy(Unb z2b;py!G8??L*}3ND~04qAxU@$bn!|NnmJAsfoB0GDG;|@Q@p}3r(yi{doc8*7E*qOiuS=j zD02Obtj8N0j5e1m8;dZR&VX>iB?Rp`Zsl@Kp)^XH5|I)`i3gwSN9u(l*Ts;l1$4Pw z6`qCZT&}1LqbYmJwNT}yMSY-Ar5?u`dl!vJ24#SKZ&0$29pzdAK}AXk~M*(6SgU*8Tu2Vz&Ea7Vz=-Zb+yaM-VuJ zY~NcqfnpJ%ySA-CxXPdL1%KOwOnOc@j(R$m(Ujk&h?cXv5~qPw{4j{B7Q;iTs>XM5 zSP+4tyCKA0=8rT3h<|8OsC$NmBF@#Y!6AQp?+=Ss41YFV$ecYz{zy8L_bKr3BfMqC z>h1UM0o}6lD#JfM&!B{V1#PN6)%_hbAyfY7Sw?Bi@Mq7En_8s*H{7*~Ca=gcU{40c zq1sWE9YYleRlrmd8LnmBlZsa3J_CCuC=qeJ2p$zs5wFph{6!U&7ei#XXE0vGC3LlG z6h1QUCOnK@hE)@s4^yi)$*5kNWZGlkvKuCW5IH8bAvmzj@1nAjCd#yy-ClOjYxyZ+ zx-2y&G&(psa-rw@JVItzXkftjFq8WO1W0(7un=`t`ls3*)(&>D*JlK&hS$+fyop+X z*<}VTINuttL+lR!6OhM>L=^$5?IVV{{PYAjz+w(5)HSM2&nSaPCPGcJSQpil%7 z!p^9M=-BhLhIn22^oab(bQ~T~^-kSZ4n_8sHZY(wIAsF;*7uHY{-|Eg)EaoYir>#v zM{$lKl$grO{82JU#q-$nLSe{P6v9#8Fs)KIA@g{j1H(TY>Occx&-}9Qh(iPMivxUX zbT}MqrZ~PVH$oX>B(mLp44c;>&_}q|kDXI+8G%_UlUf?RP1RdYIg`G^1>d_CfBTip zA}awX&ql|kjYZ3XO8!c;s%y;Ww@-TSSL|1ZRt)LX>(!lbYgyCee~72LhTUAE-j48m zHM{*k>QAT_Y@{595Kt1f5B=r;!l>RH`aLsPMKcJGkeD&eA%tCqs`0StU-4?%m}cm= zWBZSR|2gP>jx@(Idl9x5Y05T(4TRylWSe|G^jnyh;5R`wLm$JY@Vf##3HD*S8Er-} zXh#OOp_jqScg)ab*>C8YY7H^C4fcc$-)B_5B=zT=g*L zg)GqaFfuLB`wvc63->tSGurQwKMZ}U+D_8k#?8kq?P}!dyM~P6Hx}Ma{Z#MqGDs6x zd7)e|cS-XwD!?&x?$FyfwoFUZg9pujJ`s`a5lew^*M)ar-XHph z8+LcWNS}~-Fu#LgJ^2|J{2?y$izL@@R-M^@fL`4U`%5A+gSE71m^2nfb8ueG0lj>1 z*nfiXEQuEmLWf-@GePSe#axtaeA&i|I=qG?sLDtJPMin0wcYza87M6*4nP3pM{|tkUorhJK$?%9B549{mWxRX8^|v(}zBXzK#K0 z$-{U$N|+6Dwc)&50{S1>lnLX9Vdhr?V^G3^xUGgfVd&qH&!oTJt5B?agkt?6lytych5t5~ zor+=bDVSQAT`--3jizHPv4ZPIHX0>(>x2DskjH5--GNc(URAq#yZU{vEU)ceA89se zH*2qW>%Cpx*L@0nw)=eQd(iiDKeOL1zYqMg{I>yeAW2v<1dNfVvV>@{4{ZQ;s;4$k}K&gU4icD6x-;%H`6u(siX*re;mGO}%I8iK$7Fk!_;s3z#MI^F(0(3EjujNtjjCnE8d#6YkKnx!%SP{rCGaXx6i4r($8(IzBsRU ze%Ab}3*KG0by20w*EU$AUR=5O=;Fa8%}YMKz5VtpOG}p?Sf*YUxon3c+|lf~>TGp> zUVBena^1=L#~am+n;SoATG(`{d1Ld}E$z#fEI+jT!xhV06Iy@Ywx`|PKGw`-v%BYStJ_y!U6Z*sGdiV9d|3JY5 z`yTjsTl2QpAB=pk_rU|(lefS4P|-t=JoL?utvgQa_~zlI5AS{W+RoCQ_w4-Q2aX?H z+Euyh@DIfw4m^_f$mt)={?Vt8p8dBEc31Bnc&y~HqkDGkIkD&KA20mziM`_9&3g~+ z{rD#{e$u}$cHhcx>G8)mK7RNK^2CxS9)IH6{-XUg`w#E`;>nsP&mE{aaQ3Ox zrwmUmeX8rJ15aIfTJ!Ymr(b#c`ZL+jta;|ZGuIEA5AHd5{n^RSZad_C$a?7Dp~0Wk zAC5fy_0Ow+{`k+okShB~pMPI;-^#v={jvRP`ky-zd}Qg79Y>BH`TDu|=N!*{@x1=| zbck98f}aO|FA+m9VMUUmHNF9uH7 zP8=HJ-&-dJUkH97>4nx8cE51x#j+On*DoYrSaM<0g-0&DdtvbH z__rs&?RfjXx23ngxM;X|;^O=7i0>?U=fFGH-YsS`PN9L=Fe_zm=nA-bIGa2O4fn%9 ze)0fN9K|*OQ+oIf;lB*Vckkem{n=UhXa)Adirz#8)}Y@hz&xC)aioW|6RQGK%*~!t zVD?SPOA4$4He0)8_-c|wV-?to#L^N4))0{{RA6snq&*7k1NgWC`{8ApuNBxI{fnE2 zVDy1(>5b9HLzC#UIgCui3p*8<0zRz3xH~MIRbUn1j}#cZsn||E=uj^rs*)901Ansu zYcXGIRA6uPv5%UZt>Qdqd#5Q((OO^K(A3@PbbZHvVS}r)rK4TU&dN6ABa}tT zbVnj4(m_^|wRr!pg*1_7(nUn_42%J<#2ZKspjw2ASk-GFogAkfkPAMuut4M@D|ncb z^dPL0!$wl>5o!hfc2MmkS@1O>PAlwru-iHQJWy*xTqE#WNj<5CyNPrIX4Ki;7REeB#`mYPkmrHrhjAx*r1O2W|8+V1>l9cib)nQcacgK2{?#Kl zo@LXfluSKrxm>bH9dg)NXVasn3)^&@p z?puVd&@XZ5!pb|r5U44dVf#i-&GSe<5V z$;7463Awi*25#{%Wrwptdr+&#_=E0bx=;nZ`3Ck%@#rE>jp8*j@=NNC)8W3J16VI+jHF=o)oJe??@h>EgLK!r&vZE)Jbb;9qyC8OY6ydq#3srF40EXM4M>~T~1ffR@z3}X$M_N zSCL{enYwTux{G#0i@H_|)lCVCg$jGICC(0g$b zz)V``7P^(*PkZSDbQ@VgAH+){578a;VbV%>lI8RVbQk?0O1O>eqmK{^>dhb}q=Wv5 zK8ic6AJc!sPT6iU4W|nqBQCm!{+RBiKcV|bC;cgIbg!aM(EapDdVoHKec&17AM|PZ z3_VDnB{OmQ?q~Ec{W+CT*Zv)Ks)ww`t)G6-==xO>I&QZRO``~Bj8#pWYCRszzLEEk+>*=rPTex$4fxb;P z(2HapeTTkF-=ml4zti{WujvQ$H{_?}4%|YXM?NL<$pU(r{uZZ_e@Cy7h4epgg71&y z8~O)&mHv_3PZrUS=%45{`e*tvMgbeKmavK3ML!{T(!b!;@$BszoggcU+LfI z-|1J_fT^MXpkLGfrr(goWC{6%eoIc!L7XAMk_t8yZx>YLPwXZnZis0GZ@~vA$@~O= zJn$Kav#Y^)|1wkv6T*cEAySAEqJ(Ll z3zPBcVu>(CC>5p(Wq8TrI{7R4n@}zo1(RSFEP_?25T*&!g&D$3j0%FVu9ZNZ#rWe1 zj8YyauaYfTMW_^J3A3?38cL=LbA&2ku23z^!|0};96?X$NBqfB%mzzI897Z}!`S9| za+DlHzkCqqQeVbSjW0PM%*PDfhioPHkq1aG*+%Zh4TFU^QgMxJCa+)}C)q=GH z&>^hUl-If%dK$Fd?JYT$a*J#m3luwBu?>oyC)?$2+oZS^{PH5jU9Q+l{Bnb~yvS))NcQkdhH>_ac**O-2pQ*0JRoC6t*xInh*Hqup<*ch~Xz$XP>zu%bcVCs)Z_Zl0#nS=Zgwps5@d0YZ&(BEwip{&JN>^AwTf88oxy*t0!i=jF?>^OVBO zQ_{*SQd!!Y#9L#oF{`zsu60)VG})b(?{;fUS)HAzpC(R7 zfw>4d9AK(zsBdX)b#hnGNHgYcjnhMlSzq%2D`PL1KKvO?l1#&FeW=(^K)2J|MQW7;OiI@~7P5HhJ z!-N#R6~2r{ZG*x`!w4TxEj%BX+ZFpYjqt zLwl>Uy}qSRCZC(HY2~#*(>g3MpeofpSCLq*A`Z8P%2Onmm#b-)W4C+69@ac=jigY? zD^@iOYa)e$S)puJ%EGL~F}ur3sW2w1SI0;*H7i1~xML|;W~HPpIRPD`nrVkDH;qf? z*5$!%zLHYDLL=X*>Eu{UK+r@czs?&ZX~K@6GMWJa`1Y>b$Q6XZn26T_gQnykSx)sgS%{5QAta(=R9Qdss;oN8Su;y7U zYo4_ufpT3aQlw|i*R1u_Jc@v<#hP`Ttna!Ts&}zc48{41<}tZ7jiPx9+-^;stZnKz zAqD0l1HeJ2c(g^6U1mSn?Gj zT<)F$a&CNiP4V>fYr6S1D1DJ zGxQVyZDU8bi`y+dJiN1I4GZtY5VxHH4J}R0T@0bUg%j`}P6D2O?o*nA8Cn~x3P zb+*;lwz3vw$j+5XT5^hfRyMd=I_iPrUDwgp=9Gcf4wei5POK)@b;yo@_U^V?wtCpa zf_xfVJJ7BHY1&%a*?_ULp{}F7o|DhXDNtLwT^&B`N6rWvYy;V^uG{5mXs=t#NYrsC#97JMXq~vrSpf)-LVz8N3~#X>;3Ou{jTH z?FJ{;aCb0>fj9Y!#w-&5uzQQ!fxy{3r_OHr95!8Cu3DEaFE zn4I;Jx@t{79`pnmDX}3wk@@xqMo?qC2vBG252IlS(@CVt)?n|Cqzp4tQtKsEh7@74 zvGk-!lSx5{6YE83ph{Aw*7T>*0F$N8B55qPL`jumuU>2ea$K)Xl&Y%WRc4P9r6L9u z+3jMVj17*`;H9`kDVv37Gt7Z1n+R$2Iz`F1%I1KN$ijRXRKTDDN1Ou(RO28m$=_5b zk!qVn_)`RlaN|&40>es}={$}#G=_29i`3ff^-jA)GwgOH2X?U@`Oz8enUa@Yw1|>A z#fdy=O;t8Yt20X8IwMK|Q5>0)hKn1L5bOK2wMLNzvh2pm?6Y4?-NK&7GrwiaK-V{kFH4m zG8LKu7EH*xL$vhjoUBl}ut^*%5J`*!lWs0iv^u9*P9oqY(WPX>LgeABc}5Ck^&^CLDWE~OCQ}O2&zx(UIZyVCON4(I_Yc?i zkxS>L(?Q54iInzZVUgE|2tAhQ1j~Gz6sj|d7AX+b zF8~eOfvOk|9|w3i7L7Nt*->Pp(a0nb4n`2x7w(-QJ&+NXgnAx{%tmBnN>Tbg%Is(e zp4l<_J{7ZL^?hn)$LagLm>sY0(=dCSzE8{S1bv@3v&ZZEe3+e~hgjr-kTed6TqkBp z^mf*4G9|r7VB~P%DmgIIBQSM1&?N_odLjj7+$_T^ug}YQWf}F%Un24+g0CdxkJ&op zkJ-t{AG1@CKW3*Qf6ShM{4qNX`D6A(1*QzCewIRXr&uz0ZqASZvUa ztQ2!S#B(#8JiVCDInM_*nU3YV`T}ZbOm3M!iF^*78X@LVT~S{ijbM2#fIN_aG2=MU zYMe!x624LyRgx(ceIF`ncpcD-QA8v%Ma&W_m~Mb*r}y?&=qjL#Z0G@@e$h1*Q5q2r z2~UO+ikI9T^udA7>X0-j?a}QPO^as8-ILnnsMkqe=v|@p)Fx*fV22%w z;>I{mu!fF6XLT0E>5zgI`Gmz&0lhI20V7N|YtV>M7+$Dw=t^$j1QeaD+hRygS(*%}V>r_Q)H56gG%y?nG%_3p zG;zMnfSNg93~J$gF=#pGi$NJr+bn|&;cgjZ2=@SY>M*@~xr@`gPX-zK z78zvdTM=`rOz(afWC*=7$PgaT_xtf7uB3_U$CWz^W>4tL?8XeqyFpSVSFLe(=$WjG z#boeIHHMaSJGwi0`nR%=XpKjb!UU4epy#8!gVchr43F2~fjkEL7+=s}^AVEv`yy|gr&CHlaBf#JP1I>%P6951J literal 0 HcmV?d00001 diff --git a/apps/nextjs/src/styles/globals.css b/apps/nextjs/src/styles/globals.css new file mode 100644 index 000000000..c921103b2 --- /dev/null +++ b/apps/nextjs/src/styles/globals.css @@ -0,0 +1,82 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; + +@layer base { + :root { + --background: 0deg 0% 100%; + --foreground: 222.2deg 47.4% 11.2%; + + --muted: 210deg 40% 96.1%; + --muted-foreground: 215.4deg 16.3% 46.9%; + + --popover: 0deg 0% 100%; + --popover-foreground: 222.2deg 47.4% 11.2%; + + --border: 214.3deg 31.8% 91.4%; + --input: 214.3deg 31.8% 91.4%; + + --card: 0deg 0% 100%; + --card-foreground: 222.2deg 47.4% 11.2%; + + --primary: 222.2deg 47.4% 11.2%; + --primary-foreground: 210deg 40% 98%; + + --secondary: 210deg 40% 96.1%; + --secondary-foreground: 222.2deg 47.4% 11.2%; + + --accent: 210deg 40% 96.1%; + --accent-foreground: 222.2deg 47.4% 11.2%; + + --destructive: 0deg 100% 50%; + --destructive-foreground: 210deg 40% 98%; + + --ring: 215deg 20.2% 65.1%; + + --radius: 0.5rem; + } + + .dark { + --background: 224 71% 4%; + --foreground: 213 31% 91%; + + --muted: 223 47% 11%; + --muted-foreground: 215.4 16.3% 56.9%; + + --accent: 216 34% 17%; + --accent-foreground: 210 40% 98%; + + --popover: 224 71% 4%; + --popover-foreground: 215 20.2% 65.1%; + + --border: 216 34% 17%; + --input: 216 34% 17%; + + --card: 224 71% 4%; + --card-foreground: 213 31% 91%; + + --primary: 210 40% 98%; + --primary-foreground: 222.2 47.4% 1.2%; + + --secondary: 222.2 47.4% 11.2%; + --secondary-foreground: 210 40% 98%; + + --destructive: 0 63% 31%; + --destructive-foreground: 210 40% 98%; + + --ring: 216 34% 17%; + + --radius: 0.5rem; + } +} + +@layer base { + body { + @apply bg-background text-foreground; + font-feature-settings: "rlig" 1, "calt" 1; + } + + .container { + @apply max-sm:px-4; + } +} diff --git a/apps/nextjs/src/trpc/client.ts b/apps/nextjs/src/trpc/client.ts new file mode 100644 index 000000000..015191fe1 --- /dev/null +++ b/apps/nextjs/src/trpc/client.ts @@ -0,0 +1,23 @@ +import { createTRPCClient, loggerLink } from "@trpc/client"; + +import type { AppRouter } from "@acme/api"; + +import { endingLink, transformer } from "./shared"; + +export const api = createTRPCClient({ + transformer: transformer, + links: [ + loggerLink({ + enabled: (opts) => + process.env.NODE_ENV === "development" || + (opts.direction === "down" && opts.result instanceof Error), + }), + endingLink({ + headers: { + "x-trpc-source": "client", + }, + }), + ], +}); + +export { type RouterInputs, type RouterOutputs } from "@acme/api"; diff --git a/apps/nextjs/src/trpc/server.ts b/apps/nextjs/src/trpc/server.ts new file mode 100644 index 000000000..ff6fd5e53 --- /dev/null +++ b/apps/nextjs/src/trpc/server.ts @@ -0,0 +1,28 @@ +import { headers } from "next/headers"; +import { createTRPCClient, loggerLink } from "@trpc/client"; + +import type { AppRouter } from "@acme/api"; + +import { endingLink, transformer } from "./shared"; + +export const api = createTRPCClient({ + transformer: transformer, + links: [ + loggerLink({ + enabled: (opts) => + process.env.NODE_ENV === "development" || + (opts.direction === "down" && opts.result instanceof Error), + }), + endingLink({ + headers: () => { + const h = new Map(headers()); + h.delete("connection"); + h.delete("transfer-encoding"); + h.set("x-trpc-source", "server"); + return Object.fromEntries(h.entries()); + }, + }), + ], +}); + +export { type RouterInputs, type RouterOutputs } from "@acme/api"; diff --git a/apps/nextjs/src/trpc/shared.ts b/apps/nextjs/src/trpc/shared.ts new file mode 100644 index 000000000..0c80bb260 --- /dev/null +++ b/apps/nextjs/src/trpc/shared.ts @@ -0,0 +1,44 @@ +import type { HTTPBatchLinkOptions, HTTPHeaders, TRPCLink } from "@trpc/client"; +import { httpBatchLink } from "@trpc/client"; + +import type { AppRouter } from "@acme/api"; + +export { transformer } from "@acme/api/transformer"; + +const getBaseUrl = () => { + if (typeof window !== "undefined") return ""; + const vc = process.env.VERCEL_URL; + if (vc) return `https://${vc}`; + return `http://localhost:3000`; +}; + +const lambdas = ["ingestion"]; + +export const endingLink = (opts?: { + headers?: HTTPHeaders | (() => HTTPHeaders); +}) => + ((runtime) => { + const sharedOpts = { + headers: opts?.headers, + } satisfies Partial; + + const edgeLink = httpBatchLink({ + ...sharedOpts, + url: `${getBaseUrl()}/api/trpc/edge`, + })(runtime); + const lambdaLink = httpBatchLink({ + ...sharedOpts, + url: `${getBaseUrl()}/api/trpc/lambda`, + })(runtime); + + return (ctx) => { + const path = ctx.op.path.split(".") as [string, ...string[]]; + const endpoint = lambdas.includes(path[0]) ? "lambda" : "edge"; + + const newCtx = { + ...ctx, + op: { ...ctx.op, path: path.join(".") }, + }; + return endpoint === "edge" ? edgeLink(newCtx) : lambdaLink(newCtx); + }; + }) satisfies TRPCLink; diff --git a/apps/nextjs/tailwind.config.ts b/apps/nextjs/tailwind.config.ts new file mode 100644 index 000000000..243a23599 --- /dev/null +++ b/apps/nextjs/tailwind.config.ts @@ -0,0 +1,10 @@ +import type { Config } from "tailwindcss"; + +import baseConfig from "@acme/tailwind-config"; + +export default { + // We need to append the path to the UI package to the content array so that + // those classes are included correctly. + content: [...baseConfig.content, "../../packages/ui/src/**/*.{ts,tsx}"], + presets: [baseConfig], +} satisfies Config; diff --git a/apps/nextjs/tsconfig.json b/apps/nextjs/tsconfig.json new file mode 100644 index 000000000..55e606524 --- /dev/null +++ b/apps/nextjs/tsconfig.json @@ -0,0 +1,17 @@ +{ + "extends": "@acme/tsconfig/base.json", + "compilerOptions": { + "baseUrl": ".", + "paths": { + "~/*": ["./src/*"], + }, + "plugins": [ + { + "name": "next", + }, + ], + "tsBuildInfoFile": "node_modules/.cache/tsbuildinfo.json", + }, + "include": ["next-env.d.ts", ".next/types/**/*.ts", "*.ts", "*.mjs", "src"], + "exclude": ["node_modules"], +} diff --git a/package.json b/package.json new file mode 100644 index 000000000..789af56b8 --- /dev/null +++ b/package.json @@ -0,0 +1,33 @@ +{ + "name": "create-t3-turbo", + "private": true, + "engines": { + "node": ">=v18.17.1" + }, + "packageManager": "pnpm@8.6.12", + "scripts": { + "build": "turbo build", + "clean": "git clean -xdf node_modules dist .next", + "clean:workspaces": "turbo clean", + "db:generate": "turbo db:generate", + "db:push": "turbo db:push db:generate", + "db:studio": "pnpm -F db studio", + "dev": "cross-env FORCE_COLOR=1 turbo dev --parallel", + "dev:web": "turbo dev --parallel --filter !@acme/expo --filter !@acme/db", + "format": "turbo format --continue -- --cache --cache-location='node_modules/.cache/.prettiercache' --ignore-path='../../.gitignore'", + "format:fix": "turbo format --continue -- --write --cache --cache-location='node_modules/.cache/.prettiercache' --ignore-path='../../.gitignore'", + "lint": "turbo lint --continue -- --cache --cache-location 'node_modules/.cache/.eslintcache' && manypkg check", + "lint:fix": "turbo lint --continue -- --fix --cache --cache-location 'node_modules/.cache/.eslintcache' && manypkg fix", + "typecheck": "turbo typecheck" + }, + "dependencies": { + "@acme/prettier-config": "^0.1.0", + "@manypkg/cli": "^0.21.2", + "@turbo/gen": "^1.11.3", + "cross-env": "^7.0.3", + "prettier": "^3.2.4", + "turbo": "^1.11.3", + "typescript": "^5.3.3" + }, + "prettier": "@acme/prettier-config" +} diff --git a/packages/api/package.json b/packages/api/package.json new file mode 100644 index 000000000..f90dff35a --- /dev/null +++ b/packages/api/package.json @@ -0,0 +1,55 @@ +{ + "name": "@acme/api", + "private": true, + "version": "0.1.0", + "exports": { + ".": "./src/index.ts", + "./env": "./src/env.mjs", + "./edge": "./src/edge.ts", + "./lambda": "./src/lambda.ts", + "./transformer": "./src/transformer.ts", + "./validators": "./src/validators.ts" + }, + "typesVersions": { + "*": { + "*": [ + "src/*" + ] + } + }, + "license": "MIT", + "scripts": { + "clean": "rm -rf .turbo node_modules", + "lint": "eslint .", + "format": "prettier --check \"**/*.{mjs,ts,json}\"", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@acme/db": "^0.1.0", + "@acme/stripe": "^0.1.0", + "@clerk/nextjs": "^4.29.4", + "@dinero.js/currencies": "2.0.0-alpha.14", + "@t3-oss/env-nextjs": "^0.7.3", + "@trpc/client": "next", + "@trpc/server": "next", + "dinero.js": "2.0.0-alpha.14", + "superjson": "2.2.1", + "zod": "^3.22.4", + "zod-form-data": "^2.0.2" + }, + "devDependencies": { + "@acme/eslint-config": "^0.2.0", + "@acme/prettier-config": "^0.1.0", + "@acme/tsconfig": "^0.1.0", + "eslint": "^8.56.0", + "prettier": "^3.2.4", + "typescript": "^5.3.3" + }, + "eslintConfig": { + "root": true, + "extends": [ + "@acme/eslint-config/base" + ] + }, + "prettier": "@acme/prettier-config" +} diff --git a/packages/api/src/edge.ts b/packages/api/src/edge.ts new file mode 100644 index 000000000..ed402749b --- /dev/null +++ b/packages/api/src/edge.ts @@ -0,0 +1,13 @@ +import { authRouter } from "./router/auth"; +import { organizationsRouter } from "./router/organizations"; +import { projectRouter } from "./router/project"; +import { stripeRouter } from "./router/stripe"; +import { createTRPCRouter } from "./trpc"; + +// Deployed to /trpc/edge/** +export const edgeRouter = createTRPCRouter({ + project: projectRouter, + auth: authRouter, + stripe: stripeRouter, + organization: organizationsRouter, +}); diff --git a/packages/api/src/env.mjs b/packages/api/src/env.mjs new file mode 100644 index 000000000..70532b2e5 --- /dev/null +++ b/packages/api/src/env.mjs @@ -0,0 +1,19 @@ +import { createEnv } from "@t3-oss/env-nextjs"; +import * as z from "zod"; + +export const env = createEnv({ + shared: {}, + server: { + NEXTJS_URL: z.preprocess( + (str) => + process.env.VERCEL_URL ? `https://${process.env.VERCEL_URL}` : str, + process.env.VERCEL_URL ? z.string().min(1) : z.string().url(), + ), + }, + // 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: {}, + skipValidation: + !!process.env.SKIP_ENV_VALIDATION || + process.env.npm_lifecycle_event === "lint", +}); diff --git a/packages/api/src/index.ts b/packages/api/src/index.ts new file mode 100644 index 000000000..59d7dc88a --- /dev/null +++ b/packages/api/src/index.ts @@ -0,0 +1,22 @@ +import type { inferRouterInputs, inferRouterOutputs } from "@trpc/server"; + +import type { AppRouter } from "./root"; + +export { createTRPCContext, createInnerTRPCContext } from "./trpc"; + +// TODO: Maybe just export `createAction` instead of the whole `trpc` object? +export { t } from "./trpc"; + +export type { AppRouter } from "./root"; +export { appRouter } from "./root"; +/** + * Inference helpers for input types + * @example type HelloInput = RouterInputs['example']['hello'] + **/ +export type RouterInputs = inferRouterInputs; + +/** + * Inference helpers for output types + * @example type HelloOutput = RouterOutputs['example']['hello'] + **/ +export type RouterOutputs = inferRouterOutputs; diff --git a/packages/api/src/lambda.ts b/packages/api/src/lambda.ts new file mode 100644 index 000000000..1c6fcafd4 --- /dev/null +++ b/packages/api/src/lambda.ts @@ -0,0 +1,7 @@ +import { ingestionRouter } from "./router/ingestion"; +import { createTRPCRouter } from "./trpc"; + +// Deployed to /trpc/lambda/** +export const lambdaRouter = createTRPCRouter({ + ingestion: ingestionRouter, +}); diff --git a/packages/api/src/root.ts b/packages/api/src/root.ts new file mode 100644 index 000000000..f79e5a529 --- /dev/null +++ b/packages/api/src/root.ts @@ -0,0 +1,8 @@ +import { edgeRouter } from "./edge"; +import { lambdaRouter } from "./lambda"; +import { mergeRouters } from "./trpc"; + +// Used to provide a good DX with a single client +// Then, a custom link is used to generate the correct URL for the request +export const appRouter = mergeRouters(edgeRouter, lambdaRouter); +export type AppRouter = typeof appRouter; diff --git a/packages/api/src/router/auth.ts b/packages/api/src/router/auth.ts new file mode 100644 index 000000000..8b2beed40 --- /dev/null +++ b/packages/api/src/router/auth.ts @@ -0,0 +1,28 @@ +import { clerkClient } from "@clerk/nextjs"; + +import { createTRPCRouter, protectedProcedure } from "../trpc"; + +export const authRouter = createTRPCRouter({ + mySubscription: protectedProcedure.query(async (opts) => { + const customer = await opts.ctx.db + .selectFrom("Customer") + .select(["plan", "endsAt"]) + .where("clerkUserId", "=", opts.ctx.auth.userId) + .executeTakeFirst(); + + if (!customer) return null; + + return { plan: customer.plan ?? null, endsAt: customer.endsAt ?? null }; + }), + listOrganizations: protectedProcedure.query(async (opts) => { + const memberships = await clerkClient.users.getOrganizationMembershipList({ + userId: opts.ctx.auth.userId, + }); + + return memberships.map(({ organization }) => ({ + id: organization.id, + name: organization.name, + image: organization.imageUrl, + })); + }), +}); diff --git a/packages/api/src/router/ingestion.ts b/packages/api/src/router/ingestion.ts new file mode 100644 index 000000000..7970ac7a2 --- /dev/null +++ b/packages/api/src/router/ingestion.ts @@ -0,0 +1,93 @@ +import { z } from "zod"; +import { zfd } from "zod-form-data"; + +import { genId } from "@acme/db"; + +import { + createTRPCRouter, + protectedApiFormDataProcedure, + protectedProcedure, +} from "../trpc"; + +globalThis.File = File; + +const myFileValidator = z.preprocess( + // @ts-expect-error - this is a hack. not sure why it's needed since it should already be a File + (file: File) => + new File([file], file.name, { + type: file.type, + lastModified: file.lastModified, + }), + zfd.file(z.instanceof(File)), +); + +/** + * FIXME: Not all of these have to run on lambda, just the upload one + */ + +export const ingestionRouter = createTRPCRouter({ + byId: protectedProcedure + .input(z.object({ id: z.string() })) + .query(async (opts) => { + const ingestion = await opts.ctx.db + .selectFrom("Ingestion") + .select(["id", "createdAt", "hash", "schema", "origin", "parent"]) + .where("id", "=", opts.input.id) + .executeTakeFirstOrThrow(); + + return ingestion; + }), + + list: protectedProcedure + .input( + z.object({ + projectId: z.string(), + limit: z.number().optional(), + }), + ) + .query(async (opts) => { + let query = opts.ctx.db + .selectFrom("Ingestion") + .select(["id", "createdAt", "hash"]) + .where("projectId", "=", opts.input.projectId); + + if (opts.input.limit) { + query = query.limit(opts.input.limit).orderBy("createdAt", "desc"); + } + const ingestions = await query.execute(); + + return ingestions.map((ingestion) => ({ + ...ingestion, + adds: Math.floor(Math.random() * 10), + subs: Math.floor(Math.random() * 10), + })); + }), + upload: protectedApiFormDataProcedure + .input( + zfd.formData({ + hash: zfd.text(), + parent: zfd.text().optional(), + origin: zfd.text(), + schema: myFileValidator, + }), + ) + .mutation(async (opts) => { + const fileContent = await opts.input.schema.text(); + + const id = "ingest_" + genId(); + await opts.ctx.db + .insertInto("Ingestion") + .values({ + id, + projectId: opts.ctx.apiKey.projectId, + hash: opts.input.hash, + parent: opts.input.parent, + origin: opts.input.origin, + schema: fileContent, + apiKeyId: opts.ctx.apiKey.id, + }) + .executeTakeFirst(); + + return { status: "ok" }; + }), +}); diff --git a/packages/api/src/router/organizations.ts b/packages/api/src/router/organizations.ts new file mode 100644 index 000000000..5ffebc891 --- /dev/null +++ b/packages/api/src/router/organizations.ts @@ -0,0 +1,97 @@ +import { clerkClient } from "@clerk/nextjs"; +import { TRPCError } from "@trpc/server"; +import * as z from "zod"; + +import { + createTRPCRouter, + protectedAdminProcedure, + protectedOrgProcedure, +} from "../trpc"; +import { inviteOrgMemberSchema } from "../validators"; + +export const organizationsRouter = createTRPCRouter({ + listMembers: protectedOrgProcedure.query(async (opts) => { + const { orgId } = opts.ctx.auth; + + const members = + await clerkClient.organizations.getOrganizationMembershipList({ + organizationId: orgId, + }); + + return members.map((member) => ({ + id: member.id, + email: member.publicUserData?.identifier ?? "", + role: member.role, + joinedAt: member.createdAt, + avatarUrl: member.publicUserData?.imageUrl, + name: [ + member.publicUserData?.firstName, + member.publicUserData?.lastName, + ].join(" "), + })); + }), + + deleteMember: protectedAdminProcedure + .input(z.object({ userId: z.string() })) + .mutation(async (opts) => { + const { orgId } = opts.ctx.auth; + + try { + const member = + await clerkClient.organizations.deleteOrganizationMembership({ + organizationId: orgId, + userId: opts.input.userId, + }); + + return { memberName: member.publicUserData?.firstName }; + } catch (e) { + console.log("Error deleting member", e); + throw new TRPCError({ + code: "NOT_FOUND", + message: "User not found", + }); + } + }), + + inviteMember: protectedAdminProcedure + .input(inviteOrgMemberSchema) + .mutation(async (opts) => { + const { orgId } = opts.ctx.auth; + + const { email } = opts.input; + const users = await clerkClient.users.getUserList({ + emailAddress: [email], + }); + const user = users[0]; + + if (users.length === 0 || !user) { + throw new TRPCError({ + code: "NOT_FOUND", + message: "User not found", + }); + } + + if (users.length > 1) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "Multiple users found with that email address", + }); + } + + const member = + await clerkClient.organizations.createOrganizationMembership({ + organizationId: orgId, + userId: user.id, + role: opts.input.role, + }); + + const { firstName, lastName } = member.publicUserData ?? {}; + return { name: [firstName, lastName].join(" ") }; + }), + + deleteOrganization: protectedAdminProcedure.mutation(async (opts) => { + const { orgId } = opts.ctx.auth; + + await clerkClient.organizations.deleteOrganization(orgId); + }), +}); diff --git a/packages/api/src/router/project.ts b/packages/api/src/router/project.ts new file mode 100644 index 000000000..4a1fc6bc6 --- /dev/null +++ b/packages/api/src/router/project.ts @@ -0,0 +1,414 @@ +import { clerkClient } from "@clerk/nextjs"; +import { TRPCError } from "@trpc/server"; +import { z } from "zod"; + +import { genId } from "@acme/db"; + +import { + createTRPCRouter, + protectedAdminProcedure, + protectedProcedure, +} from "../trpc"; +import { + createApiKeySchema, + createProjectSchema, + renameProjectSchema, + transferToOrgSchema, +} from "../validators"; + +const PROJECT_LIMITS = { + FREE: 1, + PRO: 3, +} as const; + +export const projectRouter = createTRPCRouter({ + create: protectedProcedure + .input(createProjectSchema) + .mutation(async (opts) => { + const { userId, orgId } = opts.ctx.auth; + const { name } = opts.input; + + // Check if limit is reached + let query = opts.ctx.db + .selectFrom("Project") + .select(({ fn }) => [fn.count("id").as("projects")]); + if (orgId) { + query = query.where("organizationId", "=", orgId); + } else { + query = query.where("userId", "=", userId); + } + const projects = (await query.executeTakeFirst())?.projects ?? 0; + + // FIXME: Don't hardcode the limit to PRO + if (projects >= PROJECT_LIMITS.PRO) { + throw new TRPCError({ code: "BAD_REQUEST", message: "Limit reached" }); + } + + const projectId = "project_" + genId(); + + await opts.ctx.db + .insertInto("Project") + .values({ + id: projectId, + name, + userId: orgId ? null : userId, + organizationId: orgId, + }) + .execute(); + + return projectId; + }), + + rename: protectedProcedure + .input(renameProjectSchema) + .mutation(async (opts) => { + const { projectId, name } = opts.input; + + // TODO: Validate permissions, should anyone with access to the project be able to change the name? + + await opts.ctx.db + .updateTable("Project") + .set({ + name, + }) + .where("id", "=", projectId) + .execute(); + }), + + delete: protectedProcedure + .input(z.object({ id: z.string() })) + .mutation(async (opts) => { + const { userId, orgId } = opts.ctx.auth; + + const deleteQuery = opts.ctx.db + .deleteFrom("Project") + .where("id", "=", opts.input.id); + + // TODO: Check billing etc + + if (orgId) { + // TODO: Check permissions + await deleteQuery.where("organizationId", "=", orgId).execute(); + } else { + await deleteQuery.where("userId", "=", userId).execute(); + } + }), + + transferToPersonal: protectedAdminProcedure + .input(z.object({ id: z.string() })) + .mutation(async (opts) => { + const project = await opts.ctx.db + .selectFrom("Project") + .select(["id", "userId", "organizationId"]) + .where("id", "=", opts.input.id) + .executeTakeFirst(); + + if (!project) { + throw new TRPCError({ + code: "NOT_FOUND", + message: "Project not found", + }); + } + + if (!project.organizationId) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "Project is already personal", + }); + } + + await opts.ctx.db + .updateTable("Project") + .set({ + userId: opts.ctx.auth.userId, + organizationId: null, + }) + .where("id", "=", project.id) + .execute(); + }), + + transferToOrganization: protectedProcedure + .input(transferToOrgSchema) + .mutation(async (opts) => { + const { userId, orgId: userOrgId, orgRole } = opts.ctx.auth; + const { orgId: targetOrgId } = opts.input; + + const orgs = await clerkClient.users.getOrganizationMembershipList({ + userId: userId, + }); + const org = orgs.find((org) => org.organization.id === targetOrgId); + + if (!org) { + throw new TRPCError({ + code: "UNAUTHORIZED", + message: "You're not a member of the target organization", + }); + } + + const project = await opts.ctx.db + .selectFrom("Project") + .select(["id", "userId", "organizationId"]) + .where(({ eb, and, or }) => + and([ + eb("id", "=", opts.input.projectId), + or([ + eb("userId", "=", userId), + eb("organizationId", "=", userOrgId ?? ""), + ]), + ]), + ) + .executeTakeFirst(); + + if (!project) { + throw new TRPCError({ + code: "NOT_FOUND", + message: "Project not found", + }); + } + + if (project.organizationId === targetOrgId) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "Project is already in the target organization", + }); + } + + if ( + project.organizationId && + project.organizationId !== userOrgId && + orgRole !== "admin" + ) { + throw new TRPCError({ + code: "UNAUTHORIZED", + message: "You must be an admin to transfer this project", + }); + } + + await opts.ctx.db + .updateTable("Project") + .set({ + userId: null, + organizationId: targetOrgId, + }) + .where("id", "=", project.id) + .execute(); + }), + + listByActiveWorkspace: protectedProcedure.query(async (opts) => { + const { userId, orgId } = opts.ctx.auth; + + let query = opts.ctx.db + .selectFrom("Project") + .select(["id", "name", "url", "tier"]); + if (orgId) { + query = query.where("organizationId", "=", orgId); + } else { + query = query.where("userId", "=", userId); + } + + const projects = await query.execute(); + + // FIXME: Don't hardcode the limit to PRO + return { + projects, + limit: PROJECT_LIMITS.PRO, + limitReached: projects.length >= PROJECT_LIMITS.PRO, + }; + }), + + byId: protectedProcedure + .input(z.object({ id: z.string() })) + .query(async (opts) => { + const { userId } = opts.ctx.auth; + const { id } = opts.input; + + const orgs = await clerkClient.users.getOrganizationMembershipList({ + userId: userId, + }); + const orgIds = orgs.map((org) => org.organization.id); + + // Verify the user has access to the project + const query = opts.ctx.db + .selectFrom("Project") + .select(["id", "name", "url", "tier", "organizationId"]) + .where(({ eb, and, or }) => + and([ + eb("id", "=", id), + orgIds.length > 0 + ? or([ + eb("userId", "=", userId), + eb("organizationId", "in", orgIds), + ]) + : eb("userId", "=", userId), + ]), + ); + + const project = await query.executeTakeFirst(); + if (!project) { + throw new TRPCError({ + code: "NOT_FOUND", + message: "Project not found", + }); + } + + return project; + }), + + listApiKeys: protectedProcedure + .input( + z.object({ + projectId: z.string(), + }), + ) + .query(async (opts) => { + const { userId } = opts.ctx.auth; + const { projectId } = opts.input; + + const apiKeys = await opts.ctx.db + .selectFrom("ApiKey") + .select([ + "id", + "name", + "key", + "createdAt", + "lastUsed", + "expiresAt", + "revokedAt", + ]) + .where("projectId", "=", projectId) + .where("clerkUserId", "=", userId) + // first active, then expired, then revoked + .orderBy((eb) => + eb + .case() + .when("revokedAt", "is not", null) + .then(3) + .when( + eb.and([ + eb("expiresAt", "is not", null), + eb("expiresAt", "<", new Date()), + ]), + ) + .then(2) + .else(1) + .end(), + ) + .orderBy("createdAt", "desc") + .execute(); + + // TODO: Project admins should maybe be able to see all keys for the project? + + return apiKeys; + }), + + createApiKey: protectedProcedure + .input(createApiKeySchema) + .mutation(async (opts) => { + const projectId = opts.input.projectId; + const userId = opts.ctx.auth.userId; + + // Verify the user has access to the project + const project = await opts.ctx.db + .selectFrom("Project") + .select(["id", "name", "userId", "organizationId"]) + .where("id", "=", projectId) + .executeTakeFirst(); + + if (!project) { + throw new TRPCError({ + code: "NOT_FOUND", + message: "Project not found", + }); + } + + if (project.userId && project.userId !== userId) { + throw new TRPCError({ + code: "FORBIDDEN", + message: "You don't have access to this project", + }); + } + + if (project.organizationId) { + const orgs = await clerkClient.users.getOrganizationMembershipList({ + userId, + }); + const isMemberInProjectOrg = orgs.some( + (org) => org.organization.id === project.organizationId, + ); + + if (!isMemberInProjectOrg) { + throw new TRPCError({ + code: "FORBIDDEN", + message: "You don't have access to this project", + }); + } + } + + // Generate the key + const apiKey = "sk_live_" + genId(); + const apiKeyId = "api_key_" + genId(); + await opts.ctx.db + .insertInto("ApiKey") + .values({ + id: apiKeyId, + name: opts.input.name, + key: apiKey, + expiresAt: opts.input.expiresAt, + projectId: opts.input.projectId, + clerkUserId: userId, + }) + .execute(); + + return apiKey; + }), + + revokeApiKeys: protectedProcedure + .input(z.object({ ids: z.string().array() })) + .mutation(async (opts) => { + const { userId } = opts.ctx.auth; + + const result = await opts.ctx.db + .updateTable("ApiKey") + .set({ revokedAt: new Date() }) + .where("id", "in", opts.input.ids) + .where("clerkUserId", "=", String(userId)) + .where("revokedAt", "is", null) + .executeTakeFirst(); + + if (result.numUpdatedRows === BigInt(0)) { + throw new TRPCError({ + code: "NOT_FOUND", + message: "API key not found", + }); + } + + return { success: true, numRevoked: result.numUpdatedRows }; + }), + + rollApiKey: protectedProcedure + .input(z.object({ id: z.string() })) + .mutation(async (opts) => { + const apiKey = await opts.ctx.db + .selectFrom("ApiKey") + .select(["id"]) + .where("id", "=", opts.input.id) + .where("clerkUserId", "=", opts.ctx.auth.userId) + .executeTakeFirst(); + + if (!apiKey) { + throw new TRPCError({ + code: "NOT_FOUND", + message: "API key not found", + }); + } + + const newKey = "sk_live_" + genId(); + await opts.ctx.db + .updateTable("ApiKey") + .set({ key: newKey }) + .where("id", "=", opts.input.id) + .execute(); + + return newKey; + }), +}); diff --git a/packages/api/src/router/stripe.ts b/packages/api/src/router/stripe.ts new file mode 100644 index 000000000..e9a7c3e25 --- /dev/null +++ b/packages/api/src/router/stripe.ts @@ -0,0 +1,111 @@ +import { currentUser } from "@clerk/nextjs"; +import * as currencies from "@dinero.js/currencies"; +import { dinero } from "dinero.js"; +import * as z from "zod"; + +import { PLANS, stripe } from "@acme/stripe"; + +import { env } from "../env.mjs"; +import { createTRPCRouter, protectedProcedure, publicProcedure } from "../trpc"; +import { purchaseOrgSchema } from "../validators"; + +export const stripeRouter = createTRPCRouter({ + createSession: protectedProcedure + .input(z.object({ planId: z.string() })) + .mutation(async (opts) => { + const { userId } = opts.ctx.auth; + + const customer = await opts.ctx.db + .selectFrom("Customer") + .select(["id", "plan", "stripeId"]) + .where("clerkUserId", "=", userId) + .executeTakeFirst(); + + const returnUrl = env.NEXTJS_URL + "/dashboard"; + + if (customer && customer.plan !== "FREE") { + /** + * User is subscribed, create a billing portal session + */ + const session = await stripe.billingPortal.sessions.create({ + customer: customer.stripeId, + return_url: returnUrl, + }); + return { success: true as const, url: session.url }; + } + + /** + * User is not subscribed, create a checkout session + * Use existing email address if available + */ + + const user = await currentUser(); + const email = user?.emailAddresses.find( + (addr) => addr.id === user?.primaryEmailAddressId, + )?.emailAddress; + + const session = await stripe.checkout.sessions.create({ + mode: "subscription", + payment_method_types: ["card"], + customer_email: email, + client_reference_id: userId, + subscription_data: { metadata: { userId } }, + cancel_url: returnUrl, + success_url: returnUrl, + line_items: [{ price: PLANS.PRO?.priceId, quantity: 1 }], + }); + + if (!session.url) return { success: false as const }; + return { success: true as const, url: session.url }; + }), + + plans: publicProcedure.query(async () => { + const proPrice = await stripe.prices.retrieve(PLANS.PRO.priceId); + const stdPrice = await stripe.prices.retrieve(PLANS.STANDARD.priceId); + + return [ + { + ...PLANS.STANDARD, + price: dinero({ + amount: stdPrice.unit_amount!, + currency: + currencies[stdPrice.currency as keyof typeof currencies] ?? + currencies.USD, + }), + }, + { + ...PLANS.PRO, + price: dinero({ + amount: proPrice.unit_amount!, + currency: + currencies[proPrice.currency as keyof typeof currencies] ?? + currencies.USD, + }), + }, + ]; + }), + + purchaseOrg: protectedProcedure + .input(purchaseOrgSchema) + .mutation(async (opts) => { + const { userId } = opts.ctx.auth; + const { orgName, planId } = opts.input; + + const baseUrl = new URL(opts.ctx.req?.nextUrl ?? env.NEXTJS_URL).origin; + + const session = await stripe.checkout.sessions.create({ + mode: "subscription", + payment_method_types: ["card"], + client_reference_id: userId, + subscription_data: { + metadata: { userId, organizationName: orgName }, + }, + success_url: baseUrl + "/onboarding", + cancel_url: baseUrl, + line_items: [{ price: planId, quantity: 1 }], + }); + + if (!session.url) return { success: false as const }; + return { success: true as const, url: session.url }; + }), +}); diff --git a/packages/api/src/transformer.ts b/packages/api/src/transformer.ts new file mode 100644 index 000000000..97f93b592 --- /dev/null +++ b/packages/api/src/transformer.ts @@ -0,0 +1,30 @@ +import { dinero } from "dinero.js"; +import type { Dinero, DineroSnapshot } from "dinero.js"; +import superjson from "superjson"; +import type { JSONValue } from "superjson/dist/types"; + +/** + * TODO: Maybe put this in a shared package that can be safely shared between `api`, `nextjs` and `expo` packages + */ +superjson.registerCustom( + { + isApplicable: (val): val is Dinero => { + try { + // if this doesn't crash we're kinda sure it's a Dinero instance + (val as Dinero).calculator.add(1, 2); + return true; + } catch { + return false; + } + }, + serialize: (val) => { + return val.toJSON() as JSONValue; + }, + deserialize: (val) => { + return dinero(val as DineroSnapshot); + }, + }, + "Dinero", +); + +export const transformer = superjson; diff --git a/packages/api/src/trpc.ts b/packages/api/src/trpc.ts new file mode 100644 index 000000000..297c2bb18 --- /dev/null +++ b/packages/api/src/trpc.ts @@ -0,0 +1,224 @@ +/** + * YOU PROBABLY DON'T NEED TO EDIT THIS FILE, UNLESS: + * 1. You want to modify request context (see Part 1) + * 2. You want to create a new middleware or type of procedure (see Part 3) + * + * tl;dr - this is where all the tRPC server stuff is created and plugged in. + * The pieces you will need to use are documented accordingly near the end + */ +import type { NextRequest } from "next/server"; +import type { + SignedInAuthObject, + SignedOutAuthObject, +} from "@clerk/nextjs/server"; +import { initTRPC, TRPCError } from "@trpc/server"; +import { ZodError } from "zod"; + +import { db } from "@acme/db"; + +import { transformer } from "./transformer"; + +type AuthContext = SignedInAuthObject | SignedOutAuthObject; +/** + * 1. CONTEXT + * + * This section defines the "contexts" that are available in the backend API + * + * These allow you to access things like the database, the session, etc, when + * processing a request + * + */ +interface CreateContextOptions { + headers: Headers; + auth: AuthContext; + apiKey?: string | null; + req?: NextRequest; +} + +/** + * This helper generates the "internals" for a tRPC context. If you need to use + * it, you can export it from here + * + * Examples of things you may need it for: + * - testing, so we dont have to mock Next.js' req/res + * - trpc's `createSSGHelpers` where we don't have req/res + * @see https://create.t3.gg/en/usage/trpc#-servertrpccontextts + */ +export const createInnerTRPCContext = (opts: CreateContextOptions) => { + return { + ...opts, + db, + }; +}; + +/** + * This is the actual context you'll use in your router. It will be used to + * process every request that goes through your tRPC endpoint + * @link https://trpc.io/docs/context + */ +export const createTRPCContext = async (opts: { + headers: Headers; + auth: AuthContext; + req?: NextRequest; + // eslint-disable-next-line @typescript-eslint/require-await +}) => { + const apiKey = opts.req?.headers.get("x-acme-api-key"); + + return createInnerTRPCContext({ + auth: opts.auth, + apiKey, + req: opts.req, + headers: opts.headers, + }); +}; + +/** + * 2. INITIALIZATION + * + * This is where the trpc api is initialized, connecting the context and + * transformer + */ +export const t = initTRPC.context().create({ + transformer, + errorFormatter({ shape, error }) { + return { + ...shape, + data: { + ...shape.data, + zodError: + error.cause instanceof ZodError ? error.cause.flatten() : null, + }, + }; + }, +}); + +/** + * 3. ROUTER & PROCEDURE (THE IMPORTANT BIT) + * + * These are the pieces you use to build your tRPC API. You should import these + * a lot in the /src/server/api/routers folder + */ + +/** + * This is how you create new routers and subrouters in your tRPC API + * @see https://trpc.io/docs/router + */ +export const createTRPCRouter = t.router; +export const mergeRouters = t.mergeRouters; + +/** + * Public (unauthed) procedure + * + * This is the base piece you use to build new queries and mutations on your + * tRPC API. It does not guarantee that a user querying is authorized, but you + * can still access user session data if they are logged in + */ +export const publicProcedure = t.procedure; + +/** + * Reusable procedure that enforces users are logged in before running the + * code + */ +export const protectedProcedure = t.procedure.use(({ ctx, next }) => { + if (!ctx.auth?.userId) { + throw new TRPCError({ code: "UNAUTHORIZED" }); + } + return next({ + ctx: { + auth: { + ...ctx.auth, + userId: ctx.auth.userId, + }, + }, + }); +}); +/** + * Reusable procedure that enforces users are part of an organization before + * running the code + */ +export const protectedOrgProcedure = protectedProcedure.use(({ ctx, next }) => { + if (!ctx.auth?.orgId) { + throw new TRPCError({ + code: "UNAUTHORIZED", + message: "You must be in an organization to perform this action", + }); + } + return next({ + ctx: { + auth: { + ...ctx.auth, + orgId: ctx.auth.orgId, + }, + }, + }); +}); +/** + * Procedure that enforces users are admins of an organization before running + * the code + */ +export const protectedAdminProcedure = protectedOrgProcedure.use( + ({ ctx, next }) => { + if (ctx.auth.orgRole !== "admin") { + throw new TRPCError({ + code: "UNAUTHORIZED", + message: "You must be an admin to perform this action", + }); + } + + return next({ + ctx: { + auth: { + ...ctx.auth, + orgRole: ctx.auth.orgRole, + }, + }, + }); + }, +); + +/** + * Procedure to authenticate API requests with an API key + */ +export const protectedApiProcedure = t.procedure.use(async ({ ctx, next }) => { + if (!ctx.apiKey) { + throw new TRPCError({ code: "UNAUTHORIZED" }); + } + + // Check db for API key + const apiKey = await ctx.db + .selectFrom("ApiKey") + .select(["id", "key", "projectId"]) + .where("ApiKey.key", "=", ctx.apiKey) + .where("revokedAt", "is", null) + .executeTakeFirst(); + + if (!apiKey) { + throw new TRPCError({ code: "UNAUTHORIZED" }); + } + + void ctx.db + .updateTable("ApiKey") + .set({ lastUsed: new Date() }) + .where("id", "=", apiKey.id) + .execute(); + + return next({ + ctx: { + apiKey, + }, + }); +}); + +/** + * Procedure to parse form data and put it in the rawInput and authenticate requests with an API key + */ +export const protectedApiFormDataProcedure = protectedApiProcedure.use( + async function formData(opts) { + const formData = await opts.ctx.req?.formData?.(); + if (!formData) throw new TRPCError({ code: "BAD_REQUEST" }); + + return opts.next({ + input: formData, + }); + }, +); diff --git a/packages/api/src/validators.ts b/packages/api/src/validators.ts new file mode 100644 index 000000000..786a4ea30 --- /dev/null +++ b/packages/api/src/validators.ts @@ -0,0 +1,55 @@ +import * as z from "zod"; + +import { PLANS } from "@acme/stripe/plans"; + +/** + * Shared validators used in both the frontend and backend + */ + +export const createProjectSchema = z.object({ + name: z.string().min(5, "Name must be at least 5 characters"), + url: z.string().url("Must be a valid URL").optional(), +}); +export type CreateProject = z.infer; + +export const renameProjectSchema = z.object({ + projectId: z.string(), + name: z.string().min(5, "Name must be at least 5 characters"), +}); +export type RenameProject = z.infer; + +export const purchaseOrgSchema = z.object({ + orgName: z.string().min(5, "Name must be at least 5 characters"), + planId: z.string().refine( + (str) => + Object.values(PLANS) + .map((p) => p.priceId) + .includes(str), + "Invalid planId", + ), +}); +export type PurchaseOrg = z.infer; + +export const createApiKeySchema = z.object({ + projectId: z.string(), + name: z.string(), + expiresAt: z.date().optional(), +}); +export type CreateApiKey = z.infer; + +export const MEMBERSHIP = { + Member: "basic_member", + Admin: "admin", +} as const; + +export const inviteOrgMemberSchema = z.object({ + email: z.string().email(), + role: z.nativeEnum(MEMBERSHIP), +}); +export type InviteOrgMember = z.infer; + +export const transferToOrgSchema = z.object({ + projectId: z.string(), + orgId: z.string(), +}); +export type TransferToOrg = z.infer; diff --git a/packages/api/tsconfig.json b/packages/api/tsconfig.json new file mode 100644 index 000000000..e6fd377e4 --- /dev/null +++ b/packages/api/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "@acme/tsconfig/base.json", + "compilerOptions": { + "tsBuildInfoFile": "node_modules/.cache/tsbuildinfo.json", + }, + "include": ["src"], + "exclude": ["node_modules"], +} diff --git a/packages/db/index.ts b/packages/db/index.ts new file mode 100644 index 000000000..31bb56b71 --- /dev/null +++ b/packages/db/index.ts @@ -0,0 +1,22 @@ +// Generated by prisma/post-generate.ts + +import { Kysely } from "kysely"; +import { PlanetScaleDialect } from "kysely-planetscale"; +import { customAlphabet } from "nanoid"; + +import type { DB } from "./prisma/types"; + +export { jsonArrayFrom, jsonObjectFrom } from "kysely/helpers/postgres"; + +export * from "./prisma/types"; +export * from "./prisma/enums"; + +export const db = new Kysely({ + dialect: new PlanetScaleDialect({ + url: process.env.DATABASE_URL, + }), +}); + +// Use custom alphabet without special chars for less chaotic, copy-able URLs +// Will not collide for a long long time: https://zelark.github.io/nano-id-cc/ +export const genId = customAlphabet("0123456789abcdefghijklmnopqrstuvwxyz", 16); diff --git a/packages/db/package.json b/packages/db/package.json new file mode 100644 index 000000000..09720338b --- /dev/null +++ b/packages/db/package.json @@ -0,0 +1,45 @@ +{ + "name": "@acme/db", + "private": true, + "version": "0.1.0", + "exports": { + ".": "./index.ts" + }, + "license": "MIT", + "scripts": { + "clean": "rm -rf .turbo node_modules", + "db:generate": "pnpm with-env prisma generate", + "db:push": "pnpm with-env prisma db push --skip-generate", + "studio": "pnpm with-env prisma studio --port 5556", + "format": "prisma format && prettier --check \"**/*.{mjs,ts,json}\"", + "lint": "eslint .", + "typecheck": "tsc --noEmit", + "with-env": "dotenv -e ../../.env.local --" + }, + "dependencies": { + "@planetscale/database": "^1.14.0", + "kysely": "^0.27.2", + "kysely-planetscale": "^1.4.0", + "nanoid": "^5.0.4" + }, + "devDependencies": { + "@acme/eslint-config": "^0.2.0", + "@acme/prettier-config": "^0.1.0", + "@acme/tsconfig": "^0.1.0", + "dotenv-cli": "^7.3.0", + "eslint": "^8.56.0", + "prettier": "^3.2.4", + "prisma": "^5.8.1", + "prisma-kysely": "^1.7.1", + "typescript": "^5.3.3" + }, + "eslintConfig": { + "extends": [ + "@acme/eslint-config/base" + ], + "rules": { + "@typescript-eslint/consistent-type-definitions": "off" + } + }, + "prettier": "@acme/prettier-config" +} diff --git a/packages/db/prisma/enums.ts b/packages/db/prisma/enums.ts new file mode 100644 index 000000000..905c8fd8a --- /dev/null +++ b/packages/db/prisma/enums.ts @@ -0,0 +1,12 @@ +export const ProjectTier = { + FREE: "FREE", + PRO: "PRO", +} as const; +export type ProjectTier = (typeof ProjectTier)[keyof typeof ProjectTier]; +export const SubscriptionPlan = { + FREE: "FREE", + STANDARD: "STANDARD", + PRO: "PRO", +} as const; +export type SubscriptionPlan = + (typeof SubscriptionPlan)[keyof typeof SubscriptionPlan]; diff --git a/packages/db/prisma/schema.prisma b/packages/db/prisma/schema.prisma new file mode 100644 index 000000000..637e55dc9 --- /dev/null +++ b/packages/db/prisma/schema.prisma @@ -0,0 +1,83 @@ +generator kysely { + provider = "prisma-kysely" + output = "." + enumFileName = "enums.ts" +} + +datasource db { + provider = "mysql" + url = env("DATABASE_URL") + relationMode = "prisma" +} + +enum ProjectTier { + FREE + PRO +} + +model Project { + id String @id @db.VarChar(30) // prefix_ + nanoid (16) + createdAt DateTime @default(now()) + + // A project is tied to a Clerk User or Organization + organizationId String? @db.VarChar(36) // uuid v4 + userId String? @db.VarChar(36) // uuid v4 + + name String + tier ProjectTier @default(FREE) + url String? + + @@index([organizationId]) + @@index([userId]) +} + +enum SubscriptionPlan { + FREE + STANDARD + PRO +} + +model Customer { + id String @id @db.VarChar(30) // prefix_ + nanoid (16) + stripeId String @unique + subscriptionId String? + clerkUserId String + clerkOrganizationId String? + name String? + plan SubscriptionPlan? + paidUntil DateTime? + endsAt DateTime? + + @@index([clerkUserId]) +} + +model ApiKey { + id String @id @db.VarChar(30) // prefix_ + nanoid (16) + createdAt DateTime @default(now()) + expiresAt DateTime? + lastUsed DateTime? + revokedAt DateTime? + + projectId String @db.VarChar(30) // prefix_ + nanoid (16) + clerkUserId String @db.VarChar(36) // uuid v4 + + name String @default("Secret Key") + key String @unique + + @@index([projectId]) +} + +model Ingestion { + id String @id @db.VarChar(30) // prefix_ + nanoid (16) + createdAt DateTime @default(now()) + + projectId String @db.VarChar(30) // prefix_ + nanoid (16) + apiKeyId String @db.VarChar(30) // prefix_ + nanoid (16) + + schema Json + hash String @db.VarChar(40) // sha1 + parent String? @db.VarChar(40) // sha1 + origin String @db.VarChar(100) + + @@index([projectId]) +} diff --git a/packages/db/prisma/types.ts b/packages/db/prisma/types.ts new file mode 100644 index 000000000..0f5b09f98 --- /dev/null +++ b/packages/db/prisma/types.ts @@ -0,0 +1,57 @@ +import type { ColumnType } from "kysely"; + +import type { ProjectTier, SubscriptionPlan } from "./enums"; + +export type Generated = + T extends ColumnType + ? ColumnType + : ColumnType; +export type Timestamp = ColumnType; + +export type ApiKey = { + id: string; + createdAt: Generated; + expiresAt: Timestamp | null; + lastUsed: Timestamp | null; + revokedAt: Timestamp | null; + projectId: string; + clerkUserId: string; + name: Generated; + key: string; +}; +export type Customer = { + id: string; + stripeId: string; + subscriptionId: string | null; + clerkUserId: string; + clerkOrganizationId: string | null; + name: string | null; + plan: SubscriptionPlan | null; + paidUntil: Timestamp | null; + endsAt: Timestamp | null; +}; +export type Ingestion = { + id: string; + createdAt: Generated; + projectId: string; + apiKeyId: string; + schema: unknown; + hash: string; + parent: string | null; + origin: string; +}; +export type Project = { + id: string; + createdAt: Generated; + organizationId: string | null; + userId: string | null; + name: string; + tier: Generated; + url: string | null; +}; +export type DB = { + ApiKey: ApiKey; + Customer: Customer; + Ingestion: Ingestion; + Project: Project; +}; diff --git a/packages/db/tsconfig.json b/packages/db/tsconfig.json new file mode 100644 index 000000000..d2580ea7f --- /dev/null +++ b/packages/db/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "@acme/tsconfig/base.json", + "compilerOptions": { + "tsBuildInfoFile": "node_modules/.cache/tsbuildinfo.json", + }, + "include": ["*.ts", "prisma", "src"], + "exclude": ["node_modules"], +} diff --git a/packages/stripe/package.json b/packages/stripe/package.json new file mode 100644 index 000000000..30d3c6d65 --- /dev/null +++ b/packages/stripe/package.json @@ -0,0 +1,45 @@ +{ + "name": "@acme/stripe", + "private": true, + "version": "0.1.0", + "exports": { + ".": "./src/index.ts", + "./plans": "./src/plans.ts", + "./env": "./src/env.mjs" + }, + "typesVersions": { + "*": { + "*": [ + "src/*" + ] + } + }, + "license": "MIT", + "scripts": { + "clean": "rm -rf .turbo node_modules", + "dev": "stripe listen --forward-to localhost:3000/api/webhooks/stripe", + "lint": "eslint .", + "format": "prettier --check \"**/*.{mjs,ts,json}\"", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@acme/db": "^0.1.0", + "@clerk/nextjs": "^4.29.4", + "@t3-oss/env-nextjs": "^0.7.3", + "stripe": "^14.13.0" + }, + "devDependencies": { + "@acme/eslint-config": "^0.2.0", + "@acme/prettier-config": "^0.1.0", + "@acme/tsconfig": "^0.1.0", + "eslint": "^8.56.0", + "prettier": "^3.2.4", + "typescript": "^5.3.3" + }, + "eslintConfig": { + "extends": [ + "@acme/eslint-config/base" + ] + }, + "prettier": "@acme/prettier-config" +} diff --git a/packages/stripe/src/env.mjs b/packages/stripe/src/env.mjs new file mode 100644 index 000000000..12ebbd324 --- /dev/null +++ b/packages/stripe/src/env.mjs @@ -0,0 +1,36 @@ +import { createEnv } from "@t3-oss/env-nextjs"; +import * as z from "zod"; + +export const env = createEnv({ + shared: {}, + server: { + NEXTJS_URL: z.preprocess( + (str) => + process.env.VERCEL_URL ? `https://${process.env.VERCEL_URL}` : str, + process.env.VERCEL_URL ? z.string().min(1) : z.string().url(), + ), + + STRIPE_API_KEY: z.string(), + }, + client: { + NEXT_PUBLIC_STRIPE_STD_PRODUCT_ID: z.string(), + NEXT_PUBLIC_STRIPE_STD_MONTHLY_PRICE_ID: z.string(), + NEXT_PUBLIC_STRIPE_PRO_PRODUCT_ID: z.string(), + NEXT_PUBLIC_STRIPE_PRO_MONTHLY_PRICE_ID: z.string(), + }, + // 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: { + NEXT_PUBLIC_STRIPE_STD_PRODUCT_ID: + process.env.NEXT_PUBLIC_STRIPE_STD_PRODUCT_ID, + NEXT_PUBLIC_STRIPE_STD_MONTHLY_PRICE_ID: + process.env.NEXT_PUBLIC_STRIPE_STD_MONTHLY_PRICE_ID, + NEXT_PUBLIC_STRIPE_PRO_PRODUCT_ID: + process.env.NEXT_PUBLIC_STRIPE_PRO_PRODUCT_ID, + NEXT_PUBLIC_STRIPE_PRO_MONTHLY_PRICE_ID: + process.env.NEXT_PUBLIC_STRIPE_PRO_MONTHLY_PRICE_ID, + }, + skipValidation: + !!process.env.SKIP_ENV_VALIDATION || + process.env.npm_lifecycle_event === "lint", +}); diff --git a/packages/stripe/src/index.ts b/packages/stripe/src/index.ts new file mode 100644 index 000000000..3b8dfff38 --- /dev/null +++ b/packages/stripe/src/index.ts @@ -0,0 +1,13 @@ +import { Stripe } from "stripe"; + +import { env } from "./env.mjs"; + +export * from "./plans"; +export * from "./webhooks"; + +export type { Stripe }; + +export const stripe = new Stripe(env.STRIPE_API_KEY, { + apiVersion: "2023-10-16", + typescript: true, +}); diff --git a/packages/stripe/src/plans.ts b/packages/stripe/src/plans.ts new file mode 100644 index 000000000..586662c45 --- /dev/null +++ b/packages/stripe/src/plans.ts @@ -0,0 +1,41 @@ +import { SubscriptionPlan } from "@acme/db"; + +import { env } from "./env.mjs"; + +interface PlanInfo { + key: SubscriptionPlan; + name: string; + description: string; + preFeatures?: string; + features: string[]; + priceId: string; +} + +export const PLANS: Record = { + STANDARD: { + key: SubscriptionPlan.STANDARD, + name: "Standard", + description: "For individuals", + features: ["Invite up to 1 team member", "Lorem ipsum dolor sit amet"], + priceId: env.NEXT_PUBLIC_STRIPE_STD_MONTHLY_PRICE_ID, + }, + PRO: { + key: SubscriptionPlan.PRO, + name: "Pro", + description: "For teams", + preFeatures: "Everything in standard, plus", + features: ["Invite up to 5 team members", "Unlimited projects"], + priceId: env.NEXT_PUBLIC_STRIPE_PRO_MONTHLY_PRICE_ID, + }, + FREE: { + key: SubscriptionPlan.FREE, + name: "Free", + description: "For individuals", + features: ["Invite up to 1 team member", "Lorem ipsum dolor sit amet"], + priceId: "no-id-necessary", + }, +}; + +export function stripePriceToSubscriptionPlan(priceId: string | undefined) { + return Object.values(PLANS).find((plan) => plan.priceId === priceId); +} diff --git a/packages/stripe/src/webhooks.ts b/packages/stripe/src/webhooks.ts new file mode 100644 index 000000000..631b9297d --- /dev/null +++ b/packages/stripe/src/webhooks.ts @@ -0,0 +1,153 @@ +import { clerkClient } from "@clerk/nextjs"; +import type Stripe from "stripe"; + +import { db, genId } from "@acme/db"; + +import { stripe } from "."; +import { stripePriceToSubscriptionPlan } from "./plans"; + +export async function handleEvent(event: Stripe.Event) { + switch (event.type) { + case "checkout.session.completed": { + const session = event.data.object; + if (typeof session.subscription !== "string") { + throw new Error("Missing or invalid subscription id"); + } + const subscription = await stripe.subscriptions.retrieve( + session.subscription, + ); + + const customerId = + typeof subscription.customer === "string" + ? subscription.customer + : subscription.customer.id; + const { userId, organizationName } = subscription.metadata; + + if (!userId) { + throw new Error("Missing user id"); + } + + const customer = await db + .selectFrom("Customer") + .select("id") + .where("stripeId", "=", customerId) + .executeTakeFirst(); + + const subscriptionPlan = stripePriceToSubscriptionPlan( + subscription.items.data[0]?.price.id, + ); + + /** + * User is already subscribed, update their info + */ + if (customer) { + return await db + .updateTable("Customer") + .where("id", "=", customer.id) + .set({ + stripeId: customerId, + subscriptionId: subscription.id, + paidUntil: new Date(subscription.current_period_end * 1000), + plan: subscriptionPlan?.key, + }) + .execute(); + } + + /** + * User is not subscribed, create a new customer and org + */ + const organization = await clerkClient.organizations.createOrganization({ + createdBy: userId, + name: organizationName!, + }); + + // TODO: SET ACTIVE ORG WHEN CLERK CAN BOTHER TO LET ME DO TAHT SERVERSIDE!!! + + await db + .insertInto("Customer") + .values({ + id: genId(), + clerkUserId: userId, + clerkOrganizationId: organization.id, + stripeId: customerId, + subscriptionId: subscription.id, + plan: subscriptionPlan?.key, + paidUntil: new Date(subscription.current_period_end * 1000), + endsAt: new Date(subscription.current_period_end * 1000), + }) + .execute(); + break; + } + case "invoice.payment_succeeded": { + const invoice = event.data.object; + if (typeof invoice.subscription !== "string") { + throw new Error("Missing or invalid subscription id"); + } + const subscription = await stripe.subscriptions.retrieve( + invoice.subscription, + ); + + const subscriptionPlan = stripePriceToSubscriptionPlan( + subscription.items.data[0]?.price.id, + ); + + await db + .updateTable("Customer") + .where("subscriptionId", "=", subscription.id) + .set({ + plan: subscriptionPlan?.key, + paidUntil: new Date(subscription.current_period_end * 1000), + }) + .execute(); + break; + } + case "invoice.payment_failed": { + // TODO: Handle failed payments + break; + } + case "customer.subscription.deleted": { + const subscription = event.data.object; + const customerId = + typeof subscription.customer === "string" + ? subscription.customer + : subscription.customer.id; + + await db + .updateTable("Customer") + .where("stripeId", "=", customerId) + .set({ + subscriptionId: null, + plan: "FREE", + paidUntil: null, + }) + .execute(); + break; + } + case "customer.subscription.updated": { + const subscription = event.data.object; + const customerId = + typeof subscription.customer === "string" + ? subscription.customer + : subscription.customer.id; + + const subscriptionPlan = stripePriceToSubscriptionPlan( + subscription.items.data[0]?.price.id, + ); + + await db + .updateTable("Customer") + .where("stripeId", "=", customerId) + .set({ + plan: subscriptionPlan?.key, + paidUntil: new Date(subscription.current_period_end * 1000), + }) + .execute(); + break; + } + default: { + console.log("🆗 Stripe Webhook Unhandled Event Type: ", event.type); + return; + } + } + console.log("✅ Stripe Webhook Processed"); +} diff --git a/packages/stripe/tsconfig.json b/packages/stripe/tsconfig.json new file mode 100644 index 000000000..ab8c374a5 --- /dev/null +++ b/packages/stripe/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "@acme/tsconfig/base.json", + "compilerOptions": { + "tsBuildInfoFile": "node_modules/.cache/tsbuildinfo.json", + }, + "include": ["*.ts", "src"], + "exclude": ["node_modules"], +} diff --git a/packages/ui/package.json b/packages/ui/package.json new file mode 100644 index 000000000..649a7bc82 --- /dev/null +++ b/packages/ui/package.json @@ -0,0 +1,97 @@ +{ + "name": "@acme/ui", + "private": true, + "version": "0.1.0", + "scripts": { + "clean": "git clean -xdf .turbo node_modules", + "format": "prettier --check \"**/*.{ts,tsx}\"", + "lint": "eslint .", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@radix-ui/react-avatar": "^1.0.4", + "@radix-ui/react-checkbox": "^1.0.4", + "@radix-ui/react-dialog": "^1.0.5", + "@radix-ui/react-dropdown-menu": "^2.0.6", + "@radix-ui/react-label": "^2.0.2", + "@radix-ui/react-popover": "^1.0.7", + "@radix-ui/react-scroll-area": "^1.0.5", + "@radix-ui/react-select": "^2.0.0", + "@radix-ui/react-slot": "^1.0.2", + "@radix-ui/react-tabs": "^1.0.4", + "@radix-ui/react-toast": "^1.1.5", + "class-variance-authority": "^0.7.0", + "clsx": "^2.1.0", + "cmdk": "^0.2.0", + "lucide-react": "0.307.0", + "tailwind-merge": "^2.2.0", + "zod": "^3.22.4" + }, + "peerDependencies": { + "@tanstack/react-table": "^8.10.7", + "react": "^18.2.0", + "react-day-picker": "^8.10.0", + "react-dom": "^18.2.0", + "react-hook-form": "^7.45.4", + "tailwindcss": "3.4.1", + "tailwindcss-animate": "^1.0.7" + }, + "devDependencies": { + "@acme/eslint-config": "0.2.0", + "@acme/prettier-config": "0.1.0", + "@acme/tailwind-config": "0.1.0", + "@acme/tsconfig": "0.1.0", + "@tanstack/react-table": "^8.11.3", + "@types/react": "^18.2.48", + "@types/react-dom": "^18.2.18", + "date-fns": "^3.2.0", + "eslint": "^8.56.0", + "prettier": "^3.2.4", + "react": "18.2.0", + "react-day-picker": "^8.10.0", + "react-dom": "18.2.0", + "react-hook-form": "^7.49.2", + "tailwindcss": "3.4.1", + "tailwindcss-animate": "^1.0.7", + "typescript": "^5.3.3" + }, + "eslintConfig": { + "root": true, + "extends": [ + "@acme/eslint-config/base", + "@acme/eslint-config/react" + ] + }, + "prettier": "@acme/prettier-config", + "exports": { + ".": "./src/index.ts", + "./avatar": "./src/avatar.tsx", + "./button": "./src/button.tsx", + "./calendar": "./src/calendar.tsx", + "./card": "./src/card.tsx", + "./checkbox": "./src/checkbox.tsx", + "./command": "./src/command.tsx", + "./data-table": "./src/data-table.tsx", + "./dialog": "./src/dialog.tsx", + "./dropdown-menu": "./src/dropdown-menu.tsx", + "./form": "./src/form.tsx", + "./icons": "./src/icons.tsx", + "./input": "./src/input.tsx", + "./label": "./src/label.tsx", + "./popover": "./src/popover.tsx", + "./scroll-area": "./src/scroll-area.tsx", + "./select": "./src/select.tsx", + "./sheet": "./src/sheet.tsx", + "./table": "./src/table.tsx", + "./tabs": "./src/tabs.tsx", + "./toaster": "./src/toaster.tsx", + "./use-toast": "./src/use-toast.tsx" + }, + "typesVersions": { + "*": { + "*": [ + "src/*" + ] + } + } +} diff --git a/packages/ui/src/avatar.tsx b/packages/ui/src/avatar.tsx new file mode 100644 index 000000000..54d931124 --- /dev/null +++ b/packages/ui/src/avatar.tsx @@ -0,0 +1,50 @@ +"use client"; + +import * as React from "react"; +import * as AvatarPrimitive from "@radix-ui/react-avatar"; + +import { cn } from "./utils/cn"; + +const Avatar = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +Avatar.displayName = AvatarPrimitive.Root.displayName; + +const AvatarImage = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +AvatarImage.displayName = AvatarPrimitive.Image.displayName; + +const AvatarFallback = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName; + +export { Avatar, AvatarImage, AvatarFallback }; diff --git a/packages/ui/src/button.tsx b/packages/ui/src/button.tsx new file mode 100644 index 000000000..2b2aafab9 --- /dev/null +++ b/packages/ui/src/button.tsx @@ -0,0 +1,57 @@ +import * as React from "react"; +import { Slot } from "@radix-ui/react-slot"; +import { cva } from "class-variance-authority"; +import type { VariantProps } from "class-variance-authority"; + +import { cn } from "./utils/cn"; + +const buttonVariants = cva( + "inline-flex items-center justify-center rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50", + { + variants: { + variant: { + default: "bg-primary text-primary-foreground hover:bg-primary/90", + destructive: + "bg-destructive text-destructive-foreground hover:bg-destructive/90", + outline: + "border border-input bg-background hover:bg-accent hover:text-accent-foreground", + secondary: + "bg-secondary text-secondary-foreground hover:bg-secondary/80", + ghost: "hover:bg-accent hover:text-accent-foreground", + link: "text-primary underline-offset-4 hover:underline", + }, + size: { + default: "h-10 px-4 py-2", + sm: "h-9 rounded-md px-3", + lg: "h-11 rounded-md px-8", + icon: "h-10 w-10", + }, + }, + defaultVariants: { + variant: "default", + size: "default", + }, + }, +); + +export interface ButtonProps + extends React.ButtonHTMLAttributes, + VariantProps { + asChild?: boolean; +} + +const Button = React.forwardRef( + ({ className, variant, size, asChild = false, ...props }, ref) => { + const Comp = asChild ? Slot : "button"; + return ( + + ); + }, +); +Button.displayName = "Button"; + +export { Button, buttonVariants }; diff --git a/packages/ui/src/calendar.tsx b/packages/ui/src/calendar.tsx new file mode 100644 index 000000000..ab62c7a6a --- /dev/null +++ b/packages/ui/src/calendar.tsx @@ -0,0 +1,69 @@ +"use client"; + +import * as React from "react"; +import { ChevronLeft, ChevronRight } from "lucide-react"; +import { DayPicker } from "react-day-picker"; + +import { buttonVariants } from "./button"; +import { cn } from "./utils/cn"; + +export type { DateRange } from "react-day-picker"; +export type CalendarProps = React.ComponentProps; + +function Calendar({ + className, + classNames, + showOutsideDays = true, + ...props +}: CalendarProps) { + return ( + ( + + ), + IconRight: ({ ...props }) => ( + + ), + }} + {...props} + /> + ); +} +Calendar.displayName = "Calendar"; + +export { Calendar }; diff --git a/packages/ui/src/card.tsx b/packages/ui/src/card.tsx new file mode 100644 index 000000000..ea3c0bb9a --- /dev/null +++ b/packages/ui/src/card.tsx @@ -0,0 +1,88 @@ +import * as React from "react"; + +import { cn } from "./utils/cn"; + +const Card = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
    +)); +Card.displayName = "Card"; + +const CardHeader = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
    +)); +CardHeader.displayName = "CardHeader"; + +const CardTitle = React.forwardRef< + HTMLParagraphElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +

    + {props.children} +

    +)); +CardTitle.displayName = "CardTitle"; + +const CardDescription = React.forwardRef< + HTMLParagraphElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +

    +)); +CardDescription.displayName = "CardDescription"; + +const CardContent = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +

    +)); +CardContent.displayName = "CardContent"; + +const CardFooter = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
    +)); +CardFooter.displayName = "CardFooter"; + +export { + Card, + CardHeader, + CardFooter, + CardTitle, + CardDescription, + CardContent, +}; diff --git a/packages/ui/src/checkbox.tsx b/packages/ui/src/checkbox.tsx new file mode 100644 index 000000000..3c5f5b9a6 --- /dev/null +++ b/packages/ui/src/checkbox.tsx @@ -0,0 +1,30 @@ +"use client"; + +import * as React from "react"; +import * as CheckboxPrimitive from "@radix-ui/react-checkbox"; +import { Check } from "lucide-react"; + +import { cn } from "./utils/cn"; + +const Checkbox = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + + + + +)); +Checkbox.displayName = CheckboxPrimitive.Root.displayName; + +export { Checkbox }; diff --git a/packages/ui/src/command.tsx b/packages/ui/src/command.tsx new file mode 100644 index 000000000..d49e1ac24 --- /dev/null +++ b/packages/ui/src/command.tsx @@ -0,0 +1,156 @@ +"use client"; + +import * as React from "react"; +import type { DialogProps } from "@radix-ui/react-dialog"; +import { Command as CommandPrimitive } from "cmdk"; +import { Search } from "lucide-react"; + +import { Dialog, DialogContent } from "./dialog"; +import { cn } from "./utils/cn"; + +const Command = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +Command.displayName = CommandPrimitive.displayName; + +type CommandDialogProps = DialogProps; + +const CommandDialog = ({ children, ...props }: CommandDialogProps) => { + return ( + + + + {children} + + + + ); +}; + +const CommandInput = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + // eslint-disable-next-line react/no-unknown-property +
    + + +
    +)); + +CommandInput.displayName = CommandPrimitive.Input.displayName; + +const CommandList = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); + +CommandList.displayName = CommandPrimitive.List.displayName; + +const CommandEmpty = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>((props, ref) => ( + +)); + +CommandEmpty.displayName = CommandPrimitive.Empty.displayName; + +const CommandGroup = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); + +CommandGroup.displayName = CommandPrimitive.Group.displayName; + +const CommandSeparator = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +CommandSeparator.displayName = CommandPrimitive.Separator.displayName; + +const CommandItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); + +CommandItem.displayName = CommandPrimitive.Item.displayName; + +const CommandShortcut = ({ + className, + ...props +}: React.HTMLAttributes) => { + return ( + + ); +}; +CommandShortcut.displayName = "CommandShortcut"; + +export { + Command, + CommandDialog, + CommandInput, + CommandList, + CommandEmpty, + CommandGroup, + CommandItem, + CommandShortcut, + CommandSeparator, +}; diff --git a/packages/ui/src/data-table.tsx b/packages/ui/src/data-table.tsx new file mode 100644 index 000000000..280072307 --- /dev/null +++ b/packages/ui/src/data-table.tsx @@ -0,0 +1,80 @@ +"use client"; + +import type { ColumnDef } from "@tanstack/react-table"; +import { + flexRender, + getCoreRowModel, + useReactTable, +} from "@tanstack/react-table"; + +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "./table"; + +interface DataTableProps { + columns: ColumnDef[]; + data: TData[]; +} + +export function DataTable({ + columns, + data, +}: DataTableProps) { + const table = useReactTable({ + data, + columns, + getCoreRowModel: getCoreRowModel(), + }); + + return ( +
    + + + {table.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((header) => { + return ( + + {header.isPlaceholder + ? null + : flexRender( + header.column.columnDef.header, + header.getContext(), + )} + + ); + })} + + ))} + + + {table.getRowModel().rows?.length ? ( + table.getRowModel().rows.map((row) => ( + + {row.getVisibleCells().map((cell) => ( + + {flexRender(cell.column.columnDef.cell, cell.getContext())} + + ))} + + )) + ) : ( + + + No results. + + + )} + +
    +
    + ); +} diff --git a/packages/ui/src/dialog.tsx b/packages/ui/src/dialog.tsx new file mode 100644 index 000000000..ecbcc9456 --- /dev/null +++ b/packages/ui/src/dialog.tsx @@ -0,0 +1,122 @@ +"use client"; + +import * as React from "react"; +import * as DialogPrimitive from "@radix-ui/react-dialog"; +import { X } from "lucide-react"; + +import { cn } from "./utils/cn"; + +const Dialog = DialogPrimitive.Root; + +const DialogTrigger = DialogPrimitive.Trigger; + +const DialogPortal = DialogPrimitive.Portal; + +const DialogClose = DialogPrimitive.Close; + +const DialogOverlay = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +DialogOverlay.displayName = DialogPrimitive.Overlay.displayName; + +const DialogContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + + + {children} + + + Close + + + +)); +DialogContent.displayName = DialogPrimitive.Content.displayName; + +const DialogHeader = ({ + className, + ...props +}: React.HTMLAttributes) => ( +
    +); +DialogHeader.displayName = "DialogHeader"; + +const DialogFooter = ({ + className, + ...props +}: React.HTMLAttributes) => ( +
    +); +DialogFooter.displayName = "DialogFooter"; + +const DialogTitle = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +DialogTitle.displayName = DialogPrimitive.Title.displayName; + +const DialogDescription = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +DialogDescription.displayName = DialogPrimitive.Description.displayName; + +export { + Dialog, + DialogPortal, + DialogOverlay, + DialogClose, + DialogTrigger, + DialogContent, + DialogHeader, + DialogFooter, + DialogTitle, + DialogDescription, +}; diff --git a/packages/ui/src/dropdown-menu.tsx b/packages/ui/src/dropdown-menu.tsx new file mode 100644 index 000000000..2dc7c086a --- /dev/null +++ b/packages/ui/src/dropdown-menu.tsx @@ -0,0 +1,200 @@ +"use client"; + +import * as React from "react"; +import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"; +import { Check, ChevronRight, Circle } from "lucide-react"; + +import { cn } from "./utils/cn"; + +const DropdownMenu = DropdownMenuPrimitive.Root; + +const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger; + +const DropdownMenuGroup = DropdownMenuPrimitive.Group; + +const DropdownMenuPortal = DropdownMenuPrimitive.Portal; + +const DropdownMenuSub = DropdownMenuPrimitive.Sub; + +const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup; + +const DropdownMenuSubTrigger = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & { + inset?: boolean; + } +>(({ className, inset, children, ...props }, ref) => ( + + {children} + + +)); +DropdownMenuSubTrigger.displayName = + DropdownMenuPrimitive.SubTrigger.displayName; + +const DropdownMenuSubContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +DropdownMenuSubContent.displayName = + DropdownMenuPrimitive.SubContent.displayName; + +const DropdownMenuContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, sideOffset = 4, ...props }, ref) => ( + + + +)); +DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName; + +const DropdownMenuItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & { + inset?: boolean; + } +>(({ className, inset, ...props }, ref) => ( + +)); +DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName; + +const DropdownMenuCheckboxItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, checked, ...props }, ref) => ( + + + + + + + {children} + +)); +DropdownMenuCheckboxItem.displayName = + DropdownMenuPrimitive.CheckboxItem.displayName; + +const DropdownMenuRadioItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + + + + + + {children} + +)); +DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName; + +const DropdownMenuLabel = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & { + inset?: boolean; + } +>(({ className, inset, ...props }, ref) => ( + +)); +DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName; + +const DropdownMenuSeparator = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName; + +const DropdownMenuShortcut = ({ + className, + ...props +}: React.HTMLAttributes) => { + return ( + + ); +}; +DropdownMenuShortcut.displayName = "DropdownMenuShortcut"; + +export { + DropdownMenu, + DropdownMenuTrigger, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuCheckboxItem, + DropdownMenuRadioItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuShortcut, + DropdownMenuGroup, + DropdownMenuPortal, + DropdownMenuSub, + DropdownMenuSubContent, + DropdownMenuSubTrigger, + DropdownMenuRadioGroup, +}; diff --git a/packages/ui/src/form.tsx b/packages/ui/src/form.tsx new file mode 100644 index 000000000..99e3c179f --- /dev/null +++ b/packages/ui/src/form.tsx @@ -0,0 +1,173 @@ +"use client"; + +import * as React from "react"; +import type * as LabelPrimitive from "@radix-ui/react-label"; +import { Slot } from "@radix-ui/react-slot"; +import type { ControllerProps, FieldPath, FieldValues } from "react-hook-form"; +import { Controller, FormProvider, useFormContext } from "react-hook-form"; + +import { Label } from "./label"; +import { cn } from "./utils/cn"; + +const Form = FormProvider; + +interface FormFieldContextValue< + TFieldValues extends FieldValues = FieldValues, + TName extends FieldPath = FieldPath, +> { + name: TName; +} + +const FormFieldContext = React.createContext( + {} as FormFieldContextValue, +); + +const FormField = < + TFieldValues extends FieldValues = FieldValues, + TName extends FieldPath = FieldPath, +>({ + ...props +}: ControllerProps) => { + return ( + + + + ); +}; + +const useFormField = () => { + const fieldContext = React.useContext(FormFieldContext); + const itemContext = React.useContext(FormItemContext); + const { getFieldState, formState } = useFormContext(); + + const fieldState = getFieldState(fieldContext.name, formState); + + if (!fieldContext) { + throw new Error("useFormField should be used within "); + } + + const { id } = itemContext; + + return { + id, + name: fieldContext.name, + formItemId: `${id}-form-item`, + formDescriptionId: `${id}-form-item-description`, + formMessageId: `${id}-form-item-message`, + ...fieldState, + }; +}; + +interface FormItemContextValue { + id: string; +} + +const FormItemContext = React.createContext( + {} as FormItemContextValue, +); + +const FormItem = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => { + const id = React.useId(); + + return ( + +
    + + ); +}); +FormItem.displayName = "FormItem"; + +const FormLabel = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => { + const { error, formItemId } = useFormField(); + + return ( +