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 ( +
+
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 ( +