Merge pull request #5 from ForFarmTeam/feature-farm-setup

UI: add multiple placeholder UI in pages
This commit is contained in:
Sirin Puenggun 2025-03-09 22:11:26 +07:00 committed by GitHub
commit 281c9069f8
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
70 changed files with 7269 additions and 731 deletions

View File

@ -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

View File

@ -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)

View File

@ -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
}

View File

@ -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
}

View File

@ -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 {

View File

@ -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

View File

@ -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)
}

View File

@ -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
}

View File

@ -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

View File

@ -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")
}

View File

@ -0,0 +1,2 @@
-- +goose Up
ALTER TABLE farms DROP COLUMN plant_types;

View File

@ -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;

198
frontend/api/farm.ts Normal file
View File

@ -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<Crop> {
try {
const response = await axiosInstance.get<Crop>(`/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<CropAnalytics> {
try {
const response = await axiosInstance.get<CropAnalytics>(`/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<Farm[]> {
// Simulate network delay
await new Promise((resolve) => setTimeout(resolve, 1000));
try {
const response = await axiosInstance.get<Farm[]>("/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<Farm>): Promise<Farm> {
await new Promise((resolve) => setTimeout(resolve, 800));
// In a real implementation you might call:
// const response = await axiosInstance.post<Farm>("/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 };
}
}

148
frontend/api/hub.ts Normal file
View File

@ -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: `<p>Sustainable farming is not just a trend; it's a necessary evolution in agricultural practices. […]</p>`,
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<Blog[]> {
await new Promise((resolve) => setTimeout(resolve, 1000));
try {
const response = await axiosInstance.get<Blog[]>("/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<Blog | null> {
await new Promise((resolve) => setTimeout(resolve, 500));
try {
const response = await axiosInstance.get<Blog>(`/api/blogs/${id}`);
return response.data;
} catch (error) {
const blog = dummyBlogs.find((blog) => blog.id === Number(id));
return blog || null;
}
}

97
frontend/api/inventory.ts Normal file
View File

@ -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<InventoryItem[]> {
try {
const response = await axiosInstance.get<InventoryItem[]>("/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<InventoryItem, "id" | "lastUpdated" | "status">
): Promise<InventoryItem> {
// Simulate network delay
await new Promise((resolve) => setTimeout(resolve, 500));
try {
const response = await axiosInstance.post<InventoryItem>("/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",
};
}
}

22
frontend/api/user.ts Normal file
View File

@ -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<UserDataOutput> {
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;
}
}

View File

@ -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<ChatMessage[]>([]);
const [inputValue, setInputValue] = useState("");
const [isHistoryOpen, setIsHistoryOpen] = useState(false);
const [selectedFarm, setSelectedFarm] = useState<string | null>(null);
const [selectedCrop, setSelectedCrop] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(false);
const messagesEndRef = useRef<HTMLDivElement>(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 (
<div className="flex flex-col min-h-screen bg-gradient-to-br from-green-50 to-blue-50 dark:from-green-950 dark:to-blue-950">
{/* Header */}
<header className="border-b bg-white dark:bg-gray-950 shadow-sm py-4 px-6">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Button variant="ghost" size="icon" onClick={() => router.push("/farms")} aria-label="Back to farms">
<ChevronLeft className="h-5 w-5" />
</Button>
<div className="flex items-center gap-2">
<MessageSquare className="h-5 w-5 text-green-600 dark:text-green-400" />
<h1 className="text-xl font-semibold">ForFarm Assistant</h1>
</div>
</div>
<Button
variant="ghost"
size="icon"
onClick={() => setIsHistoryOpen(!isHistoryOpen)}
aria-label={isHistoryOpen ? "Close history" : "Open history"}>
{isHistoryOpen ? <PanelRightClose className="h-5 w-5" /> : <PanelRightOpen className="h-5 w-5" />}
</Button>
</div>
</header>
{/* Main content */}
<div className="flex flex-1 overflow-hidden">
{/* Chat area */}
<div className="flex-1 flex flex-col h-full overflow-hidden">
{/* Farm/Crop selector */}
<div className="bg-white dark:bg-gray-900 p-4 border-b">
<div className="flex flex-col sm:flex-row gap-3">
<div className="flex-1">
<label className="text-sm font-medium mb-1 block text-gray-700 dark:text-gray-300">
Select Farm (Optional)
</label>
<Select value={selectedFarm || ""} onValueChange={handleFarmSelect}>
<SelectTrigger className="w-full">
<SelectValue placeholder="All Farms" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Farms</SelectItem>
{mockFarms.map((farm) => (
<SelectItem key={farm.id} value={farm.id}>
{farm.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex-1">
<label className="text-sm font-medium mb-1 block text-gray-700 dark:text-gray-300">
Select Crop (Optional)
</label>
<Select value={selectedCrop || ""} onValueChange={handleCropSelect} disabled={!selectedFarm}>
<SelectTrigger className="w-full">
<SelectValue placeholder={selectedFarm ? "All Crops" : "Select a farm first"} />
</SelectTrigger>
<SelectContent>
{selectedFarm && <SelectItem value="all">All Crops</SelectItem>}
{filteredCrops.map((crop) => (
<SelectItem key={crop.id} value={crop.id}>
{crop.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
</div>
{/* Messages */}
<ScrollArea className="flex-1 p-4">
<div className="space-y-4 max-w-3xl mx-auto">
{messages.map((message) => (
<div key={message.id} className={`flex ${message.sender === "user" ? "justify-end" : "justify-start"}`}>
<div
className={`max-w-[80%] rounded-lg p-4 ${
message.sender === "user"
? "bg-green-600 text-white dark:bg-green-700"
: "bg-white dark:bg-gray-800 border dark:border-gray-700 shadow-sm"
}`}>
{message.relatedTo && (
<div className="mb-1">
<Badge variant="outline" className="text-xs font-normal">
{message.relatedTo.type === "farm" ? "🏡 " : "🌱 "}
{message.relatedTo.name}
</Badge>
</div>
)}
<div className="text-sm">{message.content}</div>
<div className="mt-1 text-xs opacity-70 text-right">
{message.timestamp.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })}
</div>
</div>
</div>
))}
{isLoading && (
<div className="flex justify-start">
<div className="max-w-[80%] rounded-lg p-4 bg-white dark:bg-gray-800 border dark:border-gray-700 shadow-sm">
<div className="flex items-center gap-2">
<div className="h-2 w-2 bg-green-500 rounded-full animate-pulse"></div>
<div className="h-2 w-2 bg-green-500 rounded-full animate-pulse delay-150"></div>
<div className="h-2 w-2 bg-green-500 rounded-full animate-pulse delay-300"></div>
<span className="text-sm text-gray-500 dark:text-gray-400 ml-1">
ForFarm Assistant is typing...
</span>
</div>
</div>
</div>
)}
<div ref={messagesEndRef} />
</div>
</ScrollArea>
{/* Recommended prompts */}
<div className="bg-white dark:bg-gray-900 border-t p-4">
<h3 className="text-sm font-medium text-gray-700 dark:text-gray-300 mb-2 flex items-center gap-1">
<Sparkles className="h-4 w-4 text-green-500" />
Recommended Questions
</h3>
<div className="flex flex-wrap gap-2">
{recommendedPrompts.map((prompt) => (
<Button
key={prompt.id}
variant="outline"
size="sm"
className="text-xs rounded-full bg-green-50 dark:bg-green-900/20 border-green-200 dark:border-green-800 hover:bg-green-100 dark:hover:bg-green-900/40 text-green-800 dark:text-green-300"
onClick={() => handlePromptClick(prompt.text)}>
{prompt.text}
</Button>
))}
</div>
</div>
{/* Input area */}
<div className="bg-white dark:bg-gray-900 border-t p-4">
<div className="flex gap-2 max-w-3xl mx-auto">
<Input
value={inputValue}
onChange={(e) => 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();
}
}}
/>
<Button
onClick={() => handleSendMessage()}
disabled={!inputValue.trim() || isLoading}
className="bg-green-600 hover:bg-green-700 text-white">
<Send className="h-4 w-4" />
</Button>
</div>
</div>
</div>
{/* Chat history sidebar */}
{isHistoryOpen && (
<div className="w-80 border-l bg-white dark:bg-gray-900 flex flex-col">
<div className="p-4 border-b flex items-center justify-between">
<h2 className="font-medium flex items-center gap-1">
<History className="h-4 w-4" />
Chat History
</h2>
<Button variant="ghost" size="icon" onClick={() => setIsHistoryOpen(false)}>
<X className="h-4 w-4" />
</Button>
</div>
<div className="p-3">
<div className="relative">
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-gray-500 dark:text-gray-400" />
<Input placeholder="Search conversations..." className="pl-9" />
</div>
</div>
<Tabs defaultValue="recent" className="flex-1 flex flex-col">
<TabsList className="mx-3 mb-2">
<TabsTrigger value="recent" className="flex-1">
Recent
</TabsTrigger>
<TabsTrigger value="farms" className="flex-1">
By Farm
</TabsTrigger>
<TabsTrigger value="crops" className="flex-1">
By Crop
</TabsTrigger>
</TabsList>
<ScrollArea className="flex-1">
<TabsContent value="recent" className="m-0 p-0">
<div className="space-y-1 p-2">
{mockChatHistory
.filter((msg) => msg.sender === "user")
.sort((a, b) => b.timestamp.getTime() - a.timestamp.getTime())
.map((message) => (
<Card
key={message.id}
className="p-3 cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800/50"
onClick={() => handleLoadChatHistory(message.id)}>
<div className="flex items-start gap-3">
<Avatar className="h-8 w-8 bg-green-100 dark:bg-green-900">
<div className="text-xs font-medium text-green-700 dark:text-green-300">
{message.relatedTo?.name.substring(0, 2) || "Me"}
</div>
</Avatar>
<div className="flex-1 min-w-0">
<div className="flex items-center justify-between">
<p className="text-sm font-medium truncate">
{message.relatedTo ? message.relatedTo.name : "General Question"}
</p>
<p className="text-xs text-gray-500 dark:text-gray-400 flex items-center gap-1">
<Clock className="h-3 w-3" />
{message.timestamp.toLocaleDateString()}
</p>
</div>
<p className="text-xs text-gray-600 dark:text-gray-300 truncate mt-1">
{message.content}
</p>
</div>
</div>
</Card>
))}
</div>
</TabsContent>
<TabsContent value="farms" className="m-0 p-0">
<div className="space-y-3 p-3">
{mockFarms.map((farm) => (
<div key={farm.id}>
<h3 className="text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">{farm.name}</h3>
<div className="space-y-1">
{mockChatHistory
.filter(
(msg) =>
msg.sender === "user" && msg.relatedTo?.type === "farm" && msg.relatedTo.id === farm.id
)
.map((message) => (
<Card
key={message.id}
className="p-3 cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800/50"
onClick={() => handleLoadChatHistory(message.id)}>
<p className="text-xs text-gray-600 dark:text-gray-300 truncate">{message.content}</p>
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
{message.timestamp.toLocaleDateString()}
</p>
</Card>
))}
</div>
<Separator className="my-3" />
</div>
))}
</div>
</TabsContent>
<TabsContent value="crops" className="m-0 p-0">
<div className="space-y-3 p-3">
{mockCrops.map((crop) => (
<div key={crop.id}>
<h3 className="text-sm font-medium text-gray-700 dark:text-gray-300 mb-2 flex items-center gap-1">
<Leaf className="h-3 w-3 text-green-600" />
{crop.name}
<span className="text-xs text-gray-500 dark:text-gray-400">
({mockFarms.find((f) => f.id === crop.farmId)?.name})
</span>
</h3>
<div className="space-y-1">
{mockChatHistory
.filter(
(msg) =>
msg.sender === "user" && msg.relatedTo?.type === "crop" && msg.relatedTo.id === crop.id
)
.map((message) => (
<Card
key={message.id}
className="p-3 cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800/50"
onClick={() => handleLoadChatHistory(message.id)}>
<p className="text-xs text-gray-600 dark:text-gray-300 truncate">{message.content}</p>
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
{message.timestamp.toLocaleDateString()}
</p>
</Card>
))}
</div>
<Separator className="my-3" />
</div>
))}
</div>
</TabsContent>
</ScrollArea>
</Tabs>
</div>
)}
</div>
</div>
);
}

View File

@ -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 (
<Breadcrumb>
<BreadcrumbList>
{breadcrumbItems.map((item, index) => {
const isLast = index === breadcrumbItems.length - 1;
return (
<React.Fragment key={item.href}>
{isLast ? (
<BreadcrumbItem>
<BreadcrumbPage>{item.title}</BreadcrumbPage>
</BreadcrumbItem>
) : (
<BreadcrumbItem>
<BreadcrumbLink href={item.href}>{item.title}</BreadcrumbLink>
</BreadcrumbItem>
)}
{index < breadcrumbItems.length - 1 && <BreadcrumbSeparator />}
</React.Fragment>
);
})}
</BreadcrumbList>
</Breadcrumb>
);
}

View File

@ -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 (
<Card className="w-full bg-muted/50 hover:bg-muted/80 transition-all cursor-pointer group hover:shadow-lg">
<CardHeader className="p-4 pb-2">
<Card
onClick={onClick}
className={`w-full h-full overflow-hidden transition-all duration-200 hover:shadow-lg border-muted/60 bg-white dark:bg-slate-800 hover:bg-muted/10 dark:hover:bg-slate-700`}>
<CardHeader className="p-4 pb-0">
<div className="flex items-center justify-between">
<div className="h-8 w-8 rounded-full bg-primary/10 flex items-center justify-center">
<Sprout className="h-4 w-4 text-primary" />
<Badge variant="outline" className={`capitalize ${statusColor.bg} ${statusColor.text} ${statusColor.border}`}>
{crop.status}
</Badge>
<div className="flex items-center text-xs text-muted-foreground">
<Calendar className="h-3 w-3 mr-1" />
{crop.plantedDate.toLocaleDateString()}
</div>
<span className={`text-sm font-medium capitalize ${statusColors[crop.status]}`}>{crop.status}</span>
</div>
</CardHeader>
<CardContent className="p-4">
<div className="space-y-2">
<h3 className="text-lg font-medium truncate">{crop.name}</h3>
<div className="flex items-center gap-1 text-sm text-muted-foreground">
<Calendar className="h-3 w-3" />
<p>Planted: {crop.plantedDate.toLocaleDateString()}</p>
<div className="flex items-start gap-3">
<div className={`h-10 w-10 rounded-full ${statusColor.bg} flex-shrink-0 flex items-center justify-center`}>
<Sprout className={`h-5 w-5 ${statusColor.text}`} />
</div>
<div className="flex-1">
<h3 className="text-xl font-medium mb-1">{crop.name}</h3>
<p className="text-sm text-muted-foreground mb-2">
{crop.variety} {crop.area}
</p>
{crop.status !== "planned" && (
<div className="space-y-2 mt-3">
<div className="flex justify-between text-sm">
<span className="text-muted-foreground">Progress</span>
<span className="font-medium">{crop.progress}%</span>
</div>
<Progress
value={crop.progress}
className={`h-2 ${
crop.status === "growing" ? "bg-green-500" : crop.status === "harvested" ? "bg-yellow-500" : ""
}`}
/>
</div>
)}
{crop.status === "growing" && (
<div className="flex items-center mt-3 text-sm">
<div className="flex items-center gap-1 text-green-600 dark:text-green-300">
<BarChart className="h-3.5 w-3.5" />
<span className="font-medium">Health: {crop.healthScore}%</span>
</div>
</div>
)}
</div>
</div>
</CardContent>
<CardFooter className="p-4 pt-0">
<Button
variant="ghost"
size="sm"
className="ml-auto gap-1 text-green-600 dark:text-green-300 hover:text-green-700 dark:hover:text-green-400 hover:bg-green-50/50 dark:hover:bg-green-800">
View details <ArrowRight className="h-3.5 w-3.5" />
</Button>
</CardFooter>
</Card>
);
}

View File

@ -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<Crop>) => Promise<void>;
}
export function CropDialog({ open, onOpenChange, onSubmit }: CropDialogProps) {
const [selectedPlant, setSelectedPlant] = useState<string | null>(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 (
<Dialog open={open} onOpenChange={onOpenChange}>
<VisuallyHidden>
<DialogTitle></DialogTitle>
</VisuallyHidden>
<DialogContent className="sm:max-w-[900px] p-0">
<div className="grid md:grid-cols-2 h-[600px]">
{/* Left side - Plant Selection */}
<div className="p-6 overflow-y-auto border-r dark:border-slate-700">
<h2 className="text-lg font-semibold mb-4">Select Plant to Grow</h2>
<div className="space-y-4">
{plants.map((plant) => (
<Card
key={plant.id}
className={cn(
"p-4 cursor-pointer hover:bg-muted/50 dark:hover:bg-muted/40 transition-colors",
selectedPlant === plant.id && "border-primary dark:border-primary dark:bg-primary/5 bg-primary/5"
)}
onClick={() => setSelectedPlant(plant.id)}>
<div className="flex items-center gap-4">
<img
src={plant.image || "/placeholder.svg"}
alt={plant.name}
className="w-20 h-20 rounded-lg object-cover"
/>
<div className="flex-1">
<div className="flex items-center justify-between">
<h3 className="font-medium">{plant.name}</h3>
{selectedPlant === plant.id && <Check className="h-4 w-4 text-primary" />}
</div>
<p className="text-sm text-muted-foreground">Growth time: {plant.growthTime}</p>
</div>
</div>
</Card>
))}
</div>
</div>
{/* Right side - Map */}
<div className="relative">
<div className="absolute inset-0 bg-muted/10 dark:bg-muted/20">
<div className="h-full w-full flex items-center justify-center">
<GoogleMapWithDrawing />
</div>
</div>
</div>
{/* Footer */}
<div className="absolute bottom-0 left-0 right-0 p-4 bg-background dark:bg-background border-t dark:border-slate-700">
<div className="flex justify-end gap-2">
<Button variant="outline" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button onClick={handleSubmit} disabled={!selectedPlant}>
Plant Crop
</Button>
</div>
</div>
</div>
</DialogContent>
</Dialog>
);
}

View File

@ -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 (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[800px] dark:bg-background">
<DialogHeader>
<DialogTitle>Crop Analytics - {crop.name}</DialogTitle>
</DialogHeader>
<Tabs defaultValue="overview">
<TabsList className="grid w-full grid-cols-3">
<TabsTrigger value="overview">Overview</TabsTrigger>
<TabsTrigger value="growth">Growth</TabsTrigger>
<TabsTrigger value="environment">Environment</TabsTrigger>
</TabsList>
<TabsContent value="overview" className="space-y-4">
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
<Card className="dark:bg-slate-800">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Growth Rate</CardTitle>
<Sprout className="h-4 w-4 text-muted-foreground dark:text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">+2.5%</div>
<p className="text-xs text-muted-foreground">+20.1% from last week</p>
</CardContent>
</Card>
<Card className="dark:bg-slate-800">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Water Usage</CardTitle>
<Droplets className="h-4 w-4 text-muted-foreground dark:text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">15.2L</div>
<p className="text-xs text-muted-foreground">per day average</p>
</CardContent>
</Card>
<Card className="dark:bg-slate-800">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Sunlight</CardTitle>
<Sun className="h-4 w-4 text-muted-foreground dark:text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{analytics.sunlight}%</div>
<p className="text-xs text-muted-foreground">optimal conditions</p>
</CardContent>
</Card>
</div>
<Card className="dark:bg-slate-800">
<CardHeader>
<CardTitle>Growth Timeline</CardTitle>
<CardDescription>Daily growth rate over time</CardDescription>
</CardHeader>
<CardContent className="h-[200px] flex items-center justify-center text-muted-foreground">
<LineChart className="h-8 w-8" />
<span className="ml-2">Growth chart placeholder</span>
</CardContent>
</Card>
</TabsContent>
<TabsContent value="growth" className="space-y-4">
<Card className="dark:bg-slate-800">
<CardHeader>
<CardTitle>Detailed Growth Analysis</CardTitle>
<CardDescription>Comprehensive growth metrics</CardDescription>
</CardHeader>
<CardContent className="h-[300px] flex items-center justify-center text-muted-foreground">
Detailed growth analysis placeholder
</CardContent>
</Card>
</TabsContent>
<TabsContent value="environment" className="space-y-4">
<Card className="dark:bg-slate-800">
<CardHeader>
<CardTitle>Environmental Conditions</CardTitle>
<CardDescription>Temperature, humidity, and more</CardDescription>
</CardHeader>
<CardContent className="h-[300px] flex items-center justify-center text-muted-foreground">
Environmental metrics placeholder
</CardContent>
</Card>
</TabsContent>
</Tabs>
</DialogContent>
</Dialog>
);
}

View File

@ -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<Message[]>([
{
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 (
<Dialog open={open} onOpenChange={onOpenChange}>
<VisuallyHidden>
<DialogTitle>Farming Assistant Chat</DialogTitle>
</VisuallyHidden>
<DialogContent className="sm:max-w-[500px] p-0 dark:bg-background">
<div className="flex flex-col h-[600px]">
<div className="p-4 border-b dark:border-slate-700">
<h2 className="text-lg font-semibold">Farming Assistant</h2>
<p className="text-sm text-muted-foreground">Ask questions about your {cropName}</p>
</div>
<ScrollArea className="flex-1 p-4">
<div className="space-y-4">
{messages.map((message, i) => (
<div key={i} className={`flex ${message.role === "user" ? "justify-end" : "justify-start"}`}>
<div
className={`rounded-lg px-4 py-2 max-w-[80%] ${
message.role === "user"
? "bg-primary text-primary-foreground dark:bg-primary dark:text-primary-foreground"
: "bg-muted dark:bg-muted dark:text-muted-foreground"
}`}>
{message.content}
</div>
</div>
))}
</div>
</ScrollArea>
<div className="p-4 border-t dark:border-slate-700">
<form
onSubmit={(e) => {
e.preventDefault();
handleSend();
}}
className="flex gap-2">
<Input placeholder="Type your message..." value={input} onChange={(e) => setInput(e.target.value)} />
<Button type="submit" size="icon">
<Send className="h-4 w-4" />
</Button>
</form>
</div>
</div>
</DialogContent>
</Dialog>
);
}

View File

@ -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<CropDetailPageParams> }) {
const router = useRouter();
const [crop, setCrop] = useState<Crop | null>(null);
const [analytics, setAnalytics] = useState<CropAnalytics | null>(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 (
<div className="min-h-screen flex items-center justify-center bg-background text-foreground">Loading...</div>
);
}
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 (
<div className="min-h-screen bg-background text-foreground">
<div className="container max-w-7xl p-6 mx-auto">
{/* Header */}
<div className="flex flex-col gap-6 mb-8">
<div className="flex items-center justify-between">
<Button
variant="ghost"
className="gap-2 text-green-700 dark:text-green-300 hover:text-green-800 dark:hover:text-green-200 hover:bg-green-100/50 dark:hover:bg-green-800/50"
onClick={() => router.back()}>
<ArrowLeft className="h-4 w-4" /> Back to Farm
</Button>
<HoverCard>
<HoverCardTrigger asChild>
<Button variant="outline" className="gap-2">
<Calendar className="h-4 w-4" /> Timeline
</Button>
</HoverCardTrigger>
<HoverCardContent className="w-80">
<div className="flex justify-between space-x-4">
<Avatar>
<AvatarImage src="/placeholder.svg" />
<AvatarFallback>
<Sprout className="h-4 w-4" />
</AvatarFallback>
</Avatar>
<div className="space-y-1">
<h4 className="text-sm font-semibold">Growth Timeline</h4>
<p className="text-sm text-muted-foreground">Planted on {crop.plantedDate.toLocaleDateString()}</p>
<div className="flex items-center pt-2">
<Separator className="w-full" />
<span className="mx-2 text-xs text-muted-foreground">
{Math.floor(analytics.growthProgress)}% Complete
</span>
<Separator className="w-full" />
</div>
</div>
</div>
</HoverCardContent>
</HoverCard>
</div>
<div className="flex flex-col md:flex-row justify-between gap-4">
<div className="space-y-1">
<h1 className="text-3xl font-bold tracking-tight">{crop.name}</h1>
<p className="text-muted-foreground">
{crop.variety} {crop.area}
</p>
</div>
<div className="flex items-center gap-4">
<div className="flex flex-col items-end">
<div className="flex items-center gap-2">
<Badge variant="outline" className={`${healthColors[analytics.plantHealth]} border`}>
Health Score: {crop.healthScore}%
</Badge>
<Badge variant="outline" className="bg-blue-50 dark:bg-blue-900 text-blue-600 dark:text-blue-300">
Growing
</Badge>
</div>
{crop.expectedHarvest ? (
<p className="text-sm text-muted-foreground mt-1">
Expected harvest: {crop.expectedHarvest.toLocaleDateString()}
</p>
) : (
<p className="text-sm text-muted-foreground mt-1">Expected harvest date not available</p>
)}
</div>
</div>
</div>
</div>
{/* Main Content */}
<div className="grid gap-6 md:grid-cols-12">
{/* Left Column */}
<div className="md:col-span-8 space-y-6">
{/* Quick Actions */}
<div className="grid sm:grid-cols-2 lg:grid-cols-4 gap-4">
{quickActions.map((action) => (
<Button
key={action.title}
variant="outline"
className={`h-auto p-4 flex flex-col items-center gap-3 transition-all group ${action.color} hover:scale-105`}
onClick={action.onClick}>
<div className={`p-3 rounded-lg ${action.color} group-hover:scale-110 transition-transform`}>
<action.icon className="h-5 w-5" />
</div>
<div className="text-center">
<div className="font-medium mb-1">{action.title}</div>
<p className="text-xs text-muted-foreground">{action.description}</p>
</div>
</Button>
))}
</div>
{/* Environmental Metrics */}
<Card className="border-green-100 dark:border-green-700">
<CardHeader>
<CardTitle>Environmental Conditions</CardTitle>
<CardDescription>Real-time monitoring of growing conditions</CardDescription>
</CardHeader>
<CardContent>
<div className="grid gap-6">
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
{[
{
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) => (
<Card
key={metric.label}
className="border-none shadow-none bg-gradient-to-br from-white to-gray-50/50 dark:from-slate-800 dark:to-slate-700/50">
<CardContent className="p-4">
<div className="flex items-start gap-4">
<div className={`p-2 rounded-lg ${metric.bg}`}>
<metric.icon className={`h-4 w-4 ${metric.color}`} />
</div>
<div>
<p className="text-sm font-medium text-muted-foreground">{metric.label}</p>
<p className="text-2xl font-semibold tracking-tight">{metric.value}</p>
</div>
</div>
</CardContent>
</Card>
))}
</div>
<Separator />
{/* Growth Progress */}
<div className="space-y-2">
<div className="flex justify-between text-sm">
<span className="font-medium">Growth Progress</span>
<span className="text-muted-foreground">{analytics.growthProgress}%</span>
</div>
<Progress value={analytics.growthProgress} className="h-2" />
</div>
{/* Next Action Card */}
<Card className="border-green-100 dark:border-green-700 bg-green-50/50 dark:bg-green-900/50">
<CardContent className="p-4">
<div className="flex items-start gap-4">
<div className="p-2 rounded-lg bg-green-100 dark:bg-green-800">
<Timer className="h-4 w-4 text-green-600 dark:text-green-300" />
</div>
<div>
<p className="font-medium mb-1">Next Action Required</p>
<p className="text-sm text-muted-foreground">{analytics.nextAction}</p>
<p className="text-xs text-muted-foreground mt-1">
Due by {analytics.nextActionDue.toLocaleDateString()}
</p>
</div>
</div>
</CardContent>
</Card>
</div>
</CardContent>
</Card>
{/* Map Section */}
<Card className="border-green-100 dark:border-green-700">
<CardHeader>
<CardTitle>Field Map</CardTitle>
<CardDescription>View and manage crop location</CardDescription>
</CardHeader>
<CardContent className="p-0 h-[400px]">
<GoogleMapWithDrawing />
</CardContent>
</Card>
</div>
{/* Right Column */}
<div className="md:col-span-4 space-y-6">
{/* Nutrient Levels */}
<Card className="border-green-100 dark:border-green-700">
<CardHeader>
<CardTitle>Nutrient Levels</CardTitle>
<CardDescription>Current soil composition</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-4">
{[
{
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) => (
<div key={nutrient.name} className="space-y-2">
<div className="flex justify-between text-sm">
<span className="font-medium">{nutrient.name}</span>
<span className="text-muted-foreground">{nutrient.value}%</span>
</div>
<Progress value={nutrient.value} className={`h-2 ${nutrient.color}`} />
</div>
))}
</div>
</CardContent>
</Card>
{/* Recent Activity */}
<Card className="border-green-100 dark:border-green-700">
<CardHeader>
<CardTitle>Recent Activity</CardTitle>
<CardDescription>Latest updates and changes</CardDescription>
</CardHeader>
<CardContent>
<ScrollArea className="h-[300px] pr-4">
{[...Array(5)].map((_, i) => (
<div key={i} className="mb-4 last:mb-0">
<div className="flex items-start gap-4">
<div className="p-2 rounded-lg bg-gray-100 dark:bg-gray-800">
<Activity icon={i} />
</div>
<div>
<p className="text-sm font-medium">
{
[
"Irrigation completed",
"Nutrient levels checked",
"Growth measurement taken",
"Pest inspection completed",
"Soil pH tested",
][i]
}
</p>
<p className="text-xs text-muted-foreground">2 hours ago</p>
</div>
</div>
{i < 4 && <Separator className="my-4 dark:bg-slate-700" />}
</div>
))}
</ScrollArea>
</CardContent>
</Card>
</div>
</div>
{/* Dialogs */}
<ChatbotDialog open={isChatOpen} onOpenChange={setIsChatOpen} cropName={crop.name} />
<AnalyticsDialog open={isAnalyticsOpen} onOpenChange={setIsAnalyticsOpen} crop={crop} analytics={analytics} />
</div>
</div>
);
}
/**
* Helper component to render an activity icon based on the index.
*/
function Activity({ icon }: { icon: number }) {
const icons = [
<Droplets key="0" className="h-4 w-4 text-blue-500 dark:text-blue-300" />,
<Leaf key="1" className="h-4 w-4 text-green-500 dark:text-green-300" />,
<LineChart key="2" className="h-4 w-4 text-purple-500 dark:text-purple-300" />,
<Sprout key="3" className="h-4 w-4 text-yellow-500 dark:text-yellow-300" />,
<ThermometerSun key="4" className="h-4 w-4 text-orange-500 dark:text-orange-300" />,
];
return icons[icon];
}

View File

@ -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<Farm | undefined>(getFarmById(farmId));
const [crops, setCrops] = useState<Crop[]>(getCropsByFarmId(farmId));
const [farm, setFarm] = useState<Farm | null>(null);
const [crops, setCrops] = useState<Crop[]>([]);
const [isDialogOpen, setIsDialogOpen] = useState(false);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [activeFilter, setActiveFilter] = useState<string>("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<Crop>) => {
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 (
<div className="container max-w-screen-xl p-8">
<Button variant="ghost" className="mb-4" onClick={() => router.back()}>
<ArrowLeft className="mr-2 h-4 w-4" /> Back to Farms
</Button>
<div className="min-h-screen bg-background text-foreground">
<div className="container max-w-7xl p-6 mx-auto">
<div className="flex flex-col gap-6">
{/* Breadcrumbs */}
<nav className="flex items-center text-sm text-muted-foreground">
<Button
variant="link"
className="p-0 h-auto font-normal text-muted-foreground"
onClick={() => router.push("/")}>
<Home className="h-3.5 w-3.5 mr-1" />
Home
</Button>
<ChevronRight className="h-3.5 w-3.5 mx-1" />
<Button
variant="link"
className="p-0 h-auto font-normal text-muted-foreground"
onClick={() => router.push("/farms")}>
Farms
</Button>
<ChevronRight className="h-3.5 w-3.5 mx-1" />
<span className="text-foreground font-medium truncate">{farm?.name || "Farm Details"}</span>
</nav>
<div className="grid gap-6 md:grid-cols-2">
<Card className="md:col-span-2">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<div className="flex items-center space-x-2">
<div className="h-8 w-8 rounded-full bg-primary/10 flex items-center justify-center">
<Sprout className="h-4 w-4 text-primary" />
</div>
<h1 className="text-2xl font-bold">{farm?.name ?? "Unknown Farm"}</h1>
</div>
<div className="flex items-center text-sm text-muted-foreground">
<MapPin className="mr-1 h-4 w-4" />
{farm?.location ?? "Unknown Location"}
</div>
</CardHeader>
<CardContent>
<div className="grid gap-2">
<div className="flex justify-between">
<span className="font-medium">Farm Type:</span>
<span className="text-muted-foreground">{farm?.type ?? "Unknown Type"}</span>
</div>
<div className="flex justify-between">
<span className="font-medium">Created:</span>
<span className="text-muted-foreground">{farm?.createdAt?.toLocaleDateString() ?? "Unknown Date"}</span>
</div>
<div className="flex justify-between">
<span className="font-medium">Total Crops:</span>
<span className="text-muted-foreground">{crops.length}</span>
</div>
</div>
</CardContent>
</Card>
{/* Back button */}
<Button
variant="outline"
size="sm"
className="w-fit gap-2 text-muted-foreground"
onClick={() => router.push("/farms")}>
<ArrowLeft className="h-4 w-4" /> Back to Farms
</Button>
<div className="md:col-span-2">
<h2 className="text-xl font-bold mb-4">Crops</h2>
<Separator className="my-4" />
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
<Dialog open={isDialogOpen} onOpenChange={setIsDialogOpen}>
<Card
className="w-full bg-muted/50 hover:bg-muted/80 transition-all cursor-pointer group hover:shadow-lg"
onClick={() => setIsDialogOpen(true)}>
<CardContent className="p-6">
<div className="flex flex-col gap-6">
<div className="h-8 w-8 rounded-full bg-primary/10 flex items-center justify-center group-hover:bg-primary/20 transition-colors">
<Plus className="h-5 w-5 text-primary" />
{/* Error state */}
{error && (
<Alert variant="destructive">
<AlertTriangle className="h-4 w-4" />
<AlertTitle>Error</AlertTitle>
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
{/* Loading state */}
{isLoading && (
<div className="flex flex-col items-center justify-center py-12">
<Loader2 className="h-8 w-8 text-green-600 animate-spin mb-4" />
<p className="text-muted-foreground">Loading farm details...</p>
</div>
)}
{/* Farm details */}
{!isLoading && !error && farm && (
<>
<div className="grid gap-6 md:grid-cols-12">
{/* Farm info card */}
<Card className="md:col-span-8">
<CardHeader className="pb-2">
<div className="flex items-center justify-between">
<Badge
variant="outline"
className="capitalize bg-green-50 dark:bg-green-900 text-green-700 dark:text-green-300 border-green-200">
{farm.type}
</Badge>
<div className="flex items-center text-sm text-muted-foreground">
<Calendar className="h-4 w-4 mr-1" />
Created {farm.createdAt.toLocaleDateString()}
</div>
</div>
<div className="space-y-1">
<h3 className="text-xl font-medium">Add Crop</h3>
<p className="text-sm text-muted-foreground">Plant a new crop</p>
<div className="flex items-start gap-4 mt-2">
<div className="h-12 w-12 rounded-full bg-green-100 dark:bg-green-800 flex items-center justify-center">
<Sprout className="h-6 w-6 text-green-600 dark:text-green-300" />
</div>
<div>
<h1 className="text-2xl font-bold">{farm.name}</h1>
<div className="flex items-center text-muted-foreground mt-1">
<MapPin className="h-4 w-4 mr-1" />
{farm.location}
</div>
</div>
</div>
</CardHeader>
<CardContent>
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4 mt-2">
<div className="bg-muted/30 dark:bg-muted/20 rounded-lg p-3">
<p className="text-xs text-muted-foreground">Total Area</p>
<p className="text-lg font-semibold">{farm.area}</p>
</div>
<div className="bg-muted/30 dark:bg-muted/20 rounded-lg p-3">
<p className="text-xs text-muted-foreground">Total Crops</p>
<p className="text-lg font-semibold">{farm.crops}</p>
</div>
<div className="bg-muted/30 dark:bg-muted/20 rounded-lg p-3">
<p className="text-xs text-muted-foreground">Growing Crops</p>
<p className="text-lg font-semibold">{cropCounts.growing}</p>
</div>
<div className="bg-muted/30 dark:bg-muted/20 rounded-lg p-3">
<p className="text-xs text-muted-foreground">Harvested</p>
<p className="text-lg font-semibold">{cropCounts.harvested}</p>
</div>
</div>
</CardContent>
</Card>
{/* Weather card */}
<Card className="md:col-span-4">
<CardHeader>
<CardTitle className="text-lg">Current Conditions</CardTitle>
<CardDescription>Weather at your farm location</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div className="flex items-start gap-2">
<div className="p-2 rounded-lg bg-orange-50 dark:bg-orange-900">
<Sun className="h-4 w-4 text-orange-500 dark:text-orange-200" />
</div>
<div>
<p className="text-sm font-medium text-muted-foreground">Temperature</p>
<p className="text-xl font-semibold">{farm.weather?.temperature}°C</p>
</div>
</div>
<div className="flex items-start gap-2">
<div className="p-2 rounded-lg bg-blue-50 dark:bg-blue-900">
<Droplets className="h-4 w-4 text-blue-500 dark:text-blue-200" />
</div>
<div>
<p className="text-sm font-medium text-muted-foreground">Humidity</p>
<p className="text-xl font-semibold">{farm.weather?.humidity}%</p>
</div>
</div>
<div className="flex items-start gap-2">
<div className="p-2 rounded-lg bg-yellow-50 dark:bg-yellow-900">
<Sun className="h-4 w-4 text-yellow-500 dark:text-yellow-200" />
</div>
<div>
<p className="text-sm font-medium text-muted-foreground">Sunlight</p>
<p className="text-xl font-semibold">{farm.weather?.sunlight}%</p>
</div>
</div>
<div className="flex items-start gap-2">
<div className="p-2 rounded-lg bg-gray-50 dark:bg-gray-900">
<Wind className="h-4 w-4 text-gray-500 dark:text-gray-300" />
</div>
<div>
<p className="text-sm font-medium text-muted-foreground">Rainfall</p>
<p className="text-xl font-semibold">{farm.weather?.rainfall}</p>
</div>
</div>
</div>
</CardContent>
</Card>
</div>
{/* Crops section */}
<div className="mt-4">
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4 mb-4">
<div>
<h2 className="text-xl font-bold flex items-center">
<LayoutGrid className="h-5 w-5 mr-2 text-green-600 dark:text-green-300" />
Crops
</h2>
<p className="text-sm text-muted-foreground">Manage and monitor all crops in this farm</p>
</div>
</CardContent>
</Card>
<DialogContent className="sm:max-w-[425px]">
<DialogHeader>
<DialogTitle>Add New Crop</DialogTitle>
<DialogDescription>Fill out the form to add a new crop to your farm.</DialogDescription>
</DialogHeader>
<AddCropForm onSubmit={handleAddCrop} onCancel={() => setIsDialogOpen(false)} />
</DialogContent>
</Dialog>
<Button
onClick={() => setIsDialogOpen(true)}
className="gap-2 bg-green-600 hover:bg-green-700 w-full sm:w-auto">
<Plus className="h-4 w-4" />
Add New Crop
</Button>
</div>
{crops.map((crop) => (
<CropCard key={crop.id} crop={crop} />
))}
</div>
<Tabs defaultValue="all" className="mt-6">
<TabsList>
<TabsTrigger value="all" onClick={() => setActiveFilter("all")}>
All Crops ({cropCounts.all})
</TabsTrigger>
<TabsTrigger value="growing" onClick={() => setActiveFilter("growing")}>
Growing ({cropCounts.growing})
</TabsTrigger>
<TabsTrigger value="planned" onClick={() => setActiveFilter("planned")}>
Planned ({cropCounts.planned})
</TabsTrigger>
<TabsTrigger value="harvested" onClick={() => setActiveFilter("harvested")}>
Harvested ({cropCounts.harvested})
</TabsTrigger>
</TabsList>
<TabsContent value="all" className="mt-6">
{filteredCrops.length === 0 ? (
<div className="flex flex-col items-center justify-center py-12 bg-muted/20 dark:bg-muted/30 rounded-lg border border-dashed">
<div className="bg-green-100 dark:bg-green-900 p-3 rounded-full mb-4">
<Sprout className="h-6 w-6 text-green-600 dark:text-green-300" />
</div>
<h3 className="text-xl font-medium mb-2">No crops found</h3>
<p className="text-muted-foreground text-center max-w-md mb-6">
{activeFilter === "all"
? "You haven't added any crops to this farm yet."
: `No ${activeFilter} crops found. Try a different filter.`}
</p>
<Button onClick={() => setIsDialogOpen(true)} className="gap-2">
<Plus className="h-4 w-4" />
Add your first crop
</Button>
</div>
) : (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
<AnimatePresence>
{filteredCrops.map((crop, index) => (
<motion.div
key={crop.id}
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -20 }}
transition={{ duration: 0.2, delay: index * 0.05 }}>
<CropCard
crop={crop}
onClick={() => router.push(`/farms/${crop.farmId}/crops/${crop.id}`)}
/>
</motion.div>
))}
</AnimatePresence>
</div>
)}
</TabsContent>
{/* Growing tab */}
<TabsContent value="growing" className="mt-6">
{filteredCrops.length === 0 ? (
<div className="flex flex-col items-center justify-center py-12 bg-muted/20 dark:bg-muted/30 rounded-lg border border-dashed">
<div className="bg-green-100 dark:bg-green-900 p-3 rounded-full mb-4">
<Sprout className="h-6 w-6 text-green-600 dark:text-green-300" />
</div>
<h3 className="text-xl font-medium mb-2">No growing crops</h3>
<p className="text-muted-foreground text-center max-w-md mb-6">
You don't have any growing crops in this farm yet.
</p>
<Button onClick={() => setIsDialogOpen(true)} className="gap-2">
<Plus className="h-4 w-4" />
Add a growing crop
</Button>
</div>
) : (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
<AnimatePresence>
{filteredCrops.map((crop, index) => (
<motion.div
key={crop.id}
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -20 }}
transition={{ duration: 0.2, delay: index * 0.05 }}>
<CropCard
crop={crop}
onClick={() => router.push(`/farms/${crop.farmId}/crops/${crop.id}`)}
/>
</motion.div>
))}
</AnimatePresence>
</div>
)}
</TabsContent>
{/* Planned tab */}
<TabsContent value="planned" className="mt-6">
{filteredCrops.length === 0 ? (
<div className="flex flex-col items-center justify-center py-12 bg-muted/20 dark:bg-muted/30 rounded-lg border border-dashed">
<h3 className="text-xl font-medium mb-2">No planned crops</h3>
<p className="text-muted-foreground text-center max-w-md mb-6">
You don't have any planned crops in this farm yet.
</p>
<Button onClick={() => setIsDialogOpen(true)} className="gap-2">
<Plus className="h-4 w-4" />
Plan a new crop
</Button>
</div>
) : (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
<AnimatePresence>
{filteredCrops.map((crop, index) => (
<motion.div
key={crop.id}
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -20 }}
transition={{ duration: 0.2, delay: index * 0.05 }}>
<CropCard
crop={crop}
onClick={() => router.push(`/farms/${crop.farmId}/crops/${crop.id}`)}
/>
</motion.div>
))}
</AnimatePresence>
</div>
)}
</TabsContent>
{/* Harvested tab */}
<TabsContent value="harvested" className="mt-6">
{filteredCrops.length === 0 ? (
<div className="flex flex-col items-center justify-center py-12 bg-muted/20 dark:bg-muted/30 rounded-lg border border-dashed">
<h3 className="text-xl font-medium mb-2">No harvested crops</h3>
<p className="text-muted-foreground text-center max-w-md mb-6">
You don't have any harvested crops in this farm yet.
</p>
<Button onClick={() => setActiveFilter("all")} className="gap-2">
View all crops
</Button>
</div>
) : (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
<AnimatePresence>
{filteredCrops.map((crop, index) => (
<motion.div
key={crop.id}
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -20 }}
transition={{ duration: 0.2, delay: index * 0.05 }}>
<CropCard
crop={crop}
onClick={() => router.push(`/farms/${crop.farmId}/crops/${crop.id}`)}
/>
</motion.div>
))}
</AnimatePresence>
</div>
)}
</TabsContent>
</Tabs>
</div>
</>
)}
</div>
</div>
{/* Add Crop Dialog */}
<CropDialog open={isDialogOpen} onOpenChange={setIsDialogOpen} onSubmit={handleAddCrop} />
</div>
);
}

View File

@ -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<Farm>) => Promise<void>;
onCancel: () => void;
}
export function AddFarmForm({ onSubmit, onCancel }: AddFarmFormProps) {
const [isSubmitting, setIsSubmitting] = useState(false);
const form = useForm<z.infer<typeof farmFormSchema>>({
resolver: zodResolver(farmFormSchema),
defaultValues: {
name: "",
location: "",
type: "",
area: "",
},
});
const handleSubmit = async (values: z.infer<typeof farmFormSchema>) => {
try {
setIsSubmitting(true);
await onSubmit(values);
form.reset();
} catch (error) {
console.error("Error submitting form:", error);
} finally {
setIsSubmitting(false);
}
};
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
<form onSubmit={form.handleSubmit(handleSubmit)} className="space-y-6">
<FormField
control={form.control}
name="name"
@ -37,7 +60,7 @@ export function AddFarmForm({ onSubmit, onCancel }: AddFarmFormProps) {
<FormControl>
<Input placeholder="Enter farm name" {...field} />
</FormControl>
<FormDescription>This is your farm&apos;s display name.</FormDescription>
<FormDescription>This is your farm's display name.</FormDescription>
<FormMessage />
</FormItem>
)}
@ -52,6 +75,7 @@ export function AddFarmForm({ onSubmit, onCancel }: AddFarmFormProps) {
<FormControl>
<Input placeholder="Enter farm location" {...field} />
</FormControl>
<FormDescription>City, region or specific address</FormDescription>
<FormMessage />
</FormItem>
)}
@ -73,6 +97,7 @@ export function AddFarmForm({ onSubmit, onCancel }: AddFarmFormProps) {
<SelectItem value="durian">Durian</SelectItem>
<SelectItem value="mango">Mango</SelectItem>
<SelectItem value="rice">Rice</SelectItem>
<SelectItem value="mixed">Mixed Crops</SelectItem>
<SelectItem value="other">Other</SelectItem>
</SelectContent>
</Select>
@ -81,11 +106,35 @@ export function AddFarmForm({ onSubmit, onCancel }: AddFarmFormProps) {
)}
/>
<FormField
control={form.control}
name="area"
render={({ field }) => (
<FormItem>
<FormLabel>Total Area (optional)</FormLabel>
<FormControl>
<Input placeholder="e.g., 10 hectares" {...field} />
</FormControl>
<FormDescription>The total size of your farm</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<div className="flex justify-end gap-2">
<Button type="button" variant="outline" onClick={onCancel}>
<Button type="button" variant="outline" onClick={onCancel} disabled={isSubmitting}>
Cancel
</Button>
<Button type="submit">Create Farm</Button>
<Button type="submit" disabled={isSubmitting} className="bg-green-600 hover:bg-green-700">
{isSubmitting ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Creating...
</>
) : (
"Create Farm"
)}
</Button>
</div>
</form>
</Form>

View File

@ -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 (
<Card className={cardClasses} onClick={onClick}>
<CardContent className="p-6">
<div className="flex flex-col gap-6">
<div className="h-8 w-8 rounded-full bg-primary/10 flex items-center justify-center group-hover:bg-primary/20 transition-colors">
<Plus className="h-5 w-5 text-primary" />
</div>
<div className="space-y-1">
<h3 className="text-xl font-medium">Setup</h3>
<p className="text-sm text-muted-foreground">Setup new farm</p>
</div>
<div className="flex flex-col items-center justify-center h-full p-6 text-center cursor-pointer">
<div className="h-12 w-12 rounded-full bg-green-100 dark:bg-green-800 flex items-center justify-center mb-4 group-hover:bg-green-200 dark:group-hover:bg-green-700 transition-colors">
<Plus className="h-6 w-6 text-green-600 dark:text-green-300" />
</div>
</CardContent>
<h3 className="text-xl font-medium mb-2">Add New Farm</h3>
<p className="text-sm text-muted-foreground">Create a new farm to manage your crops and resources</p>
</div>
</Card>
);
}
if (variant === "farm" && farm) {
const formattedDate = new Intl.DateTimeFormat("en-US", {
year: "numeric",
month: "short",
day: "numeric",
}).format(farm.createdAt);
return (
<Card className={cardClasses} onClick={onClick}>
<CardHeader className="p-4 pb-0">
<div className="flex items-center justify-between">
<div className="h-8 w-8 rounded-full bg-primary/10 flex items-center justify-center">
<Sprout className="h-4 w-4 text-primary" />
<Badge
variant="outline"
className="capitalize bg-green-50 dark:bg-green-900 text-green-700 dark:text-green-300 border-green-200">
{farm.type}
</Badge>
<div className="flex items-center text-xs text-muted-foreground">
<CalendarDays className="h-3 w-3 mr-1" />
{formattedDate}
</div>
<span className="text-sm font-medium text-primary">{farm.type}</span>
</div>
</CardHeader>
<CardContent className="p-4">
<div className="space-y-4">
<div className="flex items-start gap-3">
<div className="h-10 w-10 rounded-full flex-shrink-0 flex items-center justify-center">
<Sprout className="h-5 w-5 text-green-600" />
</div>
<div>
<h3 className="text-xl font-medium truncate">{farm.name}</h3>
<div className="flex items-center gap-1 mt-1 text-muted-foreground">
<MapPin className="h-3 w-3" />
<p className="text-sm">{farm.location}</p>
<h3 className="text-xl font-medium mb-1 truncate">{farm.name}</h3>
<div className="flex items-center text-sm text-muted-foreground mb-2">
<MapPin className="h-3.5 w-3.5 mr-1 flex-shrink-0" />
<span className="truncate">{farm.location}</span>
</div>
<div className="grid grid-cols-2 gap-2 mt-3">
<div className="bg-muted/30 dark:bg-muted/20 rounded-md p-2 text-center">
<p className="text-xs text-muted-foreground">Area</p>
<p className="font-medium">{farm.area}</p>
</div>
<div className="bg-muted/30 dark:bg-muted/20 rounded-md p-2 text-center">
<p className="text-xs text-muted-foreground">Crops</p>
<p className="font-medium">{farm.crops}</p>
</div>
</div>
</div>
<div className="text-xs text-muted-foreground">Created {farm.createdAt.toLocaleDateString()}</div>
</div>
</CardContent>
<CardFooter className="p-4 pt-0">
<Button
variant="ghost"
size="sm"
className="ml-auto gap-1 text-green-600 hover:text-green-700 hover:bg-green-50/50 dark:hover:bg-green-800">
View details <ArrowRight className="h-3.5 w-3.5" />
</Button>
</CardFooter>
</Card>
);
}

