diff --git a/backend/internal/api/api.go b/backend/internal/api/api.go index 24d50e9..cc64d33 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,18 +72,17 @@ 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) + a.registerUserRoutes(r, api) }) return router 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/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/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/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/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_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/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 } 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") +} 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 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/farm.ts b/frontend/api/farm.ts new file mode 100644 index 0000000..4e18614 --- /dev/null +++ b/frontend/api/farm.ts @@ -0,0 +1,198 @@ +import axiosInstance from "./config"; +import type { Crop, CropAnalytics, Farm } from "@/types"; + +/** + * Fetch a specific crop by id using axios. + * Falls back to dummy data on error. + */ +export async function fetchCropById(id: string): Promise { + try { + const response = await axiosInstance.get(`/api/crops/${id}`); + return response.data; + } catch (error) { + // Fallback dummy data + return { + id, + farmId: "1", + name: "Monthong Durian", + plantedDate: new Date("2024-01-15"), + status: "growing", + variety: "Premium Grade", + expectedHarvest: new Date("2024-07-15"), + area: "2.5 hectares", + healthScore: 85, + }; + } +} + +/** + * Fetch crop analytics by crop id using axios. + * Returns dummy analytics if the API call fails. + */ +export async function fetchAnalyticsByCropId(id: string): Promise { + try { + const response = await axiosInstance.get(`/api/crops/${id}/analytics`); + return response.data; + } catch (error) { + return { + cropId: id, + growthProgress: 45, + humidity: 75, + temperature: 28, + sunlight: 85, + waterLevel: 65, + plantHealth: "good", + nextAction: "Water the plant", + nextActionDue: new Date("2024-02-15"), + soilMoisture: 70, + windSpeed: "12 km/h", + rainfall: "25mm last week", + nutrientLevels: { + nitrogen: 80, + phosphorus: 65, + potassium: 75, + }, + }; + } +} + +/** + * Fetch an array of farms using axios. + * Simulates a delay and a random error; returns dummy data if the API is unavailable. + */ +export async function fetchFarms(): Promise { + // Simulate network delay + await new Promise((resolve) => setTimeout(resolve, 1000)); + + try { + const response = await axiosInstance.get("/api/farms"); + return response.data; + } catch (error) { + // Optionally, you could simulate a random error here. For now we return fallback data. + return [ + { + id: "1", + name: "Green Valley Farm", + location: "Bangkok", + type: "durian", + createdAt: new Date("2023-01-01"), + area: "12.5 hectares", + crops: 5, + }, + { + id: "2", + name: "Sunrise Orchard", + location: "Chiang Mai", + type: "mango", + createdAt: new Date("2023-02-15"), + area: "8.3 hectares", + crops: 3, + }, + { + id: "3", + name: "Golden Harvest Fields", + location: "Phuket", + type: "rice", + createdAt: new Date("2023-03-22"), + area: "20.1 hectares", + crops: 2, + }, + ]; + } +} + +/** + * Simulates creating a new farm. + * Waits for 800ms and then uses dummy data. + */ +export async function createFarm(data: Partial): Promise { + await new Promise((resolve) => setTimeout(resolve, 800)); + // In a real implementation you might call: + // const response = await axiosInstance.post("/api/farms", data); + // return response.data; + return { + id: Math.random().toString(36).substr(2, 9), + name: data.name!, + location: data.location!, + type: data.type!, + createdAt: new Date(), + area: data.area || "0 hectares", + crops: 0, + }; +} + +// Additional functions for fetching crop details remain unchanged... + +/** + * Fetch detailed information for a specific farm (including its crops) using axios. + * If the API call fails, returns fallback dummy data. + */ +export async function fetchFarmDetails(farmId: string): Promise<{ farm: Farm; crops: Crop[] }> { + // Simulate network delay + await new Promise((resolve) => setTimeout(resolve, 1200)); + + try { + const response = await axiosInstance.get<{ farm: Farm; crops: Crop[] }>(`/api/farms/${farmId}`); + return response.data; + } catch (error) { + // If the given farmId is "999", simulate a not found error. + if (farmId === "999") { + throw new Error("FARM_NOT_FOUND"); + } + + const farm: Farm = { + id: farmId, + name: "Green Valley Farm", + location: "Bangkok, Thailand", + type: "durian", + createdAt: new Date("2023-01-15"), + area: "12.5 hectares", + crops: 3, + // Additional details such as weather can be included if needed. + weather: { + temperature: 28, + humidity: 75, + rainfall: "25mm last week", + sunlight: 85, + }, + }; + + const crops: Crop[] = [ + { + id: "1", + farmId, + name: "Monthong Durian", + plantedDate: new Date("2023-03-15"), + status: "growing", + variety: "Premium", + area: "4.2 hectares", + healthScore: 92, + progress: 65, + }, + { + id: "2", + farmId, + name: "Chanee Durian", + plantedDate: new Date("2023-02-20"), + status: "planned", + variety: "Standard", + area: "3.8 hectares", + healthScore: 0, + progress: 0, + }, + { + id: "3", + farmId, + name: "Kradum Durian", + plantedDate: new Date("2022-11-05"), + status: "harvested", + variety: "Premium", + area: "4.5 hectares", + healthScore: 100, + progress: 100, + }, + ]; + + return { farm, crops }; + } +} diff --git a/frontend/api/hub.ts b/frontend/api/hub.ts new file mode 100644 index 0000000..4ed0325 --- /dev/null +++ b/frontend/api/hub.ts @@ -0,0 +1,148 @@ +import axiosInstance from "./config"; +import type { Blog } from "@/types"; + +// Dummy blog data used as a fallback. +const dummyBlogs: Blog[] = [ + { + id: 1, + title: "Sustainable Farming Practices for Modern Agriculture", + description: + "Learn about eco-friendly farming techniques that can increase yield while preserving the environment.", + date: "2023-05-15", + author: "Emma Johnson", + topic: "Sustainability", + image: "/placeholder.svg?height=400&width=600", + readTime: "5 min read", + featured: true, + content: `

Sustainable farming is not just a trend; it's a necessary evolution in agricultural practices. […]

