From 7d3ce5c60499a84e31494e58e41b67b482423513 Mon Sep 17 00:00:00 2001 From: afiqzudinhadi Date: Tue, 8 Jul 2025 21:17:52 +0800 Subject: [PATCH] feat: init next-template integration with drizzle --- apps/next-template/.env.example | 5 +- .../src/app/api/products/route.tsx | 162 +++++++++++++++++ .../src/app/product/create-form.tsx | 67 +++++++ .../src/app/product/page-client.tsx | 166 ++++++++++++++++++ apps/next-template/src/app/product/page.tsx | 53 ++++++ apps/next-template/tsconfig.json | 3 +- 6 files changed, 454 insertions(+), 2 deletions(-) create mode 100644 apps/next-template/src/app/api/products/route.tsx create mode 100644 apps/next-template/src/app/product/create-form.tsx create mode 100644 apps/next-template/src/app/product/page-client.tsx create mode 100644 apps/next-template/src/app/product/page.tsx diff --git a/apps/next-template/.env.example b/apps/next-template/.env.example index 1b5e347..9625be3 100644 --- a/apps/next-template/.env.example +++ b/apps/next-template/.env.example @@ -1,2 +1,5 @@ NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY= -CLERK_SECRET_KEY= \ No newline at end of file +CLERK_SECRET_KEY= + +# Drizzle ORM configuration for Supabase PostgreSQL database +DATABASE_URL= diff --git a/apps/next-template/src/app/api/products/route.tsx b/apps/next-template/src/app/api/products/route.tsx new file mode 100644 index 0000000..19c59fb --- /dev/null +++ b/apps/next-template/src/app/api/products/route.tsx @@ -0,0 +1,162 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getProducts, createProduct } from "@repo/db/index"; + +export async function GET(request: NextRequest) { + try { + const { searchParams } = new URL(request.url); + const page = parseInt(searchParams.get("page") || "1"); + const limit = parseInt(searchParams.get("limit") || "10"); + + // Validate pagination parameters + if (page < 1 || limit < 1 || limit > 100) { + return NextResponse.json( + { + error: "Invalid pagination parameters. Page must be >= 1, limit must be 1-100", + }, + { status: 400 } + ); + } + + const result = await getProducts(page, limit); + return NextResponse.json(result); + } catch (error: any) { + console.error("API Error:", error); + return NextResponse.json( + { error: "Failed to fetch products", message: error.message }, + { status: 500 } + ); + } +} + +export async function POST(request: NextRequest) { + try { + const body = await request.json(); + const { name, description, price, imageUrl, isPublished } = body; + + // Enhanced validation + if (!name || typeof name !== "string" || !name.trim()) { + return NextResponse.json( + { + error: "Product name is required and must be a non-empty string", + }, + { status: 400 } + ); + } + + if (price === undefined || price === null || isNaN(Number(price))) { + return NextResponse.json( + { error: "Price is required and must be a valid number" }, + { status: 400 } + ); + } + + const priceValue = Number(price); + if (priceValue < 0) { + return NextResponse.json( + { error: "Price must be non-negative" }, + { status: 400 } + ); + } + + // Validate optional fields + if (description && typeof description !== "string") { + return NextResponse.json( + { error: "Description must be a string" }, + { status: 400 } + ); + } + + if (imageUrl && typeof imageUrl !== "string") { + return NextResponse.json( + { error: "Image URL must be a string" }, + { status: 400 } + ); + } + + // Validate URL format if provided + if (imageUrl && imageUrl.trim()) { + try { + new URL(imageUrl.trim()); + } catch { + return NextResponse.json( + { error: "Image URL must be a valid URL" }, + { status: 400 } + ); + } + } + + // Prepare product data + const productData = { + name: name.trim(), + description: description?.trim() || null, + price: priceValue, + imageUrl: imageUrl?.trim() || null, + isPublished: Boolean(isPublished), + }; + + // Create product in database + const newProduct = await createProduct(productData); + + // Return success response with created product + return NextResponse.json( + { + success: true, + message: "Product created successfully", + data: newProduct, + }, + { status: 201 } + ); + } catch (error: any) { + console.error("API Error:", error); + + // Handle specific database errors + if (error.message?.includes("duplicate") || error.code === "23505") { + return NextResponse.json( + { + success: false, + error: "Product already exists", + message: "A product with this name already exists", + }, + { status: 409 } + ); + } + + // Handle database connection errors + if (error.code === "ENOTFOUND" || error.code === "ECONNREFUSED") { + return NextResponse.json( + { + success: false, + error: "Database connection failed", + message: "Unable to connect to database", + }, + { status: 503 } + ); + } + + // Handle validation errors from database + if (error.code === "23502") { + // NOT NULL violation + return NextResponse.json( + { + success: false, + error: "Missing required field", + message: "A required field is missing", + }, + { status: 400 } + ); + } + + // Generic error response + return NextResponse.json( + { + success: false, + error: "Failed to create product", + message: error.message || "An unexpected error occurred", + ...(process.env.NODE_ENV === "development" && { + stack: error.stack, + }), + }, + { status: 500 } + ); + } +} diff --git a/apps/next-template/src/app/product/create-form.tsx b/apps/next-template/src/app/product/create-form.tsx new file mode 100644 index 0000000..ddf50c2 --- /dev/null +++ b/apps/next-template/src/app/product/create-form.tsx @@ -0,0 +1,67 @@ +"use client"; +import React, { useState } from "react"; +import { + ProductCreate, + ProductCreateData, +} from "@repo/ui/components/products/create"; +import { Button } from "@repo/ui/components/button"; + +interface CreateFormProps { + onProductCreated?: () => void; + className?: string; +} + +const CreateForm: React.FC = ({ + onProductCreated, + className, +}) => { + const [showForm, setShowForm] = useState(false); + + const handleProductCreated = (product: ProductCreateData) => { + console.log("Product created successfully:", product); + + // Hide the form after successful creation + setShowForm(false); + + // Trigger parent to refresh products list + if (onProductCreated) { + onProductCreated(); + } + }; + + const handleCancel = () => { + setShowForm(false); + }; + + const toggleForm = () => { + setShowForm(!showForm); + }; + + return ( +
+ {/* Toggle Button */} +
+ +
+ + {/* Create Form */} + {showForm && ( +
+ +
+ )} +
+ ); +}; + +export default CreateForm; diff --git a/apps/next-template/src/app/product/page-client.tsx b/apps/next-template/src/app/product/page-client.tsx new file mode 100644 index 0000000..e1c89dc --- /dev/null +++ b/apps/next-template/src/app/product/page-client.tsx @@ -0,0 +1,166 @@ +"use client"; +import React, { useState, useCallback } from "react"; +import { ProductGrid } from "@repo/ui/components/products/index"; +import { PaginatedResponse } from "@repo/ui/components/template/table-show"; +import { Button } from "@repo/ui/components/button"; +import CreateForm from "./create-form"; + +interface Product { + id: string; + name: string; + description: string; + price: number; + imageUrl?: string; + isPublished?: boolean; + created_at?: string; + updated_at?: string; +} + +interface PageClientProps { + initialPaginatedProducts: PaginatedResponse; + initialError: string | null; +} + +const fetchProducts = async ( + page: number = 1, + limit: number = 10 +): Promise> => { + const response = await fetch(`/api/products?page=${page}&limit=${limit}`); + if (!response.ok) { + throw new Error("Failed to fetch products"); + } + return response.json(); +}; + +const PageClient: React.FC = ({ + initialPaginatedProducts, + initialError, +}) => { + const [paginatedProducts, setPaginatedProducts] = useState< + PaginatedResponse + >(initialPaginatedProducts); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(initialError); + + const loadProducts = useCallback(async (page: number = 1) => { + try { + setLoading(true); + setError(null); + const result = await fetchProducts(page); + setPaginatedProducts(result); + } catch (err: any) { + console.error("Failed to load products:", err); + setError(err.message || "Failed to load products"); + } finally { + setLoading(false); + } + }, []); + + const handlePageChange = (page: number) => { + loadProducts(page); + }; + + const handleEdit = (product: Product) => { + // TODO: Implement edit functionality + 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(`/api/products/${productId}`, { + method: "DELETE", + }); + + if (!response.ok) { + const errorData = await response.json().catch(() => ({})); + throw new Error(errorData.error || "Failed to delete product"); + } + + // Refresh current page after deletion + await loadProducts(paginatedProducts.pagination.page); + } catch (err: any) { + console.error("Delete error:", err); + alert(`Failed to delete product: ${err.message}`); + } + }; + + const handleProductCreated = useCallback(() => { + // Refresh products list and go to first page to see the new product + loadProducts(1); + }, [loadProducts]); + + // Show loading state when no data and loading + if (loading && paginatedProducts.data.length === 0 && !error) { + return ( +
+
Loading products...
+
+ ); + } + + // Show error state + if (error && paginatedProducts.data.length === 0) { + return ( +
+
+

