Compare commits

..

No commits in common. "main" and "vanilla" have entirely different histories.

73 changed files with 1396 additions and 6399 deletions

1
.npmrc Normal file
View file

@ -0,0 +1 @@
auto-install-peers = true

View file

@ -21,7 +21,6 @@ This Turborepo includes the following packages and apps:
- `@repo/ui`: shared UI components for frontend use.
- A dummy React UI library (which contains `<CounterButton>` and `<Link>` components)
- And shadcn/ui components (which contains `<Button>` and `<Card>` components)
- `@repo/auth`: shared auth helpers using [Clerk](https://clerk.com/) as auth provider
Each package and app is 100% [TypeScript](https://www.typescriptlang.org/).
@ -35,24 +34,3 @@ This Turborepo has some additional tools already setup for you:
- [Prettier](https://prettier.io) for code formatting
- [shadcn/ui](https://ui.shadcn.com/docs/monorepo) for UI components
- [Tailwind CSS v4](https://tailwindcss.com/) for styling and to use shadcn/ui
### Setting up the monorepo
To get started with this monorepo
1. clone the repository:
```bash
git clone git@github.com:afiqzudinhadi/ecommerce-template.git
```
2. Add or remove `apps` and `packages` as needed. You can use the existing apps and packages as a reference for creating new ones.
- For example, to create a new app, you can copy the `next-template` directory and rename it to your desired app name. `admin-panel`, `public-site`, `landing-page`, `server` etc.
- Ensure that the new app's `package.json` has the correct name and version.
- Update the root `package.json` name to match your monorepo's name.
3. Navigate to the root directory of the monorepo and install dependencies:
```bash
npm install
```

View file

@ -1,5 +0,0 @@
# TODO
- [ ] Permissions
- [ ] Stripe integration
- [ ] Docker for deployment

View file

@ -1,2 +0,0 @@
# Drizzle ORM configuration for Supabase PostgreSQL database
DATABASE_URL=

View file

@ -15,7 +15,6 @@
"preset": "@repo/jest-presets/node"
},
"dependencies": {
"@repo/db": "*",
"@repo/logger": "*",
"body-parser": "^1.20.3",
"cors": "^2.8.5",

View file

@ -1,43 +0,0 @@
import { Router } from "express";
import { createProduct, getProducts } from "@repo/db/index";
const router = Router();
// GET /api/products - Get all products
router.get("/", async (req, res) => {
try {
const page = parseInt(req.query.page as string) || 1;
const limit = parseInt(req.query.limit as string) || 10;
// Validate pagination parameters
if (page < 1 || limit < 1 || limit > 100) {
return res.status(400).json({
error: "Invalid pagination parameters. Page must be >= 1, limit must be 1-100",
});
}
const result = await getProducts(page, limit);
res.status(200).json(result);
} catch (e: unknown) {
console.log("Error fetching products:", e);
res.status(500).json({
error: e instanceof Error ? e.message : "Unknown error",
});
}
});
// POST /api/products - Create new product
router.post("/", async (req, res) => {
try {
const { name, price, description } = req.body;
await createProduct({ name, price, description });
res.status(201).json({ ok: true });
} catch (e: unknown) {
console.error("Error creating product:", e);
res.status(500).json({
error: e instanceof Error ? e.message : "Unknown error",
});
}
});
export default router;

View file

@ -1,82 +0,0 @@
import { Router } from "express";
import { getUsers, createUser } from "@repo/db/index";
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: unknown) {
console.error("Error fetching users:", error);
res.status(500).json({
error: "Failed to fetch users",
message: error instanceof Error ? error.message : "Unknown error",
});
}
});
// POST /api/users - Create new user
router.post("/", async (req, res) => {
try {
const { name, email, password, role } = 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: unknown) {
console.error("Error creating user:", error);
// Handle unique constraint violation (duplicate email)
if (
error instanceof Error &&
(error.message?.includes("duplicate") ||
(error as any).code === "23505")
) {
return res.status(409).json({ error: "Email already exists" });
}
res.status(500).json({
error: "Failed to create user",
message: error instanceof Error ? error.message : "Unknown error",
});
}
});
export default router;

View file

@ -2,25 +2,21 @@ 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")
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 });
})
});
.use("/api/products", productRoutes)
.use("/api/users", userRoutes);
return app;
};

View file

@ -2,10 +2,7 @@
"extends": "@repo/typescript-config/base.json",
"compilerOptions": {
"lib": ["ES2015"],
"outDir": "./dist",
"paths": {
"@repo/db/*": ["../../packages/db/src/*"]
}
"outDir": "./dist"
},
"exclude": ["node_modules"],
"include": ["."]

View file

@ -1,5 +0,0 @@
NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY=
CLERK_SECRET_KEY=
# Drizzle ORM configuration for Supabase PostgreSQL database
DATABASE_URL=

View file

@ -1,3 +0,0 @@
# clerk configuration (can include secrets)
/.clerk/

View file

@ -12,9 +12,7 @@
"clean": "rm -rf .next"
},
"dependencies": {
"@clerk/nextjs": "^6.22.0",
"@repo/logger": "*",
"@repo/db": "*",
"@repo/ui": "*",
"next": "^15.3.0",
"react": "^18.3.1",

View file

@ -1,162 +0,0 @@
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 }
);
}
}

View file

@ -1,23 +0,0 @@
import {
Card,
CardHeader,
CardTitle,
CardContent,
} from "@repo/ui/components/card";
export default function ClerkTestPage() {
return (
<div className="flex items-center justify-center min-h-screen bg-muted">
<Card className="w-full max-w-md shadow-lg">
<CardHeader>
<CardTitle>Welcome, you are logged in!</CardTitle>
</CardHeader>
<CardContent>
<p className="text-muted-foreground">
This is a protected test page.
</p>
</CardContent>
</Card>
</div>
);
}

View file

@ -1,14 +1,5 @@
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,
@ -16,24 +7,8 @@ export default function RootLayout({
children: React.ReactNode;
}) {
return (
<ClerkProvider>
<html lang="en">
<body>
<header className="flex items-center justify-end py-4 px-6 bg-white shadow-sm mb-6">
<div>
<SignedOut>
<SignInButton mode="modal">
<Button variant="outline">Sign In</Button>
</SignInButton>
</SignedOut>
<SignedIn>
<UserButton afterSignOutUrl="/" />
</SignedIn>
</div>
</header>
{children}
</body>
<body>{children}</body>
</html>
</ClerkProvider>
);
}

View file

@ -1,67 +0,0 @@
"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<CreateFormProps> = ({
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 (
<div className={className}>
{/* Toggle Button */}
<div className="mb-6">
<Button
onClick={toggleForm}
variant={showForm ? "outline" : "default"}
>
{showForm ? "Cancel" : "Add New Product"}
</Button>
</div>
{/* Create Form */}
{showForm && (
<div className="mb-6 max-w-md">
<ProductCreate
onSuccess={handleProductCreated}
onCancel={handleCancel}
apiEndpoint="/api/products"
className="bg-white border rounded-lg shadow-sm"
/>
</div>
)}
</div>
);
};
export default CreateForm;

View file

