p 1 and 2 compeleted
This commit is contained in:
1 parent
6adb541967
commit
3573709d99
28 files changed
+2533
-240
No files matched your search
@@ -0,0 +1,477 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { toast } from "@/lib/toast";
|
||||
import {
|
||||
roleSelectionSchema,
|
||||
basicInfoSchema,
|
||||
clientInfoSchema,
|
||||
invitationOptionsSchema,
|
||||
mergeUserCreationData,
|
||||
type RoleSelectionData,
|
||||
type BasicInfoData,
|
||||
type ClientInfoData,
|
||||
type InvitationOptionsData,
|
||||
} from "@/lib/validation/user-schemas";
|
||||
import { ChevronLeft, ChevronRight, Check } from "lucide-react";
|
||||
|
||||
interface CreateUserModalProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onSuccess: () => void;
|
||||
}
|
||||
|
||||
type Step = 1 | 2 | 3 | 4;
|
||||
|
||||
export function CreateUserModal({
|
||||
open,
|
||||
onOpenChange,
|
||||
onSuccess,
|
||||
}: CreateUserModalProps) {
|
||||
const [currentStep, setCurrentStep] = useState<Step>(1);
|
||||
const [roleData, setRoleData] = useState<RoleSelectionData | null>(null);
|
||||
const [basicInfo, setBasicInfo] = useState<BasicInfoData | null>(null);
|
||||
const [clientInfo, setClientInfo] = useState<ClientInfoData | null>(null);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
// Step 1: Role Selection Form
|
||||
const roleForm = useForm<RoleSelectionData>({
|
||||
resolver: zodResolver(roleSelectionSchema),
|
||||
defaultValues: { role: "client" },
|
||||
});
|
||||
|
||||
// Step 2: Basic Info Form
|
||||
const basicForm = useForm<BasicInfoData>({
|
||||
resolver: zodResolver(basicInfoSchema),
|
||||
defaultValues: {
|
||||
firstName: "",
|
||||
lastName: "",
|
||||
phone: "",
|
||||
email: "",
|
||||
},
|
||||
});
|
||||
|
||||
// Step 3: Client Info Form
|
||||
const clientForm = useForm<ClientInfoData>({
|
||||
resolver: zodResolver(clientInfoSchema),
|
||||
defaultValues: {
|
||||
membershipType: "basic",
|
||||
membershipStatus: "active",
|
||||
sendWelcomeEmail: false,
|
||||
},
|
||||
});
|
||||
|
||||
// Step 4: Invitation Options Form
|
||||
const invitationForm = useForm<InvitationOptionsData>({
|
||||
resolver: zodResolver(invitationOptionsSchema),
|
||||
defaultValues: {
|
||||
sendInvitation: true,
|
||||
gymId: "",
|
||||
},
|
||||
});
|
||||
|
||||
const handleRoleNext = async () => {
|
||||
const isValid = await roleForm.trigger();
|
||||
if (isValid) {
|
||||
const data = roleForm.getValues();
|
||||
setRoleData(data);
|
||||
setCurrentStep(2);
|
||||
}
|
||||
};
|
||||
|
||||
const handleBasicNext = async () => {
|
||||
const isValid = await basicForm.trigger();
|
||||
if (isValid) {
|
||||
const data = basicForm.getValues();
|
||||
setBasicInfo(data);
|
||||
|
||||
// Determine next step based on role
|
||||
if (roleData?.role === "client") {
|
||||
setCurrentStep(3);
|
||||
} else {
|
||||
// For admin/trainer, skip client info
|
||||
setCurrentStep(4);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleClientNext = async () => {
|
||||
const isValid = await clientForm.trigger();
|
||||
if (isValid) {
|
||||
const data = clientForm.getValues();
|
||||
setClientInfo(data);
|
||||
|
||||
// If email provided, show invitation options
|
||||
if (basicInfo?.email) {
|
||||
setCurrentStep(4);
|
||||
} else {
|
||||
// No email, submit directly
|
||||
await handleSubmit();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = async (invitationData?: InvitationOptionsData) => {
|
||||
if (!roleData || !basicInfo) return;
|
||||
|
||||
try {
|
||||
setIsSubmitting(true);
|
||||
|
||||
const payload = mergeUserCreationData(
|
||||
roleData,
|
||||
basicInfo,
|
||||
clientInfo || undefined,
|
||||
invitationData,
|
||||
);
|
||||
|
||||
const response = await fetch("/api/users/create", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
toast.error(error.error || "Failed to create user");
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
toast.success(result.message || "User created successfully!");
|
||||
|
||||
// Reset form
|
||||
resetModal();
|
||||
onSuccess();
|
||||
onOpenChange(false);
|
||||
} catch (error) {
|
||||
console.error("Create user error:", error);
|
||||
toast.error("An unexpected error occurred");
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleInvitationSubmit = async () => {
|
||||
const isValid = await invitationForm.trigger();
|
||||
if (isValid) {
|
||||
const data = invitationForm.getValues();
|
||||
await handleSubmit(data);
|
||||
}
|
||||
};
|
||||
|
||||
const resetModal = () => {
|
||||
setCurrentStep(1);
|
||||
setRoleData(null);
|
||||
setBasicInfo(null);
|
||||
setClientInfo(null);
|
||||
roleForm.reset();
|
||||
basicForm.reset();
|
||||
clientForm.reset();
|
||||
invitationForm.reset();
|
||||
};
|
||||
|
||||
const handleBack = () => {
|
||||
if (currentStep === 4 && roleData?.role === "client") {
|
||||
setCurrentStep(3);
|
||||
} else if (currentStep === 4 || currentStep === 3) {
|
||||
setCurrentStep(2);
|
||||
} else if (currentStep === 2) {
|
||||
setCurrentStep(1);
|
||||
}
|
||||
};
|
||||
|
||||
const getStepTitle = () => {
|
||||
switch (currentStep) {
|
||||
case 1:
|
||||
return "Select Role";
|
||||
case 2:
|
||||
return "Basic Information";
|
||||
case 3:
|
||||
return "Membership Details";
|
||||
case 4:
|
||||
return "Invitation Options";
|
||||
default:
|
||||
return "";
|
||||
}
|
||||
};
|
||||
|
||||
const getStepDescription = () => {
|
||||
switch (currentStep) {
|
||||
case 1:
|
||||
return "Choose the role for this user";
|
||||
case 2:
|
||||
return "Enter user's basic information";
|
||||
case 3:
|
||||
return "Configure client membership";
|
||||
case 4:
|
||||
return basicInfo?.email
|
||||
? "Choose how to notify the user"
|
||||
: "Review and create";
|
||||
default:
|
||||
return "";
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-[500px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{getStepTitle()}</DialogTitle>
|
||||
<DialogDescription>{getStepDescription()}</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{/* Step Progress Indicator */}
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
{[1, 2, 3, 4].map((step) => (
|
||||
<div
|
||||
key={step}
|
||||
className={`flex items-center justify-center w-8 h-8 rounded-full text-sm font-medium ${
|
||||
step < currentStep
|
||||
? "bg-green-500 text-white"
|
||||
: step === currentStep
|
||||
? "bg-blue-500 text-white"
|
||||
: "bg-gray-200 text-gray-600"
|
||||
}`}
|
||||
>
|
||||
{step < currentStep ? <Check className="w-4 h-4" /> : step}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Step 1: Role Selection */}
|
||||
{currentStep === 1 && (
|
||||
<form
|
||||
onSubmit={roleForm.handleSubmit(handleRoleNext)}
|
||||
className="space-y-4"
|
||||
>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">Role *</label>
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
{(["admin", "trainer", "client"] as const).map((role) => (
|
||||
<label
|
||||
key={role}
|
||||
className={`flex items-center justify-center p-4 border-2 rounded-lg cursor-pointer transition-colors ${
|
||||
roleForm.watch("role") === role
|
||||
? "border-blue-500 bg-blue-50"
|
||||
: "border-gray-200 hover:border-gray-300"
|
||||
}`}
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
value={role}
|
||||
{...roleForm.register("role")}
|
||||
className="sr-only"
|
||||
/>
|
||||
<span className="text-sm font-medium capitalize">
|
||||
{role}
|
||||
</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
{roleForm.formState.errors.role && (
|
||||
<p className="text-sm text-red-500">
|
||||
{roleForm.formState.errors.role.message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button type="submit" className="w-full">
|
||||
Next <ChevronRight className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
)}
|
||||
|
||||
{/* Step 2: Basic Information */}
|
||||
{currentStep === 2 && (
|
||||
<form
|
||||
onSubmit={basicForm.handleSubmit(handleBasicNext)}
|
||||
className="space-y-4"
|
||||
>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">First Name *</label>
|
||||
<Input {...basicForm.register("firstName")} placeholder="John" />
|
||||
{basicForm.formState.errors.firstName && (
|
||||
<p className="text-sm text-red-500">
|
||||
{basicForm.formState.errors.firstName.message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">Last Name *</label>
|
||||
<Input {...basicForm.register("lastName")} placeholder="Doe" />
|
||||
{basicForm.formState.errors.lastName && (
|
||||
<p className="text-sm text-red-500">
|
||||
{basicForm.formState.errors.lastName.message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">Phone (Optional)</label>
|
||||
<Input
|
||||
{...basicForm.register("phone")}
|
||||
placeholder="+1234567890"
|
||||
type="tel"
|
||||
/>
|
||||
{basicForm.formState.errors.phone && (
|
||||
<p className="text-sm text-red-500">
|
||||
{basicForm.formState.errors.phone.message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">Email *</label>
|
||||
<Input
|
||||
{...basicForm.register("email")}
|
||||
placeholder="john.doe@example.com"
|
||||
type="email"
|
||||
required
|
||||
/>
|
||||
<p className="text-xs text-gray-500">
|
||||
Email is currently required in the database schema
|
||||
</p>
|
||||
{basicForm.formState.errors.email && (
|
||||
<p className="text-sm text-red-500">
|
||||
{basicForm.formState.errors.email.message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<DialogFooter className="flex justify-between">
|
||||
<Button type="button" variant="outline" onClick={handleBack}>
|
||||
<ChevronLeft className="mr-2 h-4 w-4" /> Back
|
||||
</Button>
|
||||
<Button type="submit">
|
||||
Next <ChevronRight className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
)}
|
||||
|
||||
{/* Step 3: Client Information (only for clients) */}
|
||||
{currentStep === 3 && roleData?.role === "client" && (
|
||||
<form
|
||||
onSubmit={clientForm.handleSubmit(handleClientNext)}
|
||||
className="space-y-4"
|
||||
>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">Membership Type *</label>
|
||||
<select
|
||||
{...clientForm.register("membershipType")}
|
||||
className="w-full p-2 border border-gray-300 rounded-md"
|
||||
>
|
||||
<option value="basic">Basic</option>
|
||||
<option value="premium">Premium</option>
|
||||
<option value="vip">VIP</option>
|
||||
</select>
|
||||
{clientForm.formState.errors.membershipType && (
|
||||
<p className="text-sm text-red-500">
|
||||
{clientForm.formState.errors.membershipType.message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">Status *</label>
|
||||
<select
|
||||
{...clientForm.register("membershipStatus")}
|
||||
className="w-full p-2 border border-gray-300 rounded-md"
|
||||
>
|
||||
<option value="active">Active</option>
|
||||
<option value="inactive">Inactive</option>
|
||||
<option value="pending">Pending</option>
|
||||
</select>
|
||||
</div>
|
||||
{basicInfo?.email && (
|
||||
<div className="flex items-center space-x-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="sendWelcome"
|
||||
{...clientForm.register("sendWelcomeEmail")}
|
||||
className="w-4 h-4"
|
||||
/>
|
||||
<label htmlFor="sendWelcome" className="text-sm">
|
||||
Send welcome email to {basicInfo.email}
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
<DialogFooter className="flex justify-between">
|
||||
<Button type="button" variant="outline" onClick={handleBack}>
|
||||
<ChevronLeft className="mr-2 h-4 w-4" /> Back
|
||||
</Button>
|
||||
<Button type="submit" disabled={isSubmitting}>
|
||||
{basicInfo?.email ? (
|
||||
<>
|
||||
Next <ChevronRight className="ml-2 h-4 w-4" />
|
||||
</>
|
||||
) : (
|
||||
<>Create User</>
|
||||
)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
)}
|
||||
|
||||
{/* Step 4: Invitation Options */}
|
||||
{currentStep === 4 && (
|
||||
<form
|
||||
onSubmit={invitationForm.handleSubmit(handleInvitationSubmit)}
|
||||
className="space-y-4"
|
||||
>
|
||||
{basicInfo?.email && (
|
||||
<>
|
||||
<div className="p-4 bg-blue-50 border border-blue-200 rounded-md">
|
||||
<p className="text-sm text-blue-900">
|
||||
Email provided: <strong>{basicInfo.email}</strong>
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="sendInvitation"
|
||||
{...invitationForm.register("sendInvitation")}
|
||||
className="w-4 h-4"
|
||||
/>
|
||||
<label htmlFor="sendInvitation" className="text-sm">
|
||||
Send Clerk invitation email (user must accept to create
|
||||
account)
|
||||
</label>
|
||||
</div>
|
||||
<p className="text-xs text-gray-500">
|
||||
{invitationForm.watch("sendInvitation")
|
||||
? "User will receive an email to create their account"
|
||||
: "User record will be created without a Clerk account"}
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
{!basicInfo?.email && (
|
||||
<div className="p-4 bg-amber-50 border border-amber-200 rounded-md">
|
||||
<p className="text-sm text-amber-900">
|
||||
No email provided. User will be created in the database only.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
<DialogFooter className="flex justify-between">
|
||||
<Button type="button" variant="outline" onClick={handleBack}>
|
||||
<ChevronLeft className="mr-2 h-4 w-4" /> Back
|
||||
</Button>
|
||||
<Button type="submit" disabled={isSubmitting}>
|
||||
{isSubmitting ? "Creating..." : "Create User"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user