+ Error Loading Products +

+

{error}

+
+ +
+ ); + } + + return ( +
+ {/* Header Section */} +
+
+

Products

+

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

+
+
+ + {/* Create Product Form */} + + + {/* Error Banner (when products exist but there's an error) */} + {error && paginatedProducts.data.length > 0 && ( +
+

+ Warning: {error} +

+
+ )} + + {/* Products Grid */} + +
+ ); +}; + +export default PageClient; diff --git a/apps/next-template/src/app/product/page.tsx b/apps/next-template/src/app/product/page.tsx new file mode 100644 index 0000000..0fce0b5 --- /dev/null +++ b/apps/next-template/src/app/product/page.tsx @@ -0,0 +1,53 @@ +import React from "react"; +import { getProducts } from "@repo/db/index"; +import PageClient from "./page-client"; +import { Product } from "@repo/ui/components/products"; +import { PaginatedResponse } from "@repo/ui/components/template/table-show"; + +export default async function ProductPage() { + // Fetch initial data on the server (first page) + let initialPaginatedProducts: PaginatedResponse = { + data: [], + pagination: { + page: 1, + limit: 10, + totalCount: 0, + totalPages: 0, + hasNextPage: false, + hasPreviousPage: false, + }, + }; + let initialError = null; + + try { + const result = await getProducts(1, 10); // Get first page with 10 items + initialPaginatedProducts = { + data: result.data.map((product: any) => ({ + ...product, + id: String(product.id), // Convert to string for consistency + description: product.description ?? "", + imageUrl: product.imageUrl ?? undefined, + isPublished: product.isPublished ?? false, + created_at: product.createdAt + ? product.createdAt.toISOString() + : undefined, + updated_at: product.updatedAt + ? product.updatedAt.toISOString() + : undefined, + })), + pagination: result.pagination, + }; + } catch (error: any) { + console.error("Failed to fetch initial products:", error); + initialError = error.message; + } + + return ( +
+ +
+ ); +} diff --git a/apps/next-template/tsconfig.json b/apps/next-template/tsconfig.json index abe79fe..0203ffe 100644 --- a/apps/next-template/tsconfig.json +++ b/apps/next-template/tsconfig.json @@ -10,7 +10,8 @@ ], "paths": { "~/*": ["./src/*"], - "@repo/ui/*": ["../../packages/ui/src/*"] + "@repo/ui/*": ["../../packages/ui/src/*"], + "@repo/db/*": ["../../packages/db/src/*"] } }, "include": ["src", "next.config.ts", "next-env.d.ts", ".next/types/**/*.ts"]