@ -1,166 +0,0 @@
"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<Product>;
initialError: string | null;
}
const fetchProducts = async (
page: number = 1,
limit: number = 10
): Promise<PaginatedResponse<Product>> => {
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<PageClientProps> = ({
initialPaginatedProducts,
initialError,
}) => {
const [paginatedProducts, setPaginatedProducts] = useState<
PaginatedResponse<Product>
>(initialPaginatedProducts);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(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 (
<div className="flex justify-center items-center min-h-64">
<div className="text-lg text-gray-600">Loading products...</div>
</div>
);
}
// Show error state
if (error && paginatedProducts.data.length === 0) {
return (
<div className="flex flex-col justify-center items-center min-h-64 space-y-4">
<div className="text-red-600 bg-red-50 p-4 rounded-md max-w-md text-center">
<h3 className="font-semibold mb-2">
Error Loading Products
</h3>
<p>{error}</p>
</div>
<Button onClick={() => loadProducts(1)} variant="outline">
Try Again
</Button>
</div>
);
}
return (
<div className="container mx-auto p-6 space-y-6">
{/* Header Section */}
<div className="flex justify-between items-center">
<div>
<h1 className="text-3xl font-bold">Products</h1>
<p className="text-gray-600 mt-1">
{paginatedProducts.pagination.totalCount} product
{paginatedProducts.pagination.totalCount !== 1
? "s"
: ""}{" "}
found
</p>
</div>
</div>
{/* Create Product Form */}
<CreateForm
onProductCreated={handleProductCreated}
className="mb-6"
/>
{/* Error Banner (when products exist but there's an error) */}
{error && paginatedProducts.data.length > 0 && (
<div className="bg-red-50 border border-red-200 text-red-600 px-4 py-3 rounded-md">
<p>
<strong>Warning:</strong> {error}
</p>
</div>
)}
{/* Products Grid */}
<ProductGrid
paginatedProducts={paginatedProducts}
onEdit={handleEdit}
onDelete={handleDelete}
onPageChange={handlePageChange}
loading={loading}
className="mt-6"
/>
</div>
);
};
export default PageClient;

View file

@ -1,53 +0,0 @@
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<Product> = {
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 (
<div className="container">
<PageClient
initialPaginatedProducts={initialPaginatedProducts}
initialError={initialError}
/>
</div>
);
}

View file

@ -1,28 +0,0 @@
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)(.*)",
],
};

View file

@ -10,8 +10,7 @@
],
"paths": {
"~/*": ["./src/*"],
"@repo/ui/*": ["../../packages/ui/src/*"],
"@repo/db/*": ["../../packages/db/src/*"]
"@repo/ui/*": ["../../packages/ui/src/*"]
}
},
"include": ["src", "next.config.ts", "next-env.d.ts", ".next/types/**/*.ts"]

View file

@ -1,2 +0,0 @@
CLERK_PUBLISHABLE_KEY=
CLERK_SECRET_KEY=

View file

@ -6,15 +6,9 @@ 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 (
<html lang="en">
@ -37,8 +31,6 @@ export function Layout({ children }: { children: React.ReactNode }) {
);
}
export function App() {
export default function App() {
return <Outlet />;
}
export default ClerkApp(App);

View file

@ -1,124 +0,0 @@
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" },
}
);
}
}

View file

@ -1,40 +0,0 @@
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 (
<div className="flex min-h-screen items-center justify-center">
<Card className="w-full max-w-md shadow-lg">
<CardHeader>
<CardTitle className="text-center">Welcome</CardTitle>
</CardHeader>
<CardContent>
<SignedIn>
<div className="flex flex-col items-center gap-4">
<p className="text-green-700 font-medium">
You are signed in!
</p>
<UserButton />
</div>
</SignedIn>
<SignedOut>
<div className="flex flex-col items-center gap-4">
<p className="text-red-700 font-medium">
You are signed out
</p>
<SignInButton mode="modal">
<Button variant="default">Sign In</Button>
</SignInButton>
</div>
</SignedOut>
</CardContent>
</Card>
</div>
);
}

View file

@ -1,24 +0,0 @@
import { SignInButton, UserButton, SignedIn, SignedOut } from "@clerk/remix";
import { Button } from "@repo/ui/components/button";
import { Outlet } from "@remix-run/react";
export default function Index() {
return (
<div>
<header className="flex items-center justify-end py-4 px-6 bg-white shadow-sm mb-6">
<div>
<SignedOut>
<SignInButton mode="modal">
<Button variant="outline">Sign In</Button>
</SignInButton>
</SignedOut>
<SignedIn>
<UserButton />
</SignedIn>
</div>
</header>
<Outlet />
</div>
);
}

View file

@ -1,209 +0,0 @@
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<Product>;
error: string | null;
}
export async function loader({
request,
}: LoaderFunctionArgs): Promise<LoaderData> {
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<Product> = {
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, error } = useLoaderData<LoaderData>();
const revalidator = useRevalidator();
const [loading, setLoading] = useState(false);
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 (
<div className="flex justify-center items-center min-h-64">
<div className="text-lg text-gray-600">Loading products...</div>
</div>
);
}
// Show error state
if (error && products.data.length === 0) {
return (
<div className="flex flex-col justify-center items-center min-h-64 space-y-4">
<div className="text-red-600 bg-red-50 p-4 rounded-md max-w-md text-center">
<h3 className="font-semibold mb-2">
Error Loading Products
</h3>
<p>{error}</p>
</div>
<Button
onClick={() => revalidator.revalidate()}
variant="outline"
>
Try Again
</Button>
</div>
);
}
return (
<div className="container mx-auto p-6 space-y-6">
{/* Header Section */}
<div className="flex justify-between items-center">
<div>
<h1 className="text-3xl font-bold">Products</h1>
<p className="text-gray-600 mt-1">
{products.pagination.totalCount} product
{products.pagination.totalCount !== 1 ? "s" : ""} found
</p>
</div>
<Button
onClick={() => setShowCreateForm(!showCreateForm)}
variant={showCreateForm ? "outline" : "default"}
>
{showCreateForm ? "Cancel" : "Add Product"}
</Button>
</div>
{/* Create Product Form */}
{showCreateForm && (
<div className="mb-6 max-w-md">
<ProductCreate
onSuccess={handleProductCreated}
onCancel={handleFormCancel}
apiEndpoint="/api/products"
className="bg-white border rounded-lg shadow-sm"
/>
</div>
)}
{/* Error Banner */}
{error && products.data.length > 0 && (
<div className="bg-red-50 border border-red-200 text-red-600 px-4 py-3 rounded-md">
<p>
<strong>Warning:</strong> {error}
</p>
</div>
)}
{/* Products Grid */}
<ProductGrid
paginatedProducts={products}
onEdit={handleEdit}
onDelete={handleDelete}
onPageChange={handlePageChange}
loading={revalidator.state === "loading"}
className="mt-6"
/>
</div>
);
}

View file

