From 215c178249da0446e205f6d1a5cbf8ccb1545fd3 Mon Sep 17 00:00:00 2001 From: Sosokker Date: Fri, 14 Feb 2025 04:13:16 +0700 Subject: [PATCH 01/22] feat: add dynamic breadcrumb --- frontend/app/(sidebar)/dynamic-breadcrumb.tsx | 47 ++++++++++ frontend/app/(sidebar)/layout.tsx | 28 ++---- frontend/lib/utils.ts | 22 ++++- frontend/package.json | 3 + frontend/pnpm-lock.yaml | 91 +++++++++++++++++++ 5 files changed, 169 insertions(+), 22 deletions(-) create mode 100644 frontend/app/(sidebar)/dynamic-breadcrumb.tsx diff --git a/frontend/app/(sidebar)/dynamic-breadcrumb.tsx b/frontend/app/(sidebar)/dynamic-breadcrumb.tsx new file mode 100644 index 0000000..00486a6 --- /dev/null +++ b/frontend/app/(sidebar)/dynamic-breadcrumb.tsx @@ -0,0 +1,47 @@ +import { + Breadcrumb, + BreadcrumbItem, + BreadcrumbLink, + BreadcrumbList, + BreadcrumbPage, + BreadcrumbSeparator, +} from "@/components/ui/breadcrumb"; +import React from "react"; + +export interface DynamicBreadcrumbProps { + pathname: string; +} + +export default function DynamicBreadcrumb({ pathname }: DynamicBreadcrumbProps) { + const segments = pathname.split("/").filter(Boolean); + + const breadcrumbItems = segments.map((segment, index) => { + const href = "/" + segments.slice(0, index + 1).join("/"); + const title = segment.charAt(0).toUpperCase() + segment.slice(1); + return { title, href }; + }); + + return ( + + + {breadcrumbItems.map((item, index) => { + const isLast = index === breadcrumbItems.length - 1; + return ( + + {isLast ? ( + + {item.title} + + ) : ( + + {item.title} + + )} + {index < breadcrumbItems.length - 1 && } + + ); + })} + + + ); +} diff --git a/frontend/app/(sidebar)/layout.tsx b/frontend/app/(sidebar)/layout.tsx index d391981..3e79b13 100644 --- a/frontend/app/(sidebar)/layout.tsx +++ b/frontend/app/(sidebar)/layout.tsx @@ -1,21 +1,21 @@ +"use client"; + import { AppSidebar } from "@/components/sidebar/app-sidebar"; import { ThemeToggle } from "@/components/theme-toggle"; -import { - Breadcrumb, - BreadcrumbItem, - BreadcrumbLink, - BreadcrumbList, - BreadcrumbPage, - BreadcrumbSeparator, -} from "@/components/ui/breadcrumb"; import { Separator } from "@/components/ui/separator"; import { SidebarInset, SidebarProvider, SidebarTrigger } from "@/components/ui/sidebar"; +import DynamicBreadcrumb from "./dynamic-breadcrumb"; +import { extractRoute } from "@/lib/utils"; +import { usePathname } from "next/navigation"; export default function AppLayout({ children, }: Readonly<{ children: React.ReactNode; }>) { + const pathname = usePathname(); + const currentPathname = extractRoute(pathname); + return ( @@ -25,17 +25,7 @@ export default function AppLayout({ - - - - Building Your Application - - - - Data Fetching - - - + {children} diff --git a/frontend/lib/utils.ts b/frontend/lib/utils.ts index bd0c391..e0dedc9 100644 --- a/frontend/lib/utils.ts +++ b/frontend/lib/utils.ts @@ -1,6 +1,22 @@ -import { clsx, type ClassValue } from "clsx" -import { twMerge } from "tailwind-merge" +import { clsx, type ClassValue } from "clsx"; +import { twMerge } from "tailwind-merge"; export function cn(...inputs: ClassValue[]) { - return twMerge(clsx(inputs)) + return twMerge(clsx(inputs)); +} + +/** + * Given a pathname string, returns a cleaned route path by removing numeric segments. + * + * For example, "/farms/1/crops/2" becomes "/farms/crops". + * + * @param pathname A pathname such as "/farms/1/crops/2" + * @returns A cleaned pathname string starting with a "/" + */ +export function extractRoute(pathname: string): string { + // Split the pathname into segments and remove any empty segments. + const segments = pathname.split("/").filter(Boolean); + // Remove segments which are entirely numeric. + const nonNumericSegments = segments.filter((segment) => isNaN(Number(segment))); + return "/" + nonNumericSegments.join("/"); } diff --git a/frontend/package.json b/frontend/package.json index 193ef85..e7de5e8 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -16,10 +16,13 @@ "@radix-ui/react-dialog": "^1.1.6", "@radix-ui/react-dropdown-menu": "^2.1.6", "@radix-ui/react-label": "^2.1.2", + "@radix-ui/react-progress": "^1.1.2", + "@radix-ui/react-scroll-area": "^1.2.3", "@radix-ui/react-select": "^2.1.6", "@radix-ui/react-separator": "^1.1.2", "@radix-ui/react-slot": "^1.1.2", "@radix-ui/react-switch": "^1.1.3", + "@radix-ui/react-tabs": "^1.1.3", "@radix-ui/react-tooltip": "^1.1.8", "@react-google-maps/api": "^2.20.6", "@tailwindcss/typography": "^0.5.16", diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml index 2c858e0..271ea63 100644 --- a/frontend/pnpm-lock.yaml +++ b/frontend/pnpm-lock.yaml @@ -29,6 +29,12 @@ importers: '@radix-ui/react-label': specifier: ^2.1.2 version: 2.1.2(@types/react-dom@19.0.3(@types/react@19.0.8))(@types/react@19.0.8)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) + '@radix-ui/react-progress': + specifier: ^1.1.2 + version: 1.1.2(@types/react-dom@19.0.3(@types/react@19.0.8))(@types/react@19.0.8)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) + '@radix-ui/react-scroll-area': + specifier: ^1.2.3 + version: 1.2.3(@types/react-dom@19.0.3(@types/react@19.0.8))(@types/react@19.0.8)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) '@radix-ui/react-select': specifier: ^2.1.6 version: 2.1.6(@types/react-dom@19.0.3(@types/react@19.0.8))(@types/react@19.0.8)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) @@ -41,6 +47,9 @@ importers: '@radix-ui/react-switch': specifier: ^1.1.3 version: 1.1.3(@types/react-dom@19.0.3(@types/react@19.0.8))(@types/react@19.0.8)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) + '@radix-ui/react-tabs': + specifier: ^1.1.3 + version: 1.1.3(@types/react-dom@19.0.3(@types/react@19.0.8))(@types/react@19.0.8)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) '@radix-ui/react-tooltip': specifier: ^1.1.8 version: 1.1.8(@types/react-dom@19.0.3(@types/react@19.0.8))(@types/react@19.0.8)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) @@ -671,6 +680,19 @@ packages: '@types/react-dom': optional: true + '@radix-ui/react-progress@1.1.2': + resolution: {integrity: sha512-u1IgJFQ4zNAUTjGdDL5dcl/U8ntOR6jsnhxKb5RKp5Ozwl88xKR9EqRZOe/Mk8tnx0x5tNUe2F+MzsyjqMg0MA==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@radix-ui/react-roving-focus@1.1.2': resolution: {integrity: sha512-zgMQWkNO169GtGqRvYrzb0Zf8NhMHS2DuEB/TiEmVnpr5OqPU3i8lfbxaAmC2J/KYuIQxyoQQ6DxepyXp61/xw==} peerDependencies: @@ -684,6 +706,19 @@ packages: '@types/react-dom': optional: true + '@radix-ui/react-scroll-area@1.2.3': + resolution: {integrity: sha512-l7+NNBfBYYJa9tNqVcP2AGvxdE3lmE6kFTBXdvHgUaZuy+4wGCL1Cl2AfaR7RKyimj7lZURGLwFO59k4eBnDJQ==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@radix-ui/react-select@2.1.6': resolution: {integrity: sha512-T6ajELxRvTuAMWH0YmRJ1qez+x4/7Nq7QIx7zJ0VK3qaEWdnWpNbEDnmWldG1zBDwqrLy5aLMUWcoGirVj5kMg==} peerDependencies: @@ -732,6 +767,19 @@ packages: '@types/react-dom': optional: true + '@radix-ui/react-tabs@1.1.3': + resolution: {integrity: sha512-9mFyI30cuRDImbmFF6O2KUJdgEOsGh9Vmx9x/Dh9tOhL7BngmQPQfwW4aejKm5OHpfWIdmeV6ySyuxoOGjtNng==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@radix-ui/react-tooltip@1.1.8': resolution: {integrity: sha512-YAA2cu48EkJZdAMHC0dqo9kialOcRStbtiY4nJPaht7Ptrhcvpo+eDChaM6BIs8kL6a8Z5l5poiqLnXcNduOkA==} peerDependencies: @@ -2925,6 +2973,16 @@ snapshots: '@types/react': 19.0.8 '@types/react-dom': 19.0.3(@types/react@19.0.8) + '@radix-ui/react-progress@1.1.2(@types/react-dom@19.0.3(@types/react@19.0.8))(@types/react@19.0.8)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)': + dependencies: + '@radix-ui/react-context': 1.1.1(@types/react@19.0.8)(react@19.0.0) + '@radix-ui/react-primitive': 2.0.2(@types/react-dom@19.0.3(@types/react@19.0.8))(@types/react@19.0.8)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) + react: 19.0.0 + react-dom: 19.0.0(react@19.0.0) + optionalDependencies: + '@types/react': 19.0.8 + '@types/react-dom': 19.0.3(@types/react@19.0.8) + '@radix-ui/react-roving-focus@1.1.2(@types/react-dom@19.0.3(@types/react@19.0.8))(@types/react@19.0.8)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)': dependencies: '@radix-ui/primitive': 1.1.1 @@ -2942,6 +3000,23 @@ snapshots: '@types/react': 19.0.8 '@types/react-dom': 19.0.3(@types/react@19.0.8) + '@radix-ui/react-scroll-area@1.2.3(@types/react-dom@19.0.3(@types/react@19.0.8))(@types/react@19.0.8)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)': + dependencies: + '@radix-ui/number': 1.1.0 + '@radix-ui/primitive': 1.1.1 + '@radix-ui/react-compose-refs': 1.1.1(@types/react@19.0.8)(react@19.0.0) + '@radix-ui/react-context': 1.1.1(@types/react@19.0.8)(react@19.0.0) + '@radix-ui/react-direction': 1.1.0(@types/react@19.0.8)(react@19.0.0) + '@radix-ui/react-presence': 1.1.2(@types/react-dom@19.0.3(@types/react@19.0.8))(@types/react@19.0.8)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) + '@radix-ui/react-primitive': 2.0.2(@types/react-dom@19.0.3(@types/react@19.0.8))(@types/react@19.0.8)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) + '@radix-ui/react-use-callback-ref': 1.1.0(@types/react@19.0.8)(react@19.0.0) + '@radix-ui/react-use-layout-effect': 1.1.0(@types/react@19.0.8)(react@19.0.0) + react: 19.0.0 + react-dom: 19.0.0(react@19.0.0) + optionalDependencies: + '@types/react': 19.0.8 + '@types/react-dom': 19.0.3(@types/react@19.0.8) + '@radix-ui/react-select@2.1.6(@types/react-dom@19.0.3(@types/react@19.0.8))(@types/react@19.0.8)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)': dependencies: '@radix-ui/number': 1.1.0 @@ -3002,6 +3077,22 @@ snapshots: '@types/react': 19.0.8 '@types/react-dom': 19.0.3(@types/react@19.0.8) + '@radix-ui/react-tabs@1.1.3(@types/react-dom@19.0.3(@types/react@19.0.8))(@types/react@19.0.8)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)': + dependencies: + '@radix-ui/primitive': 1.1.1 + '@radix-ui/react-context': 1.1.1(@types/react@19.0.8)(react@19.0.0) + '@radix-ui/react-direction': 1.1.0(@types/react@19.0.8)(react@19.0.0) + '@radix-ui/react-id': 1.1.0(@types/react@19.0.8)(react@19.0.0) + '@radix-ui/react-presence': 1.1.2(@types/react-dom@19.0.3(@types/react@19.0.8))(@types/react@19.0.8)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) + '@radix-ui/react-primitive': 2.0.2(@types/react-dom@19.0.3(@types/react@19.0.8))(@types/react@19.0.8)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) + '@radix-ui/react-roving-focus': 1.1.2(@types/react-dom@19.0.3(@types/react@19.0.8))(@types/react@19.0.8)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) + '@radix-ui/react-use-controllable-state': 1.1.0(@types/react@19.0.8)(react@19.0.0) + react: 19.0.0 + react-dom: 19.0.0(react@19.0.0) + optionalDependencies: + '@types/react': 19.0.8 + '@types/react-dom': 19.0.3(@types/react@19.0.8) + '@radix-ui/react-tooltip@1.1.8(@types/react-dom@19.0.3(@types/react@19.0.8))(@types/react@19.0.8)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)': dependencies: '@radix-ui/primitive': 1.1.1 From 20c1efa32554fadccfa6898e250e866adcde40eb Mon Sep 17 00:00:00 2001 From: Sosokker Date: Fri, 14 Feb 2025 04:16:09 +0700 Subject: [PATCH 02/22] ui: modify new crop dialog --- .../(sidebar)/farms/[farmId]/crop-card.tsx | 15 +- .../(sidebar)/farms/[farmId]/crop-dialog.tsx | 131 ++++++++++++++++++ .../app/(sidebar)/farms/[farmId]/page.tsx | 59 ++++---- frontend/app/(sidebar)/farms/page.tsx | 15 +- 4 files changed, 182 insertions(+), 38 deletions(-) create mode 100644 frontend/app/(sidebar)/farms/[farmId]/crop-dialog.tsx diff --git a/frontend/app/(sidebar)/farms/[farmId]/crop-card.tsx b/frontend/app/(sidebar)/farms/[farmId]/crop-card.tsx index 6d9dd72..2304364 100644 --- a/frontend/app/(sidebar)/farms/[farmId]/crop-card.tsx +++ b/frontend/app/(sidebar)/farms/[farmId]/crop-card.tsx @@ -4,9 +4,10 @@ import { Crop } from "@/types"; interface CropCardProps { crop: Crop; + onClick?: () => void; } -export function CropCard({ crop }: CropCardProps) { +export function CropCard({ crop, onClick }: CropCardProps) { const statusColors = { growing: "text-green-500", harvested: "text-yellow-500", @@ -14,8 +15,10 @@ export function CropCard({ crop }: CropCardProps) { }; return ( - - + +
@@ -24,10 +27,10 @@ export function CropCard({ crop }: CropCardProps) {
-
-

{crop.name}

+
+

{crop.name}

- +

Planted: {crop.plantedDate.toLocaleDateString()}

diff --git a/frontend/app/(sidebar)/farms/[farmId]/crop-dialog.tsx b/frontend/app/(sidebar)/farms/[farmId]/crop-dialog.tsx new file mode 100644 index 0000000..108dd20 --- /dev/null +++ b/frontend/app/(sidebar)/farms/[farmId]/crop-dialog.tsx @@ -0,0 +1,131 @@ +"use client"; + +import { useState } from "react"; +import { Dialog, DialogContent } from "@/components/ui/dialog"; +import { Button } from "@/components/ui/button"; +import { Card } from "@/components/ui/card"; +import { Check, MapPin } from "lucide-react"; +import { cn } from "@/lib/utils"; +import type { Crop } from "@/types"; + +interface Plant { + id: string; + name: string; + image: string; + growthTime: string; +} + +const plants: Plant[] = [ + { + id: "durian", + name: "Durian", + image: "/placeholder.svg?height=80&width=80", + growthTime: "4-5 months", + }, + { + id: "mango", + name: "Mango", + image: "/placeholder.svg?height=80&width=80", + growthTime: "3-4 months", + }, + { + id: "coconut", + name: "Coconut", + image: "/placeholder.svg?height=80&width=80", + growthTime: "5-6 months", + }, +]; + +interface CropDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + onSubmit: (data: Partial) => Promise; +} + +export function CropDialog({ open, onOpenChange, onSubmit }: CropDialogProps) { + const [selectedPlant, setSelectedPlant] = useState(null); + const [location, setLocation] = useState({ lat: 13.7563, lng: 100.5018 }); // Bangkok coordinates + + const handleSubmit = async () => { + if (!selectedPlant) return; + + await onSubmit({ + name: plants.find((p) => p.id === selectedPlant)?.name || "", + plantedDate: new Date(), + status: "planned", + }); + + setSelectedPlant(null); + onOpenChange(false); + }; + + return ( + + +
+ {/* Left side - Plant Selection */} +
+

Select Plant to Grow

+
+ {plants.map((plant) => ( + setSelectedPlant(plant.id)}> +
+ {plant.name} +
+
+

{plant.name}

+ {selectedPlant === plant.id && } +
+

Growth time: {plant.growthTime}

+
+
+
+ ))} +
+
+ + {/* Right side - Map */} +
+
+ {/* Placeholder map - Replace with your map component */} +
+
+ +

+ Map placeholder +
+ Lat: {location.lat.toFixed(4)} +
+ Lng: {location.lng.toFixed(4)} +

+
+
+
+
+ + {/* Footer */} +
+
+ + +
+
+
+
+
+ ); +} diff --git a/frontend/app/(sidebar)/farms/[farmId]/page.tsx b/frontend/app/(sidebar)/farms/[farmId]/page.tsx index aa500ce..43eaab7 100644 --- a/frontend/app/(sidebar)/farms/[farmId]/page.tsx +++ b/frontend/app/(sidebar)/farms/[farmId]/page.tsx @@ -6,29 +6,28 @@ import { ArrowLeft, MapPin, Plus, Sprout } from "lucide-react"; import { Card, CardContent, CardHeader } from "@/components/ui/card"; import { Button } from "@/components/ui/button"; import { Separator } from "@/components/ui/separator"; -import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from "@/components/ui/dialog"; -import { AddCropForm } from "./add-crop-form"; +import { CropDialog } from "./crop-dialog"; import { CropCard } from "./crop-card"; import { Farm, Crop } from "@/types"; import React from "react"; const crops: Crop[] = [ { - id: "crop1", + id: "1", farmId: "1", name: "Monthong Durian", plantedDate: new Date("2023-03-15"), status: "growing", }, { - id: "crop2", + id: "2", farmId: "1", name: "Chanee Durian", plantedDate: new Date("2023-02-20"), status: "planned", }, { - id: "crop3", + id: "3", farmId: "2", name: "Kradum Durian", plantedDate: new Date("2022-11-05"), @@ -76,6 +75,7 @@ export default function FarmDetailPage({ params }: { params: Promise<{ farmId: s status: data.status!, }; setCrops((prevCrops) => [...prevCrops, newCrop]); + // When the crop gets added, close the dialog setIsDialogOpen(false); }; @@ -121,33 +121,34 @@ export default function FarmDetailPage({ params }: { params: Promise<{ farmId: s

Crops

- - setIsDialogOpen(true)}> - -
-
- -
-
-

Add Crop

-

Plant a new crop

-
+ {/* Clickable "Add Crop" Card */} + setIsDialogOpen(true)}> + +
+
+
- - - - - Add New Crop - Fill out the form to add a new crop to your farm. - - setIsDialogOpen(false)} /> - -
+
+

Add Crop

+

Plant a new crop

+
+
+ + + + {/* New Crop Dialog */} + {crops.map((crop) => ( - + { + router.push(`/farms/${crop.farmId}/crops/${crop.id}`); + }} + /> ))}
diff --git a/frontend/app/(sidebar)/farms/page.tsx b/frontend/app/(sidebar)/farms/page.tsx index 04895ea..e123112 100644 --- a/frontend/app/(sidebar)/farms/page.tsx +++ b/frontend/app/(sidebar)/farms/page.tsx @@ -4,12 +4,14 @@ import { useState } from "react"; import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from "@/components/ui/dialog"; import { Separator } from "@/components/ui/separator"; import { Input } from "@/components/ui/input"; -import { Search } from "lucide-react"; +import { Link, Search } from "lucide-react"; import { FarmCard } from "./farm-card"; import { AddFarmForm } from "./add-farm-form"; +import { useRouter } from "next/navigation"; import type { Farm } from "@/types"; export default function FarmSetupPage() { + const router = useRouter(); const [isDialogOpen, setIsDialogOpen] = useState(false); const [searchQuery, setSearchQuery] = useState(""); const [farms, setFarms] = useState([ @@ -17,7 +19,7 @@ export default function FarmSetupPage() { id: "1", name: "Green Valley Farm", location: "Bangkok", - type: "durian", + type: "Durian", createdAt: new Date(), }, ]); @@ -70,7 +72,14 @@ export default function FarmSetupPage() { {filteredFarms.map((farm) => ( - + { + router.push(`/farms/${farm.id}`); + }} + /> ))} From 8ddf1c82e60a0ee86f36e94dbb7b5b4876640d7c Mon Sep 17 00:00:00 2001 From: Sosokker Date: Fri, 14 Feb 2025 06:01:34 +0700 Subject: [PATCH 03/22] feat: add template for specific crops page --- .../crops/[cropId]/analytics-dialog.tsx | 104 +++++++ .../crops/[cropId]/chatbot-dialog.tsx | 87 ++++++ .../farms/[farmId]/crops/[cropId]/page.tsx | 253 ++++++++++++++++++ frontend/components/ui/badge.tsx | 36 +++ frontend/components/ui/progress.tsx | 28 ++ frontend/components/ui/scroll-area.tsx | 48 ++++ frontend/components/ui/tabs.tsx | 55 ++++ frontend/package.json | 1 + frontend/pnpm-lock.yaml | 3 + frontend/types.ts | 12 + 10 files changed, 627 insertions(+) create mode 100644 frontend/app/(sidebar)/farms/[farmId]/crops/[cropId]/analytics-dialog.tsx create mode 100644 frontend/app/(sidebar)/farms/[farmId]/crops/[cropId]/chatbot-dialog.tsx create mode 100644 frontend/app/(sidebar)/farms/[farmId]/crops/[cropId]/page.tsx create mode 100644 frontend/components/ui/badge.tsx create mode 100644 frontend/components/ui/progress.tsx create mode 100644 frontend/components/ui/scroll-area.tsx create mode 100644 frontend/components/ui/tabs.tsx diff --git a/frontend/app/(sidebar)/farms/[farmId]/crops/[cropId]/analytics-dialog.tsx b/frontend/app/(sidebar)/farms/[farmId]/crops/[cropId]/analytics-dialog.tsx new file mode 100644 index 0000000..7beeff7 --- /dev/null +++ b/frontend/app/(sidebar)/farms/[farmId]/crops/[cropId]/analytics-dialog.tsx @@ -0,0 +1,104 @@ +"use client"; + +import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; +import { LineChart, Sprout, Droplets, Sun } from "lucide-react"; +import type { Crop, CropAnalytics } from "@/types"; + +interface AnalyticsDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + crop: Crop; + analytics: CropAnalytics; +} + +export function AnalyticsDialog({ open, onOpenChange, crop, analytics }: AnalyticsDialogProps) { + return ( + + + + Crop Analytics - {crop.name} + + + + + Overview + Growth + Environment + + + +
+ + + Growth Rate + + + +
+2.5%
+

+20.1% from last week

+
+
+ + + Water Usage + + + +
15.2L
+

per day average

+
+
+ + + Sunlight + + + +
{analytics.sunlight}%
+

optimal conditions

+
+
+
+ + + + Growth Timeline + Daily growth rate over time + + + + Growth chart placeholder + + +
+ + + + + Detailed Growth Analysis + Comprehensive growth metrics + + + Detailed growth analysis placeholder + + + + + + + + Environmental Conditions + Temperature, humidity, and more + + + Environmental metrics placeholder + + + +
+
+
+ ); +} diff --git a/frontend/app/(sidebar)/farms/[farmId]/crops/[cropId]/chatbot-dialog.tsx b/frontend/app/(sidebar)/farms/[farmId]/crops/[cropId]/chatbot-dialog.tsx new file mode 100644 index 0000000..e704521 --- /dev/null +++ b/frontend/app/(sidebar)/farms/[farmId]/crops/[cropId]/chatbot-dialog.tsx @@ -0,0 +1,87 @@ +"use client"; + +import { useState } from "react"; +import { Dialog, DialogContent, DialogTitle } from "@/components/ui/dialog"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { ScrollArea } from "@/components/ui/scroll-area"; +import { Send } from "lucide-react"; +import { VisuallyHidden } from "@radix-ui/react-visually-hidden"; + +interface Message { + role: "user" | "assistant"; + content: string; +} + +interface ChatbotDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + cropName: string; +} + +export function ChatbotDialog({ open, onOpenChange, cropName }: ChatbotDialogProps) { + const [messages, setMessages] = useState([ + { + role: "assistant", + content: `Hello! I'm your farming assistant. How can I help you with your ${cropName} today?`, + }, + ]); + const [input, setInput] = useState(""); + + const handleSend = () => { + if (!input.trim()) return; + + const newMessages: Message[] = [ + ...messages, + { role: "user", content: input }, + { role: "assistant", content: `Here's some information about ${cropName}: [AI response placeholder]` }, + ]; + setMessages(newMessages); + setInput(""); + }; + + return ( + + + + + +
+
+

Farming Assistant

+

Ask questions about your {cropName}

+
+ + +
+ {messages.map((message, i) => ( +
+
+ {message.content} +
+
+ ))} +
+
+ +
+
{ + e.preventDefault(); + handleSend(); + }} + className="flex gap-2"> + setInput(e.target.value)} /> + +
+
+
+
+
+ ); +} diff --git a/frontend/app/(sidebar)/farms/[farmId]/crops/[cropId]/page.tsx b/frontend/app/(sidebar)/farms/[farmId]/crops/[cropId]/page.tsx new file mode 100644 index 0000000..7e94118 --- /dev/null +++ b/frontend/app/(sidebar)/farms/[farmId]/crops/[cropId]/page.tsx @@ -0,0 +1,253 @@ +"use client"; + +import React, { useState } from "react"; +import { useRouter } from "next/navigation"; +import { + ArrowLeft, + MapPin, + Sprout, + LineChart, + MessageSquare, + Settings, + AlertCircle, + Droplets, + Sun, + ThermometerSun, + Timer, + ListCollapse, +} from "lucide-react"; +import { Card, CardContent, CardHeader } from "@/components/ui/card"; +import { Button } from "@/components/ui/button"; +import { Separator } from "@/components/ui/separator"; +import { Progress } from "@/components/ui/progress"; +import { Badge } from "@/components/ui/badge"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { ChatbotDialog } from "./chatbot-dialog"; +import { AnalyticsDialog } from "./analytics-dialog"; +import type { Crop, CropAnalytics } from "@/types"; + +const getCropById = (id: string): Crop => { + return { + id, + farmId: "1", + name: "Monthong Durian", + plantedDate: new Date("2024-01-15"), + status: "growing", + }; +}; + +const getAnalyticsByCropId = (id: string): CropAnalytics => { + return { + cropId: id, + growthProgress: 45, // Percentage + humidity: 75, // Percentage + temperature: 28, // °C + sunlight: 85, // Percentage + waterLevel: 65, // Percentage + plantHealth: "good", // "good", "warning", "critical" + nextAction: "Water the plant", + nextActionDue: new Date("2024-02-15"), + }; +}; + +export default function CropDetailPage({ params }: { params: Promise<{ farmId: string; cropId: string }> }) { + const { farmId, cropId } = React.use(params); + + const router = useRouter(); + const [crop] = useState(getCropById(cropId)); + const analytics = getAnalyticsByCropId(cropId); + + // Colors for plant health badge. + const healthColors = { + good: "text-green-500", + warning: "text-yellow-500", + critical: "text-red-500", + }; + + const actions = [ + { + title: "Analytics", + icon: LineChart, + description: "View detailed growth analytics", + onClick: () => setIsAnalyticsOpen(true), + }, + { + title: "Chat Assistant", + icon: MessageSquare, + description: "Get help and advice", + onClick: () => setIsChatOpen(true), + }, + { + title: "Detailed", + icon: ListCollapse, + description: "View detailed of crop", + onClick: () => console.log("Detailed clicked"), + }, + { + title: "Settings", + icon: Settings, + description: "Configure crop settings", + onClick: () => console.log("Settings clicked"), + }, + { + title: "Report Issue", + icon: AlertCircle, + description: "Report a problem", + onClick: () => console.log("Report clicked"), + }, + ]; + + const [isChatOpen, setIsChatOpen] = useState(false); + const [isAnalyticsOpen, setIsAnalyticsOpen] = useState(false); + + return ( +
+ + +
+ {/* Left Column - Crop Details */} +
+ + +
+
+ +
+
+

{crop.name}

+

Planted on {crop.plantedDate.toLocaleDateString()}

+
+
+ + {analytics.plantHealth.toUpperCase()} + +
+ +
+
+

Growth Progress

+ +

{analytics.growthProgress}% Complete

+
+ +
+
+
+ + Humidity +
+

{analytics.humidity}%

+
+
+
+ + Temperature +
+

{analytics.temperature}°C

+
+
+
+ + Sunlight +
+

{analytics.sunlight}%

+
+
+
+ + Water Level +
+

{analytics.waterLevel}%

+
+
+ + + +
+
+ + Next Action Required +
+
+

{analytics.nextAction}

+

+ Due by {analytics.nextActionDue.toLocaleDateString()} +

+
+
+
+
+
+ + + +

Actions

+
+ +
+ {actions.map((action) => ( + + ))} +
+
+
+
+ +
+ + +
+
+ +

+ Map placeholder +
+ Click to view full map +

+
+
+
+
+ + + +

Quick Analytics

+
+ + + + Growth + Health + Water + + + Growth chart placeholder + + + Health metrics placeholder + + + Water usage placeholder + + + +
+
+
+ + + + +
+ ); +} diff --git a/frontend/components/ui/badge.tsx b/frontend/components/ui/badge.tsx new file mode 100644 index 0000000..e87d62b --- /dev/null +++ b/frontend/components/ui/badge.tsx @@ -0,0 +1,36 @@ +import * as React from "react" +import { cva, type VariantProps } from "class-variance-authority" + +import { cn } from "@/lib/utils" + +const badgeVariants = cva( + "inline-flex items-center rounded-md border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2", + { + variants: { + variant: { + default: + "border-transparent bg-primary text-primary-foreground shadow hover:bg-primary/80", + secondary: + "border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80", + destructive: + "border-transparent bg-destructive text-destructive-foreground shadow hover:bg-destructive/80", + outline: "text-foreground", + }, + }, + defaultVariants: { + variant: "default", + }, + } +) + +export interface BadgeProps + extends React.HTMLAttributes, + VariantProps {} + +function Badge({ className, variant, ...props }: BadgeProps) { + return ( +
+ ) +} + +export { Badge, badgeVariants } diff --git a/frontend/components/ui/progress.tsx b/frontend/components/ui/progress.tsx new file mode 100644 index 0000000..4fc3b47 --- /dev/null +++ b/frontend/components/ui/progress.tsx @@ -0,0 +1,28 @@ +"use client" + +import * as React from "react" +import * as ProgressPrimitive from "@radix-ui/react-progress" + +import { cn } from "@/lib/utils" + +const Progress = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, value, ...props }, ref) => ( + + + +)) +Progress.displayName = ProgressPrimitive.Root.displayName + +export { Progress } diff --git a/frontend/components/ui/scroll-area.tsx b/frontend/components/ui/scroll-area.tsx new file mode 100644 index 0000000..0b4a48d --- /dev/null +++ b/frontend/components/ui/scroll-area.tsx @@ -0,0 +1,48 @@ +"use client" + +import * as React from "react" +import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area" + +import { cn } from "@/lib/utils" + +const ScrollArea = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + + {children} + + + + +)) +ScrollArea.displayName = ScrollAreaPrimitive.Root.displayName + +const ScrollBar = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, orientation = "vertical", ...props }, ref) => ( + + + +)) +ScrollBar.displayName = ScrollAreaPrimitive.ScrollAreaScrollbar.displayName + +export { ScrollArea, ScrollBar } diff --git a/frontend/components/ui/tabs.tsx b/frontend/components/ui/tabs.tsx new file mode 100644 index 0000000..0f4caeb --- /dev/null +++ b/frontend/components/ui/tabs.tsx @@ -0,0 +1,55 @@ +"use client" + +import * as React from "react" +import * as TabsPrimitive from "@radix-ui/react-tabs" + +import { cn } from "@/lib/utils" + +const Tabs = TabsPrimitive.Root + +const TabsList = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +TabsList.displayName = TabsPrimitive.List.displayName + +const TabsTrigger = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +TabsTrigger.displayName = TabsPrimitive.Trigger.displayName + +const TabsContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +TabsContent.displayName = TabsPrimitive.Content.displayName + +export { Tabs, TabsList, TabsTrigger, TabsContent } diff --git a/frontend/package.json b/frontend/package.json index e7de5e8..eca4aa1 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -24,6 +24,7 @@ "@radix-ui/react-switch": "^1.1.3", "@radix-ui/react-tabs": "^1.1.3", "@radix-ui/react-tooltip": "^1.1.8", + "@radix-ui/react-visually-hidden": "^1.1.2", "@react-google-maps/api": "^2.20.6", "@tailwindcss/typography": "^0.5.16", "@tanstack/react-query": "^5.66.0", diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml index 271ea63..1f662f2 100644 --- a/frontend/pnpm-lock.yaml +++ b/frontend/pnpm-lock.yaml @@ -53,6 +53,9 @@ importers: '@radix-ui/react-tooltip': specifier: ^1.1.8 version: 1.1.8(@types/react-dom@19.0.3(@types/react@19.0.8))(@types/react@19.0.8)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) + '@radix-ui/react-visually-hidden': + specifier: ^1.1.2 + version: 1.1.2(@types/react-dom@19.0.3(@types/react@19.0.8))(@types/react@19.0.8)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) '@react-google-maps/api': specifier: ^2.20.6 version: 2.20.6(react-dom@19.0.0(react@19.0.0))(react@19.0.0) diff --git a/frontend/types.ts b/frontend/types.ts index 5ae3150..98fc9b1 100644 --- a/frontend/types.ts +++ b/frontend/types.ts @@ -6,6 +6,18 @@ export interface Crop { status: "growing" | "harvested" | "planned"; } +export interface CropAnalytics { + cropId: string; + humidity: number; + temperature: number; + sunlight: number; + waterLevel: number; + growthProgress: number; + plantHealth: "good" | "warning" | "critical"; + nextAction: string; + nextActionDue: Date; +} + export interface Farm { id: string; name: string; From 3df25a7c2f6ba1b0a6b165e51320910d5701fe04 Mon Sep 17 00:00:00 2001 From: Sosokker Date: Fri, 14 Feb 2025 06:34:34 +0700 Subject: [PATCH 04/22] feat: add plant query api --- backend/internal/api/api.go | 20 +++++----- backend/internal/api/plant.go | 40 +++++++++++++++++++ backend/internal/repository/postgres_plant.go | 2 +- 3 files changed, 51 insertions(+), 11 deletions(-) create mode 100644 backend/internal/api/plant.go diff --git a/backend/internal/api/api.go b/backend/internal/api/api.go index 24d50e9..a66a918 100644 --- a/backend/internal/api/api.go +++ b/backend/internal/api/api.go @@ -22,27 +22,29 @@ type api struct { logger *slog.Logger httpClient *http.Client - userRepo domain.UserRepository - cropRepo domain.CroplandRepository - farmRepo domain.FarmRepository + userRepo domain.UserRepository + cropRepo domain.CroplandRepository + farmRepo domain.FarmRepository + plantRepo domain.PlantRepository } func NewAPI(ctx context.Context, logger *slog.Logger, pool *pgxpool.Pool) *api { client := &http.Client{} - // Initialize repositories for users and croplands userRepository := repository.NewPostgresUser(pool) croplandRepository := repository.NewPostgresCropland(pool) farmRepository := repository.NewPostgresFarm(pool) + plantRepository := repository.NewPostgresPlant(pool) return &api{ logger: logger, httpClient: client, - userRepo: userRepository, - cropRepo: croplandRepository, - farmRepo: farmRepository, + userRepo: userRepository, + cropRepo: croplandRepository, + farmRepo: farmRepository, + plantRepo: plantRepository, } } @@ -70,15 +72,13 @@ func (a *api) Routes() *chi.Mux { config := huma.DefaultConfig("ForFarm Public API", "v1.0.0") api := humachi.New(router, config) - // Register Authentication Routes router.Group(func(r chi.Router) { a.registerAuthRoutes(r, api) a.registerCropRoutes(r, api) + a.registerPlantRoutes(r, api) }) - // Register Cropland Routes, including Auth Middleware if required router.Group(func(r chi.Router) { - // Apply Authentication middleware to the Cropland routes api.UseMiddleware(m.AuthMiddleware(api)) a.registerHelloRoutes(r, api) a.registerFarmRoutes(r, api) diff --git a/backend/internal/api/plant.go b/backend/internal/api/plant.go new file mode 100644 index 0000000..51add35 --- /dev/null +++ b/backend/internal/api/plant.go @@ -0,0 +1,40 @@ +package api + +import ( + "context" + "net/http" + + "github.com/danielgtaylor/huma/v2" + "github.com/forfarm/backend/internal/domain" + "github.com/go-chi/chi/v5" +) + +func (a *api) registerPlantRoutes(_ chi.Router, api huma.API) { + tags := []string{"plant"} + prefix := "/plant" + + huma.Register(api, huma.Operation{ + OperationID: "getAllPlant", + Method: http.MethodGet, + Path: prefix, + Tags: tags, + }, a.getAllPlantHandler) +} + +type GetAllPlantsOutput struct { + Body struct { + Plants []domain.Plant `json:"plants"` + } +} + +func (a *api) getAllPlantHandler(ctx context.Context, input *struct{}) (*GetAllPlantsOutput, error) { + resp := &GetAllPlantsOutput{} + plants, err := a.plantRepo.GetAll(ctx) + if err != nil { + return nil, err + } + + resp.Body.Plants = plants + + return resp, nil +} diff --git a/backend/internal/repository/postgres_plant.go b/backend/internal/repository/postgres_plant.go index 797f498..963c31a 100644 --- a/backend/internal/repository/postgres_plant.go +++ b/backend/internal/repository/postgres_plant.go @@ -33,7 +33,7 @@ func (p *postgresPlantRepository) fetch(ctx context.Context, query string, args &plant.PlantingDetail, &plant.IsPerennial, &plant.DaysToEmerge, &plant.DaysToFlower, &plant.DaysToMaturity, &plant.HarvestWindow, &plant.PHValue, &plant.EstimateLossRate, &plant.EstimateRevenuePerHU, - &plant.HarvestUnitID, &plant.WaterNeeds, &plant.CreatedAt, &plant.UpdatedAt, + &plant.HarvestUnitID, &plant.WaterNeeds, ); err != nil { return nil, err } From dd94c2491850f6ce013d8fe2a40aeaaaa6f91d31 Mon Sep 17 00:00:00 2001 From: Sosokker Date: Fri, 14 Feb 2025 06:40:26 +0700 Subject: [PATCH 05/22] refac: drop plantTypes from farms --- backend/internal/api/farm.go | 19 ++++++++---------- backend/internal/domain/farm.go | 19 +++++++++--------- backend/internal/repository/postgres_farm.go | 20 ++----------------- ...003_drop_column_plant_types_from_farms.sql | 2 ++ 4 files changed, 21 insertions(+), 39 deletions(-) create mode 100644 backend/migrations/00003_drop_column_plant_types_from_farms.sql diff --git a/backend/internal/api/farm.go b/backend/internal/api/farm.go index ecaf840..4820e11 100644 --- a/backend/internal/api/farm.go +++ b/backend/internal/api/farm.go @@ -7,7 +7,6 @@ import ( "github.com/danielgtaylor/huma/v2" "github.com/forfarm/backend/internal/domain" "github.com/go-chi/chi/v5" - "github.com/google/uuid" ) func (a *api) registerFarmRoutes(_ chi.Router, api huma.API) { @@ -46,11 +45,10 @@ func (a *api) registerFarmRoutes(_ chi.Router, api huma.API) { type CreateFarmInput struct { Header string `header:"Authorization" required:"true" example:"Bearer token"` Body struct { - Name string `json:"name"` - Lat []float64 `json:"lat"` - Lon []float64 `json:"lon"` - OwnerID string `json:"owner_id"` - PlantTypes []uuid.UUID `json:"plant_types"` + Name string `json:"name"` + Lat []float64 `json:"lat"` + Lon []float64 `json:"lon"` + OwnerID string `json:"owner_id"` } } @@ -62,11 +60,10 @@ type CreateFarmOutput struct { func (a *api) createFarmHandler(ctx context.Context, input *CreateFarmInput) (*CreateFarmOutput, error) { farm := &domain.Farm{ - Name: input.Body.Name, - Lat: input.Body.Lat, - Lon: input.Body.Lon, - OwnerID: input.Body.OwnerID, - PlantTypes: input.Body.PlantTypes, + Name: input.Body.Name, + Lat: input.Body.Lat, + Lon: input.Body.Lon, + OwnerID: input.Body.OwnerID, } err := a.farmRepo.CreateOrUpdate(ctx, farm) diff --git a/backend/internal/domain/farm.go b/backend/internal/domain/farm.go index 666c2c1..6422735 100644 --- a/backend/internal/domain/farm.go +++ b/backend/internal/domain/farm.go @@ -2,20 +2,19 @@ package domain import ( "context" - validation "github.com/go-ozzo/ozzo-validation/v4" - "github.com/google/uuid" "time" + + validation "github.com/go-ozzo/ozzo-validation/v4" ) type Farm struct { - UUID string - Name string - Lat []float64 - Lon []float64 - CreatedAt time.Time - UpdatedAt time.Time - OwnerID string - PlantTypes []uuid.UUID + UUID string + Name string + Lat []float64 + Lon []float64 + CreatedAt time.Time + UpdatedAt time.Time + OwnerID string } func (f *Farm) Validate() error { diff --git a/backend/internal/repository/postgres_farm.go b/backend/internal/repository/postgres_farm.go index 44ae54f..5cd9439 100644 --- a/backend/internal/repository/postgres_farm.go +++ b/backend/internal/repository/postgres_farm.go @@ -2,10 +2,10 @@ package repository import ( "context" + "strings" + "github.com/forfarm/backend/internal/domain" "github.com/google/uuid" - "github.com/lib/pq" - "strings" ) type postgresFarmRepository struct { @@ -26,7 +26,6 @@ func (p *postgresFarmRepository) fetch(ctx context.Context, query string, args . var farms []domain.Farm for rows.Next() { var f domain.Farm - var plantTypes pq.StringArray if err := rows.Scan( &f.UUID, &f.Name, @@ -35,19 +34,10 @@ func (p *postgresFarmRepository) fetch(ctx context.Context, query string, args . &f.CreatedAt, &f.UpdatedAt, &f.OwnerID, - &plantTypes, ); err != nil { return nil, err } - for _, plantTypeStr := range plantTypes { - plantTypeUUID, err := uuid.Parse(plantTypeStr) - if err != nil { - return nil, err - } - f.PlantTypes = append(f.PlantTypes, plantTypeUUID) - } - farms = append(farms, f) } return farms, nil @@ -83,11 +73,6 @@ func (p *postgresFarmRepository) CreateOrUpdate(ctx context.Context, f *domain.F f.UUID = uuid.New().String() } - plantTypes := make([]string, len(f.PlantTypes)) - for i, pt := range f.PlantTypes { - plantTypes[i] = pt.String() - } - query := ` INSERT INTO farms (uuid, name, lat, lon, created_at, updated_at, owner_id, plant_types) VALUES ($1, $2, $3, $4, NOW(), NOW(), $5, $6) @@ -108,7 +93,6 @@ func (p *postgresFarmRepository) CreateOrUpdate(ctx context.Context, f *domain.F f.Lat, f.Lon, f.OwnerID, - pq.StringArray(plantTypes), ).Scan(&f.UUID, &f.CreatedAt, &f.UpdatedAt) } diff --git a/backend/migrations/00003_drop_column_plant_types_from_farms.sql b/backend/migrations/00003_drop_column_plant_types_from_farms.sql new file mode 100644 index 0000000..b643d83 --- /dev/null +++ b/backend/migrations/00003_drop_column_plant_types_from_farms.sql @@ -0,0 +1,2 @@ +-- +goose Up +ALTER TABLE farms DROP COLUMN plant_types; \ No newline at end of file From 8f6488a70d2a18fce2473da5baebe3aeb3bfffc2 Mon Sep 17 00:00:00 2001 From: Sosokker Date: Fri, 14 Feb 2025 08:03:35 +0700 Subject: [PATCH 06/22] feat: add api to fetch user profile data --- backend/internal/api/api.go | 1 + backend/internal/api/user.go | 62 ++++++++++++++++++++ backend/internal/domain/user.go | 1 + backend/internal/repository/postgres_user.go | 16 +++++ backend/internal/utilities/jwt.go | 24 +++++++- 5 files changed, 103 insertions(+), 1 deletion(-) create mode 100644 backend/internal/api/user.go diff --git a/backend/internal/api/api.go b/backend/internal/api/api.go index a66a918..cc64d33 100644 --- a/backend/internal/api/api.go +++ b/backend/internal/api/api.go @@ -82,6 +82,7 @@ func (a *api) Routes() *chi.Mux { api.UseMiddleware(m.AuthMiddleware(api)) a.registerHelloRoutes(r, api) a.registerFarmRoutes(r, api) + a.registerUserRoutes(r, api) }) return router diff --git a/backend/internal/api/user.go b/backend/internal/api/user.go new file mode 100644 index 0000000..f289582 --- /dev/null +++ b/backend/internal/api/user.go @@ -0,0 +1,62 @@ +package api + +import ( + "context" + "fmt" + "net/http" + "strings" + + "github.com/danielgtaylor/huma/v2" + "github.com/forfarm/backend/internal/domain" + "github.com/forfarm/backend/internal/utilities" + "github.com/go-chi/chi/v5" +) + +func (a *api) registerUserRoutes(_ chi.Router, api huma.API) { + tags := []string{"user"} + prefix := "/user" + + huma.Register(api, huma.Operation{ + OperationID: "getSelfData", + Method: http.MethodGet, + Path: prefix + "/me", + Tags: tags, + }, a.getSelfData) +} + +type getSelfDataInput struct { + Authorization string `header:"Authorization" required:"true" example:"Bearer token"` +} + +type getSelfDataOutput struct { + Body struct { + User domain.User `json:"user"` + } +} + +func (a *api) getSelfData(ctx context.Context, input *getSelfDataInput) (*getSelfDataOutput, error) { + resp := &getSelfDataOutput{} + + authHeader := input.Authorization + if authHeader == "" { + return nil, fmt.Errorf("no authorization header provided") + } + + authToken := strings.TrimPrefix(authHeader, "Bearer ") + if authToken == "" { + return nil, fmt.Errorf("no token provided") + } + + uuid, err := utilities.ExtractUUIDFromToken(authToken) + if err != nil { + return nil, err + } + + user, err := a.userRepo.GetByUUID(ctx, uuid) + if err != nil { + return nil, err + } + + resp.Body.User = user + return resp, nil +} diff --git a/backend/internal/domain/user.go b/backend/internal/domain/user.go index f87937a..b87fc0b 100644 --- a/backend/internal/domain/user.go +++ b/backend/internal/domain/user.go @@ -51,6 +51,7 @@ func (u *User) Validate() error { type UserRepository interface { GetByID(context.Context, int64) (User, error) + GetByUUID(context.Context, string) (User, error) GetByUsername(context.Context, string) (User, error) GetByEmail(context.Context, string) (User, error) CreateOrUpdate(context.Context, *User) error diff --git a/backend/internal/repository/postgres_user.go b/backend/internal/repository/postgres_user.go index 67a00f5..dd94576 100644 --- a/backend/internal/repository/postgres_user.go +++ b/backend/internal/repository/postgres_user.go @@ -60,6 +60,22 @@ func (p *postgresUserRepository) GetByID(ctx context.Context, id int64) (domain. return users[0], nil } +func (p *postgresUserRepository) GetByUUID(ctx context.Context, uuid string) (domain.User, error) { + query := ` + SELECT id, uuid, username, password, email, created_at, updated_at, is_active + FROM users + WHERE uuid = $1` + + users, err := p.fetch(ctx, query, uuid) + if err != nil { + return domain.User{}, err + } + if len(users) == 0 { + return domain.User{}, domain.ErrNotFound + } + return users[0], nil +} + func (p *postgresUserRepository) GetByUsername(ctx context.Context, username string) (domain.User, error) { query := ` SELECT id, uuid, username, password, email, created_at, updated_at, is_active diff --git a/backend/internal/utilities/jwt.go b/backend/internal/utilities/jwt.go index 5b406b3..f5cdc8f 100644 --- a/backend/internal/utilities/jwt.go +++ b/backend/internal/utilities/jwt.go @@ -8,7 +8,6 @@ import ( "github.com/golang-jwt/jwt/v5" ) -// TODO: Change later var deafultSecretKey = []byte(config.JWT_SECRET_KEY) func CreateJwtToken(uuid string) (string, error) { @@ -52,3 +51,26 @@ func VerifyJwtToken(tokenString string, customKey ...[]byte) error { return nil } + +// ExtractUUIDFromToken decodes the JWT token using the default secret key, +// and returns the uuid claim contained within the token. +func ExtractUUIDFromToken(tokenString string) (string, error) { + token, err := jwt.Parse(tokenString, func(token *jwt.Token) (interface{}, error) { + if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok { + return nil, jwt.ErrSignatureInvalid + } + return deafultSecretKey, nil + }) + if err != nil { + return "", err + } + + if claims, ok := token.Claims.(jwt.MapClaims); ok && token.Valid { + if uuid, ok := claims["uuid"].(string); ok { + return uuid, nil + } + return "", errors.New("uuid not found in token") + } + + return "", errors.New("invalid token claims") +} From 0c16743230a11b88aa64497e29bf4488701563d2 Mon Sep 17 00:00:00 2001 From: Sosokker Date: Fri, 14 Feb 2025 08:06:27 +0700 Subject: [PATCH 07/22] feat: fetch user profile on sidebar --- frontend/api/config.ts | 7 ++--- frontend/api/user.ts | 22 +++++++++++++ frontend/components/sidebar/app-sidebar.tsx | 33 +++++++++++++++++--- frontend/public/avatars/avatar.webp | Bin 0 -> 7176 bytes frontend/types.ts | 11 +++++++ 5 files changed, 65 insertions(+), 8 deletions(-) create mode 100644 frontend/api/user.ts create mode 100644 frontend/public/avatars/avatar.webp diff --git a/frontend/api/config.ts b/frontend/api/config.ts index a48150b..68c107d 100644 --- a/frontend/api/config.ts +++ b/frontend/api/config.ts @@ -1,4 +1,5 @@ import axios from "axios"; +import Cookies from "js-cookie"; const axiosInstance = axios.create({ baseURL: process.env.NEXT_PUBLIC_BACKEND_URL || "http://localhost:8000", @@ -9,7 +10,7 @@ const axiosInstance = axios.create({ axiosInstance.interceptors.request.use( (config) => { - const token = localStorage.getItem("token"); + const token = Cookies.get("token"); if (token) { config.headers.Authorization = `Bearer ${token}`; } @@ -20,9 +21,7 @@ axiosInstance.interceptors.request.use( axiosInstance.interceptors.response.use( (response) => response, - (error) => { - return Promise.reject(error); - } + (error) => Promise.reject(error) ); export default axiosInstance; diff --git a/frontend/api/user.ts b/frontend/api/user.ts new file mode 100644 index 0000000..50b5669 --- /dev/null +++ b/frontend/api/user.ts @@ -0,0 +1,22 @@ +import axios from "axios"; +import axiosInstance from "./config"; +import { User } from "@/types"; + +export interface UserDataOutput { + user: User; +} + +/** + * Fetches the data for the authenticated user. + */ +export async function fetchUserMe(): Promise { + try { + const response = await axiosInstance.get("/user/me"); + return response.data; + } catch (error: any) { + if (axios.isAxiosError(error)) { + throw new Error(error.response?.data?.message || "Failed to fetch user data."); + } + throw error; + } +} diff --git a/frontend/components/sidebar/app-sidebar.tsx b/frontend/components/sidebar/app-sidebar.tsx index a2a1186..5faacd1 100644 --- a/frontend/components/sidebar/app-sidebar.tsx +++ b/frontend/components/sidebar/app-sidebar.tsx @@ -19,12 +19,14 @@ import { NavProjects } from "./nav-projects"; import { NavUser } from "./nav-user"; import { TeamSwitcher } from "./team-switcher"; import { Sidebar, SidebarContent, SidebarFooter, SidebarHeader, SidebarRail } from "@/components/ui/sidebar"; +import { useEffect } from "react"; +import { fetchUserMe } from "@/api/user"; const data = { user: { name: "shadcn", email: "m@example.com", - avatar: "/avatars/shadcn.jpg", + avatar: "/avatars/avatar.webp", }, teams: [ { @@ -134,6 +136,31 @@ const data = { }; export function AppSidebar({ ...props }: React.ComponentProps) { + const [user, setUser] = React.useState<{ name: string; email: string; avatar: string }>({ + name: "", + email: "", + avatar: "/avatars/avatar.webp", + }); + const [loading, setLoading] = React.useState(true); + const [error, setError] = React.useState(""); + + useEffect(() => { + async function getUser() { + try { + const data = await fetchUserMe(); + let to_set = user; + to_set.name = data.user.UUID; + to_set.email = data.user.Email; + setUser(to_set); + } catch (err: any) { + setError(err.message); + } finally { + setLoading(false); + } + } + getUser(); + }, []); + return ( @@ -143,9 +170,7 @@ export function AppSidebar({ ...props }: React.ComponentProps) { - - - + {loading ? "Loading..." : error ? error : } ); diff --git a/frontend/public/avatars/avatar.webp b/frontend/public/avatars/avatar.webp new file mode 100644 index 0000000000000000000000000000000000000000..20f5a9e7aaaafd1e3309bcb7b8d4e20af9cb49db GIT binary patch literal 7176 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 Date: Fri, 14 Feb 2025 09:24:59 +0700 Subject: [PATCH 08/22] refac: move map to each dialoge --- .../(sidebar)/farms/[farmId]/crop-dialog.tsx | 13 ++-- .../farms/[farmId]/crops/[cropId]/page.tsx | 8 ++- frontend/app/(sidebar)/setup/page.tsx | 2 +- .../google-map-with-drawing.tsx | 0 to_insert.sql | 71 +++++++++++++++++++ 5 files changed, 86 insertions(+), 8 deletions(-) rename frontend/{app/(sidebar)/setup => components}/google-map-with-drawing.tsx (100%) create mode 100644 to_insert.sql diff --git a/frontend/app/(sidebar)/farms/[farmId]/crop-dialog.tsx b/frontend/app/(sidebar)/farms/[farmId]/crop-dialog.tsx index 108dd20..5173479 100644 --- a/frontend/app/(sidebar)/farms/[farmId]/crop-dialog.tsx +++ b/frontend/app/(sidebar)/farms/[farmId]/crop-dialog.tsx @@ -1,12 +1,14 @@ "use client"; import { useState } from "react"; -import { Dialog, DialogContent } from "@/components/ui/dialog"; +import { Dialog, DialogContent, DialogTitle } from "@/components/ui/dialog"; import { Button } from "@/components/ui/button"; import { Card } from "@/components/ui/card"; import { Check, MapPin } from "lucide-react"; import { cn } from "@/lib/utils"; import type { Crop } from "@/types"; +import { VisuallyHidden } from "@radix-ui/react-visually-hidden"; +import GoogleMapWithDrawing from "@/components/google-map-with-drawing"; interface Plant { id: string; @@ -61,6 +63,9 @@ export function CropDialog({ open, onOpenChange, onSubmit }: CropDialogProps) { return ( + + +
{/* Left side - Plant Selection */} @@ -97,9 +102,9 @@ export function CropDialog({ open, onOpenChange, onSubmit }: CropDialogProps) { {/* Right side - Map */}
- {/* Placeholder map - Replace with your map component */}
-
+ + {/*

Map placeholder @@ -108,7 +113,7 @@ export function CropDialog({ open, onOpenChange, onSubmit }: CropDialogProps) {
Lng: {location.lng.toFixed(4)}

-
+
*/}
diff --git a/frontend/app/(sidebar)/farms/[farmId]/crops/[cropId]/page.tsx b/frontend/app/(sidebar)/farms/[farmId]/crops/[cropId]/page.tsx index 7e94118..4ab720b 100644 --- a/frontend/app/(sidebar)/farms/[farmId]/crops/[cropId]/page.tsx +++ b/frontend/app/(sidebar)/farms/[farmId]/crops/[cropId]/page.tsx @@ -25,6 +25,7 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { ChatbotDialog } from "./chatbot-dialog"; import { AnalyticsDialog } from "./analytics-dialog"; import type { Crop, CropAnalytics } from "@/types"; +import GoogleMapWithDrawing from "@/components/google-map-with-drawing"; const getCropById = (id: string): Crop => { return { @@ -204,17 +205,18 @@ export default function CropDetailPage({ params }: { params: Promise<{ farmId: s
- + +
-
+ {/*

Map placeholder
Click to view full map

-
+
*/}
diff --git a/frontend/app/(sidebar)/setup/page.tsx b/frontend/app/(sidebar)/setup/page.tsx index 223ec01..e766fd0 100644 --- a/frontend/app/(sidebar)/setup/page.tsx +++ b/frontend/app/(sidebar)/setup/page.tsx @@ -1,7 +1,7 @@ import PlantingDetailsForm from "./planting-detail-form"; import HarvestDetailsForm from "./harvest-detail-form"; import { Separator } from "@/components/ui/separator"; -import GoogleMapWithDrawing from "./google-map-with-drawing"; +import GoogleMapWithDrawing from "@/components/google-map-with-drawing"; export default function SetupPage() { return ( diff --git a/frontend/app/(sidebar)/setup/google-map-with-drawing.tsx b/frontend/components/google-map-with-drawing.tsx similarity index 100% rename from frontend/app/(sidebar)/setup/google-map-with-drawing.tsx rename to frontend/components/google-map-with-drawing.tsx diff --git a/to_insert.sql b/to_insert.sql new file mode 100644 index 0000000..adcc6f7 --- /dev/null +++ b/to_insert.sql @@ -0,0 +1,71 @@ +INSERT INTO light_profiles (name) +VALUES + ('Full Sun'), + ('Partial Shade'), + ('Full Shade'); + +INSERT INTO soil_conditions (name) +VALUES + ('Loamy'), + ('Sandy'), + ('Clay'), + ('Silty'); + +INSERT INTO harvest_units (name) +VALUES + ('Kilograms'), + ('Pounds'), + ('Bushels'), + ('Tons'); + +INSERT INTO plants ( + uuid, name, variety, row_spacing, optimal_temp, planting_depth, average_height, + light_profile_id, soil_condition_id, planting_detail, is_perennial, days_to_emerge, + days_to_flower, days_to_maturity, harvest_window, ph_value, estimate_loss_rate, + estimate_revenue_per_hu, harvest_unit_id, water_needs +) +VALUES + ( + '450e8400-e29b-41d4-a716-446655440000', -- UUID + 'Tomato', -- Name + 'Cherry', -- Variety + 0.5, -- Row Spacing (meters) + 25.0, -- Optimal Temperature (°C) + 0.02, -- Planting Depth (meters) + 1.5, -- Average Height (meters) + 1, -- Light Profile ID (Full Sun) + 1, -- Soil Condition ID (Loamy) + 'Plant in well-drained soil.', -- Planting Detail + FALSE, -- Is Perennial + 7, -- Days to Emerge + 60, -- Days to Flower + 90, -- Days to Maturity + 14, -- Harvest Window (days) + 6.5, -- pH Value + 0.1, -- Estimate Loss Rate + 10.0, -- Estimate Revenue per Harvest Unit + 1, -- Harvest Unit ID (Kilograms) + 2.5 -- Water Needs (liters per day) + ), + ( + '550e8400-e29b-41d4-a716-446655440001', -- UUID + 'Corn', -- Name + 'Sweet', -- Variety + 0.75, -- Row Spacing (meters) + 30.0, -- Optimal Temperature (°C) + 0.05, -- Planting Depth (meters) + 2.0, -- Average Height (meters) + 1, -- Light Profile ID (Full Sun) + 2, -- Soil Condition ID (Sandy) + 'Plant in rows with adequate spacing.', -- Planting Detail + FALSE, -- Is Perennial + 10, -- Days to Emerge + 70, -- Days to Flower + 100, -- Days to Maturity + 21, -- Harvest Window (days) + 6.0, -- pH Value + 0.15, -- Estimate Loss Rate + 8.0, -- Estimate Revenue per Harvest Unit + 2, -- Harvest Unit ID (Pounds) + 3.0 -- Water Needs (liters per day) + ); \ No newline at end of file From cbcf2b870bf6bbe93855fc6c57e9389f854bafdd Mon Sep 17 00:00:00 2001 From: Sosokker Date: Thu, 6 Mar 2025 22:26:43 +0700 Subject: [PATCH 09/22] ui: update landing page --- frontend/app/globals.css | 66 ++++++ frontend/app/page.tsx | 315 +++++++++++++++++++++----- frontend/components/ui/calendar.tsx | 76 +++++++ frontend/components/ui/pagination.tsx | 117 ++++++++++ frontend/components/ui/popover.tsx | 33 +++ frontend/components/ui/table.tsx | 120 ++++++++++ frontend/package.json | 3 + frontend/pnpm-lock.yaml | 61 +++++ frontend/public/placeholder.svg | 1 + 9 files changed, 737 insertions(+), 55 deletions(-) create mode 100644 frontend/components/ui/calendar.tsx create mode 100644 frontend/components/ui/pagination.tsx create mode 100644 frontend/components/ui/popover.tsx create mode 100644 frontend/components/ui/table.tsx create mode 100644 frontend/public/placeholder.svg diff --git a/frontend/app/globals.css b/frontend/app/globals.css index 491435b..405715a 100644 --- a/frontend/app/globals.css +++ b/frontend/app/globals.css @@ -87,3 +87,69 @@ body { @apply bg-background text-foreground; } } + +@layer utilities { + .text-balance { + text-wrap: balance; + } +} + +/* Add custom styles for the blog content */ +.prose h2 { + @apply text-2xl font-semibold mt-8 mb-4; +} + +.prose p { + @apply mb-4 leading-relaxed; +} + +.prose ul { + @apply list-disc pl-6 mb-4 space-y-2; +} + +/* Animation utilities */ +@keyframes blob { + 0%, + 100% { + transform: translate(0, 0) scale(1); + } + 25% { + transform: translate(20px, 15px) scale(1.1); + } + 50% { + transform: translate(-15px, 10px) scale(0.9); + } + 75% { + transform: translate(15px, -25px) scale(1.05); + } +} + +@keyframes float { + 0%, + 100% { + transform: translateY(0); + } + 50% { + transform: translateY(-15px); + } +} + +.animate-blob { + animation: blob 15s infinite; +} + +.animate-float { + animation: float 6s ease-in-out infinite; +} + +.animation-delay-2000 { + animation-delay: 2s; +} + +.animation-delay-4000 { + animation-delay: 4s; +} + +.animation-delay-1000 { + animation-delay: 1s; +} diff --git a/frontend/app/page.tsx b/frontend/app/page.tsx index 2664cbf..e574d77 100644 --- a/frontend/app/page.tsx +++ b/frontend/app/page.tsx @@ -1,69 +1,274 @@ import Image from "next/image"; import Link from "next/link"; -import { ArrowRight, Cloud, BarChart, Zap } from "lucide-react"; -import { Leaf } from "lucide-react"; +import { ArrowRight, Cloud, BarChart, Zap, Leaf, ChevronRight, Users, Shield, LineChart } from "lucide-react"; import { Button } from "@/components/ui/button"; +import { Badge } from "@/components/ui/badge"; export default function Home() { return ( -
-
- - - - ForFarm - - - - - Documentation - - - Get started - - -
+
+ {/* Animated background elements */} +
+
+
+
+
-
-
- ForFarm Icon -

Your Smart Farming Platform

-

- It's a smart and easy way to optimize your agricultural business, with the help of AI-driven insights and - real-time data. -

- - - + {/* 3D floating elements */} +
+
+
-
+
+
+
+
+
+
- {/*
*/} +
+
+ + + + ForFarm + + + BETA + + + +
+ + Log in + + + Get started + +
+
-
- - Terms - - {" • "} - - Privacy - - {" • "} - - Cookies - -
+
+ {/* Hero section */} +
+
+ + Smart Farming Solution + +

+ Grow Smarter,
+ + Harvest Better + +

+

+ Optimize your agricultural business with AI-driven insights and real-time data monitoring. ForFarm helps + you make informed decisions for sustainable farming. +

+
+ + + + + + +
+ +
+
+ {[1, 2, 3, 4].map((i) => ( +
+ User +
+ ))} +
+
+ 500+ farmers already using ForFarm +
+
+
+ +
+
+
+
+ +
+ ForFarm Dashboard Preview +
+
+

Farm Dashboard

+

Real-time monitoring

+
+ Live Demo +
+
+
+
+ + {/* Features section */} +
+
+ + Why Choose ForFarm + +

Smart Features for Modern Farming

+

+ Our platform combines cutting-edge technology with agricultural expertise to help you optimize every + aspect of your farm. +

+
+ +
+ {[ + { + icon: , + title: "Data-Driven Insights", + description: + "Make informed decisions with comprehensive analytics and reporting on all aspects of your farm.", + }, + { + icon: , + title: "Weather Integration", + description: + "Get real-time weather forecasts and alerts tailored to your specific location and crops.", + }, + { + icon: , + title: "Resource Optimization", + description: "Reduce waste and maximize efficiency with smart resource management tools.", + }, + { + icon: , + title: "Team Collaboration", + description: "Coordinate farm activities and share information seamlessly with your entire team.", + }, + { + icon: , + title: "Crop Protection", + description: "Identify potential threats to your crops early and get recommendations for protection.", + }, + { + icon: , + title: "Yield Prediction", + description: "Use AI-powered models to forecast yields and plan your harvests more effectively.", + }, + ].map((feature, index) => ( +
+
+
+
{feature.icon}
+

{feature.title}

+

{feature.description}

+
+
+ ))} +
+
+ + {/* CTA section */} +
+
+
+
+

Ready to transform your farming?

+

+ Join hundreds of farmers who are already using ForFarm to increase yields, reduce costs, and farm more + sustainably. +

+
+ + + +
+
+
+ +
+
+ + + + ForFarm + + + +
+
+
© {new Date().getFullYear()} ForFarm. All rights reserved.
+
+ + Terms + + + Privacy + + + Cookies + +
+
+
+
); } diff --git a/frontend/components/ui/calendar.tsx b/frontend/components/ui/calendar.tsx new file mode 100644 index 0000000..115cff9 --- /dev/null +++ b/frontend/components/ui/calendar.tsx @@ -0,0 +1,76 @@ +"use client" + +import * as React from "react" +import { ChevronLeft, ChevronRight } from "lucide-react" +import { DayPicker } from "react-day-picker" + +import { cn } from "@/lib/utils" +import { buttonVariants } from "@/components/ui/button" + +export type CalendarProps = React.ComponentProps + +function Calendar({ + className, + classNames, + showOutsideDays = true, + ...props +}: CalendarProps) { + return ( + .day-range-end)]:rounded-r-md [&:has(>.day-range-start)]:rounded-l-md first:[&:has([aria-selected])]:rounded-l-md last:[&:has([aria-selected])]:rounded-r-md" + : "[&:has([aria-selected])]:rounded-md" + ), + day: cn( + buttonVariants({ variant: "ghost" }), + "h-8 w-8 p-0 font-normal aria-selected:opacity-100" + ), + day_range_start: "day-range-start", + day_range_end: "day-range-end", + day_selected: + "bg-primary text-primary-foreground hover:bg-primary hover:text-primary-foreground focus:bg-primary focus:text-primary-foreground", + day_today: "bg-accent text-accent-foreground", + day_outside: + "day-outside text-muted-foreground aria-selected:bg-accent/50 aria-selected:text-muted-foreground", + day_disabled: "text-muted-foreground opacity-50", + day_range_middle: + "aria-selected:bg-accent aria-selected:text-accent-foreground", + day_hidden: "invisible", + ...classNames, + }} + components={{ + IconLeft: ({ className, ...props }) => ( + + ), + IconRight: ({ className, ...props }) => ( + + ), + }} + {...props} + /> + ) +} +Calendar.displayName = "Calendar" + +export { Calendar } diff --git a/frontend/components/ui/pagination.tsx b/frontend/components/ui/pagination.tsx new file mode 100644 index 0000000..d331105 --- /dev/null +++ b/frontend/components/ui/pagination.tsx @@ -0,0 +1,117 @@ +import * as React from "react" +import { ChevronLeft, ChevronRight, MoreHorizontal } from "lucide-react" + +import { cn } from "@/lib/utils" +import { ButtonProps, buttonVariants } from "@/components/ui/button" + +const Pagination = ({ className, ...props }: React.ComponentProps<"nav">) => ( +