diff --git a/apps/remix-template/app/routes/api.products.tsx b/apps/remix-template/app/routes/api.products.tsx new file mode 100644 index 0000000..45d90e5 --- /dev/null +++ b/apps/remix-template/app/routes/api.products.tsx @@ -0,0 +1,124 @@ +import type { ActionFunctionArgs, LoaderFunctionArgs } from "@remix-run/node"; +import { getProducts, createProduct } from "@repo/db/index"; + +export async function loader({ request }: LoaderFunctionArgs) { + try { + const url = new URL(request.url); + const page = parseInt(url.searchParams.get("page") || "1"); + const limit = parseInt(url.searchParams.get("limit") || "10"); + + if (page < 1 || limit < 1 || limit > 100) { + return new Response( + JSON.stringify({ + error: "Invalid pagination parameters. Page must be >= 1, limit must be 1-100", + }), + { + status: 400, + headers: { "Content-Type": "application/json" }, + } + ); + } + + const result = await getProducts(page, limit); + return new Response(JSON.stringify(result), { + headers: { "Content-Type": "application/json" }, + }); + } catch (error: any) { + console.error("API Error:", error); + return new Response( + JSON.stringify({ + error: "Failed to fetch products", + message: error.message, + }), + { + status: 500, + headers: { "Content-Type": "application/json" }, + } + ); + } +} + +export async function action({ request }: ActionFunctionArgs) { + if (request.method !== "POST") { + return new Response(JSON.stringify({ error: "Method not allowed" }), { + status: 405, + headers: { "Content-Type": "application/json" }, + }); + } + + try { + const body = await request.json(); + const { name, description, price, imageUrl, isPublished } = body; + + // Validation + if (!name || typeof name !== "string" || !name.trim()) { + return new Response( + JSON.stringify({ + error: "Product name is required and must be a non-empty string", + }), + { + status: 400, + headers: { "Content-Type": "application/json" }, + } + ); + } + + if (price === undefined || price === null || isNaN(Number(price))) { + return new Response( + JSON.stringify({ + error: "Price is required and must be a valid number", + }), + { + status: 400, + headers: { "Content-Type": "application/json" }, + } + ); + } + + const priceValue = Number(price); + if (priceValue < 0) { + return new Response( + JSON.stringify({ error: "Price must be non-negative" }), + { + status: 400, + headers: { "Content-Type": "application/json" }, + } + ); + } + + const productData = { + name: name.trim(), + description: description?.trim() || null, + price: priceValue.toString(), + imageUrl: imageUrl?.trim() || null, + isPublished: Boolean(isPublished), + }; + + const newProduct = await createProduct(productData); + + return new Response( + JSON.stringify({ + success: true, + message: "Product created successfully", + data: newProduct, + }), + { + status: 201, + headers: { "Content-Type": "application/json" }, + } + ); + } catch (error: any) { + console.error("API Error:", error); + return new Response( + JSON.stringify({ + success: false, + error: "Failed to create product", + message: error.message || "An unexpected error occurred", + }), + { + status: 500, + headers: { "Content-Type": "application/json" }, + } + ); + } +} diff --git a/apps/remix-template/app/routes/product.tsx b/apps/remix-template/app/routes/product.tsx new file mode 100644 index 0000000..bed255e --- /dev/null +++ b/apps/remix-template/app/routes/product.tsx @@ -0,0 +1,212 @@ +import React, { useState, useCallback } from "react"; +import type { LoaderFunctionArgs } from "@remix-run/node"; +import { useLoaderData, useRevalidator } from "@remix-run/react"; +import { getProducts } from "@repo/db/index"; +import { ProductGrid } from "@repo/ui/components/products/index"; +import { ProductCreate } from "@repo/ui/components/products/create"; +import { PaginatedResponse } from "@repo/ui/components/template/table-show"; +import { Button } from "@repo/ui/components/button"; + +interface Product { + id: string; + name: string; + description: string; + price: number; + imageUrl?: string; + isPublished?: boolean; + created_at?: string; + updated_at?: string; +} + +interface LoaderData { + products: PaginatedResponse; + error: string | null; +} + +export async function loader({ + request, +}: LoaderFunctionArgs): Promise { + try { + const url = new URL(request.url); + const page = parseInt(url.searchParams.get("page") || "1"); + const limit = parseInt(url.searchParams.get("limit") || "10"); + + const result = await getProducts(page, limit); + + const products: PaginatedResponse = { + data: result.data.map((product: any) => ({ + ...product, + id: String(product.id), + 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, + }; + + // Return raw object with Single Fetch + return { products, error: null }; + } catch (error: any) { + console.error("Failed to fetch products:", error); + + // Return raw object with Single Fetch + return { + products: { + data: [], + pagination: { + page: 1, + limit: 10, + totalCount: 0, + totalPages: 0, + hasNextPage: false, + hasPreviousPage: false, + }, + }, + error: error.message || "Failed to load products", + }; + } +} + +export default function ProductPage() { + const { products: initialProducts, error: initialError } = + useLoaderData(); + const revalidator = useRevalidator(); + + const [products, setProducts] = useState(initialProducts); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(initialError); + const [showCreateForm, setShowCreateForm] = useState(false); + + const handlePageChange = (page: number) => { + // Use Remix navigation for better UX + const url = new URL(window.location.href); + url.searchParams.set("page", page.toString()); + window.history.pushState({}, "", url); + revalidator.revalidate(); + }; + + 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 products list + revalidator.revalidate(); + } catch (err: any) { + console.error("Delete error:", err); + alert(`Failed to delete product: ${err.message}`); + } + }; + + const handleProductCreated = useCallback(() => { + // Refresh the entire page data using Remix revalidator + revalidator.revalidate(); + setShowCreateForm(false); + }, [revalidator]); + + const handleFormCancel = () => { + setShowCreateForm(false); + }; + + // Show loading state when revalidating + if (revalidator.state === "loading" && products.data.length === 0) { + return ( +
+
Loading products...
+
+ ); + } + + // Show error state + if (error && products.data.length === 0) { + return ( +
+
+