@ -11,12 +11,10 @@
"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/db": "*",
"@repo/ui": "*",
"@vercel/analytics": "^1.5.0",
"@vercel/remix": "2.16.6",
@ -27,8 +25,8 @@
"devDependencies": {
"@remix-run/dev": "^2.15.2",
"@repo/eslint-config": "*",
"@types/react": "^19.0.10",
"@types/react-dom": "^19.0.4",
"@types/react": "^18.3.18",
"@types/react-dom": "^18.3.5",
"@typescript-eslint/eslint-plugin": "^8.34.0",
"@typescript-eslint/parser": "^8.34.0",
"autoprefixer": "^10.4.20",

View file

@ -7,8 +7,7 @@
"skipLibCheck": true,
"paths": {
"~/*": ["./app/*"],
"@repo/ui/*": ["../../packages/ui/src/*"],
"@repo/db/*": ["../../packages/db/src/*"]
"@repo/ui/*": ["../../packages/ui/src/*"]
},
// Vite takes care of building everything, not tsc.

View file

@ -7,13 +7,5 @@ import tsconfigPaths from "vite-tsconfig-paths";
installGlobals();
export default defineConfig({
plugins: [
remix({
presets: [vercelPreset()],
future: {
v3_singleFetch: true,
},
}),
tsconfigPaths(),
],
plugins: [remix({ presets: [vercelPreset()] }), tsconfigPaths()],
});

View file

@ -1,3 +0,0 @@
VITE_CLERK_PUBLISHABLE_KEY=
VITE_API_BASE_URL=http://localhost:5001

View file

@ -10,7 +10,6 @@
"lint": "eslint src/ --max-warnings 0"
},
"dependencies": {
"@clerk/clerk-react": "^5.32.0",
"@repo/ui": "*",
"react": "^18.3.1",
"react-dom": "^18.3.1"
@ -18,8 +17,8 @@
"devDependencies": {
"@repo/eslint-config": "*",
"@repo/typescript-config": "*",
"@types/react": "^19.0.10",
"@types/react-dom": "^19.0.4",
"@types/react": "^18.3.18",
"@types/react-dom": "^18.3.5",
"@vitejs/plugin-react": "^4.5.1",
"eslint": "^9.29.0",
"typescript": "5.8.2",

View file

@ -2,8 +2,6 @@ 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 {
@ -12,36 +10,15 @@ import {
CardHeader,
CardTitle,
} from "@repo/ui/components/card";
import {
SignedIn,
SignedOut,
SignInButton,
UserButton,
} from "@clerk/clerk-react";
function App() {
return (
<div>
<header className="flex items-center justify-end py-4 px-6 bg-white shadow-sm mb-6">
<div>
<SignedOut>
<SignInButton mode="modal">
<Button variant="outline">Sign In</Button>
</SignInButton>
</SignedOut>
<SignedIn>
<UserButton />
</SignedIn>
</div>
</header>
<div className="container">
<h1 className="title">
Admin <br />
<span>Kitchen Sink</span>
</h1>
<CounterButton />
<Card className="my-6">
<CardHeader>
<CardTitle>shadcn/ui Card Example</CardTitle>
@ -50,17 +27,6 @@ function App() {
<Button>shadcn/ui Button</Button>
</CardContent>
</Card>
<Card className="my-6">
<CardHeader>
<CardTitle>Clerk Authentication Example</CardTitle>
</CardHeader>
<CardContent>
<SignedOut>You are signed out!</SignedOut>
<SignedIn>You are signed in!</SignedIn>
</CardContent>
</Card>
<p className="description">
Built With{" "}
<Link href="https://turborepo.com" newTab>
@ -71,10 +37,6 @@ function App() {
Vite
</Link>
</p>
<ProductForm />
<ProductIndex />
</div>
</div>
);
}

View file

@ -1,76 +0,0 @@
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<ProductFormProps> = ({
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 (
<div className={className}>
{/* Toggle Button */}
<div className="mb-4">
<button
onClick={handleToggleForm}
className="px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600 transition-colors"
>
{isVisible ? "Hide Form" : "Add New Product"}
</button>
</div>
{/* Product Create Form */}
{isVisible && (
<div className="max-w-2xl">
<ProductCreate
onSuccess={handleProductCreated}
onCancel={handleCancel}
apiEndpoint={`${apiBaseUrl}/api/products`}
className="shadow-lg"
/>
</div>
)}
</div>
);
};
export default ProductForm;

View file

@ -1,155 +0,0 @@
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<PaginatedResponse<Product>> => {
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<Product>
>({
data: [],
pagination: {
page: 1,
limit: 10,
totalCount: 0,
totalPages: 0,
hasNextPage: false,
hasPreviousPage: false,
},
});
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const loadProducts = useCallback(async (page: number = 1) => {
try {
setLoading(true);
const result = await fetchProducts(page);
setPaginatedProducts(result);
setError(null);
} catch (err: unknown) {
setError(err instanceof Error ? err.message : "An error occurred");
} 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: unknown) {
const errorMessage =
err instanceof Error ? err.message : "An error occurred";
console.error("Delete error:", errorMessage);
alert("Failed to delete product: " + errorMessage);
}
};
if (loading && paginatedProducts.data.length === 0) {
return (
<div className="flex justify-center items-center min-h-64">
<div className="text-lg text-gray-600">Loading products...</div>
</div>
);
}
if (error) {
return (
<div className="flex flex-col justify-center items-center min-h-64 space-y-4">
<div className="text-red-600 bg-red-50 p-4 rounded-md max-w-md text-center">
<h3 className="font-semibold mb-2">
Error Loading Products
</h3>
<p>{error}</p>
</div>
<button
onClick={() => loadProducts(1)}
className="px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600 transition-colors"
>
Try Again
</button>
</div>
);
}
return (
<div className="container mx-auto p-6 space-y-6">
<div className="flex justify-between items-center">
<div>
<h2 className="text-3xl font-bold">Products</h2>
<p className="text-gray-600 mt-1">
{paginatedProducts.pagination.totalCount} product
{paginatedProducts.pagination.totalCount !== 1
? "s"
: ""}{" "}
found
</p>
</div>
</div>
<ProductGrid
paginatedProducts={paginatedProducts}
onEdit={handleEdit}
onDelete={handleDelete}
onPageChange={handlePageChange}
loading={loading}
className="mt-6"
/>
</div>
);
};
export default ProductIndex;

View file

@ -2,24 +2,13 @@ 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(
<React.StrictMode>
<ClerkProvider publishableKey={PUBLISHABLE_KEY} afterSignOutUrl="/">
<App />
</ClerkProvider>
</React.StrictMode>
);
} else {

View file

@ -1,11 +1,28 @@
{
"extends": "@repo/typescript-config/vite.json",
"compilerOptions": {
"types": ["vite/client"],
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"paths": {
"~/*": ["./src/*"],
"@repo/ui/*": ["../../packages/ui/src/*"]
}
},
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src"],
"references": [{ "path": "./tsconfig.node.json" }]

3975
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -2,28 +2,14 @@
"name": "ecommerce-template",
"private": true,
"scripts": {
"reinstall": "npm run clean:node_modules && npm install",
"build": "turbo run build",
"clean": "turbo run clean",
"clean:node_modules": "find . -name 'node_modules' -type d -prune -exec rm -rf '{}' + && find . -name 'package-lock.json' -type f -delete && npm cache clean --force",
"clean:node_modules": "find . -name 'node_modules' -type d -prune -exec rm -rf '{}' +",
"dev": "turbo run dev",
"format": "prettier --write \"**/*.{ts,tsx,md}\"",
"lint": "turbo run lint",
"test": "turbo run test",
"check-types": "turbo run check-types",
"db:check": "turbo run db:check",
"db:generate": "turbo run db:generate",
"db:migrate": "turbo run db:migrate",
"db:pull": "turbo run db:pull",
"db:push": "turbo run db:push",
"db:init": "turbo run db:init",
"db:login": "turbo run db:login",
"db:link": "turbo run db:link",
"db:start": "turbo run db:start",
"db:stop": "turbo run db:stop",
"db:reset": "turbo run db:reset",
"db:reset-local": "turbo run db:reset-local",
"db:seed": "turbo run db:seed"
"check-types": "turbo run check-types"
},
"devDependencies": {
"prettier": "^3.5.3",

View file

@ -1,5 +0,0 @@
# @repo/auth
Helper tools package for Clerk authentication. As of now, this helper package is not being used anywhere in the repo.
Due to Clerk's implementations for different frameworks, each app (Next.js, Remix, etc.) have its own Clerk setup. This package is intended to provide shared utilities and types that can be used across these implementations.

View file

@ -1,3 +0,0 @@
export function hasRole(userRoles: string[], role: string): boolean {
return userRoles.includes(role);
}

View file

@ -1,2 +0,0 @@
export * from "./roles";
export * from "./helpers";

View file

@ -1,12 +0,0 @@
{
"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"
}
}

View file

@ -1,5 +0,0 @@
export const ROLES = Object.freeze({
SUPER_ADMIN: "super_admin",
ADMIN: "admin",
USER: "user",
});

View file

@ -1,14 +0,0 @@
{
"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"]
}

View file

@ -14,8 +14,7 @@
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"forceConsistentCasingInFileNames": true
"noFallthroughCasesInSwitch": true
},
"include": ["src"],
"references": [{ "path": "./tsconfig.node.json" }]

View file

@ -1,5 +0,0 @@
# Drizzle ORM configuration for Supabase PostgreSQL database
DATABASE_URL=
# Required to run supabase cli commands
SUPABASE_DB_PASSWORD=

View file

@ -1,85 +0,0 @@
# @repo/db
Shared database package for the monorepo using `Drizzle ORM` and `Supabase (Postgres)` as the database. This package provides a centralized way to manage database connections, models, migrations, and seeders across all applications in the monorepo.
WIP - Current implementation has no authorization and application logic yet. It is a basic setup to get started with `Drizzle ORM` and `Supabase`.
TODO:
- Configure to work with `Clerk` for authentication and authorization
- Implement application logic for the database models
## What's inside?
This repo uses `Drizzle ORM` for database interactions and includes the following:
### Apps and Packages
- `drizzle-orm`: ORM for TypeScript and JavaScript
- `@drizzle-orm/postgres`: Postgres driver for Drizzle ORM
- `drizzle-kit`: Tool for generating migrations and managing the database schema
- `@faker-js/faker`: Library for generating fake data for seeding the database
### Structure
The database package is structured as follows:
```
src/
├── queries/ # Database queries
│ ├── products.ts # Example product query
│ └── users.ts # Example user query
├── schema/ # Database models
│ ├── column.helpers.ts # Helper functions for column definitions
│ ├── products.ts # Example product models
│ └── user.ts # Example user model
├── seeders/ # Database migration files
│ ├── products.ts # Example product seeder
│ ├── seed.ts # Main seeder file
│ └── user.ts # Example user seeder
├── database.ts # Database connection and initialization
└── index.ts # Database schema exports
supabase/ # Supabase configuration and migrations
├── migrations/ # Database migration files
│ └── *.sql # Example migration file
├── seed.sql # SQL file for seeding the database
└── config.toml # Supabase configuration file
```
### Local Supabase Instance
To run a local Supabase instance, you need to have the Supabase CLI installed
https://supabase.com/docs/guides/local-development/cli/getting-started
Some commands to manage the local Supabase instance:
- `npx supabase init`: Initialize Supabase in the monorepo
- `npx supabase login`: Log in to your Supabase account
- `npx supabase link`: Link the Supabase project to the monorepo
- `npx supbase start`: Start the Supabase local development server using Docker. Docker must be installed and running.
- `npx supabase stop`: Stop the Supabase local development server
### Development Commands
- `turbo run db:check`: Check the database schema against the models
- `turbo run db:generate`: Generate database schema and types
- `turbo run db:migrate`: Run database migrations
- `turbo run db:push`: Push the current schema to the database
- `turbo run db:pull`: Pull the current schema from the database
- `turbo run db:reset`: Reset the online database. Connection to the online Supabase DB with Supabase CLI is required; Database password is required in `.env` file; `turbo run db:generate` must be run before this command to ensure the database schema is up-to-date, as the CLI will look for the migrations sql in the supabase directory.
- `turbo run db:seed`: Seed the database with initial data
### Usage
WIP
To use the database package in your applications, you can import the necessary modules and functions from the `@repo/db` package. For example:
### Known Issues
_Please install latest version of "drizzle-orm"_
- For `npm` users, run `npm install --force drizzle-kit drizzle-orm` in root of monorepo
- remove the entries in `package.json` file and run `npm install` again
- https://github.com/drizzle-team/drizzle-orm/issues/2699#issuecomment-2660850530

View file

@ -1,15 +0,0 @@
import { defineConfig } from "drizzle-kit";
export default defineConfig({
schema: "./src/schema",
out: "./supabase/migrations",
dialect: "postgresql",
entities: {
roles: {
provider: "supabase",
},
},
dbCredentials: {
url: process.env.DATABASE_URL!,
},
});

View file

@ -1,4 +0,0 @@
import { config } from "@repo/eslint-config/react";
/** @type {import("eslint").Linter.Config} */
export default config;

View file

@ -1,39 +0,0 @@
{
"name": "@repo/db",
"version": "0.0.0",
"author": "Afiq Zudin Hadi",
"private": true,
"main": "index.ts",
"license": "MIT",
"type": "module",
"exports": {
"./index": "./src/index.ts"
},
"scripts": {
"lint": "eslint . --ext .ts,.tsx",
"db:check": "npx drizzle-kit check",
"db:generate": "npx drizzle-kit generate",
"db:migrate": "npx drizzle-kit migrate",
"db:pull": "npx drizzle-kit pull",
"db:push": "npx drizzle-kit push",
"db:init": "npx supabase init",
"db:login": "npx supabase login",
"db:link": "npx supabase link",
"db:start": "npx supabase start",
"db:stop": "npx supabase stop",
"db:reset": "npx supabase db reset --linked",
"db:reset-local": "npx supabase db reset",
"db:seed": "tsx --env-file=.env ./src/seeders/seed.tsx"
},
"devDependencies": {
"@faker-js/faker": "^9.8.0",
"@types/react": ">=18",
"drizzle-kit": "^0.31.2",
"tsx": "^4.20.3"
},
"dependencies": {
"drizzle-orm": "^0.44.2",
"postgres": "^3.4.7",
"typescript": "^5.8.2"
}
}

View file

@ -1,25 +0,0 @@
import { drizzle } from "drizzle-orm/postgres-js";
import postgres from "postgres";
import dotenv from "dotenv";
dotenv.config({ path: ".env" });
let db_url = process.env.DATABASE_URL!;
if (!db_url) {
throw new Error("DATABASE_URL is not set in environment variables.");
}
try {
db_url = decodeURI(db_url);
} catch {
console.warn("Failed to decode DATABASE_URL, using as is.");
}
const maskedUrl = db_url.replace(
/(postgresql:\/\/[^:]+:)[^@]+(@[^\/]+)/,
'$1***$2'
);
console.log("Connecting to database URL:", maskedUrl);
const client = postgres(db_url!);
export const db = drizzle(client);

View file

@ -1,2 +0,0 @@
export * from "./queries/users.ts";
export * from "./queries/products.ts";

View file

@ -1,33 +0,0 @@
import { db } from "../database.ts";
import { type InsertProduct, products } from "../schema/products.ts";
import { count, desc } from "drizzle-orm";
export async function createProduct(data: InsertProduct) {
await db.insert(products).values(data);
}
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,
},
};
}

View file

@ -1,20 +0,0 @@
import { db } from "../database.ts";
import { type InsertUser, users } from "../schema/users.ts";
import { eq } from "drizzle-orm";
export async function createUser(data: InsertUser) {
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;
}

View file

@ -1,11 +0,0 @@
import { timestamp } from "drizzle-orm/pg-core";
export const timestamps = {
updatedAt: timestamp("updated_at", { withTimezone: true })
.$onUpdate(() => new Date())
.notNull(),
createdAt: timestamp("created_at", { withTimezone: true })
.defaultNow()
.notNull(),
deletedAt: timestamp("deleted_at", { withTimezone: true }),
};

View file

@ -1,15 +0,0 @@
import { pgTable, serial, text, numeric, boolean } from "drizzle-orm/pg-core";
import { timestamps } from "./column.helpers.ts";
export const products = pgTable("products", {
id: serial("id").primaryKey(),
name: text("name").notNull(),
description: text("description"),
price: numeric("price", { precision: 10, scale: 2 }).notNull(),
imageUrl: text("image_url"),
isPublished: boolean("is_published").default(false),
...timestamps,
});
export type InsertProduct = typeof products.$inferInsert;
export type SelectProduct = typeof products.$inferSelect;

View file

@ -1,13 +0,0 @@
import { pgTable, text } from "drizzle-orm/pg-core";
import { timestamps } from "./column.helpers.ts";
export const users = pgTable("users", {
id: text("id").primaryKey(), // Clerk user ID
email: text("email").notNull(),
name: text("name"),
role: text("role").default("user").notNull(),
...timestamps,
});
export type InsertUser = typeof users.$inferInsert;
export type SelectUser = typeof users.$inferSelect;

View file

@ -1,29 +0,0 @@
import { faker } from "@faker-js/faker";
import { db } from "../database";
import { products } from "../schema/products";
import type { SeedFunction } from "./seed";
type ProductRow = {
id: number;
name: string;
description: string | null;
price: string;
imageUrl: string | null;
isPublished: boolean;
};
export const seedProducts: SeedFunction = async () => {
// generate random data for 10 people to stick into the chefs table
const data: ProductRow[] = Array.from({ length: 10 }, () => ({
id: faker.number.int({ min: 1, max: 10000 }),
name: faker.commerce.productName(),
description: faker.commerce.productDescription(),
price: faker.commerce.price({ min: 1, max: 1000, dec: 2 }),
imageUrl: faker.image.url(),
isPublished: faker.datatype.boolean(),
}));
await db.insert(products).values(data);
return `${data.length} Products seeded successfully`;
};

View file

@ -1,52 +0,0 @@
import { seedProducts } from "./products";
import { seedUsers } from "./users";
const seedDb = async () => {
// note: this function assumes we're starting with an empty database
console.log("Seeding database...");
console.log("Adding independent data...");
const res = await Promise.allSettled([
seedUsers(),
seedProducts(),
// add more independent seeding functions here
// these will run in parallel
]);
res.forEach((result) => {
if (result.status === "rejected") {
console.log("Error seeding database:", result.reason);
} else {
console.log(result.value);
}
});
// console.log("Adding related data...");
// const dependentTasks = [
// seedRecipes,
// // add more dependent tasks here, they will run in this order
// ];
// for (const task of dependentTasks) {
// try {
// const result = await task();
// console.log(result);
// } catch e {
// console.log("Error seeding database:", e);
// }
// }
console.log("Seeding complete!");
};
seedDb()
.then(() => {
console.log("Seeding complete!");
process.exit(0);
})
.catch((err) => {
console.error("Error seeding database:", err);
process.exit(1);
});
export type SeedFunction = () => Promise<string>;

View file

@ -1,23 +0,0 @@
import { faker } from "@faker-js/faker";
import { db } from "../database";
import { users } from "../schema/users";
import type { SeedFunction } from "./seed";
type UserRow = {
id: string;
name: string;
email: string;
};
export const seedUsers: SeedFunction = async () => {
// generate random data for 10 people to stick into the chefs table
const data: UserRow[] = Array.from({ length: 10 }, () => ({
id: faker.number.int({ min: 1, max: 10000 }).toString(),
name: faker.person.fullName(),
email: faker.internet.email(),
}));
await db.insert(users).values(data);
return `${data.length} Users seeded successfully`;
};

View file

@ -1,8 +0,0 @@
# Supabase
.branches
.temp
# dotenvx
.env.keys
.env.local
.env.*.local

View file

@ -1,317 +0,0 @@
# For detailed configuration reference documentation, visit:
# https://supabase.com/docs/guides/local-development/cli/config
# A string used to distinguish different Supabase projects on the same host. Defaults to the
# working directory name when running `supabase init`.
project_id = "db"
[api]
enabled = true
# Port to use for the API URL.
port = 54321
# Schemas to expose in your API. Tables, views and stored procedures in this schema will get API
# endpoints. `public` and `graphql_public` schemas are included by default.
schemas = ["public", "graphql_public"]
# Extra schemas to add to the search_path of every request.
extra_search_path = ["public", "extensions"]
# The maximum number of rows returns from a view, table, or stored procedure. Limits payload size
# for accidental or malicious requests.
max_rows = 1000
[api.tls]
# Enable HTTPS endpoints locally using a self-signed certificate.
enabled = false
[db]
# Port to use for the local database URL.
port = 54322
# Port used by db diff command to initialize the shadow database.
shadow_port = 54320
# The database major version to use. This has to be the same as your remote database's. Run `SHOW
# server_version;` on the remote database to check.
major_version = 17
[db.pooler]
enabled = false
# Port to use for the local connection pooler.
port = 54329
# Specifies when a server connection can be reused by other clients.
# Configure one of the supported pooler modes: `transaction`, `session`.
pool_mode = "transaction"
# How many server connections to allow per user/database pair.
default_pool_size = 20
# Maximum number of client connections allowed.
max_client_conn = 100
# [db.vault]
# secret_key = "env(SECRET_VALUE)"
[db.migrations]
# If disabled, migrations will be skipped during a db push or reset.
enabled = true
# Specifies an ordered list of schema files that describe your database.
# Supports glob patterns relative to supabase directory: "./schemas/*.sql"
schema_paths = []
[db.seed]
# If enabled, seeds the database after migrations during a db reset.
enabled = true
# Specifies an ordered list of seed files to load during db reset.
# Supports glob patterns relative to supabase directory: "./seeds/*.sql"
sql_paths = ["./seed.sql"]
[realtime]
enabled = true
# Bind realtime via either IPv4 or IPv6. (default: IPv4)
# ip_version = "IPv6"
# The maximum length in bytes of HTTP request headers. (default: 4096)
# max_header_length = 4096
[studio]
enabled = true
# Port to use for Supabase Studio.
port = 54323
# External URL of the API server that frontend connects to.
api_url = "http://127.0.0.1"
# OpenAI API Key to use for Supabase AI in the Supabase Studio.
openai_api_key = "env(OPENAI_API_KEY)"
# Email testing server. Emails sent with the local dev setup are not actually sent - rather, they
# are monitored, and you can view the emails that would have been sent from the web interface.
[inbucket]
enabled = true
# Port to use for the email testing server web interface.
port = 54324
# Uncomment to expose additional ports for testing user applications that send emails.
# smtp_port = 54325
# pop3_port = 54326
# admin_email = "admin@email.com"
# sender_name = "Admin"
[storage]
enabled = true
# The maximum file size allowed (e.g. "5MB", "500KB").
file_size_limit = "50MiB"
# Image transformation API is available to Supabase Pro plan.
# [storage.image_transformation]
# enabled = true
# Uncomment to configure local storage buckets
# [storage.buckets.images]
# public = false
# file_size_limit = "50MiB"
# allowed_mime_types = ["image/png", "image/jpeg"]
# objects_path = "./images"
[auth]
enabled = true
# The base URL of your website. Used as an allow-list for redirects and for constructing URLs used
# in emails.
site_url = "http://127.0.0.1:3000"
# A list of *exact* URLs that auth providers are permitted to redirect to post authentication.
additional_redirect_urls = ["https://127.0.0.1:3000"]
# How long tokens are valid for, in seconds. Defaults to 3600 (1 hour), maximum 604,800 (1 week).
jwt_expiry = 3600
# If disabled, the refresh token will never expire.
enable_refresh_token_rotation = true
# Allows refresh tokens to be reused after expiry, up to the specified interval in seconds.
# Requires enable_refresh_token_rotation = true.
refresh_token_reuse_interval = 10
# Allow/disallow new user signups to your project.
enable_signup = true
# Allow/disallow anonymous sign-ins to your project.
enable_anonymous_sign_ins = false
# Allow/disallow testing manual linking of accounts
enable_manual_linking = false
# Passwords shorter than this value will be rejected as weak. Minimum 6, recommended 8 or more.
minimum_password_length = 6
# Passwords that do not meet the following requirements will be rejected as weak. Supported values
# are: `letters_digits`, `lower_upper_letters_digits`, `lower_upper_letters_digits_symbols`
password_requirements = ""
[auth.rate_limit]
# Number of emails that can be sent per hour. Requires auth.email.smtp to be enabled.
email_sent = 2
# Number of SMS messages that can be sent per hour. Requires auth.sms to be enabled.
sms_sent = 30
# Number of anonymous sign-ins that can be made per hour per IP address. Requires enable_anonymous_sign_ins = true.
anonymous_users = 30
# Number of sessions that can be refreshed in a 5 minute interval per IP address.
token_refresh = 150
# Number of sign up and sign-in requests that can be made in a 5 minute interval per IP address (excludes anonymous users).
sign_in_sign_ups = 30
# Number of OTP / Magic link verifications that can be made in a 5 minute interval per IP address.
token_verifications = 30
# Number of Web3 logins that can be made in a 5 minute interval per IP address.
web3 = 30
# Configure one of the supported captcha providers: `hcaptcha`, `turnstile`.
# [auth.captcha]
# enabled = true
# provider = "hcaptcha"
# secret = ""
[auth.email]
# Allow/disallow new user signups via email to your project.
enable_signup = true
# If enabled, a user will be required to confirm any email change on both the old, and new email
# addresses. If disabled, only the new email is required to confirm.
double_confirm_changes = true
# If enabled, users need to confirm their email address before signing in.
enable_confirmations = false
# If enabled, users will need to reauthenticate or have logged in recently to change their password.
secure_password_change = false
# Controls the minimum amount of time that must pass before sending another signup confirmation or password reset email.
max_frequency = "1s"
# Number of characters used in the email OTP.
otp_length = 6
# Number of seconds before the email OTP expires (defaults to 1 hour).
otp_expiry = 3600
# Use a production-ready SMTP server
# [auth.email.smtp]
# enabled = true
# host = "smtp.sendgrid.net"
# port = 587
# user = "apikey"
# pass = "env(SENDGRID_API_KEY)"
# admin_email = "admin@email.com"
# sender_name = "Admin"
# Uncomment to customize email template
# [auth.email.template.invite]
# subject = "You have been invited"
# content_path = "./supabase/templates/invite.html"
[auth.sms]
# Allow/disallow new user signups via SMS to your project.
enable_signup = false
# If enabled, users need to confirm their phone number before signing in.
enable_confirmations = false
# Template for sending OTP to users
template = "Your code is {{ .Code }}"
# Controls the minimum amount of time that must pass before sending another sms otp.
max_frequency = "5s"
# Use pre-defined map of phone number to OTP for testing.
# [auth.sms.test_otp]
# 4152127777 = "123456"
# Configure logged in session timeouts.
# [auth.sessions]
# Force log out after the specified duration.
# timebox = "24h"
# Force log out if the user has been inactive longer than the specified duration.
# inactivity_timeout = "8h"
# This hook runs before a token is issued and allows you to add additional claims based on the authentication method used.
# [auth.hook.custom_access_token]
# enabled = true
# uri = "pg-functions://<database>/<schema>/<hook_name>"
# Configure one of the supported SMS providers: `twilio`, `twilio_verify`, `messagebird`, `textlocal`, `vonage`.
[auth.sms.twilio]
enabled = false
account_sid = ""
message_service_sid = ""
# DO NOT commit your Twilio auth token to git. Use environment variable substitution instead:
auth_token = "env(SUPABASE_AUTH_SMS_TWILIO_AUTH_TOKEN)"
# Multi-factor-authentication is available to Supabase Pro plan.
[auth.mfa]
# Control how many MFA factors can be enrolled at once per user.
max_enrolled_factors = 10
# Control MFA via App Authenticator (TOTP)
[auth.mfa.totp]
enroll_enabled = false
verify_enabled = false
# Configure MFA via Phone Messaging
[auth.mfa.phone]
enroll_enabled = false
verify_enabled = false
otp_length = 6
template = "Your code is {{ .Code }}"
max_frequency = "5s"
# Configure MFA via WebAuthn
# [auth.mfa.web_authn]
# enroll_enabled = true
# verify_enabled = true
# Use an external OAuth provider. The full list of providers are: `apple`, `azure`, `bitbucket`,
# `discord`, `facebook`, `github`, `gitlab`, `google`, `keycloak`, `linkedin_oidc`, `notion`, `twitch`,
# `twitter`, `slack`, `spotify`, `workos`, `zoom`.
[auth.external.apple]
enabled = false
client_id = ""
# DO NOT commit your OAuth provider secret to git. Use environment variable substitution instead:
secret = "env(SUPABASE_AUTH_EXTERNAL_APPLE_SECRET)"
# Overrides the default auth redirectUrl.
redirect_uri = ""
# Overrides the default auth provider URL. Used to support self-hosted gitlab, single-tenant Azure,
# or any other third-party OIDC providers.
url = ""
# If enabled, the nonce check will be skipped. Required for local sign in with Google auth.
skip_nonce_check = false
# Allow Solana wallet holders to sign in to your project via the Sign in with Solana (SIWS, EIP-4361) standard.
# You can configure "web3" rate limit in the [auth.rate_limit] section and set up [auth.captcha] if self-hosting.
[auth.web3.solana]
enabled = false
# Use Firebase Auth as a third-party provider alongside Supabase Auth.
[auth.third_party.firebase]
enabled = false
# project_id = "my-firebase-project"
# Use Auth0 as a third-party provider alongside Supabase Auth.
[auth.third_party.auth0]
enabled = false
# tenant = "my-auth0-tenant"
# tenant_region = "us"
# Use AWS Cognito (Amplify) as a third-party provider alongside Supabase Auth.
[auth.third_party.aws_cognito]
enabled = false
# user_pool_id = "my-user-pool-id"
# user_pool_region = "us-east-1"
# Use Clerk as a third-party provider alongside Supabase Auth.
[auth.third_party.clerk]
enabled = false
# Obtain from https://clerk.com/setup/supabase
# domain = "example.clerk.accounts.dev"
[edge_runtime]
enabled = true
# Configure one of the supported request policies: `oneshot`, `per_worker`.
# Use `oneshot` for hot reload, or `per_worker` for load testing.
policy = "oneshot"
# Port to attach the Chrome inspector for debugging edge functions.
inspector_port = 8083
# The Deno major version to use.
deno_version = 1
# [edge_runtime.secrets]
# secret_key = "env(SECRET_VALUE)"
[analytics]
enabled = true
port = 54327
# Configure one of the supported backends: `postgres`, `bigquery`.
backend = "postgres"
# Experimental features may be deprecated any time
[experimental]
# Configures Postgres storage engine to use OrioleDB (S3)
orioledb_version = ""
# Configures S3 bucket URL, eg. <bucket_name>.s3-<region>.amazonaws.com
s3_host = "env(S3_HOST)"
# Configures S3 bucket region, eg. us-east-1
s3_region = "env(S3_REGION)"
# Configures AWS_ACCESS_KEY_ID for S3 bucket
s3_access_key = "env(S3_ACCESS_KEY)"
# Configures AWS_SECRET_ACCESS_KEY for S3 bucket
s3_secret_key = "env(S3_SECRET_KEY)"

View file

@ -1,21 +0,0 @@
CREATE TABLE "products" (
"id" serial PRIMARY KEY NOT NULL,
"name" text NOT NULL,
"description" text,
"price" numeric(10, 2) NOT NULL,
"image_url" text,
"is_published" boolean DEFAULT false,
"updated_at" timestamp with time zone NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"deleted_at" timestamp with time zone
);
--> statement-breakpoint
CREATE TABLE "users" (
"id" text PRIMARY KEY NOT NULL,
"email" text NOT NULL,
"name" text,
"role" text DEFAULT 'user' NOT NULL,
"updated_at" timestamp with time zone NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"deleted_at" timestamp with time zone
);

View file

@ -1,145 +0,0 @@
{
"id": "b00718fb-5a84-4440-92fd-92d7ebaca1b1",
"prevId": "00000000-0000-0000-0000-000000000000",
"version": "7",
"dialect": "postgresql",
"tables": {
"public.products": {
"name": "products",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "serial",
"primaryKey": true,
"notNull": true
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"description": {
"name": "description",
"type": "text",
"primaryKey": false,
"notNull": false
},
"price": {
"name": "price",
"type": "numeric(10, 2)",
"primaryKey": false,
"notNull": true
},
"image_url": {
"name": "image_url",
"type": "text",
"primaryKey": false,
"notNull": false
},
"is_published": {
"name": "is_published",
"type": "boolean",
"primaryKey": false,
"notNull": false,
"default": false
},
"updated_at": {
"name": "updated_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"deleted_at": {
"name": "deleted_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.users": {
"name": "users",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"email": {
"name": "email",
"type": "text",
"primaryKey": false,
"notNull": true
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": false
},
"role": {
"name": "role",
"type": "text",
"primaryKey": false,
"notNull": true,
"default": "'user'"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"deleted_at": {
"name": "deleted_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
}
},
"enums": {},
"schemas": {},
"sequences": {},
"roles": {},
"policies": {},
"views": {},
"_meta": {
"columns": {},
"schemas": {},
"tables": {}
}
}

View file

@ -1,13 +0,0 @@
{
"version": "7",
"dialect": "postgresql",
"entries": [
{
"idx": 0,
"version": "7",
"when": 1751101339243,
"tag": "0000_awesome_prowler",
"breakpoints": true
}
]
}

View file

@ -1,14 +0,0 @@
{
"extends": "@repo/typescript-config/react-library.json",
"compilerOptions": {
"lib": ["dom", "ES2015"],
"sourceMap": true,
"types": ["jest", "node"],
"baseUrl": ".",
"paths": {
"@repo/db/index.ts": ["./src/index.ts"]
}
},
"include": [".", "src"],
"exclude": ["dist", "build", "node_modules"]
}

View file

@ -1,129 +0,0 @@
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;
imageUrl: string;
isPublished: boolean;
}
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<void> => {
// 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,
imageUrl: data.imageUrl as string,
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 (
<CreateForm
title="Create New Product"
fields={productFields}
onSubmit={handleSubmit}
onCancel={onCancel}
loading={loading}
submitText="Create Product"
cancelText="Cancel"
className={className}
/>
);
}
// Export individual field configurations for reuse
export const productFormFields = productFields;
export type { ProductCreateProps };

View file

@ -1,169 +0,0 @@
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<Product>[] = [
{ 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<Product>[] = [
...(onEdit
? [
{
label: "Edit",
variant: "outline" as const,
onClick: onEdit,
},
]
: []),
...(onDelete
? [
{
label: "Delete",
variant: "destructive" as const,
onClick: (p: Product) => onDelete(p.id),
},
]
: []),
];
return (
<TableShow
record={product}
title={(p) => 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<Product>;
onEdit?: (product: Product) => void;
onDelete?: (productId: string) => void;
onPageChange: (page: number) => void;
loading?: boolean;
className?: string;
}) {
const actions: ActionConfig<Product>[] = [
...(onEdit
? [
{
label: "Edit",
variant: "outline" as const,
onClick: onEdit,
},
]
: []),
...(onDelete
? [
{
label: "Delete",
variant: "destructive" as const,
onClick: (p: Product) => onDelete(p.id),
},
]
: []),
];
return (
<TableGrid
paginatedData={paginatedProducts}
title={(p) => 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<Product>[] = [
...(onEdit
? [
{
label: "Edit",
variant: "outline" as const,
onClick: onEdit,
},
]
: []),
...(onDelete
? [
{
label: "Delete",
variant: "destructive" as const,
onClick: (p: Product) => onDelete(p.id),
},
]
: []),
];
return (
<SimpleTableGrid
records={products}
title={(p) => p.name}
fields={productFields}
actions={actions}
className={className}
statusField="isPublished"
imageField="imageUrl"
emptyMessage="No products found."
/>
);
}

View file

@ -1,359 +0,0 @@
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]: string | number | boolean;
}
interface CreateFormProps {
title: string;
fields: FormField[];
onSubmit: (data: FormData) => Promise<void>;
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<FormData>(() => {
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<Record<string, string>>({});
const [isSubmitting, setIsSubmitting] = useState(false);
const validateField = (
field: FormField,
value: string | number | boolean
): 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<string, string> = {};
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: string | number | boolean
) => {
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: unknown) {
setErrors({
submit:
error instanceof Error
? 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 (
<textarea
className={baseInputClasses}
value={String(formData[field.key] || "")}
onChange={(e) =>
handleInputChange(field.key, e.target.value)
}
disabled={isFieldDisabled}
placeholder={field.placeholder}
rows={3}
/>
);
case "select":
return (
<select
className={baseInputClasses}
value={String(formData[field.key] || "")}
onChange={(e) =>
handleInputChange(field.key, e.target.value)
}
disabled={isFieldDisabled}
>
<option value="">
{field.placeholder || `Select ${field.label}`}
</option>
{field.options?.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
);
case "checkbox":
return (
<div className="flex items-center">
<input
type="checkbox"
className="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded"
checked={Boolean(formData[field.key])}
onChange={(e) =>
handleInputChange(field.key, e.target.checked)
}
disabled={isFieldDisabled}
/>
<label className="ml-2 text-sm text-gray-700">
{field.placeholder || field.label}
</label>
</div>
);
case "number":
return (
<input
type="number"
className={baseInputClasses}
value={
typeof formData[field.key] === "number"
? String(formData[field.key])
: ""
}
onChange={(e) =>
handleInputChange(
field.key,
parseFloat(e.target.value) || 0
)
}
disabled={isFieldDisabled}
placeholder={field.placeholder}
min={field.validation?.min}
max={field.validation?.max}
step={field.type === "number" ? "any" : undefined}
/>
);
default:
return (
<input
type={field.type}
className={baseInputClasses}
value={String(formData[field.key] || "")}
onChange={(e) =>
handleInputChange(field.key, e.target.value)
}
disabled={isFieldDisabled}
placeholder={field.placeholder}
pattern={field.validation?.pattern}
minLength={field.validation?.minLength}
maxLength={field.validation?.maxLength}
/>
);
}
};
return (
<Card className={className}>
<CardHeader>
<CardTitle>{title}</CardTitle>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit} className="space-y-4">
{fields.map((field) => (
<div key={field.key}>
{field.type !== "checkbox" && (
<label className="block text-sm font-medium text-gray-700">
{field.label}
{field.required && (
<span className="text-red-500 ml-1">
*
</span>
)}
</label>
)}
{renderField(field)}
{errors[field.key] && (
<p className="mt-1 text-sm text-red-600">
{errors[field.key]}
</p>
)}
{field.helpText && (
<p className="mt-1 text-sm text-gray-500">
{field.helpText}
</p>
)}
</div>
))}
{errors.submit && (
<div className="text-red-600 text-sm p-2 bg-red-50 rounded">
{errors.submit}
</div>
)}
<div className="flex gap-2 pt-4">
{onCancel && (
<Button
type="button"
variant="outline"
onClick={onCancel}
disabled={isSubmitting}
className="flex-1"
>
{cancelText}
</Button>
)}
<Button
type="submit"
disabled={isSubmitting}
className="flex-1"
>
{isSubmitting ? "Saving..." : submitText}
</Button>
</div>
</form>
</CardContent>
</Card>
);
}
export type { CreateFormProps };

View file

@ -1,493 +0,0 @@
import { Card, CardContent, CardHeader, CardTitle } from "../card";
import { Button } from "../button";
import { cn } from "@repo/ui/lib/utils";
// Base interface that all table records should extend
export interface BaseRecord {
id: string;
created_at?: string;
updated_at?: string;
}
// Pagination information interface
export interface PaginationInfo {
page: number;
limit: number;
totalCount: number;
totalPages: number;
hasNextPage: boolean;
hasPreviousPage: boolean;
}
// Paginated response interface
export interface PaginatedResponse<T> {
data: T[];
pagination: PaginationInfo;
}
// Configuration for how to display fields
export interface FieldConfig<T = any> {
key: keyof T;
label: string;
type?: "text" | "currency" | "date" | "boolean" | "image" | "status";
format?: (value: any) => string;
className?: string;
hide?: boolean;
}
// Configuration for actions
export interface ActionConfig<T = any> {
label: string;
variant?:
| "default"
| "destructive"
| "outline"
| "secondary"
| "ghost"
| "link";
size?: "default" | "sm" | "lg" | "icon";
onClick: (record: T) => void;
className?: string;
show?: (record: T) => boolean;
}
// Pagination controls component
interface PaginationControlsProps {
pagination: PaginationInfo;
onPageChange: (page: number) => void;
loading?: boolean;
className?: string;
}
export function PaginationControls({
pagination,
onPageChange,
loading = false,
className,
}: PaginationControlsProps) {
const {
page,
totalPages,
hasNextPage,
hasPreviousPage,
totalCount,
limit,
} = pagination;
// Calculate visible page numbers
const getVisiblePages = () => {
const delta = 2; // Number of pages to show on each side of current page
const range = [];
const rangeWithDots = [];
for (
let i = Math.max(2, page - delta);
i <= Math.min(totalPages - 1, page + delta);
i++
) {
range.push(i);
}
if (page - delta > 2) {
rangeWithDots.push(1, "...");
} else {
rangeWithDots.push(1);
}
rangeWithDots.push(...range);
if (page + delta < totalPages - 1) {
rangeWithDots.push("...", totalPages);
} else if (totalPages > 1) {
rangeWithDots.push(totalPages);
}
return rangeWithDots;
};
const startItem = (page - 1) * limit + 1;
const endItem = Math.min(page * limit, totalCount);
return (
<div
className={cn(
"flex flex-col sm:flex-row items-center justify-between gap-4",
className
)}
>
{/* Results info */}
<div className="text-sm text-gray-700">
Showing {startItem} to {endItem} of {totalCount} results
</div>
{/* Pagination controls */}
<div className="flex items-center gap-2">
{/* Previous button */}
<Button
variant="outline"
size="sm"
onClick={() => onPageChange(page - 1)}
disabled={!hasPreviousPage || loading}
>
Previous
</Button>
{/* Page numbers */}
<div className="flex items-center gap-1">
{getVisiblePages().map((pageNum, index) => (
<span key={index}>
{pageNum === "..." ? (
<span className="px-2 py-1 text-gray-500">
...
</span>
) : (
<Button
variant={
pageNum === page ? "default" : "outline"
}
size="sm"
onClick={() =>
onPageChange(pageNum as number)
}
disabled={loading}
className="min-w-[2.5rem]"
>
{pageNum}
</Button>
)}
</span>
))}
</div>
{/* Next button */}
<Button
variant="outline"
size="sm"
onClick={() => onPageChange(page + 1)}
disabled={!hasNextPage || loading}
>
Next
</Button>
</div>
</div>
);
}
interface TableShowProps<T extends BaseRecord> {
record: T;
title: string | ((record: T) => string);
fields: FieldConfig<T>[];
actions?: ActionConfig<T>[];
className?: string;
showId?: boolean;
statusField?: keyof T;
imageField?: keyof T;
}
export function TableShow<T extends BaseRecord>({
record,
title,
fields,
actions = [],
className,
showId = true,
statusField,
imageField,
}: TableShowProps<T>) {
const getTitle = () => {
return typeof title === "function" ? title(record) : title;
};
const formatValue = (value: any, field: FieldConfig<T>) => {
if (value === null || value === undefined) return "N/A";
if (field.format) {
return field.format(value);
}
switch (field.type) {
case "currency":
return `$${Number(value).toFixed(2)}`;
case "date":
return new Date(value).toLocaleDateString();
case "boolean":
return value ? "Yes" : "No";
default:
return String(value);
}
};
const getStatusComponent = () => {
if (!statusField) return null;
const status = record[statusField];
const isPublished =
status === true || status === "published" || status === "active";
if (isPublished) return null;
return (
<span className="inline-flex items-center px-2 py-1 text-xs font-medium bg-yellow-100 text-yellow-800 rounded-full">
{status === false || status === "draft"
? "Draft"
: String(status)}
</span>
);
};
const getImageComponent = () => {
if (!imageField) return null;
const imageUrl = record[imageField] as string;
if (!imageUrl) return null;
return (
<div className="w-full h-48 bg-gray-100 rounded-lg overflow-hidden">
<img
src={imageUrl}
alt={getTitle()}
className="w-full h-full object-cover"
/>
</div>
);
};
const visibleActions = actions.filter(
(action) => !action.show || action.show(record)
);
return (
<Card className={cn("w-full max-w-md", className)}>
<CardHeader>
<CardTitle className="text-xl font-bold">
{getTitle()}
</CardTitle>
{getStatusComponent()}
</CardHeader>
<CardContent className="space-y-4">
{getImageComponent()}
<div className="space-y-2">
{fields
.filter((field) => !field.hide)
.map((field) => {
const value = record[field.key];
if (field.type === "image") {
return value ? (
<div
key={String(field.key)}
className="w-full"
>
<img
src={String(value)}
alt={field.label}
className="w-full h-32 object-cover rounded"
/>
</div>
) : null;
}
return (
<div
key={String(field.key)}
className={cn(
"flex justify-between items-start",
field.className
)}
>
<span className="text-sm font-medium text-gray-600">
{field.label}:
</span>
<span
className={cn(
"text-sm",
field.type === "currency" &&
"font-bold text-green-600"
)}
>
{formatValue(value, field)}
</span>
</div>
);
})}
{showId && (
<div className="flex justify-between items-center pt-2 border-t">
<span className="text-xs text-gray-500">ID:</span>
<span className="text-xs text-gray-500 font-mono">
{record.id}
</span>
</div>
)}
</div>
{visibleActions.length > 0 && (
<div className="flex gap-2 pt-4">
{visibleActions.map((action, index) => (
<Button
key={index}
variant={action.variant || "outline"}
size={action.size || "sm"}
onClick={() => action.onClick(record)}
className={cn("flex-1", action.className)}
>
{action.label}
</Button>
))}
</div>
)}
</CardContent>
</Card>
);
}
// Updated Grid version for paginated records
interface TableGridProps<T extends BaseRecord> {
paginatedData: PaginatedResponse<T>;
title: string | ((record: T) => string);
fields: FieldConfig<T>[];
actions?: ActionConfig<T>[];
onPageChange: (page: number) => void;
loading?: boolean;
className?: string;
showId?: boolean;
statusField?: keyof T;
imageField?: keyof T;
emptyMessage?: string;
}
export function TableGrid<T extends BaseRecord>({
paginatedData,
title,
fields,
actions,
onPageChange,
loading = false,
className,
showId,
statusField,
imageField,
emptyMessage = "No records found.",
}: TableGridProps<T>) {
const { data: records, pagination } = paginatedData;
if (records.length === 0 && !loading) {
return (
<div className="space-y-6">
<div className="text-center py-8 text-gray-500">
{emptyMessage}
</div>
{pagination.totalCount > 0 && (
<PaginationControls
pagination={pagination}
onPageChange={onPageChange}
loading={loading}
/>
)}
</div>
);
}
return (
<div className="space-y-6">
{/* Loading state overlay */}
{loading && (
<div className="text-center py-4">
<div className="text-gray-500">Loading...</div>
</div>
)}
{/* Grid of records */}
<div
className={cn(
"grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6",
loading && "opacity-50 pointer-events-none",
className
)}
>
{records.map((record) => (
<TableShow
key={record.id}
record={record}
title={title}
fields={fields}
actions={actions}
showId={showId}
statusField={statusField}
imageField={imageField}
/>
))}
</div>
{/* Pagination controls */}
{pagination.totalPages > 1 && (
<PaginationControls
pagination={pagination}
onPageChange={onPageChange}
loading={loading}
/>
)}
</div>
);
}
// Simple non-paginated grid for backward compatibility
interface SimpleTableGridProps<T extends BaseRecord> {
records: T[];
title: string | ((record: T) => string);
fields: FieldConfig<T>[];
actions?: ActionConfig<T>[];
className?: string;
showId?: boolean;
statusField?: keyof T;
imageField?: keyof T;
emptyMessage?: string;
}
export function SimpleTableGrid<T extends BaseRecord>({
records,
title,
fields,
actions,
className,
showId,
statusField,
imageField,
emptyMessage = "No records found.",
}: SimpleTableGridProps<T>) {
if (records.length === 0) {
return (
<div className="text-center py-8 text-gray-500">{emptyMessage}</div>
);
}
return (
<div
className={cn(
"grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6",
className
)}
>
{records.map((record) => (
<TableShow
key={record.id}
record={record}
title={title}
fields={fields}
actions={actions}
showId={showId}
statusField={statusField}
imageField={imageField}
/>
))}
</div>
);
}
export type {
TableShowProps,
TableGridProps,
SimpleTableGridProps,
PaginationControlsProps,
};

View file

@ -1,69 +0,0 @@
import React from "react";
import {
TableShow,
TableGrid,
FieldConfig,
ActionConfig,
BaseRecord,
} from "../template/table-show";
export interface User extends BaseRecord {
name: string;
email: string;
role: string;
isActive?: boolean;
lastLogin?: string;
}
const userFields: FieldConfig<User>[] = [
{ key: "email", label: "Email", type: "text" },
{ key: "role", label: "Role", type: "text" },
{ key: "lastLogin", label: "Last Login", type: "date" },
];
interface UserShowProps {
user: User;
onEdit?: (user: User) => void;
onDeactivate?: (userId: string) => void;
className?: string;
}
export function UserShow({
user,
onEdit,
onDeactivate,
className,
}: UserShowProps) {
const actions: ActionConfig<User>[] = [
...(onEdit
? [
{
label: "Edit",
variant: "outline" as const,
onClick: onEdit,
},
]
: []),
...(onDeactivate
? [
{
label: "Deactivate",
variant: "destructive" as const,
onClick: (u: User) => onDeactivate(u.id),
show: (u: User) => u.isActive !== false,
},
]
: []),
];
return (
<TableShow
record={user}
title={(u) => u.name}
fields={userFields}
actions={actions}
className={className}
statusField="isActive"
/>
);
}

View file

@ -1,7 +1,6 @@
{
"$schema": "https://turborepo.com/schema.json",
"ui": "tui",
"globalEnv": ["DATABASE_URL"],
"tasks": {
"build": {
"inputs": ["$TURBO_DEFAULT$", ".env*"],
@ -31,52 +30,6 @@
},
"clean": {
"cache": false
},
"db:check": {
"cache": false
},
"db:generate": {
"cache": false,
"persistent": true
},
"db:migrate": {
"cache": false
},
"db:push": {
"cache": false,
"persistent": true
},
"db:pull": {
"cache": false
},
"db:init": {
"cache": false,
"persistent": true
},
"db:login": {
"cache": false,
"persistent": true
},
"db:link": {
"cache": false,
"persistent": true
},
"db:start": {
"cache": false,
"persistent": true
},
"db:stop": {
"cache": false
},
"db:reset": {
"cache": false,
"persistent": true
},
"db:reset-local": {
"cache": false
},
"db:seed": {
"cache": false
}
}
}