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
@@ -21,7 +21,7 @@ export async function GET(request: NextRequest) {
|
||||
}
|
||||
|
||||
const profile = db
|
||||
.prepare(`SELECT * FROM fitness_profiles WHERE userId = ?`)
|
||||
.prepare(`SELECT * FROM fitness_profiles WHERE user_id = ?`)
|
||||
.get(userId);
|
||||
|
||||
if (profile) {
|
||||
@@ -78,8 +78,8 @@ export async function POST(request: NextRequest) {
|
||||
|
||||
// Check if profile exists
|
||||
const existingProfile = db
|
||||
.prepare(`SELECT userId FROM fitness_profiles WHERE userId = ?`)
|
||||
.get(userId) as { userId: string } | undefined;
|
||||
.prepare(`SELECT user_id FROM fitness_profiles WHERE user_id = ?`)
|
||||
.get(userId) as { user_id: string } | undefined;
|
||||
|
||||
const now = new Date().toISOString();
|
||||
const fitnessGoalsJson = JSON.stringify(fitnessGoals || []);
|
||||
@@ -92,14 +92,13 @@ export async function POST(request: NextRequest) {
|
||||
weight = ?,
|
||||
age = ?,
|
||||
gender = ?,
|
||||
fitnessGoals = ?,
|
||||
activityLevel = ?,
|
||||
medicalConditions = ?,
|
||||
fitness_goals = ?,
|
||||
activity_level = ?,
|
||||
medical_conditions = ?,
|
||||
allergies = ?,
|
||||
injuries = ?,
|
||||
preferences = ?,
|
||||
updatedAt = ?
|
||||
WHERE userId = ?`,
|
||||
updated_at = ?
|
||||
WHERE user_id = ?`,
|
||||
).run(
|
||||
height,
|
||||
weight,
|
||||
@@ -110,7 +109,6 @@ export async function POST(request: NextRequest) {
|
||||
medicalConditions || null,
|
||||
allergies || null,
|
||||
injuries || null,
|
||||
preferences || null,
|
||||
now,
|
||||
userId,
|
||||
);
|
||||
@@ -121,12 +119,15 @@ export async function POST(request: NextRequest) {
|
||||
});
|
||||
} else {
|
||||
// Create new profile
|
||||
const profileId = `fp_${Date.now().toString(36)}${Math.random().toString(36).slice(2, 8)}`;
|
||||
|
||||
db.prepare(
|
||||
`INSERT INTO fitness_profiles
|
||||
(userId, height, weight, age, gender, fitnessGoals, activityLevel,
|
||||
medicalConditions, allergies, injuries, preferences, createdAt, updatedAt)
|
||||
(id, user_id, height, weight, age, gender, fitness_goals, activity_level,
|
||||
medical_conditions, allergies, injuries, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
).run(
|
||||
profileId,
|
||||
userId,
|
||||
height,
|
||||
weight,
|
||||
@@ -137,7 +138,6 @@ export async function POST(request: NextRequest) {
|
||||
medicalConditions || null,
|
||||
allergies || null,
|
||||
injuries || null,
|
||||
preferences || null,
|
||||
now,
|
||||
now,
|
||||
);
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { auth, clerkClient } from "@clerk/nextjs/server";
|
||||
import { db, users, clients } from "@fitai/database";
|
||||
import { createUserSchema } from "@/lib/validation/user-schemas";
|
||||
import { sendWelcomeEmail, sendInvitationEmail } from "@/lib/email";
|
||||
import {
|
||||
successResponse,
|
||||
errorResponse,
|
||||
unauthorizedResponse,
|
||||
} from "@/lib/api/responses";
|
||||
import log from "@/lib/logger";
|
||||
|
||||
/**
|
||||
* POST /api/users/create
|
||||
*
|
||||
* Create a new user with support for:
|
||||
* - Direct creation (database only, no Clerk account)
|
||||
* - Invitation-based creation (sends Clerk invitation email)
|
||||
* - Client-specific fields (membership type, status)
|
||||
* - Welcome emails for clients
|
||||
*/
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const { userId } = await auth();
|
||||
if (!userId) {
|
||||
return unauthorizedResponse("Unauthorized");
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
const validationResult = createUserSchema.safeParse(body);
|
||||
|
||||
if (!validationResult.success) {
|
||||
return errorResponse("Validation failed", {
|
||||
status: 400,
|
||||
details: validationResult.error.flatten().fieldErrors as Record<
|
||||
string,
|
||||
string[]
|
||||
>,
|
||||
});
|
||||
}
|
||||
|
||||
const data = validationResult.data;
|
||||
|
||||
// Note: Email is required in current schema
|
||||
// TODO: Add schema migration to make email optional for direct creation
|
||||
if (!data.email) {
|
||||
return errorResponse("Email is required", { status: 400 });
|
||||
}
|
||||
|
||||
// Case 1: Send Clerk Invitation (requires email)
|
||||
if (data.sendInvitation && data.email) {
|
||||
try {
|
||||
const client = await clerkClient();
|
||||
const invitation = await client.invitations.createInvitation({
|
||||
emailAddress: data.email,
|
||||
publicMetadata: {
|
||||
role: data.role,
|
||||
gymId: data.gymId,
|
||||
},
|
||||
});
|
||||
|
||||
log.info("Clerk invitation sent", {
|
||||
email: data.email,
|
||||
role: data.role,
|
||||
invitationId: invitation.id,
|
||||
});
|
||||
|
||||
// Send custom invitation email (in addition to Clerk's)
|
||||
if (invitation.url) {
|
||||
await sendInvitationEmail(data.email, data.role, invitation.url);
|
||||
}
|
||||
|
||||
return successResponse(
|
||||
{
|
||||
invitation: {
|
||||
id: invitation.id,
|
||||
email: invitation.emailAddress,
|
||||
status: invitation.status,
|
||||
},
|
||||
},
|
||||
{ status: 200 },
|
||||
);
|
||||
} catch (error: unknown) {
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : "Unknown error";
|
||||
log.error("Failed to send Clerk invitation", error);
|
||||
return errorResponse(`Failed to send invitation: ${errorMessage}`, {
|
||||
status: 500,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Case 2: Direct Creation (database only, no Clerk account)
|
||||
// This creates a user record that can later be linked when they sign up
|
||||
|
||||
// Generate a temporary user ID (will be replaced when they sign up with Clerk)
|
||||
const tempUserId = `temp_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
|
||||
|
||||
// Insert user into database
|
||||
const [newUser] = await db
|
||||
.insert(users)
|
||||
.values({
|
||||
id: tempUserId,
|
||||
email: data.email,
|
||||
firstName: data.firstName,
|
||||
lastName: data.lastName,
|
||||
role: data.role,
|
||||
phone: data.phone,
|
||||
gymId: data.gymId,
|
||||
})
|
||||
.returning();
|
||||
|
||||
log.info("User created in database", {
|
||||
userId: newUser.id,
|
||||
role: data.role,
|
||||
hasEmail: !!data.email,
|
||||
});
|
||||
|
||||
// Case 3: Create client record if role is client
|
||||
if (data.role === "client" && data.membershipType) {
|
||||
const clientId = `client_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
|
||||
|
||||
await db.insert(clients).values({
|
||||
id: clientId,
|
||||
userId: newUser.id,
|
||||
membershipType: data.membershipType,
|
||||
membershipStatus: data.membershipStatus || "active",
|
||||
joinDate: new Date(),
|
||||
});
|
||||
|
||||
log.info("Client record created", {
|
||||
userId: newUser.id,
|
||||
membershipType: data.membershipType,
|
||||
});
|
||||
|
||||
// Send welcome email if requested and email provided
|
||||
if (data.sendWelcomeEmail && data.email) {
|
||||
await sendWelcomeEmail(data.email, data.firstName);
|
||||
log.info("Welcome email sent", { email: data.email });
|
||||
}
|
||||
}
|
||||
|
||||
return successResponse(
|
||||
{
|
||||
user: {
|
||||
id: newUser.id,
|
||||
email: newUser.email,
|
||||
firstName: newUser.firstName,
|
||||
lastName: newUser.lastName,
|
||||
role: newUser.role,
|
||||
},
|
||||
},
|
||||
{ status: 201 },
|
||||
);
|
||||
} catch (error) {
|
||||
log.error("Failed to create user", error);
|
||||
return errorResponse("Failed to create user", { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -128,6 +128,30 @@ export async function POST(req: Request) {
|
||||
now,
|
||||
);
|
||||
|
||||
// Auto-create client record for self-registered users (role is "client")
|
||||
if (role === "client") {
|
||||
const clientId = `cli_${Date.now().toString(36)}${Math.random().toString(36).slice(2, 8)}`;
|
||||
const clientStmt = db.prepare(`
|
||||
INSERT INTO clients (id, user_id, membership_type, membership_status, join_date, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
`);
|
||||
|
||||
clientStmt.run(
|
||||
clientId,
|
||||
id,
|
||||
"basic", // Default membership type
|
||||
"active", // Default membership status
|
||||
now,
|
||||
now,
|
||||
now,
|
||||
);
|
||||
|
||||
log.info("Client record auto-created for self-registered user", {
|
||||
userId: id,
|
||||
clientId,
|
||||
});
|
||||
}
|
||||
|
||||
// If this is a client invited by a trainer, create trainer-client link
|
||||
if (roleAssigned === "client" && inviterUserId && gymId) {
|
||||
const inviterRow = db
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
UserButton,
|
||||
} from "@clerk/nextjs";
|
||||
import { Sidebar } from "@/components/ui/Sidebar";
|
||||
import { Toaster } from "sonner";
|
||||
|
||||
const inter = Inter({ subsets: ["latin"] });
|
||||
|
||||
@@ -28,10 +29,9 @@ export default function RootLayout({
|
||||
<body className={inter.className}>
|
||||
<div className="flex min-h-screen bg-slate-50">
|
||||
<Sidebar />
|
||||
<main className="flex-1 ml-20 p-8">
|
||||
{children}
|
||||
</main>
|
||||
<main className="flex-1 ml-20 p-8">{children}</main>
|
||||
</div>
|
||||
<Toaster richColors position="top-right" />
|
||||
</body>
|
||||
</html>
|
||||
</ClerkProvider>
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useUser } from "@clerk/nextjs";
|
||||
import log from "@/lib/logger";
|
||||
import { toast } from "@/lib/toast";
|
||||
|
||||
interface User {
|
||||
id: string;
|
||||
@@ -68,14 +69,14 @@ export default function RecommendationsPage() {
|
||||
|
||||
if (!res.ok) {
|
||||
const error = await res.json();
|
||||
alert(`Error: ${error.error}`);
|
||||
toast.error(`Error: ${error.error}`);
|
||||
} else {
|
||||
alert("Recommendation generated successfully!");
|
||||
toast.success("Recommendation generated successfully!");
|
||||
fetchData(); // Refresh data
|
||||
}
|
||||
} catch (error) {
|
||||
log.error("Failed to generate recommendation", error);
|
||||
alert("Failed to generate recommendation.");
|
||||
toast.error("Failed to generate recommendation.");
|
||||
} finally {
|
||||
setGenerating(null);
|
||||
}
|
||||
@@ -98,13 +99,16 @@ export default function RecommendationsPage() {
|
||||
|
||||
if (!res.ok) {
|
||||
const errorData = await res.json();
|
||||
alert(`Failed to update status: ${errorData.error || "Unknown error"}`);
|
||||
toast.error(
|
||||
`Failed to update status: ${errorData.error || "Unknown error"}`,
|
||||
);
|
||||
} else {
|
||||
toast.success("Recommendation status updated");
|
||||
fetchData(); // Refresh data
|
||||
}
|
||||
} catch (error) {
|
||||
log.error("Failed to approve recommendation", error);
|
||||
alert("Error processing request");
|
||||
toast.error("Error processing request");
|
||||
}
|
||||
};
|
||||
|
||||
@@ -136,16 +140,16 @@ export default function RecommendationsPage() {
|
||||
|
||||
if (!res.ok) {
|
||||
const errorData = await res.json();
|
||||
alert(
|
||||
toast.error(
|
||||
`Failed to update recommendation: ${errorData.error || "Unknown error"}`,
|
||||
);
|
||||
} else {
|
||||
alert("Recommendation updated successfully!");
|
||||
toast.success("Recommendation updated successfully!");
|
||||
fetchData(); // Refresh data
|
||||
}
|
||||
} catch (error) {
|
||||
log.error("Failed to update recommendation", error);
|
||||
alert("Failed to update recommendation.");
|
||||
toast.error("Failed to update recommendation.");
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -2,43 +2,46 @@
|
||||
|
||||
import { useState } from "react";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { toast } from "@/lib/toast";
|
||||
|
||||
export function GenerateButton({ userId }: { userId: string }) {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const handleGenerate = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await fetch("/api/recommendations/generate", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ userId }),
|
||||
});
|
||||
const handleGenerate = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await fetch("/api/recommendations/generate", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ userId }),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const error = await res.json();
|
||||
alert(`Error: ${error.error}`);
|
||||
} else {
|
||||
alert("Recommendation generated successfully! Check Pending Approvals.");
|
||||
// In a real app, we'd revalidate the path or update state
|
||||
window.location.reload();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
alert("Failed to generate recommendation.");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
if (!res.ok) {
|
||||
const error = await res.json();
|
||||
toast.error(`Error: ${error.error}`);
|
||||
} else {
|
||||
toast.success(
|
||||
"Recommendation generated successfully! Check Pending Approvals.",
|
||||
);
|
||||
// In a real app, we'd revalidate the path or update state
|
||||
window.location.reload();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
toast.error("Failed to generate recommendation.");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={handleGenerate}
|
||||
disabled={loading}
|
||||
className="bg-blue-600 text-white px-4 py-2 rounded hover:bg-blue-700 disabled:opacity-50 flex items-center"
|
||||
>
|
||||
{loading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
Generate
|
||||
</button>
|
||||
);
|
||||
return (
|
||||
<button
|
||||
onClick={handleGenerate}
|
||||
disabled={loading}
|
||||
className="bg-blue-600 text-white px-4 py-2 rounded hover:bg-blue-700 disabled:opacity-50 flex items-center"
|
||||
>
|
||||
{loading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
Generate
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -2,116 +2,123 @@
|
||||
|
||||
import { useState } from "react";
|
||||
import { Loader2, Check, X } from "lucide-react";
|
||||
import { toast } from "@/lib/toast";
|
||||
|
||||
type Recommendation = {
|
||||
recommendation: {
|
||||
id: string;
|
||||
recommendationText: string;
|
||||
activityPlan: string;
|
||||
dietPlan: string;
|
||||
createdAt: Date;
|
||||
};
|
||||
user: {
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
};
|
||||
recommendation: {
|
||||
id: string;
|
||||
recommendationText: string;
|
||||
activityPlan: string;
|
||||
dietPlan: string;
|
||||
createdAt: Date;
|
||||
};
|
||||
user: {
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
};
|
||||
};
|
||||
|
||||
export function RecommendationList({
|
||||
initialRecommendations,
|
||||
initialRecommendations,
|
||||
}: {
|
||||
initialRecommendations: Recommendation[];
|
||||
initialRecommendations: Recommendation[];
|
||||
}) {
|
||||
const [recommendations, setRecommendations] = useState(initialRecommendations);
|
||||
const [processingId, setProcessingId] = useState<string | null>(null);
|
||||
const [recommendations, setRecommendations] = useState(
|
||||
initialRecommendations,
|
||||
);
|
||||
const [processingId, setProcessingId] = useState<string | null>(null);
|
||||
|
||||
const handleAction = async (id: string, status: "approved" | "rejected") => {
|
||||
setProcessingId(id);
|
||||
try {
|
||||
const res = await fetch("/api/recommendations/approve", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
recommendationId: id,
|
||||
status,
|
||||
approvedBy: "admin_placeholder", // In real app, get from auth context
|
||||
}),
|
||||
});
|
||||
const handleAction = async (id: string, status: "approved" | "rejected") => {
|
||||
setProcessingId(id);
|
||||
try {
|
||||
const res = await fetch("/api/recommendations/approve", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
recommendationId: id,
|
||||
status,
|
||||
approvedBy: "admin_placeholder", // In real app, get from auth context
|
||||
}),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
alert("Failed to update status");
|
||||
} else {
|
||||
setRecommendations((prev) =>
|
||||
prev.filter((item) => item.recommendation.id !== id)
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
alert("Error processing request");
|
||||
} finally {
|
||||
setProcessingId(null);
|
||||
}
|
||||
};
|
||||
|
||||
if (recommendations.length === 0) {
|
||||
return <p className="text-gray-500 italic">No pending recommendations.</p>;
|
||||
if (!res.ok) {
|
||||
toast.error("Failed to update status");
|
||||
} else {
|
||||
toast.success(
|
||||
`Recommendation ${status === "approved" ? "approved" : "rejected"}`,
|
||||
);
|
||||
setRecommendations((prev) =>
|
||||
prev.filter((item) => item.recommendation.id !== id),
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
toast.error("Error processing request");
|
||||
} finally {
|
||||
setProcessingId(null);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<ul className="space-y-6">
|
||||
{recommendations.map(({ recommendation, user }) => (
|
||||
<li key={recommendation.id} className="border rounded p-4">
|
||||
<div className="flex justify-between items-start mb-2">
|
||||
<h3 className="font-bold">
|
||||
For: {user.firstName} {user.lastName}
|
||||
</h3>
|
||||
<span className="text-xs text-gray-500">
|
||||
{new Date(recommendation.createdAt).toLocaleDateString()}
|
||||
</span>
|
||||
</div>
|
||||
<div className="space-y-2 text-sm mb-4">
|
||||
<div>
|
||||
<span className="font-semibold">Advice:</span>{" "}
|
||||
{recommendation.recommendationText}
|
||||
</div>
|
||||
<div>
|
||||
<span className="font-semibold">Activity:</span>{" "}
|
||||
{recommendation.activityPlan}
|
||||
</div>
|
||||
<div>
|
||||
<span className="font-semibold">Diet:</span> {recommendation.dietPlan}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex space-x-2">
|
||||
<button
|
||||
onClick={() => handleAction(recommendation.id, "approved")}
|
||||
disabled={processingId === recommendation.id}
|
||||
className="flex-1 bg-green-600 text-white py-2 rounded hover:bg-green-700 disabled:opacity-50 flex justify-center items-center"
|
||||
>
|
||||
{processingId === recommendation.id ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<>
|
||||
<Check className="mr-2 h-4 w-4" /> Approve
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleAction(recommendation.id, "rejected")}
|
||||
disabled={processingId === recommendation.id}
|
||||
className="flex-1 bg-red-600 text-white py-2 rounded hover:bg-red-700 disabled:opacity-50 flex justify-center items-center"
|
||||
>
|
||||
{processingId === recommendation.id ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<>
|
||||
<X className="mr-2 h-4 w-4" /> Reject
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
if (recommendations.length === 0) {
|
||||
return <p className="text-gray-500 italic">No pending recommendations.</p>;
|
||||
}
|
||||
|
||||
return (
|
||||
<ul className="space-y-6">
|
||||
{recommendations.map(({ recommendation, user }) => (
|
||||
<li key={recommendation.id} className="border rounded p-4">
|
||||
<div className="flex justify-between items-start mb-2">
|
||||
<h3 className="font-bold">
|
||||
For: {user.firstName} {user.lastName}
|
||||
</h3>
|
||||
<span className="text-xs text-gray-500">
|
||||
{new Date(recommendation.createdAt).toLocaleDateString()}
|
||||
</span>
|
||||
</div>
|
||||
<div className="space-y-2 text-sm mb-4">
|
||||
<div>
|
||||
<span className="font-semibold">Advice:</span>{" "}
|
||||
{recommendation.recommendationText}
|
||||
</div>
|
||||
<div>
|
||||
<span className="font-semibold">Activity:</span>{" "}
|
||||
{recommendation.activityPlan}
|
||||
</div>
|
||||
<div>
|
||||
<span className="font-semibold">Diet:</span>{" "}
|
||||
{recommendation.dietPlan}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex space-x-2">
|
||||
<button
|
||||
onClick={() => handleAction(recommendation.id, "approved")}
|
||||
disabled={processingId === recommendation.id}
|
||||
className="flex-1 bg-green-600 text-white py-2 rounded hover:bg-green-700 disabled:opacity-50 flex justify-center items-center"
|
||||
>
|
||||
{processingId === recommendation.id ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<>
|
||||
<Check className="mr-2 h-4 w-4" /> Approve
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleAction(recommendation.id, "rejected")}
|
||||
disabled={processingId === recommendation.id}
|
||||
className="flex-1 bg-red-600 text-white py-2 rounded hover:bg-red-700 disabled:opacity-50 flex justify-center items-center"
|
||||
>
|
||||
{processingId === recommendation.id ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<>
|
||||
<X className="mr-2 h-4 w-4" /> Reject
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
import * as React from "react";
|
||||
import * as DialogPrimitive from "@radix-ui/react-dialog";
|
||||
import { X } from "lucide-react";
|
||||
|
||||
const Dialog = DialogPrimitive.Root;
|
||||
|
||||
const DialogTrigger = DialogPrimitive.Trigger;
|
||||
|
||||
const DialogPortal = DialogPrimitive.Portal;
|
||||
|
||||
const DialogClose = DialogPrimitive.Close;
|
||||
|
||||
const DialogOverlay = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Overlay>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
|
||||
>(({ className = "", ...props }, ref) => (
|
||||
<DialogPrimitive.Overlay
|
||||
ref={ref}
|
||||
className={`fixed inset-0 z-50 bg-black/50 backdrop-blur-sm ${className}`}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
|
||||
|
||||
const DialogContent = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
|
||||
>(({ className = "", children, ...props }, ref) => (
|
||||
<DialogPortal>
|
||||
<DialogOverlay />
|
||||
<DialogPrimitive.Content
|
||||
ref={ref}
|
||||
className={`fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border border-gray-200 bg-white p-6 shadow-lg duration-200 sm:rounded-lg ${className}`}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-white transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-gray-950 focus:ring-offset-2 disabled:pointer-events-none">
|
||||
<X className="h-4 w-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogPrimitive.Close>
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPortal>
|
||||
));
|
||||
DialogContent.displayName = DialogPrimitive.Content.displayName;
|
||||
|
||||
const DialogHeader = ({
|
||||
className = "",
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={`flex flex-col space-y-1.5 text-center sm:text-left ${className}`}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
DialogHeader.displayName = "DialogHeader";
|
||||
|
||||
const DialogFooter = ({
|
||||
className = "",
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={`flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2 ${className}`}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
DialogFooter.displayName = "DialogFooter";
|
||||
|
||||
const DialogTitle = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Title>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
|
||||
>(({ className = "", ...props }, ref) => (
|
||||
<DialogPrimitive.Title
|
||||
ref={ref}
|
||||
className={`text-lg font-semibold leading-none tracking-tight ${className}`}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
DialogTitle.displayName = DialogPrimitive.Title.displayName;
|
||||
|
||||
const DialogDescription = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Description>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
|
||||
>(({ className = "", ...props }, ref) => (
|
||||
<DialogPrimitive.Description
|
||||
ref={ref}
|
||||
className={`text-sm text-gray-500 ${className}`}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
DialogDescription.displayName = DialogPrimitive.Description.displayName;
|
||||
|
||||
export {
|
||||
Dialog,
|
||||
DialogPortal,
|
||||
DialogOverlay,
|
||||
DialogClose,
|
||||
DialogTrigger,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogFooter,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import { useState, useEffect } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardHeader, CardContent } from "@/components/ui/card";
|
||||
import log from "@/lib/logger";
|
||||
import { toast } from "@/lib/toast";
|
||||
|
||||
interface Recommendation {
|
||||
id: string;
|
||||
@@ -63,8 +64,9 @@ export function Recommendations({ userId }: RecommendationsProps) {
|
||||
if (response.ok) {
|
||||
setNewRec({ ...newRec, content: "" });
|
||||
fetchRecommendations();
|
||||
toast.success("Recommendation added successfully");
|
||||
} else {
|
||||
alert("Failed to add recommendation");
|
||||
toast.error("Failed to add recommendation");
|
||||
}
|
||||
} catch (error) {
|
||||
log.error("Failed to add recommendation", error);
|
||||
|
||||
@@ -8,6 +8,8 @@ import { Button } from "@/components/ui/button";
|
||||
import { useUser } from "@clerk/nextjs";
|
||||
import { getGymIdFromUser } from "@/lib/error-helpers";
|
||||
import log from "@/lib/logger";
|
||||
import { toast } from "@/lib/toast";
|
||||
import { CreateUserModal } from "./CreateUserModal";
|
||||
|
||||
interface User {
|
||||
id: string;
|
||||
@@ -40,6 +42,7 @@ export function UserManagement() {
|
||||
const [selectedUser, setSelectedUser] = useState<User | null>(null);
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const [isDeleting, setIsDeleting] = useState(false);
|
||||
const [createModalOpen, setCreateModalOpen] = useState(false);
|
||||
const [editForm, setEditForm] = useState<{
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
@@ -148,8 +151,9 @@ export function UserManagement() {
|
||||
});
|
||||
if (response.ok) {
|
||||
fetchUsers();
|
||||
toast.success("Users deleted successfully");
|
||||
} else {
|
||||
alert("Error deleting users");
|
||||
toast.error("Error deleting users");
|
||||
}
|
||||
} catch (error) {
|
||||
log.error("Failed to delete users", error);
|
||||
@@ -255,12 +259,13 @@ export function UserManagement() {
|
||||
// Still re-fetch from server to ensure consistency
|
||||
log.debug("Re-fetching users after successful edit");
|
||||
fetchUsers();
|
||||
toast.success("User updated successfully");
|
||||
} else {
|
||||
const errText = await response.text().catch(() => "");
|
||||
log.error("User update failed", new Error(errText), {
|
||||
status: response.status,
|
||||
});
|
||||
alert("Error updating user");
|
||||
toast.error("Error updating user");
|
||||
}
|
||||
} else {
|
||||
// Create (Invite) new user
|
||||
@@ -278,15 +283,15 @@ export function UserManagement() {
|
||||
setIsEditing(false);
|
||||
setEditForm(null);
|
||||
fetchUsers();
|
||||
alert("Invitation sent successfully!");
|
||||
toast.success("Invitation sent successfully!");
|
||||
} else {
|
||||
const errorData = await response.json();
|
||||
alert(`Error sending invitation: ${errorData.error}`);
|
||||
toast.error(`Error sending invitation: ${errorData.error}`);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
alert("An unexpected error occurred");
|
||||
toast.error("An unexpected error occurred");
|
||||
}
|
||||
};
|
||||
|
||||
@@ -301,8 +306,9 @@ export function UserManagement() {
|
||||
setIsDeleting(false);
|
||||
setSelectedUser(null);
|
||||
fetchUsers();
|
||||
toast.success("User deleted successfully");
|
||||
} else {
|
||||
alert("Error deleting user");
|
||||
toast.error("Error deleting user");
|
||||
}
|
||||
} catch (error) {
|
||||
log.error("Failed to delete user", error);
|
||||
@@ -327,22 +333,8 @@ export function UserManagement() {
|
||||
>
|
||||
Edit User
|
||||
</Button>
|
||||
<Button
|
||||
variant={filter === "client" ? "default" : "outline"}
|
||||
onClick={() => {
|
||||
setEditForm({
|
||||
firstName: "",
|
||||
lastName: "",
|
||||
email: "",
|
||||
role: "client",
|
||||
phone: "",
|
||||
gymId: user ? getGymIdFromUser(user) : "",
|
||||
});
|
||||
setSelectedUser(null);
|
||||
setIsEditing(true);
|
||||
}}
|
||||
>
|
||||
Invite User
|
||||
<Button variant="default" onClick={() => setCreateModalOpen(true)}>
|
||||
Create User
|
||||
</Button>
|
||||
<Button
|
||||
variant={filter === "client" ? "default" : "outline"}
|
||||
@@ -657,6 +649,12 @@ export function UserManagement() {
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<CreateUserModal
|
||||
open={createModalOpen}
|
||||
onOpenChange={setCreateModalOpen}
|
||||
onSuccess={() => fetchUsers()}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
import {
|
||||
Body,
|
||||
Container,
|
||||
Head,
|
||||
Heading,
|
||||
Html,
|
||||
Preview,
|
||||
Section,
|
||||
Text,
|
||||
} from "@react-email/components";
|
||||
|
||||
interface RecommendationEmailProps {
|
||||
clientName: string;
|
||||
recommendationText: string;
|
||||
}
|
||||
|
||||
export function RecommendationEmail({
|
||||
clientName,
|
||||
recommendationText,
|
||||
}: RecommendationEmailProps) {
|
||||
return (
|
||||
<Html>
|
||||
<Head />
|
||||
<Preview>New fitness recommendation from your trainer</Preview>
|
||||
<Body style={main}>
|
||||
<Container style={container}>
|
||||
<Heading style={h1}>New Recommendation! 📋</Heading>
|
||||
<Text style={text}>Hi {clientName},</Text>
|
||||
<Text style={text}>
|
||||
Your trainer has created a new personalized recommendation for you:
|
||||
</Text>
|
||||
<Section style={section}>
|
||||
<Text style={recommendationContent}>{recommendationText}</Text>
|
||||
</Section>
|
||||
<Text style={text}>
|
||||
Open your FitAI mobile app to view the complete recommendation
|
||||
including your activity plan and nutrition guidance.
|
||||
</Text>
|
||||
<Text style={text}>
|
||||
Keep up the great work!
|
||||
<br />
|
||||
The FitAI Team
|
||||
</Text>
|
||||
</Container>
|
||||
</Body>
|
||||
</Html>
|
||||
);
|
||||
}
|
||||
|
||||
// Default export for email previews
|
||||
export default RecommendationEmail;
|
||||
|
||||
// Styles
|
||||
const main = {
|
||||
backgroundColor: "#f6f9fc",
|
||||
fontFamily:
|
||||
'-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Helvetica Neue",Ubuntu,sans-serif',
|
||||
};
|
||||
|
||||
const container = {
|
||||
backgroundColor: "#ffffff",
|
||||
margin: "0 auto",
|
||||
padding: "20px 0 48px",
|
||||
marginBottom: "64px",
|
||||
maxWidth: "600px",
|
||||
};
|
||||
|
||||
const h1 = {
|
||||
color: "#333",
|
||||
fontSize: "28px",
|
||||
fontWeight: "bold",
|
||||
margin: "40px 0 20px",
|
||||
padding: "0 40px",
|
||||
};
|
||||
|
||||
const text = {
|
||||
color: "#333",
|
||||
fontSize: "16px",
|
||||
lineHeight: "26px",
|
||||
padding: "0 40px",
|
||||
margin: "16px 0",
|
||||
};
|
||||
|
||||
const section = {
|
||||
padding: "20px 40px",
|
||||
backgroundColor: "#f0fdf4",
|
||||
margin: "20px 0",
|
||||
borderLeft: "4px solid #10b981",
|
||||
borderRadius: "4px",
|
||||
};
|
||||
|
||||
const recommendationContent = {
|
||||
color: "#333",
|
||||
fontSize: "16px",
|
||||
lineHeight: "26px",
|
||||
margin: "0",
|
||||
padding: "0",
|
||||
fontStyle: "italic" as const,
|
||||
};
|
||||
@@ -0,0 +1,117 @@
|
||||
import {
|
||||
Body,
|
||||
Container,
|
||||
Head,
|
||||
Heading,
|
||||
Html,
|
||||
Preview,
|
||||
Section,
|
||||
Text,
|
||||
} from "@react-email/components";
|
||||
|
||||
interface TrainerAssignmentEmailProps {
|
||||
clientName: string;
|
||||
trainerName: string;
|
||||
}
|
||||
|
||||
export function TrainerAssignmentEmail({
|
||||
clientName,
|
||||
trainerName,
|
||||
}: TrainerAssignmentEmailProps) {
|
||||
return (
|
||||
<Html>
|
||||
<Head />
|
||||
<Preview>You've been assigned a trainer - {trainerName}</Preview>
|
||||
<Body style={main}>
|
||||
<Container style={container}>
|
||||
<Heading style={h1}>Great News! 🎯</Heading>
|
||||
<Text style={text}>Hi {clientName},</Text>
|
||||
<Text style={text}>
|
||||
You've been assigned a personal trainer to help you reach your
|
||||
fitness goals!
|
||||
</Text>
|
||||
<Section style={section}>
|
||||
<Heading style={h2}>Your Trainer</Heading>
|
||||
<Text style={highlightText}>{trainerName}</Text>
|
||||
</Section>
|
||||
<Text style={text}>
|
||||
Your trainer will work with you to:
|
||||
<br />
|
||||
• Create personalized workout plans
|
||||
<br />
|
||||
• Provide nutrition guidance
|
||||
<br />
|
||||
• Track your progress and achievements
|
||||
<br />• Adjust your program as you improve
|
||||
</Text>
|
||||
<Text style={text}>
|
||||
Check your FitAI mobile app to view your trainer's profile and start
|
||||
receiving personalized recommendations.
|
||||
</Text>
|
||||
<Text style={text}>
|
||||
Let's crush those goals together!
|
||||
<br />
|
||||
The FitAI Team
|
||||
</Text>
|
||||
</Container>
|
||||
</Body>
|
||||
</Html>
|
||||
);
|
||||
}
|
||||
|
||||
// Default export for email previews
|
||||
export default TrainerAssignmentEmail;
|
||||
|
||||
// Styles
|
||||
const main = {
|
||||
backgroundColor: "#f6f9fc",
|
||||
fontFamily:
|
||||
'-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Helvetica Neue",Ubuntu,sans-serif',
|
||||
};
|
||||
|
||||
const container = {
|
||||
backgroundColor: "#ffffff",
|
||||
margin: "0 auto",
|
||||
padding: "20px 0 48px",
|
||||
marginBottom: "64px",
|
||||
maxWidth: "600px",
|
||||
};
|
||||
|
||||
const h1 = {
|
||||
color: "#333",
|
||||
fontSize: "28px",
|
||||
fontWeight: "bold",
|
||||
margin: "40px 0 20px",
|
||||
padding: "0 40px",
|
||||
};
|
||||
|
||||
const h2 = {
|
||||
color: "#333",
|
||||
fontSize: "20px",
|
||||
fontWeight: "bold",
|
||||
margin: "10px 0",
|
||||
padding: "0",
|
||||
};
|
||||
|
||||
const text = {
|
||||
color: "#333",
|
||||
fontSize: "16px",
|
||||
lineHeight: "26px",
|
||||
padding: "0 40px",
|
||||
margin: "16px 0",
|
||||
};
|
||||
|
||||
const section = {
|
||||
padding: "20px 40px",
|
||||
backgroundColor: "#eff6ff",
|
||||
margin: "20px 0",
|
||||
borderLeft: "4px solid #3b82f6",
|
||||
};
|
||||
|
||||
const highlightText = {
|
||||
color: "#3b82f6",
|
||||
fontSize: "24px",
|
||||
fontWeight: "bold",
|
||||
margin: "0",
|
||||
padding: "0",
|
||||
};
|
||||
@@ -0,0 +1,133 @@
|
||||
import {
|
||||
Body,
|
||||
Container,
|
||||
Head,
|
||||
Heading,
|
||||
Html,
|
||||
Preview,
|
||||
Section,
|
||||
Text,
|
||||
Button,
|
||||
Link,
|
||||
} from "@react-email/components";
|
||||
|
||||
interface UserInvitationEmailProps {
|
||||
email: string;
|
||||
role: string;
|
||||
inviteUrl: string;
|
||||
}
|
||||
|
||||
export function UserInvitationEmail({
|
||||
email,
|
||||
role,
|
||||
inviteUrl,
|
||||
}: UserInvitationEmailProps) {
|
||||
const roleDisplay =
|
||||
role === "admin"
|
||||
? "Administrator"
|
||||
: role === "trainer"
|
||||
? "Trainer"
|
||||
: "Client";
|
||||
|
||||
return (
|
||||
<Html>
|
||||
<Head />
|
||||
<Preview>You've been invited to join FitAI as a {roleDisplay}</Preview>
|
||||
<Body style={main}>
|
||||
<Container style={container}>
|
||||
<Heading style={h1}>Welcome to FitAI! 🎉</Heading>
|
||||
<Text style={text}>
|
||||
You've been invited to join FitAI as a{" "}
|
||||
<strong>{roleDisplay}</strong>.
|
||||
</Text>
|
||||
<Text style={text}>
|
||||
Click the button below to accept your invitation and create your
|
||||
account:
|
||||
</Text>
|
||||
<Section style={buttonContainer}>
|
||||
<Button style={button} href={inviteUrl}>
|
||||
Accept Invitation
|
||||
</Button>
|
||||
</Section>
|
||||
<Text style={text}>
|
||||
Or copy and paste this URL into your browser:
|
||||
</Text>
|
||||
<Text style={link}>
|
||||
<Link href={inviteUrl}>{inviteUrl}</Link>
|
||||
</Text>
|
||||
<Text style={text}>
|
||||
This invitation was sent to {email}. If you didn't expect this
|
||||
email, you can safely ignore it.
|
||||
</Text>
|
||||
<Text style={footer}>The FitAI Team</Text>
|
||||
</Container>
|
||||
</Body>
|
||||
</Html>
|
||||
);
|
||||
}
|
||||
|
||||
// Default export for email previews
|
||||
export default UserInvitationEmail;
|
||||
|
||||
// Styles
|
||||
const main = {
|
||||
backgroundColor: "#f6f9fc",
|
||||
fontFamily:
|
||||
'-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Helvetica Neue",Ubuntu,sans-serif',
|
||||
};
|
||||
|
||||
const container = {
|
||||
backgroundColor: "#ffffff",
|
||||
margin: "0 auto",
|
||||
padding: "20px 0 48px",
|
||||
marginBottom: "64px",
|
||||
maxWidth: "600px",
|
||||
};
|
||||
|
||||
const h1 = {
|
||||
color: "#333",
|
||||
fontSize: "28px",
|
||||
fontWeight: "bold",
|
||||
margin: "40px 0 20px",
|
||||
padding: "0 40px",
|
||||
};
|
||||
|
||||
const text = {
|
||||
color: "#333",
|
||||
fontSize: "16px",
|
||||
lineHeight: "26px",
|
||||
padding: "0 40px",
|
||||
margin: "16px 0",
|
||||
};
|
||||
|
||||
const buttonContainer = {
|
||||
padding: "27px 40px",
|
||||
textAlign: "center" as const,
|
||||
};
|
||||
|
||||
const button = {
|
||||
backgroundColor: "#3b82f6",
|
||||
borderRadius: "5px",
|
||||
color: "#fff",
|
||||
fontSize: "16px",
|
||||
fontWeight: "bold",
|
||||
textDecoration: "none",
|
||||
textAlign: "center" as const,
|
||||
display: "inline-block",
|
||||
padding: "12px 32px",
|
||||
};
|
||||
|
||||
const link = {
|
||||
color: "#3b82f6",
|
||||
fontSize: "14px",
|
||||
padding: "0 40px",
|
||||
wordBreak: "break-all" as const,
|
||||
};
|
||||
|
||||
const footer = {
|
||||
color: "#333",
|
||||
fontSize: "16px",
|
||||
lineHeight: "26px",
|
||||
padding: "0 40px",
|
||||
marginTop: "32px",
|
||||
};
|
||||
@@ -0,0 +1,101 @@
|
||||
import {
|
||||
Body,
|
||||
Container,
|
||||
Head,
|
||||
Heading,
|
||||
Html,
|
||||
Preview,
|
||||
Section,
|
||||
Text,
|
||||
Button,
|
||||
} from "@react-email/components";
|
||||
|
||||
interface WelcomeEmailProps {
|
||||
name: string;
|
||||
}
|
||||
|
||||
export function WelcomeEmail({ name }: WelcomeEmailProps) {
|
||||
return (
|
||||
<Html>
|
||||
<Head />
|
||||
<Preview>Welcome to FitAI - Your fitness journey starts here!</Preview>
|
||||
<Body style={main}>
|
||||
<Container style={container}>
|
||||
<Heading style={h1}>Welcome to FitAI! 💪</Heading>
|
||||
<Text style={text}>Hi {name},</Text>
|
||||
<Text style={text}>
|
||||
We're excited to have you join FitAI! Your account has been
|
||||
successfully created, and you're all set to begin your fitness
|
||||
journey.
|
||||
</Text>
|
||||
<Section style={section}>
|
||||
<Heading style={h2}>Getting Started</Heading>
|
||||
<Text style={text}>
|
||||
• Download the FitAI mobile app to track your workouts
|
||||
<br />
|
||||
• Complete your fitness profile for personalized recommendations
|
||||
<br />
|
||||
• Check in at the gym to start tracking your attendance
|
||||
<br />• Connect with your trainer for custom workout plans
|
||||
</Text>
|
||||
</Section>
|
||||
<Text style={text}>
|
||||
If you have any questions, feel free to reach out to your gym
|
||||
administrator or trainer.
|
||||
</Text>
|
||||
<Text style={text}>
|
||||
Let's make fitness a lifestyle!
|
||||
<br />
|
||||
The FitAI Team
|
||||
</Text>
|
||||
</Container>
|
||||
</Body>
|
||||
</Html>
|
||||
);
|
||||
}
|
||||
|
||||
// Default export for email previews
|
||||
export default WelcomeEmail;
|
||||
|
||||
// Styles
|
||||
const main = {
|
||||
backgroundColor: "#f6f9fc",
|
||||
fontFamily:
|
||||
'-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Helvetica Neue",Ubuntu,sans-serif',
|
||||
};
|
||||
|
||||
const container = {
|
||||
backgroundColor: "#ffffff",
|
||||
margin: "0 auto",
|
||||
padding: "20px 0 48px",
|
||||
marginBottom: "64px",
|
||||
maxWidth: "600px",
|
||||
};
|
||||
|
||||
const h1 = {
|
||||
color: "#333",
|
||||
fontSize: "28px",
|
||||
fontWeight: "bold",
|
||||
margin: "40px 0 20px",
|
||||
padding: "0 40px",
|
||||
};
|
||||
|
||||
const h2 = {
|
||||
color: "#333",
|
||||
fontSize: "20px",
|
||||
fontWeight: "bold",
|
||||
margin: "20px 0 10px",
|
||||
};
|
||||
|
||||
const text = {
|
||||
color: "#333",
|
||||
fontSize: "16px",
|
||||
lineHeight: "26px",
|
||||
padding: "0 40px",
|
||||
};
|
||||
|
||||
const section = {
|
||||
padding: "20px 40px",
|
||||
backgroundColor: "#f8f9fa",
|
||||
margin: "20px 0",
|
||||
};
|
||||
@@ -0,0 +1,148 @@
|
||||
import { Resend } from "resend";
|
||||
import { render } from "@react-email/render";
|
||||
import log from "./logger";
|
||||
|
||||
/**
|
||||
* Email service using Resend
|
||||
*
|
||||
* Environment variables required:
|
||||
* - RESEND_API_KEY: API key from Resend dashboard
|
||||
* - EMAIL_FROM: Sender email address (e.g., "FitAI <noreply@fitai.com>")
|
||||
* - EMAIL_REPLY_TO: Reply-to email address (optional)
|
||||
*/
|
||||
|
||||
const resend = new Resend(process.env.RESEND_API_KEY);
|
||||
|
||||
const DEFAULT_FROM = process.env.EMAIL_FROM || "FitAI <noreply@fitai.com>";
|
||||
const DEFAULT_REPLY_TO = process.env.EMAIL_REPLY_TO;
|
||||
|
||||
export interface SendEmailOptions {
|
||||
to: string | string[];
|
||||
subject: string;
|
||||
react: React.ReactElement;
|
||||
from?: string;
|
||||
replyTo?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Send an email using Resend
|
||||
*
|
||||
* @param options - Email options including recipient, subject, and React component
|
||||
* @returns Response from Resend API
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* import { WelcomeEmail } from '@/emails/welcome-email';
|
||||
*
|
||||
* await sendEmail({
|
||||
* to: 'user@example.com',
|
||||
* subject: 'Welcome to FitAI',
|
||||
* react: <WelcomeEmail name="John" />
|
||||
* });
|
||||
* ```
|
||||
*/
|
||||
export async function sendEmail({
|
||||
to,
|
||||
subject,
|
||||
react,
|
||||
from = DEFAULT_FROM,
|
||||
replyTo = DEFAULT_REPLY_TO,
|
||||
}: SendEmailOptions) {
|
||||
try {
|
||||
// Check if email is configured
|
||||
if (!process.env.RESEND_API_KEY) {
|
||||
log.warn("RESEND_API_KEY not configured. Email not sent.", {
|
||||
to,
|
||||
subject,
|
||||
});
|
||||
return { success: false, error: "Email not configured" };
|
||||
}
|
||||
|
||||
const { data, error } = await resend.emails.send({
|
||||
from,
|
||||
to,
|
||||
subject,
|
||||
react,
|
||||
...(replyTo && { replyTo }),
|
||||
});
|
||||
|
||||
if (error) {
|
||||
log.error("Failed to send email", error, { to, subject });
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
|
||||
log.info("Email sent successfully", { to, subject, emailId: data?.id });
|
||||
return { success: true, data };
|
||||
} catch (error) {
|
||||
log.error("Email service error", error, { to, subject });
|
||||
return { success: false, error: "Email service error" };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a welcome email to a new user
|
||||
*/
|
||||
export async function sendWelcomeEmail(email: string, name: string) {
|
||||
const { WelcomeEmail } = await import("@/emails/welcome-email");
|
||||
|
||||
return sendEmail({
|
||||
to: email,
|
||||
subject: "Welcome to FitAI!",
|
||||
react: WelcomeEmail({ name }),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Send an invitation email to a new user
|
||||
*/
|
||||
export async function sendInvitationEmail(
|
||||
email: string,
|
||||
role: string,
|
||||
inviteUrl: string,
|
||||
) {
|
||||
const { UserInvitationEmail } = await import(
|
||||
"@/emails/user-invitation-email"
|
||||
);
|
||||
|
||||
return sendEmail({
|
||||
to: email,
|
||||
subject: "You've been invited to join FitAI",
|
||||
react: UserInvitationEmail({ email, role, inviteUrl }),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a trainer assignment notification email
|
||||
*/
|
||||
export async function sendTrainerAssignmentEmail(
|
||||
clientEmail: string,
|
||||
clientName: string,
|
||||
trainerName: string,
|
||||
) {
|
||||
const { TrainerAssignmentEmail } = await import(
|
||||
"@/emails/trainer-assignment-email"
|
||||
);
|
||||
|
||||
return sendEmail({
|
||||
to: clientEmail,
|
||||
subject: "You've been assigned a trainer",
|
||||
react: TrainerAssignmentEmail({ clientName, trainerName }),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a recommendation notification email to a client
|
||||
*/
|
||||
export async function sendRecommendationEmail(
|
||||
clientEmail: string,
|
||||
clientName: string,
|
||||
recommendationText: string,
|
||||
) {
|
||||
const { RecommendationEmail } = await import("@/emails/recommendation-email");
|
||||
|
||||
return sendEmail({
|
||||
to: clientEmail,
|
||||
subject: "New fitness recommendation from your trainer",
|
||||
react: RecommendationEmail({ clientName, recommendationText }),
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import { toast as sonnerToast } from "sonner";
|
||||
|
||||
/**
|
||||
* Toast notification utility wrapper around Sonner
|
||||
*
|
||||
* Provides a consistent API for showing toast notifications throughout the app
|
||||
*/
|
||||
export const toast = {
|
||||
/**
|
||||
* Show a success toast notification
|
||||
* @param message - The message to display
|
||||
* @param description - Optional description text
|
||||
*/
|
||||
success: (message: string, description?: string) => {
|
||||
return sonnerToast.success(message, { description });
|
||||
},
|
||||
|
||||
/**
|
||||
* Show an error toast notification
|
||||
* @param message - The message to display
|
||||
* @param description - Optional description text
|
||||
*/
|
||||
error: (message: string, description?: string) => {
|
||||
return sonnerToast.error(message, { description });
|
||||
},
|
||||
|
||||
/**
|
||||
* Show an info toast notification
|
||||
* @param message - The message to display
|
||||
* @param description - Optional description text
|
||||
*/
|
||||
info: (message: string, description?: string) => {
|
||||
return sonnerToast.info(message, { description });
|
||||
},
|
||||
|
||||
/**
|
||||
* Show a warning toast notification
|
||||
* @param message - The message to display
|
||||
* @param description - Optional description text
|
||||
*/
|
||||
warning: (message: string, description?: string) => {
|
||||
return sonnerToast.warning(message, { description });
|
||||
},
|
||||
|
||||
/**
|
||||
* Show a loading toast notification
|
||||
* @param message - The message to display
|
||||
* @returns Toast ID that can be used to update or dismiss the toast
|
||||
*/
|
||||
loading: (message: string) => {
|
||||
return sonnerToast.loading(message);
|
||||
},
|
||||
|
||||
/**
|
||||
* Show a promise toast that updates based on promise state
|
||||
* @param promise - The promise to track
|
||||
* @param messages - Messages for loading, success, and error states
|
||||
*/
|
||||
promise: <T>(
|
||||
promise: Promise<T>,
|
||||
messages: {
|
||||
loading: string;
|
||||
success: string | ((data: T) => string);
|
||||
error: string | ((error: Error) => string);
|
||||
},
|
||||
) => {
|
||||
return sonnerToast.promise(promise, messages);
|
||||
},
|
||||
|
||||
/**
|
||||
* Dismiss a specific toast by ID or all toasts
|
||||
* @param toastId - Optional toast ID to dismiss. If not provided, dismisses all toasts
|
||||
*/
|
||||
dismiss: (toastId?: string | number) => {
|
||||
return sonnerToast.dismiss(toastId);
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,111 @@
|
||||
import { z } from "zod";
|
||||
|
||||
/**
|
||||
* Validation schemas for user creation flow
|
||||
*/
|
||||
|
||||
// Step 1: Role Selection
|
||||
export const roleSelectionSchema = z.object({
|
||||
role: z.enum(["admin", "trainer", "client"], {
|
||||
message: "Please select a role",
|
||||
}),
|
||||
});
|
||||
|
||||
export type RoleSelectionData = z.infer<typeof roleSelectionSchema>;
|
||||
|
||||
// Step 2: Basic Information
|
||||
export const basicInfoSchema = z.object({
|
||||
firstName: z
|
||||
.string()
|
||||
.min(2, "First name must be at least 2 characters")
|
||||
.max(50, "First name must be less than 50 characters"),
|
||||
lastName: z
|
||||
.string()
|
||||
.min(2, "Last name must be at least 2 characters")
|
||||
.max(50, "Last name must be less than 50 characters"),
|
||||
phone: z
|
||||
.string()
|
||||
.regex(/^\+?[1-9]\d{1,14}$/, "Please enter a valid phone number")
|
||||
.optional()
|
||||
.or(z.literal("")),
|
||||
email: z.string().email("Please enter a valid email address"),
|
||||
});
|
||||
|
||||
export type BasicInfoData = z.infer<typeof basicInfoSchema>;
|
||||
|
||||
// Step 3: Client-Specific Information (only for clients)
|
||||
export const clientInfoSchema = z.object({
|
||||
membershipType: z.enum(["basic", "premium", "vip"], {
|
||||
message: "Please select a membership type",
|
||||
}),
|
||||
membershipStatus: z.enum(["active", "inactive", "suspended"]),
|
||||
sendWelcomeEmail: z.boolean(),
|
||||
});
|
||||
|
||||
export type ClientInfoData = z.infer<typeof clientInfoSchema>;
|
||||
|
||||
// Step 4: Invitation Options (for admin/trainer with email)
|
||||
export const invitationOptionsSchema = z.object({
|
||||
sendInvitation: z.boolean(),
|
||||
gymId: z.string().optional(),
|
||||
});
|
||||
|
||||
export type InvitationOptionsData = z.infer<typeof invitationOptionsSchema>;
|
||||
|
||||
// Complete user creation payload
|
||||
export const createUserSchema = z.object({
|
||||
role: z.enum(["admin", "trainer", "client"]),
|
||||
firstName: z.string().min(2).max(50),
|
||||
lastName: z.string().min(2).max(50),
|
||||
phone: z.string().optional(),
|
||||
email: z.string().email(),
|
||||
|
||||
// Client-specific fields
|
||||
membershipType: z.enum(["basic", "premium", "vip"]).optional(),
|
||||
membershipStatus: z.enum(["active", "inactive", "suspended"]).optional(),
|
||||
|
||||
// Invitation options
|
||||
sendInvitation: z.boolean(),
|
||||
sendWelcomeEmail: z.boolean(),
|
||||
gymId: z.string().optional(),
|
||||
});
|
||||
|
||||
export type CreateUserData = z.infer<typeof createUserSchema>;
|
||||
|
||||
/**
|
||||
* Helper function to validate and merge form data from all steps
|
||||
*/
|
||||
export function mergeUserCreationData(
|
||||
roleData: RoleSelectionData,
|
||||
basicInfo: BasicInfoData,
|
||||
clientInfo?: ClientInfoData,
|
||||
invitationOptions?: InvitationOptionsData,
|
||||
): CreateUserData {
|
||||
const merged: CreateUserData = {
|
||||
role: roleData.role,
|
||||
firstName: basicInfo.firstName,
|
||||
lastName: basicInfo.lastName,
|
||||
phone: basicInfo.phone || undefined,
|
||||
email: basicInfo.email,
|
||||
sendInvitation: false,
|
||||
sendWelcomeEmail: false,
|
||||
};
|
||||
|
||||
// Add client-specific data
|
||||
if (roleData.role === "client" && clientInfo) {
|
||||
merged.membershipType = clientInfo.membershipType;
|
||||
merged.membershipStatus = clientInfo.membershipStatus;
|
||||
merged.sendWelcomeEmail = clientInfo.sendWelcomeEmail;
|
||||
}
|
||||
|
||||
// Add invitation options for admin/trainer
|
||||
if (
|
||||
(roleData.role === "admin" || roleData.role === "trainer") &&
|
||||
invitationOptions
|
||||
) {
|
||||
merged.sendInvitation = invitationOptions.sendInvitation;
|
||||
merged.gymId = invitationOptions.gymId;
|
||||
}
|
||||
|
||||
return createUserSchema.parse(merged);
|
||||
}
|
||||
Reference in new issue
Block a user