feat: use react query to fetch farm

This commit is contained in:
Sosokker 2025-03-07 03:20:22 +07:00
parent 54bc4c33a3
commit ba4c2ce1b4
5 changed files with 208 additions and 212 deletions

View File

@ -1,171 +1,198 @@
import axiosInstance from "./config";
import type { Crop, CropAnalytics, Farm } from "@/types"; import type { Crop, CropAnalytics, Farm } from "@/types";
/** /**
* Fetch mock crop data by id. * Fetch a specific crop by id using axios.
* @param id - The crop identifier. * Falls back to dummy data on error.
* @returns A promise that resolves to a Crop object.
*/ */
export async function fetchCropById(id: string): Promise<Crop> { export async function fetchCropById(id: string): Promise<Crop> {
// Simulate an API delay if needed. try {
return Promise.resolve({ const response = await axiosInstance.get<Crop>(`/api/crops/${id}`);
id, return response.data;
farmId: "1", } catch (error) {
name: "Monthong Durian", // Fallback dummy data
plantedDate: new Date("2024-01-15"), return {
status: "growing", id,
variety: "Premium Grade", farmId: "1",
expectedHarvest: new Date("2024-07-15"), name: "Monthong Durian",
area: "2.5 hectares", plantedDate: new Date("2024-01-15"),
healthScore: 85, status: "growing",
}); variety: "Premium Grade",
expectedHarvest: new Date("2024-07-15"),
area: "2.5 hectares",
healthScore: 85,
};
}
} }
/** /**
* Fetch mock crop analytics data by crop id. * Fetch crop analytics by crop id using axios.
* @param id - The crop identifier. * Returns dummy analytics if the API call fails.
* @returns A promise that resolves to a CropAnalytics object.
*/ */
export async function fetchAnalyticsByCropId(id: string): Promise<CropAnalytics> { export async function fetchAnalyticsByCropId(id: string): Promise<CropAnalytics> {
return Promise.resolve({ try {
cropId: id, const response = await axiosInstance.get<CropAnalytics>(`/api/crops/${id}/analytics`);
growthProgress: 45, return response.data;
humidity: 75, } catch (error) {
temperature: 28, return {
sunlight: 85, cropId: id,
waterLevel: 65, growthProgress: 45,
plantHealth: "good", humidity: 75,
nextAction: "Water the plant", temperature: 28,
nextActionDue: new Date("2024-02-15"), sunlight: 85,
soilMoisture: 70, waterLevel: 65,
windSpeed: "12 km/h", plantHealth: "good",
rainfall: "25mm last week", nextAction: "Water the plant",
nutrientLevels: { nextActionDue: new Date("2024-02-15"),
nitrogen: 80, soilMoisture: 70,
phosphorus: 65, windSpeed: "12 km/h",
potassium: 75, rainfall: "25mm last week",
}, nutrientLevels: {
}); nitrogen: 80,
phosphorus: 65,
potassium: 75,
},
};
}
} }
/** /**
* Simulates an API call to fetch farms. * Fetch an array of farms using axios.
* Introduces a delay and a random error to emulate network conditions. * Simulates a delay and a random error; returns dummy data if the API is unavailable.
*
* @returns A promise that resolves to an array of Farm objects.
*/ */
export async function fetchFarms(): Promise<Farm[]> { export async function fetchFarms(): Promise<Farm[]> {
// Simulate network delay // Simulate network delay
await new Promise((resolve) => setTimeout(resolve, 1000)); await new Promise((resolve) => setTimeout(resolve, 1000));
// Simulate a random error (roughly 1 in 10 chance) try {
if (Math.random() < 0.1) { const response = await axiosInstance.get<Farm[]>("/api/farms");
throw new Error("Failed to fetch farms. Please try again later."); 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,
},
];
} }
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 an API call to fetch farm details along with its crops. * Simulates creating a new farm.
* This function adds a delay and randomly generates an error to mimic real-world conditions. * Waits for 800ms and then uses dummy data.
* */
* @param farmId - The unique identifier of the farm to retrieve. export async function createFarm(data: Partial<Farm>): Promise<Farm> {
* @returns A promise resolving with an object that contains the farm details and an array of crops. await new Promise((resolve) => setTimeout(resolve, 800));
* @throws An error if the simulated network call fails or if the farm is not found. // 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[] }> { export async function fetchFarmDetails(farmId: string): Promise<{ farm: Farm; crops: Crop[] }> {
// Simulate network delay // Simulate network delay
await new Promise((resolve) => setTimeout(resolve, 1200)); await new Promise((resolve) => setTimeout(resolve, 1200));
// Randomly simulate an error (about 1 in 10 chance) try {
if (Math.random() < 0.1) { const response = await axiosInstance.get<{ farm: Farm; crops: Crop[] }>(`/api/farms/${farmId}`);
throw new Error("Failed to fetch farm details. Please try again later."); 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 };
} }
// Simulate a not found error if the given farmId is "999"
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,
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 };
} }

