From e70d4d34c2e46f2ca7d3f8acb1747ed0eb6a0f6f Mon Sep 17 00:00:00 2001 From: afiqzudinhadi Date: Mon, 23 Jun 2025 14:36:26 +0800 Subject: [PATCH 01/30] feat: add clerk-helpers package --- packages/auth/helpers.ts | 3 +++ packages/auth/index.ts | 2 ++ packages/auth/package.json | 12 ++++++++++++ packages/auth/roles.ts | 5 +++++ packages/auth/tsconfig.json | 14 ++++++++++++++ 5 files changed, 36 insertions(+) create mode 100644 packages/auth/helpers.ts create mode 100644 packages/auth/index.ts create mode 100644 packages/auth/package.json create mode 100644 packages/auth/roles.ts create mode 100644 packages/auth/tsconfig.json diff --git a/packages/auth/helpers.ts b/packages/auth/helpers.ts new file mode 100644 index 0000000..625ec6b --- /dev/null +++ b/packages/auth/helpers.ts @@ -0,0 +1,3 @@ +export function hasRole(userRoles: string[], role: string): boolean { + return userRoles.includes(role); +} diff --git a/packages/auth/index.ts b/packages/auth/index.ts new file mode 100644 index 0000000..836ce67 --- /dev/null +++ b/packages/auth/index.ts @@ -0,0 +1,2 @@ +export * from "./roles"; +export * from "./helpers"; diff --git a/packages/auth/package.json b/packages/auth/package.json new file mode 100644 index 0000000..77eabf1 --- /dev/null +++ b/packages/auth/package.json @@ -0,0 +1,12 @@ +{ + "name": "@repo/auth", + "version": "0.0.0", + "author": "Afiq Zudin Hadi", + "private": true, + "license": "MIT", + "main": "index.ts", + "type": "module", + "exports": { + "./indext.ts": "./index.ts" + } +} diff --git a/packages/auth/roles.ts b/packages/auth/roles.ts new file mode 100644 index 0000000..04c1a9d --- /dev/null +++ b/packages/auth/roles.ts @@ -0,0 +1,5 @@ +export const ROLES = Object.freeze({ + SUPER_ADMIN: "super_admin", + ADMIN: "admin", + USER: "user", +}); diff --git a/packages/auth/tsconfig.json b/packages/auth/tsconfig.json new file mode 100644 index 0000000..b745739 --- /dev/null +++ b/packages/auth/tsconfig.json @@ -0,0 +1,14 @@ +{ + "extends": "@repo/typescript-config/base.json", + "compilerOptions": { + "lib": ["dom", "ES2015"], + "sourceMap": true, + "types": ["jest", "node"], + "baseUrl": ".", + "paths": { + "@repo/auth/index.ts": ["./index.ts"] + } + }, + "include": [".", "src"], + "exclude": ["dist", "build", "node_modules"] +} From da8aaa1db6489fa4f0696ba48cfadb67362f4d6e Mon Sep 17 00:00:00 2001 From: afiqzudinhadi Date: Mon, 23 Jun 2025 14:36:45 +0800 Subject: [PATCH 02/30] feat: add clerk integration to remix-template --- apps/remix-template/app/root.tsx | 10 +- apps/remix-template/app/routes/clerk.tsx | 40 ++++ apps/remix-template/package.json | 2 + package-lock.json | 249 +++++++++++++++++++++++ 4 files changed, 300 insertions(+), 1 deletion(-) create mode 100644 apps/remix-template/app/routes/clerk.tsx diff --git a/apps/remix-template/app/root.tsx b/apps/remix-template/app/root.tsx index 5fc8e46..b6d7be6 100644 --- a/apps/remix-template/app/root.tsx +++ b/apps/remix-template/app/root.tsx @@ -6,9 +6,15 @@ import { ScrollRestoration, } from "@remix-run/react"; import { Analytics } from "@vercel/analytics/react"; + import "@repo/ui/styles/styles.css"; import "@repo/ui/styles/global.css"; +import type { LoaderFunction } from "@remix-run/node"; +import { rootAuthLoader } from "@clerk/remix/ssr.server"; +import { ClerkApp } from "@clerk/remix"; +export const loader: LoaderFunction = (args) => rootAuthLoader(args); + export function Layout({ children }: { children: React.ReactNode }) { return ( @@ -31,6 +37,8 @@ export function Layout({ children }: { children: React.ReactNode }) { ); } -export default function App() { +export function App() { return ; } + +export default ClerkApp(App); diff --git a/apps/remix-template/app/routes/clerk.tsx b/apps/remix-template/app/routes/clerk.tsx new file mode 100644 index 0000000..8c1c646 --- /dev/null +++ b/apps/remix-template/app/routes/clerk.tsx @@ -0,0 +1,40 @@ +import { SignInButton, UserButton, SignedIn, SignedOut } from "@clerk/remix"; +import { + Card, + CardContent, + CardHeader, + CardTitle, +} from "@repo/ui/components/card"; +import { Button } from "@repo/ui/components/button"; + +export default function Index() { + return ( +
+ + + Welcome + + + +
+

+ You are signed in! +

+ +
+
+ +
+

+ You are signed out +

+ + + +
+
+
+
+
+ ); +} diff --git a/apps/remix-template/package.json b/apps/remix-template/package.json index ff13669..c1f947a 100644 --- a/apps/remix-template/package.json +++ b/apps/remix-template/package.json @@ -11,10 +11,12 @@ "lint": "eslint app/ --max-warnings 0" }, "dependencies": { + "@clerk/remix": "^4.8.1", "@radix-ui/react-slot": "^1.2.3", "@remix-run/node": "^2.15.2", "@remix-run/react": "^2.15.2", "@remix-run/server-runtime": "^2.15.2", + "@repo/auth": "*", "@repo/ui": "*", "@vercel/analytics": "^1.5.0", "@vercel/remix": "2.16.6", diff --git a/package-lock.json b/package-lock.json index cf44244..783852b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -166,10 +166,12 @@ "apps/remix-template": { "version": "0.0.0", "dependencies": { + "@clerk/remix": "^4.8.1", "@radix-ui/react-slot": "^1.2.3", "@remix-run/node": "^2.15.2", "@remix-run/react": "^2.15.2", "@remix-run/server-runtime": "^2.15.2", + "@repo/auth": "*", "@repo/ui": "*", "@vercel/analytics": "^1.5.0", "@vercel/remix": "2.16.6", @@ -1034,6 +1036,124 @@ "integrity": "sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==", "license": "MIT" }, + "node_modules/@clerk/backend": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@clerk/backend/-/backend-2.1.0.tgz", + "integrity": "sha512-Ltb3IzdGeahM1Jr+u0UAVnVjEIYXUsq9/mTb1adolsOc9oR2LCKHE7rTRnmkwY8y+IwCzXVuywIYW+zLTk47+Q==", + "license": "MIT", + "dependencies": { + "@clerk/shared": "^3.9.7", + "@clerk/types": "^4.60.1", + "cookie": "1.0.2", + "snakecase-keys": "8.0.1", + "tslib": "2.8.1" + }, + "engines": { + "node": ">=18.17.0" + } + }, + "node_modules/@clerk/backend/node_modules/cookie": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.0.2.tgz", + "integrity": "sha512-9Kr/j4O16ISv8zBBhJoi4bXOYNTkFLOqSL3UDB0njXxCXNezjeyVrJyGOWtgfs/q2km1gwBcfH8q1yEGoMYunA==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@clerk/clerk-react": { + "version": "5.32.0", + "resolved": "https://registry.npmjs.org/@clerk/clerk-react/-/clerk-react-5.32.0.tgz", + "integrity": "sha512-RdipOVBdbUbFaDb8kF3EzhJw0eh5LPxSWWRTu4k+pOdRB1gzETiToLchj5nDWFkl8qW/uL4gQbZH+Pfc7izO0A==", + "license": "MIT", + "dependencies": { + "@clerk/shared": "^3.9.7", + "@clerk/types": "^4.60.1", + "tslib": "2.8.1" + }, + "engines": { + "node": ">=18.17.0" + }, + "peerDependencies": { + "react": "^18.0.0 || ^19.0.0 || ^19.0.0-0", + "react-dom": "^18.0.0 || ^19.0.0 || ^19.0.0-0" + } + }, + "node_modules/@clerk/remix": { + "version": "4.8.1", + "resolved": "https://registry.npmjs.org/@clerk/remix/-/remix-4.8.1.tgz", + "integrity": "sha512-QOK9tuijOrAzRDo8Cy+d4PqQfVChXr93+RyozI1gLlCOwTVtJHhlxUhWeR3hg+w+QRmuilSgv99odGwaEre4WQ==", + "license": "MIT", + "dependencies": { + "@clerk/backend": "^2.1.0", + "@clerk/clerk-react": "^5.32.0", + "@clerk/shared": "^3.9.7", + "@clerk/types": "^4.60.1", + "cookie": "0.7.2", + "tslib": "2.8.1" + }, + "engines": { + "node": ">=18.17.0" + }, + "peerDependencies": { + "@remix-run/react": "^2.0.0", + "@remix-run/server-runtime": "^2.0.0", + "react": "^18.0.0 || ^19.0.0 || ^19.0.0-0", + "react-dom": "^18.0.0 || ^19.0.0 || ^19.0.0-0", + "react-router": "^6.0.0" + } + }, + "node_modules/@clerk/remix/node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/@clerk/shared": { + "version": "3.9.7", + "resolved": "https://registry.npmjs.org/@clerk/shared/-/shared-3.9.7.tgz", + "integrity": "sha512-D3iuzuDoadK6BKb7qFZEH332z4Sl5mxLuHMf8rKdIMHuOu1RQK0RqFJtKPcFvDxq0RvJ9MC+rK24P2Sa2tOoNA==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "@clerk/types": "^4.60.1", + "dequal": "2.0.3", + "glob-to-regexp": "0.4.1", + "js-cookie": "3.0.5", + "std-env": "^3.9.0", + "swr": "^2.3.3" + }, + "engines": { + "node": ">=18.17.0" + }, + "peerDependencies": { + "react": "^18.0.0 || ^19.0.0 || ^19.0.0-0", + "react-dom": "^18.0.0 || ^19.0.0 || ^19.0.0-0" + }, + "peerDependenciesMeta": { + "react": { + "optional": true + }, + "react-dom": { + "optional": true + } + } + }, + "node_modules/@clerk/types": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@clerk/types/-/types-4.60.1.tgz", + "integrity": "sha512-Lbt0/Q9W9BJtGz1IITdmeqal7y1zp9EaxOEMvsTEDhOxKf/0F/M1d0gJmHEU0puQzYPp9zJsIWC2yJjbgy3Y3Q==", + "license": "MIT", + "dependencies": { + "csstype": "3.1.3" + }, + "engines": { + "node": ">=18.17.0" + } + }, "node_modules/@cspotcode/source-map-support": { "version": "0.8.1", "resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz", @@ -3525,6 +3645,10 @@ "web-streams-polyfill": "^3.1.1" } }, + "node_modules/@repo/auth": { + "resolved": "packages/auth", + "link": true + }, "node_modules/@repo/eslint-config": { "resolved": "packages/config-eslint", "link": true @@ -7663,6 +7787,16 @@ "node": ">=12" } }, + "node_modules/dot-case": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/dot-case/-/dot-case-3.0.4.tgz", + "integrity": "sha512-Kv5nKlh6yRrdrGvxeJ2e5y2eRUpkUosIW4A2AS38zwSz27zu7ufDwQPi5Jhs3XAlGNetl3bmnGhQsMtkKJnj3w==", + "license": "MIT", + "dependencies": { + "no-case": "^3.0.4", + "tslib": "^2.0.3" + } + }, "node_modules/dotenv": { "version": "16.5.0", "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.5.0.tgz", @@ -9409,6 +9543,12 @@ "node": ">= 6" } }, + "node_modules/glob-to-regexp": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/glob-to-regexp/-/glob-to-regexp-0.4.1.tgz", + "integrity": "sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw==", + "license": "BSD-2-Clause" + }, "node_modules/globals": { "version": "16.2.0", "resolved": "https://registry.npmjs.org/globals/-/globals-16.2.0.tgz", @@ -11333,6 +11473,15 @@ "node": ">=10" } }, + "node_modules/js-cookie": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/js-cookie/-/js-cookie-3.0.5.tgz", + "integrity": "sha512-cEiJEAEoIbWfCZYKWhVwFuvPX1gETRYPw6LlaTKoxD3s2AkXzkCjnp6h0V77ozyqj0jakteJ4YqDJT830+lVGw==", + "license": "MIT", + "engines": { + "node": ">=14" + } + }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -11936,6 +12085,15 @@ "loose-envify": "cli.js" } }, + "node_modules/lower-case": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/lower-case/-/lower-case-2.0.2.tgz", + "integrity": "sha512-7fm3l3NAF9WfN6W3JOmf5drwpVqX78JtoGJ3A6W0a6ZnldM41w2fV5D490psKFTpMds8TJse/eHLFFsNHHjHgg==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.3" + } + }, "node_modules/lru-cache": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", @@ -11994,6 +12152,18 @@ "tmpl": "1.0.5" } }, + "node_modules/map-obj": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/map-obj/-/map-obj-4.3.0.tgz", + "integrity": "sha512-hdN1wVrZbb29eBGiGjJbeP8JbKjq1urkHJ/LIP/NY48MZ1QVXUsQBV1G1zvYFHn1XE06cwjBsOI2K3Ulnj1YXQ==", + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/markdown-extensions": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/markdown-extensions/-/markdown-extensions-1.1.1.tgz", @@ -13363,6 +13533,16 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/no-case": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/no-case/-/no-case-3.0.4.tgz", + "integrity": "sha512-fgAN3jGAh+RoxUGZHTSOLJIqUc2wmoBwGR4tbpNAKmmovFoWq0OdRkb0VkldReO2a2iBT/OEulG9XSUc10r3zg==", + "license": "MIT", + "dependencies": { + "lower-case": "^2.0.2", + "tslib": "^2.0.3" + } + }, "node_modules/node-int64": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz", @@ -15509,6 +15689,42 @@ "node": ">=8" } }, + "node_modules/snake-case": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/snake-case/-/snake-case-3.0.4.tgz", + "integrity": "sha512-LAOh4z89bGQvl9pFfNF8V146i7o7/CqFPbqzYgP+yYzDIDeS9HaNFtXABamRW+AQzEVODcvE79ljJ+8a9YSdMg==", + "license": "MIT", + "dependencies": { + "dot-case": "^3.0.4", + "tslib": "^2.0.3" + } + }, + "node_modules/snakecase-keys": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/snakecase-keys/-/snakecase-keys-8.0.1.tgz", + "integrity": "sha512-Sj51kE1zC7zh6TDlNNz0/Jn1n5HiHdoQErxO8jLtnyrkJW/M5PrI7x05uDgY3BO7OUQYKCvmeMurW6BPUdwEOw==", + "license": "MIT", + "dependencies": { + "map-obj": "^4.1.0", + "snake-case": "^3.0.4", + "type-fest": "^4.15.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/snakecase-keys/node_modules/type-fest": { + "version": "4.41.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.41.0.tgz", + "integrity": "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==", + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/source-map": { "version": "0.7.4", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.4.tgz", @@ -15636,6 +15852,12 @@ "node": ">= 0.8" } }, + "node_modules/std-env": { + "version": "3.9.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.9.0.tgz", + "integrity": "sha512-UGvjygr6F6tpH7o2qyqR6QYpwraIjKSdtzyBdyytFOHmPZY917kwdwLG0RbOjWOnKmnm3PeHjaoLLMie7kPLQw==", + "license": "MIT" + }, "node_modules/stdin-discarder": { "version": "0.2.2", "resolved": "https://registry.npmjs.org/stdin-discarder/-/stdin-discarder-0.2.2.tgz", @@ -16097,6 +16319,19 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/swr": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/swr/-/swr-2.3.3.tgz", + "integrity": "sha512-dshNvs3ExOqtZ6kJBaAsabhPdHyeY4P2cKwRCniDVifBMoG/SVI7tfLWqPXriVspf2Rg4tPzXJTnwaihIeFw2A==", + "license": "MIT", + "dependencies": { + "dequal": "^2.0.3", + "use-sync-external-store": "^1.4.0" + }, + "peerDependencies": { + "react": "^16.11.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/symbol-tree": { "version": "3.2.4", "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", @@ -17880,6 +18115,15 @@ "requires-port": "^1.0.0" } }, + "node_modules/use-sync-external-store": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.5.0.tgz", + "integrity": "sha512-Rb46I4cGGVBmjamjphe8L/UnvJD+uPPtTkNvX5mZgqdbavhI4EbgIWJiIHXJ8bc/i9EQGPRh4DwEURJ552Do0A==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/util": { "version": "0.12.5", "resolved": "https://registry.npmjs.org/util/-/util-0.12.5.tgz", @@ -19062,6 +19306,11 @@ "url": "https://github.com/sponsors/wooorm" } }, + "packages/auth": { + "name": "@repo/auth", + "version": "0.0.0", + "license": "MIT" + }, "packages/config-eslint": { "name": "@repo/eslint-config", "version": "0.0.0", From 14ef2719c799524014d4bd3a0c8bc9e43c00a7ac Mon Sep 17 00:00:00 2001 From: afiqzudinhadi Date: Mon, 23 Jun 2025 14:50:04 +0800 Subject: [PATCH 03/30] feat: add clerk to vite-template --- apps/vite-template/package.json | 1 + apps/vite-template/src/app/index.tsx | 69 ++++++++++++++++++---------- apps/vite-template/src/main.tsx | 25 +++++++--- package-lock.json | 1 + 4 files changed, 65 insertions(+), 31 deletions(-) diff --git a/apps/vite-template/package.json b/apps/vite-template/package.json index c039e03..cdc9fe0 100644 --- a/apps/vite-template/package.json +++ b/apps/vite-template/package.json @@ -10,6 +10,7 @@ "lint": "eslint src/ --max-warnings 0" }, "dependencies": { + "@clerk/clerk-react": "^5.32.0", "@repo/ui": "*", "react": "^18.3.1", "react-dom": "^18.3.1" diff --git a/apps/vite-template/src/app/index.tsx b/apps/vite-template/src/app/index.tsx index 70d0076..5217819 100644 --- a/apps/vite-template/src/app/index.tsx +++ b/apps/vite-template/src/app/index.tsx @@ -10,33 +10,54 @@ import { CardHeader, CardTitle, } from "@repo/ui/components/card"; +import { + SignedIn, + SignedOut, + SignInButton, + UserButton, +} from "@clerk/clerk-react"; function App() { return ( -
-

- Admin
- Kitchen Sink -

- - - - shadcn/ui Card Example - - - - - -

- Built With{" "} - - Turborepo - - {" & "} - - Vite - -

+
+
+
+ + + + + + + + +
+
+ +
+

+ Admin
+ Kitchen Sink +

+ + + + shadcn/ui Card Example + + + + + +

+ Built With{" "} + + Turborepo + + {" & "} + + Vite + +

+
); } diff --git a/apps/vite-template/src/main.tsx b/apps/vite-template/src/main.tsx index 4582464..04b9577 100644 --- a/apps/vite-template/src/main.tsx +++ b/apps/vite-template/src/main.tsx @@ -2,15 +2,26 @@ import React from "react"; import { createRoot } from "react-dom/client"; import App from "./app"; import "./index.css"; +import { ClerkProvider } from "@clerk/clerk-react"; + +const PUBLISHABLE_KEY = import.meta.env.VITE_CLERK_PUBLISHABLE_KEY; + +if (!PUBLISHABLE_KEY) { + throw new Error( + "VITE_PUBLISHABLE_KEY is not defined. Please set it in your environment variables." + ); +} const el = document.getElementById("root"); if (el) { - const root = createRoot(el); - root.render( - - - - ); + const root = createRoot(el); + root.render( + + + + + + ); } else { - throw new Error("Could not find root element"); + throw new Error("Could not find root element"); } diff --git a/package-lock.json b/package-lock.json index 783852b..8225977 100644 --- a/package-lock.json +++ b/package-lock.json @@ -276,6 +276,7 @@ "apps/vite-template": { "version": "0.0.0", "dependencies": { + "@clerk/clerk-react": "^5.32.0", "@repo/ui": "*", "react": "^18.3.1", "react-dom": "^18.3.1" From a5faf21953a417d59232b7177dccc803f0b68b09 Mon Sep 17 00:00:00 2001 From: afiqzudinhadi Date: Mon, 23 Jun 2025 16:32:05 +0800 Subject: [PATCH 04/30] feat: add clerk integration to next-template --- apps/next-template/.gitignore | 3 +++ apps/next-template/package.json | 1 + apps/next-template/src/app/clerk/page.tsx | 23 +++++++++++++++++ apps/next-template/src/app/layout.tsx | 31 ++++++++++++++++++++--- apps/next-template/src/middleware.ts | 28 ++++++++++++++++++++ package-lock.json | 29 +++++++++++++++++++++ 6 files changed, 112 insertions(+), 3 deletions(-) create mode 100644 apps/next-template/.gitignore create mode 100644 apps/next-template/src/app/clerk/page.tsx create mode 100644 apps/next-template/src/middleware.ts diff --git a/apps/next-template/.gitignore b/apps/next-template/.gitignore new file mode 100644 index 0000000..4e4766d --- /dev/null +++ b/apps/next-template/.gitignore @@ -0,0 +1,3 @@ + +# clerk configuration (can include secrets) +/.clerk/ diff --git a/apps/next-template/package.json b/apps/next-template/package.json index 07039fe..b6cd03e 100644 --- a/apps/next-template/package.json +++ b/apps/next-template/package.json @@ -12,6 +12,7 @@ "clean": "rm -rf .next" }, "dependencies": { + "@clerk/nextjs": "^6.22.0", "@repo/logger": "*", "@repo/ui": "*", "next": "^15.3.0", diff --git a/apps/next-template/src/app/clerk/page.tsx b/apps/next-template/src/app/clerk/page.tsx new file mode 100644 index 0000000..7a8aa4a --- /dev/null +++ b/apps/next-template/src/app/clerk/page.tsx @@ -0,0 +1,23 @@ +import { + Card, + CardHeader, + CardTitle, + CardContent, +} from "@repo/ui/components/card"; + +export default function ClerkTestPage() { + return ( +
+ + + Welcome, you are logged in! + + +

+ This is a protected test page. +

+
+
+
+ ); +} diff --git a/apps/next-template/src/app/layout.tsx b/apps/next-template/src/app/layout.tsx index ac4f087..33e9b0c 100644 --- a/apps/next-template/src/app/layout.tsx +++ b/apps/next-template/src/app/layout.tsx @@ -1,5 +1,14 @@ import "@repo/ui/styles/styles.css"; import "@repo/ui/styles/global.css"; +import { + ClerkProvider, + SignInButton, + SignUpButton, + SignedIn, + SignedOut, + UserButton, +} from "@clerk/nextjs"; +import { Button } from "@repo/ui/components/button"; export default function RootLayout({ children, @@ -7,8 +16,24 @@ export default function RootLayout({ children: React.ReactNode; }) { return ( - - {children} - + + + +
+
+ + + + + + + + +
+
+ {children} + + +
); } diff --git a/apps/next-template/src/middleware.ts b/apps/next-template/src/middleware.ts new file mode 100644 index 0000000..800429b --- /dev/null +++ b/apps/next-template/src/middleware.ts @@ -0,0 +1,28 @@ +import { clerkMiddleware, createRouteMatcher } from "@clerk/nextjs/server"; + +const isProtectedRoute = createRouteMatcher(["/clerk(.*)"]); + +export default clerkMiddleware(async (auth, req) => { + // Uncomment the following line to enable Clerk's default behavior | Redirect to sign-in page if the user is not authenticated + // if (isProtectedRoute(req)) await auth.protect(); + + // More control over authentication flow + const { userId, redirectToSignIn } = await auth(); + + if (!userId && isProtectedRoute(req)) { + // Add custom logic to run before redirecting + console.log( + "User is not authenticated, redirecting to sign-in page..." + ); + return redirectToSignIn(); + } +}); + +export const config = { + matcher: [ + // Skip Next.js internals and all static files, unless found in search params + "/((?!_next|[^?]*\\.(?:html?|css|js(?!on)|jpe?g|webp|png|gif|svg|ttf|woff2?|ico|csv|docx?|xlsx?|zip|webmanifest)).*)", + // Always run for API routes + "/(api|trpc)(.*)", + ], +}; diff --git a/package-lock.json b/package-lock.json index 8225977..e1ab148 100644 --- a/package-lock.json +++ b/package-lock.json @@ -125,6 +125,7 @@ "apps/next-template": { "version": "0.0.0", "dependencies": { + "@clerk/nextjs": "^6.22.0", "@repo/logger": "*", "@repo/ui": "*", "next": "^15.3.0", @@ -1080,6 +1081,28 @@ "react-dom": "^18.0.0 || ^19.0.0 || ^19.0.0-0" } }, + "node_modules/@clerk/nextjs": { + "version": "6.22.0", + "resolved": "https://registry.npmjs.org/@clerk/nextjs/-/nextjs-6.22.0.tgz", + "integrity": "sha512-Lh+hq4iuvuSgiWSDHDM9BAM8+YZU/wtADGo2TAU7FBVEZ6I3SwyXo5JEY4F7s30TpH3kS9qC/Ye3YA6VIOOu7Q==", + "license": "MIT", + "dependencies": { + "@clerk/backend": "^2.1.0", + "@clerk/clerk-react": "^5.32.0", + "@clerk/shared": "^3.9.7", + "@clerk/types": "^4.60.1", + "server-only": "0.0.1", + "tslib": "2.8.1" + }, + "engines": { + "node": ">=18.17.0" + }, + "peerDependencies": { + "next": "^13.5.7 || ^14.2.25 || ^15.2.3", + "react": "^18.0.0 || ^19.0.0 || ^19.0.0-0", + "react-dom": "^18.0.0 || ^19.0.0 || ^19.0.0-0" + } + }, "node_modules/@clerk/remix": { "version": "4.8.1", "resolved": "https://registry.npmjs.org/@clerk/remix/-/remix-4.8.1.tgz", @@ -15457,6 +15480,12 @@ "node": ">= 0.8.0" } }, + "node_modules/server-only": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/server-only/-/server-only-0.0.1.tgz", + "integrity": "sha512-qepMx2JxAa5jjfzxG79yPPq+8BuFToHd1hm7kI+Z4zAq1ftQiP7HcxMhDDItrbtwVeLg/cY2JnKnrcFkmiswNA==", + "license": "MIT" + }, "node_modules/set-cookie-parser": { "version": "2.7.1", "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.1.tgz", From 3fcf8da40688eb4a18ddc3caa8ad0d0ade083673 Mon Sep 17 00:00:00 2001 From: afiqzudinhadi Date: Mon, 23 Jun 2025 19:45:53 +0800 Subject: [PATCH 05/30] chore: cleanup and make ui looks nicer --- .../app/routes/clerk._index.tsx | 40 +++++++++++++++++ apps/remix-template/app/routes/clerk.tsx | 44 ++++++------------- apps/vite-template/src/app/index.tsx | 14 +++++- 3 files changed, 67 insertions(+), 31 deletions(-) create mode 100644 apps/remix-template/app/routes/clerk._index.tsx diff --git a/apps/remix-template/app/routes/clerk._index.tsx b/apps/remix-template/app/routes/clerk._index.tsx new file mode 100644 index 0000000..89ed32d --- /dev/null +++ b/apps/remix-template/app/routes/clerk._index.tsx @@ -0,0 +1,40 @@ +import { SignInButton, UserButton, SignedIn, SignedOut } from "@clerk/remix"; +import { + Card, + CardContent, + CardHeader, + CardTitle, +} from "@repo/ui/components/card"; +import { Button } from "@repo/ui/components/button"; + +export default function Index() { + return ( +
+ + + Welcome + + + +
+

+ You are signed in! +

+ +
+
+ +
+

+ You are signed out +

+ + + +
+
+
+
+
+ ); +} diff --git a/apps/remix-template/app/routes/clerk.tsx b/apps/remix-template/app/routes/clerk.tsx index 8c1c646..15c1afe 100644 --- a/apps/remix-template/app/routes/clerk.tsx +++ b/apps/remix-template/app/routes/clerk.tsx @@ -1,40 +1,24 @@ import { SignInButton, UserButton, SignedIn, SignedOut } from "@clerk/remix"; -import { - Card, - CardContent, - CardHeader, - CardTitle, -} from "@repo/ui/components/card"; import { Button } from "@repo/ui/components/button"; +import { Outlet } from "@remix-run/react"; export default function Index() { return ( -
- - - Welcome - - - -
-

- You are signed in! -

- -
-
+
+
+
-
-

- You are signed out -

- - - -
+ + +
- - + + + +
+
+ +
); } diff --git a/apps/vite-template/src/app/index.tsx b/apps/vite-template/src/app/index.tsx index 5217819..ad4be23 100644 --- a/apps/vite-template/src/app/index.tsx +++ b/apps/vite-template/src/app/index.tsx @@ -28,7 +28,7 @@ function App() { - +
@@ -39,6 +39,7 @@ function App() { Kitchen Sink + shadcn/ui Card Example @@ -47,6 +48,17 @@ function App() { + + + + Clerk Authentication Example + + + You are signed out! + You are signed in! + + +

Built With{" "} From eb1dec1d40b42da382e7a48584c10a327c145dcc Mon Sep 17 00:00:00 2001 From: afiqzudinhadi Date: Mon, 23 Jun 2025 21:12:25 +0800 Subject: [PATCH 06/30] docs: add readme in packages/auth and add .env.example in each repo --- apps/next-template/.env.example | 2 ++ apps/remix-template/.env.example | 2 ++ apps/vite-template/.env.example | 1 + packages/auth/README.md | 3 +++ 4 files changed, 8 insertions(+) create mode 100644 apps/next-template/.env.example create mode 100644 apps/remix-template/.env.example create mode 100644 apps/vite-template/.env.example create mode 100644 packages/auth/README.md diff --git a/apps/next-template/.env.example b/apps/next-template/.env.example new file mode 100644 index 0000000..1b5e347 --- /dev/null +++ b/apps/next-template/.env.example @@ -0,0 +1,2 @@ +NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY= +CLERK_SECRET_KEY= \ No newline at end of file diff --git a/apps/remix-template/.env.example b/apps/remix-template/.env.example new file mode 100644 index 0000000..379149b --- /dev/null +++ b/apps/remix-template/.env.example @@ -0,0 +1,2 @@ +CLERK_PUBLISHABLE_KEY= +CLERK_SECRET_KEY= \ No newline at end of file diff --git a/apps/vite-template/.env.example b/apps/vite-template/.env.example new file mode 100644 index 0000000..a32bad2 --- /dev/null +++ b/apps/vite-template/.env.example @@ -0,0 +1 @@ +VITE_CLERK_PUBLISHABLE_KEY= \ No newline at end of file diff --git a/packages/auth/README.md b/packages/auth/README.md new file mode 100644 index 0000000..7a01660 --- /dev/null +++ b/packages/auth/README.md @@ -0,0 +1,3 @@ +# @repo/auth + +Helper tools package for Clerk authentication. As of now, this helper package is not being used anywhere in the repo. From 2a199f36798069a73fc86d2fd2e0c95df24ef282 Mon Sep 17 00:00:00 2001 From: afiqzudinhadi Date: Tue, 24 Jun 2025 10:48:12 +0800 Subject: [PATCH 07/30] docs: update root and @repo/auth readme --- README.md | 1 + packages/auth/README.md | 2 ++ 2 files changed, 3 insertions(+) diff --git a/README.md b/README.md index 77951b4..2a38e05 100644 --- a/README.md +++ b/README.md @@ -21,6 +21,7 @@ This Turborepo includes the following packages and apps: - `@repo/ui`: shared UI components for frontend use. - A dummy React UI library (which contains `` and `` components) - And shadcn/ui components (which contains `

); diff --git a/apps/vite-template/src/app/productForm.tsx b/apps/vite-template/src/app/productForm.tsx new file mode 100644 index 0000000..03be60a --- /dev/null +++ b/apps/vite-template/src/app/productForm.tsx @@ -0,0 +1,76 @@ +import React, { useState } from "react"; +import { + ProductCreate, + ProductCreateData, +} from "@repo/ui/components/products/create"; + +interface ProductFormProps { + onSuccess?: (product: ProductCreateData) => void; + onCancel?: () => void; + className?: string; +} + +const ProductForm: React.FC = ({ + onSuccess, + onCancel, + className, +}) => { + const [isVisible, setIsVisible] = useState(true); + const apiBaseUrl = + import.meta.env.VITE_API_BASE_URL || "http://localhost:5001"; + + const handleProductCreated = (product: ProductCreateData) => { + console.log("Product created successfully:", product); + + // Call the success callback if provided + if (onSuccess) { + onSuccess(product); + } + + // Optionally hide the form after successful creation + // setIsVisible(false); + }; + + const handleCancel = () => { + console.log("Product creation cancelled"); + + if (onCancel) { + onCancel(); + } else { + // Default behavior: hide the form + setIsVisible(false); + } + }; + + const handleToggleForm = () => { + setIsVisible(!isVisible); + }; + + return ( +
+ {/* Toggle Button */} +
+ +
+ + {/* Product Create Form */} + {isVisible && ( +
+ +
+ )} +
+ ); +}; + +export default ProductForm; diff --git a/apps/vite-template/src/app/productIndex.tsx b/apps/vite-template/src/app/productIndex.tsx new file mode 100644 index 0000000..ee3eab7 --- /dev/null +++ b/apps/vite-template/src/app/productIndex.tsx @@ -0,0 +1,153 @@ +import React, { useEffect, useState, useCallback } from "react"; +import { ProductGrid } from "@repo/ui/components/products/index"; +import { PaginatedResponse } from "@repo/ui/components/template/table-show"; + +interface Product { + id: string; + name: string; + description: string; + price: number; + imageUrl?: string; + isPublished?: boolean; + created_at?: string; + updated_at?: string; +} + +const apiBaseUrl = import.meta.env.VITE_API_BASE_URL || "http://localhost:5001"; + +const fetchProducts = async ( + page: number = 1, + limit: number = 10 +): Promise> => { + const response = await fetch( + `${apiBaseUrl}/api/products?page=${page}&limit=${limit}` + ); + if (!response.ok) { + throw new Error("Failed to fetch products"); + } + return response.json(); +}; + +const ProductIndex: React.FC = () => { + const [paginatedProducts, setPaginatedProducts] = useState< + PaginatedResponse + >({ + data: [], + pagination: { + page: 1, + limit: 10, + totalCount: 0, + totalPages: 0, + hasNextPage: false, + hasPreviousPage: false, + }, + }); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + const loadProducts = useCallback(async (page: number = 1) => { + try { + setLoading(true); + const result = await fetchProducts(page); + setPaginatedProducts(result); + setError(null); + } catch (err: any) { + setError(err.message); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + loadProducts(1); + }, [loadProducts]); + + const handlePageChange = (page: number) => { + loadProducts(page); + }; + + const handleEdit = (product: Product) => { + // TODO: Implement edit functionality for Vite app + console.log("Edit product:", product); + }; + + const handleDelete = async (productId: string) => { + if (!confirm("Are you sure you want to delete this product?")) { + return; + } + + try { + const response = await fetch( + `${apiBaseUrl}/api/products/${productId}`, + { + method: "DELETE", + } + ); + + if (!response.ok) { + throw new Error("Failed to delete product"); + } + + // Refresh current page + loadProducts(paginatedProducts.pagination.page); + } catch (err: any) { + console.error("Delete error:", err.message); + alert("Failed to delete product: " + err.message); + } + }; + + if (loading && paginatedProducts.data.length === 0) { + return ( +
+
Loading products...
+
+ ); + } + + if (error) { + return ( +
+
+

+ Error Loading Products +

+

{error}

+
+ +
+ ); + } + + return ( +
+
+
+

Products

+

+ {paginatedProducts.pagination.totalCount} product + {paginatedProducts.pagination.totalCount !== 1 + ? "s" + : ""}{" "} + found +

+
+
+ + +
+ ); +}; + +export default ProductIndex; diff --git a/apps/vite-template/tsconfig.node.json b/apps/vite-template/tsconfig.node.json index 42872c5..eca6668 100644 --- a/apps/vite-template/tsconfig.node.json +++ b/apps/vite-template/tsconfig.node.json @@ -1,10 +1,10 @@ { - "compilerOptions": { - "composite": true, - "skipLibCheck": true, - "module": "ESNext", - "moduleResolution": "bundler", - "allowSyntheticDefaultImports": true - }, - "include": ["vite.config.ts"] + "compilerOptions": { + "composite": true, + "skipLibCheck": true, + "module": "ESNext", + "moduleResolution": "bundler", + "allowSyntheticDefaultImports": true + }, + "include": ["vite.config.ts"] } diff --git a/packages/db/src/database.ts b/packages/db/src/database.ts index b3b1c5d..251f7d0 100644 --- a/packages/db/src/database.ts +++ b/packages/db/src/database.ts @@ -1,6 +1,17 @@ import { drizzle } from "drizzle-orm/postgres-js"; import postgres from "postgres"; +import dotenv from "dotenv"; +dotenv.config({ path: ".env" }); -const client = postgres(process.env.DATABASE_URL!); +let db_url = process.env.DATABASE_URL!; +try { + db_url = decodeURI(db_url); +} catch (e) { + console.warn("Failed to decode DATABASE_URL, using as is."); +} + +console.log("Connecting to database URL:", db_url); + +const client = postgres(db_url!); export const db = drizzle(client); diff --git a/packages/db/src/queries/products.ts b/packages/db/src/queries/products.ts index e6eed3d..9e09a66 100644 --- a/packages/db/src/queries/products.ts +++ b/packages/db/src/queries/products.ts @@ -1,3 +1,4 @@ +import { count, desc } from "drizzle-orm"; import { db } from "../database"; import { InsertProduct, products } from "../schema/products"; @@ -5,6 +6,28 @@ export async function createProduct(data: InsertProduct) { await db.insert(products).values(data); } -export async function getProducts() { - return db.select().from(products); +export async function getProducts(page: number = 1, limit: number = 10) { + const offset = (page - 1) * limit; + + const totalCountResult = await db.select({ count: count() }).from(products); + const totalCount = totalCountResult[0].count; + + const productsList = await db + .select() + .from(products) + .orderBy(desc(products.createdAt)) + .limit(limit) + .offset(offset); + + return { + data: productsList, + pagination: { + page, + limit, + totalCount, + totalPages: Math.ceil(totalCount / limit), + hasNextPage: page < Math.ceil(totalCount / limit), + hasPreviousPage: page > 1, + }, + }; } diff --git a/packages/db/src/queries/users.ts b/packages/db/src/queries/users.ts index 94301df..8b99ea2 100644 --- a/packages/db/src/queries/users.ts +++ b/packages/db/src/queries/users.ts @@ -1,6 +1,20 @@ import { db } from "../database"; -import { InsertUser, user } from "../schema/users"; +import { InsertUser, users } from "../schema/users"; +import { eq } from "drizzle-orm"; export async function createUser(data: InsertUser) { - await db.insert(user).values(data); + return await db.insert(users).values(data); +} + +export async function getUsers() { + return await db.select().from(users); +} +export async function getUserById(id: string) { + const result = await db + .select() + .from(users) + .where(eq(users.id, id)) + .limit(1); + + return result[0] || null; } diff --git a/packages/ui/src/components/products/create.tsx b/packages/ui/src/components/products/create.tsx new file mode 100644 index 0000000..6bdc1dc --- /dev/null +++ b/packages/ui/src/components/products/create.tsx @@ -0,0 +1,130 @@ +import React from "react"; +import { CreateForm, FormField, FormData } from "../template/create-form"; + +// Product-specific form fields configuration based on database schema +const productFields: FormField[] = [ + { + key: "name", + label: "Product Name", + type: "text", + required: true, + placeholder: "Enter product name", + validation: { + minLength: 2, + maxLength: 255, + }, + helpText: "A clear, descriptive name for your product", + }, + { + key: "price", + label: "Price", + type: "number", + required: true, + placeholder: "0.00", + validation: { + min: 0, + max: 99999999.99, // Based on numeric(10, 2) from schema + }, + helpText: "Price in USD (up to 8 digits before decimal, 2 after)", + }, + { + key: "description", + label: "Description", + type: "textarea", + placeholder: "Enter detailed product description", + validation: { + maxLength: 2000, + }, + helpText: "Detailed description of the product features and benefits", + }, + { + key: "imageUrl", + label: "Image URL", + type: "url", + placeholder: "https://example.com/product-image.jpg", + helpText: "URL to the main product image", + }, + { + key: "isPublished", + label: "Published Status", + type: "checkbox", + placeholder: "Make this product visible to customers", + helpText: "Uncheck to save as draft (defaults to false)", + }, +]; + +export interface ProductCreateData extends FormData { + name: string; + price: number; + description?: string | null; + imageUrl?: string | null; + isPublished?: boolean | null; +} + +interface ProductCreateProps { + onSuccess?: (product: ProductCreateData) => void; + onCancel?: () => void; + loading?: boolean; + className?: string; + apiEndpoint?: string; +} + +export function ProductCreate({ + onSuccess, + onCancel, + loading = false, + className, + apiEndpoint = "/api/products", +}: ProductCreateProps) { + const handleSubmit = async (data: FormData): Promise => { + // Transform and validate the data according to database schema + const productData: ProductCreateData = { + name: data.name as string, + price: Number(data.price), + description: (data.description as string) || null, + imageUrl: (data.imageUrl as string) || null, + isPublished: data.isPublished ? Boolean(data.isPublished) : false, + }; + + // Make API call + const response = await fetch(apiEndpoint, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify(productData), + }); + + if (!response.ok) { + const errorData = await response.json().catch(() => ({})); + throw new Error( + errorData.message || + `Failed to create product (${response.status})` + ); + } + + const createdProduct = await response.json(); + + if (onSuccess) { + onSuccess(createdProduct); + } + }; + + return ( + + ); +} + +// Export individual field configurations for reuse +export const productFormFields = productFields; + +export type { ProductCreateProps }; diff --git a/packages/ui/src/components/products/index.tsx b/packages/ui/src/components/products/index.tsx new file mode 100644 index 0000000..e04f2ed --- /dev/null +++ b/packages/ui/src/components/products/index.tsx @@ -0,0 +1,169 @@ +import { + TableShow, + TableGrid, + SimpleTableGrid, + FieldConfig, + ActionConfig, + BaseRecord, + PaginatedResponse, +} from "../template/table-show"; + +export interface Product extends BaseRecord { + name: string; + description: string; + price: number; + imageUrl?: string; + isPublished?: boolean; +} + +const productFields: FieldConfig[] = [ + { key: "description", label: "Description", type: "text" }, + { key: "price", label: "Price", type: "currency" }, +]; + +interface ProductShowProps { + product: Product; + onEdit?: (product: Product) => void; + onDelete?: (productId: string) => void; + className?: string; +} + +export function ProductShow({ + product, + onEdit, + onDelete, + className, +}: ProductShowProps) { + const actions: ActionConfig[] = [ + ...(onEdit + ? [ + { + label: "Edit", + variant: "outline" as const, + onClick: onEdit, + }, + ] + : []), + ...(onDelete + ? [ + { + label: "Delete", + variant: "destructive" as const, + onClick: (p: Product) => onDelete(p.id), + }, + ] + : []), + ]; + + return ( + p.name} + fields={productFields} + actions={actions} + className={className} + statusField="isPublished" + imageField="imageUrl" + /> + ); +} + +// Updated paginated ProductGrid +export function ProductGrid({ + paginatedProducts, + onEdit, + onDelete, + onPageChange, + loading, + className, +}: { + paginatedProducts: PaginatedResponse; + onEdit?: (product: Product) => void; + onDelete?: (productId: string) => void; + onPageChange: (page: number) => void; + loading?: boolean; + className?: string; +}) { + const actions: ActionConfig[] = [ + ...(onEdit + ? [ + { + label: "Edit", + variant: "outline" as const, + onClick: onEdit, + }, + ] + : []), + ...(onDelete + ? [ + { + label: "Delete", + variant: "destructive" as const, + onClick: (p: Product) => onDelete(p.id), + }, + ] + : []), + ]; + + return ( + p.name} + fields={productFields} + actions={actions} + onPageChange={onPageChange} + loading={loading} + className={className} + statusField="isPublished" + imageField="imageUrl" + emptyMessage="No products found." + /> + ); +} + +// Simple non-paginated version for backward compatibility +export function SimpleProductGrid({ + products, + onEdit, + onDelete, + className, +}: { + products: Product[]; + onEdit?: (product: Product) => void; + onDelete?: (productId: string) => void; + className?: string; +}) { + const actions: ActionConfig[] = [ + ...(onEdit + ? [ + { + label: "Edit", + variant: "outline" as const, + onClick: onEdit, + }, + ] + : []), + ...(onDelete + ? [ + { + label: "Delete", + variant: "destructive" as const, + onClick: (p: Product) => onDelete(p.id), + }, + ] + : []), + ]; + + return ( + p.name} + fields={productFields} + actions={actions} + className={className} + statusField="isPublished" + imageField="imageUrl" + emptyMessage="No products found." + /> + ); +} diff --git a/packages/ui/src/components/template/create-form.tsx b/packages/ui/src/components/template/create-form.tsx new file mode 100644 index 0000000..77db2b9 --- /dev/null +++ b/packages/ui/src/components/template/create-form.tsx @@ -0,0 +1,344 @@ +import React, { useState } from "react"; +import { Button } from "../button"; +import { Card, CardContent, CardHeader, CardTitle } from "../card"; +import { cn } from "@repo/ui/lib/utils"; + +// Base interface for form fields +export interface FormField { + key: string; + label: string; + type: + | "text" + | "number" + | "email" + | "password" + | "textarea" + | "url" + | "tel" + | "date" + | "select" + | "checkbox"; + required?: boolean; + placeholder?: string; + options?: { value: string; label: string }[]; // For select fields + validation?: { + min?: number; + max?: number; + pattern?: string; + minLength?: number; + maxLength?: number; + }; + className?: string; + disabled?: boolean; + helpText?: string; +} + +// Generic form data interface +export interface FormData { + [key: string]: any; +} + +interface CreateFormProps { + title: string; + fields: FormField[]; + onSubmit: (data: FormData) => Promise; + onCancel?: () => void; + loading?: boolean; + submitText?: string; + cancelText?: string; + className?: string; + initialData?: FormData; +} + +export function CreateForm({ + title, + fields, + onSubmit, + onCancel, + loading = false, + submitText = "Create", + cancelText = "Cancel", + className, + initialData = {}, +}: CreateFormProps) { + const [formData, setFormData] = useState(() => { + const initial: FormData = {}; + fields.forEach((field) => { + initial[field.key] = + initialData[field.key] || + (field.type === "number" + ? 0 + : field.type === "checkbox" + ? false + : ""); + }); + return initial; + }); + + const [errors, setErrors] = useState>({}); + const [isSubmitting, setIsSubmitting] = useState(false); + + const validateField = (field: FormField, value: any): string | null => { + if ( + field.required && + (!value || (typeof value === "string" && !value.trim())) + ) { + return `${field.label} is required`; + } + + if (field.validation) { + const { min, max, pattern, minLength, maxLength } = + field.validation; + + if (field.type === "number" && typeof value === "number") { + if (min !== undefined && value < min) { + return `${field.label} must be at least ${min}`; + } + if (max !== undefined && value > max) { + return `${field.label} must be at most ${max}`; + } + } + + if (typeof value === "string") { + if (minLength !== undefined && value.length < minLength) { + return `${field.label} must be at least ${minLength} characters`; + } + if (maxLength !== undefined && value.length > maxLength) { + return `${field.label} must be at most ${maxLength} characters`; + } + if (pattern && !new RegExp(pattern).test(value)) { + return `${field.label} format is invalid`; + } + } + } + + return null; + }; + + const validateForm = (): boolean => { + const newErrors: Record = {}; + + fields.forEach((field) => { + const error = validateField(field, formData[field.key]); + if (error) { + newErrors[field.key] = error; + } + }); + + setErrors(newErrors); + return Object.keys(newErrors).length === 0; + }; + + const handleInputChange = (key: string, value: any) => { + setFormData((prev) => ({ ...prev, [key]: value })); + + // Clear error when user starts typing + if (errors[key]) { + setErrors((prev) => ({ ...prev, [key]: "" })); + } + }; + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + + if (!validateForm()) { + return; + } + + setIsSubmitting(true); + try { + await onSubmit(formData); + + // Reset form on success + const resetData: FormData = {}; + fields.forEach((field) => { + resetData[field.key] = + field.type === "number" + ? 0 + : field.type === "checkbox" + ? false + : ""; + }); + setFormData(resetData); + setErrors({}); + } catch (error: any) { + setErrors({ submit: error.message || "An error occurred" }); + } finally { + setIsSubmitting(false); + } + }; + + const renderField = (field: FormField) => { + const isFieldDisabled = loading || isSubmitting || field.disabled; + const fieldError = errors[field.key]; + + const baseInputClasses = cn( + "w-full mt-1 p-2 border rounded-md transition-colors", + "focus:ring-2 focus:ring-blue-500 focus:border-transparent", + fieldError ? "border-red-500" : "border-gray-300", + isFieldDisabled && "opacity-50 cursor-not-allowed", + field.className + ); + + switch (field.type) { + case "textarea": + return ( +