View File

@ -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<Farm[]>([
{
id: "1",
name: "Green Valley Farm",
location: "Bangkok",
type: "durian",
createdAt: new Date(),
const [activeFilter, setActiveFilter] = useState<string>("all");
const [sortOrder, setSortOrder] = useState<"newest" | "oldest" | "alphabetical">("newest");
const [isDialogOpen, setIsDialogOpen] = useState(false);
const {
data: farms,
isLoading,
isError,
error,
} = useQuery<Farm[]>({
queryKey: ["farms"],
queryFn: fetchFarms,
staleTime: 60 * 1000,
});
const mutation = useMutation({
mutationFn: (data: Partial<Farm>) => 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<Farm>) => {
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 (
<div className="container max-w-screen-xl p-8">
<div className="flex justify-between items-center">
<h1 className="text-3xl font-bold">Farms</h1>
<div className="relative w-64">
<Search className="absolute left-2 top-2.5 h-4 w-4 text-muted-foreground" />
<Input
placeholder="Search farms..."
className="pl-8"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
/>
<div className="min-h-screen bg-gradient-to-b">
<div className="container max-w-7xl p-6 mx-auto">
<div className="flex flex-col gap-6">
{/* Header */}
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4">
<div>
<h1 className="text-3xl font-bold tracking-tight">Your Farms</h1>
<p className="text-muted-foreground mt-1">Manage and monitor all your agricultural properties</p>
</div>
<div className="flex items-center gap-3">
<div className="relative flex-1 md:w-64">
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
<Input
placeholder="Search farms..."
className="pl-8"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
/>
</div>
<Button onClick={() => setIsDialogOpen(true)} className="gap-2 bg-green-600 hover:bg-green-700">
<Plus className="h-4 w-4" />
Add Farm
</Button>
</div>
</div>
{/* Filtering and sorting controls */}
<div className="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4">
<div className="flex flex-wrap gap-2">
{farmTypes.map((type) => (
<Badge
key={type}
variant={activeFilter === type ? "default" : "outline"}
className={`capitalize cursor-pointer ${
activeFilter === type ? "bg-green-600" : "hover:bg-green-100"
}`}
onClick={() => setActiveFilter(type)}>
{type === "all" ? "All Farms" : type}
</Badge>
))}
</div>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" className="gap-2">
<SlidersHorizontal className="h-4 w-4" />
Sort
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuLabel>Sort by</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuItem
className={sortOrder === "newest" ? "bg-green-50" : ""}
onClick={() => setSortOrder("newest")}>
<Calendar className="h-4 w-4 mr-2" />
Newest first
{sortOrder === "newest" && <Check className="h-4 w-4 ml-2" />}
</DropdownMenuItem>
<DropdownMenuItem
className={sortOrder === "oldest" ? "bg-green-50" : ""}
onClick={() => setSortOrder("oldest")}>
<Calendar className="h-4 w-4 mr-2" />
Oldest first
{sortOrder === "oldest" && <Check className="h-4 w-4 ml-2" />}
</DropdownMenuItem>
<DropdownMenuItem
className={sortOrder === "alphabetical" ? "bg-green-50" : ""}
onClick={() => setSortOrder("alphabetical")}>
<Filter className="h-4 w-4 mr-2" />
Alphabetical
{sortOrder === "alphabetical" && <Check className="h-4 w-4 ml-2" />}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
<Separator className="my-2" />
{/* Error state */}
{isError && (
<Alert variant="destructive">
<AlertTriangle className="h-4 w-4" />
<AlertTitle>Error</AlertTitle>
<AlertDescription>{(error as Error)?.message}</AlertDescription>
</Alert>
)}
{/* Loading state */}
{isLoading && (
<div className="flex flex-col items-center justify-center py-12">
<Loader2 className="h-8 w-8 text-green-600 animate-spin mb-4" />
<p className="text-muted-foreground">Loading your farms...</p>
</div>
)}
{/* Empty state */}
{!isLoading && !isError && filteredAndSortedFarms.length === 0 && (
<div className="flex flex-col items-center justify-center py-12 bg-muted/20 rounded-lg border border-dashed">
<div className="bg-green-100 p-3 rounded-full mb-4">
<Leaf className="h-6 w-6 text-green-600" />
</div>
<h3 className="text-xl font-medium mb-2">No farms found</h3>
{searchQuery || activeFilter !== "all" ? (
<p className="text-muted-foreground text-center max-w-md mb-6">
No farms match your current filters. Try adjusting your search or filters.
</p>
) : (
<p className="text-muted-foreground text-center max-w-md mb-6">
You haven't added any farms yet. Get started by adding your first farm.
</p>
)}
<Button
onClick={() => {
setSearchQuery("");
setActiveFilter("all");
if (!farms || farms.length === 0) {
setIsDialogOpen(true);
}
}}
className="gap-2">
{searchQuery || activeFilter !== "all" ? (
"Clear filters"
) : (
<>
<Plus className="h-4 w-4" />
Add your first farm
</>
)}
</Button>
</div>
)}
{/* Grid of farm cards */}
{!isLoading && !isError && filteredAndSortedFarms.length > 0 && (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
<AnimatePresence>
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -20 }}
transition={{ duration: 0.2 }}
className="col-span-1">
<FarmCard variant="add" onClick={() => setIsDialogOpen(true)} />
</motion.div>
{filteredAndSortedFarms.map((farm, index) => (
<motion.div
key={farm.id}
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -20 }}
transition={{ duration: 0.2, delay: index * 0.05 }}
className="col-span-1">
<FarmCard variant="farm" farm={farm} onClick={() => router.push(`/farms/${farm.id}`)} />
</motion.div>
))}
</AnimatePresence>
</div>
)}
</div>
</div>
<Separator className="my-4" />
<div className="grid grid-cols-5 gap-4">
<Dialog open={isDialogOpen} onOpenChange={setIsDialogOpen}>
<FarmCard variant="add" onClick={() => setIsDialogOpen(true)} />
<DialogContent className="sm:max-w-[425px]">
<DialogHeader>
<DialogTitle>Setup New Farm</DialogTitle>
<DialogDescription>Fill out the form to configure your new farm.</DialogDescription>
</DialogHeader>
<AddFarmForm onSubmit={handleAddFarm} onCancel={() => setIsDialogOpen(false)} />
</DialogContent>
</Dialog>
{filteredFarms.map((farm) => (
<FarmCard key={farm.id} variant="farm" farm={farm} />
))}
</div>
{/* Add Farm Dialog */}
<Dialog open={isDialogOpen} onOpenChange={setIsDialogOpen}>
<DialogContent className="sm:max-w-[500px]">
<DialogHeader>
<DialogTitle>Add New Farm</DialogTitle>
<DialogDescription>Fill out the details below to add a new farm to your account.</DialogDescription>
</DialogHeader>
<AddFarmForm onSubmit={handleAddFarm} onCancel={() => setIsDialogOpen(false)} />
</DialogContent>
</Dialog>
</div>
);
}
/**
* A helper component for the Check icon.
*/
function Check({ className }: { className?: string }) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className={className}>
<polyline points="20 6 9 17 4 12" />
</svg>
);
}

