diff --git a/apps/express-template/.env.example b/apps/express-template/.env.example
new file mode 100644
index 0000000..0e06826
--- /dev/null
+++ b/apps/express-template/.env.example
@@ -0,0 +1,2 @@
+# Drizzle ORM configuration for Supabase PostgreSQL database
+DATABASE_URL=
\ No newline at end of file
diff --git a/apps/express-template/src/routes/users.ts b/apps/express-template/src/routes/users.ts
new file mode 100644
index 0000000..1c3f702
--- /dev/null
+++ b/apps/express-template/src/routes/users.ts
@@ -0,0 +1,78 @@
+import { Router } from "express";
+import { getUsers, createUser } from "@repo/db/index.ts";
+import { randomUUID } from "crypto";
+
+const router = Router();
+
+// GET /api/users - Get all users
+router.get("/", async (req, res) => {
+ try {
+ const users = await getUsers();
+ res.json(users);
+ } catch (error: any) {
+ console.error("Error fetching users:", error);
+ res.status(500).json({
+ error: "Failed to fetch users",
+ message: error.message,
+ });
+ }
+});
+
+// POST /api/users - Create new user
+router.post("/", async (req, res) => {
+ try {
+ const { name, email, password, role, isActive } = req.body;
+
+ // Validation
+ if (!name || !email || !password) {
+ return res.status(400).json({
+ error: "Missing required fields: name, email, and password are required",
+ });
+ }
+
+ // Email format validation
+ const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
+ if (!emailRegex.test(email)) {
+ return res.status(400).json({ error: "Invalid email format" });
+ }
+
+ // Password length validation
+ if (password.length < 6) {
+ return res
+ .status(400)
+ .json({ error: "Password must be at least 6 characters long" });
+ }
+
+ // Role validation (based on schema enum)
+ const validRoles = ["admin", "user"];
+ if (role && !validRoles.includes(role)) {
+ return res.status(400).json({
+ error: "Invalid role. Must be 'admin' or 'user'",
+ });
+ }
+ const userData = {
+ id: randomUUID(),
+ name: name.trim(),
+ email: email.toLowerCase().trim(),
+ role: role || "user", // Default role
+ };
+
+ const newUser = await createUser(userData);
+
+ res.status(201).json(newUser);
+ } catch (error: any) {
+ console.error("Error creating user:", error);
+
+ // Handle unique constraint violation (duplicate email)
+ if (error.message?.includes("duplicate") || error.code === "23505") {
+ return res.status(409).json({ error: "Email already exists" });
+ }
+
+ res.status(500).json({
+ error: "Failed to create user",
+ message: error.message,
+ });
+ }
+});
+
+export default router;
diff --git a/apps/express-template/src/server.ts b/apps/express-template/src/server.ts
index dedb517..6be456a 100644
--- a/apps/express-template/src/server.ts
+++ b/apps/express-template/src/server.ts
@@ -2,21 +2,25 @@ import { json, urlencoded } from "body-parser";
import express, { type Express } from "express";
import morgan from "morgan";
import cors from "cors";
+import productRoutes from "./routes/products";
+import userRoutes from "./routes/users";
export const createServer = (): Express => {
- const app = express();
- app
- .disable("x-powered-by")
- .use(morgan("dev"))
- .use(urlencoded({ extended: true }))
- .use(json())
- .use(cors())
- .get("/message/:name", (req, res) => {
- return res.json({ message: `hello ${req.params.name}` });
- })
- .get("/status", (_, res) => {
- return res.json({ ok: true });
- });
+ const app = express();
+ app.disable("x-powered-by")
+ .use(morgan("dev"))
+ .use(urlencoded({ extended: true }))
+ .use(json())
+ .use(cors())
- return app;
+ .get("/message/:name", (req, res) => {
+ return res.json({ message: `hello ${req.params.name}` });
+ })
+ .get("/status", (_, res) => {
+ return res.json({ ok: true });
+ })
+
+ .use("/api/products", productRoutes)
+ .use("/api/users", userRoutes);
+ return app;
};
diff --git a/apps/express-template/tsconfig.json b/apps/express-template/tsconfig.json
index 6713bc9..ceee17d 100644
--- a/apps/express-template/tsconfig.json
+++ b/apps/express-template/tsconfig.json
@@ -1,9 +1,9 @@
{
- "extends": "@repo/typescript-config/base.json",
- "compilerOptions": {
- "lib": ["ES2015"],
- "outDir": "./dist"
- },
- "exclude": ["node_modules"],
- "include": ["."]
+ "extends": "@repo/typescript-config/base.json",
+ "compilerOptions": {
+ "lib": ["ES2015"],
+ "outDir": "./dist"
+ },
+ "exclude": ["node_modules"],
+ "include": ["."]
}
diff --git a/apps/vite-template/.env.example b/apps/vite-template/.env.example
index a32bad2..10cd1c4 100644
--- a/apps/vite-template/.env.example
+++ b/apps/vite-template/.env.example
@@ -1 +1,3 @@
-VITE_CLERK_PUBLISHABLE_KEY=
\ No newline at end of file
+VITE_CLERK_PUBLISHABLE_KEY=
+
+VITE_API_BASE_URL=http://localhost:5001
diff --git a/apps/vite-template/src/app/index.tsx b/apps/vite-template/src/app/index.tsx
index ad4be23..215a797 100644
--- a/apps/vite-template/src/app/index.tsx
+++ b/apps/vite-template/src/app/index.tsx
@@ -2,6 +2,8 @@ import "@repo/ui/styles/styles.css";
import "@repo/ui/styles/global.css";
import { CounterButton } from "@repo/ui/counter-button";
import { Link } from "@repo/ui/link";
+import ProductForm from "./ProductForm";
+import ProductIndex from "./ProductIndex";
import { Button } from "@repo/ui/components/button";
import {
@@ -69,6 +71,9 @@ function App() {
Vite
+
+
+
);
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 (
+
+ );
+ }
+
+ 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 (
+