View File

@ -18,7 +18,7 @@ const farmFormSchema = z.object({
area: z.string().optional(), area: z.string().optional(),
}); });
interface AddFarmFormProps { export interface AddFarmFormProps {
onSubmit: (data: Partial<Farm>) => Promise<void>; onSubmit: (data: Partial<Farm>) => Promise<void>;
onCancel: () => void; onCancel: () => void;
} }

View File

@ -63,7 +63,7 @@ export function FarmCard({ variant, farm, onClick }: FarmCardProps) {
<Sprout className="h-5 w-5 text-green-600" /> <Sprout className="h-5 w-5 text-green-600" />
</div> </div>
<div> <div>
<h3 className="text-xl font-medium mb-1">{farm.name}</h3> <h3 className="text-xl font-medium mb-1 truncate">{farm.name}</h3>
<div className="flex items-center text-sm text-muted-foreground mb-2"> <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" /> <MapPin className="h-3.5 w-3.5 mr-1 flex-shrink-0" />
<span className="truncate">{farm.location}</span> <span className="truncate">{farm.location}</span>

View File

@ -1,9 +1,10 @@
"use client"; "use client";
import { useState, useEffect } from "react"; import { useState } from "react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { motion, AnimatePresence } from "framer-motion"; import { motion, AnimatePresence } from "framer-motion";
import { Search, Plus, Filter, SlidersHorizontal, Leaf, Calendar, AlertTriangle, Loader2 } from "lucide-react"; 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 { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from "@/components/ui/dialog";
import { Separator } from "@/components/ui/separator"; import { Separator } from "@/components/ui/separator";
@ -23,70 +24,37 @@ import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { FarmCard } from "./farm-card"; import { FarmCard } from "./farm-card";
import { AddFarmForm } from "./add-farm-form"; import { AddFarmForm } from "./add-farm-form";
import type { Farm } from "@/types"; import type { Farm } from "@/types";
import { fetchFarms } from "@/api/farm"; import { fetchFarms, createFarm } from "@/api/farm";
/**
* FarmSetupPage component allows users to search, filter, sort, and add farms.
*/
export default function FarmSetupPage() { export default function FarmSetupPage() {
const router = useRouter(); const router = useRouter();
const queryClient = useQueryClient();
// Component state
const [isDialogOpen, setIsDialogOpen] = useState(false);
const [searchQuery, setSearchQuery] = useState(""); const [searchQuery, setSearchQuery] = useState("");
const [farms, setFarms] = useState<Farm[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [activeFilter, setActiveFilter] = useState<string>("all"); const [activeFilter, setActiveFilter] = useState<string>("all");
const [sortOrder, setSortOrder] = useState<"newest" | "oldest" | "alphabetical">("newest"); const [sortOrder, setSortOrder] = useState<"newest" | "oldest" | "alphabetical">("newest");
const [isDialogOpen, setIsDialogOpen] = useState(false);
// Load farms when the component mounts. const {
useEffect(() => { data: farms,
async function loadFarms() { isLoading,
try { isError,
setIsLoading(true); error,
setError(null); } = useQuery<Farm[]>({
const data = await fetchFarms(); queryKey: ["farms"],
setFarms(data); queryFn: fetchFarms,
} catch (err) { staleTime: 60 * 1000,
setError(err instanceof Error ? err.message : "An unknown error occurred"); });
} finally {
setIsLoading(false);
}
}
loadFarms(); const mutation = useMutation({
}, []); mutationFn: (data: Partial<Farm>) => createFarm(data),
onSuccess: () => {
/** queryClient.invalidateQueries({ queryKey: ["farms"] });
* Handles adding a new farm.
*
* @param data - Partial Farm data from the form.
*/
const handleAddFarm = async (data: Partial<Farm>) => {
try {
// Simulate an API call delay for adding a new farm.
await new Promise((resolve) => setTimeout(resolve, 800));
const newFarm: Farm = {
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,
};
setFarms((prev) => [newFarm, ...prev]);
setIsDialogOpen(false); setIsDialogOpen(false);
} catch (err) { },
setError("Failed to add farm. Please try again."); });
}
};
// Filter and sort farms based on the current state. const filteredAndSortedFarms = (farms || [])
const filteredAndSortedFarms = farms
.filter( .filter(
(farm) => (farm) =>
(activeFilter === "all" || farm.type === activeFilter) && (activeFilter === "all" || farm.type === activeFilter) &&
@ -96,16 +64,20 @@ export default function FarmSetupPage() {
) )
.sort((a, b) => { .sort((a, b) => {
if (sortOrder === "newest") { if (sortOrder === "newest") {
return b.createdAt.getTime() - a.createdAt.getTime(); return new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime();
} else if (sortOrder === "oldest") { } else if (sortOrder === "oldest") {
return a.createdAt.getTime() - b.createdAt.getTime(); return new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime();
} else { } else {
return a.name.localeCompare(b.name); return a.name.localeCompare(b.name);
} }
}); });
// Get available farm types for filters. // Get distinct farm types.
const farmTypes = ["all", ...new Set(farms.map((farm) => farm.type))]; const farmTypes = ["all", ...new Set((farms || []).map((farm) => farm.type))];
const handleAddFarm = async (data: Partial<Farm>) => {
await mutation.mutateAsync(data);
};
return ( return (
<div className="min-h-screen bg-gradient-to-b"> <div className="min-h-screen bg-gradient-to-b">
@ -187,11 +159,11 @@ export default function FarmSetupPage() {
<Separator className="my-2" /> <Separator className="my-2" />
{/* Error state */} {/* Error state */}
{error && ( {isError && (
<Alert variant="destructive"> <Alert variant="destructive">
<AlertTriangle className="h-4 w-4" /> <AlertTriangle className="h-4 w-4" />
<AlertTitle>Error</AlertTitle> <AlertTitle>Error</AlertTitle>
<AlertDescription>{error}</AlertDescription> <AlertDescription>{(error as Error)?.message}</AlertDescription>
</Alert> </Alert>
)} )}
@ -204,7 +176,7 @@ export default function FarmSetupPage() {
)} )}
{/* Empty state */} {/* Empty state */}
{!isLoading && !error && filteredAndSortedFarms.length === 0 && ( {!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="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"> <div className="bg-green-100 p-3 rounded-full mb-4">
<Leaf className="h-6 w-6 text-green-600" /> <Leaf className="h-6 w-6 text-green-600" />
@ -223,7 +195,7 @@ export default function FarmSetupPage() {
onClick={() => { onClick={() => {
setSearchQuery(""); setSearchQuery("");
setActiveFilter("all"); setActiveFilter("all");
if (!farms.length) { if (!farms || farms.length === 0) {
setIsDialogOpen(true); setIsDialogOpen(true);
} }
}} }}
@ -241,7 +213,7 @@ export default function FarmSetupPage() {
)} )}
{/* Grid of farm cards */} {/* Grid of farm cards */}
{!isLoading && !error && filteredAndSortedFarms.length > 0 && ( {!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"> <div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
<AnimatePresence> <AnimatePresence>
<motion.div <motion.div
@ -252,7 +224,6 @@ export default function FarmSetupPage() {
className="col-span-1"> className="col-span-1">
<FarmCard variant="add" onClick={() => setIsDialogOpen(true)} /> <FarmCard variant="add" onClick={() => setIsDialogOpen(true)} />
</motion.div> </motion.div>
{filteredAndSortedFarms.map((farm, index) => ( {filteredAndSortedFarms.map((farm, index) => (
<motion.div <motion.div
key={farm.id} key={farm.id}
@ -285,9 +256,7 @@ export default function FarmSetupPage() {
} }
/** /**
* A helper component to render the Check icon. * A helper component for the Check icon.
*
* @param props - Optional className for custom styling.
*/ */
function Check({ className }: { className?: string }) { function Check({ className }: { className?: string }) {
return ( return (

View File

@ -170,7 +170,7 @@ export default function KnowledgeHubPage() {
</CardContent> </CardContent>
<CardFooter className="p-4 pt-0 flex justify-between items-center"> <CardFooter className="p-4 pt-0 flex justify-between items-center">
<div className="text-sm text-muted-foreground">By {blog.author}</div> <div className="text-sm text-muted-foreground">By {blog.author}</div>
<Link href={`/blog/${blog.id}`}> <Link href={`/hub/${blog.id}`}>
<Button variant="ghost" size="sm" className="gap-1"> <Button variant="ghost" size="sm" className="gap-1">
Read <ChevronRight className="h-4 w-4" /> Read <ChevronRight className="h-4 w-4" />
</Button> </Button>