View File

@ -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<Blog | null>({
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 <div className="flex min-h-screen bg-background items-center justify-center">Loading...</div>;
}
if (isError || !blog) {
return <div className="flex min-h-screen bg-background items-center justify-center">Error loading blog.</div>;
}
return (
<div className="min-h-screen bg-background">
{/* Header */}
<header className="border-b sticky top-0 z-10 bg-background/95 backdrop-blur">
<div className="container flex items-center justify-between h-16 px-4">
<Link href="/knowledge-hub">
<Button variant="ghost" size="sm" className="gap-1">
<ArrowLeft className="h-4 w-4" /> Back to Knowledge Hub
</Button>
</Link>
<div className="flex items-center gap-2">
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button variant="ghost" size="icon">
<Share2 className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>
<p>Share article</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button variant="ghost" size="icon">
<Bookmark className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>
<p>Save article</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
</div>
</header>
{/* Main content */}
<main className="container px-4 py-8 md:py-12">
<div className="grid lg:grid-cols-[1fr_300px] gap-10 max-w-6xl mx-auto">
{/* Article content */}
<div>
<div className="mb-4">
<Badge className="rounded-full">{blog.topic}</Badge>
</div>
<h1 className="text-3xl md:text-4xl lg:text-5xl font-bold tracking-tight mb-4">{blog.title}</h1>
<div className="flex flex-wrap items-center gap-3 text-sm text-muted-foreground mb-6">
<div className="flex items-center gap-1">
<Calendar className="h-4 w-4" />
<span>{new Date(blog.date).toLocaleDateString()}</span>
</div>
<div className="flex items-center gap-1">
<Clock className="h-4 w-4" />
<span>{blog.readTime}</span>
</div>
<span>By {blog.author}</span>
</div>
<div className="relative h-[300px] md:h-[400px] mb-8 rounded-lg overflow-hidden">
<Image src={blog.image || "/placeholder.svg"} alt={blog.title} fill className="object-cover" />
</div>
<p className="text-lg md:text-xl text-muted-foreground mb-8">{blog.description}</p>
<div className="prose prose-green max-w-none" dangerouslySetInnerHTML={{ __html: blog.content || "" }} />
</div>
{/* Sidebar */}
<div className="space-y-8">
{/* Table of contents */}
<div className="sticky top-24">
<Card>
<CardHeader>
<CardTitle>Table of Contents</CardTitle>
</CardHeader>
<CardContent>
<nav className="space-y-2">
{blog.tableOfContents?.map((item) => (
<button
key={item.id}
onClick={() => scrollToSection(item.id)}
className={`text-left w-full px-2 py-1 text-sm rounded-md hover:bg-muted transition-colors ${
item.level > 1 ? "ml-4" : ""
}`}>
{item.title}
</button>
))}
</nav>
</CardContent>
</Card>
{/* Related articles */}
{blog.relatedArticles && (
<Card className="mt-8">
<CardHeader>
<CardTitle>Related Articles</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-4">
{blog.relatedArticles.map((article) => (
<Link href={`/blog/${article.id}`} key={article.id}>
<div className="flex gap-3 group">
<div className="relative w-16 h-16 rounded overflow-hidden flex-shrink-0">
<Image
src={article.image || "/placeholder.svg"}
alt={article.title}
fill
className="object-cover"
/>
</div>
<div>
<h4 className="text-sm font-medium group-hover:text-primary transition-colors">
{article.title}
</h4>
<Badge variant="outline" className="mt-1 text-xs">
{article.topic}
</Badge>
</div>
</div>
</Link>
))}
</div>
</CardContent>
</Card>
)}
</div>
</div>
</div>
</main>
{/* Scroll to top button */}
{showScrollTop && (
<button
onClick={scrollToTop}
className="fixed bottom-6 right-6 p-2 rounded-full bg-primary text-primary-foreground shadow-lg transition-opacity hover:opacity-90"
aria-label="Scroll to top">
<ChevronUp className="h-5 w-5" />
</button>
)}
</div>
);
}

View File

@ -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<Blog[]>({
queryKey: ["blogs"],
queryFn: fetchBlogs,
staleTime: 60 * 1000,
});
if (isLoading) {
return <div className="flex min-h-screen bg-background items-center justify-center">Loading...</div>;
}
if (isError || !blogs) {
return <div className="flex min-h-screen bg-background items-center justify-center">Error loading blogs.</div>;
}
// 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 (
<div className="flex min-h-screen bg-background">
<div className="flex-1 flex flex-col">
<main className="flex-1 p-6 md:p-10">
<div className="max-w-7xl mx-auto">
{/* Header */}
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4 mb-8">
<div>
<h1 className="text-3xl md:text-4xl font-bold tracking-tight">Knowledge Hub</h1>
<p className="text-muted-foreground mt-2">
Explore our collection of articles, guides, and resources to help you grow better.
</p>
</div>
<div className="relative w-full md:w-64">
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
<Input
type="search"
placeholder="Search articles..."
className="pl-8"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
/>
</div>
</div>
{/* Featured article */}
{featuredBlogs.length > 0 && (
<div className="mb-12">
<h2 className="text-2xl font-semibold mb-6">Featured Articles</h2>
<div className="grid md:grid-cols-2 gap-6">
{featuredBlogs.slice(0, 2).map((blog) => (
<Card key={blog.id} className="overflow-hidden group">
<div className="relative h-64 overflow-hidden">
<Image
src={blog.image || "/placeholder.svg"}
alt={blog.title}
fill
className="object-cover transition-transform duration-300 group-hover:scale-105"
/>
<div className="absolute inset-0 bg-gradient-to-t from-black/60 to-transparent" />
<div className="absolute bottom-0 left-0 p-6 text-white">
<Badge className="bg-primary hover:bg-primary/90 mb-2">{blog.topic}</Badge>
<h3 className="text-xl font-bold">{blog.title}</h3>
<div className="flex items-center mt-2 text-sm">
<CalendarIcon className="h-4 w-4 mr-1" />
<span>{new Date(blog.date).toLocaleDateString()}</span>
<span className="mx-2"></span>
<span>{blog.readTime}</span>
</div>
</div>
</div>
<CardFooter className="p-4 flex justify-between items-center">
<div className="text-sm text-muted-foreground">By {blog.author}</div>
<Link href={`/hub/${blog.id}`}>
<Button variant="ghost" size="sm" className="gap-1">
Read more <ChevronRight className="h-4 w-4" />
</Button>
</Link>
</CardFooter>
</Card>
))}
</div>
</div>
)}
{/* Topic filters */}
<div className="mb-8">
<h2 className="text-xl font-semibold mb-4">Browse by Topic</h2>
<div className="flex flex-wrap gap-2">
{topics.map((topic) => (
<Button
key={topic}
variant={selectedTopic === topic ? "default" : "outline"}
size="sm"
onClick={() => setSelectedTopic(topic)}
className="rounded-full">
{topic === "Sustainability" && <Leaf className="mr-1 h-4 w-4" />}
{topic}
</Button>
))}
</div>
</div>
<Separator className="my-8" />
{/* Blog grid */}
<div className="grid sm:grid-cols-2 lg:grid-cols-3 gap-6">
{filteredBlogs.length === 0 ? (
<div className="col-span-full text-center py-12">
<h3 className="text-xl font-medium mb-2">No articles found</h3>
<p className="text-muted-foreground">
Try adjusting your search or filter to find what you're looking for.
</p>
</div>
) : (
filteredBlogs.map((blog) => (
<Card key={blog.id} className="overflow-hidden group h-full flex flex-col">
<div className="relative h-48 overflow-hidden">
<Image
src={blog.image || "/placeholder.svg"}
alt={blog.title}
fill
className="object-cover transition-transform duration-300 group-hover:scale-105"
/>
</div>
<CardHeader className="p-4 pb-2">
<div className="flex items-center justify-between mb-2">
<Badge variant="outline">{blog.topic}</Badge>
<div className="text-xs text-muted-foreground">{new Date(blog.date).toLocaleDateString()}</div>
</div>
<CardTitle className="text-lg">{blog.title}</CardTitle>
</CardHeader>
<CardContent className="p-4 pt-0 flex-grow">
<CardDescription className="line-clamp-2">{blog.description}</CardDescription>
</CardContent>
<CardFooter className="p-4 pt-0 flex justify-between items-center">
<div className="text-sm text-muted-foreground">By {blog.author}</div>
<Link href={`/hub/${blog.id}`}>
<Button variant="ghost" size="sm" className="gap-1">
Read <ChevronRight className="h-4 w-4" />
</Button>
</Link>
</CardFooter>
</Card>
))
)}
</div>
{/* Pagination - simplified for this example */}
{filteredBlogs.length > 0 && (
<div className="flex justify-center mt-12">
<Button variant="outline" size="sm" className="mx-1">
1
</Button>
<Button variant="ghost" size="sm" className="mx-1">
2
</Button>
<Button variant="ghost" size="sm" className="mx-1">
3
</Button>
<Button variant="ghost" size="sm" className="mx-1">
...
</Button>
<Button variant="ghost" size="sm" className="mx-1">
Next
</Button>
</div>
)}
</div>
</main>
</div>
</div>
);
}

