Repository: roztownsend/Seedly
Pull request #51: Update/product details
Author: login: camposjoaoc
Created: 2025-05-22T09:50:13Z | Merged: 2025-05-23T08:35:47Z

**ProductDetails: Dynamic tasks from backend + Zustand cart update**

Description:

Now fetching and showing plant tasks dynamically from `/plants/:id/tasks`
Tasks are styled with Tailwind for better visibility
Compatible with the new Zustand cart actions and QuantityControl
Improved loading and not-found states for a smoother UX

Let me know if you think anything should be changed or improved!


Diff:
diff --git a/backend/src/models/plant.model.ts b/backend/src/models/plant.model.ts
index 7eb67d7..ce18662 100644
--- a/backend/src/models/plant.model.ts
+++ b/backend/src/models/plant.model.ts
@@ -31,6 +31,7 @@ export class Plant extends Model<
   declare updatedAt: CreationOptional<Date>;
 
   declare getPurchaseItems: HasManyGetAssociationsMixin<PurchaseItem>;
+  declare getTasks: HasManyGetAssociationsMixin<Task>;
   static initModel(sequelize: Sequelize): typeof Plant {
     return Plant.init(
       {
diff --git a/backend/src/routes/plantRoutes.ts b/backend/src/routes/plantRoutes.ts
index bbb2fbc..58a2de2 100644
--- a/backend/src/routes/plantRoutes.ts
+++ b/backend/src/routes/plantRoutes.ts
@@ -4,6 +4,7 @@ import { Router, Request, Response } from "express";
 import { pool } from "../config/dbConnection";
 import { z } from "zod";
 import { Plant } from "../models/plant.model";
+import { Task } from "../models/task.model";
 const router = Router();
 
 //get all plants
@@ -73,4 +74,17 @@ router.get("/search", async (req: Request, res: Response): Promise<void> => {
   }
 });
 
+// GET /plants/:id/tasks
+router.get("/:id/tasks", async (req: Request, res: Response) => {
+  try {
+    const plantId = req.params.id;
+    const plant = await Plant.findByPk(plantId);
+    const tasks = await plant?.getTasks();
+    res.json(tasks);
+  } catch (err) {
+    console.error("Error fetching tasks:", err);
+    res.status(500).json({ error: "Internal server error" });
+  }
+});
+
 export default router;
diff --git a/frontend/vite-project/src/App.tsx b/frontend/vite-project/src/App.tsx
index c0908f2..646962d 100644
--- a/frontend/vite-project/src/App.tsx
+++ b/frontend/vite-project/src/App.tsx
@@ -21,7 +21,7 @@ function App() {
             <Route path="/shop" element={<Shop />} />
             <Route path="/login" element={<Login />} />
             <Route path="/signup" element={<Signup />} />
-            <Route path="/product/:id" element={<ProductDetails />} />
+            <Route path="/products/:id/:slug" element={<ProductDetails />} />
             <Route path="/shippingForm" element={<ShippingForm />} />
           </Routes>
         </main>
diff --git a/frontend/vite-project/src/components/card-component/ProductCard.tsx b/frontend/vite-project/src/components/card-component/ProductCard.tsx
index c870088..7582a38 100644
--- a/frontend/vite-project/src/components/card-component/ProductCard.tsx
+++ b/frontend/vite-project/src/components/card-component/ProductCard.tsx
@@ -3,12 +3,18 @@ import "./productCard.css";
 import { useCartActions } from "../../stores/cartStore";
 import { QuantityControl } from "../quantity-control/QuantityControl";
 import { memo } from "react";
+import { Link } from "react-router-dom";
 
 const ProductCard: React.FC<ProductCardProps> = ({ item }) => {
   const { image_url, product_name, price, id } = item;
-
   const { addItem } = useCartActions();
 
+  // Function to create a slug from the product name
+  // This function replaces spaces with dashes and removes special characters to create a URL-friendly string
+  function slugify(text: string) {
+    return text.toLowerCase().replace(/\s+/g, "-").replace(/[^\w-]+/g, "");
+  }
+
   const handleAddToCart = () => {
     const tempItem = { ...item, quantity: 1 };
     addItem(tempItem);
@@ -16,9 +22,11 @@ const ProductCard: React.FC<ProductCardProps> = ({ item }) => {
 
   return (
     <section className="product-card">
+      <Link to={`/products/${id}/${slugify(product_name)}`}>
       <div className="product-card__image-box">
         <img src={image_url} alt={product_name} className="product-card__img" />
       </div>
+      </Link>
       <div className="product-card__details">
         <div className="name-price">
           <h5>{product_name}</h5>
diff --git a/frontend/vite-project/src/pages/ProductDetails.tsx b/frontend/vite-project/src/pages/ProductDetails.tsx
index 44d7b54..3a8cedc 100644
--- a/frontend/vite-project/src/pages/ProductDetails.tsx
+++ b/frontend/vite-project/src/pages/ProductDetails.tsx
@@ -5,6 +5,7 @@ import { FaHourglassHalf, FaRegSadTear } from "react-icons/fa";
 import { useCartActions } from "../stores/cartStore";
 import { ProductItem } from "../stores/productsStore";
 import { QuantityControl } from "../components/quantity-control/QuantityControl";
+import { Task } from "../types/types";
 import "./page-styles/ProductDetails.css";
 
 const ProductDetails: React.FC = () => {
@@ -12,6 +13,12 @@ const ProductDetails: React.FC = () => {
   const [plant, setPlant] = useState<ProductItem | null>(null);
   const [loading, setLoading] = useState(true);
   const { addItem } = useCartActions();
+  const [tasks, setTasks] = useState<{ id: string; description: string; start_month: number; end_month: number }[]>([]);
+
+  const months = [
+    "January", "February", "March", "April", "May", "June",
+    "July", "August", "September", "October", "November", "December"
+  ];
 
   useEffect(() => {
     const fetchPlant = async () => {
@@ -32,6 +39,32 @@ const ProductDetails: React.FC = () => {
     fetchPlant();
   }, [id]);
 
+  useEffect(() => {
+    if (!id) return;
+    const fetchTasks = async () => {
+      try {
+        const res = await axios.get(`http://localhost:5000/plants/${id}/tasks`);
+        setTasks(res.data);
+      } catch (err) {
+        setTasks([]);
+        console.error("Error fetching tasks", err);
+      }
+    };
+    fetchTasks();
+  }, [id]);
+
+  // Function to group tasks by description and month range
+  function groupTasks(tasks: Task[]) {
+    const grouped: { [desc: string]: { months: [number, number][], description: string } } = {};
+    tasks.forEach((task) => {
+      if (!grouped[task.description]) {
+        grouped[task.description] = { months: [], description: task.description };
+      }
+      grouped[task.description].months.push([task.start_month, task.end_month]);
+    });
+    return Object.values(grouped);
+  }
+
   // add item to cart or update quantity
   const handleAddToCart = () => {
     if (!plant) return;
@@ -51,9 +84,9 @@ const ProductDetails: React.FC = () => {
       <div className="not-found-state">
         <FaRegSadTear className="state-icon" />
         <div className="status-text state-message">
-          We couldn't find that product. Please try again in a few minutes. If
-          you still get this error, please contact us and we can assist you
-          further. Thanks for your patience!
+          <p>We couldn't find that product. Please try again in a few minutes.</p>
+          <p>If you still get this error, please contact us and we can assist you further.</p>
+          <p>Thanks for your patience!</p>
         </div>
       </div>
     );
@@ -84,18 +117,24 @@ const ProductDetails: React.FC = () => {
               Tasks for {plant.product_name}
             </h4>
             <ul className="product-tasks__items">
-              <li>
-                <strong>Task:</strong> This is some placeholder text to test
-                styling.
-              </li>
-              <li>
-                <strong>Another Task:</strong> This is some more placeholder
-                text.
-              </li>
-              <li>
-                <strong>One More Task:</strong> This is one last placeholder
-                text to test styling.
-              </li>
+              {tasks.length > 0 ? (
+                groupTasks(tasks).map((group, idx) => (
+                  <li key={idx} className="product-task-item">
+                    <span role="img" aria-label="task">🌱</span>
+                    <strong>
+                      {group.months
+                        .map(
+                          ([start, end]) =>
+                            months[start - 1] + (start !== end ? ` to ${months[end - 1]}` : "")
+                        )
+                        .join(", ")}
+                    </strong>
+                    {" "}{group.description}
+                  </li>
+                ))
+              ) : (
+                <li>There are no tasks for this plant at the moment!</li>
+              )}
             </ul>
           </div>
           <div className="product-actions">
@@ -113,4 +152,5 @@ const ProductDetails: React.FC = () => {
     </div>
   );
 };
+
 export default ProductDetails;
diff --git a/frontend/vite-project/src/pages/page-styles/ProductDetails.css b/frontend/vite-project/src/pages/page-styles/ProductDetails.css
index 7d93f7e..1917a84 100644
--- a/frontend/vite-project/src/pages/page-styles/ProductDetails.css
+++ b/frontend/vite-project/src/pages/page-styles/ProductDetails.css
@@ -18,7 +18,8 @@
     @apply flex md:flex-col w-full justify-between items-baseline;
 }
 
-.product-title, h4 {
+.product-title,
+h4 {
     @apply text-yellow-800;
 }
 
@@ -31,10 +32,15 @@
     @apply text-base leading-relaxed;
 }
 
-.product-tasks-heading {
+.product-tasks-heading,
+.product-tasks__items {
     @apply pt-3;
 }
 
+.product-task-item {
+    @apply bg-yellow-50 px-1 py-1 flex items-center gap-2;
+}
+
 .product-actions {
     @apply flex w-full justify-center md:justify-start py-6;
 }
@@ -62,5 +68,5 @@
 }
 
 .state-message {
-    @apply w-1/2;
+    @apply w-full text-center;
 }
\ No newline at end of file
diff --git a/frontend/vite-project/src/types/types.tsx b/frontend/vite-project/src/types/types.tsx
index 21e8678..3342d19 100644
--- a/frontend/vite-project/src/types/types.tsx
+++ b/frontend/vite-project/src/types/types.tsx
@@ -13,3 +13,10 @@ export interface HeroProps {
   subheading: string;
   imageUrl?: string;
 }
+
+export type Task = {
+  id: string;
+  description: string;
+  start_month: number;
+  end_month: number;
+};
\ No newline at end of file


Reviews:
user: login: reevcrona
body: LGTM! Great job!
state: APPROVED
author_association: COLLABORATOR
submitted_at: 2025-05-22T10:52:08Z
user: login: reevcrona
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-05-22T10:58:16Z
user: login: roztownsend
body: Looks good so far. It's more of a nice-to-have, but showing a variation of the product name in the URL instead of the ID.
state: CHANGES_REQUESTED
author_association: OWNER
submitted_at: 2025-05-22T11:47:34Z
user: login: camposjoaoc
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-05-22T12:07:41Z
user: login: camposjoaoc
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-05-22T13:41:17Z
user: login: roztownsend
body: Nice 👍
state: APPROVED
author_association: OWNER
submitted_at: 2025-05-22T13:46:08Z

Review comments:
diff_hunk: @@ -73,4 +74,16 @@ router.get("/search", async (req: Request, res: Response): Promise<void> => {
   }
 });
 
+// GET /plants/:id/tasks
+router.get("/:id/tasks", async (req: Request, res: Response) => {
+  try {
+    const plantId = req.params.id;
+    const tasks = await Task.findAll({ where: { plant_id: plantId } });
+    res.json(tasks);
path: backend/src/routes/plantRoutes.ts
user: login: reevcrona
body: ```suggestion
    const plantId = req.params.id;
    const plant = await Plant.findByPk(plantId);
    const tasks = await plant?.getTasks();
    res.json(tasks);

```
You could also do this, I'm not sure if it would provide any performance benefit, but it’s an option. You would just need to declare the method in Plant.model.ts to inform TypeScript about it.
created_at: 2025-05-22T10:58:15Z
author_association: COLLABORATOR
diff_hunk: @@ -12,6 +12,7 @@ const ProductDetails: React.FC = () => {
   const [plant, setPlant] = useState<ProductItem | null>(null);
   const [loading, setLoading] = useState(true);
   const { addItem } = useCartActions();
+  const [tasks, setTasks] = useState<{ id: string; description: string }[]>([]);
path: frontend/vite-project/src/pages/ProductDetails.tsx
user: login: roztownsend
body: ```suggestion
  const [tasks, setTasks] = useState<{ id: string; description: string; start_month: number; end_month: number }[]>([]);
```
created_at: 2025-05-22T11:44:58Z
author_association: OWNER
diff_hunk: @@ -32,6 +33,20 @@ const ProductDetails: React.FC = () => {
     fetchPlant();
   }, [id]);
 
+  useEffect(() => {
+    if (!id) return;
+    const fetchTasks = async () => {
+      try {
+        const res = await axios.get(`http://localhost:5000/plants/${id}/tasks`);
+        setTasks(res.data);
+      } catch (err) {
+        setTasks([]);
+        console.error("Error fetching tasks", err);
+      }
+    };
+    fetchTasks();
+  }, [id]);
+
path: frontend/vite-project/src/pages/ProductDetails.tsx
user: login: roztownsend
body: There is probably a better way to do this, but 

```suggestion
  const months = ["", "January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December"];
```
created_at: 2025-05-22T11:45:46Z
author_association: OWNER
diff_hunk: @@ -84,18 +99,17 @@ const ProductDetails: React.FC = () => {
               Tasks for {plant.product_name}
             </h4>
             <ul className="product-tasks__items">
-              <li>
-                <strong>Task:</strong> This is some placeholder text to test
-                styling.
-              </li>
-              <li>
-                <strong>Another Task:</strong> This is some more placeholder
-                text.
-              </li>
-              <li>
-                <strong>One More Task:</strong> This is one last placeholder
-                text to test styling.
-              </li>
+              {/* --- Dynamic rendering of tasks --- */}
+              {tasks.length > 0 ? (
+                tasks.map((task) => (
+                  <li key={task.id} className="product-task-item">
+                    <span role="img" aria-label="task">🌱</span>
+                    <strong>Task:</strong> {task.description}
path: frontend/vite-project/src/pages/ProductDetails.tsx
user: login: roztownsend
body: ```suggestion
                    <strong>{months[task.start_month]} to {months[task.end_month]}</strong> {task.description}
                  </li>
```

You'll need to add some additional logic here in cases where the task starts and ends on the same month. I would have it default to the zero index which is a blank string, but again, there may be better ways to do it.
created_at: 2025-05-22T11:47:19Z
author_association: OWNER
diff_hunk: @@ -73,4 +74,16 @@ router.get("/search", async (req: Request, res: Response): Promise<void> => {
   }
 });
 
+// GET /plants/:id/tasks
+router.get("/:id/tasks", async (req: Request, res: Response) => {
+  try {
+    const plantId = req.params.id;
+    const tasks = await Task.findAll({ where: { plant_id: plantId } });
+    res.json(tasks);
path: backend/src/routes/plantRoutes.ts
user: login: camposjoaoc
body: Thanks for the suggestion! That could work — I’ll give it a try and see how it goes. And yeah, I’ll add the method to Plant.model.ts 🚀
created_at: 2025-05-22T12:07:41Z
author_association: COLLABORATOR
diff_hunk: @@ -84,18 +99,17 @@ const ProductDetails: React.FC = () => {
               Tasks for {plant.product_name}
             </h4>
             <ul className="product-tasks__items">
-              <li>
-                <strong>Task:</strong> This is some placeholder text to test
-                styling.
-              </li>
-              <li>
-                <strong>Another Task:</strong> This is some more placeholder
-                text.
-              </li>
-              <li>
-                <strong>One More Task:</strong> This is one last placeholder
-                text to test styling.
-              </li>
+              {/* --- Dynamic rendering of tasks --- */}
+              {tasks.length > 0 ? (
+                tasks.map((task) => (
+                  <li key={task.id} className="product-task-item">
+                    <span role="img" aria-label="task">🌱</span>
+                    <strong>Task:</strong> {task.description}
path: frontend/vite-project/src/pages/ProductDetails.tsx
user: login: camposjoaoc
body: Thanks for the feedback, Ros! I’ll apply the suggestions and see how it goes. I hadn’t realized the part about having the same task in different months — it was right in front of me but I missed it, my bad haha 🚀
created_at: 2025-05-22T13:41:17Z
author_association: COLLABORATOR

PR comments:
user: login: camposjoaoc
created_at: 2025-05-22T13:42:28Z
body: Made adjustments based on feedback from Jacob and Ros:

Improve product details and tasks display
	•	Grouped and displayed plant tasks by description and month range on the product details page
	•	Updated routes and product card to use friendly URLs with product name slugs
	•	Added and used a shared Task type for better type safety
	•	Applied small improvements to task fetching and rendering logic

author_association: COLLABORATOR
user: login: camposjoaoc
created_at: 2025-05-22T13:50:42Z
body: About the product name in the URL @roztownsend — I’m using the slugify function in the ProductCard component. It just turns the product name into a URL-friendly format by making it lowercase, replacing spaces with dashes, and removing special characters.

Since the product page fetches data based on the product ID (which I’m already passing in the URL), I decided to keep the ID there and just add the product name at the end.

Changing it to fetch by name would mean rewriting that whole logic, which doesn’t feel necessary right now — especially since everything’s working fine. And honestly, using the ID feels more reliable anyway.

author_association: COLLABORATOR