Compare commits
32 commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4a951eee55 | |||
| 7d6bbc3a6a | |||
| b88f4a21f4 | |||
| 7d4b49a0e5 | |||
| 90cfaca2d8 | |||
| 806636622a | |||
| b9e531e24c | |||
| f30288f9ac | |||
| cca56420af | |||
| f5a12d3dd1 | |||
| 2585af6a28 | |||
| 3f9b0d57d1 | |||
| ff267f7530 | |||
| 082bd78bf7 | |||
| 7045e582c7 | |||
| 6cb6eb503b | |||
| 7d3ce5c604 | |||
| 9e418621e0 | |||
| 5d92336500 | |||
| 2048d539d3 | |||
| aca6a4da98 | |||
| fc3a21263a | |||
| 52bfa28fcd | |||
| a6fc74e851 | |||
| 0b1cbef59e | |||
| 2a199f3679 | |||
| eb1dec1d40 | |||
| 3fcf8da406 | |||
| a5faf21953 | |||
| 14ef2719c7 | |||
| da8aaa1db6 | |||
| e70d4d34c2 |
73 changed files with 6400 additions and 1397 deletions
1
.npmrc
1
.npmrc
|
|
@ -1 +0,0 @@
|
||||||
auto-install-peers = true
|
|
||||||
22
README.md
22
README.md
|
|
@ -21,6 +21,7 @@ This Turborepo includes the following packages and apps:
|
||||||
- `@repo/ui`: shared UI components for frontend use.
|
- `@repo/ui`: shared UI components for frontend use.
|
||||||
- A dummy React UI library (which contains `<CounterButton>` and `<Link>` components)
|
- A dummy React UI library (which contains `<CounterButton>` and `<Link>` components)
|
||||||
- And shadcn/ui components (which contains `<Button>` and `<Card>` 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/).
|
Each package and app is 100% [TypeScript](https://www.typescriptlang.org/).
|
||||||
|
|
||||||
|
|
@ -34,3 +35,24 @@ This Turborepo has some additional tools already setup for you:
|
||||||
- [Prettier](https://prettier.io) for code formatting
|
- [Prettier](https://prettier.io) for code formatting
|
||||||
- [shadcn/ui](https://ui.shadcn.com/docs/monorepo) for UI components
|
- [shadcn/ui](https://ui.shadcn.com/docs/monorepo) for UI components
|
||||||
- [Tailwind CSS v4](https://tailwindcss.com/) for styling and to use shadcn/ui
|
- [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
|
||||||
|
```
|
||||||
|
|
|
||||||
5
TODO.md
Normal file
5
TODO.md
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
# TODO
|
||||||
|
|
||||||
|
- [ ] Permissions
|
||||||
|
- [ ] Stripe integration
|
||||||
|
- [ ] Docker for deployment
|
||||||
2
apps/express-template/.env.example
Normal file
2
apps/express-template/.env.example
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
# Drizzle ORM configuration for Supabase PostgreSQL database
|
||||||
|
DATABASE_URL=
|
||||||
|
|
@ -15,6 +15,7 @@
|
||||||
"preset": "@repo/jest-presets/node"
|
"preset": "@repo/jest-presets/node"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@repo/db": "*",
|
||||||
"@repo/logger": "*",
|
"@repo/logger": "*",
|
||||||
"body-parser": "^1.20.3",
|
"body-parser": "^1.20.3",
|
||||||
"cors": "^2.8.5",
|
"cors": "^2.8.5",
|
||||||
|
|
|
||||||
43
apps/express-template/src/routes/products.ts
Normal file
43
apps/express-template/src/routes/products.ts
Normal file
|
|
@ -0,0 +1,43 @@
|
||||||
|
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;
|
||||||
82
apps/express-template/src/routes/users.ts
Normal file
82
apps/express-template/src/routes/users.ts
Normal file
|
|
@ -0,0 +1,82 @@
|
||||||
|
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;
|
||||||
|
|
@ -2,21 +2,25 @@ import { json, urlencoded } from "body-parser";
|
||||||
import express, { type Express } from "express";
|
import express, { type Express } from "express";
|
||||||
import morgan from "morgan";
|
import morgan from "morgan";
|
||||||
import cors from "cors";
|
import cors from "cors";
|
||||||
|
import productRoutes from "./routes/products";
|
||||||
|
import userRoutes from "./routes/users";
|
||||||
|
|
||||||
export const createServer = (): Express => {
|
export const createServer = (): Express => {
|
||||||
const app = express();
|
const app = express();
|
||||||
app
|
app.disable("x-powered-by")
|
||||||
.disable("x-powered-by")
|
.use(morgan("dev"))
|
||||||
.use(morgan("dev"))
|
.use(urlencoded({ extended: true }))
|
||||||
.use(urlencoded({ extended: true }))
|
.use(json())
|
||||||
.use(json())
|
.use(cors())
|
||||||
.use(cors())
|
|
||||||
.get("/message/:name", (req, res) => {
|
|
||||||
return res.json({ message: `hello ${req.params.name}` });
|
|
||||||
})
|
|
||||||
.get("/status", (_, res) => {
|
|
||||||
return res.json({ ok: true });
|
|
||||||
});
|
|
||||||
|
|
||||||
return app;
|
.get("/message/:name", (req, res) => {
|
||||||
|
return res.json({ message: `hello ${req.params.name}` });
|
||||||
|
})
|
||||||
|
.get("/status", (_, res) => {
|
||||||
|
return res.json({ ok: true });
|
||||||
|
})
|
||||||
|
|
||||||
|
.use("/api/products", productRoutes)
|
||||||
|
.use("/api/users", userRoutes);
|
||||||
|
return app;
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,12 @@
|
||||||
{
|
{
|
||||||
"extends": "@repo/typescript-config/base.json",
|
"extends": "@repo/typescript-config/base.json",
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"lib": ["ES2015"],
|
"lib": ["ES2015"],
|
||||||
"outDir": "./dist"
|
"outDir": "./dist",
|
||||||
},
|
"paths": {
|
||||||
"exclude": ["node_modules"],
|
"@repo/db/*": ["../../packages/db/src/*"]
|
||||||
"include": ["."]
|
}
|
||||||
|
},
|
||||||
|
"exclude": ["node_modules"],
|
||||||
|
"include": ["."]
|
||||||
}
|
}
|
||||||
|
|
|
||||||
5
apps/next-template/.env.example
Normal file
5
apps/next-template/.env.example
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY=
|
||||||
|
CLERK_SECRET_KEY=
|
||||||
|
|
||||||
|
# Drizzle ORM configuration for Supabase PostgreSQL database
|
||||||
|
DATABASE_URL=
|
||||||
3
apps/next-template/.gitignore
vendored
Normal file
3
apps/next-template/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
|
||||||
|
# clerk configuration (can include secrets)
|
||||||
|
/.clerk/
|
||||||
|
|
@ -12,7 +12,9 @@
|
||||||
"clean": "rm -rf .next"
|
"clean": "rm -rf .next"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@clerk/nextjs": "^6.22.0",
|
||||||
"@repo/logger": "*",
|
"@repo/logger": "*",
|
||||||
|
"@repo/db": "*",
|
||||||
"@repo/ui": "*",
|
"@repo/ui": "*",
|
||||||
"next": "^15.3.0",
|
"next": "^15.3.0",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
|
|
|
||||||
162
apps/next-template/src/app/api/products/route.tsx
Normal file
162
apps/next-template/src/app/api/products/route.tsx
Normal file
|
|
@ -0,0 +1,162 @@
|
||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { getProducts, createProduct } from "@repo/db/index";
|
||||||
|
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const { searchParams } = new URL(request.url);
|
||||||
|
const page = parseInt(searchParams.get("page") || "1");
|
||||||
|
const limit = parseInt(searchParams.get("limit") || "10");
|
||||||
|
|
||||||
|
// Validate pagination parameters
|
||||||
|
if (page < 1 || limit < 1 || limit > 100) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
error: "Invalid pagination parameters. Page must be >= 1, limit must be 1-100",
|
||||||
|
},
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await getProducts(page, limit);
|
||||||
|
return NextResponse.json(result);
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error("API Error:", error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Failed to fetch products", message: error.message },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const body = await request.json();
|
||||||
|
const { name, description, price, imageUrl, isPublished } = body;
|
||||||
|
|
||||||
|
// Enhanced validation
|
||||||
|
if (!name || typeof name !== "string" || !name.trim()) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
error: "Product name is required and must be a non-empty string",
|
||||||
|
},
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (price === undefined || price === null || isNaN(Number(price))) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Price is required and must be a valid number" },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const priceValue = Number(price);
|
||||||
|
if (priceValue < 0) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Price must be non-negative" },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate optional fields
|
||||||
|
if (description && typeof description !== "string") {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Description must be a string" },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (imageUrl && typeof imageUrl !== "string") {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Image URL must be a string" },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate URL format if provided
|
||||||
|
if (imageUrl && imageUrl.trim()) {
|
||||||
|
try {
|
||||||
|
new URL(imageUrl.trim());
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Image URL must be a valid URL" },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prepare product data
|
||||||
|
const productData = {
|
||||||
|
name: name.trim(),
|
||||||
|
description: description?.trim() || null,
|
||||||
|
price: priceValue,
|
||||||
|
imageUrl: imageUrl?.trim() || null,
|
||||||
|
isPublished: Boolean(isPublished),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Create product in database
|
||||||
|
const newProduct = await createProduct(productData);
|
||||||
|
|
||||||
|
// Return success response with created product
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
success: true,
|
||||||
|
message: "Product created successfully",
|
||||||
|
data: newProduct,
|
||||||
|
},
|
||||||
|
{ status: 201 }
|
||||||
|
);
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error("API Error:", error);
|
||||||
|
|
||||||
|
// Handle specific database errors
|
||||||
|
if (error.message?.includes("duplicate") || error.code === "23505") {
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
success: false,
|
||||||
|
error: "Product already exists",
|
||||||
|
message: "A product with this name already exists",
|
||||||
|
},
|
||||||
|
{ status: 409 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle database connection errors
|
||||||
|
if (error.code === "ENOTFOUND" || error.code === "ECONNREFUSED") {
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
success: false,
|
||||||
|
error: "Database connection failed",
|
||||||
|
message: "Unable to connect to database",
|
||||||
|
},
|
||||||
|
{ status: 503 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle validation errors from database
|
||||||
|
if (error.code === "23502") {
|
||||||
|
// NOT NULL violation
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
success: false,
|
||||||
|
error: "Missing required field",
|
||||||
|
message: "A required field is missing",
|
||||||
|
},
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generic error response
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
success: false,
|
||||||
|
error: "Failed to create product",
|
||||||
|
message: error.message || "An unexpected error occurred",
|
||||||
|
...(process.env.NODE_ENV === "development" && {
|
||||||
|
stack: error.stack,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
23
apps/next-template/src/app/clerk/page.tsx
Normal file
23
apps/next-template/src/app/clerk/page.tsx
Normal file
|
|
@ -0,0 +1,23 @@
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,5 +1,14 @@
|
||||||
import "@repo/ui/styles/styles.css";
|
import "@repo/ui/styles/styles.css";
|
||||||
import "@repo/ui/styles/global.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({
|
export default function RootLayout({
|
||||||
children,
|
children,
|
||||||
|
|
@ -7,8 +16,24 @@ export default function RootLayout({
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<html lang="en">
|
<ClerkProvider>
|
||||||
<body>{children}</body>
|
<html lang="en">
|
||||||
</html>
|
<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>
|
||||||
|
</html>
|
||||||
|
</ClerkProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
67
apps/next-template/src/app/product/create-form.tsx
Normal file
67
apps/next-template/src/app/product/create-form.tsx
Normal file
|
|
@ -0,0 +1,67 @@
|
||||||
|
"use client";
|
||||||
|
import React, { useState } from "react";
|
||||||
|
import {
|
||||||
|
ProductCreate,
|
||||||
|
ProductCreateData,
|
||||||
|
} from "@repo/ui/components/products/create";
|
||||||
|
import { Button } from "@repo/ui/components/button";
|
||||||
|
|
||||||
|
interface CreateFormProps {
|
||||||
|
onProductCreated?: () => void;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const CreateForm: React.FC<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;
|
||||||
166
apps/next-template/src/app/product/page.client.tsx
Normal file
166
apps/next-template/src/app/product/page.client.tsx
Normal file
|
|
@ -0,0 +1,166 @@
|
||||||
|
"use client";
|
||||||
|
import React, { useState, useCallback } from "react";
|
||||||
|
import { ProductGrid } from "@repo/ui/components/products/index";
|
||||||
|
import { PaginatedResponse } from "@repo/ui/components/template/table-show";
|
||||||
|
import { Button } from "@repo/ui/components/button";
|
||||||
|
import CreateForm from "./create-form";
|
||||||
|
|
||||||
|
interface Product {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
price: number;
|
||||||
|
imageUrl?: string;
|
||||||
|
isPublished?: boolean;
|
||||||
|
created_at?: string;
|
||||||
|
updated_at?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PageClientProps {
|
||||||
|
initialPaginatedProducts: PaginatedResponse<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;
|
||||||
53
apps/next-template/src/app/product/page.tsx
Normal file
53
apps/next-template/src/app/product/page.tsx
Normal file
|
|
@ -0,0 +1,53 @@
|
||||||
|
import React from "react";
|
||||||
|
import { getProducts } from "@repo/db/index";
|
||||||
|
import PageClient from "./page.client";
|
||||||
|
import { Product } from "@repo/ui/components/products";
|
||||||
|
import { PaginatedResponse } from "@repo/ui/components/template/table-show";
|
||||||
|
|
||||||
|
export default async function ProductPage() {
|
||||||
|
// Fetch initial data on the server (first page)
|
||||||
|
let initialPaginatedProducts: PaginatedResponse<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>
|
||||||
|
);
|
||||||
|
}
|
||||||
28
apps/next-template/src/middleware.ts
Normal file
28
apps/next-template/src/middleware.ts
Normal file
|
|
@ -0,0 +1,28 @@
|
||||||
|
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)(.*)",
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
@ -10,7 +10,8 @@
|
||||||
],
|
],
|
||||||
"paths": {
|
"paths": {
|
||||||
"~/*": ["./src/*"],
|
"~/*": ["./src/*"],
|
||||||
"@repo/ui/*": ["../../packages/ui/src/*"]
|
"@repo/ui/*": ["../../packages/ui/src/*"],
|
||||||
|
"@repo/db/*": ["../../packages/db/src/*"]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"include": ["src", "next.config.ts", "next-env.d.ts", ".next/types/**/*.ts"]
|
"include": ["src", "next.config.ts", "next-env.d.ts", ".next/types/**/*.ts"]
|
||||||
|
|
|
||||||
2
apps/remix-template/.env.example
Normal file
2
apps/remix-template/.env.example
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
CLERK_PUBLISHABLE_KEY=
|
||||||
|
CLERK_SECRET_KEY=
|
||||||
|
|
@ -6,9 +6,15 @@ import {
|
||||||
ScrollRestoration,
|
ScrollRestoration,
|
||||||
} from "@remix-run/react";
|
} from "@remix-run/react";
|
||||||
import { Analytics } from "@vercel/analytics/react";
|
import { Analytics } from "@vercel/analytics/react";
|
||||||
|
|
||||||
import "@repo/ui/styles/styles.css";
|
import "@repo/ui/styles/styles.css";
|
||||||
import "@repo/ui/styles/global.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 }) {
|
export function Layout({ children }: { children: React.ReactNode }) {
|
||||||
return (
|
return (
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
|
|
@ -31,6 +37,8 @@ export function Layout({ children }: { children: React.ReactNode }) {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function App() {
|
export function App() {
|
||||||
return <Outlet />;
|
return <Outlet />;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export default ClerkApp(App);
|
||||||
|
|
|
||||||
124
apps/remix-template/app/routes/api.products.tsx
Normal file
124
apps/remix-template/app/routes/api.products.tsx
Normal file
|
|
@ -0,0 +1,124 @@
|
||||||
|
import type { ActionFunctionArgs, LoaderFunctionArgs } from "@remix-run/node";
|
||||||
|
import { getProducts, createProduct } from "@repo/db/index";
|
||||||
|
|
||||||
|
export async function loader({ request }: LoaderFunctionArgs) {
|
||||||
|
try {
|
||||||
|
const url = new URL(request.url);
|
||||||
|
const page = parseInt(url.searchParams.get("page") || "1");
|
||||||
|
const limit = parseInt(url.searchParams.get("limit") || "10");
|
||||||
|
|
||||||
|
if (page < 1 || limit < 1 || limit > 100) {
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({
|
||||||
|
error: "Invalid pagination parameters. Page must be >= 1, limit must be 1-100",
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
status: 400,
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await getProducts(page, limit);
|
||||||
|
return new Response(JSON.stringify(result), {
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
});
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error("API Error:", error);
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({
|
||||||
|
error: "Failed to fetch products",
|
||||||
|
message: error.message,
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
status: 500,
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function action({ request }: ActionFunctionArgs) {
|
||||||
|
if (request.method !== "POST") {
|
||||||
|
return new Response(JSON.stringify({ error: "Method not allowed" }), {
|
||||||
|
status: 405,
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const body = await request.json();
|
||||||
|
const { name, description, price, imageUrl, isPublished } = body;
|
||||||
|
|
||||||
|
// Validation
|
||||||
|
if (!name || typeof name !== "string" || !name.trim()) {
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({
|
||||||
|
error: "Product name is required and must be a non-empty string",
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
status: 400,
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (price === undefined || price === null || isNaN(Number(price))) {
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({
|
||||||
|
error: "Price is required and must be a valid number",
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
status: 400,
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const priceValue = Number(price);
|
||||||
|
if (priceValue < 0) {
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({ error: "Price must be non-negative" }),
|
||||||
|
{
|
||||||
|
status: 400,
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const productData = {
|
||||||
|
name: name.trim(),
|
||||||
|
description: description?.trim() || null,
|
||||||
|
price: priceValue.toString(),
|
||||||
|
imageUrl: imageUrl?.trim() || null,
|
||||||
|
isPublished: Boolean(isPublished),
|
||||||
|
};
|
||||||
|
|
||||||
|
const newProduct = await createProduct(productData);
|
||||||
|
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({
|
||||||
|
success: true,
|
||||||
|
message: "Product created successfully",
|
||||||
|
data: newProduct,
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
status: 201,
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
}
|
||||||
|
);
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error("API Error:", error);
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({
|
||||||
|
success: false,
|
||||||
|
error: "Failed to create product",
|
||||||
|
message: error.message || "An unexpected error occurred",
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
status: 500,
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
40
apps/remix-template/app/routes/clerk._index.tsx
Normal file
40
apps/remix-template/app/routes/clerk._index.tsx
Normal file
|
|
@ -0,0 +1,40 @@
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
24
apps/remix-template/app/routes/clerk.tsx
Normal file
24
apps/remix-template/app/routes/clerk.tsx
Normal file
|
|
@ -0,0 +1,24 @@
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
209
apps/remix-template/app/routes/product.tsx
Normal file
209
apps/remix-template/app/routes/product.tsx
Normal file
|
|
@ -0,0 +1,209 @@
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -11,10 +11,12 @@
|
||||||
"lint": "eslint app/ --max-warnings 0"
|
"lint": "eslint app/ --max-warnings 0"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@clerk/remix": "^4.8.1",
|
||||||
"@radix-ui/react-slot": "^1.2.3",
|
"@radix-ui/react-slot": "^1.2.3",
|
||||||
"@remix-run/node": "^2.15.2",
|
"@remix-run/node": "^2.15.2",
|
||||||
"@remix-run/react": "^2.15.2",
|
"@remix-run/react": "^2.15.2",
|
||||||
"@remix-run/server-runtime": "^2.15.2",
|
"@remix-run/server-runtime": "^2.15.2",
|
||||||
|
"@repo/db": "*",
|
||||||
"@repo/ui": "*",
|
"@repo/ui": "*",
|
||||||
"@vercel/analytics": "^1.5.0",
|
"@vercel/analytics": "^1.5.0",
|
||||||
"@vercel/remix": "2.16.6",
|
"@vercel/remix": "2.16.6",
|
||||||
|
|
@ -25,8 +27,8 @@
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@remix-run/dev": "^2.15.2",
|
"@remix-run/dev": "^2.15.2",
|
||||||
"@repo/eslint-config": "*",
|
"@repo/eslint-config": "*",
|
||||||
"@types/react": "^18.3.18",
|
"@types/react": "^19.0.10",
|
||||||
"@types/react-dom": "^18.3.5",
|
"@types/react-dom": "^19.0.4",
|
||||||
"@typescript-eslint/eslint-plugin": "^8.34.0",
|
"@typescript-eslint/eslint-plugin": "^8.34.0",
|
||||||
"@typescript-eslint/parser": "^8.34.0",
|
"@typescript-eslint/parser": "^8.34.0",
|
||||||
"autoprefixer": "^10.4.20",
|
"autoprefixer": "^10.4.20",
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,8 @@
|
||||||
"skipLibCheck": true,
|
"skipLibCheck": true,
|
||||||
"paths": {
|
"paths": {
|
||||||
"~/*": ["./app/*"],
|
"~/*": ["./app/*"],
|
||||||
"@repo/ui/*": ["../../packages/ui/src/*"]
|
"@repo/ui/*": ["../../packages/ui/src/*"],
|
||||||
|
"@repo/db/*": ["../../packages/db/src/*"]
|
||||||
},
|
},
|
||||||
|
|
||||||
// Vite takes care of building everything, not tsc.
|
// Vite takes care of building everything, not tsc.
|
||||||
|
|
|
||||||
|
|
@ -7,5 +7,13 @@ import tsconfigPaths from "vite-tsconfig-paths";
|
||||||
installGlobals();
|
installGlobals();
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [remix({ presets: [vercelPreset()] }), tsconfigPaths()],
|
plugins: [
|
||||||
|
remix({
|
||||||
|
presets: [vercelPreset()],
|
||||||
|
future: {
|
||||||
|
v3_singleFetch: true,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
tsconfigPaths(),
|
||||||
|
],
|
||||||
});
|
});
|
||||||
|
|
|
||||||
3
apps/vite-template/.env.example
Normal file
3
apps/vite-template/.env.example
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
VITE_CLERK_PUBLISHABLE_KEY=
|
||||||
|
|
||||||
|
VITE_API_BASE_URL=http://localhost:5001
|
||||||
|
|
@ -10,6 +10,7 @@
|
||||||
"lint": "eslint src/ --max-warnings 0"
|
"lint": "eslint src/ --max-warnings 0"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@clerk/clerk-react": "^5.32.0",
|
||||||
"@repo/ui": "*",
|
"@repo/ui": "*",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-dom": "^18.3.1"
|
"react-dom": "^18.3.1"
|
||||||
|
|
@ -17,8 +18,8 @@
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@repo/eslint-config": "*",
|
"@repo/eslint-config": "*",
|
||||||
"@repo/typescript-config": "*",
|
"@repo/typescript-config": "*",
|
||||||
"@types/react": "^18.3.18",
|
"@types/react": "^19.0.10",
|
||||||
"@types/react-dom": "^18.3.5",
|
"@types/react-dom": "^19.0.4",
|
||||||
"@vitejs/plugin-react": "^4.5.1",
|
"@vitejs/plugin-react": "^4.5.1",
|
||||||
"eslint": "^9.29.0",
|
"eslint": "^9.29.0",
|
||||||
"typescript": "5.8.2",
|
"typescript": "5.8.2",
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,8 @@ import "@repo/ui/styles/styles.css";
|
||||||
import "@repo/ui/styles/global.css";
|
import "@repo/ui/styles/global.css";
|
||||||
import { CounterButton } from "@repo/ui/counter-button";
|
import { CounterButton } from "@repo/ui/counter-button";
|
||||||
import { Link } from "@repo/ui/link";
|
import { Link } from "@repo/ui/link";
|
||||||
|
import ProductForm from "./ProductForm";
|
||||||
|
import ProductIndex from "./ProductIndex";
|
||||||
|
|
||||||
import { Button } from "@repo/ui/components/button";
|
import { Button } from "@repo/ui/components/button";
|
||||||
import {
|
import {
|
||||||
|
|
@ -10,33 +12,69 @@ import {
|
||||||
CardHeader,
|
CardHeader,
|
||||||
CardTitle,
|
CardTitle,
|
||||||
} from "@repo/ui/components/card";
|
} from "@repo/ui/components/card";
|
||||||
|
import {
|
||||||
|
SignedIn,
|
||||||
|
SignedOut,
|
||||||
|
SignInButton,
|
||||||
|
UserButton,
|
||||||
|
} from "@clerk/clerk-react";
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
return (
|
return (
|
||||||
<div className="container">
|
<div>
|
||||||
<h1 className="title">
|
<header className="flex items-center justify-end py-4 px-6 bg-white shadow-sm mb-6">
|
||||||
Admin <br />
|
<div>
|
||||||
<span>Kitchen Sink</span>
|
<SignedOut>
|
||||||
</h1>
|
<SignInButton mode="modal">
|
||||||
<CounterButton />
|
<Button variant="outline">Sign In</Button>
|
||||||
<Card className="my-6">
|
</SignInButton>
|
||||||
<CardHeader>
|
</SignedOut>
|
||||||
<CardTitle>shadcn/ui Card Example</CardTitle>
|
<SignedIn>
|
||||||
</CardHeader>
|
<UserButton />
|
||||||
<CardContent>
|
</SignedIn>
|
||||||
<Button>shadcn/ui Button</Button>
|
</div>
|
||||||
</CardContent>
|
</header>
|
||||||
</Card>
|
|
||||||
<p className="description">
|
<div className="container">
|
||||||
Built With{" "}
|
<h1 className="title">
|
||||||
<Link href="https://turborepo.com" newTab>
|
Admin <br />
|
||||||
Turborepo
|
<span>Kitchen Sink</span>
|
||||||
</Link>
|
</h1>
|
||||||
{" & "}
|
<CounterButton />
|
||||||
<Link href="https://vitejs.dev/" newTab>
|
|
||||||
Vite
|
<Card className="my-6">
|
||||||
</Link>
|
<CardHeader>
|
||||||
</p>
|
<CardTitle>shadcn/ui Card Example</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<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>
|
||||||
|
Turborepo
|
||||||
|
</Link>
|
||||||
|
{" & "}
|
||||||
|
<Link href="https://vitejs.dev/" newTab>
|
||||||
|
Vite
|
||||||
|
</Link>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<ProductForm />
|
||||||
|
<ProductIndex />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
76
apps/vite-template/src/app/productForm.tsx
Normal file
76
apps/vite-template/src/app/productForm.tsx
Normal file
|
|
@ -0,0 +1,76 @@
|
||||||
|
import React, { useState } from "react";
|
||||||
|
import {
|
||||||
|
ProductCreate,
|
||||||
|
ProductCreateData,
|
||||||
|
} from "@repo/ui/components/products/create";
|
||||||
|
|
||||||
|
interface ProductFormProps {
|
||||||
|
onSuccess?: (product: ProductCreateData) => void;
|
||||||
|
onCancel?: () => void;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ProductForm: React.FC<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;
|
||||||
155
apps/vite-template/src/app/productIndex.tsx
Normal file
155
apps/vite-template/src/app/productIndex.tsx
Normal file
|
|
@ -0,0 +1,155 @@
|
||||||
|
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;
|
||||||
|
|
@ -2,15 +2,26 @@ import React from "react";
|
||||||
import { createRoot } from "react-dom/client";
|
import { createRoot } from "react-dom/client";
|
||||||
import App from "./app";
|
import App from "./app";
|
||||||
import "./index.css";
|
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");
|
const el = document.getElementById("root");
|
||||||
if (el) {
|
if (el) {
|
||||||
const root = createRoot(el);
|
const root = createRoot(el);
|
||||||
root.render(
|
root.render(
|
||||||
<React.StrictMode>
|
<React.StrictMode>
|
||||||
<App />
|
<ClerkProvider publishableKey={PUBLISHABLE_KEY} afterSignOutUrl="/">
|
||||||
</React.StrictMode>
|
<App />
|
||||||
);
|
</ClerkProvider>
|
||||||
|
</React.StrictMode>
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
throw new Error("Could not find root element");
|
throw new Error("Could not find root element");
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,28 +1,11 @@
|
||||||
{
|
{
|
||||||
|
"extends": "@repo/typescript-config/vite.json",
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"target": "ES2020",
|
"types": ["vite/client"],
|
||||||
"useDefineForClassFields": true,
|
|
||||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
|
||||||
"module": "ESNext",
|
|
||||||
"skipLibCheck": true,
|
|
||||||
"paths": {
|
"paths": {
|
||||||
"~/*": ["./src/*"],
|
"~/*": ["./src/*"],
|
||||||
"@repo/ui/*": ["../../packages/ui/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"],
|
"include": ["src"],
|
||||||
"references": [{ "path": "./tsconfig.node.json" }]
|
"references": [{ "path": "./tsconfig.node.json" }]
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,10 @@
|
||||||
{
|
{
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"composite": true,
|
"composite": true,
|
||||||
"skipLibCheck": true,
|
"skipLibCheck": true,
|
||||||
"module": "ESNext",
|
"module": "ESNext",
|
||||||
"moduleResolution": "bundler",
|
"moduleResolution": "bundler",
|
||||||
"allowSyntheticDefaultImports": true
|
"allowSyntheticDefaultImports": true
|
||||||
},
|
},
|
||||||
"include": ["vite.config.ts"]
|
"include": ["vite.config.ts"]
|
||||||
}
|
}
|
||||||
|
|
|
||||||
3979
package-lock.json
generated
3979
package-lock.json
generated
File diff suppressed because it is too large
Load diff
18
package.json
18
package.json
|
|
@ -2,14 +2,28 @@
|
||||||
"name": "ecommerce-template",
|
"name": "ecommerce-template",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
"reinstall": "npm run clean:node_modules && npm install",
|
||||||
"build": "turbo run build",
|
"build": "turbo run build",
|
||||||
"clean": "turbo run clean",
|
"clean": "turbo run clean",
|
||||||
"clean:node_modules": "find . -name 'node_modules' -type d -prune -exec rm -rf '{}' +",
|
"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",
|
||||||
"dev": "turbo run dev",
|
"dev": "turbo run dev",
|
||||||
"format": "prettier --write \"**/*.{ts,tsx,md}\"",
|
"format": "prettier --write \"**/*.{ts,tsx,md}\"",
|
||||||
"lint": "turbo run lint",
|
"lint": "turbo run lint",
|
||||||
"test": "turbo run test",
|
"test": "turbo run test",
|
||||||
"check-types": "turbo run check-types"
|
"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"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"prettier": "^3.5.3",
|
"prettier": "^3.5.3",
|
||||||
|
|
|
||||||
5
packages/auth/README.md
Normal file
5
packages/auth/README.md
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
# @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.
|
||||||
3
packages/auth/helpers.ts
Normal file
3
packages/auth/helpers.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
export function hasRole(userRoles: string[], role: string): boolean {
|
||||||
|
return userRoles.includes(role);
|
||||||
|
}
|
||||||
2
packages/auth/index.ts
Normal file
2
packages/auth/index.ts
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
export * from "./roles";
|
||||||
|
export * from "./helpers";
|
||||||
12
packages/auth/package.json
Normal file
12
packages/auth/package.json
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
{
|
||||||
|
"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"
|
||||||
|
}
|
||||||
|
}
|
||||||
5
packages/auth/roles.ts
Normal file
5
packages/auth/roles.ts
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
export const ROLES = Object.freeze({
|
||||||
|
SUPER_ADMIN: "super_admin",
|
||||||
|
ADMIN: "admin",
|
||||||
|
USER: "user",
|
||||||
|
});
|
||||||
14
packages/auth/tsconfig.json
Normal file
14
packages/auth/tsconfig.json
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
{
|
||||||
|
"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"]
|
||||||
|
}
|
||||||
|
|
@ -1,21 +1,22 @@
|
||||||
{
|
{
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"target": "ES2020",
|
"target": "ES2020",
|
||||||
"useDefineForClassFields": true,
|
"useDefineForClassFields": true,
|
||||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||||
"module": "ESNext",
|
"module": "ESNext",
|
||||||
"skipLibCheck": true,
|
"skipLibCheck": true,
|
||||||
"moduleResolution": "bundler",
|
"moduleResolution": "bundler",
|
||||||
"allowImportingTsExtensions": true,
|
"allowImportingTsExtensions": true,
|
||||||
"resolveJsonModule": true,
|
"resolveJsonModule": true,
|
||||||
"isolatedModules": true,
|
"isolatedModules": true,
|
||||||
"noEmit": true,
|
"noEmit": true,
|
||||||
"jsx": "react-jsx",
|
"jsx": "react-jsx",
|
||||||
"strict": true,
|
"strict": true,
|
||||||
"noUnusedLocals": true,
|
"noUnusedLocals": true,
|
||||||
"noUnusedParameters": true,
|
"noUnusedParameters": true,
|
||||||
"noFallthroughCasesInSwitch": true
|
"noFallthroughCasesInSwitch": true,
|
||||||
},
|
"forceConsistentCasingInFileNames": true
|
||||||
"include": ["src"],
|
},
|
||||||
"references": [{ "path": "./tsconfig.node.json" }]
|
"include": ["src"],
|
||||||
|
"references": [{ "path": "./tsconfig.node.json" }]
|
||||||
}
|
}
|
||||||
|
|
|
||||||
5
packages/db/.env.example
Normal file
5
packages/db/.env.example
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
# Drizzle ORM configuration for Supabase PostgreSQL database
|
||||||
|
DATABASE_URL=
|
||||||
|
|
||||||
|
# Required to run supabase cli commands
|
||||||
|
SUPABASE_DB_PASSWORD=
|
||||||
85
packages/db/README.md
Normal file
85
packages/db/README.md
Normal file
|
|
@ -0,0 +1,85 @@
|
||||||
|
# @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
|
||||||
15
packages/db/drizzle.config.ts
Normal file
15
packages/db/drizzle.config.ts
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
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!,
|
||||||
|
},
|
||||||
|
});
|
||||||
4
packages/db/eslint.config.mjs
Normal file
4
packages/db/eslint.config.mjs
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
import { config } from "@repo/eslint-config/react";
|
||||||
|
|
||||||
|
/** @type {import("eslint").Linter.Config} */
|
||||||
|
export default config;
|
||||||
39
packages/db/package.json
Normal file
39
packages/db/package.json
Normal file
|
|
@ -0,0 +1,39 @@
|
||||||
|
{
|
||||||
|
"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"
|
||||||
|
}
|
||||||
|
}
|
||||||
25
packages/db/src/database.ts
Normal file
25
packages/db/src/database.ts
Normal file
|
|
@ -0,0 +1,25 @@
|
||||||
|
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);
|
||||||
2
packages/db/src/index.ts
Normal file
2
packages/db/src/index.ts
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
export * from "./queries/users.ts";
|
||||||
|
export * from "./queries/products.ts";
|
||||||
33
packages/db/src/queries/products.ts
Normal file
33
packages/db/src/queries/products.ts
Normal file
|
|
@ -0,0 +1,33 @@
|
||||||
|
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,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
20
packages/db/src/queries/users.ts
Normal file
20
packages/db/src/queries/users.ts
Normal file
|
|
@ -0,0 +1,20 @@
|
||||||
|
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;
|
||||||
|
}
|
||||||
11
packages/db/src/schema/column.helpers.ts
Normal file
11
packages/db/src/schema/column.helpers.ts
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
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 }),
|
||||||
|
};
|
||||||
15
packages/db/src/schema/products.ts
Normal file
15
packages/db/src/schema/products.ts
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
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;
|
||||||
13
packages/db/src/schema/users.ts
Normal file
13
packages/db/src/schema/users.ts
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
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;
|
||||||
29
packages/db/src/seeders/products.tsx
Normal file
29
packages/db/src/seeders/products.tsx
Normal file
|
|
@ -0,0 +1,29 @@
|
||||||
|
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`;
|
||||||
|
};
|
||||||
52
packages/db/src/seeders/seed.tsx
Normal file
52
packages/db/src/seeders/seed.tsx
Normal file
|
|
@ -0,0 +1,52 @@
|
||||||
|
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>;
|
||||||
23
packages/db/src/seeders/users.tsx
Normal file
23
packages/db/src/seeders/users.tsx
Normal file
|
|
@ -0,0 +1,23 @@
|
||||||
|
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`;
|
||||||
|
};
|
||||||
8
packages/db/supabase/.gitignore
vendored
Normal file
8
packages/db/supabase/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
# Supabase
|
||||||
|
.branches
|
||||||
|
.temp
|
||||||
|
|
||||||
|
# dotenvx
|
||||||
|
.env.keys
|
||||||
|
.env.local
|
||||||
|
.env.*.local
|
||||||
317
packages/db/supabase/config.toml
Normal file
317
packages/db/supabase/config.toml
Normal file
|
|
@ -0,0 +1,317 @@
|
||||||
|
# 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)"
|
||||||
21
packages/db/supabase/migrations/0000_awesome_prowler.sql
Normal file
21
packages/db/supabase/migrations/0000_awesome_prowler.sql
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
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
|
||||||
|
);
|
||||||
145
packages/db/supabase/migrations/meta/0000_snapshot.json
Normal file
145
packages/db/supabase/migrations/meta/0000_snapshot.json
Normal file
|
|
@ -0,0 +1,145 @@
|
||||||
|
{
|
||||||
|
"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": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
13
packages/db/supabase/migrations/meta/_journal.json
Normal file
13
packages/db/supabase/migrations/meta/_journal.json
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
{
|
||||||
|
"version": "7",
|
||||||
|
"dialect": "postgresql",
|
||||||
|
"entries": [
|
||||||
|
{
|
||||||
|
"idx": 0,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1751101339243,
|
||||||
|
"tag": "0000_awesome_prowler",
|
||||||
|
"breakpoints": true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
14
packages/db/tsconfig.json
Normal file
14
packages/db/tsconfig.json
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
{
|
||||||
|
"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"]
|
||||||
|
}
|
||||||
129
packages/ui/src/components/products/create.tsx
Normal file
129
packages/ui/src/components/products/create.tsx
Normal file
|
|
@ -0,0 +1,129 @@
|
||||||
|
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 };
|
||||||
169
packages/ui/src/components/products/index.tsx
Normal file
169
packages/ui/src/components/products/index.tsx
Normal file
|
|
@ -0,0 +1,169 @@
|
||||||
|
import {
|
||||||
|
TableShow,
|
||||||
|
TableGrid,
|
||||||
|
SimpleTableGrid,
|
||||||
|
FieldConfig,
|
||||||
|
ActionConfig,
|
||||||
|
BaseRecord,
|
||||||
|
PaginatedResponse,
|
||||||
|
} from "../template/table-show";
|
||||||
|
|
||||||
|
export interface Product extends BaseRecord {
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
price: number;
|
||||||
|
imageUrl?: string;
|
||||||
|
isPublished?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const productFields: FieldConfig<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."
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
359
packages/ui/src/components/template/create-form.tsx
Normal file
359
packages/ui/src/components/template/create-form.tsx
Normal file
|
|
@ -0,0 +1,359 @@
|
||||||
|
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 };
|
||||||
493
packages/ui/src/components/template/table-show.tsx
Normal file
493
packages/ui/src/components/template/table-show.tsx
Normal file
|
|
@ -0,0 +1,493 @@
|
||||||
|
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,
|
||||||
|
};
|
||||||
69
packages/ui/src/components/users/index.tsx
Normal file
69
packages/ui/src/components/users/index.tsx
Normal file
|
|
@ -0,0 +1,69 @@
|
||||||
|
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"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
47
turbo.json
47
turbo.json
|
|
@ -1,6 +1,7 @@
|
||||||
{
|
{
|
||||||
"$schema": "https://turborepo.com/schema.json",
|
"$schema": "https://turborepo.com/schema.json",
|
||||||
"ui": "tui",
|
"ui": "tui",
|
||||||
|
"globalEnv": ["DATABASE_URL"],
|
||||||
"tasks": {
|
"tasks": {
|
||||||
"build": {
|
"build": {
|
||||||
"inputs": ["$TURBO_DEFAULT$", ".env*"],
|
"inputs": ["$TURBO_DEFAULT$", ".env*"],
|
||||||
|
|
@ -30,6 +31,52 @@
|
||||||
},
|
},
|
||||||
"clean": {
|
"clean": {
|
||||||
"cache": false
|
"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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue