mirror of
https://github.com/ForFarmTeam/ForFarm.git
synced 2025-12-18 13:34:08 +01:00
Merge pull request #27 from ForFarmTeam/feature-profile-settings
feat: add setting and profile setting page
This commit is contained in:
commit
3dc5f37436
@ -11,6 +11,8 @@ import (
|
||||
"github.com/forfarm/backend/internal/domain"
|
||||
"github.com/forfarm/backend/internal/utilities"
|
||||
"github.com/go-chi/chi/v5"
|
||||
validation "github.com/go-ozzo/ozzo-validation/v4"
|
||||
"github.com/jackc/pgx/v5"
|
||||
)
|
||||
|
||||
func (a *api) registerUserRoutes(_ chi.Router, api huma.API) {
|
||||
@ -29,13 +31,25 @@ type getSelfDataInput struct {
|
||||
Authorization string `header:"Authorization" required:"true" example:"Bearer token"`
|
||||
}
|
||||
|
||||
// getSelfDataOutput uses domain.User which now has camelCase tags
|
||||
type getSelfDataOutput struct {
|
||||
Body struct {
|
||||
User domain.User `json:"user"`
|
||||
}
|
||||
}
|
||||
|
||||
type UpdateSelfDataInput struct {
|
||||
Authorization string `header:"Authorization" required:"true" example:"Bearer token"`
|
||||
Body struct {
|
||||
Username *string `json:"username,omitempty"`
|
||||
}
|
||||
}
|
||||
|
||||
type UpdateSelfDataOutput struct {
|
||||
Body struct {
|
||||
User domain.User `json:"user"`
|
||||
}
|
||||
}
|
||||
|
||||
func (a *api) getSelfData(ctx context.Context, input *getSelfDataInput) (*getSelfDataOutput, error) {
|
||||
resp := &getSelfDataOutput{}
|
||||
|
||||
@ -71,3 +85,70 @@ func (a *api) getSelfData(ctx context.Context, input *getSelfDataInput) (*getSel
|
||||
resp.Body.User = user
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
func (a *api) updateSelfData(ctx context.Context, input *UpdateSelfDataInput) (*UpdateSelfDataOutput, error) {
|
||||
userID, err := a.getUserIDFromHeader(input.Authorization)
|
||||
if err != nil {
|
||||
return nil, huma.Error401Unauthorized("Authentication failed", err)
|
||||
}
|
||||
|
||||
user, err := a.userRepo.GetByUUID(ctx, userID)
|
||||
if err != nil {
|
||||
if errors.Is(err, domain.ErrNotFound) || errors.Is(err, pgx.ErrNoRows) {
|
||||
a.logger.Warn("Attempt to update non-existent user", "user_uuid", userID)
|
||||
return nil, huma.Error404NotFound("User not found")
|
||||
}
|
||||
a.logger.Error("Failed to get user for update", "user_uuid", userID, "error", err)
|
||||
return nil, huma.Error500InternalServerError("Failed to retrieve user for update")
|
||||
}
|
||||
|
||||
updated := false
|
||||
if input.Body.Username != nil {
|
||||
trimmedUsername := strings.TrimSpace(*input.Body.Username)
|
||||
if trimmedUsername != user.Username {
|
||||
err := validation.Validate(trimmedUsername,
|
||||
validation.Required.Error("username cannot be empty if provided"),
|
||||
validation.Length(3, 30).Error("username must be between 3 and 30 characters"),
|
||||
)
|
||||
if err != nil {
|
||||
return nil, huma.Error422UnprocessableEntity("Invalid username", err)
|
||||
}
|
||||
user.Username = trimmedUsername
|
||||
updated = true
|
||||
}
|
||||
}
|
||||
// Check other field here la
|
||||
|
||||
if !updated {
|
||||
a.logger.Info("No changes detected for user update", "user_uuid", userID)
|
||||
return &UpdateSelfDataOutput{Body: struct {
|
||||
User domain.User `json:"user"`
|
||||
}{User: user}}, nil
|
||||
}
|
||||
|
||||
// Validate the *entire* user object after updates (optional but good practice)
|
||||
// if err := user.Validate(); err != nil {
|
||||
// return nil, huma.Error422UnprocessableEntity("Validation failed after update", err)
|
||||
// }
|
||||
|
||||
// Save updated user
|
||||
err = a.userRepo.CreateOrUpdate(ctx, &user)
|
||||
if err != nil {
|
||||
a.logger.Error("Failed to update user in database", "user_uuid", userID, "error", err)
|
||||
return nil, huma.Error500InternalServerError("Failed to save user profile")
|
||||
}
|
||||
|
||||
a.logger.Info("User profile updated successfully", "user_uuid", userID)
|
||||
|
||||
updatedUser, fetchErr := a.userRepo.GetByUUID(ctx, userID)
|
||||
if fetchErr != nil {
|
||||
a.logger.Error("Failed to fetch user data after update", "user_uuid", userID, "error", fetchErr)
|
||||
return &UpdateSelfDataOutput{Body: struct {
|
||||
User domain.User `json:"user"`
|
||||
}{User: user}}, nil
|
||||
}
|
||||
|
||||
return &UpdateSelfDataOutput{Body: struct {
|
||||
User domain.User `json:"user"`
|
||||
}{User: updatedUser}}, nil
|
||||
}
|
||||
|
||||
29
frontend/api/profile.ts
Normal file
29
frontend/api/profile.ts
Normal file
@ -0,0 +1,29 @@
|
||||
import axiosInstance from "./config";
|
||||
import type { User } from "@/types";
|
||||
|
||||
export interface UpdateUserProfileInput {
|
||||
username?: string;
|
||||
// email?: string;
|
||||
// avatar?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the current user's profile information.
|
||||
* Sends a PUT request to the /user/me endpoint.
|
||||
* @param data - An object containing the fields to update.
|
||||
* @returns The updated user data from the backend.
|
||||
*/
|
||||
export async function updateUserProfile(data: UpdateUserProfileInput): Promise<User> {
|
||||
try {
|
||||
// Backend expects { user: ... } in the response body
|
||||
const response = await axiosInstance.put<{ user: User }>("/user/me", data);
|
||||
return response.data.user;
|
||||
} catch (error) {
|
||||
console.error("Error updating user profile:", error);
|
||||
throw new Error(
|
||||
(error as any).response?.data?.detail ||
|
||||
(error as any).response?.data?.message ||
|
||||
"Failed to update profile. Please try again."
|
||||
);
|
||||
}
|
||||
}
|
||||
269
frontend/app/(sidebar)/profile/page.tsx
Normal file
269
frontend/app/(sidebar)/profile/page.tsx
Normal file
@ -0,0 +1,269 @@
|
||||
// frontend/app/(sidebar)/profile/page.tsx
|
||||
"use client";
|
||||
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import * as z from "zod";
|
||||
import { toast } from "sonner";
|
||||
import { Loader2, User as UserIcon, Mail, Save, X, Edit, Camera } from "lucide-react";
|
||||
|
||||
import { fetchUserMe, UserDataOutput } from "@/api/user"; // Fetch function
|
||||
import { updateUserProfile, UpdateUserProfileInput } from "@/api/profile"; // Update function
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Form, FormControl, FormField, FormItem, FormMessage } from "@/components/ui/form";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { Skeleton } from "@/components/ui/skeleton"; // For loading state
|
||||
|
||||
// Schema for editable fields
|
||||
const profileSchema = z.object({
|
||||
username: z
|
||||
.string()
|
||||
.min(3, "Username must be at least 3 characters")
|
||||
.max(30, "Username cannot exceed 30 characters")
|
||||
.optional() // Make it optional if user doesn't have one initially
|
||||
.or(z.literal("")), // Allow empty string
|
||||
});
|
||||
|
||||
type ProfileFormData = z.infer<typeof profileSchema>;
|
||||
|
||||
export default function ProfilePage() {
|
||||
const queryClient = useQueryClient();
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
|
||||
// Fetch current user data
|
||||
const {
|
||||
data: userData,
|
||||
isLoading,
|
||||
isError,
|
||||
error,
|
||||
} = useQuery<UserDataOutput>({
|
||||
queryKey: ["userMe"],
|
||||
queryFn: fetchUserMe,
|
||||
staleTime: 5 * 60 * 1000, // Cache for 5 minutes
|
||||
});
|
||||
|
||||
const user = userData?.user;
|
||||
|
||||
// Setup react-hook-form
|
||||
const form = useForm<ProfileFormData>({
|
||||
resolver: zodResolver(profileSchema),
|
||||
defaultValues: {
|
||||
username: "",
|
||||
},
|
||||
});
|
||||
|
||||
// Populate form when user data loads or edit mode changes
|
||||
useEffect(() => {
|
||||
if (user) {
|
||||
form.reset({
|
||||
username: user.username || "",
|
||||
});
|
||||
}
|
||||
}, [user, isEditing, form.reset]);
|
||||
|
||||
// Mutation for updating profile
|
||||
const mutation = useMutation({
|
||||
mutationFn: updateUserProfile,
|
||||
onSuccess: (updatedUser) => {
|
||||
toast.success("Profile updated successfully!");
|
||||
// Update the cache with the new user data
|
||||
queryClient.setQueryData(["userMe"], { user: updatedUser });
|
||||
setIsEditing(false); // Exit edit mode
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(`Failed to update profile: ${error.message}`);
|
||||
},
|
||||
});
|
||||
|
||||
const handleEditToggle = () => {
|
||||
if (isEditing) {
|
||||
// Reset form to original values if canceling edit
|
||||
form.reset({ username: user?.username || "" });
|
||||
}
|
||||
setIsEditing(!isEditing);
|
||||
};
|
||||
|
||||
const onSubmit = (formData: ProfileFormData) => {
|
||||
const updatePayload: UpdateUserProfileInput = {};
|
||||
// Only include username if it actually changed
|
||||
if (formData.username !== undefined && formData.username !== (user?.username || "")) {
|
||||
// Allow setting to empty string if desired, or add validation to prevent it
|
||||
updatePayload.username = formData.username;
|
||||
}
|
||||
|
||||
if (Object.keys(updatePayload).length > 0) {
|
||||
mutation.mutate(updatePayload);
|
||||
} else {
|
||||
// No changes were made
|
||||
setIsEditing(false); // Just exit edit mode
|
||||
}
|
||||
};
|
||||
|
||||
// --- Render Logic ---
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="p-6 space-y-4">
|
||||
<Skeleton className="h-8 w-1/4 mb-4" />
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<Skeleton className="h-6 w-1/3" />
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="flex items-center space-x-4">
|
||||
<Skeleton className="h-20 w-20 rounded-full" />
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-5 w-48" />
|
||||
<Skeleton className="h-4 w-64" />
|
||||
</div>
|
||||
</div>
|
||||
<Skeleton className="h-10 w-full" />
|
||||
<Skeleton className="h-10 w-full" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isError) {
|
||||
return <div className="p-6 text-destructive">Error loading profile: {(error as Error)?.message}</div>;
|
||||
}
|
||||
|
||||
if (!user) {
|
||||
return <div className="p-6 text-muted-foreground">User data not found.</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-4 md:p-6 space-y-6">
|
||||
<h1 className="text-2xl font-bold">User Profile</h1>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<CardTitle>Account Information</CardTitle>
|
||||
<CardDescription>View and manage your personal details.</CardDescription>
|
||||
</div>
|
||||
<Button
|
||||
variant={isEditing ? "outline" : "default"}
|
||||
size="sm"
|
||||
onClick={handleEditToggle}
|
||||
disabled={mutation.isPending}>
|
||||
{isEditing ? <X className="mr-2 h-4 w-4" /> : <Edit className="mr-2 h-4 w-4" />}
|
||||
{isEditing ? "Cancel" : "Edit Profile"}
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
|
||||
<div className="flex flex-col sm:flex-row items-center gap-6">
|
||||
{/* Avatar Section (Placeholder for upload) */}
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<Avatar className="h-24 w-24 border-2 border-primary/20">
|
||||
<AvatarImage
|
||||
src={user.avatar || `https://api.dicebear.com/9.x/initials/svg?seed=${user.email}`}
|
||||
alt={user.username || user.email}
|
||||
/>
|
||||
<AvatarFallback className="text-lg">
|
||||
{user.username ? user.username.charAt(0).toUpperCase() : user.email.charAt(0).toUpperCase()}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={isEditing || mutation.isPending}
|
||||
onClick={() => toast.info("Avatar upload coming soon!")}>
|
||||
<Camera className="mr-2 h-3 w-3" /> Change
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 space-y-4 w-full">
|
||||
{/* Username Field */}
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="username"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<Label htmlFor="username" className="flex items-center gap-1">
|
||||
<UserIcon className="h-4 w-4 text-muted-foreground" /> Username
|
||||
</Label>
|
||||
<FormControl>
|
||||
<Input
|
||||
id="username"
|
||||
placeholder="Enter your username"
|
||||
{...field}
|
||||
readOnly={!isEditing}
|
||||
className={
|
||||
!isEditing
|
||||
? "border-none bg-transparent px-1 shadow-none read-only:focus-visible:ring-0 read-only:focus-visible:ring-offset-0"
|
||||
: ""
|
||||
}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{/* Email Field (Read-only) */}
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="email" className="flex items-center gap-1">
|
||||
<Mail className="h-4 w-4 text-muted-foreground" /> Email
|
||||
</Label>
|
||||
<Input
|
||||
id="email"
|
||||
type="email"
|
||||
value={user.email}
|
||||
readOnly
|
||||
className="border-none bg-transparent px-1 shadow-none read-only:focus-visible:ring-0 read-only:focus-visible:ring-offset-0"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground px-1">Email cannot be changed currently.</p>
|
||||
</div>
|
||||
|
||||
{/* User ID (Read-only) */}
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="userId" className="flex items-center gap-1">
|
||||
ID
|
||||
</Label>
|
||||
<Input
|
||||
id="userId"
|
||||
value={user.uuid}
|
||||
readOnly
|
||||
className="border-none bg-transparent px-1 shadow-none text-xs text-muted-foreground read-only:focus-visible:ring-0 read-only:focus-visible:ring-offset-0"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Save Button (Visible only in edit mode) */}
|
||||
{isEditing && (
|
||||
<>
|
||||
<Separator />
|
||||
<div className="flex justify-end">
|
||||
<Button type="submit" disabled={mutation.isPending || !form.formState.isDirty}>
|
||||
{mutation.isPending ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" /> Saving...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Save className="mr-2 h-4 w-4" /> Save Changes
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</form>
|
||||
</Form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
94
frontend/app/(sidebar)/settings/page.tsx
Normal file
94
frontend/app/(sidebar)/settings/page.tsx
Normal file
@ -0,0 +1,94 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { useTheme } from "next-themes";
|
||||
import { Card, CardHeader, CardTitle, CardDescription, CardContent, CardFooter } from "@/components/ui/card";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Paintbrush, User, Trash2, ExternalLink } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { toast } from "sonner";
|
||||
|
||||
export default function SettingsPage() {
|
||||
const { theme, setTheme } = useTheme();
|
||||
|
||||
const handleDeleteAccount = () => {
|
||||
toast.warning("Account deletion is not yet implemented.", {
|
||||
description: "This feature will be available in a future update.",
|
||||
action: { label: "Close", onClick: () => toast.dismiss() },
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="p-4 md:p-6 space-y-8">
|
||||
<h1 className="text-2xl font-bold">Settings</h1>
|
||||
|
||||
{/* Appearance Settings */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Paintbrush className="h-5 w-5 text-primary" /> Appearance
|
||||
</CardTitle>
|
||||
<CardDescription>Customize the look and feel of the application.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
|
||||
<Label htmlFor="theme" className="whitespace-nowrap">
|
||||
Theme
|
||||
</Label>
|
||||
<Select value={theme} onValueChange={setTheme}>
|
||||
<SelectTrigger id="theme" className="w-full sm:w-[180px]">
|
||||
<SelectValue placeholder="Select theme" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="light">Light</SelectItem>
|
||||
<SelectItem value="dark">Dark</SelectItem>
|
||||
<SelectItem value="system">System</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
{/* Add other appearance settings here if needed */}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Account Settings */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<User className="h-5 w-5 text-primary" /> Account
|
||||
</CardTitle>
|
||||
<CardDescription>Manage your account details and security.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<Link href="/profile" passHref>
|
||||
<Button variant="outline" className="w-full justify-between">
|
||||
<span>Edit Profile Information</span>
|
||||
<ExternalLink className="h-4 w-4 text-muted-foreground" />
|
||||
</Button>
|
||||
</Link>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full justify-between"
|
||||
onClick={() => toast.info("Password change coming soon!")}>
|
||||
<span>Change Password</span>
|
||||
<ExternalLink className="h-4 w-4 text-muted-foreground" />
|
||||
</Button>
|
||||
</CardContent>
|
||||
<Separator className="my-4" />
|
||||
<CardFooter className="flex flex-col items-start gap-4">
|
||||
<div>
|
||||
<h4 className="font-medium text-destructive">Danger Zone</h4>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
Permanently delete your account and all associated data. This action cannot be undone.
|
||||
</p>
|
||||
</div>
|
||||
<Button variant="destructive" onClick={handleDeleteAccount} className="gap-2">
|
||||
<Trash2 className="h-4 w-4" /> Delete Account
|
||||
</Button>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -1,141 +1,147 @@
|
||||
"use client";
|
||||
import { useState } from "react";
|
||||
import PlantingDetailsForm from "./planting-detail-form";
|
||||
import HarvestDetailsForm from "./harvest-detail-form";
|
||||
import GoogleMapWithDrawing from "@/components/google-map-with-drawing";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import {
|
||||
plantingDetailsFormSchema,
|
||||
harvestDetailsFormSchema,
|
||||
} from "@/schemas/application.schema";
|
||||
import { z } from "zod";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { toast } from "sonner";
|
||||
// "use client";
|
||||
// import { useState } from "react";
|
||||
// import PlantingDetailsForm from "./planting-detail-form";
|
||||
// import HarvestDetailsForm from "./harvest-detail-form";
|
||||
// import GoogleMapWithDrawing from "@/components/google-map-with-drawing";
|
||||
// import { Separator } from "@/components/ui/separator";
|
||||
// import {
|
||||
// plantingDetailsFormSchema,
|
||||
// harvestDetailsFormSchema,
|
||||
// } from "@/schemas/application.schema";
|
||||
// import { z } from "zod";
|
||||
// import { Button } from "@/components/ui/button";
|
||||
// import { toast } from "sonner";
|
||||
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
type PlantingSchema = z.infer<typeof plantingDetailsFormSchema>;
|
||||
type HarvestSchema = z.infer<typeof harvestDetailsFormSchema>;
|
||||
// type PlantingSchema = z.infer<typeof plantingDetailsFormSchema>;
|
||||
// type HarvestSchema = z.infer<typeof harvestDetailsFormSchema>;
|
||||
|
||||
const steps = [
|
||||
{ title: "Step 1", description: "Planting Details" },
|
||||
{ title: "Step 2", description: "Harvest Details" },
|
||||
{ title: "Step 3", description: "Select Map Area" },
|
||||
];
|
||||
// const steps = [
|
||||
// { title: "Step 1", description: "Planting Details" },
|
||||
// { title: "Step 2", description: "Harvest Details" },
|
||||
// { title: "Step 3", description: "Select Map Area" },
|
||||
// ];
|
||||
|
||||
// export default function SetupPage() {
|
||||
// const [step, setStep] = useState(1);
|
||||
// const [plantingDetails, setPlantingDetails] = useState<PlantingSchema | null>(
|
||||
// null
|
||||
// );
|
||||
// const [harvestDetails, setHarvestDetails] = useState<HarvestSchema | null>(
|
||||
// null
|
||||
// );
|
||||
// const [mapData, setMapData] = useState<{ lat: number; lng: number }[] | null>(
|
||||
// null
|
||||
// );
|
||||
|
||||
// const handleNext = () => {
|
||||
// if (step === 1 && !plantingDetails) {
|
||||
// toast.warning(
|
||||
// "Please complete the Planting Details before proceeding.",
|
||||
// {
|
||||
// action: {
|
||||
// label: "Close",
|
||||
// onClick: () => toast.dismiss(),
|
||||
// },
|
||||
// }
|
||||
// );
|
||||
// return;
|
||||
// }
|
||||
// if (step === 2 && !harvestDetails) {
|
||||
// toast.warning(
|
||||
// "Please complete the Harvest Details before proceeding.",
|
||||
// {
|
||||
// action: {
|
||||
// label: "Close",
|
||||
// onClick: () => toast.dismiss(),
|
||||
// },
|
||||
// }
|
||||
// );
|
||||
// return;
|
||||
// }
|
||||
// setStep((prev) => prev + 1);
|
||||
// };
|
||||
|
||||
// const handleBack = () => {
|
||||
// setStep((prev) => prev - 1);
|
||||
// };
|
||||
|
||||
// const handleSubmit = () => {
|
||||
// if (!mapData) {
|
||||
// toast.warning("Please select an area on the map before submitting.", {
|
||||
// action: {
|
||||
// label: "Close",
|
||||
// onClick: () => toast.dismiss(),
|
||||
// },
|
||||
// });
|
||||
// return;
|
||||
// }
|
||||
|
||||
// console.log("Submitting:", { plantingDetails, harvestDetails, mapData });
|
||||
|
||||
// // send request to the server
|
||||
|
||||
// };
|
||||
|
||||
// return (
|
||||
// <div className="p-5">
|
||||
// {/* Stepper Navigation */}
|
||||
// <div className="flex justify-between items-center mb-5">
|
||||
// {steps.map((item, index) => (
|
||||
// <div key={index} className="flex flex-col items-center">
|
||||
// <div
|
||||
// className={`w-10 h-10 flex items-center justify-center rounded-full text-white font-bold ${
|
||||
// step === index + 1 ? "bg-blue-500" : "bg-gray-500"
|
||||
// }`}
|
||||
// >
|
||||
// {index + 1}
|
||||
// </div>
|
||||
// <span className="font-medium mt-2">{item.title}</span>
|
||||
// <span className="text-gray-500 text-sm">{item.description}</span>
|
||||
// </div>
|
||||
// ))}
|
||||
// </div>
|
||||
|
||||
// <Separator className="mb-5" />
|
||||
|
||||
// {step === 1 && (
|
||||
// <>
|
||||
// <h2 className="text-xl text-center mb-5">Planting Details</h2>
|
||||
// <PlantingDetailsForm onChange={setPlantingDetails} />
|
||||
// </>
|
||||
// )}
|
||||
|
||||
// {step === 2 && (
|
||||
// <>
|
||||
// <h2 className="text-xl text-center mb-5">Harvest Details</h2>
|
||||
// <HarvestDetailsForm onChange={setHarvestDetails} />
|
||||
// </>
|
||||
// )}
|
||||
|
||||
// {step === 3 && (
|
||||
// <>
|
||||
// <h2 className="text-xl text-center mb-5">Select Area on Map</h2>
|
||||
// <GoogleMapWithDrawing onAreaSelected={setMapData} />
|
||||
// </>
|
||||
// )}
|
||||
|
||||
// <div className="mt-10 flex justify-between">
|
||||
// <Button onClick={handleBack} disabled={step === 1}>
|
||||
// Back
|
||||
// </Button>
|
||||
|
||||
// {step < 3 ? (
|
||||
// <Button onClick={handleNext}>Next</Button>
|
||||
// ) : (
|
||||
// <Button onClick={handleSubmit}>Submit</Button>
|
||||
// )}
|
||||
// </div>
|
||||
// </div>
|
||||
// );
|
||||
// }
|
||||
|
||||
export default function SetupPage() {
|
||||
const [step, setStep] = useState(1);
|
||||
const [plantingDetails, setPlantingDetails] = useState<PlantingSchema | null>(
|
||||
null
|
||||
);
|
||||
const [harvestDetails, setHarvestDetails] = useState<HarvestSchema | null>(
|
||||
null
|
||||
);
|
||||
const [mapData, setMapData] = useState<{ lat: number; lng: number }[] | null>(
|
||||
null
|
||||
);
|
||||
|
||||
const handleNext = () => {
|
||||
if (step === 1 && !plantingDetails) {
|
||||
toast.warning(
|
||||
"Please complete the Planting Details before proceeding.",
|
||||
{
|
||||
action: {
|
||||
label: "Close",
|
||||
onClick: () => toast.dismiss(),
|
||||
},
|
||||
}
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (step === 2 && !harvestDetails) {
|
||||
toast.warning(
|
||||
"Please complete the Harvest Details before proceeding.",
|
||||
{
|
||||
action: {
|
||||
label: "Close",
|
||||
onClick: () => toast.dismiss(),
|
||||
},
|
||||
}
|
||||
);
|
||||
return;
|
||||
}
|
||||
setStep((prev) => prev + 1);
|
||||
};
|
||||
|
||||
const handleBack = () => {
|
||||
setStep((prev) => prev - 1);
|
||||
};
|
||||
|
||||
const handleSubmit = () => {
|
||||
if (!mapData) {
|
||||
toast.warning("Please select an area on the map before submitting.", {
|
||||
action: {
|
||||
label: "Close",
|
||||
onClick: () => toast.dismiss(),
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
console.log("Submitting:", { plantingDetails, harvestDetails, mapData });
|
||||
|
||||
// send request to the server
|
||||
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="p-5">
|
||||
{/* Stepper Navigation */}
|
||||
<div className="flex justify-between items-center mb-5">
|
||||
{steps.map((item, index) => (
|
||||
<div key={index} className="flex flex-col items-center">
|
||||
<div
|
||||
className={`w-10 h-10 flex items-center justify-center rounded-full text-white font-bold ${
|
||||
step === index + 1 ? "bg-blue-500" : "bg-gray-500"
|
||||
}`}
|
||||
>
|
||||
{index + 1}
|
||||
</div>
|
||||
<span className="font-medium mt-2">{item.title}</span>
|
||||
<span className="text-gray-500 text-sm">{item.description}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<Separator className="mb-5" />
|
||||
|
||||
{step === 1 && (
|
||||
<>
|
||||
<h2 className="text-xl text-center mb-5">Planting Details</h2>
|
||||
<PlantingDetailsForm onChange={setPlantingDetails} />
|
||||
</>
|
||||
)}
|
||||
|
||||
{step === 2 && (
|
||||
<>
|
||||
<h2 className="text-xl text-center mb-5">Harvest Details</h2>
|
||||
<HarvestDetailsForm onChange={setHarvestDetails} />
|
||||
</>
|
||||
)}
|
||||
|
||||
{step === 3 && (
|
||||
<>
|
||||
<h2 className="text-xl text-center mb-5">Select Area on Map</h2>
|
||||
<GoogleMapWithDrawing onAreaSelected={setMapData} />
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="mt-10 flex justify-between">
|
||||
<Button onClick={handleBack} disabled={step === 1}>
|
||||
Back
|
||||
</Button>
|
||||
|
||||
{step < 3 ? (
|
||||
<Button onClick={handleNext}>Next</Button>
|
||||
) : (
|
||||
<Button onClick={handleSubmit}>Submit</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
// redirect to /farms
|
||||
redirect("/farms");
|
||||
}
|
||||
|
||||
@ -10,21 +10,21 @@ import {
|
||||
GalleryVerticalEnd,
|
||||
Map,
|
||||
PieChart,
|
||||
Settings2,
|
||||
Settings,
|
||||
SquareTerminal,
|
||||
User,
|
||||
UserCircle,
|
||||
} from "lucide-react";
|
||||
|
||||
import { NavMain } from "./nav-main";
|
||||
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 { Sidebar, SidebarContent, SidebarHeader, SidebarRail } from "@/components/ui/sidebar";
|
||||
// import { NavCrops } from "./nav-crops";
|
||||
import { fetchUserMe } from "@/api/user";
|
||||
import { usePathname } from "next/navigation";
|
||||
|
||||
interface Team {
|
||||
name: string;
|
||||
logo: React.ComponentType;
|
||||
logo: React.ComponentType<{ className?: string }>; // Ensure logo type accepts className
|
||||
plan: string;
|
||||
}
|
||||
|
||||
@ -34,12 +34,13 @@ interface NavItem {
|
||||
title: string;
|
||||
url: string;
|
||||
icon: LucideIcon;
|
||||
isActive?: boolean; // Add isActive property
|
||||
}
|
||||
|
||||
interface SidebarConfig {
|
||||
teams: Team[];
|
||||
navMain: NavItem[];
|
||||
crops: NavItem[];
|
||||
// crops: NavItem[];
|
||||
}
|
||||
|
||||
interface AppSidebarProps extends React.ComponentProps<typeof Sidebar> {
|
||||
@ -69,27 +70,28 @@ function UserErrorFallback({ message }: { message: string }) {
|
||||
);
|
||||
}
|
||||
|
||||
const defaultNavMain: NavItem[] = [
|
||||
{ title: "Farms", url: "/farms", icon: Map },
|
||||
{ title: "Inventory", url: "/inventory", icon: SquareTerminal },
|
||||
{ title: "Marketplace", url: "/marketplace", icon: PieChart }, // Updated title and icon
|
||||
{ title: "Knowledge Hub", url: "/hub", icon: BookOpen },
|
||||
{ title: "AI Chatbot", url: "/chatbot", icon: Bot },
|
||||
{ title: "Profile", url: "/profile", icon: UserCircle }, // Added Profile
|
||||
{ title: "Settings", url: "/settings", icon: Settings }, // Kept Settings
|
||||
];
|
||||
|
||||
export function AppSidebar({ config, ...props }: AppSidebarProps) {
|
||||
const pathname = usePathname();
|
||||
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 },
|
||||
],
|
||||
navMain: defaultNavMain.map((item) => ({
|
||||
...item,
|
||||
isActive: pathname.startsWith(item.url) && (item.url !== "/" || pathname === "/"),
|
||||
})),
|
||||
};
|
||||
|
||||
// Allow external configuration override
|
||||
@ -107,17 +109,18 @@ export function AppSidebar({ config, ...props }: AppSidebarProps) {
|
||||
async function getUser() {
|
||||
try {
|
||||
const data = await fetchUserMe();
|
||||
console.log(data);
|
||||
console.log("Fetched user data:", data);
|
||||
setUser({
|
||||
name: data.user.uuid,
|
||||
name: data.user.username || data.user.email.split("@")[0] || `User ${data.user.uuid.substring(0, 6)}`,
|
||||
email: data.user.email,
|
||||
avatar: data.user.avatar || "/avatars/avatar.webp",
|
||||
avatar: data.user.avatar || `https://api.dicebear.com/9.x/initials/svg?seed=${data.user.email}`,
|
||||
});
|
||||
} catch (err: unknown) {
|
||||
console.error("Failed to fetch user for sidebar:", err);
|
||||
if (err instanceof Error) {
|
||||
setError(err.message);
|
||||
} else {
|
||||
setError("An unexpected error occurred");
|
||||
setError("An unexpected error occurred fetching user data");
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
@ -129,17 +132,14 @@ export function AppSidebar({ config, ...props }: AppSidebarProps) {
|
||||
return (
|
||||
<Sidebar collapsible="icon" {...props}>
|
||||
<SidebarHeader>
|
||||
<TeamSwitcher teams={sidebarConfig.teams} />
|
||||
{loading ? <UserSkeleton /> : error ? <UserErrorFallback message={error} /> : <NavUser user={user} />}
|
||||
</SidebarHeader>
|
||||
<SidebarContent>
|
||||
<NavMain items={sidebarConfig.navMain} />
|
||||
<div className="mt-6">
|
||||
<NavCrops crops={sidebarConfig.crops} />
|
||||
</div>
|
||||
</SidebarContent>
|
||||
<SidebarFooter>
|
||||
{/* <SidebarFooter>
|
||||
{loading ? <UserSkeleton /> : error ? <UserErrorFallback message={error} /> : <NavUser user={user} />}
|
||||
</SidebarFooter>
|
||||
</SidebarFooter> */}
|
||||
<SidebarRail />
|
||||
</Sidebar>
|
||||
);
|
||||
|
||||
Loading…
Reference in New Issue
Block a user