+ Error Loading Products +

+

{error}

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

Products

+

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

+
+ +
+ + {/* Create Product Form */} + {showCreateForm && ( +
+ +
+ )} + + {/* Error Banner */} + {error && products.data.length > 0 && ( +
+

+ Warning: {error} +

+
+ )} + + {/* Products Grid */} + +
+ ); +} diff --git a/apps/remix-template/package.json b/apps/remix-template/package.json index c1f947a..265e714 100644 --- a/apps/remix-template/package.json +++ b/apps/remix-template/package.json @@ -16,7 +16,7 @@ "@remix-run/node": "^2.15.2", "@remix-run/react": "^2.15.2", "@remix-run/server-runtime": "^2.15.2", - "@repo/auth": "*", + "@repo/db": "*", "@repo/ui": "*", "@vercel/analytics": "^1.5.0", "@vercel/remix": "2.16.6", diff --git a/apps/remix-template/tsconfig.json b/apps/remix-template/tsconfig.json index 15cd766..7346785 100644 --- a/apps/remix-template/tsconfig.json +++ b/apps/remix-template/tsconfig.json @@ -7,7 +7,8 @@ "skipLibCheck": true, "paths": { "~/*": ["./app/*"], - "@repo/ui/*": ["../../packages/ui/src/*"] + "@repo/ui/*": ["../../packages/ui/src/*"], + "@repo/db/*": ["../../packages/db/src/*"] }, // Vite takes care of building everything, not tsc. diff --git a/apps/remix-template/vite.config.ts b/apps/remix-template/vite.config.ts index 8990578..3a5f9e9 100644 --- a/apps/remix-template/vite.config.ts +++ b/apps/remix-template/vite.config.ts @@ -7,5 +7,13 @@ import tsconfigPaths from "vite-tsconfig-paths"; installGlobals(); export default defineConfig({ - plugins: [remix({ presets: [vercelPreset()] }), tsconfigPaths()], + plugins: [ + remix({ + presets: [vercelPreset()], + future: { + v3_singleFetch: true, + }, + }), + tsconfigPaths(), + ], });