`, + tableOfContents: [ + { id: "importance", title: "The Importance of Sustainable Agriculture", level: 1 }, + { id: "crop-rotation", title: "Crop Rotation and Diversification", level: 1 }, + { id: "ipm", title: "Integrated Pest Management (IPM)", level: 1 }, + { id: "water-conservation", title: "Water Conservation Techniques", level: 1 }, + { id: "soil-health", title: "Soil Health Management", level: 1 }, + { id: "renewable-energy", title: "Renewable Energy Integration", level: 1 }, + { id: "conclusion", title: "Conclusion", level: 1 }, + ], + relatedArticles: [ + { + id: 2, + title: "Optimizing Fertilizer Usage for Maximum Crop Yield", + topic: "Fertilizers", + image: "/placeholder.svg?height=200&width=300", + description: "", + date: "", + author: "", + readTime: "", + featured: false, + }, + { + id: 4, + title: "Water Conservation Techniques for Drought-Prone Areas", + topic: "Sustainability", + image: "/placeholder.svg?height=200&width=300", + description: "", + date: "", + author: "", + readTime: "", + featured: false, + }, + { + id: 5, + title: "Organic Pest Control Methods That Actually Work", + topic: "Organic", + image: "/placeholder.svg?height=200&width=300", + description: "", + date: "", + author: "", + readTime: "", + featured: false, + }, + ], + }, + { + id: 2, + title: "Optimizing Fertilizer Usage for Maximum Crop Yield", + description: "Discover the perfect balance of fertilizers to maximize your harvest without wasting resources.", + date: "2023-06-02", + author: "Michael Chen", + topic: "Fertilizers", + image: "/placeholder.svg?height=400&width=600", + readTime: "7 min read", + featured: false, + }, + { + id: 3, + title: "Seasonal Planting Guide: What to Grow and When", + description: + "A comprehensive guide to help you plan your planting schedule throughout the year for optimal results.", + date: "2023-06-18", + author: "Sarah Williams", + topic: "Plantation", + image: "/placeholder.svg?height=400&width=600", + readTime: "8 min read", + featured: false, + }, + { + id: 4, + title: "Water Conservation Techniques for Drought-Prone Areas", + description: "Essential strategies to maintain your crops during water shortages and drought conditions.", + date: "2023-07-05", + author: "David Rodriguez", + topic: "Sustainability", + image: "/placeholder.svg?height=400&width=600", + readTime: "6 min read", + featured: false, + }, + { + id: 5, + title: "Organic Pest Control Methods That Actually Work", + description: "Natural and effective ways to keep pests at bay without resorting to harmful chemicals.", + date: "2023-07-22", + author: "Lisa Thompson", + topic: "Organic", + image: "/placeholder.svg?height=400&width=600", + readTime: "9 min read", + featured: false, + }, + { + id: 6, + title: "The Future of Smart Farming: IoT and Agriculture", + description: "How Internet of Things technology is revolutionizing the way we monitor and manage farms.", + date: "2023-08-10", + author: "James Wilson", + topic: "Technology", + image: "/placeholder.svg?height=400&width=600", + readTime: "10 min read", + featured: true, + }, +]; + +/** + * Fetches a list of blog posts. + * Simulates a network delay and returns dummy data when the API endpoint is unavailable. + */ +export async function fetchBlogs(): Promise { + await new Promise((resolve) => setTimeout(resolve, 1000)); + try { + const response = await axiosInstance.get("/api/blogs"); + return response.data; + } catch (error) { + return dummyBlogs; + } +} + +/** + * Fetches a single blog post by its id. + * Returns the API result if available; otherwise falls back to dummy data. + */ +export async function fetchBlogById(id: string): Promise { + await new Promise((resolve) => setTimeout(resolve, 500)); + try { + const response = await axiosInstance.get(`/api/blogs/${id}`); + return response.data; + } catch (error) { + const blog = dummyBlogs.find((blog) => blog.id === Number(id)); + return blog || null; + } +} diff --git a/frontend/api/inventory.ts b/frontend/api/inventory.ts new file mode 100644 index 0000000..7d8982d --- /dev/null +++ b/frontend/api/inventory.ts @@ -0,0 +1,97 @@ +import axiosInstance from "./config"; +import type { InventoryItem, CreateInventoryItemInput } from "@/types"; + +/** + * Simulates an API call to fetch inventory items. + * Waits for a simulated delay and then attempts an axios GET request. + * If the request fails, returns fallback dummy data. + */ +export async function fetchInventoryItems(): Promise { + try { + const response = await axiosInstance.get("/api/inventory"); + return response.data; + } catch (error) { + // Fallback dummy data + return [ + { + id: 1, + name: "Tomato Seeds", + category: "Seeds", + type: "Plantation", + quantity: 500, + unit: "packets", + lastUpdated: "2023-03-01", + status: "In Stock", + }, + { + id: 2, + name: "NPK Fertilizer", + category: "Fertilizer", + type: "Fertilizer", + quantity: 200, + unit: "kg", + lastUpdated: "2023-03-05", + status: "Low Stock", + }, + { + id: 3, + name: "Corn Seeds", + category: "Seeds", + type: "Plantation", + quantity: 300, + unit: "packets", + lastUpdated: "2023-03-10", + status: "In Stock", + }, + { + id: 4, + name: "Organic Compost", + category: "Fertilizer", + type: "Fertilizer", + quantity: 150, + unit: "kg", + lastUpdated: "2023-03-15", + status: "In Stock", + }, + { + id: 5, + name: "Wheat Seeds", + category: "Seeds", + type: "Plantation", + quantity: 250, + unit: "packets", + lastUpdated: "2023-03-20", + status: "In Stock", + }, + ]; + } +} + +/** + * Simulates creating a new inventory item. + * Uses axios POST and if unavailable, returns a simulated response. + * + * Note: The function accepts all fields except id, lastUpdated, and status. + */ +export async function createInventoryItem( + item: Omit +): Promise { + // Simulate network delay + await new Promise((resolve) => setTimeout(resolve, 500)); + try { + const response = await axiosInstance.post("/api/inventory", item); + return response.data; + } catch (error) { + // Simulate successful creation if API endpoint is not available + return { + id: Math.floor(Math.random() * 1000), + name: item.name, + category: item.category, + type: item.type, + quantity: item.quantity, + unit: item.unit, + lastUpdated: new Date().toISOString(), + status: "In Stock", + }; + } +} 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/app/(sidebar)/chatbot/page.tsx b/frontend/app/(sidebar)/chatbot/page.tsx new file mode 100644 index 0000000..a73b48f --- /dev/null +++ b/frontend/app/(sidebar)/chatbot/page.tsx @@ -0,0 +1,693 @@ +"use client"; + +import { useState, useRef, useEffect } from "react"; +import { useRouter } from "next/navigation"; +import { + ChevronLeft, + Send, + Clock, + X, + Leaf, + MessageSquare, + History, + PanelRightClose, + PanelRightOpen, + Search, + Sparkles, +} from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { ScrollArea } from "@/components/ui/scroll-area"; +import { Avatar } from "@/components/ui/avatar"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; +import { Badge } from "@/components/ui/badge"; +import { Card } from "@/components/ui/card"; +import { Separator } from "@/components/ui/separator"; +import type { Farm, Crop } from "@/types"; + +// Mock data for farms and crops +const mockFarms: Farm[] = [ + { + id: "farm1", + name: "Green Valley Farm", + location: "California", + type: "Organic", + createdAt: new Date("2023-01-15"), + area: "120 acres", + crops: 8, + weather: { + temperature: 24, + humidity: 65, + rainfall: "2mm", + sunlight: 80, + }, + }, + { + id: "farm2", + name: "Sunrise Fields", + location: "Iowa", + type: "Conventional", + createdAt: new Date("2022-11-05"), + area: "350 acres", + crops: 5, + weather: { + temperature: 22, + humidity: 58, + rainfall: "0mm", + sunlight: 90, + }, + }, +]; + +const mockCrops: Crop[] = [ + { + id: "crop1", + farmId: "farm1", + name: "Organic Tomatoes", + plantedDate: new Date("2023-03-10"), + status: "Growing", + variety: "Roma", + area: "15 acres", + healthScore: 92, + progress: 65, + }, + { + id: "crop2", + farmId: "farm1", + name: "Sweet Corn", + plantedDate: new Date("2023-04-05"), + status: "Growing", + variety: "Golden Bantam", + area: "25 acres", + healthScore: 88, + progress: 45, + }, + { + id: "crop3", + farmId: "farm2", + name: "Soybeans", + plantedDate: new Date("2023-05-15"), + status: "Growing", + variety: "Pioneer", + area: "120 acres", + healthScore: 95, + progress: 30, + }, +]; + +// Mock chat history +interface ChatMessage { + id: string; + content: string; + sender: "user" | "bot"; + timestamp: Date; + relatedTo?: { + type: "farm" | "crop"; + id: string; + name: string; + }; +} + +const mockChatHistory: ChatMessage[] = [ + { + id: "msg1", + content: "When should I harvest my tomatoes?", + sender: "user", + timestamp: new Date("2023-07-15T10:30:00"), + relatedTo: { + type: "crop", + id: "crop1", + name: "Organic Tomatoes", + }, + }, + { + id: "msg2", + content: + "Based on the current growth stage of your Roma tomatoes, they should be ready for harvest in approximately 2-3 weeks. The ideal time to harvest is when they've developed their full red color but are still firm to the touch. Keep monitoring the soil moisture levels as consistent watering during the final ripening stage is crucial for flavor development.", + sender: "bot", + timestamp: new Date("2023-07-15T10:30:30"), + }, + { + id: "msg3", + content: "What's the best fertilizer for corn?", + sender: "user", + timestamp: new Date("2023-07-16T14:22:00"), + relatedTo: { + type: "crop", + id: "crop2", + name: "Sweet Corn", + }, + }, + { + id: "msg4", + content: + "For your Sweet Corn at Green Valley Farm, I recommend a nitrogen-rich fertilizer with an NPK ratio of approximately 16-4-8. Corn is a heavy nitrogen feeder, especially during its growth phase. Apply the fertilizer when the plants are knee-high and again when they begin to tassel. Based on your soil analysis, consider supplementing with sulfur to address the slight deficiency detected in your last soil test.", + sender: "bot", + timestamp: new Date("2023-07-16T14:22:45"), + }, +]; + +// Recommended prompts +const recommendedPrompts = [ + { + id: "prompt1", + text: "When should I water my crops?", + category: "Irrigation", + }, + { + id: "prompt2", + text: "How can I improve soil health?", + category: "Soil Management", + }, + { + id: "prompt3", + text: "What pests might affect my crops this season?", + category: "Pest Control", + }, + { + id: "prompt4", + text: "Recommend a crop rotation plan", + category: "Planning", + }, + { + id: "prompt5", + text: "How to maximize yield for my current crops?", + category: "Optimization", + }, + { + id: "prompt6", + text: "What's the best time to harvest?", + category: "Harvesting", + }, +]; + +export default function ChatbotPage() { + const [messages, setMessages] = useState([]); + const [inputValue, setInputValue] = useState(""); + const [isHistoryOpen, setIsHistoryOpen] = useState(false); + const [selectedFarm, setSelectedFarm] = useState(null); + const [selectedCrop, setSelectedCrop] = useState(null); + const [isLoading, setIsLoading] = useState(false); + const messagesEndRef = useRef(null); + const router = useRouter(); + + // Initialize with a welcome message + useEffect(() => { + setMessages([ + { + id: "welcome", + content: + "👋 Hello! I'm ForFarm Assistant, your farming AI companion. How can I help you today? You can ask me about crop management, pest control, weather impacts, or select a specific farm or crop to get tailored advice.", + sender: "bot", + timestamp: new Date(), + }, + ]); + }, []); + + // Scroll to bottom of messages + useEffect(() => { + messagesEndRef.current?.scrollIntoView({ behavior: "smooth" }); + }, [messages]); + + // Filter crops based on selected farm + const filteredCrops = selectedFarm ? mockCrops.filter((crop) => crop.farmId === selectedFarm) : mockCrops; + + // Handle sending a message + const handleSendMessage = (content: string = inputValue) => { + if (!content.trim()) return; + + // Create user message + const userMessage: ChatMessage = { + id: `user-${Date.now()}`, + content, + sender: "user", + timestamp: new Date(), + ...(selectedFarm || selectedCrop + ? { + relatedTo: { + type: selectedCrop ? "crop" : "farm", + id: selectedCrop || selectedFarm || "", + name: selectedCrop + ? mockCrops.find((c) => c.id === selectedCrop)?.name || "" + : mockFarms.find((f) => f.id === selectedFarm)?.name || "", + }, + } + : {}), + }; + + setMessages((prev) => [...prev, userMessage]); + setInputValue(""); + setIsLoading(true); + + // Simulate bot response after a delay + setTimeout(() => { + const botResponse: ChatMessage = { + id: `bot-${Date.now()}`, + content: generateBotResponse(content, selectedFarm, selectedCrop), + sender: "bot", + timestamp: new Date(), + }; + + setMessages((prev) => [...prev, botResponse]); + setIsLoading(false); + }, 1500); + }; + + // Generate a bot response based on the user's message and selected farm/crop + const generateBotResponse = (message: string, farmId: string | null, cropId: string | null): string => { + const lowerMessage = message.toLowerCase(); + + // Get farm and crop details if selected + const farm = farmId ? mockFarms.find((f) => f.id === farmId) : null; + const crop = cropId ? mockCrops.find((c) => c.id === cropId) : null; + + // Personalize response based on selected farm/crop + let contextPrefix = ""; + if (crop) { + contextPrefix = `For your ${crop.name} (${crop.variety}) at ${farm?.name || "your farm"}, `; + } else if (farm) { + contextPrefix = `For ${farm.name}, `; + } + + // Generate response based on message content + if (lowerMessage.includes("water") || lowerMessage.includes("irrigation")) { + return `${contextPrefix}I recommend watering deeply but infrequently to encourage strong root growth. Based on the current weather conditions${ + farm ? ` in ${farm.location}` : "" + } (${farm?.weather?.rainfall || "minimal"} rainfall recently), you should water ${ + crop ? `your ${crop.name}` : "your crops" + } approximately 2-3 times per week, ensuring the soil remains moist but not waterlogged.`; + } else if (lowerMessage.includes("fertiliz") || lowerMessage.includes("nutrient")) { + return `${contextPrefix}a balanced NPK fertilizer with a ratio of 10-10-10 would be suitable for general application. ${ + crop + ? `For ${crop.name} specifically, consider increasing ${ + crop.name.toLowerCase().includes("tomato") + ? "potassium" + : crop.name.toLowerCase().includes("corn") + ? "nitrogen" + : "phosphorus" + } for optimal growth during the current ${ + crop.progress && crop.progress < 30 ? "early" : crop.progress && crop.progress < 70 ? "middle" : "late" + } growth stage.` + : "" + }`; + } else if (lowerMessage.includes("pest") || lowerMessage.includes("insect") || lowerMessage.includes("disease")) { + return `${contextPrefix}monitor for ${ + crop + ? crop.name.toLowerCase().includes("tomato") + ? "tomato hornworms, aphids, and early blight" + : crop.name.toLowerCase().includes("corn") + ? "corn borers, rootworms, and rust" + : "common agricultural pests" + : "common agricultural pests like aphids, beetles, and fungal diseases" + }. I recommend implementing integrated pest management (IPM) practices, including regular scouting, beneficial insects, and targeted treatments only when necessary.`; + } else if (lowerMessage.includes("harvest") || lowerMessage.includes("yield")) { + return `${contextPrefix}${ + crop + ? `your ${crop.name} should be ready to harvest in approximately ${Math.max( + 1, + Math.round((100 - (crop.progress || 50)) / 10) + )} weeks based on the current growth stage. Look for ${ + crop.name.toLowerCase().includes("tomato") + ? "firm, fully colored fruits" + : crop.name.toLowerCase().includes("corn") + ? "full ears with dried silk and plump kernels" + : "signs of maturity specific to this crop type" + }` + : "harvest timing depends on the specific crops you're growing, but generally you should look for visual cues of ripeness and maturity" + }.`; + } else if (lowerMessage.includes("soil") || lowerMessage.includes("compost")) { + return `${contextPrefix}improving soil health is crucial for sustainable farming. I recommend regular soil testing, adding organic matter through compost or cover crops, practicing crop rotation, and minimizing soil disturbance. ${ + farm + ? `Based on the soil type common in ${farm.location}, you might also consider adding ${ + farm.location.includes("California") ? "gypsum to improve drainage" : "lime to adjust pH levels" + }.` + : "" + }`; + } else if (lowerMessage.includes("weather") || lowerMessage.includes("forecast") || lowerMessage.includes("rain")) { + return `${contextPrefix}${ + farm + ? `the current conditions show temperature at ${farm.weather?.temperature}°C with ${farm.weather?.humidity}% humidity. There's been ${farm.weather?.rainfall} of rainfall recently, and sunlight levels are at ${farm.weather?.sunlight}% of optimal.` + : "I recommend checking your local agricultural weather service for the most accurate forecast for your specific location." + } ${ + crop + ? `For your ${crop.name}, ${ + farm?.weather?.rainfall === "0mm" + ? "the dry conditions mean you should increase irrigation" + : "the recent rainfall means you can reduce irrigation temporarily" + }.` + : "" + }`; + } else { + return `${contextPrefix}I understand you're asking about "${message}". To provide the most helpful advice, could you provide more specific details about your farming goals or challenges? I'm here to help with crop management, pest control, irrigation strategies, and more.`; + } + }; + + // Handle selecting a farm + const handleFarmSelect = (farmId: string) => { + setSelectedFarm(farmId); + setSelectedCrop(null); // Reset crop selection when farm changes + }; + + // Handle selecting a crop + const handleCropSelect = (cropId: string) => { + setSelectedCrop(cropId); + }; + + // Handle clicking a recommended prompt + const handlePromptClick = (promptText: string) => { + setInputValue(promptText); + handleSendMessage(promptText); + }; + + // Handle loading a chat history item + const handleLoadChatHistory = (messageId: string) => { + // Find the message in history + const historyItem = mockChatHistory.find((msg) => msg.id === messageId); + if (!historyItem) return; + + // Set related farm/crop if available + if (historyItem.relatedTo) { + if (historyItem.relatedTo.type === "farm") { + setSelectedFarm(historyItem.relatedTo.id); + setSelectedCrop(null); + } else if (historyItem.relatedTo.type === "crop") { + const crop = mockCrops.find((c) => c.id === historyItem.relatedTo?.id); + if (crop) { + setSelectedFarm(crop.farmId); + setSelectedCrop(historyItem.relatedTo.id); + } + } + } + + // Load the conversation + const conversation = mockChatHistory.filter( + (msg) => + msg.id === messageId || + (msg.timestamp >= historyItem.timestamp && msg.timestamp <= new Date(historyItem.timestamp.getTime() + 60000)) + ); + + setMessages(conversation); + setIsHistoryOpen(false); + }; + + return ( +
+ {/* Header */} +
+
+
+ +
+ +

ForFarm Assistant

+
+
+ +
+
+ + {/* Main content */} +
+ {/* Chat area */} +
+ {/* Farm/Crop selector */} +
+
+
+ + +
+
+ + +
+
+
+ + {/* Messages */} + +
+ {messages.map((message) => ( +
+
+ {message.relatedTo && ( +
+ + {message.relatedTo.type === "farm" ? "🏡 " : "🌱 "} + {message.relatedTo.name} + +
+ )} +
{message.content}
+
+ {message.timestamp.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })} +
+
+
+ ))} + {isLoading && ( +
+
+
+
+
+
+ + ForFarm Assistant is typing... + +
+
+
+ )} +
+
+ + + {/* Recommended prompts */} +
+

+ + Recommended Questions +

+
+ {recommendedPrompts.map((prompt) => ( + + ))} +
+
+ + {/* Input area */} +
+
+ setInputValue(e.target.value)} + placeholder="Ask about your farm or crops..." + className="flex-1" + onKeyDown={(e) => { + if (e.key === "Enter" && !e.shiftKey) { + e.preventDefault(); + handleSendMessage(); + } + }} + /> + +
+
+
+ + {/* Chat history sidebar */} + {isHistoryOpen && ( +
+
+

+ + Chat History +

+ +
+ +
+
+ + +
+
+ + + + + Recent + + + By Farm + + + By Crop + + + + + +
+ {mockChatHistory + .filter((msg) => msg.sender === "user") + .sort((a, b) => b.timestamp.getTime() - a.timestamp.getTime()) + .map((message) => ( + handleLoadChatHistory(message.id)}> +
+ +
+ {message.relatedTo?.name.substring(0, 2) || "Me"} +
+
+
+
+

+ {message.relatedTo ? message.relatedTo.name : "General Question"} +

+

+ + {message.timestamp.toLocaleDateString()} +

+
+

+ {message.content} +

+
+
+
+ ))} +
+
+ + +
+ {mockFarms.map((farm) => ( +
+

{farm.name}

+
+ {mockChatHistory + .filter( + (msg) => + msg.sender === "user" && msg.relatedTo?.type === "farm" && msg.relatedTo.id === farm.id + ) + .map((message) => ( + handleLoadChatHistory(message.id)}> +

{message.content}

+

+ {message.timestamp.toLocaleDateString()} +

+
+ ))} +
+ +
+ ))} +
+
+ + +
+ {mockCrops.map((crop) => ( +
+

+ + {crop.name} + + ({mockFarms.find((f) => f.id === crop.farmId)?.name}) + +

+
+ {mockChatHistory + .filter( + (msg) => + msg.sender === "user" && msg.relatedTo?.type === "crop" && msg.relatedTo.id === crop.id + ) + .map((message) => ( + handleLoadChatHistory(message.id)}> +

{message.content}

+

+ {message.timestamp.toLocaleDateString()} +

+
+ ))} +
+ +
+ ))} +
+
+
+
+
+ )} +
+
+ ); +} 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)/farms/[farmId]/crop-card.tsx b/frontend/app/(sidebar)/farms/[farmId]/crop-card.tsx index 6d9dd72..e4e0d35 100644 --- a/frontend/app/(sidebar)/farms/[farmId]/crop-card.tsx +++ b/frontend/app/(sidebar)/farms/[farmId]/crop-card.tsx @@ -1,37 +1,98 @@ -import { Card, CardContent, CardHeader } from "@/components/ui/card"; -import { Sprout, Calendar } from "lucide-react"; -import { Crop } from "@/types"; +"use client"; + +import { Card, CardContent, CardHeader, CardFooter } from "@/components/ui/card"; +import { Sprout, Calendar, ArrowRight, BarChart } from "lucide-react"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Progress } from "@/components/ui/progress"; +import type { 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", - planned: "text-blue-500", + growing: { + bg: "bg-green-50 dark:bg-green-900", + text: "text-green-600 dark:text-green-300", + border: "border-green-200", + }, + harvested: { + bg: "bg-yellow-50 dark:bg-yellow-900", + text: "text-yellow-600 dark:text-yellow-300", + border: "border-yellow-200", + }, + planned: { + bg: "bg-blue-50 dark:bg-blue-900", + text: "text-blue-600 dark:text-blue-300", + border: "border-blue-200", + }, }; + const statusColor = statusColors[crop.status as keyof typeof statusColors]; + return ( - - + +
-
- + + {crop.status} + +
+ + {crop.plantedDate.toLocaleDateString()}
- {crop.status}
-
-

{crop.name}

-
- -

Planted: {crop.plantedDate.toLocaleDateString()}

+
+
+ +
+
+

{crop.name}

+

+ {crop.variety} • {crop.area} +

+ + {crop.status !== "planned" && ( +
+
+ Progress + {crop.progress}% +
+ +
+ )} + + {crop.status === "growing" && ( +
+
+ + Health: {crop.healthScore}% +
+
+ )}
+ + + ); } 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..c57f3cd --- /dev/null +++ b/frontend/app/(sidebar)/farms/[farmId]/crop-dialog.tsx @@ -0,0 +1,126 @@ +"use client"; + +import { useState } from "react"; +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; + 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 */} +
+
+
+ +
+
+
+ + {/* Footer */} +
+
+ + +
+
+
+
+
+ ); +} 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..c7ea5f2 --- /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..72873b0 --- /dev/null +++ b/frontend/app/(sidebar)/farms/[farmId]/crops/[cropId]/chatbot-dialog.tsx @@ -0,0 +1,89 @@ +"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 Chat + + +
+
+

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..0b767fd --- /dev/null +++ b/frontend/app/(sidebar)/farms/[farmId]/crops/[cropId]/page.tsx @@ -0,0 +1,407 @@ +"use client"; + +import React, { useEffect, useState } from "react"; +import { useRouter } from "next/navigation"; +import { + ArrowLeft, + Sprout, + LineChart, + MessageSquare, + Settings, + Droplets, + Sun, + ThermometerSun, + Timer, + ListCollapse, + Calendar, + Leaf, + CloudRain, + Wind, +} from "lucide-react"; +import { Card, CardContent, CardHeader, CardTitle, CardDescription } 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 { ScrollArea } from "@/components/ui/scroll-area"; +import { ChatbotDialog } from "./chatbot-dialog"; +import { AnalyticsDialog } from "./analytics-dialog"; +import { HoverCard, HoverCardContent, HoverCardTrigger } from "@/components/ui/hover-card"; +import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; +import type { Crop, CropAnalytics } from "@/types"; +import GoogleMapWithDrawing from "@/components/google-map-with-drawing"; +import { fetchCropById, fetchAnalyticsByCropId } from "@/api/farm"; + +interface CropDetailPageParams { + farmId: string; + cropId: string; +} + +export default function CropDetailPage({ params }: { params: Promise }) { + const router = useRouter(); + const [crop, setCrop] = useState(null); + const [analytics, setAnalytics] = useState(null); + const [isChatOpen, setIsChatOpen] = useState(false); + const [isAnalyticsOpen, setIsAnalyticsOpen] = useState(false); + + useEffect(() => { + async function fetchData() { + const resolvedParams = await params; + const cropData = await fetchCropById(resolvedParams.cropId); + const analyticsData = await fetchAnalyticsByCropId(resolvedParams.cropId); + setCrop(cropData); + setAnalytics(analyticsData); + } + fetchData(); + }, [params]); + + if (!crop || !analytics) { + return ( +
Loading...
+ ); + } + + const healthColors = { + good: "text-green-500 bg-green-50 dark:bg-green-900", + warning: "text-yellow-500 bg-yellow-50 dark:bg-yellow-900", + critical: "text-red-500 bg-red-50 dark:bg-red-900", + }; + + const quickActions = [ + { + title: "Analytics", + icon: LineChart, + description: "View detailed growth analytics", + onClick: () => setIsAnalyticsOpen(true), + color: "bg-blue-50 dark:bg-blue-900 text-blue-600 dark:text-blue-300", + }, + { + title: "Chat Assistant", + icon: MessageSquare, + description: "Get help and advice", + onClick: () => setIsChatOpen(true), + color: "bg-green-50 dark:bg-green-900 text-green-600 dark:text-green-300", + }, + { + title: "Crop Details", + icon: ListCollapse, + description: "View detailed information", + onClick: () => console.log("Details clicked"), + color: "bg-purple-50 dark:bg-purple-900 text-purple-600 dark:text-purple-300", + }, + { + title: "Settings", + icon: Settings, + description: "Configure crop settings", + onClick: () => console.log("Settings clicked"), + color: "bg-gray-50 dark:bg-gray-900 text-gray-600 dark:text-gray-300", + }, + ]; + + return ( +
+
+ {/* Header */} +
+
+ + + + + + +
+ + + + + + +
+

Growth Timeline

+

Planted on {crop.plantedDate.toLocaleDateString()}

+
+ + + {Math.floor(analytics.growthProgress)}% Complete + + +
+
+
+
+
+
+ +
+
+

{crop.name}

+

+ {crop.variety} • {crop.area} +

+
+
+
+
+ + Health Score: {crop.healthScore}% + + + Growing + +
+ {crop.expectedHarvest ? ( +

+ Expected harvest: {crop.expectedHarvest.toLocaleDateString()} +

+ ) : ( +

Expected harvest date not available

+ )} +
+
+
+
+ + {/* Main Content */} +
+ {/* Left Column */} +
+ {/* Quick Actions */} +
+ {quickActions.map((action) => ( + + ))} +
+ + {/* Environmental Metrics */} + + + Environmental Conditions + Real-time monitoring of growing conditions + + +
+
+ {[ + { + icon: ThermometerSun, + label: "Temperature", + value: `${analytics.temperature}°C`, + color: "text-orange-500 dark:text-orange-300", + bg: "bg-orange-50 dark:bg-orange-900", + }, + { + icon: Droplets, + label: "Humidity", + value: `${analytics.humidity}%`, + color: "text-blue-500 dark:text-blue-300", + bg: "bg-blue-50 dark:bg-blue-900", + }, + { + icon: Sun, + label: "Sunlight", + value: `${analytics.sunlight}%`, + color: "text-yellow-500 dark:text-yellow-300", + bg: "bg-yellow-50 dark:bg-yellow-900", + }, + { + icon: Leaf, + label: "Soil Moisture", + value: `${analytics.soilMoisture}%`, + color: "text-green-500 dark:text-green-300", + bg: "bg-green-50 dark:bg-green-900", + }, + { + icon: Wind, + label: "Wind Speed", + value: analytics.windSpeed, + color: "text-gray-500 dark:text-gray-300", + bg: "bg-gray-50 dark:bg-gray-900", + }, + { + icon: CloudRain, + label: "Rainfall", + value: analytics.rainfall, + color: "text-indigo-500 dark:text-indigo-300", + bg: "bg-indigo-50 dark:bg-indigo-900", + }, + ].map((metric) => ( + + +
+
+ +
+
+

{metric.label}

+

{metric.value}

+
+
+
+
+ ))} +
+ + + + {/* Growth Progress */} +
+
+ Growth Progress + {analytics.growthProgress}% +
+ +
+ + {/* Next Action Card */} + + +
+
+ +
+
+

Next Action Required

+

{analytics.nextAction}

+

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

+
+
+
+
+
+
+
+ + {/* Map Section */} + + + Field Map + View and manage crop location + + + + + +
+ + {/* Right Column */} +
+ {/* Nutrient Levels */} + + + Nutrient Levels + Current soil composition + + +
+ {[ + { + name: "Nitrogen (N)", + value: analytics.nutrientLevels.nitrogen, + color: "bg-blue-500 dark:bg-blue-700", + }, + { + name: "Phosphorus (P)", + value: analytics.nutrientLevels.phosphorus, + color: "bg-yellow-500 dark:bg-yellow-700", + }, + { + name: "Potassium (K)", + value: analytics.nutrientLevels.potassium, + color: "bg-green-500 dark:bg-green-700", + }, + ].map((nutrient) => ( +
+
+ {nutrient.name} + {nutrient.value}% +
+ +
+ ))} +
+
+
+ + {/* Recent Activity */} + + + Recent Activity + Latest updates and changes + + + + {[...Array(5)].map((_, i) => ( +
+
+
+ +
+
+

+ { + [ + "Irrigation completed", + "Nutrient levels checked", + "Growth measurement taken", + "Pest inspection completed", + "Soil pH tested", + ][i] + } +

+

2 hours ago

+
+
+ {i < 4 && } +
+ ))} +
+
+
+
+
+ + {/* Dialogs */} + + +
+
+ ); +} + +/** + * Helper component to render an activity icon based on the index. + */ +function Activity({ icon }: { icon: number }) { + const icons = [ + , + , + , + , + , + ]; + return icons[icon]; +} diff --git a/frontend/app/(sidebar)/farms/[farmId]/page.tsx b/frontend/app/(sidebar)/farms/[farmId]/page.tsx index aa500ce..8c0d879 100644 --- a/frontend/app/(sidebar)/farms/[farmId]/page.tsx +++ b/frontend/app/(sidebar)/farms/[farmId]/page.tsx @@ -1,157 +1,459 @@ "use client"; -import { useState } from "react"; +import React, { useState, useEffect } from "react"; import { useRouter } from "next/navigation"; -import { ArrowLeft, MapPin, Plus, Sprout } from "lucide-react"; -import { Card, CardContent, CardHeader } from "@/components/ui/card"; +import { + ArrowLeft, + MapPin, + Plus, + Sprout, + Calendar, + LayoutGrid, + AlertTriangle, + Loader2, + Home, + ChevronRight, + Droplets, + Sun, + Wind, +} from "lucide-react"; +import { Card, CardContent, CardHeader, CardTitle, CardDescription } 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"; +import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; +import { Badge } from "@/components/ui/badge"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { motion, AnimatePresence } from "framer-motion"; +import type { Farm, Crop } from "@/types"; +import { fetchFarmDetails } from "@/api/farm"; -const crops: Crop[] = [ - { - id: "crop1", - farmId: "1", - name: "Monthong Durian", - plantedDate: new Date("2023-03-15"), - status: "growing", - }, - { - id: "crop2", - farmId: "1", - name: "Chanee Durian", - plantedDate: new Date("2023-02-20"), - status: "planned", - }, - { - id: "crop3", - farmId: "2", - name: "Kradum Durian", - plantedDate: new Date("2022-11-05"), - status: "harvested", - }, -]; +/** + * Used in Next.js; params is now a Promise and must be unwrapped with React.use() + */ +interface FarmDetailPageProps { + params: Promise<{ farmId: string }>; +} -const farms: Farm[] = [ - { - id: "1", - name: "Green Valley Farm", - location: "Bangkok", - type: "durian", - createdAt: new Date("2023-01-01"), - }, - { - id: "2", - name: "Golden Farm", - location: "Chiang Mai", - type: "mango", - createdAt: new Date("2022-12-01"), - }, -]; - -const getFarmById = (id: string): Farm | undefined => { - return farms.find((farm) => farm.id === id); -}; - -const getCropsByFarmId = (farmId: string): Crop[] => crops.filter((crop) => crop.farmId === farmId); - -export default function FarmDetailPage({ params }: { params: Promise<{ farmId: string }> }) { - const { farmId } = React.use(params); +export default function FarmDetailPage({ params }: FarmDetailPageProps) { + // Unwrap the promised params using React.use() (experimental) + const resolvedParams = React.use(params); const router = useRouter(); - const [farm] = useState(getFarmById(farmId)); - const [crops, setCrops] = useState(getCropsByFarmId(farmId)); + const [farm, setFarm] = useState(null); + const [crops, setCrops] = useState([]); const [isDialogOpen, setIsDialogOpen] = useState(false); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); + const [activeFilter, setActiveFilter] = useState("all"); + // Fetch farm details on initial render using the resolved params + useEffect(() => { + async function loadFarmDetails() { + try { + setIsLoading(true); + setError(null); + const { farm, crops } = await fetchFarmDetails(resolvedParams.farmId); + setFarm(farm); + setCrops(crops); + } catch (err) { + if (err instanceof Error) { + if (err.message === "FARM_NOT_FOUND") { + router.push("/not-found"); + return; + } + setError(err.message); + } else { + setError("An unknown error occurred"); + } + } finally { + setIsLoading(false); + } + } + + loadFarmDetails(); + }, [resolvedParams.farmId, router]); + + /** + * Handles adding a new crop. + */ const handleAddCrop = async (data: Partial) => { - const newCrop: Crop = { - id: Math.random().toString(36).substr(2, 9), - farmId: farm!.id, - name: data.name!, - plantedDate: data.plantedDate!, - status: data.status!, - }; - setCrops((prevCrops) => [...prevCrops, newCrop]); - setIsDialogOpen(false); + try { + // Simulate API delay + await new Promise((resolve) => setTimeout(resolve, 800)); + + const newCrop: Crop = { + id: Math.random().toString(36).substr(2, 9), + farmId: farm!.id, + name: data.name!, + plantedDate: data.plantedDate!, + status: data.status!, + variety: data.variety || "Standard", + area: data.area || "0 hectares", + healthScore: data.status === "growing" ? 85 : 0, + progress: data.status === "growing" ? 10 : 0, + }; + + setCrops((prev) => [newCrop, ...prev]); + + // Update the farm's crop count + if (farm) { + setFarm({ ...farm, crops: farm.crops + 1 }); + } + + setIsDialogOpen(false); + } catch (err) { + setError("Failed to add crop. Please try again."); + } + }; + + // Filter crops based on the active filter + const filteredCrops = crops.filter((crop) => activeFilter === "all" || crop.status === activeFilter); + + // Calculate crop counts grouped by status + const cropCounts = { + all: crops.length, + growing: crops.filter((crop) => crop.status === "growing").length, + planned: crops.filter((crop) => crop.status === "planned").length, + harvested: crops.filter((crop) => crop.status === "harvested").length, }; return ( -
- +
+
+
+ {/* Breadcrumbs */} + -
- - -
-
- -
-

{farm?.name ?? "Unknown Farm"}

-
-
- - {farm?.location ?? "Unknown Location"} -
-
- -
-
- Farm Type: - {farm?.type ?? "Unknown Type"} -
-
- Created: - {farm?.createdAt?.toLocaleDateString() ?? "Unknown Date"} -
-
- Total Crops: - {crops.length} -
-
-
-
+ {/* Back button */} + -
-

Crops

- -
- - setIsDialogOpen(true)}> - -
-
- + {/* Error state */} + {error && ( + + + Error + {error} + + )} + + {/* Loading state */} + {isLoading && ( +
+ +

Loading farm details...

+
+ )} + + {/* Farm details */} + {!isLoading && !error && farm && ( + <> +
+ {/* Farm info card */} + + +
+ + {farm.type} + +
+ + Created {farm.createdAt.toLocaleDateString()} +
-
-

Add Crop

-

Plant a new crop

+
+
+ +
+
+

{farm.name}

+
+ + {farm.location} +
+
+ + +
+
+

Total Area

+

{farm.area}

+
+
+

Total Crops

+

{farm.crops}

+
+
+

Growing Crops

+

{cropCounts.growing}

+
+
+

Harvested

+

{cropCounts.harvested}

+
+
+
+ + + {/* Weather card */} + + + Current Conditions + Weather at your farm location + + +
+
+
+ +
+
+

Temperature

+

{farm.weather?.temperature}°C

+
+
+
+
+ +
+
+

Humidity

+

{farm.weather?.humidity}%

+
+
+
+
+ +
+
+

Sunlight

+

{farm.weather?.sunlight}%

+
+
+
+
+ +
+
+

Rainfall

+

{farm.weather?.rainfall}

+
+
+
+
+
+
+ + {/* Crops section */} +
+
+
+

+ + Crops +

+

Manage and monitor all crops in this farm

- - - - - Add New Crop - Fill out the form to add a new crop to your farm. - - setIsDialogOpen(false)} /> - -
+ +
- {crops.map((crop) => ( - - ))} -
+ + + setActiveFilter("all")}> + All Crops ({cropCounts.all}) + + setActiveFilter("growing")}> + Growing ({cropCounts.growing}) + + setActiveFilter("planned")}> + Planned ({cropCounts.planned}) + + setActiveFilter("harvested")}> + Harvested ({cropCounts.harvested}) + + + + + {filteredCrops.length === 0 ? ( +
+
+ +
+

No crops found

+

+ {activeFilter === "all" + ? "You haven't added any crops to this farm yet." + : `No ${activeFilter} crops found. Try a different filter.`} +

+ +
+ ) : ( +
+ + {filteredCrops.map((crop, index) => ( + + router.push(`/farms/${crop.farmId}/crops/${crop.id}`)} + /> + + ))} + +
+ )} +
+ + {/* Growing tab */} + + {filteredCrops.length === 0 ? ( +
+
+ +
+

No growing crops

+

+ You don't have any growing crops in this farm yet. +

+ +
+ ) : ( +
+ + {filteredCrops.map((crop, index) => ( + + router.push(`/farms/${crop.farmId}/crops/${crop.id}`)} + /> + + ))} + +
+ )} +
+ + {/* Planned tab */} + + {filteredCrops.length === 0 ? ( +
+

No planned crops

+

+ You don't have any planned crops in this farm yet. +

+ +
+ ) : ( +
+ + {filteredCrops.map((crop, index) => ( + + router.push(`/farms/${crop.farmId}/crops/${crop.id}`)} + /> + + ))} + +
+ )} +
+ + {/* Harvested tab */} + + {filteredCrops.length === 0 ? ( +
+

No harvested crops

+

+ You don't have any harvested crops in this farm yet. +

+ +
+ ) : ( +
+ + {filteredCrops.map((crop, index) => ( + + router.push(`/farms/${crop.farmId}/crops/${crop.id}`)} + /> + + ))} + +
+ )} +
+
+
+ + )}
+ + {/* Add Crop Dialog */} +
); } diff --git a/frontend/app/(sidebar)/farms/add-farm-form.tsx b/frontend/app/(sidebar)/farms/add-farm-form.tsx index 288197a..3a23e92 100644 --- a/frontend/app/(sidebar)/farms/add-farm-form.tsx +++ b/frontend/app/(sidebar)/farms/add-farm-form.tsx @@ -7,27 +7,50 @@ import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form"; +import { useState } from "react"; +import { Loader2 } from "lucide-react"; import type { Farm } from "@/types"; -import { farmFormSchema } from "@/schemas/form.schema"; -interface AddFarmFormProps { +const farmFormSchema = z.object({ + name: z.string().min(2, "Farm name must be at least 2 characters"), + location: z.string().min(2, "Location must be at least 2 characters"), + type: z.string().min(1, "Please select a farm type"), + area: z.string().optional(), +}); + +export interface AddFarmFormProps { onSubmit: (data: Partial) => Promise; onCancel: () => void; } export function AddFarmForm({ onSubmit, onCancel }: AddFarmFormProps) { + const [isSubmitting, setIsSubmitting] = useState(false); + const form = useForm>({ resolver: zodResolver(farmFormSchema), defaultValues: { name: "", location: "", type: "", + area: "", }, }); + const handleSubmit = async (values: z.infer) => { + try { + setIsSubmitting(true); + await onSubmit(values); + form.reset(); + } catch (error) { + console.error("Error submitting form:", error); + } finally { + setIsSubmitting(false); + } + }; + return (
- + - This is your farm's display name. + This is your farm's display name. )} @@ -52,6 +75,7 @@ export function AddFarmForm({ onSubmit, onCancel }: AddFarmFormProps) { + City, region or specific address )} @@ -73,6 +97,7 @@ export function AddFarmForm({ onSubmit, onCancel }: AddFarmFormProps) { Durian Mango Rice + Mixed Crops Other @@ -81,11 +106,35 @@ export function AddFarmForm({ onSubmit, onCancel }: AddFarmFormProps) { )} /> + ( + + Total Area (optional) + + + + The total size of your farm + + + )} + /> +
- - +
diff --git a/frontend/app/(sidebar)/farms/farm-card.tsx b/frontend/app/(sidebar)/farms/farm-card.tsx index 210cd1c..40afe47 100644 --- a/frontend/app/(sidebar)/farms/farm-card.tsx +++ b/frontend/app/(sidebar)/farms/farm-card.tsx @@ -1,5 +1,10 @@ -import { Card, CardContent, CardHeader } from "@/components/ui/card"; -import { MapPin, Sprout, Plus } from "lucide-react"; +"use client"; + +import { Card, CardContent, CardHeader, CardFooter } from "@/components/ui/card"; +import { MapPin, Sprout, Plus, CalendarDays, ArrowRight } from "lucide-react"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { cn } from "@/lib/utils"; import type { Farm } from "@/types"; export interface FarmCardProps { @@ -9,50 +14,81 @@ export interface FarmCardProps { } export function FarmCard({ variant, farm, onClick }: FarmCardProps) { - const cardClasses = - "w-full max-w-[240px] bg-muted/50 hover:bg-muted/80 transition-all cursor-pointer group hover:shadow-lg"; + const cardClasses = cn( + "w-full h-full overflow-hidden transition-all duration-200 hover:shadow-lg border", + variant === "add" + ? "bg-green-50/50 dark:bg-green-900/50 hover:bg-green-50/80 dark:hover:bg-green-900/80 border-dashed border-muted/60" + : "bg-white dark:bg-slate-800 hover:bg-muted/10 dark:hover:bg-slate-700 border-muted/60" + ); if (variant === "add") { return ( - -
-
- -
-
-

Setup

-

Setup new farm

-
+
+
+
- +

Add New Farm

+

Create a new farm to manage your crops and resources

+
); } if (variant === "farm" && farm) { + const formattedDate = new Intl.DateTimeFormat("en-US", { + year: "numeric", + month: "short", + day: "numeric", + }).format(farm.createdAt); + return (
-
- + + {farm.type} + +
+ + {formattedDate}
- {farm.type}
-
+
+
+ +
-

{farm.name}

-
- -

{farm.location}

+

{farm.name}

+
+ + {farm.location} +
+
+
+

Area

+

{farm.area}

+
+
+

Crops

+

{farm.crops}

+
-
Created {farm.createdAt.toLocaleDateString()}
+ + + ); } diff --git a/frontend/app/(sidebar)/farms/page.tsx b/frontend/app/(sidebar)/farms/page.tsx index 04895ea..957b321 100644 --- a/frontend/app/(sidebar)/farms/page.tsx +++ b/frontend/app/(sidebar)/farms/page.tsx @@ -1,78 +1,275 @@ "use client"; import { useState } from "react"; +import { useRouter } from "next/navigation"; +import { motion, AnimatePresence } from "framer-motion"; +import { Search, Plus, Filter, SlidersHorizontal, Leaf, Calendar, AlertTriangle, Loader2 } from "lucide-react"; +import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; + 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 { Button } from "@/components/ui/button"; +import { + DropdownMenu, + DropdownMenuTrigger, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuLabel, +} from "@/components/ui/dropdown-menu"; +import { Badge } from "@/components/ui/badge"; +import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; + import { FarmCard } from "./farm-card"; import { AddFarmForm } from "./add-farm-form"; import type { Farm } from "@/types"; +import { fetchFarms, createFarm } from "@/api/farm"; export default function FarmSetupPage() { - const [isDialogOpen, setIsDialogOpen] = useState(false); + const router = useRouter(); + const queryClient = useQueryClient(); + const [searchQuery, setSearchQuery] = useState(""); - const [farms, setFarms] = useState([ - { - id: "1", - name: "Green Valley Farm", - location: "Bangkok", - type: "durian", - createdAt: new Date(), + const [activeFilter, setActiveFilter] = useState("all"); + const [sortOrder, setSortOrder] = useState<"newest" | "oldest" | "alphabetical">("newest"); + const [isDialogOpen, setIsDialogOpen] = useState(false); + + const { + data: farms, + isLoading, + isError, + error, + } = useQuery({ + queryKey: ["farms"], + queryFn: fetchFarms, + staleTime: 60 * 1000, + }); + + const mutation = useMutation({ + mutationFn: (data: Partial) => createFarm(data), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["farms"] }); + setIsDialogOpen(false); }, - ]); + }); + + const filteredAndSortedFarms = (farms || []) + .filter( + (farm) => + (activeFilter === "all" || farm.type === activeFilter) && + (farm.name.toLowerCase().includes(searchQuery.toLowerCase()) || + farm.location.toLowerCase().includes(searchQuery.toLowerCase()) || + farm.type.toLowerCase().includes(searchQuery.toLowerCase())) + ) + .sort((a, b) => { + if (sortOrder === "newest") { + return new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(); + } else if (sortOrder === "oldest") { + return new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime(); + } else { + return a.name.localeCompare(b.name); + } + }); + + // Get distinct farm types. + const farmTypes = ["all", ...new Set((farms || []).map((farm) => farm.type))]; const handleAddFarm = async (data: Partial) => { - const newFarm: Farm = { - id: Math.random().toString(36).substr(2, 9), - name: data.name!, - location: data.location!, - type: data.type!, - createdAt: new Date(), - }; - setFarms([...farms, newFarm]); - setIsDialogOpen(false); + await mutation.mutateAsync(data); }; - const filteredFarms = farms.filter( - (farm) => - farm.name.toLowerCase().includes(searchQuery.toLowerCase()) || - farm.location.toLowerCase().includes(searchQuery.toLowerCase()) || - farm.type.toLowerCase().includes(searchQuery.toLowerCase()) - ); - return ( -
-
-

Farms

-
- - setSearchQuery(e.target.value)} - /> +
+
+
+ {/* Header */} +
+
+

Your Farms

+

Manage and monitor all your agricultural properties

+
+
+
+ + setSearchQuery(e.target.value)} + /> +
+ +
+
+ + {/* Filtering and sorting controls */} +
+
+ {farmTypes.map((type) => ( + setActiveFilter(type)}> + {type === "all" ? "All Farms" : type} + + ))} +
+ + + + + + Sort by + + setSortOrder("newest")}> + + Newest first + {sortOrder === "newest" && } + + setSortOrder("oldest")}> + + Oldest first + {sortOrder === "oldest" && } + + setSortOrder("alphabetical")}> + + Alphabetical + {sortOrder === "alphabetical" && } + + + +
+ + + + {/* Error state */} + {isError && ( + + + Error + {(error as Error)?.message} + + )} + + {/* Loading state */} + {isLoading && ( +
+ +

Loading your farms...

+
+ )} + + {/* Empty state */} + {!isLoading && !isError && filteredAndSortedFarms.length === 0 && ( +
+
+ +
+

No farms found

+ {searchQuery || activeFilter !== "all" ? ( +

+ No farms match your current filters. Try adjusting your search or filters. +

+ ) : ( +

+ You haven't added any farms yet. Get started by adding your first farm. +

+ )} + +
+ )} + + {/* Grid of farm cards */} + {!isLoading && !isError && filteredAndSortedFarms.length > 0 && ( +
+ + + setIsDialogOpen(true)} /> + + {filteredAndSortedFarms.map((farm, index) => ( + + router.push(`/farms/${farm.id}`)} /> + + ))} + +
+ )}
- -
- - setIsDialogOpen(true)} /> - - - Setup New Farm - Fill out the form to configure your new farm. - - setIsDialogOpen(false)} /> - - - - {filteredFarms.map((farm) => ( - - ))} -
+ {/* Add Farm Dialog */} + + + + Add New Farm + Fill out the details below to add a new farm to your account. + + setIsDialogOpen(false)} /> + +
); } + +/** + * A helper component for the Check icon. + */ +function Check({ className }: { className?: string }) { + return ( + + + + ); +} diff --git a/frontend/app/(sidebar)/hub/[id]/page.tsx b/frontend/app/(sidebar)/hub/[id]/page.tsx new file mode 100644 index 0000000..497e57a --- /dev/null +++ b/frontend/app/(sidebar)/hub/[id]/page.tsx @@ -0,0 +1,206 @@ +"use client"; + +import { useState, useEffect } from "react"; +import Link from "next/link"; +import Image from "next/image"; +import { ArrowLeft, Calendar, Clock, Share2, Bookmark, ChevronUp } from "lucide-react"; +import { useQuery } from "@tanstack/react-query"; +import { useParams } from "next/navigation"; + +import { Button } from "@/components/ui/button"; +import { Badge } from "@/components/ui/badge"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"; + +import { fetchBlogById } from "@/api/hub"; +import type { Blog } from "@/types"; + +export default function BlogPage() { + // Get the dynamic route parameter. + const params = useParams(); + const blogId = params.id as string; + + // Fetch the blog based on its id. + const { + data: blog, + isLoading, + isError, + } = useQuery({ + queryKey: ["blog", blogId], + queryFn: () => fetchBlogById(blogId), + staleTime: 60 * 1000, + }); + + // Local state for the "scroll to top" button. + const [showScrollTop, setShowScrollTop] = useState(false); + + useEffect(() => { + const handleScroll = () => { + setShowScrollTop(window.scrollY > 300); + }; + + window.addEventListener("scroll", handleScroll); + return () => window.removeEventListener("scroll", handleScroll); + }, []); + + const scrollToTop = () => { + window.scrollTo({ top: 0, behavior: "smooth" }); + }; + + const scrollToSection = (id: string) => { + const element = document.getElementById(id); + if (element) { + element.scrollIntoView({ behavior: "smooth" }); + } + }; + + if (isLoading) { + return
Loading...
; + } + + if (isError || !blog) { + return
Error loading blog.
; + } + + return ( +
+ {/* Header */} +
+
+ + + +
+ + + + + + +

Share article

+
+
+
+ + + + + + + +

Save article

+
+
+
+
+
+
+ + {/* Main content */} +
+
+ {/* Article content */} +
+
+ {blog.topic} +
+

{blog.title}

+
+
+ + {new Date(blog.date).toLocaleDateString()} +
+
+ + {blog.readTime} +
+ By {blog.author} +
+
+ {blog.title} +
+

{blog.description}

+
+
+ + {/* Sidebar */} +
+ {/* Table of contents */} +
+ + + Table of Contents + + + + + + + {/* Related articles */} + {blog.relatedArticles && ( + + + Related Articles + + +
+ {blog.relatedArticles.map((article) => ( + +
+
+ {article.title} +
+
+

+ {article.title} +

+ + {article.topic} + +
+
+ + ))} +
+
+
+ )} +
+
+
+
+ + {/* Scroll to top button */} + {showScrollTop && ( + + )} +
+ ); +} diff --git a/frontend/app/(sidebar)/hub/page.tsx b/frontend/app/(sidebar)/hub/page.tsx new file mode 100644 index 0000000..2e19982 --- /dev/null +++ b/frontend/app/(sidebar)/hub/page.tsx @@ -0,0 +1,209 @@ +"use client"; + +import { useState } from "react"; +import Link from "next/link"; +import Image from "next/image"; +import { CalendarIcon, ChevronRight, Leaf, Search } from "lucide-react"; +import { useQuery } from "@tanstack/react-query"; + +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Badge } from "@/components/ui/badge"; +import { Separator } from "@/components/ui/separator"; +import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card"; + +import { fetchBlogs } from "@/api/hub"; +import type { Blog } from "@/types"; + +export default function KnowledgeHubPage() { + const [selectedTopic, setSelectedTopic] = useState("All"); + const [searchQuery, setSearchQuery] = useState(""); + + // Fetch blogs using react-query. + const { + data: blogs, + isLoading, + isError, + } = useQuery({ + queryKey: ["blogs"], + queryFn: fetchBlogs, + staleTime: 60 * 1000, + }); + + if (isLoading) { + return
Loading...
; + } + + if (isError || !blogs) { + return
Error loading blogs.
; + } + + // Derive the list of topics from the fetched blogs. + const topics = ["All", ...new Set(blogs.map((blog) => blog.topic))]; + + // Filter blogs based on selected topic and search query. + const filteredBlogs = blogs.filter((blog) => { + const matchesTopic = selectedTopic === "All" || blog.topic === selectedTopic; + const matchesSearch = + blog.title.toLowerCase().includes(searchQuery.toLowerCase()) || + blog.description.toLowerCase().includes(searchQuery.toLowerCase()); + return matchesTopic && matchesSearch; + }); + + // Get featured blogs + const featuredBlogs = blogs.filter((blog) => blog.featured); + + return ( +
+
+
+
+ {/* Header */} +
+
+

Knowledge Hub

+

+ Explore our collection of articles, guides, and resources to help you grow better. +

+
+
+ + setSearchQuery(e.target.value)} + /> +
+
+ + {/* Featured article */} + {featuredBlogs.length > 0 && ( +
+

Featured Articles

+
+ {featuredBlogs.slice(0, 2).map((blog) => ( + +
+ {blog.title} +
+
+ {blog.topic} +

{blog.title}

+
+ + {new Date(blog.date).toLocaleDateString()} + + {blog.readTime} +
+
+
+ +
By {blog.author}
+ + + +
+ + ))} +
+
+ )} + + {/* Topic filters */} +
+

Browse by Topic

+
+ {topics.map((topic) => ( + + ))} +
+
+ + + + {/* Blog grid */} +
+ {filteredBlogs.length === 0 ? ( +
+

No articles found

+

+ Try adjusting your search or filter to find what you're looking for. +

+
+ ) : ( + filteredBlogs.map((blog) => ( + +
+ {blog.title} +
+ +
+ {blog.topic} +
{new Date(blog.date).toLocaleDateString()}
+
+ {blog.title} +
+ + {blog.description} + + +
By {blog.author}
+ + + +
+
+ )) + )} +
+ + {/* Pagination - simplified for this example */} + {filteredBlogs.length > 0 && ( +
+ + + + + +
+ )} +
+
+
+
+ ); +} diff --git a/frontend/app/(sidebar)/inventory/add-inventory-item.tsx b/frontend/app/(sidebar)/inventory/add-inventory-item.tsx new file mode 100644 index 0000000..448097c --- /dev/null +++ b/frontend/app/(sidebar)/inventory/add-inventory-item.tsx @@ -0,0 +1,171 @@ +"use client"; + +import { useState } from "react"; +import { CalendarIcon } from "lucide-react"; +import { format } from "date-fns"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; + +import { Button } from "@/components/ui/button"; +import { Calendar } from "@/components/ui/calendar"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; +import { + Select, + SelectContent, + SelectGroup, + SelectItem, + SelectLabel, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { cn } from "@/lib/utils"; +import { createInventoryItem } from "@/api/inventory"; +import type { CreateInventoryItemInput } from "@/types"; + +export function AddInventoryItem() { + const [date, setDate] = useState(); + const [open, setOpen] = useState(false); + const [itemName, setItemName] = useState(""); + const [itemType, setItemType] = useState(""); + const [itemCategory, setItemCategory] = useState(""); + const [itemQuantity, setItemQuantity] = useState(0); + const [itemUnit, setItemUnit] = useState(""); + + const queryClient = useQueryClient(); + + const mutation = useMutation({ + mutationFn: (item: CreateInventoryItemInput) => createInventoryItem(item), + onSuccess: () => { + // Invalidate queries to refresh inventory data. + queryClient.invalidateQueries({ queryKey: ["inventoryItems"] }); + // Reset form fields and close dialog. + setItemName(""); + setItemType(""); + setItemCategory(""); + setItemQuantity(0); + setItemUnit(""); + setDate(undefined); + setOpen(false); + }, + }); + + const handleSave = () => { + // Basic validation (you can extend this as needed) + if (!itemName || !itemType || !itemCategory || !itemUnit) return; + mutation.mutate({ + name: itemName, + type: itemType, + category: itemCategory, + quantity: itemQuantity, + unit: itemUnit, + }); + }; + + return ( + + + + + + + Add Inventory Item + Add a new plantation or fertilizer item to your inventory. + +
+
+ + setItemName(e.target.value)} /> +
+
+ + +
+
+ + setItemCategory(e.target.value)} + /> +
+
+ + setItemQuantity(Number(e.target.value))} + /> +
+
+ + setItemUnit(e.target.value)} + /> +
+
+ + + + + + + + + +
+
+ + + +
+
+ ); +} diff --git a/frontend/app/(sidebar)/inventory/page.tsx b/frontend/app/(sidebar)/inventory/page.tsx new file mode 100644 index 0000000..290461b --- /dev/null +++ b/frontend/app/(sidebar)/inventory/page.tsx @@ -0,0 +1,151 @@ +"use client"; + +import { useState } from "react"; +import { useQuery } from "@tanstack/react-query"; +import { Calendar, ChevronDown, Plus, Search } from "lucide-react"; + +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; +import { Pagination, PaginationContent, PaginationItem, PaginationLink } from "@/components/ui/pagination"; +import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; +import { Calendar as CalendarComponent } from "@/components/ui/calendar"; +import { Badge } from "@/components/ui/badge"; + +import { fetchInventoryItems } from "@/api/inventory"; +import { AddInventoryItem } from "./add-inventory-item"; + +export default function InventoryPage() { + const [date, setDate] = useState(); + const [inventoryType, setInventoryType] = useState("all"); + const [currentPage, setCurrentPage] = useState(1); + + // Fetch inventory items using react-query. + const { + data: inventoryItems, + isLoading, + isError, + } = useQuery({ + queryKey: ["inventoryItems"], + queryFn: fetchInventoryItems, + staleTime: 60 * 1000, + }); + + if (isLoading) { + return
Loading...
; + } + + if (isError || !inventoryItems) { + return ( +
Error loading inventory data.
+ ); + } + + // Filter items based on selected type. + const filteredItems = + inventoryType === "all" + ? inventoryItems + : inventoryItems.filter((item) => + inventoryType === "plantation" ? item.type === "Plantation" : item.type === "Fertilizer" + ); + + return ( +
+
+
+

Inventory

+ + {/* Filters and search */} +
+
+ + +
+ +
+ + + + + + + + + +
+ + +
+ + +
+
+ + {/* Table */} +
+

Table Fields

+ + + + Name + Category + Type + Quantity + Last Updated + Status + + + + {filteredItems.length === 0 ? ( + + + No inventory items found + + + ) : ( + filteredItems.map((item) => ( + + {item.name} + {item.category} + {item.type} + + {item.quantity} {item.unit} + + {item.lastUpdated} + + {item.status} + + + )) + )} + +
+
+
+
+
+ ); +} 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/app/(sidebar)/marketplace/loading.tsx b/frontend/app/(sidebar)/marketplace/loading.tsx new file mode 100644 index 0000000..0b57d1f --- /dev/null +++ b/frontend/app/(sidebar)/marketplace/loading.tsx @@ -0,0 +1,99 @@ +import { Skeleton } from "@/components/ui/skeleton"; +import { Card, CardContent, CardHeader } from "@/components/ui/card"; +import { RefreshCw } from "lucide-react"; + +export default function MarketplaceLoading() { + return ( +
+
+
+ + +
+ +
+ + +
+
+ +
+ + +
+
+ + +
+
+ + +
+
+
+ +
+
+ +

Loading market data...

+
+
+ +
+ + + +
+
+
+ +
+ + + + + + +
+ + + + + +
+
+
+ + + + + + + +
+ + + +
+
+
+
+
+ + + + + + + +
+ + + + +
+
+
+
+ ); +} diff --git a/frontend/app/(sidebar)/marketplace/page.tsx b/frontend/app/(sidebar)/marketplace/page.tsx new file mode 100644 index 0000000..33b21f0 --- /dev/null +++ b/frontend/app/(sidebar)/marketplace/page.tsx @@ -0,0 +1,1020 @@ +"use client"; + +import { useState, useEffect } from "react"; +import { useSearchParams } from "next/navigation"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle +} from "@/components/ui/card"; +import { + Tabs, + TabsContent, + TabsList, + TabsTrigger +} from "@/components/ui/tabs"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue +} from "@/components/ui/select"; +import { Button } from "@/components/ui/button"; +import { Badge } from "@/components/ui/badge"; +import { + LineChart, + Line, + XAxis, + YAxis, + CartesianGrid, + Tooltip, + Legend, + ResponsiveContainer, + BarChart, + Bar, + Cell +} from "recharts"; +import { + ArrowUpRight, + ArrowDownRight, + TrendingUp, + Calendar, + MapPin, + RefreshCw, + AlertCircle, + ChevronRight, + Leaf, + BarChart3, + LineChartIcon, + PieChart +} from "lucide-react"; +import { Skeleton } from "@/components/ui/skeleton"; +import { + Table, + TableBody, + TableCaption, + TableCell, + TableHead, + TableHeader, + TableRow +} from "@/components/ui/table"; +import { Progress } from "@/components/ui/progress"; +import { ScrollArea } from "@/components/ui/scroll-area"; +import { Separator } from "@/components/ui/separator"; +import { + Alert, + AlertDescription, + AlertTitle +} from "@/components/ui/alert"; + +// Define types for market data + +interface ITrend { + direction: "up" | "down"; + value: string; +} + +interface IMarketPrice { + market: string; + price: number; + demand: number; + trend: ITrend; +} + +export interface IMarketData { + id: string; + name: string; + marketPrices: IMarketPrice[]; + averagePrice: number; + recommendedPrice: number; + demandScore: number; + opportunity: boolean; +} + +export interface IHistoricalData { + date: string; + price: number; + volume: number; +} + +export interface IMarketComparison { + name: string; + price: number; + demand: number; +} + +// Types for tooltip props from recharts +interface CustomTooltipProps { + active?: boolean; + payload?: { value: number }[]; + label?: string; +} + +// Types for MarketOpportunity props +interface MarketOpportunityProps { + crop: string; + data: IMarketComparison[]; +} + +// Mock data for market prices +const generateMarketData = (): IMarketData[] => { + const crops = [ + "Corn", + "Wheat", + "Soybeans", + "Rice", + "Potatoes", + "Tomatoes", + "Apples", + "Oranges" + ]; + const markets = [ + "National Market", + "Regional Hub", + "Local Market", + "Export Market", + "Wholesale Market" + ]; + + const getRandomPrice = (base: number) => + Number((base + Math.random() * 2).toFixed(2)); + const getRandomDemand = () => Math.floor(Math.random() * 100); + const getRandomTrend = (): ITrend => + Math.random() > 0.5 + ? { direction: "up", value: (Math.random() * 5).toFixed(1) } + : { direction: "down", value: (Math.random() * 5).toFixed(1) }; + + return crops.map((crop) => { + const basePrice = + crop === "Corn" + ? 4 + : crop === "Wheat" + ? 6 + : crop === "Soybeans" + ? 10 + : crop === "Rice" + ? 12 + : crop === "Potatoes" + ? 3 + : crop === "Tomatoes" + ? 2 + : crop === "Apples" + ? 1.5 + : 8; + + return { + id: crypto.randomUUID(), + name: crop, + marketPrices: markets.map((market) => ({ + market, + price: getRandomPrice(basePrice), + demand: getRandomDemand(), + trend: getRandomTrend() + })), + averagePrice: getRandomPrice(basePrice - 0.5), + recommendedPrice: getRandomPrice(basePrice + 0.2), + demandScore: getRandomDemand(), + opportunity: Math.random() > 0.7 + }; + }); +}; + +// Generate historical price data for a specific crop +const generateHistoricalData = ( + crop: string, + days = 30 +): IHistoricalData[] => { + const basePrice = + crop === "Corn" + ? 4 + : crop === "Wheat" + ? 6 + : crop === "Soybeans" + ? 10 + : crop === "Rice" + ? 12 + : crop === "Potatoes" + ? 3 + : crop === "Tomatoes" + ? 2 + : crop === "Apples" + ? 1.5 + : 8; + + const data: IHistoricalData[] = []; + let currentPrice = basePrice; + + for (let i = days; i >= 0; i--) { + const date = new Date(); + date.setDate(date.getDate() - i); + const change = (Math.random() - 0.5) * 0.4; + currentPrice = Math.max(0.5, currentPrice + change); + + data.push({ + date: date.toISOString().split("T")[0], + price: Number(currentPrice.toFixed(2)), + volume: Math.floor(Math.random() * 1000) + 200 + }); + } + + return data; +}; + +// Generate market comparison data +const generateMarketComparisonData = ( + crop: string +): IMarketComparison[] => { + const markets = [ + "National Market", + "Regional Hub", + "Local Market", + "Export Market", + "Wholesale Market" + ]; + const basePrice = + crop === "Corn" + ? 4 + : crop === "Wheat" + ? 6 + : crop === "Soybeans" + ? 10 + : crop === "Rice" + ? 12 + : crop === "Potatoes" + ? 3 + : crop === "Tomatoes" + ? 2 + : crop === "Apples" + ? 1.5 + : 8; + + return markets.map((market) => ({ + name: market, + price: Number((basePrice + (Math.random() - 0.5) * 2).toFixed(2)), + demand: Math.floor(Math.random() * 100) + })); +}; + +// Custom tooltip for the price chart +const CustomTooltip = ({ + active, + payload, + label +}: CustomTooltipProps) => { + if (active && payload && payload.length) { + return ( + +

{label}

+

Price: ${payload[0].value}

+ {payload[1] && ( +

+ Volume: {payload[1].value} units +

+ )} +
+ ); + } + return null; +}; + +// Market opportunity component +const MarketOpportunity = ({ crop, data }: MarketOpportunityProps) => { + const highestPrice = Math.max(...data.map((item) => item.price)); + const bestMarket = data.find((item) => item.price === highestPrice); + const highestDemand = Math.max(...data.map((item) => item.demand)); + const highDemandMarket = data.find( + (item) => item.demand === highestDemand + ); + + return ( + + + + + Sales Opportunity for {crop} + + + Based on current market conditions + + + +
+
+

+ Best Price Opportunity +

+
+
+

+ ${bestMarket?.price} +

+

+ {bestMarket?.name} +

+
+ + {Math.round((bestMarket!.price / (highestPrice - 1)) * 100)}% + above average + +
+
+ + + +
+

+ Highest Demand +

+
+
+

+ {highDemandMarket?.name} +

+
+ + + {highDemandMarket?.demand}% demand + +
+
+ +
+
+ + + + + Recommendation + + + Consider selling your {crop} at {bestMarket?.name} within + the next 7 days to maximize profit. + + +
+
+
+ ); +}; + +export default function MarketplacePage() { + const searchParams = useSearchParams(); + const initialCrop = searchParams.get("crop") || "Corn"; + + const [selectedCrop, setSelectedCrop] = useState(initialCrop); + const [timeRange, setTimeRange] = useState("30"); + const [isLoading, setIsLoading] = useState(true); + const [marketData, setMarketData] = useState([]); + const [historicalData, setHistoricalData] = useState([]); + const [marketComparison, setMarketComparison] = + useState([]); + const [lastUpdated, setLastUpdated] = useState(new Date()); + + useEffect(() => { + setIsLoading(true); + const timer = setTimeout(() => { + setMarketData(generateMarketData()); + setHistoricalData( + generateHistoricalData(selectedCrop, Number.parseInt(timeRange)) + ); + setMarketComparison(generateMarketComparisonData(selectedCrop)); + setLastUpdated(new Date()); + setIsLoading(false); + }, 1200); + + return () => clearTimeout(timer); + }, [selectedCrop, timeRange]); + + const handleRefresh = () => { + setIsLoading(true); + setTimeout(() => { + setMarketData(generateMarketData()); + setHistoricalData( + generateHistoricalData(selectedCrop, Number.parseInt(timeRange)) + ); + setMarketComparison(generateMarketComparisonData(selectedCrop)); + setLastUpdated(new Date()); + setIsLoading(false); + }, 1000); + }; + + // Removed unused variable "selectedCropData" + + const getTrendColor = (trend: ITrend) => { + return trend.direction === "up" ? "text-green-600" : "text-red-600"; + }; + + const getTrendIcon = (trend: ITrend) => { + return trend.direction === "up" ? ( + + ) : ( + + ); + }; + + return ( +
+
+
+

+ Marketplace Information +

+

+ Make informed decisions with real-time market data and price + analytics +

+
+ +
+ +
+ Last updated: {lastUpdated.toLocaleTimeString()} +
+
+
+ +
+ + +
+
+ Price Analytics + + Track price trends and market movements + +
+
+ + + +
+
+
+ + {isLoading ? ( +
+
+ +

+ Loading market data... +

+
+
+ ) : ( + + + + Price Trend + + + Market Comparison + + + Demand Analysis + + + + +
+ + + + { + const date = new Date(value); + return `${date.getMonth() + 1}/${ + date.getDate() + }`; + }} + /> + `$${value}`} + domain={["dataMin - 0.5", "dataMax + 0.5"]} + /> + + } /> + + + + + +
+ +
+ + +
+
+

+ Current Price +

+

+ $ + {historicalData[ + historicalData.length - 1 + ]?.price.toFixed(2)} +

+
+
+ +
+
+
+
+ + + +
+
+

+ 30-Day Average +

+

+ $ + {( + historicalData.reduce( + (sum, item) => sum + item.price, + 0 + ) / historicalData.length + ).toFixed(2)} +

+
+
+ +
+
+
+
+ + + +
+
+

+ Recommended Price +

+

+ $ + {( + historicalData[historicalData.length - 1] + ?.price * 1.05 + ).toFixed(2)} +

+
+ + +5% margin + +
+
+
+
+
+ + +
+ + + + + `$${value}`} + /> + + + + {marketComparison.map((entry, index) => ( + item.price + ) + ) + ? "#15803d" + : "#16a34a" + } + /> + ))} + + + +
+ +
+ + + Market comparison for {selectedCrop} as of{" "} + {new Date().toLocaleDateString()} + + + + Market + Price + Demand + Price Difference + + Action + + + + + {marketComparison.map((market) => { + const avgPrice = + marketComparison.reduce( + (sum, m) => sum + m.price, + 0 + ) / marketComparison.length; + const priceDiff = ( + ((market.price - avgPrice) / avgPrice) * + 100 + ).toFixed(1); + const isPriceHigh = Number.parseFloat(priceDiff) > 0; + + return ( + + setSelectedCrop(market.name) + } + > + + {market.name} + + + ${market.price.toFixed(2)} + + +
+ + {market.demand}% +
+
+ +
+ {isPriceHigh ? ( + + ) : ( + + )} + {priceDiff}% +
+
+ + + +
+ ); + })} +
+
+
+
+ + +
+ + + + Demand Forecast + + + Projected demand for {selectedCrop} over the next 30 days + + + +
+ {marketComparison.map((market) => ( +
+
+ {market.name} + + {market.demand}% + +
+ +
+ ))} +
+
+
+ + +
+
+
+ )} +
+
+ +
+ + + Market Summary + + Today's market overview + + + + {isLoading ? ( +
+ + + +
+ ) : ( + +
+ {marketData.slice(0, 5).map((crop) => ( +
+
+

{crop.name}

+
+ ${crop.averagePrice.toFixed(2)} + {crop.marketPrices[0].trend.direction === "up" ? ( + + + {crop.marketPrices[0].trend.value}% + + ) : ( + + + {crop.marketPrices[0].trend.value}% + + )} +
+
+ +
+ ))} +
+
+ )} +
+
+ + + + Top Opportunities + + Best selling opportunities today + + + + {isLoading ? ( +
+ + + +
+ ) : ( +
+ {marketData + .filter((crop) => crop.opportunity) + .slice(0, 3) + .map((crop) => { + const bestMarket = crop.marketPrices.reduce( + (best, current) => + current.price > best.price ? current : best, + crop.marketPrices[0] + ); + return ( +
+
+
+

{crop.name}

+

+ {bestMarket.market} - $ + {bestMarket.price.toFixed(2)} +

+
+ + High Demand + +
+
+
+ + + Recommended + +
+ +
+
+ ); + })} +
+ )} +
+
+
+
+ + + + Market Price Table + + Comprehensive price data across all markets and crops + + + + {isLoading ? ( +
+ + + + +
+ ) : ( +
+ + + + Crop + {marketData[0]?.marketPrices.map((market) => ( + + {market.market} + + ))} + Average + Recommended + + + + {marketData.map((crop) => ( + setSelectedCrop(crop.name)} + > + + {crop.name} + + {crop.marketPrices.map((market) => ( + +
+ ${market.price.toFixed(2)} + + {getTrendIcon(market.trend)} + +
+
+ ))} + + ${crop.averagePrice.toFixed(2)} + + + ${crop.recommendedPrice.toFixed(2)} + +
+ ))} +
+
+
+ )} +
+
+
+ ); +} \ No newline at end of file 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/auth/signin/forgot-password-modal.tsx b/frontend/app/auth/signin/forgot-password-modal.tsx index c2eb19c..14f0901 100644 --- a/frontend/app/auth/signin/forgot-password-modal.tsx +++ b/frontend/app/auth/signin/forgot-password-modal.tsx @@ -18,21 +18,21 @@ export default function ForgotPasswordModal() {
- - + What's your email? - Please verify your email for us. Once you do, we'll send instructions to reset your password + Please verify your email for us. Once you do, we'll send instructions to reset your password.
diff --git a/frontend/app/auth/signin/google-oauth.tsx b/frontend/app/auth/signin/google-oauth.tsx index 7b1f2cb..8d5f1da 100644 --- a/frontend/app/auth/signin/google-oauth.tsx +++ b/frontend/app/auth/signin/google-oauth.tsx @@ -2,8 +2,9 @@ import Image from "next/image"; export function GoogleSigninButton() { return ( -
+
Google Logo + Sign in with Google
); } diff --git a/frontend/app/auth/signin/page.tsx b/frontend/app/auth/signin/page.tsx index c49235e..450ae1e 100644 --- a/frontend/app/auth/signin/page.tsx +++ b/frontend/app/auth/signin/page.tsx @@ -2,35 +2,35 @@ import { zodResolver } from "@hookform/resolvers/zod"; import { useForm } from "react-hook-form"; - import { signInSchema } from "@/schemas/auth.schema"; - import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { Checkbox } from "@/components/ui/checkbox"; import { Button } from "@/components/ui/button"; import ForgotPasswordModal from "./forgot-password-modal"; - import Link from "next/link"; import Image from "next/image"; import { GoogleSigninButton } from "./google-oauth"; -import { z } from "zod"; +import type { z } from "zod"; import { useContext, useState } from "react"; import { useRouter } from "next/navigation"; - import { loginUser } from "@/api/authentication"; import { SessionContext } from "@/context/SessionContext"; +import { Eye, EyeOff, Leaf, ArrowRight, AlertCircle, Loader2 } from "lucide-react"; +import { Alert, AlertDescription } from "@/components/ui/alert"; +import { ThemeToggle } from "@/components/theme-toggle"; export default function SigninPage() { const [serverError, setServerError] = useState(null); const [isLoading, setIsLoading] = useState(false); + const [showPassword, setShowPassword] = useState(false); const router = useRouter(); const session = useContext(SessionContext); const { register, handleSubmit, - formState: { errors }, + formState: { errors, isSubmitting }, } = useForm({ resolver: zodResolver(signInSchema), defaultValues: { @@ -56,68 +56,188 @@ export default function SigninPage() { router.push("/setup"); } catch (error: any) { console.error("Error logging in:", error); - setServerError(error.message); + setServerError(error.message || "Invalid email or password. Please try again."); } finally { setIsLoading(false); } }; return ( -
-
-
+
+
+ {/* Left side - Image */} +
+
+
+
+ + + ForFarm + +
-
-
-
- - Forfarm - -

Welcome back.

-
- New to Forfarm? - - - Sign up - - +
+

Grow smarter with ForFarm

+

+ Join thousands of farmers using our platform to optimize their agricultural operations and increase + yields. +

+
+
+ {[1, 2, 3].map((i) => ( +
+ User +
+ ))} +
+
+ 500+ farmers already using ForFarm +
+
+
+
+ + {/* Right side - Form */} +
+
+
+ + Forfarm + +
+ +
+

Welcome back

+

+ New to Forfarm?{" "} + + Sign up + +

+
+ + {serverError && ( + + + {serverError} + + )} {/* Sign in form */} -
-
- - - {errors.email &&

{errors.email.message}

} -
-
- - - {errors.password &&

{errors.password.message}

} + +
+ +
+ +
+ {errors.email && ( +

+ + {errors.email.message} +

+ )}
- +
+ {errors.password && ( +

+ + {errors.password.message} +

+ )} +
+ +
+ + +
+ + - diff --git a/frontend/app/auth/signin/waterdrop.tsx b/frontend/app/auth/signin/waterdrop.tsx deleted file mode 100644 index b06bb2f..0000000 --- a/frontend/app/auth/signin/waterdrop.tsx +++ /dev/null @@ -1,12 +0,0 @@ -import React from "react"; - -const WaterDrop = () => { - return ( -
- {/* Water Drop animation */} -
-
- ); -}; - -export default WaterDrop; diff --git a/frontend/app/auth/signup/page.tsx b/frontend/app/auth/signup/page.tsx index 4600e86..ed0cb02 100644 --- a/frontend/app/auth/signup/page.tsx +++ b/frontend/app/auth/signup/page.tsx @@ -1,28 +1,30 @@ "use client"; +import type React from "react"; import { zodResolver } from "@hookform/resolvers/zod"; import { useForm } from "react-hook-form"; - import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { Button } from "@/components/ui/button"; - import { signUpSchema } from "@/schemas/auth.schema"; - import Link from "next/link"; import Image from "next/image"; import { useContext, useState } from "react"; -import { z } from "zod"; - +import type { z } from "zod"; import { useRouter } from "next/navigation"; - import { registerUser } from "@/api/authentication"; import { SessionContext } from "@/context/SessionContext"; +import { Eye, EyeOff, Leaf, ArrowRight, AlertCircle, Loader2, Check } from "lucide-react"; +import { Alert, AlertDescription } from "@/components/ui/alert"; +import { Progress } from "@/components/ui/progress"; export default function SignupPage() { const [serverError, setServerError] = useState(null); const [successMessage, setSuccessMessage] = useState(null); const [isLoading, setIsLoading] = useState(false); + const [showPassword, setShowPassword] = useState(false); + const [showConfirmPassword, setShowConfirmPassword] = useState(false); + const [passwordStrength, setPasswordStrength] = useState(0); const router = useRouter(); const session = useContext(SessionContext); @@ -30,7 +32,8 @@ export default function SignupPage() { const { register, handleSubmit, - formState: { errors }, + watch, + formState: { errors, isSubmitting }, } = useForm>({ resolver: zodResolver(signUpSchema), defaultValues: { @@ -40,6 +43,29 @@ export default function SignupPage() { }, }); + const password = watch("password"); + + // Calculate password strength based on several criteria + const calculatePasswordStrength = (password: string) => { + if (!password) return 0; + let strength = 0; + + // Length check + if (password.length >= 8) strength += 25; + // Contains lowercase + if (/[a-z]/.test(password)) strength += 25; + // Contains uppercase + if (/[A-Z]/.test(password)) strength += 25; + // Contains number or special char + if (/[0-9!@#$%^&*(),.?":{}|<>]/.test(password)) strength += 25; + return strength; + }; + + const onPasswordChange = (e: React.ChangeEvent) => { + const newStrength = calculatePasswordStrength(e.target.value); + setPasswordStrength(newStrength); + }; + const onSubmit = async (values: z.infer) => { setServerError(null); setSuccessMessage(null); @@ -52,83 +78,265 @@ export default function SignupPage() { setServerError("An error occurred while registering. Please try again."); throw new Error("No data received from the server."); } + session!.setToken(data.token); session!.setUser(values.email); - setSuccessMessage("Registration successful! You can now sign in."); router.push("/setup"); } catch (error: any) { console.error("Error during registration:", error); - setServerError(error.message); + setServerError(error.message || "Registration failed. Please try again."); } finally { setIsLoading(false); } }; + const getPasswordStrengthText = () => { + if (passwordStrength === 0) return ""; + if (passwordStrength <= 25) return "Weak"; + if (passwordStrength <= 50) return "Fair"; + if (passwordStrength <= 75) return "Good"; + return "Strong"; + }; + + const getPasswordStrengthColor = () => { + if (passwordStrength <= 25) return "bg-red-500"; + if (passwordStrength <= 50) return "bg-yellow-500"; + if (passwordStrength <= 75) return "bg-blue-500"; + return "bg-green-500"; + }; + return ( -
-
-
- -
-
-
- - Forfarm - -

Hi! Welcome

-
- Already have an account? - - - Sign in - - -
-
- - {/* Signup form */} -
+
+
+ {/* Left Side - Image */} +
+
+
- - - {errors.email &&

{errors.email.message}

} + + + ForFarm +
-
- - - {errors.password &&

{errors.password.message}

} -
-
- - - {errors.confirmPassword &&

{errors.confirmPassword.message}

} -
- - {serverError &&

{serverError}

} - {successMessage &&

{successMessage}

} - - - - -
-

Or sign up with

-
- {/* Google OAuth button or additional providers */} -
- Google Logo +
+

Join the farming revolution

+

+ Create your account today and discover how ForFarm can help you optimize your agricultural operations. +

+
+ {[ + "Real-time monitoring of your crops", + "Smart resource management", + "Data-driven insights for better yields", + "Connect with other farmers in your area", + ].map((feature, index) => ( +
+
+ +
+ {feature} +
+ ))}
+ + {/* Right Side - Form */} +
+
+ {/* Theme Selector Placeholder */} +
Theme Selector Placeholder
+ +
+ + Forfarm + +
+ +
+

Create your account

+

+ Already have an account?{" "} + + Sign in + +

+
+ + {serverError && ( + + + {serverError} + + )} + + {successMessage && ( + + + {successMessage} + + )} + + {/* Sign Up Form */} +
+ {/* Email */} +
+ +
+ +
+ {errors.email && ( +

+ + {errors.email.message} +

+ )} +
+ + {/* Password */} +
+ +
+ + +
+ + {/* Password Strength Indicator */} + {password && ( +
+
+ Password strength + + {getPasswordStrengthText()} + +
+ +
+ )} + + {errors.password && ( +

+ + {errors.password.message} +

+ )} +
+ + {/* Confirm Password */} +
+ +
+ + +
+ {errors.confirmPassword && ( +

+ + {errors.confirmPassword.message} +

+ )} +
+ + +
+ +
+
+
+
+
+
+ + Or sign up with + +
+
+ +
+ +
+
+ +

+ By signing up, you agree to our{" "} + + Terms of Service + {" "} + and{" "} + + Privacy Policy + +

+
+
); diff --git a/frontend/app/error.tsx b/frontend/app/error.tsx new file mode 100644 index 0000000..508896b --- /dev/null +++ b/frontend/app/error.tsx @@ -0,0 +1,103 @@ +"use client"; + +import { useEffect } from "react"; +import { Button } from "@/components/ui/button"; +import { AlertTriangle, RefreshCcw, Home, ArrowLeft, HelpCircle } from "lucide-react"; +import { useRouter } from "next/navigation"; +import Link from "next/link"; + +interface ErrorProps { + error: Error & { digest?: string }; + reset: () => void; +} + +export default function Error({ error, reset }: ErrorProps) { + useEffect(() => { + // Log the error to an error reporting service + console.error("Application error:", error); + }, [error]); + + const router = useRouter(); + + // Determine error type to show appropriate message + const getErrorMessage = () => { + if (error.message.includes("FARM_NOT_FOUND")) { + return "The farm you're looking for could not be found."; + } + if (error.message.includes("CROP_NOT_FOUND")) { + return "The crop you're looking for could not be found."; + } + if (error.message.includes("UNAUTHORIZED")) { + return "You don't have permission to access this resource."; + } + if (error.message.includes("NETWORK")) { + return "Network error. Please check your internet connection."; + } + return "We apologize for the inconvenience. An unexpected error has occurred."; + }; + + return ( +
+
+
+ {/* Decorative elements */} +
+
+ + {/* Main icon */} +
+ +
+
+ +

Something went wrong

+

{getErrorMessage()}

+ + {error.message && !["FARM_NOT_FOUND", "CROP_NOT_FOUND", "UNAUTHORIZED"].includes(error.message) && ( +
+

Error details:

+

{error.message}

+ {error.digest &&

Error ID: {error.digest}

} +
+ )} + +
+ + + +
+ +
+

+ Need help?{" "} + + Contact Support + +

+

+ + Support Code: {error.digest ? error.digest.substring(0, 8) : "Unknown"} +

+
+
+
+ ); +} diff --git a/frontend/app/global-error.tsx b/frontend/app/global-error.tsx new file mode 100644 index 0000000..f7e182f --- /dev/null +++ b/frontend/app/global-error.tsx @@ -0,0 +1,51 @@ +"use client"; + +import { Button } from "@/components/ui/button"; +import { AlertTriangle, RefreshCcw, Home } from "lucide-react"; + +interface GlobalErrorProps { + error: Error & { digest?: string }; + reset: () => void; +} + +export default function GlobalError({ error, reset }: GlobalErrorProps) { + return ( + + +
+
+
+ +
+ +

Critical Error

+

The application has encountered a critical error and cannot continue.

+ + {error.message && ( +
+

Error details:

+

{error.message}

+ {error.digest &&

Error ID: {error.digest}

} +
+ )} + +
+ + +
+
+
+ + + ); +} diff --git a/frontend/app/globals.css b/frontend/app/globals.css index 491435b..94ab5ed 100644 --- a/frontend/app/globals.css +++ b/frontend/app/globals.css @@ -3,7 +3,7 @@ @tailwind utilities; body { - font-family: Arial, Helvetica, sans-serif; + font-family: var(--font-poppins); } @layer base { @@ -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/layout.tsx b/frontend/app/layout.tsx index 17285cf..a1cc0ed 100644 --- a/frontend/app/layout.tsx +++ b/frontend/app/layout.tsx @@ -1,20 +1,16 @@ import type { Metadata } from "next"; -import { Open_Sans, Roboto_Mono } from "next/font/google"; +import { Poppins } from "next/font/google"; import "./globals.css"; import { ThemeProvider } from "@/components/theme-provider"; import { SessionProvider } from "@/context/SessionContext"; +import ReactQueryProvider from "@/lib/ReactQueryProvider"; -const openSans = Open_Sans({ +const poppins = Poppins({ subsets: ["latin"], display: "swap", - variable: "--font-opensans", -}); - -const robotoMono = Roboto_Mono({ - subsets: ["latin"], - display: "swap", - variable: "--font-roboto-mono", + variable: "--font-poppins", + weight: ["100", "200", "300", "400", "500", "600", "700", "800", "900"], }); // const geistMono = Geist_Mono({ @@ -36,13 +32,15 @@ export default function RootLayout({ - - -
-
{children}
-
-
- + + + +
+
{children}
+
+
+ +
); diff --git a/frontend/app/loading.tsx b/frontend/app/loading.tsx new file mode 100644 index 0000000..1974619 --- /dev/null +++ b/frontend/app/loading.tsx @@ -0,0 +1,27 @@ +import { Leaf } from "lucide-react"; + +export default function Loading() { + return ( +
+
+
+
+ +
+
+
+ +
+

Loading...

+

+ We're preparing your farming data. This will only take a moment. +

+
+ +
+
+
+
+
+ ); +} diff --git a/frontend/app/not-found.tsx b/frontend/app/not-found.tsx new file mode 100644 index 0000000..7322eee --- /dev/null +++ b/frontend/app/not-found.tsx @@ -0,0 +1,92 @@ +"use client"; + +import type React from "react"; +import Link from "next/link"; +import { Button } from "@/components/ui/button"; +import { Leaf, Home, Search, ArrowLeft, MapPin } from "lucide-react"; +import { Input } from "@/components/ui/input"; +import { useState } from "react"; +import { useRouter } from "next/navigation"; + +export default function NotFound() { + const [searchQuery, setSearchQuery] = useState(""); + const router = useRouter(); + + const handleSearch = (e: React.FormEvent) => { + e.preventDefault(); + if (searchQuery.trim()) { + // In a real app, this would navigate to search results + router.push(`/search?q=${encodeURIComponent(searchQuery)}`); + } + }; + + return ( +
+
+
+ {/* Decorative elements */} +
+
+ + {/* Main icon */} +
+ +
+
+ +

+ 404 +

+

Page Not Found

+

+ Looks like you've wandered into uncharted territory. This page doesn't exist or has been moved. +

+ +
+
+ + setSearchQuery(e.target.value)} + /> + + +
+ + +
+
+ +
+ + + View Farms + + + + Knowledge Hub + + + + Contact Support + +
+
+
+ ); +} diff --git a/frontend/app/page.tsx b/frontend/app/page.tsx index 2664cbf..86c3b56 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/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/frontend/components/sidebar/app-sidebar.tsx b/frontend/components/sidebar/app-sidebar.tsx index a2a1186..678b27f 100644 --- a/frontend/components/sidebar/app-sidebar.tsx +++ b/frontend/components/sidebar/app-sidebar.tsx @@ -1,6 +1,7 @@ "use client"; import * as React from "react"; +import { useEffect, useState } from "react"; import { AudioWaveform, BookOpen, @@ -12,140 +13,104 @@ import { PieChart, Settings2, SquareTerminal, + User, } from "lucide-react"; import { NavMain } from "./nav-main"; -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 { NavCrops } from "./nav-crops"; +import { fetchUserMe } from "@/api/user"; -const data = { - user: { - name: "shadcn", - email: "m@example.com", - avatar: "/avatars/shadcn.jpg", - }, - teams: [ - { - name: "Farm 1", - logo: GalleryVerticalEnd, - plan: "Hatyai", - }, - { - name: "Farm 2", - logo: AudioWaveform, - plan: "Songkla", - }, - { - name: "Farm 3", - logo: Command, - plan: "Layong", - }, - ], - navMain: [ - { - title: "Dashboard", - url: "#", - icon: SquareTerminal, - isActive: true, - items: [ - { - title: "Analytic", - url: "#", - }, - ], - }, - { - title: "AI Chatbot", - url: "#", - icon: Bot, - items: [ - { - title: "Main model", - url: "#", - }, - ], - }, - { - title: "Documentation", - url: "#", - icon: BookOpen, - items: [ - { - title: "Introduction", - url: "#", - }, - { - title: "Get Started", - url: "#", - }, - { - title: "Tutorials", - url: "#", - }, - { - title: "Changelog", - url: "#", - }, - ], - }, - { - title: "Settings", - url: "#", - icon: Settings2, - items: [ - { - title: "General", - url: "#", - }, - { - title: "Team", - url: "#", - }, - { - title: "Billing", - url: "#", - }, - { - title: "Limits", - url: "#", - }, - ], - }, - ], - projects: [ - { - name: "Crops 1", - url: "#", - icon: Frame, - }, - { - name: "Crops 2", - url: "#", - icon: PieChart, - }, - { - name: "Crops 3", - url: "#", - icon: Map, - }, - ], -}; +interface Team { + name: string; + logo: React.ComponentType; + plan: string; +} + +import { LucideIcon } from "lucide-react"; + +interface NavItem { + title: string; + url: string; + icon: LucideIcon; +} + +interface SidebarConfig { + teams: Team[]; + navMain: NavItem[]; + crops: NavItem[]; +} + +interface AppSidebarProps extends React.ComponentProps { + config?: SidebarConfig; +} + +export function AppSidebar({ config, ...props }: AppSidebarProps) { + const defaultConfig: SidebarConfig = { + teams: [ + { name: "Farm 1", logo: GalleryVerticalEnd, plan: "Hatyai" }, + { name: "Farm 2", logo: AudioWaveform, plan: "Songkla" }, + { name: "Farm 3", logo: Command, plan: "Layong" }, + ], + navMain: [ + { title: "Farms", url: "/farms", icon: Map }, + { title: "Inventory", url: "/inventory", icon: SquareTerminal }, + { title: "Marketplace Information", url: "/marketplace", icon: PieChart }, + { title: "Knowledge Hub", url: "/hub", icon: BookOpen }, + { title: "Users", url: "/users", icon: User }, + { title: "AI Chatbot", url: "/chatbot", icon: Bot }, + { title: "Settings", url: "/settings", icon: Settings2 }, + ], + crops: [ + { title: "Crops 1", url: "/farms/[farmId]/crops/1", icon: Map }, + { title: "Crops 2", url: "/farms/[farmId]/crops/2", icon: Map }, + { title: "Crops 3", url: "/farms/[farmId]/crops/3", icon: Map }, + ], + }; + + // Allow external configuration override + const sidebarConfig = config || defaultConfig; + + const [user, setUser] = useState<{ name: string; email: string; avatar: string }>({ + name: "", + email: "", + avatar: "/avatars/avatar.webp", + }); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(""); + + useEffect(() => { + async function getUser() { + try { + const data = await fetchUserMe(); + setUser({ + name: data.user.UUID, + email: data.user.Email, + avatar: data.user.Avatar || "/avatars/avatar.webp", + }); + } catch (err: any) { + setError(err.message); + } finally { + setLoading(false); + } + } + getUser(); + }, []); -export function AppSidebar({ ...props }: React.ComponentProps) { return ( - + - - + +
+ +
- - - + {loading ? "Loading..." : error ? error : }
); diff --git a/frontend/components/sidebar/nav-crops.tsx b/frontend/components/sidebar/nav-crops.tsx new file mode 100644 index 0000000..9106eb8 --- /dev/null +++ b/frontend/components/sidebar/nav-crops.tsx @@ -0,0 +1,44 @@ +"use client"; + +import { LucideIcon } from "lucide-react"; +import { + SidebarGroup, + SidebarGroupLabel, + SidebarGroupContent, + SidebarMenu, + SidebarMenuButton, + SidebarMenuItem, +} from "@/components/ui/sidebar"; + +interface CropItem { + title: string; + url: string; + icon: LucideIcon; +} + +interface NavCropsProps { + crops: CropItem[]; + title?: string; +} + +export function NavCrops({ crops, title = "Crops" }: NavCropsProps) { + return ( + + {title} + + + {crops.map((crop) => ( + + + + + {crop.title} + + + + ))} + + + + ); +} diff --git a/frontend/components/sidebar/nav-main.tsx b/frontend/components/sidebar/nav-main.tsx index 20ace28..ebfc210 100644 --- a/frontend/components/sidebar/nav-main.tsx +++ b/frontend/components/sidebar/nav-main.tsx @@ -1,11 +1,10 @@ "use client"; -import { ChevronRight, type LucideIcon } from "lucide-react"; - -import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible"; +import { type LucideIcon } from "lucide-react"; import { SidebarGroup, SidebarGroupLabel, + SidebarGroupContent, SidebarMenu, SidebarMenuButton, SidebarMenuItem, @@ -31,20 +30,19 @@ export function NavMain({ return ( Platform - - {items.map((item) => ( - - - - + + + {items.map((item) => ( + + + {item.icon && } {item.title} - - - - + + + {item.items && ( - {item.items?.map((subItem) => ( + {item.items.map((subItem) => ( @@ -54,11 +52,11 @@ export function NavMain({ ))} - + )} - - ))} - + ))} + + ); } diff --git a/frontend/components/sidebar/nav-projects.tsx b/frontend/components/sidebar/nav-projects.tsx deleted file mode 100644 index 921c0d3..0000000 --- a/frontend/components/sidebar/nav-projects.tsx +++ /dev/null @@ -1,82 +0,0 @@ -"use client"; - -import { Folder, Forward, MoreHorizontal, Trash2, type LucideIcon } from "lucide-react"; - -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuSeparator, - DropdownMenuTrigger, -} from "@/components/ui/dropdown-menu"; -import { - SidebarGroup, - SidebarGroupLabel, - SidebarMenu, - SidebarMenuAction, - SidebarMenuButton, - SidebarMenuItem, - useSidebar, -} from "@/components/ui/sidebar"; - -export function NavProjects({ - projects, -}: { - projects: { - name: string; - url: string; - icon: LucideIcon; - }[]; -}) { - const { isMobile } = useSidebar(); - - return ( - - Projects - - {projects.map((item) => ( - - - - - {item.name} - - - - - - - More - - - - - - View Project - - - - Share Project - - - - - Delete Project - - - - - ))} - - - - More - - - - - ); -} diff --git a/frontend/components/ui/alert.tsx b/frontend/components/ui/alert.tsx new file mode 100644 index 0000000..5afd41d --- /dev/null +++ b/frontend/components/ui/alert.tsx @@ -0,0 +1,59 @@ +import * as React from "react" +import { cva, type VariantProps } from "class-variance-authority" + +import { cn } from "@/lib/utils" + +const alertVariants = cva( + "relative w-full rounded-lg border px-4 py-3 text-sm [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground [&>svg~*]:pl-7", + { + variants: { + variant: { + default: "bg-background text-foreground", + destructive: + "border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive", + }, + }, + defaultVariants: { + variant: "default", + }, + } +) + +const Alert = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes & VariantProps +>(({ className, variant, ...props }, ref) => ( +
+)) +Alert.displayName = "Alert" + +const AlertTitle = React.forwardRef< + HTMLParagraphElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)) +AlertTitle.displayName = "AlertTitle" + +const AlertDescription = React.forwardRef< + HTMLParagraphElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)) +AlertDescription.displayName = "AlertDescription" + +export { Alert, AlertTitle, AlertDescription } 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/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/hover-card.tsx b/frontend/components/ui/hover-card.tsx new file mode 100644 index 0000000..e54d91c --- /dev/null +++ b/frontend/components/ui/hover-card.tsx @@ -0,0 +1,29 @@ +"use client" + +import * as React from "react" +import * as HoverCardPrimitive from "@radix-ui/react-hover-card" + +import { cn } from "@/lib/utils" + +const HoverCard = HoverCardPrimitive.Root + +const HoverCardTrigger = HoverCardPrimitive.Trigger + +const HoverCardContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, align = "center", sideOffset = 4, ...props }, ref) => ( + +)) +HoverCardContent.displayName = HoverCardPrimitive.Content.displayName + +export { HoverCard, HoverCardTrigger, HoverCardContent } 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">) => ( +