diff --git a/backend/internal/api/user.go b/backend/internal/api/user.go index 8dd914e..87e1fb1 100644 --- a/backend/internal/api/user.go +++ b/backend/internal/api/user.go @@ -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 +} diff --git a/frontend/api/profile.ts b/frontend/api/profile.ts new file mode 100644 index 0000000..adcce02 --- /dev/null +++ b/frontend/api/profile.ts @@ -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 { + 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." + ); + } +} diff --git a/frontend/app/(sidebar)/profile/page.tsx b/frontend/app/(sidebar)/profile/page.tsx new file mode 100644 index 0000000..4300776 --- /dev/null +++ b/frontend/app/(sidebar)/profile/page.tsx @@ -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; + +export default function ProfilePage() { + const queryClient = useQueryClient(); + const [isEditing, setIsEditing] = useState(false); + + // Fetch current user data + const { + data: userData, + isLoading, + isError, + error, + } = useQuery({ + queryKey: ["userMe"], + queryFn: fetchUserMe, + staleTime: 5 * 60 * 1000, // Cache for 5 minutes + }); + + const user = userData?.user; + + // Setup react-hook-form + const form = useForm({ + 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 ( +
+ + + + + + +
+ +
+ + +
+
+ + +
+
+
+ ); + } + + if (isError) { + return
Error loading profile: {(error as Error)?.message}
; + } + + if (!user) { + return
User data not found.
; + } + + return ( +
+

User Profile

+ + +
+
+ Account Information + View and manage your personal details. +
+ +
+
+ +
+ +
+ {/* Avatar Section (Placeholder for upload) */} +
+ + + + {user.username ? user.username.charAt(0).toUpperCase() : user.email.charAt(0).toUpperCase()} + + + +
+ +
+ {/* Username Field */} + ( + + + + + + + + )} + /> + + {/* Email Field (Read-only) */} +
+ + +

Email cannot be changed currently.

+
+ + {/* User ID (Read-only) */} +
+ + +
+
+
+ + {/* Save Button (Visible only in edit mode) */} + {isEditing && ( + <> + +
+ +
+ + )} + + +
+
+
+ ); +} diff --git a/frontend/app/(sidebar)/settings/page.tsx b/frontend/app/(sidebar)/settings/page.tsx new file mode 100644 index 0000000..f4f4e78 --- /dev/null +++ b/frontend/app/(sidebar)/settings/page.tsx @@ -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 ( +
+

Settings

+ + {/* Appearance Settings */} + + + + Appearance + + Customize the look and feel of the application. + + +
+ + +
+ {/* Add other appearance settings here if needed */} +
+
+ + {/* Account Settings */} + + + + Account + + Manage your account details and security. + + + + + + + + + +
+

Danger Zone

+

+ Permanently delete your account and all associated data. This action cannot be undone. +

+
+ +
+
+
+ ); +} diff --git a/frontend/app/(sidebar)/setup/page.tsx b/frontend/app/(sidebar)/setup/page.tsx index 4febef6..8ad49b7 100644 --- a/frontend/app/(sidebar)/setup/page.tsx +++ b/frontend/app/(sidebar)/setup/page.tsx @@ -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; -type HarvestSchema = z.infer; +// type PlantingSchema = z.infer; +// type HarvestSchema = z.infer; -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( +// null +// ); +// const [harvestDetails, setHarvestDetails] = useState( +// 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 ( +//
+// {/* Stepper Navigation */} +//
+// {steps.map((item, index) => ( +//
+//
+// {index + 1} +//
+// {item.title} +// {item.description} +//
+// ))} +//
+ +// + +// {step === 1 && ( +// <> +//

Planting Details

+// +// +// )} + +// {step === 2 && ( +// <> +//

Harvest Details

+// +// +// )} + +// {step === 3 && ( +// <> +//

Select Area on Map

+// +// +// )} + +//
+// + +// {step < 3 ? ( +// +// ) : ( +// +// )} +//
+//
+// ); +// } export default function SetupPage() { - const [step, setStep] = useState(1); - const [plantingDetails, setPlantingDetails] = useState( - null - ); - const [harvestDetails, setHarvestDetails] = useState( - 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 ( -
- {/* Stepper Navigation */} -
- {steps.map((item, index) => ( -
-
- {index + 1} -
- {item.title} - {item.description} -
- ))} -
- - - - {step === 1 && ( - <> -

Planting Details

- - - )} - - {step === 2 && ( - <> -

Harvest Details

- - - )} - - {step === 3 && ( - <> -

Select Area on Map

- - - )} - -
- - - {step < 3 ? ( - - ) : ( - - )} -
-
- ); + // redirect to /farms + redirect("/farms"); } diff --git a/frontend/components/sidebar/app-sidebar.tsx b/frontend/components/sidebar/app-sidebar.tsx index 638d867..1fbf82f 100644 --- a/frontend/components/sidebar/app-sidebar.tsx +++ b/frontend/components/sidebar/app-sidebar.tsx @@ -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 { @@ -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 ( - + {loading ? : error ? : } -
- -
- + {/* {loading ? : error ? : } - + */}
);