View File

@ -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<Date | undefined>();
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 (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button>Add New Item</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-[425px]">
<DialogHeader>
<DialogTitle>Add Inventory Item</DialogTitle>
<DialogDescription>Add a new plantation or fertilizer item to your inventory.</DialogDescription>
</DialogHeader>
<div className="grid gap-4 py-4">
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="name" className="text-right">
Name
</Label>
<Input id="name" className="col-span-3" value={itemName} onChange={(e) => setItemName(e.target.value)} />
</div>
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="type" className="text-right">
Type
</Label>
<Select value={itemType} onValueChange={setItemType}>
<SelectTrigger className="col-span-3">
<SelectValue placeholder="Select type" />
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectLabel>Type</SelectLabel>
<SelectItem value="plantation">Plantation</SelectItem>
<SelectItem value="fertilizer">Fertilizer</SelectItem>
</SelectGroup>
</SelectContent>
</Select>
</div>
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="category" className="text-right">
Category
</Label>
<Input
id="category"
className="col-span-3"
placeholder="e.g., Seeds, Organic"
value={itemCategory}
onChange={(e) => setItemCategory(e.target.value)}
/>
</div>
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="quantity" className="text-right">
Quantity
</Label>
<Input
id="quantity"
type="number"
className="col-span-3"
value={itemQuantity}
onChange={(e) => setItemQuantity(Number(e.target.value))}
/>
</div>
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="unit" className="text-right">
Unit
</Label>
<Input
id="unit"
className="col-span-3"
placeholder="e.g., kg, packets"
value={itemUnit}
onChange={(e) => setItemUnit(e.target.value)}
/>
</div>
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="date" className="text-right">
Date
</Label>
<Popover>
<PopoverTrigger asChild>
<Button
variant={"outline"}
className={cn("col-span-3 justify-start text-left font-normal", !date && "text-muted-foreground")}>
<CalendarIcon className="mr-2 h-4 w-4" />
{date ? format(date, "PPP") : "Pick a date"}
</Button>
</PopoverTrigger>
<PopoverContent className="w-auto p-0">
<Calendar mode="single" selected={date} onSelect={setDate} initialFocus />
</PopoverContent>
</Popover>
</div>
</div>
<DialogFooter>
<Button type="submit" onClick={handleSave}>
Save Item
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@ -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<Date>();
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 <div className="flex min-h-screen bg-background items-center justify-center">Loading...</div>;
}
if (isError || !inventoryItems) {
return (
<div className="flex min-h-screen bg-background items-center justify-center">Error loading inventory data.</div>
);
}
// Filter items based on selected type.
const filteredItems =
inventoryType === "all"
? inventoryItems
: inventoryItems.filter((item) =>
inventoryType === "plantation" ? item.type === "Plantation" : item.type === "Fertilizer"
);
return (
<div className="flex min-h-screen bg-background">
<div className="flex-1 flex flex-col">
<main className="flex-1 p-6">
<h1 className="text-2xl font-bold tracking-tight mb-6">Inventory</h1>
{/* Filters and search */}
<div className="flex flex-col sm:flex-row gap-4 mb-6">
<div className="flex gap-2">
<Button
variant={inventoryType === "all" ? "default" : "outline"}
onClick={() => setInventoryType("all")}
className="w-24">
All
</Button>
<Select value={inventoryType} onValueChange={setInventoryType}>
<SelectTrigger className="w-32">
<SelectValue placeholder="Crop" />
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectItem value="all">All</SelectItem>
<SelectItem value="plantation">Plantation</SelectItem>
<SelectItem value="fertilizer">Fertilizer</SelectItem>
</SelectGroup>
</SelectContent>
</Select>
</div>
<div className="flex flex-1 gap-4">
<Popover>
<PopoverTrigger asChild>
<Button variant="outline" className="flex-1 justify-between">
<div className="flex items-center">
<Calendar className="mr-2 h-4 w-4" />
{date ? date.toLocaleDateString() : "Time filter"}
</div>
<ChevronDown className="h-4 w-4 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-auto p-0">
<CalendarComponent mode="single" selected={date} onSelect={setDate} initialFocus />
</PopoverContent>
</Popover>
<div className="relative flex-1">
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
<Input type="search" placeholder="Search Farms" className="pl-8" />
</div>
<AddInventoryItem />
</div>
</div>
{/* Table */}
<div className="border rounded-md">
<h3 className="px-4 py-2 border-b font-medium">Table Fields</h3>
<Table>
<TableHeader>
<TableRow>
<TableHead>Name</TableHead>
<TableHead>Category</TableHead>
<TableHead>Type</TableHead>
<TableHead className="text-right">Quantity</TableHead>
<TableHead>Last Updated</TableHead>
<TableHead>Status</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filteredItems.length === 0 ? (
<TableRow>
<TableCell colSpan={6} className="text-center py-8 text-muted-foreground">
No inventory items found
</TableCell>
</TableRow>
) : (
filteredItems.map((item) => (
<TableRow key={item.id}>
<TableCell className="font-medium">{item.name}</TableCell>
<TableCell>{item.category}</TableCell>
<TableCell>{item.type}</TableCell>
<TableCell className="text-right">
{item.quantity} {item.unit}
</TableCell>
<TableCell>{item.lastUpdated}</TableCell>
<TableCell>
<Badge variant={item.status === "Low Stock" ? "destructive" : "default"}>{item.status}</Badge>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
</main>
</div>
</div>
);
}

View File

@ -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 (
<SidebarProvider>
<AppSidebar />
@ -25,17 +25,7 @@ export default function AppLayout({
<SidebarTrigger className="-ml-1" />
<ThemeToggle />
<Separator orientation="vertical" className="mr-2 h-4" />
<Breadcrumb>
<BreadcrumbList>
<BreadcrumbItem className="hidden md:block">
<BreadcrumbLink href="#">Building Your Application</BreadcrumbLink>
</BreadcrumbItem>
<BreadcrumbSeparator className="hidden md:block" />
<BreadcrumbItem>
<BreadcrumbPage>Data Fetching</BreadcrumbPage>
</BreadcrumbItem>
</BreadcrumbList>
</Breadcrumb>
<DynamicBreadcrumb pathname={currentPathname} />
</div>
</header>
{children}

View File

@ -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 (
<div className="container mx-auto py-6 px-4 md:px-6">
<div className="flex flex-col md:flex-row justify-between items-start md:items-center mb-6 gap-4">
<div>
<Skeleton className="h-10 w-64 mb-2" />
<Skeleton className="h-5 w-96" />
</div>
<div className="flex items-center gap-2">
<Skeleton className="h-9 w-32" />
<Skeleton className="h-5 w-40" />
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-4 gap-6 mb-6">
<Card className="md:col-span-3">
<CardHeader className="pb-2">
<div className="flex flex-col md:flex-row justify-between md:items-center gap-4">
<div>
<Skeleton className="h-6 w-40 mb-2" />
<Skeleton className="h-4 w-60" />
</div>
<div className="flex flex-col sm:flex-row gap-2">
<Skeleton className="h-10 w-[180px]" />
<Skeleton className="h-10 w-[140px]" />
</div>
</div>
</CardHeader>
<CardContent>
<div className="w-full h-[300px] flex items-center justify-center">
<div className="text-center">
<RefreshCw className="h-8 w-8 animate-spin mx-auto text-primary/70" />
<p className="mt-2 text-sm text-muted-foreground">Loading market data...</p>
</div>
</div>
<div className="mt-4 grid grid-cols-1 sm:grid-cols-3 gap-4">
<Skeleton className="h-24 w-full" />
<Skeleton className="h-24 w-full" />
<Skeleton className="h-24 w-full" />
</div>
</CardContent>
</Card>
<div className="space-y-6">
<Card>
<CardHeader className="pb-2">
<Skeleton className="h-6 w-40 mb-2" />
<Skeleton className="h-4 w-48" />
</CardHeader>
<CardContent>
<div className="space-y-3">
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-3/4" />
<Skeleton className="h-4 w-5/6" />
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-3/4" />
</div>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<Skeleton className="h-6 w-40 mb-2" />
<Skeleton className="h-4 w-48" />
</CardHeader>
<CardContent>
<div className="space-y-3">
<Skeleton className="h-16 w-full rounded-lg" />
<Skeleton className="h-16 w-full rounded-lg" />
<Skeleton className="h-16 w-full rounded-lg" />
</div>
</CardContent>
</Card>
</div>
</div>
<Card>
<CardHeader>
<Skeleton className="h-6 w-40 mb-2" />
<Skeleton className="h-4 w-80" />
</CardHeader>
<CardContent>
<div className="space-y-3">
<Skeleton className="h-8 w-full" />
<Skeleton className="h-8 w-full" />
<Skeleton className="h-8 w-full" />
<Skeleton className="h-8 w-full" />
</div>
</CardContent>
</Card>
</div>
);
}

File diff suppressed because it is too large Load Diff

View File

@ -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 (

View File

@ -18,21 +18,21 @@ export default function ForgotPasswordModal() {
<div>
<Dialog>
<DialogTrigger asChild>
<Button className=" whitespace-nowrap flex bg-transparent border-none hover:bg-transparent shadow-none">
<Button className="whitespace-nowrap flex bg-transparent border-none hover:bg-transparent shadow-none">
<h1 className="text-green-600 underline">Forgot password?</h1>
</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-md">
<DialogContent className="sm:max-w-md dark:bg-slate-800">
<DialogHeader>
<DialogTitle>What&apos;s your email?</DialogTitle>
<DialogDescription>
Please verify your email for us. Once you do, we&apos;ll send instructions to reset your password
Please verify your email for us. Once you do, we&apos;ll send instructions to reset your password.
</DialogDescription>
</DialogHeader>
<div className="flex items-center space-x-2">
<div className="grid flex-1 gap-2">
<Label htmlFor="link" className="sr-only">
Link
Email
</Label>
<Input id="email" type="email" placeholder="your.email@gmail.com" />
</div>

View File

@ -2,8 +2,9 @@ import Image from "next/image";
export function GoogleSigninButton() {
return (
<div className="flex w-1/3 justify-center rounded-full border-2 border-border bg-gray-100 hover:bg-gray-300 duration-300 cursor-pointer ">
<div className="flex items-center justify-center gap-3 w-full py-2 px-4 rounded-full border border-border bg-gray-100 dark:bg-slate-700 hover:bg-gray-200 dark:hover:bg-slate-600 transition-colors cursor-pointer">
<Image src="/google-logo.png" alt="Google Logo" width={35} height={35} className="object-contain" />
<span className="font-medium text-gray-800 dark:text-gray-100">Sign in with Google</span>
</div>
);
}

View File

@ -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<string | null>(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 (
<div>
<div className="grid grid-cols-[0.7fr_1.2fr] h-screen overflow-hidden">
<div className="flex bg-[url('/plant-background.jpeg')] bg-cover bg-center"></div>
<div className="min-h-screen bg-gradient-to-br from-green-50 dark:from-gray-900 to-white dark:to-gray-800">
<div className="grid lg:grid-cols-[1fr_1.2fr] h-screen overflow-hidden">
{/* Left side - Image */}
<div className="hidden lg:block relative">
<div className="absolute inset-0 bg-[url('/plant-background.jpeg')] bg-cover bg-center">
<div className="absolute inset-0 bg-gradient-to-br from-green-900/70 to-green-800/40 flex flex-col justify-between p-10">
<div>
<Link href="/" className="flex items-center gap-2 text-white">
<Leaf className="h-6 w-6" />
<span className="font-bold text-xl">ForFarm</span>
</Link>
</div>
<div className="flex justify-center items-center">
<div className="container px-[25%]">
<div className="flex flex-col justify-center items-center">
<span>
<Image src="/forfarm-logo.png" alt="Forfarm" width={150} height={150} />
</span>
<h1 className="text-3xl font-semibold">Welcome back.</h1>
<div className="flex whitespace-nowrap gap-x-2 mt-2">
<span className="text-md">New to Forfarm?</span>
<span className="text-green-600">
<Link href="signup" className="underline">
Sign up
</Link>
</span>
<div className="max-w-md">
<h2 className="text-3xl font-bold text-white mb-4">Grow smarter with ForFarm</h2>
<p className="text-green-100 mb-6">
Join thousands of farmers using our platform to optimize their agricultural operations and increase
yields.
</p>
<div className="flex items-center gap-4">
<div className="flex -space-x-2">
{[1, 2, 3].map((i) => (
<div key={i} className="w-8 h-8 rounded-full border-2 border-green-600 overflow-hidden">
<Image
src={`/placeholder.svg?height=32&width=32`}
alt="User"
width={32}
height={32}
className="bg-green-200"
/>
</div>
))}
</div>
<div className="text-sm text-green-100">
<span className="font-bold">500+</span> farmers already using ForFarm
</div>
</div>
</div>
</div>
</div>
</div>
{/* Right side - Form */}
<div className="flex justify-center items-center p-6">
<div className="w-full max-w-md">
<div className="lg:hidden flex justify-center mb-8">
<Link href="/" className="flex items-center gap-2">
<Image src="/forfarm-logo.png" alt="Forfarm" width={80} height={80} />
</Link>
</div>
<div className="text-center mb-8">
<h1 className="text-3xl font-bold mb-2">Welcome back</h1>
<p className="text-gray-500 dark:text-gray-400">
New to Forfarm?{" "}
<Link href="/auth/signup" className="text-green-600 hover:text-green-700 font-medium">
Sign up
</Link>
</p>
</div>
{serverError && (
<Alert variant="destructive" className="mb-6 animate-fadeIn">
<AlertCircle className="h-4 w-4" />
<AlertDescription>{serverError}</AlertDescription>
</Alert>
)}
{/* Sign in form */}
<form onSubmit={handleSubmit(onSubmit)} className="flex flex-col mt-4">
<div>
<Label htmlFor="email">Email</Label>
<Input type="email" id="email" placeholder="Email" {...register("email")} />
{errors.email && <p className="text-red-600 text-sm">{errors.email.message}</p>}
</div>
<div className="mt-5">
<Label htmlFor="password">Password</Label>
<Input type="password" id="password" placeholder="Password" {...register("password")} />
{errors.password && <p className="text-red-600 text-sm">{errors.password.message}</p>}
<form onSubmit={handleSubmit(onSubmit)} className="space-y-5">
<div className="space-y-2">
<Label htmlFor="email" className="text-sm font-medium dark:text-gray-300">
Email
</Label>
<div className="relative">
<Input
type="email"
id="email"
placeholder="name@example.com"
className={`h-12 px-4 ${errors.email ? "border-red-500 focus-visible:ring-red-500" : ""}`}
{...register("email")}
/>
</div>
{errors.email && (
<p className="text-red-500 text-sm mt-1 flex items-center gap-1">
<AlertCircle className="h-3 w-3" />
{errors.email.message}
</p>
)}
</div>
<Button type="submit" className="mt-5 rounded-full" disabled={isLoading}>
{isLoading ? "Logging in..." : "Log in"}
<div className="space-y-2">
<div className="flex justify-between">
<Label htmlFor="password" className="text-sm font-medium dark:text-gray-300">
Password
</Label>
<ForgotPasswordModal />
</div>
<div className="relative">
<Input
type={showPassword ? "text" : "password"}
id="password"
placeholder="••••••••"
className={`h-12 px-4 ${errors.password ? "border-red-500 focus-visible:ring-red-500" : ""}`}
{...register("password")}
/>
<button
type="button"
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-500 hover:text-gray-700"
onClick={() => setShowPassword(!showPassword)}>
{showPassword ? <EyeOff className="h-5 w-5" /> : <Eye className="h-5 w-5" />}
</button>
</div>
{errors.password && (
<p className="text-red-500 text-sm mt-1 flex items-center gap-1">
<AlertCircle className="h-3 w-3" />
{errors.password.message}
</p>
)}
</div>
<div className="flex items-center space-x-2">
<Checkbox id="remember" />
<label
htmlFor="remember"
className="text-sm text-gray-500 dark:text-gray-400 cursor-pointer select-none">
Remember me for 30 days
</label>
</div>
<Button
type="submit"
className="w-full h-12 rounded-full font-medium text-base bg-green-600 hover:bg-green-700 transition-all"
disabled={isLoading}>
{isLoading ? (
<span className="flex items-center gap-2">
<Loader2 className="h-4 w-4 animate-spin" />
Logging in...
</span>
) : (
<span className="flex items-center justify-center gap-2">
Log in
<ArrowRight className="h-4 w-4" />
</span>
)}
</Button>
</form>
<div id="signin-footer" className="flex justify-between mt-5">
<div className="flex items-center space-x-2">
<Checkbox id="terms" />
<label htmlFor="terms" className="text-sm font-medium leading-none">
Remember me
</label>
<div className="mt-8">
<div className="relative">
<div className="absolute inset-0 flex items-center">
<div className="w-full border-t border-gray-200 dark:border-gray-700"></div>
</div>
<div className="relative flex justify-center text-sm">
<span className="px-2 bg-gradient-to-br from-green-50 to-white dark:from-gray-900 dark:to-gray-800 text-gray-500">
Or continue with
</span>
</div>
</div>
<ForgotPasswordModal />
</div>
<div className="my-5">
<p className="text-sm">Or log in with</p>
<div className="flex flex-col gap-x-5 mt-3">
<div className="mt-6">
<GoogleSigninButton />
</div>
</div>
<p className="text-center text-sm text-gray-500 dark:text-gray-400 mt-8">
By signing in, you agree to our{" "}
<Link href="/terms" className="text-green-600 hover:text-green-700">
Terms of Service
</Link>{" "}
and{" "}
<Link href="/privacy" className="text-green-600 hover:text-green-700">
Privacy Policy
</Link>
</p>
</div>
</div>
</div>

View File

@ -1,12 +0,0 @@
import React from "react";
const WaterDrop = () => {
return (
<div className="relative w-6 h-[400px] overflow-hidden">
{/* Water Drop animation */}
<div className="absolute w-6 h-6 bg-blue-500 rounded-full animate-drop overflow-hidden"></div>
</div>
);
};
export default WaterDrop;

View File

@ -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<string | null>(null);
const [successMessage, setSuccessMessage] = useState<string | null>(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<z.infer<typeof signUpSchema>>({
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<HTMLInputElement>) => {
const newStrength = calculatePasswordStrength(e.target.value);
setPasswordStrength(newStrength);
};
const onSubmit = async (values: z.infer<typeof signUpSchema>) => {
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 (
<div>
<div className="grid grid-cols-[0.7fr_1.2fr] h-screen overflow-hidden">
<div className="flex bg-[url('/plant-background.jpeg')] bg-cover bg-center"></div>
<div className="flex justify-center items-center">
<div className="container px-[25%]">
<div className="flex flex-col justify-center items-center">
<span>
<Image src="/forfarm-logo.png" alt="Forfarm" width={150} height={150} />
</span>
<h1 className="text-3xl font-semibold">Hi! Welcome</h1>
<div className="flex whitespace-nowrap gap-x-2 mt-2">
<span className="text-md">Already have an account?</span>
<span className="text-green-600">
<Link href="/auth/signin" className="underline">
Sign in
</Link>
</span>
</div>
</div>
{/* Signup form */}
<form onSubmit={handleSubmit(onSubmit)} className="flex flex-col mt-4">
<div className="min-h-screen bg-gradient-to-br from-green-50 dark:from-gray-900 to-white dark:to-gray-800">
<div className="grid lg:grid-cols-[1fr_1.2fr] h-screen overflow-hidden">
{/* Left Side - Image */}
<div className="hidden lg:block relative">
<div className="absolute inset-0 bg-[url('/plant-background.jpeg')] bg-cover bg-center">
<div className="absolute inset-0 bg-gradient-to-br from-green-900/70 to-green-800/40 flex flex-col justify-between p-10">
<div>
<Label htmlFor="email">Email</Label>
<Input type="email" id="email" placeholder="Email" {...register("email")} />
{errors.email && <p className="text-red-600 text-sm">{errors.email.message}</p>}
<Link href="/" className="flex items-center gap-2 text-white">
<Leaf className="h-6 w-6" />
<span className="font-bold text-xl">ForFarm</span>
</Link>
</div>
<div className="mt-5">
<Label htmlFor="password">Password</Label>
<Input type="password" id="password" placeholder="Password" {...register("password")} />
{errors.password && <p className="text-red-600 text-sm">{errors.password.message}</p>}
</div>
<div className="mt-5">
<Label htmlFor="confirmPassword">Confirm Password</Label>
<Input
type="password"
id="confirmPassword"
placeholder="Confirm Password"
{...register("confirmPassword")}
/>
{errors.confirmPassword && <p className="text-red-600 text-sm">{errors.confirmPassword.message}</p>}
</div>
{serverError && <p className="text-red-600 mt-2 text-sm">{serverError}</p>}
{successMessage && <p className="text-green-600 mt-2 text-sm">{successMessage}</p>}
<Button type="submit" className="mt-5 rounded-full" disabled={isLoading}>
{isLoading ? "Signing up..." : "Sign up"}
</Button>
</form>
<div className="my-5">
<p className="text-sm">Or sign up with</p>
<div className="flex flex-col gap-x-5 mt-3">
{/* Google OAuth button or additional providers */}
<div className="flex w-1/3 justify-center rounded-full border-2 border-border bg-gray-100 hover:bg-gray-300 duration-300 cursor-pointer">
<Image src="/google-logo.png" alt="Google Logo" width={35} height={35} className="object-contain" />
<div className="max-w-md">
<h2 className="text-3xl font-bold text-white mb-4">Join the farming revolution</h2>
<p className="text-green-100 mb-6">
Create your account today and discover how ForFarm can help you optimize your agricultural operations.
</p>
<div className="space-y-4">
{[
"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) => (
<div key={index} className="flex items-start gap-2">
<div className="rounded-full bg-green-500 p-1 mt-0.5">
<Check className="h-3 w-3 text-white" />
</div>
<span className="text-green-100 text-sm">{feature}</span>
</div>
))}
</div>
</div>
</div>
</div>
</div>
{/* Right Side - Form */}
<div className="flex justify-center items-center p-6">
<div className="w-full max-w-md">
{/* Theme Selector Placeholder */}
<div className="mb-4 text-center text-sm text-gray-500 dark:text-gray-400">Theme Selector Placeholder</div>
<div className="lg:hidden flex justify-center mb-8">
<Link href="/" className="flex items-center gap-2">
<Image src="/forfarm-logo.png" alt="Forfarm" width={80} height={80} />
</Link>
</div>
<div className="text-center mb-8">
<h1 className="text-3xl font-bold mb-2">Create your account</h1>
<p className="text-gray-500 dark:text-gray-400">
Already have an account?{" "}
<Link href="/auth/signin" className="text-green-600 hover:text-green-700 font-medium">
Sign in
</Link>
</p>
</div>
{serverError && (
<Alert variant="destructive" className="mb-6 animate-fadeIn">
<AlertCircle className="h-4 w-4" />
<AlertDescription>{serverError}</AlertDescription>
</Alert>
)}
{successMessage && (
<Alert className="mb-6 bg-green-50 text-green-800 border-green-200 animate-fadeIn">
<Check className="h-4 w-4" />
<AlertDescription>{successMessage}</AlertDescription>
</Alert>
)}
{/* Sign Up Form */}
<form onSubmit={handleSubmit(onSubmit)} className="space-y-5">
{/* Email */}
<div className="space-y-2">
<Label htmlFor="email" className="text-sm font-medium dark:text-gray-300">
Email
</Label>
<div className="relative">
<Input
type="email"
id="email"
placeholder="name@example.com"
className={`h-12 px-4 ${errors.email ? "border-red-500 focus-visible:ring-red-500" : ""}`}
{...register("email")}
/>
</div>
{errors.email && (
<p className="text-red-500 text-sm mt-1 flex items-center gap-1">
<AlertCircle className="h-3 w-3" />
{errors.email.message}
</p>
)}
</div>
{/* Password */}
<div className="space-y-2">
<Label htmlFor="password" className="text-sm font-medium dark:text-gray-300">
Password
</Label>
<div className="relative">
<Input
type={showPassword ? "text" : "password"}
id="password"
placeholder="••••••••"
className={`h-12 px-4 ${errors.password ? "border-red-500 focus-visible:ring-red-500" : ""}`}
{...register("password", { onChange: onPasswordChange })}
/>
<button
type="button"
aria-label={showPassword ? "Hide password" : "Show password"}
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-500 hover:text-gray-700"
onClick={() => setShowPassword(!showPassword)}>
{showPassword ? <EyeOff className="h-5 w-5" /> : <Eye className="h-5 w-5" />}
</button>
</div>
{/* Password Strength Indicator */}
{password && (
<div className="mt-2 space-y-1">
<div className="flex justify-between items-center">
<span className="text-xs text-gray-500 dark:text-gray-400">Password strength</span>
<span
className={`text-xs font-medium ${
passwordStrength <= 25
? "text-red-500"
: passwordStrength <= 50
? "text-yellow-500"
: passwordStrength <= 75
? "text-blue-500"
: "text-green-500"
}`}>
{getPasswordStrengthText()}
</span>
</div>
<Progress value={passwordStrength} className={`${getPasswordStrengthColor()} h-1`} />
</div>
)}
{errors.password && (
<p className="text-red-500 text-sm mt-1 flex items-center gap-1">
<AlertCircle className="h-3 w-3" />
{errors.password.message}
</p>
)}
</div>
{/* Confirm Password */}
<div className="space-y-2">
<Label htmlFor="confirmPassword" className="text-sm font-medium dark:text-gray-300">
Confirm Password
</Label>
<div className="relative">
<Input
type={showConfirmPassword ? "text" : "password"}
id="confirmPassword"
placeholder="••••••••"
className={`h-12 px-4 ${errors.confirmPassword ? "border-red-500 focus-visible:ring-red-500" : ""}`}
{...register("confirmPassword")}
/>
<button
type="button"
aria-label={showConfirmPassword ? "Hide password" : "Show password"}
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-500 hover:text-gray-700"
onClick={() => setShowConfirmPassword(!showConfirmPassword)}>
{showConfirmPassword ? <EyeOff className="h-5 w-5" /> : <Eye className="h-5 w-5" />}
</button>
</div>
{errors.confirmPassword && (
<p className="text-red-500 text-sm mt-1 flex items-center gap-1">
<AlertCircle className="h-3 w-3" />
{errors.confirmPassword.message}
</p>
)}
</div>
<Button
type="submit"
className="w-full h-12 rounded-full font-medium text-base bg-green-600 hover:bg-green-700 transition-all"
disabled={isLoading}>
{isLoading ? (
<span className="flex items-center gap-2">
<Loader2 className="h-4 w-4 animate-spin" />
Creating account...
</span>
) : (
<span className="flex items-center justify-center gap-2">
Create account
<ArrowRight className="h-4 w-4" />
</span>
)}
</Button>
</form>
<div className="mt-8">
<div className="relative">
<div className="absolute inset-0 flex items-center">
<div className="w-full border-t border-gray-200 dark:border-gray-700"></div>
</div>
<div className="relative flex justify-center text-sm">
<span className="px-2 bg-gradient-to-br from-green-50 to-white dark:from-gray-900 dark:to-gray-800 text-gray-500">
Or sign up with
</span>
</div>
</div>
<div className="mt-6">
<Button
variant="outline"
className="w-full h-12 rounded-full border-gray-300 dark:border-gray-600 hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors">
<Image src="/google-logo.png" alt="Google Logo" width={20} height={20} className="mr-2" />
Sign up with Google
</Button>
</div>
</div>
<p className="text-center text-sm text-gray-500 dark:text-gray-400 mt-8">
By signing up, you agree to our{" "}
<Link href="/terms" className="text-green-600 hover:text-green-700">
Terms of Service
</Link>{" "}
and{" "}
<Link href="/privacy" className="text-green-600 hover:text-green-700">
Privacy Policy
</Link>
</p>
</div>
</div>
</div>
</div>
);

103
frontend/app/error.tsx Normal file
View File

@ -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 (
<div className="min-h-screen bg-gradient-to-b from-red-50 to-white dark:from-red-950/30 dark:to-gray-950 flex flex-col items-center justify-center p-6">
<div className="w-full max-w-lg text-center">
<div className="relative mb-8">
{/* Decorative elements */}
<div className="absolute -top-16 -left-16 w-32 h-32 bg-red-100 dark:bg-red-900/30 rounded-full blur-3xl opacity-70"></div>
<div className="absolute -bottom-8 -right-8 w-24 h-24 bg-red-200 dark:bg-red-800/30 rounded-full blur-2xl opacity-70"></div>
{/* Main icon */}
<div className="relative bg-gradient-to-br from-red-100 to-red-200 dark:from-red-800 dark:to-red-900 h-24 w-24 rounded-full flex items-center justify-center mx-auto shadow-lg">
<AlertTriangle className="h-12 w-12 text-red-600 dark:text-red-400" />
</div>
</div>
<h1 className="text-3xl font-bold text-gray-800 dark:text-gray-100 mb-3">Something went wrong</h1>
<p className="text-muted-foreground mb-6 max-w-md mx-auto">{getErrorMessage()}</p>
{error.message && !["FARM_NOT_FOUND", "CROP_NOT_FOUND", "UNAUTHORIZED"].includes(error.message) && (
<div className="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-4 mb-8 text-left">
<p className="text-sm text-red-600 dark:text-red-400 font-medium mb-1">Error details:</p>
<p className="text-sm text-muted-foreground">{error.message}</p>
{error.digest && <p className="text-xs text-muted-foreground mt-2">Error ID: {error.digest}</p>}
</div>
)}
<div className="flex flex-col sm:flex-row gap-3 justify-center mb-8">
<Button
variant="default"
className="gap-2 bg-gradient-to-r from-green-600 to-emerald-500 hover:from-green-700 hover:to-emerald-600 h-12 px-6"
onClick={() => reset()}>
<RefreshCcw className="h-4 w-4" />
Try Again
</Button>
<Button
variant="outline"
className="gap-2 h-12 border-green-200 dark:border-green-800"
onClick={() => router.back()}>
<ArrowLeft className="h-4 w-4" />
Go Back
</Button>
<Button
variant="outline"
className="gap-2 h-12 border-green-200 dark:border-green-800"
onClick={() => router.push("/")}>
<Home className="h-4 w-4" />
Home
</Button>
</div>
<div className="text-sm text-muted-foreground">
<p className="mb-2">
Need help?{" "}
<Link href="/contact" className="text-green-600 hover:underline">
Contact Support
</Link>
</p>
<p className="flex items-center justify-center gap-1">
<HelpCircle className="h-3 w-3" />
<span>Support Code: {error.digest ? error.digest.substring(0, 8) : "Unknown"}</span>
</p>
</div>
</div>
</div>
);
}

View File

@ -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 (
<html>
<body>
<div className="min-h-screen bg-gradient-to-b from-red-50 to-white flex flex-col items-center justify-center p-6">
<div className="w-full max-w-md text-center">
<div className="bg-red-100 h-24 w-24 rounded-full flex items-center justify-center mx-auto mb-8">
<AlertTriangle className="h-12 w-12 text-red-600" />
</div>
<h1 className="text-3xl font-bold mb-3">Critical Error</h1>
<p className="text-gray-600 mb-6">The application has encountered a critical error and cannot continue.</p>
{error.message && (
<div className="bg-red-50 border border-red-200 rounded-lg p-4 mb-8 text-left">
<p className="text-sm text-red-600 font-medium mb-1">Error details:</p>
<p className="text-sm text-gray-600">{error.message}</p>
{error.digest && <p className="text-xs text-gray-500 mt-2">Error ID: {error.digest}</p>}
</div>
)}
<div className="flex flex-col sm:flex-row gap-3 justify-center">
<Button
className="gap-2 bg-green-600 hover:bg-green-700 text-white py-2 px-4 rounded"
onClick={() => reset()}>
<RefreshCcw className="h-4 w-4" />
Restart Application
</Button>
<Button
className="gap-2 bg-white border border-gray-300 text-gray-700 py-2 px-4 rounded"
onClick={() => (window.location.href = "/")}>
<Home className="h-4 w-4" />
Return to Home
</Button>
</div>
</div>
</div>
</body>
</html>
);
}

View File

@ -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;
}

View File

@ -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({
<html lang="en" suppressHydrationWarning>
<head />
<SessionProvider>
<body className={`${openSans.variable} ${robotoMono.variable} antialiased`}>
<ThemeProvider attribute="class" defaultTheme="system" enableSystem>
<div className="relative flex min-h-screen flex-col">
<div className="flex-1 bg-background">{children}</div>
</div>
</ThemeProvider>
</body>
<ReactQueryProvider>
<body className={`${poppins.variable}`}>
<ThemeProvider attribute="class" defaultTheme="system" enableSystem>
<div className="relative flex min-h-screen flex-col">
<div className="flex-1 bg-background">{children}</div>
</div>
</ThemeProvider>
</body>
</ReactQueryProvider>
</SessionProvider>
</html>
);

27
frontend/app/loading.tsx Normal file
View File

@ -0,0 +1,27 @@
import { Leaf } from "lucide-react";
export default function Loading() {
return (
<div className="min-h-screen bg-gradient-to-b from-green-50/50 to-white flex flex-col items-center justify-center p-6">
<div className="flex flex-col items-center gap-6">
<div className="relative">
<div className="h-20 w-20 rounded-full bg-green-100 flex items-center justify-center">
<Leaf className="h-10 w-10 text-green-600" />
</div>
<div className="absolute inset-0 rounded-full border-4 border-t-green-600 border-r-transparent border-b-transparent border-l-transparent animate-spin"></div>
</div>
<div className="text-center">
<h2 className="text-xl font-semibold text-green-800 mb-2">Loading...</h2>
<p className="text-muted-foreground max-w-md">
We're preparing your farming data. This will only take a moment.
</p>
</div>
<div className="w-64 h-2 bg-green-100 rounded-full overflow-hidden">
<div className="h-full bg-green-600 rounded-full animate-pulse"></div>
</div>
</div>
</div>
);
}

View File

@ -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 (
<div className="min-h-screen bg-gradient-to-b from-green-50 to-white dark:from-green-950 dark:to-gray-950 flex flex-col items-center justify-center p-6">
<div className="w-full max-w-lg text-center">
<div className="relative mb-8">
{/* Decorative elements */}
<div className="absolute -top-16 -left-16 w-32 h-32 bg-green-100 dark:bg-green-900/30 rounded-full blur-3xl opacity-70"></div>
<div className="absolute -bottom-8 -right-8 w-24 h-24 bg-green-200 dark:bg-green-800/30 rounded-full blur-2xl opacity-70"></div>
{/* Main icon */}
<div className="relative bg-gradient-to-br from-green-100 to-green-200 dark:from-green-800 dark:to-green-900 h-24 w-24 rounded-full flex items-center justify-center mx-auto shadow-lg">
<Leaf className="h-12 w-12 text-green-600 dark:text-green-400" />
</div>
</div>
<h1 className="text-7xl font-bold bg-gradient-to-r from-green-600 to-emerald-500 dark:from-green-400 dark:to-emerald-300 bg-clip-text text-transparent mb-4">
404
</h1>
<h2 className="text-3xl font-bold mb-3 text-gray-800 dark:text-gray-100">Page Not Found</h2>
<p className="text-muted-foreground mb-8 max-w-md mx-auto">
Looks like you've wandered into uncharted territory. This page doesn't exist or has been moved.
</p>
<div className="flex flex-col gap-6 mb-8">
<form onSubmit={handleSearch} className="relative">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
type="text"
placeholder="Search for farms, crops, or pages..."
className="pl-10 h-12 bg-white/80 dark:bg-gray-900/80 backdrop-blur-sm"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
/>
</form>
<div className="flex flex-col sm:flex-row gap-3 justify-center">
<Button
variant="default"
className="gap-2 bg-gradient-to-r from-green-600 to-emerald-500 hover:from-green-700 hover:to-emerald-600 h-12 px-6"
onClick={() => router.push("/")}>
<Home className="h-4 w-4" />
Return Home
</Button>
<Button
variant="outline"
className="gap-2 h-12 border-green-200 dark:border-green-800"
onClick={() => router.back()}>
<ArrowLeft className="h-4 w-4" />
Go Back
</Button>
</div>
</div>
<div className="flex flex-col sm:flex-row gap-6 justify-center text-sm text-muted-foreground">
<Link href="/farms" className="flex items-center gap-1 hover:text-green-600 transition-colors">
<MapPin className="h-3 w-3" />
View Farms
</Link>
<Link href="/knowledge-hub" className="flex items-center gap-1 hover:text-green-600 transition-colors">
<Leaf className="h-3 w-3" />
Knowledge Hub
</Link>
<Link href="/contact" className="flex items-center gap-1 hover:text-green-600 transition-colors">
<Search className="h-3 w-3" />
Contact Support
</Link>
</div>
</div>
</div>
);
}

View File

@ -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 (
<div className="min-h-screen bg-gradient-to-br from-green-400 via-green-600 to-green-900 text-white">
<header className="container mx-auto px-4 py-6 flex justify-between items-center">
<Link href="/" className="flex items-center space-x-2">
<span className="flex font-bold text-xl">
<Leaf />
ForFarm
</span>
</Link>
<span className="flex space-x-4 items-center">
<Link href="/documentation" className="hover:text-gray-200 transition-colors font-bold">
Documentation
</Link>
<Link
href="/auth/signup"
className="bg-white text-blue-600 font-bold px-4 py-2 rounded-full hover:bg-gray-100 transition-colors">
Get started
</Link>
</span>
</header>
<div className="min-h-screen bg-gradient-to-br from-green-400 via-green-600 to-green-900 text-white relative overflow-hidden">
{/* Animated background elements */}
<div className="absolute top-0 left-0 w-full h-full overflow-hidden z-0">
<div className="absolute top-20 left-10 w-72 h-72 bg-white/10 rounded-full blur-3xl animate-blob"></div>
<div className="absolute top-40 right-10 w-96 h-96 bg-green-300/10 rounded-full blur-3xl animate-blob animation-delay-2000"></div>
<div className="absolute bottom-20 left-1/3 w-80 h-80 bg-green-800/20 rounded-full blur-3xl animate-blob animation-delay-4000"></div>
</div>
<main className="container mx-auto px-4 py-20 text-center">
<div className="max-w-3xl mx-auto">
<Image
src="/water-pot.png"
alt="ForFarm Icon"
width={100}
height={100}
className="mx-auto mb-8 rounded-2xl"
/>
<h1 className="text-6xl font-bold mb-6">Your Smart Farming Platform</h1>
<p className="text-xl md:text-2xl mb-12 text-gray-200">
It's a smart and easy way to optimize your agricultural business, with the help of AI-driven insights and
real-time data.
</p>
<Link href="/setup">
<Button className="bg-black text-white text-md font-bold px-4 py-6 rounded-full hover:bg-gray-600">
Manage your farm
</Button>
</Link>
{/* 3D floating elements */}
<div className="absolute top-1/4 right-10 w-20 h-20 hidden lg:block">
<div className="relative w-full h-full animate-float animation-delay-1000">
<div className="absolute inset-0 bg-gradient-to-br from-green-300 to-green-500 rounded-xl shadow-lg transform rotate-12"></div>
</div>
</main>
</div>
<div className="absolute bottom-1/4 left-10 w-16 h-16 hidden lg:block">
<div className="relative w-full h-full animate-float">
<div className="absolute inset-0 bg-gradient-to-br from-green-200 to-green-400 rounded-xl shadow-lg transform -rotate-12"></div>
</div>
</div>
{/* <div className="absolute -inset-2 bg-gradient-to-r from-green-600 to-blue-600 rounded-lg blur opacity-10"></div> */}
<div className="relative z-10">
<header className="container mx-auto px-4 py-6 flex justify-between items-center">
<Link href="/" className="flex items-center space-x-2 group">
<span className="flex font-bold text-xl items-center gap-1.5">
<Leaf className="h-5 w-5 group-hover:text-green-300 transition-colors" />
ForFarm
</span>
<Badge variant="outline" className="bg-white/10 backdrop-blur-sm text-xs font-normal">
BETA
</Badge>
</Link>
<nav className="hidden md:flex space-x-6 items-center">
<Link href="/hub" className="hover:text-green-200 transition-colors">
Features
</Link>
{/* <Link href="/pricing" className="hover:text-green-200 transition-colors">
Pricing
</Link> */}
<Link href="/hub" className="hover:text-green-200 transition-colors">
Knowledge Hub
</Link>
{/* <Link href="/documentation" className="hover:text-green-200 transition-colors">
Documentation
</Link> */}
</nav>
<div className="flex space-x-3 items-center">
<Link
href="/auth/signin"
className="hidden md:inline-block hover:text-green-200 transition-colors font-medium">
Log in
</Link>
<Link
href="/auth/signup"
className="bg-white text-green-700 font-bold px-4 py-2 rounded-full hover:bg-green-100 transition-colors shadow-lg hover:shadow-xl transform hover:-translate-y-0.5 duration-200">
Get started
</Link>
</div>
</header>
<footer className="container mx-auto px-4 py-6 text-center text-sm text-gray-300">
<Link href="#" className="hover:text-white transition-colors">
Terms
</Link>
{" • "}
<Link href="#" className="hover:text-white transition-colors">
Privacy
</Link>
{" • "}
<Link href="#" className="hover:text-white transition-colors">
Cookies
</Link>
</footer>
<main className="container mx-auto px-4 py-12 md:py-20">
{/* Hero section */}
<div className="flex flex-col lg:flex-row items-center justify-between gap-12 mb-24">
<div className="max-w-2xl text-left">
<Badge className="mb-4 bg-white/20 backdrop-blur-sm text-white hover:bg-white/30">
Smart Farming Solution
</Badge>
<h1 className="text-5xl md:text-6xl font-bold mb-6 leading-tight">
Grow Smarter, <br />
<span className="bg-clip-text text-transparent bg-gradient-to-r from-green-200 to-white">
Harvest Better
</span>
</h1>
<p className="text-xl md:text-2xl mb-8 text-green-100 leading-relaxed">
Optimize your agricultural business with AI-driven insights and real-time data monitoring. ForFarm helps
you make informed decisions for sustainable farming.
</p>
<div className="flex flex-col sm:flex-row gap-4">
<Link href="/setup">
<Button className="bg-white text-green-700 text-md font-bold px-6 py-6 rounded-full hover:bg-green-100 transition-colors shadow-lg hover:shadow-xl transform hover:-translate-y-0.5 duration-200 w-full sm:w-auto">
Start managing your farm
<ChevronRight className="ml-2 h-5 w-5" />
</Button>
</Link>
<Link href="/demo">
<Button
variant="outline"
className="border-white dark:border-white text-black dark:text-white text-md font-bold px-6 py-6 rounded-full hover:bg-white/10 transition-colors w-full sm:w-auto">
Watch demo
</Button>
</Link>
</div>
<div className="mt-8 flex items-center gap-4">
<div className="flex -space-x-2">
{[1, 2, 3, 4].map((i) => (
<div key={i} className="w-8 h-8 rounded-full border-2 border-green-600 overflow-hidden">
<Image
src={`/placeholder.svg?height=32&width=32`}
alt="User"
width={32}
height={32}
className="bg-green-200"
/>
</div>
))}
</div>
<div className="text-sm text-green-100">
<span className="font-bold">500+</span> farmers already using ForFarm
</div>
</div>
</div>
<div className="relative w-full max-w-md">
<div className="absolute -inset-1 bg-gradient-to-r from-green-400 to-green-200 rounded-2xl blur-md opacity-75"></div>
<div className="relative bg-gradient-to-br from-green-800/90 to-green-900/90 backdrop-blur-sm border border-green-700/50 rounded-2xl p-6 shadow-2xl">
<div className="absolute -top-6 -right-6 bg-gradient-to-br from-green-400 to-green-600 p-3 rounded-xl shadow-lg transform rotate-6">
<Leaf className="h-6 w-6" />
</div>
<Image
src="/water-pot.png"
alt="ForFarm Dashboard Preview"
width={500}
height={300}
className="rounded-lg shadow-lg mb-4"
/>
<div className="flex justify-between items-center">
<div>
<h3 className="font-bold">Farm Dashboard</h3>
<p className="text-green-200 text-sm">Real-time monitoring</p>
</div>
<Badge className="bg-green-500">Live Demo</Badge>
</div>
</div>
</div>
</div>
{/* Features section */}
<section className="mb-24">
<div className="text-center mb-16">
<Badge className="mb-4 bg-white/20 backdrop-blur-sm text-white hover:bg-white/30">
Why Choose ForFarm
</Badge>
<h2 className="text-3xl md:text-4xl font-bold mb-4">Smart Features for Modern Farming</h2>
<p className="text-xl text-green-100 max-w-2xl mx-auto">
Our platform combines cutting-edge technology with agricultural expertise to help you optimize every
aspect of your farm.
</p>
</div>
<div className="grid md:grid-cols-2 lg:grid-cols-3 gap-8">
{[
{
icon: <BarChart className="h-10 w-10 text-green-300" />,
title: "Data-Driven Insights",
description:
"Make informed decisions with comprehensive analytics and reporting on all aspects of your farm.",
},
{
icon: <Cloud className="h-10 w-10 text-green-300" />,
title: "Weather Integration",
description:
"Get real-time weather forecasts and alerts tailored to your specific location and crops.",
},
{
icon: <Zap className="h-10 w-10 text-green-300" />,
title: "Resource Optimization",
description: "Reduce waste and maximize efficiency with smart resource management tools.",
},
{
icon: <Users className="h-10 w-10 text-green-300" />,
title: "Team Collaboration",
description: "Coordinate farm activities and share information seamlessly with your entire team.",
},
{
icon: <Shield className="h-10 w-10 text-green-300" />,
title: "Crop Protection",
description: "Identify potential threats to your crops early and get recommendations for protection.",
},
{
icon: <LineChart className="h-10 w-10 text-green-300" />,
title: "Yield Prediction",
description: "Use AI-powered models to forecast yields and plan your harvests more effectively.",
},
].map((feature, index) => (
<div key={index} className="relative group">
<div className="absolute -inset-0.5 bg-gradient-to-r from-green-400 to-green-200 rounded-xl blur opacity-0 group-hover:opacity-100 transition duration-300"></div>
<div className="relative bg-gradient-to-br from-green-800/80 to-green-900/80 backdrop-blur-sm border border-green-700/50 rounded-xl p-6 h-full flex flex-col">
<div className="bg-green-900/50 p-3 rounded-lg w-fit mb-4">{feature.icon}</div>
<h3 className="text-xl font-bold mb-2">{feature.title}</h3>
<p className="text-green-100">{feature.description}</p>
</div>
</div>
))}
</div>
</section>
{/* CTA section */}
<section className="relative">
<div className="absolute -inset-4 bg-gradient-to-r from-green-500 to-green-300 rounded-2xl blur-md opacity-50"></div>
<div className="relative bg-gradient-to-br from-green-700/90 to-green-800/90 backdrop-blur-sm border border-green-600/50 rounded-xl p-8 md:p-12 flex flex-col md:flex-row items-center justify-between gap-8">
<div>
<h2 className="text-2xl md:text-3xl font-bold mb-4">Ready to transform your farming?</h2>
<p className="text-green-100 mb-6 md:mb-0 max-w-lg">
Join hundreds of farmers who are already using ForFarm to increase yields, reduce costs, and farm more
sustainably.
</p>
</div>
<Link href="/auth/signup">
<Button className="bg-white text-green-700 text-md font-bold px-6 py-6 rounded-full hover:bg-green-100 transition-colors shadow-lg hover:shadow-xl transform hover:-translate-y-0.5 duration-200 whitespace-nowrap">
Get started for free
<ArrowRight className="ml-2 h-5 w-5" />
</Button>
</Link>
</div>
</section>
</main>
<footer className="container mx-auto px-4 py-12 border-t border-green-600/30">
<div className="flex flex-col md:flex-row justify-between items-center mb-8">
<Link href="/" className="flex items-center space-x-2 mb-4 md:mb-0">
<span className="flex font-bold text-xl items-center gap-1.5">
<Leaf className="h-5 w-5" />
ForFarm
</span>
</Link>
<nav className="flex flex-wrap justify-center gap-x-6 gap-y-2">
<Link href="/features" className="hover:text-green-200 transition-colors text-sm">
Features
</Link>
<Link href="/pricing" className="hover:text-green-200 transition-colors text-sm">
Pricing
</Link>
<Link href="/knowledge-hub" className="hover:text-green-200 transition-colors text-sm">
Knowledge Hub
</Link>
<Link href="/documentation" className="hover:text-green-200 transition-colors text-sm">
Documentation
</Link>
<Link href="/about" className="hover:text-green-200 transition-colors text-sm">
About Us
</Link>
<Link href="/contact" className="hover:text-green-200 transition-colors text-sm">
Contact
</Link>
</nav>
</div>
<div className="flex flex-col md:flex-row justify-between items-center text-sm text-green-200">
<div className="mb-4 md:mb-0">© {new Date().getFullYear()} ForFarm. All rights reserved.</div>
<div className="flex gap-4">
<Link href="/terms" className="hover:text-white transition-colors">
Terms
</Link>
<Link href="/privacy" className="hover:text-white transition-colors">
Privacy
</Link>
<Link href="/cookies" className="hover:text-white transition-colors">
Cookies
</Link>
</div>
</div>
</footer>
</div>
</div>
);
}

View File

@ -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<typeof Sidebar> {
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<typeof Sidebar>) {
return (
<Sidebar collapsible="icon" {...props}>
<SidebarHeader>
<TeamSwitcher teams={data.teams} />
<TeamSwitcher teams={sidebarConfig.teams} />
</SidebarHeader>
<SidebarContent>
<NavMain items={data.navMain} />
<NavProjects projects={data.projects} />
<NavMain items={sidebarConfig.navMain} />
<div className="mt-6">
<NavCrops crops={sidebarConfig.crops} />
</div>
</SidebarContent>
<SidebarFooter>
<NavUser user={data.user} />
</SidebarFooter>
<SidebarFooter>{loading ? "Loading..." : error ? error : <NavUser user={user} />}</SidebarFooter>
<SidebarRail />
</Sidebar>
);

View File

@ -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 (
<SidebarGroup>
<SidebarGroupLabel>{title}</SidebarGroupLabel>
<SidebarGroupContent>
<SidebarMenu>
{crops.map((crop) => (
<SidebarMenuItem key={crop.title}>
<SidebarMenuButton asChild>
<a href={crop.url}>
<crop.icon />
<span>{crop.title}</span>
</a>
</SidebarMenuButton>
</SidebarMenuItem>
))}
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
);
}

View File

@ -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 (
<SidebarGroup>
<SidebarGroupLabel>Platform</SidebarGroupLabel>
<SidebarMenu>
{items.map((item) => (
<Collapsible key={item.title} asChild defaultOpen={item.isActive} className="group/collapsible">
<SidebarMenuItem>
<CollapsibleTrigger asChild>
<SidebarMenuButton tooltip={item.title}>
<SidebarGroupContent>
<SidebarMenu>
{items.map((item) => (
<SidebarMenuItem key={item.title}>
<SidebarMenuButton asChild isActive={item.isActive} tooltip={item.title}>
<a href={item.url}>
{item.icon && <item.icon />}
<span>{item.title}</span>
<ChevronRight className="ml-auto transition-transform duration-200 group-data-[state=open]/collapsible:rotate-90" />
</SidebarMenuButton>
</CollapsibleTrigger>
<CollapsibleContent>
</a>
</SidebarMenuButton>
{item.items && (
<SidebarMenuSub>
{item.items?.map((subItem) => (
{item.items.map((subItem) => (
<SidebarMenuSubItem key={subItem.title}>
<SidebarMenuSubButton asChild>
<a href={subItem.url}>
@ -54,11 +52,11 @@ export function NavMain({
</SidebarMenuSubItem>
))}
</SidebarMenuSub>
</CollapsibleContent>
)}
</SidebarMenuItem>
</Collapsible>
))}
</SidebarMenu>
))}
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
);
}

View File

@ -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 (
<SidebarGroup className="group-data-[collapsible=icon]:hidden">
<SidebarGroupLabel>Projects</SidebarGroupLabel>
<SidebarMenu>
{projects.map((item) => (
<SidebarMenuItem key={item.name}>
<SidebarMenuButton asChild>
<a href={item.url}>
<item.icon />
<span>{item.name}</span>
</a>
</SidebarMenuButton>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<SidebarMenuAction showOnHover>
<MoreHorizontal />
<span className="sr-only">More</span>
</SidebarMenuAction>
</DropdownMenuTrigger>
<DropdownMenuContent
className="w-48 rounded-lg"
side={isMobile ? "bottom" : "right"}
align={isMobile ? "end" : "start"}>
<DropdownMenuItem>
<Folder className="text-muted-foreground" />
<span>View Project</span>
</DropdownMenuItem>
<DropdownMenuItem>
<Forward className="text-muted-foreground" />
<span>Share Project</span>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem>
<Trash2 className="text-muted-foreground" />
<span>Delete Project</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</SidebarMenuItem>
))}
<SidebarMenuItem>
<SidebarMenuButton className="text-sidebar-foreground/70">
<MoreHorizontal className="text-sidebar-foreground/70" />
<span>More</span>
</SidebarMenuButton>
</SidebarMenuItem>
</SidebarMenu>
</SidebarGroup>
);
}

View File

@ -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<HTMLDivElement> & VariantProps<typeof alertVariants>
>(({ className, variant, ...props }, ref) => (
<div
ref={ref}
role="alert"
className={cn(alertVariants({ variant }), className)}
{...props}
/>
))
Alert.displayName = "Alert"
const AlertTitle = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLHeadingElement>
>(({ className, ...props }, ref) => (
<h5
ref={ref}
className={cn("mb-1 font-medium leading-none tracking-tight", className)}
{...props}
/>
))
AlertTitle.displayName = "AlertTitle"
const AlertDescription = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("text-sm [&_p]:leading-relaxed", className)}
{...props}
/>
))
AlertDescription.displayName = "AlertDescription"
export { Alert, AlertTitle, AlertDescription }

View File

@ -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<HTMLDivElement>,
VariantProps<typeof badgeVariants> {}
function Badge({ className, variant, ...props }: BadgeProps) {
return (
<div className={cn(badgeVariants({ variant }), className)} {...props} />
)
}
export { Badge, badgeVariants }

View File

@ -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<typeof DayPicker>
function Calendar({
className,
classNames,
showOutsideDays = true,
...props
}: CalendarProps) {
return (
<DayPicker
showOutsideDays={showOutsideDays}
className={cn("p-3", className)}
classNames={{
months: "flex flex-col sm:flex-row space-y-4 sm:space-x-4 sm:space-y-0",
month: "space-y-4",
caption: "flex justify-center pt-1 relative items-center",
caption_label: "text-sm font-medium",
nav: "space-x-1 flex items-center",
nav_button: cn(
buttonVariants({ variant: "outline" }),
"h-7 w-7 bg-transparent p-0 opacity-50 hover:opacity-100"
),
nav_button_previous: "absolute left-1",
nav_button_next: "absolute right-1",
table: "w-full border-collapse space-y-1",
head_row: "flex",
head_cell:
"text-muted-foreground rounded-md w-8 font-normal text-[0.8rem]",
row: "flex w-full mt-2",
cell: cn(
"relative p-0 text-center text-sm focus-within:relative focus-within:z-20 [&:has([aria-selected])]:bg-accent [&:has([aria-selected].day-outside)]:bg-accent/50 [&:has([aria-selected].day-range-end)]:rounded-r-md",
props.mode === "range"
? "[&:has(>.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 }) => (
<ChevronLeft className={cn("h-4 w-4", className)} {...props} />
),
IconRight: ({ className, ...props }) => (
<ChevronRight className={cn("h-4 w-4", className)} {...props} />
),
}}
{...props}
/>
)
}
Calendar.displayName = "Calendar"
export { Calendar }

View File

@ -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<typeof HoverCardPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof HoverCardPrimitive.Content>
>(({ className, align = "center", sideOffset = 4, ...props }, ref) => (
<HoverCardPrimitive.Content
ref={ref}
align={align}
sideOffset={sideOffset}
className={cn(
"z-50 w-64 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className
)}
{...props}
/>
))
HoverCardContent.displayName = HoverCardPrimitive.Content.displayName
export { HoverCard, HoverCardTrigger, HoverCardContent }

View File

@ -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">) => (
<nav
role="navigation"
aria-label="pagination"
className={cn("mx-auto flex w-full justify-center", className)}
{...props}
/>
)
Pagination.displayName = "Pagination"
const PaginationContent = React.forwardRef<
HTMLUListElement,
React.ComponentProps<"ul">
>(({ className, ...props }, ref) => (
<ul
ref={ref}
className={cn("flex flex-row items-center gap-1", className)}
{...props}
/>
))
PaginationContent.displayName = "PaginationContent"
const PaginationItem = React.forwardRef<
HTMLLIElement,
React.ComponentProps<"li">
>(({ className, ...props }, ref) => (
<li ref={ref} className={cn("", className)} {...props} />
))
PaginationItem.displayName = "PaginationItem"
type PaginationLinkProps = {
isActive?: boolean
} & Pick<ButtonProps, "size"> &
React.ComponentProps<"a">
const PaginationLink = ({
className,
isActive,
size = "icon",
...props
}: PaginationLinkProps) => (
<a
aria-current={isActive ? "page" : undefined}
className={cn(
buttonVariants({
variant: isActive ? "outline" : "ghost",
size,
}),
className
)}
{...props}
/>
)
PaginationLink.displayName = "PaginationLink"
const PaginationPrevious = ({
className,
...props
}: React.ComponentProps<typeof PaginationLink>) => (
<PaginationLink
aria-label="Go to previous page"
size="default"
className={cn("gap-1 pl-2.5", className)}
{...props}
>
<ChevronLeft className="h-4 w-4" />
<span>Previous</span>
</PaginationLink>
)
PaginationPrevious.displayName = "PaginationPrevious"
const PaginationNext = ({
className,
...props
}: React.ComponentProps<typeof PaginationLink>) => (
<PaginationLink
aria-label="Go to next page"
size="default"
className={cn("gap-1 pr-2.5", className)}
{...props}
>
<span>Next</span>
<ChevronRight className="h-4 w-4" />
</PaginationLink>
)
PaginationNext.displayName = "PaginationNext"
const PaginationEllipsis = ({
className,
...props
}: React.ComponentProps<"span">) => (
<span
aria-hidden
className={cn("flex h-9 w-9 items-center justify-center", className)}
{...props}
>
<MoreHorizontal className="h-4 w-4" />
<span className="sr-only">More pages</span>
</span>
)
PaginationEllipsis.displayName = "PaginationEllipsis"
export {
Pagination,
PaginationContent,
PaginationLink,
PaginationItem,
PaginationPrevious,
PaginationNext,
PaginationEllipsis,
}

View File

@ -0,0 +1,33 @@
"use client"
import * as React from "react"
import * as PopoverPrimitive from "@radix-ui/react-popover"
import { cn } from "@/lib/utils"
const Popover = PopoverPrimitive.Root
const PopoverTrigger = PopoverPrimitive.Trigger
const PopoverAnchor = PopoverPrimitive.Anchor
const PopoverContent = React.forwardRef<
React.ElementRef<typeof PopoverPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content>
>(({ className, align = "center", sideOffset = 4, ...props }, ref) => (
<PopoverPrimitive.Portal>
<PopoverPrimitive.Content
ref={ref}
align={align}
sideOffset={sideOffset}
className={cn(
"z-50 w-72 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className
)}
{...props}
/>
</PopoverPrimitive.Portal>
))
PopoverContent.displayName = PopoverPrimitive.Content.displayName
export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor }

View File

@ -0,0 +1,28 @@
"use client"
import * as React from "react"
import * as ProgressPrimitive from "@radix-ui/react-progress"
import { cn } from "@/lib/utils"
const Progress = React.forwardRef<
React.ElementRef<typeof ProgressPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof ProgressPrimitive.Root>
>(({ className, value, ...props }, ref) => (
<ProgressPrimitive.Root
ref={ref}
className={cn(
"relative h-2 w-full overflow-hidden rounded-full bg-primary/20",
className
)}
{...props}
>
<ProgressPrimitive.Indicator
className="h-full w-full flex-1 bg-primary transition-all"
style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
/>
</ProgressPrimitive.Root>
))
Progress.displayName = ProgressPrimitive.Root.displayName
export { Progress }

View File

@ -0,0 +1,48 @@
"use client"
import * as React from "react"
import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area"
import { cn } from "@/lib/utils"
const ScrollArea = React.forwardRef<
React.ElementRef<typeof ScrollAreaPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.Root>
>(({ className, children, ...props }, ref) => (
<ScrollAreaPrimitive.Root
ref={ref}
className={cn("relative overflow-hidden", className)}
{...props}
>
<ScrollAreaPrimitive.Viewport className="h-full w-full rounded-[inherit]">
{children}
</ScrollAreaPrimitive.Viewport>
<ScrollBar />
<ScrollAreaPrimitive.Corner />
</ScrollAreaPrimitive.Root>
))
ScrollArea.displayName = ScrollAreaPrimitive.Root.displayName
const ScrollBar = React.forwardRef<
React.ElementRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>,
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>
>(({ className, orientation = "vertical", ...props }, ref) => (
<ScrollAreaPrimitive.ScrollAreaScrollbar
ref={ref}
orientation={orientation}
className={cn(
"flex touch-none select-none transition-colors",
orientation === "vertical" &&
"h-full w-2.5 border-l border-l-transparent p-[1px]",
orientation === "horizontal" &&
"h-2.5 flex-col border-t border-t-transparent p-[1px]",
className
)}
{...props}
>
<ScrollAreaPrimitive.ScrollAreaThumb className="relative flex-1 rounded-full bg-border" />
</ScrollAreaPrimitive.ScrollAreaScrollbar>
))
ScrollBar.displayName = ScrollAreaPrimitive.ScrollAreaScrollbar.displayName
export { ScrollArea, ScrollBar }

View File

@ -0,0 +1,120 @@
import * as React from "react"
import { cn } from "@/lib/utils"
const Table = React.forwardRef<
HTMLTableElement,
React.HTMLAttributes<HTMLTableElement>
>(({ className, ...props }, ref) => (
<div className="relative w-full overflow-auto">
<table
ref={ref}
className={cn("w-full caption-bottom text-sm", className)}
{...props}
/>
</div>
))
Table.displayName = "Table"
const TableHeader = React.forwardRef<
HTMLTableSectionElement,
React.HTMLAttributes<HTMLTableSectionElement>
>(({ className, ...props }, ref) => (
<thead ref={ref} className={cn("[&_tr]:border-b", className)} {...props} />
))
TableHeader.displayName = "TableHeader"
const TableBody = React.forwardRef<
HTMLTableSectionElement,
React.HTMLAttributes<HTMLTableSectionElement>
>(({ className, ...props }, ref) => (
<tbody
ref={ref}
className={cn("[&_tr:last-child]:border-0", className)}
{...props}
/>
))
TableBody.displayName = "TableBody"
const TableFooter = React.forwardRef<
HTMLTableSectionElement,
React.HTMLAttributes<HTMLTableSectionElement>
>(({ className, ...props }, ref) => (
<tfoot
ref={ref}
className={cn(
"border-t bg-muted/50 font-medium [&>tr]:last:border-b-0",
className
)}
{...props}
/>
))
TableFooter.displayName = "TableFooter"
const TableRow = React.forwardRef<
HTMLTableRowElement,
React.HTMLAttributes<HTMLTableRowElement>
>(({ className, ...props }, ref) => (
<tr
ref={ref}
className={cn(
"border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted",
className
)}
{...props}
/>
))
TableRow.displayName = "TableRow"
const TableHead = React.forwardRef<
HTMLTableCellElement,
React.ThHTMLAttributes<HTMLTableCellElement>
>(({ className, ...props }, ref) => (
<th
ref={ref}
className={cn(
"h-10 px-2 text-left align-middle font-medium text-muted-foreground [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
className
)}
{...props}
/>
))
TableHead.displayName = "TableHead"
const TableCell = React.forwardRef<
HTMLTableCellElement,
React.TdHTMLAttributes<HTMLTableCellElement>
>(({ className, ...props }, ref) => (
<td
ref={ref}
className={cn(
"p-2 align-middle [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
className
)}
{...props}
/>
))
TableCell.displayName = "TableCell"
const TableCaption = React.forwardRef<
HTMLTableCaptionElement,
React.HTMLAttributes<HTMLTableCaptionElement>
>(({ className, ...props }, ref) => (
<caption
ref={ref}
className={cn("mt-4 text-sm text-muted-foreground", className)}
{...props}
/>
))
TableCaption.displayName = "TableCaption"
export {
Table,
TableHeader,
TableBody,
TableFooter,
TableHead,
TableRow,
TableCell,
TableCaption,
}

View File

@ -0,0 +1,55 @@
"use client"
import * as React from "react"
import * as TabsPrimitive from "@radix-ui/react-tabs"
import { cn } from "@/lib/utils"
const Tabs = TabsPrimitive.Root
const TabsList = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.List>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.List>
>(({ className, ...props }, ref) => (
<TabsPrimitive.List
ref={ref}
className={cn(
"inline-flex h-9 items-center justify-center rounded-lg bg-muted p-1 text-muted-foreground",
className
)}
{...props}
/>
))
TabsList.displayName = TabsPrimitive.List.displayName
const TabsTrigger = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger>
>(({ className, ...props }, ref) => (
<TabsPrimitive.Trigger
ref={ref}
className={cn(
"inline-flex items-center justify-center whitespace-nowrap rounded-md px-3 py-1 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow",
className
)}
{...props}
/>
))
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName
const TabsContent = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Content>
>(({ className, ...props }, ref) => (
<TabsPrimitive.Content
ref={ref}
className={cn(
"mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
className
)}
{...props}
/>
))
TabsContent.displayName = TabsPrimitive.Content.displayName
export { Tabs, TabsList, TabsTrigger, TabsContent }

View File

@ -0,0 +1,12 @@
"use client";
import { QueryClientProvider, QueryClient } from "@tanstack/react-query";
import { useState } from "react";
const ReactQueryProvider = ({ children }: { children: React.ReactNode }) => {
const [queryClient] = useState(() => new QueryClient());
return <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>;
};
export default ReactQueryProvider;

View File

@ -1,6 +1,22 @@
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
return twMerge(clsx(inputs));
}
/**
* Given a pathname string, returns a cleaned route path by removing numeric segments.
*
* For example, "/farms/1/crops/2" becomes "/farms/crops".
*
* @param pathname A pathname such as "/farms/1/crops/2"
* @returns A cleaned pathname string starting with a "/"
*/
export function extractRoute(pathname: string): string {
// Split the pathname into segments and remove any empty segments.
const segments = pathname.split("/").filter(Boolean);
// Remove segments which are entirely numeric.
const nonNumericSegments = segments.filter((segment) => isNaN(Number(segment)));
return "/" + nonNumericSegments.join("/");
}

View File

@ -2,6 +2,9 @@ import type { NextConfig } from "next";
const nextConfig: NextConfig = {
/* config options here */
devIndicators: {
buildActivity: false,
},
};
export default nextConfig;

View File

@ -15,26 +15,36 @@
"@radix-ui/react-collapsible": "^1.1.3",
"@radix-ui/react-dialog": "^1.1.6",
"@radix-ui/react-dropdown-menu": "^2.1.6",
"@radix-ui/react-hover-card": "^1.1.6",
"@radix-ui/react-label": "^2.1.2",
"@radix-ui/react-popover": "^1.1.6",
"@radix-ui/react-progress": "^1.1.2",
"@radix-ui/react-scroll-area": "^1.2.3",
"@radix-ui/react-select": "^2.1.6",
"@radix-ui/react-separator": "^1.1.2",
"@radix-ui/react-slot": "^1.1.2",
"@radix-ui/react-switch": "^1.1.3",
"@radix-ui/react-tabs": "^1.1.3",
"@radix-ui/react-tooltip": "^1.1.8",
"@radix-ui/react-visually-hidden": "^1.1.2",
"@react-google-maps/api": "^2.20.6",
"@tailwindcss/typography": "^0.5.16",
"@tanstack/react-query": "^5.66.0",
"axios": "^1.7.9",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"date-fns": "^4.1.0",
"framer-motion": "^12.4.10",
"js-cookie": "^3.0.5",
"lucide-react": "^0.475.0",
"next": "15.1.0",
"next-auth": "^4.24.11",
"next-themes": "^0.4.4",
"react": "^19.0.0",
"react-day-picker": "8.10.1",
"react-dom": "^19.0.0",
"react-hook-form": "^7.54.2",
"recharts": "^2.15.1",
"tailwind-merge": "^3.0.1",
"tailwindcss-animate": "^1.0.7",
"zod": "^3.24.2"

View File

@ -26,9 +26,21 @@ importers:
'@radix-ui/react-dropdown-menu':
specifier: ^2.1.6
version: 2.1.6(@types/react-dom@19.0.3(@types/react@19.0.8))(@types/react@19.0.8)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
'@radix-ui/react-hover-card':
specifier: ^1.1.6
version: 1.1.6(@types/react-dom@19.0.3(@types/react@19.0.8))(@types/react@19.0.8)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
'@radix-ui/react-label':
specifier: ^2.1.2
version: 2.1.2(@types/react-dom@19.0.3(@types/react@19.0.8))(@types/react@19.0.8)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
'@radix-ui/react-popover':
specifier: ^1.1.6
version: 1.1.6(@types/react-dom@19.0.3(@types/react@19.0.8))(@types/react@19.0.8)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
'@radix-ui/react-progress':
specifier: ^1.1.2
version: 1.1.2(@types/react-dom@19.0.3(@types/react@19.0.8))(@types/react@19.0.8)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
'@radix-ui/react-scroll-area':
specifier: ^1.2.3
version: 1.2.3(@types/react-dom@19.0.3(@types/react@19.0.8))(@types/react@19.0.8)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
'@radix-ui/react-select':
specifier: ^2.1.6
version: 2.1.6(@types/react-dom@19.0.3(@types/react@19.0.8))(@types/react@19.0.8)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
@ -41,9 +53,15 @@ importers:
'@radix-ui/react-switch':
specifier: ^1.1.3
version: 1.1.3(@types/react-dom@19.0.3(@types/react@19.0.8))(@types/react@19.0.8)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
'@radix-ui/react-tabs':
specifier: ^1.1.3
version: 1.1.3(@types/react-dom@19.0.3(@types/react@19.0.8))(@types/react@19.0.8)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
'@radix-ui/react-tooltip':
specifier: ^1.1.8
version: 1.1.8(@types/react-dom@19.0.3(@types/react@19.0.8))(@types/react@19.0.8)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
'@radix-ui/react-visually-hidden':
specifier: ^1.1.2
version: 1.1.2(@types/react-dom@19.0.3(@types/react@19.0.8))(@types/react@19.0.8)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
'@react-google-maps/api':
specifier: ^2.20.6
version: 2.20.6(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
@ -62,6 +80,12 @@ importers:
clsx:
specifier: ^2.1.1
version: 2.1.1
date-fns:
specifier: ^4.1.0
version: 4.1.0
framer-motion:
specifier: ^12.4.10
version: 12.4.10(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
js-cookie:
specifier: ^3.0.5
version: 3.0.5
@ -80,12 +104,18 @@ importers:
react:
specifier: ^19.0.0
version: 19.0.0
react-day-picker:
specifier: 8.10.1
version: 8.10.1(date-fns@4.1.0)(react@19.0.0)
react-dom:
specifier: ^19.0.0
version: 19.0.0(react@19.0.0)
react-hook-form:
specifier: ^7.54.2
version: 7.54.2(react@19.0.0)
recharts:
specifier: ^2.15.1
version: 2.15.1(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
tailwind-merge:
specifier: ^3.0.1
version: 3.0.1
@ -584,6 +614,19 @@ packages:
'@types/react-dom':
optional: true
'@radix-ui/react-hover-card@1.1.6':
resolution: {integrity: sha512-E4ozl35jq0VRlrdc4dhHrNSV0JqBb4Jy73WAhBEK7JoYnQ83ED5r0Rb/XdVKw89ReAJN38N492BAPBZQ57VmqQ==}
peerDependencies:
'@types/react': '*'
'@types/react-dom': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@types/react-dom':
optional: true
'@radix-ui/react-id@1.1.0':
resolution: {integrity: sha512-EJUrI8yYh7WOjNOqpoJaf1jlFIH2LvtgAl+YcFqNCa+4hj64ZXmPkAKOFs/ukjz3byN6bdb/AVUqHkI8/uWWMA==}
peerDependencies:
@ -619,6 +662,19 @@ packages:
'@types/react-dom':
optional: true
'@radix-ui/react-popover@1.1.6':
resolution: {integrity: sha512-NQouW0x4/GnkFJ/pRqsIS3rM/k97VzKnVb2jB7Gq7VEGPy5g7uNV1ykySFt7eWSp3i2uSGFwaJcvIRJBAHmmFg==}
peerDependencies:
'@types/react': '*'
'@types/react-dom': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@types/react-dom':
optional: true
'@radix-ui/react-popper@1.2.2':
resolution: {integrity: sha512-Rvqc3nOpwseCyj/rgjlJDYAgyfw7OC1tTkKn2ivhaMGcYt8FSBlahHOZak2i3QwkRXUXgGgzeEe2RuqeEHuHgA==}
peerDependencies:
@ -671,6 +727,19 @@ packages:
'@types/react-dom':
optional: true
'@radix-ui/react-progress@1.1.2':
resolution: {integrity: sha512-u1IgJFQ4zNAUTjGdDL5dcl/U8ntOR6jsnhxKb5RKp5Ozwl88xKR9EqRZOe/Mk8tnx0x5tNUe2F+MzsyjqMg0MA==}
peerDependencies:
'@types/react': '*'
'@types/react-dom': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@types/react-dom':
optional: true
'@radix-ui/react-roving-focus@1.1.2':
resolution: {integrity: sha512-zgMQWkNO169GtGqRvYrzb0Zf8NhMHS2DuEB/TiEmVnpr5OqPU3i8lfbxaAmC2J/KYuIQxyoQQ6DxepyXp61/xw==}
peerDependencies:
@ -684,6 +753,19 @@ packages:
'@types/react-dom':
optional: true
'@radix-ui/react-scroll-area@1.2.3':
resolution: {integrity: sha512-l7+NNBfBYYJa9tNqVcP2AGvxdE3lmE6kFTBXdvHgUaZuy+4wGCL1Cl2AfaR7RKyimj7lZURGLwFO59k4eBnDJQ==}
peerDependencies:
'@types/react': '*'
'@types/react-dom': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@types/react-dom':
optional: true
'@radix-ui/react-select@2.1.6':
resolution: {integrity: sha512-T6ajELxRvTuAMWH0YmRJ1qez+x4/7Nq7QIx7zJ0VK3qaEWdnWpNbEDnmWldG1zBDwqrLy5aLMUWcoGirVj5kMg==}
peerDependencies:
@ -732,6 +814,19 @@ packages:
'@types/react-dom':
optional: true
'@radix-ui/react-tabs@1.1.3':
resolution: {integrity: sha512-9mFyI30cuRDImbmFF6O2KUJdgEOsGh9Vmx9x/Dh9tOhL7BngmQPQfwW4aejKm5OHpfWIdmeV6ySyuxoOGjtNng==}
peerDependencies:
'@types/react': '*'
'@types/react-dom': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@types/react-dom':
optional: true
'@radix-ui/react-tooltip@1.1.8':
resolution: {integrity: sha512-YAA2cu48EkJZdAMHC0dqo9kialOcRStbtiY4nJPaht7Ptrhcvpo+eDChaM6BIs8kL6a8Z5l5poiqLnXcNduOkA==}
peerDependencies:
@ -861,6 +956,33 @@ packages:
peerDependencies:
react: ^18 || ^19
'@types/d3-array@3.2.1':
resolution: {integrity: sha512-Y2Jn2idRrLzUfAKV2LyRImR+y4oa2AntrgID95SHJxuMUrkNXmanDSed71sRNZysveJVt1hLLemQZIady0FpEg==}
'@types/d3-color@3.1.3':
resolution: {integrity: sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==}
'@types/d3-ease@3.0.2':
resolution: {integrity: sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==}
'@types/d3-interpolate@3.0.4':
resolution: {integrity: sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==}
'@types/d3-path@3.1.1':
resolution: {integrity: sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==}
'@types/d3-scale@4.0.9':
resolution: {integrity: sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==}
'@types/d3-shape@3.1.7':
resolution: {integrity: sha512-VLvUQ33C+3J+8p+Daf+nYSOsjB4GXp19/S/aGo60m9h1v6XaxjiT82lKVWJCfzhtuZ3yD7i/TPeC/fuKLLOSmg==}
'@types/d3-time@3.0.4':
resolution: {integrity: sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==}
'@types/d3-timer@3.0.2':
resolution: {integrity: sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==}
'@types/estree@1.0.6':
resolution: {integrity: sha512-AYnb1nQyY49te+VRAVgmzfcgjYS91mY5P0TKUDCLEM+gNnA+3T6rWITXRLYCpahpqSQbN5cE+gHpnPyXjHWxcw==}
@ -1141,6 +1263,50 @@ packages:
csstype@3.1.3:
resolution: {integrity: sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==}
d3-array@3.2.4:
resolution: {integrity: sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==}
engines: {node: '>=12'}
d3-color@3.1.0:
resolution: {integrity: sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==}
engines: {node: '>=12'}
d3-ease@3.0.1:
resolution: {integrity: sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==}
engines: {node: '>=12'}
d3-format@3.1.0:
resolution: {integrity: sha512-YyUI6AEuY/Wpt8KWLgZHsIU86atmikuoOmCfommt0LYHiQSPjvX2AcFc38PX0CBpr2RCyZhjex+NS/LPOv6YqA==}
engines: {node: '>=12'}
d3-interpolate@3.0.1:
resolution: {integrity: sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==}
engines: {node: '>=12'}
d3-path@3.1.0:
resolution: {integrity: sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==}
engines: {node: '>=12'}
d3-scale@4.0.2:
resolution: {integrity: sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==}
engines: {node: '>=12'}
d3-shape@3.2.0:
resolution: {integrity: sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==}
engines: {node: '>=12'}
d3-time-format@4.1.0:
resolution: {integrity: sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==}
engines: {node: '>=12'}
d3-time@3.1.0:
resolution: {integrity: sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==}
engines: {node: '>=12'}
d3-timer@3.0.1:
resolution: {integrity: sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==}
engines: {node: '>=12'}
damerau-levenshtein@1.0.8:
resolution: {integrity: sha512-sdQSFB7+llfUcQHUQO3+B8ERRj0Oa4w9POWMI/puGtuf7gFywGmkaLCElnudfTiKZV+NvHqL0ifzdrI8Ro7ESA==}
@ -1156,6 +1322,9 @@ packages:
resolution: {integrity: sha512-BS8PfmtDGnrgYdOonGZQdLZslWIeCGFP9tpan0hi1Co2Zr2NKADsvGYA8XxuG/4UWgJ6Cjtv+YJnB6MM69QGlQ==}
engines: {node: '>= 0.4'}
date-fns@4.1.0:
resolution: {integrity: sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==}
debug@3.2.7:
resolution: {integrity: sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==}
peerDependencies:
@ -1173,6 +1342,9 @@ packages:
supports-color:
optional: true
decimal.js-light@2.5.1:
resolution: {integrity: sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==}
deep-is@0.1.4:
resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==}
@ -1205,6 +1377,9 @@ packages:
resolution: {integrity: sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==}
engines: {node: '>=0.10.0'}
dom-helpers@5.2.1:
resolution: {integrity: sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==}
dunder-proto@1.0.1:
resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==}
engines: {node: '>= 0.4'}
@ -1374,9 +1549,16 @@ packages:
resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==}
engines: {node: '>=0.10.0'}
eventemitter3@4.0.7:
resolution: {integrity: sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==}
fast-deep-equal@3.1.3:
resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==}
fast-equals@5.2.2:
resolution: {integrity: sha512-V7/RktU11J3I36Nwq2JnZEM7tNm17eBJz+u25qdxBZeCKiX6BkVSZQjwWIr+IobgnZy+ag73tTZgZi7tr0LrBw==}
engines: {node: '>=6.0.0'}
fast-glob@3.3.1:
resolution: {integrity: sha512-kNFPyjhh5cKjrUltxs+wFx+ZkbRaxxmZ+X0ZU31SOsxCEtP9VPgtq2teZw1DebupL5GmDaNQ6yKMMVcM41iqDg==}
engines: {node: '>=8.6.0'}
@ -1434,6 +1616,20 @@ packages:
resolution: {integrity: sha512-tzN8e4TX8+kkxGPK8D5u0FNmjPUjw3lwC9lSLxxoB/+GtsJG91CO8bSWy73APlgAZzZbXEYZJuxjkHH2w+Ezhw==}
engines: {node: '>= 6'}
framer-motion@12.4.10:
resolution: {integrity: sha512-3Msuyjcr1Pb5hjkn4EJcRe1HumaveP0Gbv4DBMKTPKcV/1GSMkQXj+Uqgneys+9DPcZM18Hac9qY9iUEF5LZtg==}
peerDependencies:
'@emotion/is-prop-valid': '*'
react: ^18.0.0 || ^19.0.0
react-dom: ^18.0.0 || ^19.0.0
peerDependenciesMeta:
'@emotion/is-prop-valid':
optional: true
react:
optional: true
react-dom:
optional: true
fsevents@2.3.3:
resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==}
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
@ -1541,6 +1737,10 @@ packages:
resolution: {integrity: sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==}
engines: {node: '>= 0.4'}
internmap@2.0.3:
resolution: {integrity: sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==}
engines: {node: '>=12'}
invariant@2.2.4:
resolution: {integrity: sha512-phJfQVBuaJM5raOpJjSfkiD6BpbCE4Ns//LaXl6wGYtUBY83nWS6Rf9tXm2e8VaK60JEjYldbPif/A2B1C2gNA==}
@ -1739,6 +1939,9 @@ packages:
lodash.merge@4.6.2:
resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==}
lodash@4.17.21:
resolution: {integrity: sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==}
loose-envify@1.4.0:
resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==}
hasBin: true
@ -1789,6 +1992,12 @@ packages:
resolution: {integrity: sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==}
engines: {node: '>=16 || 14 >=14.17'}
motion-dom@12.4.10:
resolution: {integrity: sha512-ISP5u6FTceoD6qKdLupIPU/LyXBrxGox+P2e3mBbm1+pLdlBbwv01YENJr7+1WZnW5ucVKzFScYsV1eXTCG4Xg==}
motion-utils@12.4.10:
resolution: {integrity: sha512-NPwZd94V013SwRf++jMrk2+HEBgPkeIE2RiOzhAuuQlqxMJPkKt/LXVh6Upl+iN8oarSGD2dlY5/bqgsYXDABA==}
ms@2.1.3:
resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}
@ -2032,6 +2241,12 @@ packages:
queue-microtask@1.2.3:
resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==}
react-day-picker@8.10.1:
resolution: {integrity: sha512-TMx7fNbhLk15eqcMt+7Z7S2KF7mfTId/XJDjKE8f+IUcFn0l08/kI4FiYTL/0yuOLmEcbR4Fwe3GJf/NiiMnPA==}
peerDependencies:
date-fns: ^2.28.0 || ^3.0.0
react: ^16.8.0 || ^17.0.0 || ^18.0.0
react-dom@19.0.0:
resolution: {integrity: sha512-4GV5sHFG0e/0AD4X+ySy6UJd3jVl1iNsNHdpad0qhABJ11twS3TTBnseqsKurKcsNqCEFeGL3uLpVChpIO3QfQ==}
peerDependencies:
@ -2046,6 +2261,9 @@ packages:
react-is@16.13.1:
resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==}
react-is@18.3.1:
resolution: {integrity: sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==}
react-remove-scroll-bar@2.3.8:
resolution: {integrity: sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q==}
engines: {node: '>=10'}
@ -2066,6 +2284,12 @@ packages:
'@types/react':
optional: true
react-smooth@4.0.4:
resolution: {integrity: sha512-gnGKTpYwqL0Iii09gHobNolvX4Kiq4PKx6eWBCYYix+8cdw+cGo3do906l1NBPKkSWx1DghC1dlWG9L2uGd61Q==}
peerDependencies:
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
react-style-singleton@2.2.3:
resolution: {integrity: sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ==}
engines: {node: '>=10'}
@ -2076,6 +2300,12 @@ packages:
'@types/react':
optional: true
react-transition-group@4.4.5:
resolution: {integrity: sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g==}
peerDependencies:
react: '>=16.6.0'
react-dom: '>=16.6.0'
react@19.0.0:
resolution: {integrity: sha512-V8AVnmPIICiWpGfm6GLzCR/W5FXLchHop40W4nXBmdlEceh16rCN8O8LNWm5bh5XUX91fh7KpA+W0TgMKmgTpQ==}
engines: {node: '>=0.10.0'}
@ -2087,6 +2317,16 @@ packages:
resolution: {integrity: sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==}
engines: {node: '>=8.10.0'}
recharts-scale@0.4.5:
resolution: {integrity: sha512-kivNFO+0OcUNu7jQquLXAxz1FIwZj8nrj+YkOKc5694NbjCvcT6aSZiIzNzd2Kul4o4rTto8QVR9lMNtxD4G1w==}
recharts@2.15.1:
resolution: {integrity: sha512-v8PUTUlyiDe56qUj82w/EDVuzEFXwEHp9/xOowGAZwfLjB9uAy3GllQVIYMWF6nU+qibx85WF75zD7AjqoT54Q==}
engines: {node: '>=14'}
peerDependencies:
react: ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
react-dom: ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
reflect.getprototypeof@1.0.10:
resolution: {integrity: sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==}
engines: {node: '>= 0.4'}
@ -2303,6 +2543,9 @@ packages:
thenify@3.3.1:
resolution: {integrity: sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==}
tiny-invariant@1.3.3:
resolution: {integrity: sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==}
to-regex-range@5.0.1:
resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==}
engines: {node: '>=8.0'}
@ -2384,6 +2627,9 @@ packages:
resolution: {integrity: sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==}
hasBin: true
victory-vendor@36.9.2:
resolution: {integrity: sha512-PnpQQMuxlwYdocC8fIJqVXvkeViHYzotI+NJrCuav0ZYFoq912ZHBk3mCeuj+5/VpodOjPe1z0Fk2ihgzlXqjQ==}
which-boxed-primitive@1.1.1:
resolution: {integrity: sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==}
engines: {node: '>= 0.4'}
@ -2836,6 +3082,23 @@ snapshots:
'@types/react': 19.0.8
'@types/react-dom': 19.0.3(@types/react@19.0.8)
'@radix-ui/react-hover-card@1.1.6(@types/react-dom@19.0.3(@types/react@19.0.8))(@types/react@19.0.8)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)':
dependencies:
'@radix-ui/primitive': 1.1.1
'@radix-ui/react-compose-refs': 1.1.1(@types/react@19.0.8)(react@19.0.0)
'@radix-ui/react-context': 1.1.1(@types/react@19.0.8)(react@19.0.0)
'@radix-ui/react-dismissable-layer': 1.1.5(@types/react-dom@19.0.3(@types/react@19.0.8))(@types/react@19.0.8)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
'@radix-ui/react-popper': 1.2.2(@types/react-dom@19.0.3(@types/react@19.0.8))(@types/react@19.0.8)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
'@radix-ui/react-portal': 1.1.4(@types/react-dom@19.0.3(@types/react@19.0.8))(@types/react@19.0.8)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
'@radix-ui/react-presence': 1.1.2(@types/react-dom@19.0.3(@types/react@19.0.8))(@types/react@19.0.8)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
'@radix-ui/react-primitive': 2.0.2(@types/react-dom@19.0.3(@types/react@19.0.8))(@types/react@19.0.8)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
'@radix-ui/react-use-controllable-state': 1.1.0(@types/react@19.0.8)(react@19.0.0)
react: 19.0.0
react-dom: 19.0.0(react@19.0.0)
optionalDependencies:
'@types/react': 19.0.8
'@types/react-dom': 19.0.3(@types/react@19.0.8)
'@radix-ui/react-id@1.1.0(@types/react@19.0.8)(react@19.0.0)':
dependencies:
'@radix-ui/react-use-layout-effect': 1.1.0(@types/react@19.0.8)(react@19.0.0)
@ -2878,6 +3141,29 @@ snapshots:
'@types/react': 19.0.8
'@types/react-dom': 19.0.3(@types/react@19.0.8)
'@radix-ui/react-popover@1.1.6(@types/react-dom@19.0.3(@types/react@19.0.8))(@types/react@19.0.8)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)':
dependencies:
'@radix-ui/primitive': 1.1.1
'@radix-ui/react-compose-refs': 1.1.1(@types/react@19.0.8)(react@19.0.0)
'@radix-ui/react-context': 1.1.1(@types/react@19.0.8)(react@19.0.0)
'@radix-ui/react-dismissable-layer': 1.1.5(@types/react-dom@19.0.3(@types/react@19.0.8))(@types/react@19.0.8)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
'@radix-ui/react-focus-guards': 1.1.1(@types/react@19.0.8)(react@19.0.0)
'@radix-ui/react-focus-scope': 1.1.2(@types/react-dom@19.0.3(@types/react@19.0.8))(@types/react@19.0.8)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
'@radix-ui/react-id': 1.1.0(@types/react@19.0.8)(react@19.0.0)
'@radix-ui/react-popper': 1.2.2(@types/react-dom@19.0.3(@types/react@19.0.8))(@types/react@19.0.8)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
'@radix-ui/react-portal': 1.1.4(@types/react-dom@19.0.3(@types/react@19.0.8))(@types/react@19.0.8)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
'@radix-ui/react-presence': 1.1.2(@types/react-dom@19.0.3(@types/react@19.0.8))(@types/react@19.0.8)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
'@radix-ui/react-primitive': 2.0.2(@types/react-dom@19.0.3(@types/react@19.0.8))(@types/react@19.0.8)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
'@radix-ui/react-slot': 1.1.2(@types/react@19.0.8)(react@19.0.0)
'@radix-ui/react-use-controllable-state': 1.1.0(@types/react@19.0.8)(react@19.0.0)
aria-hidden: 1.2.4
react: 19.0.0
react-dom: 19.0.0(react@19.0.0)
react-remove-scroll: 2.6.3(@types/react@19.0.8)(react@19.0.0)
optionalDependencies:
'@types/react': 19.0.8
'@types/react-dom': 19.0.3(@types/react@19.0.8)
'@radix-ui/react-popper@1.2.2(@types/react-dom@19.0.3(@types/react@19.0.8))(@types/react@19.0.8)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)':
dependencies:
'@floating-ui/react-dom': 2.1.2(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
@ -2925,6 +3211,16 @@ snapshots:
'@types/react': 19.0.8
'@types/react-dom': 19.0.3(@types/react@19.0.8)
'@radix-ui/react-progress@1.1.2(@types/react-dom@19.0.3(@types/react@19.0.8))(@types/react@19.0.8)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)':
dependencies:
'@radix-ui/react-context': 1.1.1(@types/react@19.0.8)(react@19.0.0)
'@radix-ui/react-primitive': 2.0.2(@types/react-dom@19.0.3(@types/react@19.0.8))(@types/react@19.0.8)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
react: 19.0.0
react-dom: 19.0.0(react@19.0.0)
optionalDependencies:
'@types/react': 19.0.8
'@types/react-dom': 19.0.3(@types/react@19.0.8)
'@radix-ui/react-roving-focus@1.1.2(@types/react-dom@19.0.3(@types/react@19.0.8))(@types/react@19.0.8)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)':
dependencies:
'@radix-ui/primitive': 1.1.1
@ -2942,6 +3238,23 @@ snapshots:
'@types/react': 19.0.8
'@types/react-dom': 19.0.3(@types/react@19.0.8)
'@radix-ui/react-scroll-area@1.2.3(@types/react-dom@19.0.3(@types/react@19.0.8))(@types/react@19.0.8)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)':
dependencies:
'@radix-ui/number': 1.1.0
'@radix-ui/primitive': 1.1.1
'@radix-ui/react-compose-refs': 1.1.1(@types/react@19.0.8)(react@19.0.0)
'@radix-ui/react-context': 1.1.1(@types/react@19.0.8)(react@19.0.0)
'@radix-ui/react-direction': 1.1.0(@types/react@19.0.8)(react@19.0.0)
'@radix-ui/react-presence': 1.1.2(@types/react-dom@19.0.3(@types/react@19.0.8))(@types/react@19.0.8)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
'@radix-ui/react-primitive': 2.0.2(@types/react-dom@19.0.3(@types/react@19.0.8))(@types/react@19.0.8)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
'@radix-ui/react-use-callback-ref': 1.1.0(@types/react@19.0.8)(react@19.0.0)
'@radix-ui/react-use-layout-effect': 1.1.0(@types/react@19.0.8)(react@19.0.0)
react: 19.0.0
react-dom: 19.0.0(react@19.0.0)
optionalDependencies:
'@types/react': 19.0.8
'@types/react-dom': 19.0.3(@types/react@19.0.8)
'@radix-ui/react-select@2.1.6(@types/react-dom@19.0.3(@types/react@19.0.8))(@types/react@19.0.8)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)':
dependencies:
'@radix-ui/number': 1.1.0
@ -3002,6 +3315,22 @@ snapshots:
'@types/react': 19.0.8
'@types/react-dom': 19.0.3(@types/react@19.0.8)
'@radix-ui/react-tabs@1.1.3(@types/react-dom@19.0.3(@types/react@19.0.8))(@types/react@19.0.8)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)':
dependencies:
'@radix-ui/primitive': 1.1.1
'@radix-ui/react-context': 1.1.1(@types/react@19.0.8)(react@19.0.0)
'@radix-ui/react-direction': 1.1.0(@types/react@19.0.8)(react@19.0.0)
'@radix-ui/react-id': 1.1.0(@types/react@19.0.8)(react@19.0.0)
'@radix-ui/react-presence': 1.1.2(@types/react-dom@19.0.3(@types/react@19.0.8))(@types/react@19.0.8)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
'@radix-ui/react-primitive': 2.0.2(@types/react-dom@19.0.3(@types/react@19.0.8))(@types/react@19.0.8)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
'@radix-ui/react-roving-focus': 1.1.2(@types/react-dom@19.0.3(@types/react@19.0.8))(@types/react@19.0.8)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
'@radix-ui/react-use-controllable-state': 1.1.0(@types/react@19.0.8)(react@19.0.0)
react: 19.0.0
react-dom: 19.0.0(react@19.0.0)
optionalDependencies:
'@types/react': 19.0.8
'@types/react-dom': 19.0.3(@types/react@19.0.8)
'@radix-ui/react-tooltip@1.1.8(@types/react-dom@19.0.3(@types/react@19.0.8))(@types/react@19.0.8)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)':
dependencies:
'@radix-ui/primitive': 1.1.1
@ -3119,6 +3448,30 @@ snapshots:
'@tanstack/query-core': 5.66.0
react: 19.0.0
'@types/d3-array@3.2.1': {}
'@types/d3-color@3.1.3': {}
'@types/d3-ease@3.0.2': {}
'@types/d3-interpolate@3.0.4':
dependencies:
'@types/d3-color': 3.1.3
'@types/d3-path@3.1.1': {}
'@types/d3-scale@4.0.9':
dependencies:
'@types/d3-time': 3.0.4
'@types/d3-shape@3.1.7':
dependencies:
'@types/d3-path': 3.1.1
'@types/d3-time@3.0.4': {}
'@types/d3-timer@3.0.2': {}
'@types/estree@1.0.6': {}
'@types/google.maps@3.58.1': {}
@ -3449,6 +3802,44 @@ snapshots:
csstype@3.1.3: {}
d3-array@3.2.4:
dependencies:
internmap: 2.0.3
d3-color@3.1.0: {}
d3-ease@3.0.1: {}
d3-format@3.1.0: {}
d3-interpolate@3.0.1:
dependencies:
d3-color: 3.1.0
d3-path@3.1.0: {}
d3-scale@4.0.2:
dependencies:
d3-array: 3.2.4
d3-format: 3.1.0
d3-interpolate: 3.0.1
d3-time: 3.1.0
d3-time-format: 4.1.0
d3-shape@3.2.0:
dependencies:
d3-path: 3.1.0
d3-time-format@4.1.0:
dependencies:
d3-time: 3.1.0
d3-time@3.1.0:
dependencies:
d3-array: 3.2.4
d3-timer@3.0.1: {}
damerau-levenshtein@1.0.8: {}
data-view-buffer@1.0.2:
@ -3469,6 +3860,8 @@ snapshots:
es-errors: 1.3.0
is-data-view: 1.0.2
date-fns@4.1.0: {}
debug@3.2.7:
dependencies:
ms: 2.1.3
@ -3477,6 +3870,8 @@ snapshots:
dependencies:
ms: 2.1.3
decimal.js-light@2.5.1: {}
deep-is@0.1.4: {}
define-data-property@1.1.4:
@ -3506,6 +3901,11 @@ snapshots:
dependencies:
esutils: 2.0.3
dom-helpers@5.2.1:
dependencies:
'@babel/runtime': 7.26.7
csstype: 3.1.3
dunder-proto@1.0.1:
dependencies:
call-bind-apply-helpers: 1.0.1
@ -3820,8 +4220,12 @@ snapshots:
esutils@2.0.3: {}
eventemitter3@4.0.7: {}
fast-deep-equal@3.1.3: {}
fast-equals@5.2.2: {}
fast-glob@3.3.1:
dependencies:
'@nodelib/fs.stat': 2.0.5
@ -3883,6 +4287,15 @@ snapshots:
combined-stream: 1.0.8
mime-types: 2.1.35
framer-motion@12.4.10(react-dom@19.0.0(react@19.0.0))(react@19.0.0):
dependencies:
motion-dom: 12.4.10
motion-utils: 12.4.10
tslib: 2.8.1
optionalDependencies:
react: 19.0.0
react-dom: 19.0.0(react@19.0.0)
fsevents@2.3.3:
optional: true
@ -3996,6 +4409,8 @@ snapshots:
hasown: 2.0.2
side-channel: 1.1.0
internmap@2.0.3: {}
invariant@2.2.4:
dependencies:
loose-envify: 1.4.0
@ -4197,6 +4612,8 @@ snapshots:
lodash.merge@4.6.2: {}
lodash@4.17.21: {}
loose-envify@1.4.0:
dependencies:
js-tokens: 4.0.0
@ -4238,6 +4655,12 @@ snapshots:
minipass@7.1.2: {}
motion-dom@12.4.10:
dependencies:
motion-utils: 12.4.10
motion-utils@12.4.10: {}
ms@2.1.3: {}
mz@2.7.0:
@ -4474,6 +4897,11 @@ snapshots:
queue-microtask@1.2.3: {}
react-day-picker@8.10.1(date-fns@4.1.0)(react@19.0.0):
dependencies:
date-fns: 4.1.0
react: 19.0.0
react-dom@19.0.0(react@19.0.0):
dependencies:
react: 19.0.0
@ -4485,6 +4913,8 @@ snapshots:
react-is@16.13.1: {}
react-is@18.3.1: {}
react-remove-scroll-bar@2.3.8(@types/react@19.0.8)(react@19.0.0):
dependencies:
react: 19.0.0
@ -4504,6 +4934,14 @@ snapshots:
optionalDependencies:
'@types/react': 19.0.8
react-smooth@4.0.4(react-dom@19.0.0(react@19.0.0))(react@19.0.0):
dependencies:
fast-equals: 5.2.2
prop-types: 15.8.1
react: 19.0.0
react-dom: 19.0.0(react@19.0.0)
react-transition-group: 4.4.5(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
react-style-singleton@2.2.3(@types/react@19.0.8)(react@19.0.0):
dependencies:
get-nonce: 1.0.1
@ -4512,6 +4950,15 @@ snapshots:
optionalDependencies:
'@types/react': 19.0.8
react-transition-group@4.4.5(react-dom@19.0.0(react@19.0.0))(react@19.0.0):
dependencies:
'@babel/runtime': 7.26.7
dom-helpers: 5.2.1
loose-envify: 1.4.0
prop-types: 15.8.1
react: 19.0.0
react-dom: 19.0.0(react@19.0.0)
react@19.0.0: {}
read-cache@1.0.0:
@ -4522,6 +4969,23 @@ snapshots:
dependencies:
picomatch: 2.3.1
recharts-scale@0.4.5:
dependencies:
decimal.js-light: 2.5.1
recharts@2.15.1(react-dom@19.0.0(react@19.0.0))(react@19.0.0):
dependencies:
clsx: 2.1.1
eventemitter3: 4.0.7
lodash: 4.17.21
react: 19.0.0
react-dom: 19.0.0(react@19.0.0)
react-is: 18.3.1
react-smooth: 4.0.4(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
recharts-scale: 0.4.5
tiny-invariant: 1.3.3
victory-vendor: 36.9.2
reflect.getprototypeof@1.0.10:
dependencies:
call-bind: 1.0.8
@ -4829,6 +5293,8 @@ snapshots:
dependencies:
any-promise: 1.3.0
tiny-invariant@1.3.3: {}
to-regex-range@5.0.1:
dependencies:
is-number: 7.0.0
@ -4919,6 +5385,23 @@ snapshots:
uuid@8.3.2: {}
victory-vendor@36.9.2:
dependencies:
'@types/d3-array': 3.2.1
'@types/d3-ease': 3.0.2
'@types/d3-interpolate': 3.0.4
'@types/d3-scale': 4.0.9
'@types/d3-shape': 3.1.7
'@types/d3-time': 3.0.4
'@types/d3-timer': 3.0.2
d3-array: 3.2.4
d3-ease: 3.0.1
d3-interpolate: 3.0.1
d3-scale: 4.0.2
d3-shape: 3.2.0
d3-time: 3.1.0
d3-timer: 3.0.1
which-boxed-primitive@1.1.1:
dependencies:
is-bigint: 1.1.0

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.0 KiB

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1200" height="1200" fill="none"><rect width="1200" height="1200" fill="#EAEAEA" rx="3"/><g opacity=".5"><g opacity=".5"><path fill="#FAFAFA" d="M600.709 736.5c-75.454 0-136.621-61.167-136.621-136.62 0-75.454 61.167-136.621 136.621-136.621 75.453 0 136.62 61.167 136.62 136.621 0 75.453-61.167 136.62-136.62 136.62Z"/><path stroke="#C9C9C9" stroke-width="2.418" d="M600.709 736.5c-75.454 0-136.621-61.167-136.621-136.62 0-75.454 61.167-136.621 136.621-136.621 75.453 0 136.62 61.167 136.62 136.621 0 75.453-61.167 136.62-136.62 136.62Z"/></g><path stroke="url(#a)" stroke-width="2.418" d="M0-1.209h553.581" transform="scale(1 -1) rotate(45 1163.11 91.165)"/><path stroke="url(#b)" stroke-width="2.418" d="M404.846 598.671h391.726"/><path stroke="url(#c)" stroke-width="2.418" d="M599.5 795.742V404.017"/><path stroke="url(#d)" stroke-width="2.418" d="m795.717 796.597-391.441-391.44"/><path fill="#fff" d="M600.709 656.704c-31.384 0-56.825-25.441-56.825-56.824 0-31.384 25.441-56.825 56.825-56.825 31.383 0 56.824 25.441 56.824 56.825 0 31.383-25.441 56.824-56.824 56.824Z"/><g clip-path="url(#e)"><path fill="#666" fill-rule="evenodd" d="M616.426 586.58h-31.434v16.176l3.553-3.554.531-.531h9.068l.074-.074 8.463-8.463h2.565l7.18 7.181V586.58Zm-15.715 14.654 3.698 3.699 1.283 1.282-2.565 2.565-1.282-1.283-5.2-5.199h-6.066l-5.514 5.514-.073.073v2.876a2.418 2.418 0 0 0 2.418 2.418h26.598a2.418 2.418 0 0 0 2.418-2.418v-8.317l-8.463-8.463-7.181 7.181-.071.072Zm-19.347 5.442v4.085a6.045 6.045 0 0 0 6.046 6.045h26.598a6.044 6.044 0 0 0 6.045-6.045v-7.108l1.356-1.355-1.282-1.283-.074-.073v-17.989h-38.689v23.43l-.146.146.146.147Z" clip-rule="evenodd"/></g><path stroke="#C9C9C9" stroke-width="2.418" d="M600.709 656.704c-31.384 0-56.825-25.441-56.825-56.824 0-31.384 25.441-56.825 56.825-56.825 31.383 0 56.824 25.441 56.824 56.825 0 31.383-25.441 56.824-56.824 56.824Z"/></g><defs><linearGradient id="a" x1="554.061" x2="-.48" y1=".083" y2=".087" gradientUnits="userSpaceOnUse"><stop stop-color="#C9C9C9" stop-opacity="0"/><stop offset=".208" stop-color="#C9C9C9"/><stop offset=".792" stop-color="#C9C9C9"/><stop offset="1" stop-color="#C9C9C9" stop-opacity="0"/></linearGradient><linearGradient id="b" x1="796.912" x2="404.507" y1="599.963" y2="599.965" gradientUnits="userSpaceOnUse"><stop stop-color="#C9C9C9" stop-opacity="0"/><stop offset=".208" stop-color="#C9C9C9"/><stop offset=".792" stop-color="#C9C9C9"/><stop offset="1" stop-color="#C9C9C9" stop-opacity="0"/></linearGradient><linearGradient id="c" x1="600.792" x2="600.794" y1="403.677" y2="796.082" gradientUnits="userSpaceOnUse"><stop stop-color="#C9C9C9" stop-opacity="0"/><stop offset=".208" stop-color="#C9C9C9"/><stop offset=".792" stop-color="#C9C9C9"/><stop offset="1" stop-color="#C9C9C9" stop-opacity="0"/></linearGradient><linearGradient id="d" x1="404.85" x2="796.972" y1="403.903" y2="796.02" gradientUnits="userSpaceOnUse"><stop stop-color="#C9C9C9" stop-opacity="0"/><stop offset=".208" stop-color="#C9C9C9"/><stop offset=".792" stop-color="#C9C9C9"/><stop offset="1" stop-color="#C9C9C9" stop-opacity="0"/></linearGradient><clipPath id="e"><path fill="#fff" d="M581.364 580.535h38.689v38.689h-38.689z"/></clipPath></defs></svg>

After

Width:  |  Height:  |  Size: 3.2 KiB

View File

@ -3,7 +3,32 @@ export interface Crop {
farmId: string;
name: string;
plantedDate: Date;
status: "growing" | "harvested" | "planned";
expectedHarvest?: Date;
status: string;
variety?: string;
area?: string;
healthScore?: number;
progress?: number;
}
export interface CropAnalytics {
cropId: string;
growthProgress: number;
humidity: number;
temperature: number;
sunlight: number;
waterLevel: number;
plantHealth: "good" | "warning" | "critical";
nextAction: string;
nextActionDue: Date;
soilMoisture: number;
windSpeed: string;
rainfall: string;
nutrientLevels: {
nitrogen: number;
phosphorus: number;
potassium: number;
};
}
export interface Farm {
@ -12,4 +37,62 @@ export interface Farm {
location: string;
type: string;
createdAt: Date;
area?: string;
crops: number;
weather?: {
temperature: number;
humidity: number;
rainfall: string;
sunlight: number;
};
}
export interface User {
ID: number;
UUID: string;
Username: string;
Password: string;
Email: string;
CreatedAt: string;
UpdatedAt: string;
Avatar: string;
IsActive: boolean;
}
export type InventoryItem = {
id: number;
name: string;
category: string;
type: string;
quantity: number;
unit: string;
lastUpdated: string;
status: string;
};
export type CreateInventoryItemInput = Omit<InventoryItem, "id" | "lastUpdated" | "status">;
export interface Blog {
id: number;
title: string;
description: string;
date: string;
author: string;
topic: string;
image: string;
readTime: string;
featured: boolean;
content?: string;
tableOfContents?: { id: string; title: string; level: number }[];
relatedArticles?: {
id: number;
title: string;
topic: string;
image: string;
description?: string;
date?: string;
author?: string;
readTime?: string;
featured?: boolean;
}[];
}