mirror of
https://github.com/ForFarmTeam/ForFarm.git
synced 2025-12-19 05:54:08 +01:00
feat: add setting and profile setting page
This commit is contained in:
parent
8b83a26c15
commit
aa6d434dfd
@ -11,6 +11,8 @@ import (
|
|||||||
"github.com/forfarm/backend/internal/domain"
|
"github.com/forfarm/backend/internal/domain"
|
||||||
"github.com/forfarm/backend/internal/utilities"
|
"github.com/forfarm/backend/internal/utilities"
|
||||||
"github.com/go-chi/chi/v5"
|
"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) {
|
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"`
|
Authorization string `header:"Authorization" required:"true" example:"Bearer token"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// getSelfDataOutput uses domain.User which now has camelCase tags
|
|
||||||
type getSelfDataOutput struct {
|
type getSelfDataOutput struct {
|
||||||
Body struct {
|
Body struct {
|
||||||
User domain.User `json:"user"`
|
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) {
|
func (a *api) getSelfData(ctx context.Context, input *getSelfDataInput) (*getSelfDataOutput, error) {
|
||||||
resp := &getSelfDataOutput{}
|
resp := &getSelfDataOutput{}
|
||||||
|
|
||||||
@ -71,3 +85,70 @@ func (a *api) getSelfData(ctx context.Context, input *getSelfDataInput) (*getSel
|
|||||||
resp.Body.User = user
|
resp.Body.User = user
|
||||||
return resp, nil
|
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";
|
// "use client";
|
||||||
import { useState } from "react";
|
// import { useState } from "react";
|
||||||
import PlantingDetailsForm from "./planting-detail-form";
|
// import PlantingDetailsForm from "./planting-detail-form";
|
||||||
import HarvestDetailsForm from "./harvest-detail-form";
|
// import HarvestDetailsForm from "./harvest-detail-form";
|
||||||
import GoogleMapWithDrawing from "@/components/google-map-with-drawing";
|
// import GoogleMapWithDrawing from "@/components/google-map-with-drawing";
|
||||||
import { Separator } from "@/components/ui/separator";
|
// import { Separator } from "@/components/ui/separator";
|
||||||
import {
|
// import {
|
||||||
plantingDetailsFormSchema,
|
// plantingDetailsFormSchema,
|
||||||
harvestDetailsFormSchema,
|
// harvestDetailsFormSchema,
|
||||||
} from "@/schemas/application.schema";
|
// } from "@/schemas/application.schema";
|
||||||
import { z } from "zod";
|
// import { z } from "zod";
|
||||||
import { Button } from "@/components/ui/button";
|
// import { Button } from "@/components/ui/button";
|
||||||
import { toast } from "sonner";
|
// import { toast } from "sonner";
|
||||||
|
|
||||||
|
import { redirect } from "next/navigation";
|
||||||
|
|
||||||
type PlantingSchema = z.infer<typeof plantingDetailsFormSchema>;
|
// type PlantingSchema = z.infer<typeof plantingDetailsFormSchema>;
|
||||||
type HarvestSchema = z.infer<typeof harvestDetailsFormSchema>;
|
// type HarvestSchema = z.infer<typeof harvestDetailsFormSchema>;
|
||||||
|
|
||||||
const steps = [
|
// const steps = [
|
||||||
{ title: "Step 1", description: "Planting Details" },
|
// { title: "Step 1", description: "Planting Details" },
|
||||||
{ title: "Step 2", description: "Harvest Details" },
|
// { title: "Step 2", description: "Harvest Details" },
|
||||||
{ title: "Step 3", description: "Select Map Area" },
|
// { 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() {
|
export default function SetupPage() {
|
||||||
const [step, setStep] = useState(1);
|
// redirect to /farms
|
||||||
const [plantingDetails, setPlantingDetails] = useState<PlantingSchema | null>(
|
redirect("/farms");
|
||||||
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>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -10,21 +10,21 @@ import {
|
|||||||
GalleryVerticalEnd,
|
GalleryVerticalEnd,
|
||||||
Map,
|
Map,
|
||||||
PieChart,
|
PieChart,
|
||||||
Settings2,
|
Settings,
|
||||||
SquareTerminal,
|
SquareTerminal,
|
||||||
User,
|
UserCircle,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
|
||||||
import { NavMain } from "./nav-main";
|
import { NavMain } from "./nav-main";
|
||||||
import { NavUser } from "./nav-user";
|
import { NavUser } from "./nav-user";
|
||||||
import { TeamSwitcher } from "./team-switcher";
|
import { Sidebar, SidebarContent, SidebarHeader, SidebarRail } from "@/components/ui/sidebar";
|
||||||
import { Sidebar, SidebarContent, SidebarFooter, SidebarHeader, SidebarRail } from "@/components/ui/sidebar";
|
// import { NavCrops } from "./nav-crops";
|
||||||
import { NavCrops } from "./nav-crops";
|
|
||||||
import { fetchUserMe } from "@/api/user";
|
import { fetchUserMe } from "@/api/user";
|
||||||
|
import { usePathname } from "next/navigation";
|
||||||
|
|
||||||
interface Team {
|
interface Team {
|
||||||
name: string;
|
name: string;
|
||||||
logo: React.ComponentType;
|
logo: React.ComponentType<{ className?: string }>; // Ensure logo type accepts className
|
||||||
plan: string;
|
plan: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -34,12 +34,13 @@ interface NavItem {
|
|||||||
title: string;
|
title: string;
|
||||||
url: string;
|
url: string;
|
||||||
icon: LucideIcon;
|
icon: LucideIcon;
|
||||||
|
isActive?: boolean; // Add isActive property
|
||||||
}
|
}
|
||||||
|
|
||||||
interface SidebarConfig {
|
interface SidebarConfig {
|
||||||
teams: Team[];
|
teams: Team[];
|
||||||
navMain: NavItem[];
|
navMain: NavItem[];
|
||||||
crops: NavItem[];
|
// crops: NavItem[];
|
||||||
}
|
}
|
||||||
|
|
||||||
interface AppSidebarProps extends React.ComponentProps<typeof Sidebar> {
|
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) {
|
export function AppSidebar({ config, ...props }: AppSidebarProps) {
|
||||||
|
const pathname = usePathname();
|
||||||
const defaultConfig: SidebarConfig = {
|
const defaultConfig: SidebarConfig = {
|
||||||
teams: [
|
teams: [
|
||||||
{ name: "Farm 1", logo: GalleryVerticalEnd, plan: "Hatyai" },
|
{ name: "Farm 1", logo: GalleryVerticalEnd, plan: "Hatyai" },
|
||||||
{ name: "Farm 2", logo: AudioWaveform, plan: "Songkla" },
|
{ name: "Farm 2", logo: AudioWaveform, plan: "Songkla" },
|
||||||
{ name: "Farm 3", logo: Command, plan: "Layong" },
|
{ name: "Farm 3", logo: Command, plan: "Layong" },
|
||||||
],
|
],
|
||||||
navMain: [
|
navMain: defaultNavMain.map((item) => ({
|
||||||
{ title: "Farms", url: "/farms", icon: Map },
|
...item,
|
||||||
{ title: "Inventory", url: "/inventory", icon: SquareTerminal },
|
isActive: pathname.startsWith(item.url) && (item.url !== "/" || pathname === "/"),
|
||||||
{ 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
|
// Allow external configuration override
|
||||||
@ -107,17 +109,18 @@ export function AppSidebar({ config, ...props }: AppSidebarProps) {
|
|||||||
async function getUser() {
|
async function getUser() {
|
||||||
try {
|
try {
|
||||||
const data = await fetchUserMe();
|
const data = await fetchUserMe();
|
||||||
console.log(data);
|
console.log("Fetched user data:", data);
|
||||||
setUser({
|
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,
|
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) {
|
} catch (err: unknown) {
|
||||||
|
console.error("Failed to fetch user for sidebar:", err);
|
||||||
if (err instanceof Error) {
|
if (err instanceof Error) {
|
||||||
setError(err.message);
|
setError(err.message);
|
||||||
} else {
|
} else {
|
||||||
setError("An unexpected error occurred");
|
setError("An unexpected error occurred fetching user data");
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
@ -129,17 +132,14 @@ export function AppSidebar({ config, ...props }: AppSidebarProps) {
|
|||||||
return (
|
return (
|
||||||
<Sidebar collapsible="icon" {...props}>
|
<Sidebar collapsible="icon" {...props}>
|
||||||
<SidebarHeader>
|
<SidebarHeader>
|
||||||
<TeamSwitcher teams={sidebarConfig.teams} />
|
{loading ? <UserSkeleton /> : error ? <UserErrorFallback message={error} /> : <NavUser user={user} />}
|
||||||
</SidebarHeader>
|
</SidebarHeader>
|
||||||
<SidebarContent>
|
<SidebarContent>
|
||||||
<NavMain items={sidebarConfig.navMain} />
|
<NavMain items={sidebarConfig.navMain} />
|
||||||
<div className="mt-6">
|
|
||||||
<NavCrops crops={sidebarConfig.crops} />
|
|
||||||
</div>
|
|
||||||
</SidebarContent>
|
</SidebarContent>
|
||||||
<SidebarFooter>
|
{/* <SidebarFooter>
|
||||||
{loading ? <UserSkeleton /> : error ? <UserErrorFallback message={error} /> : <NavUser user={user} />}
|
{loading ? <UserSkeleton /> : error ? <UserErrorFallback message={error} /> : <NavUser user={user} />}
|
||||||
</SidebarFooter>
|
</SidebarFooter> */}
|
||||||
<SidebarRail />
|
<SidebarRail />
|
||||||
</Sidebar>
|
</Sidebar>
|
||||||
);
|
);
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user