working on onboarding flow

This commit is contained in:
dimitar committed 2025-12-13 06:26:23 +01:00
1 parent 868eaa5e3d
commit d3a36b6103
19 files changed
+2062 -393

No files matched your search

Binary file not shown.
@@ -0,0 +1,84 @@
import { NextResponse } from "next/server";
import { auth } from "@clerk/nextjs/server";
import { getDatabase } from "@/lib/database";
import { ensureUserSynced } from "@/lib/sync-user";
/**
* GET /api/admin/clients
*
* Admin:
* - Lists clients scoped to the admin's gym (requires admin.gymId).
*
* SuperAdmin:
* - Optional query param ?gymId=<id> to filter clients by a specific gym.
* - If no gymId provided, returns all clients across all gyms.
*
* Response: Array of client users with minimal fields for listing, including membership data.
*/
export async function GET(req: Request) {
try {
const { userId } = await auth();
if (!userId) {
return new NextResponse("Unauthorized", { status: 401 });
}
const db = await getDatabase();
const user = await ensureUserSynced(userId, db);
if (!user || (user.role !== "admin" && user.role !== "superAdmin")) {
return new NextResponse("Forbidden", { status: 403 });
}
const url = new URL(req.url);
const requestedGymId = url.searchParams.get("gymId");
// Admins must have a gymId; scope to their gym
let targetGymId: string | null = null;
if (user.role === "admin") {
if (!user.gymId) {
return new NextResponse("Admin gymId not set", { status: 400 });
}
targetGymId = user.gymId;
} else if (user.role === "superAdmin") {
targetGymId = requestedGymId;
}
// Fetch users and clients
const allUsers = await db.getAllUsers();
const usersById = new Map(allUsers.map((u) => [u.id, u]));
const allClients = await db.getAllClients();
// Scope clients by gym when provided
const scopedClients = targetGymId
? allClients.filter((c) => {
const u = usersById.get(c.userId);
return u?.gymId === targetGymId;
})
: allClients;
// Compose payload merging user and client info
const payload = scopedClients.map((c) => {
const u = usersById.get(c.userId);
return {
id: c.id,
userId: c.userId,
email: u?.email ?? null,
firstName: u?.firstName ?? null,
lastName: u?.lastName ?? null,
gymId: u?.gymId ?? null,
membershipType: c.membershipType,
membershipStatus: c.membershipStatus,
joinDate: c.joinDate,
lastVisit: c.lastVisit ?? null,
emergencyContact: c.emergencyContact ?? null,
createdAt: u?.createdAt ?? null,
updatedAt: u?.updatedAt ?? null,
};
});
return NextResponse.json(payload);
} catch (error) {
console.error("GET /api/admin/clients error:", error);
return new NextResponse("Internal Server Error", { status: 500 });
}
}
@@ -0,0 +1,166 @@
import { NextResponse } from "next/server";
import { auth, clerkClient } from "@clerk/nextjs/server";
/**
* POST /api/admin/set-user-metadata
*
* Sets Clerk publicMetadata.role and publicMetadata.gymId for a target user.
*
* Authorization:
* - Caller must be an admin or superAdmin (based on their Clerk publicMetadata.role).
*
* Request body:
* {
* "targetUserId": string, // Clerk user ID of the target
* "role": "superAdmin" | "admin" | "trainer" | "client" | "generalUser", // optional
* "gymId": string | null // optional; null clears gym assignment
* }
*
* Behavior:
* - If "role" is provided, update the target user's publicMetadata.role.
* - If "gymId" is provided (including null), update publicMetadata.gymId.
* - Validates inputs and permissions.
*
* Response:
* - 200 with updated minimal user data
* - 400/401/403/404/500 on errors
*/
export async function POST(req: Request) {
try {
// Authenticate the requester
const { userId } = await auth();
if (!userId) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const client = await clerkClient();
// Fetch requester from Clerk to verify permissions
const requester = await client.users.getUser(userId);
const requesterRole =
(requester.publicMetadata?.role as
| "superAdmin"
| "admin"
| "trainer"
| "client"
| "generalUser") ?? "client";
// Only admin or superAdmin can set metadata
if (requesterRole !== "admin" && requesterRole !== "superAdmin") {
return NextResponse.json(
{ error: "Forbidden: admin or superAdmin access required" },
{ status: 403 }
);
}
// Parse body
const body = await req.json().catch(() => null);
if (!body || typeof body !== "object") {
return NextResponse.json(
{ error: "Invalid JSON body" },
{ status: 400 }
);
}
const targetUserId = typeof body.targetUserId === "string" ? body.targetUserId : null;
const role = body.role as
| "superAdmin"
| "admin"
| "trainer"
| "client"
| "generalUser"
| undefined;
const gymId =
body.gymId === null
? null
: typeof body.gymId === "string"
? body.gymId
: undefined;
if (!targetUserId) {
return NextResponse.json(
{ error: "Invalid or missing targetUserId" },
{ status: 400 }
);
}
// Validate role if provided
const allowedRoles = ["superAdmin", "admin", "trainer", "client", "generalUser"] as const;
if (role && !allowedRoles.includes(role)) {
return NextResponse.json(
{ error: "Invalid role. Must be one of superAdmin, admin, trainer, client, generalUser" },
{ status: 400 }
);
}
// Prevent non-superAdmin from assigning superAdmin
if (role === "superAdmin" && requesterRole !== "superAdmin") {
return NextResponse.json(
{ error: "Only superAdmin can assign superAdmin role" },
{ status: 403 }
);
}
// Fetch target user to ensure they exist
let targetUser;
try {
targetUser = await client.users.getUser(targetUserId);
} catch {
return NextResponse.json({ error: "Target user not found" }, { status: 404 });
}
// Construct new metadata by merging with existing
const newPublicMetadata: Record<string, unknown> = {
...(targetUser.publicMetadata || {}),
};
if (role !== undefined) {
newPublicMetadata.role = role;
}
if (gymId !== undefined) {
newPublicMetadata.gymId = gymId;
}
// Ensure at least one field to update
if (role === undefined && gymId === undefined) {
return NextResponse.json(
{ error: "Provide at least one of 'role' or 'gymId' to update" },
{ status: 400 }
);
}
// Perform update on Clerk
const updatedUser = await client.users.updateUser(targetUserId, {
publicMetadata: newPublicMetadata,
});
// Construct response payload
const primaryEmail =
updatedUser.emailAddresses?.find(
(e) => e.id === updatedUser.primaryEmailAddressId
)?.emailAddress || updatedUser.emailAddresses?.[0]?.emailAddress || null;
return NextResponse.json(
{
success: true,
message: "User metadata updated",
user: {
id: updatedUser.id,
email: primaryEmail,
firstName: updatedUser.firstName,
lastName: updatedUser.lastName,
role: updatedUser.publicMetadata?.role ?? null,
gymId: updatedUser.publicMetadata?.gymId ?? null,
},
},
{ status: 200 }
);
} catch (error: any) {
const message =
error?.errors?.[0]?.message ||
error?.message ||
"Internal server error";
console.error("Error setting user metadata:", error);
return NextResponse.json({ error: message }, { status: 500 });
}
}
+54 -20
View File
@@ -1,25 +1,59 @@
import { auth } from '@clerk/nextjs/server'
import { NextResponse } from 'next/server'
import { getDatabase } from '@/lib/database'
import { ensureUserSynced } from '@/lib/sync-user'
import { auth } from "@clerk/nextjs/server";
import { NextResponse } from "next/server";
import { getDatabase } from "@/lib/database";
import { ensureUserSynced } from "@/lib/sync-user";
export async function GET() {
try {
const { userId } = await auth()
if (!userId) return new NextResponse('Unauthorized', { status: 401 })
export async function GET(req: Request) {
try {
const { userId } = await auth();
if (!userId) return new NextResponse("Unauthorized", { status: 401 });
const db = await getDatabase()
const user = await ensureUserSynced(userId, db)
const db = await getDatabase();
const user = await ensureUserSynced(userId, db);
if (!user || (user.role !== 'admin' && user.role !== 'superAdmin')) {
return new NextResponse('Forbidden', { status: 403 })
}
const stats = await db.getDashboardStats()
return NextResponse.json(stats)
} catch (error) {
console.error('Dashboard stats error:', error)
return new NextResponse('Internal Server Error', { status: 500 })
if (!user || (user.role !== "admin" && user.role !== "superAdmin")) {
return new NextResponse("Forbidden", { status: 403 });
}
if (user.role === "admin" && !user.gymId) {
return new NextResponse("Admin gymId not set", { status: 400 });
}
const url = new URL(req.url);
const searchParams = url.searchParams;
let targetGymId: string | null = null;
if (user.role === "admin") {
targetGymId = user.gymId ?? null;
} else if (user.role === "superAdmin") {
targetGymId = searchParams.get("gymId");
}
const allUsers = await db.getAllUsers();
const allClients = await db.getAllClients();
const usersById = new Map(allUsers.map((u) => [u.id, u]));
const filteredUsers = targetGymId
? allUsers.filter((u) => u.gymId === targetGymId)
: allUsers;
const filteredClients = targetGymId
? allClients.filter((c) => {
const u = usersById.get(c.userId);
return u?.gymId === targetGymId;
})
: allClients;
const stats = {
totalUsers: filteredUsers.length,
activeClients: filteredClients.filter(
(c) => c.membershipStatus === "active",
).length,
totalRevenue: 0,
revenueGrowth: 0,
};
return NextResponse.json(stats);
} catch (error) {
console.error("Dashboard stats error:", error);
return new NextResponse("Internal Server Error", { status: 500 });
}
}
@@ -0,0 +1,71 @@
import { NextResponse } from "next/server";
import { auth } from "@clerk/nextjs/server";
import { getDatabase } from "@/lib/database";
import { ensureUserSynced } from "@/lib/sync-user";
/**
* GET /api/admin/trainers
*
* Admin:
* - Lists trainers scoped to the admin's gym (requires admin.gymId).
*
* SuperAdmin:
* - Optional query param ?gymId=<id> to filter trainers by a specific gym.
* - If no gymId provided, returns all trainers across all gyms.
*
* Response: Array of trainer users with minimal fields for listing
*/
export async function GET(req: Request) {
try {
const { userId } = await auth();
if (!userId) {
return new NextResponse("Unauthorized", { status: 401 });
}
const db = await getDatabase();
const user = await ensureUserSynced(userId, db);
if (!user || (user.role !== "admin" && user.role !== "superAdmin")) {
return new NextResponse("Forbidden", { status: 403 });
}
const url = new URL(req.url);
const requestedGymId = url.searchParams.get("gymId");
// Admins must have a gymId; scope to their gym
let targetGymId: string | null = null;
if (user.role === "admin") {
if (!user.gymId) {
return new NextResponse("Admin gymId not set", { status: 400 });
}
targetGymId = user.gymId;
} else if (user.role === "superAdmin") {
targetGymId = requestedGymId;
}
// Fetch all users and filter to trainers
const allUsers = await db.getAllUsers();
let trainers = allUsers.filter((u) => u.role === "trainer");
// Scope by gym when required/provided
if (targetGymId) {
trainers = trainers.filter((t) => t.gymId === targetGymId);
}
// Minimal payload suitable for listing
const payload = trainers.map((t) => ({
id: t.id,
email: t.email,
firstName: t.firstName,
lastName: t.lastName,
gymId: t.gymId ?? null,
createdAt: t.createdAt,
updatedAt: t.updatedAt,
}));
return NextResponse.json(payload);
} catch (error) {
console.error("GET /api/admin/trainers error:", error);
return new NextResponse("Internal Server Error", { status: 500 });
}
}
+174
View File
@@ -0,0 +1,174 @@
import { NextResponse } from "next/server";
import { auth } from "@clerk/nextjs/server";
import { eq, sql } from "@fitai/database";
import { db, users as usersTable } from "@fitai/database";
import { ensureUserSynced } from "@/lib/sync-user";
async function ensureGymsTable() {
await db.run(sql`
CREATE TABLE IF NOT EXISTS gyms (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
location TEXT,
status TEXT NOT NULL CHECK (status IN ('active','inactive')) DEFAULT 'active',
admin_user_id TEXT NOT NULL,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
)
`);
}
// GET /api/gyms
// Lists active gyms for selection (grid)
export async function GET() {
try {
await ensureGymsTable();
const rows = await db.all(
sql`SELECT * FROM gyms WHERE status = 'active' ORDER BY created_at DESC`,
);
return NextResponse.json(rows);
} catch (error) {
console.error("GET /gyms error:", error);
return new NextResponse("Internal Server Error", { status: 500 });
}
}
// POST /api/gyms
// Create a gym. Allowed roles: superAdmin, admin.
// - admin: can only create gyms for themselves (adminUserId = current user)
// - superAdmin: can create for self or specify adminUserId
export async function POST(req: Request) {
try {
await ensureGymsTable();
const { userId } = await auth();
if (!userId) return new NextResponse("Unauthorized", { status: 401 });
// Ensure our local DB has the user synced (role, etc.)
const currentUser = await ensureUserSynced(userId, {
// minimal facade for ensureUserSynced to work: it expects an object implementing part of IDatabase
getUserById: async (id: string) => {
const row = await db
.select()
.from(usersTable)
.where(eq(usersTable.id, id))
.get();
return row
? {
id: row.id,
email: row.email,
firstName: row.firstName,
lastName: row.lastName,
password: row.password ?? "",
phone: row.phone ?? undefined,
role: row.role,
imageUrl: undefined,
createdAt: new Date(row.createdAt),
updatedAt: new Date(row.updatedAt),
}
: null;
},
updateUser: async (id: string, updates: any) => {
await db
.update(usersTable)
.set({
...updates,
updatedAt: new Date(),
})
.where(eq(usersTable.id, id))
.run();
const row = await db
.select()
.from(usersTable)
.where(eq(usersTable.id, id))
.get();
return row
? {
id: row.id,
email: row.email,
firstName: row.firstName,
lastName: row.lastName,
password: row.password ?? "",
phone: row.phone ?? undefined,
role: row.role,
imageUrl: undefined,
createdAt: new Date(row.createdAt),
updatedAt: new Date(row.updatedAt),
}
: null;
},
} as any);
if (
!currentUser ||
(currentUser.role !== "admin" && currentUser.role !== "superAdmin")
) {
return new NextResponse("Forbidden", { status: 403 });
}
const body = await req.json().catch(() => null);
if (!body || typeof body !== "object") {
return new NextResponse("Invalid JSON body", { status: 400 });
}
const name = String(body.name ?? "").trim();
const location = body.location ? String(body.location).trim() : null;
let adminUserId: string | null = body.adminUserId
? String(body.adminUserId)
: null;
if (!name) {
return NextResponse.json({ error: "name is required" }, { status: 400 });
}
// Enforce admin ownership rules
if (currentUser.role === "admin") {
adminUserId = currentUser.id;
} else if (currentUser.role === "superAdmin") {
adminUserId = adminUserId || currentUser.id;
}
// Basic check that adminUserId exists and is an admin or superAdmin
const adminRow = await db
.select()
.from(usersTable)
.where(eq(usersTable.id, adminUserId!))
.get();
if (
!adminRow ||
(adminRow.role !== "admin" && adminRow.role !== "superAdmin")
) {
return NextResponse.json(
{ error: "adminUserId must reference an admin or superAdmin" },
{ status: 400 },
);
}
const id = generateId();
const nowTs = Date.now();
await db.run(
sql`INSERT INTO gyms (id, name, location, status, admin_user_id, created_at, updated_at)
VALUES (${id}, ${name}, ${location ?? null}, 'active', ${adminUserId!}, ${nowTs}, ${nowTs})`,
);
// Assign the admin to this gym immediately after creation
await db.run(
sql`UPDATE users SET gym_id = ${id}, updated_at = ${nowTs} WHERE id = ${adminUserId!}`,
);
const created = await db.get(sql`SELECT * FROM gyms WHERE id = ${id}`);
return NextResponse.json(created, { status: 201 });
} catch (error) {
console.error("POST /gyms error:", error);
return new NextResponse("Internal Server Error", { status: 500 });
}
}
function generateId(): string {
// Simple URL-safe id generator
return (
Math.random().toString(36).slice(2, 10) +
Math.random().toString(36).slice(2, 10)
);
}
+148
View File
@@ -0,0 +1,148 @@
import { NextResponse } from "next/server";
import { auth, clerkClient } from "@clerk/nextjs/server";
/**
* POST /api/invitations
*
* Create a Clerk-managed invitation and store role/gym context in publicMetadata.
* This endpoint does not implement invitation acceptance; Clerk’s acceptance link flow and webhooks will handle that.
*
* Body: {
* inviteeEmail: string,
* roleAssigned: 'trainer' | 'client' | 'admin'
* }
*
* Rules:
* - admin: can invite trainer or client; the gymId is taken from inviter's publicMetadata or user record.
* - trainer: can invite client; the gymId is taken from inviter's publicMetadata or user record.
* - superAdmin: can invite admin; requires `gymId` in body, or falls back to inviter's `gymId` if present.
*
* Returns Clerk invitation payload.
*/
export async function POST(req: Request) {
try {
const { userId } = await auth();
if (!userId) {
return new NextResponse("Unauthorized", { status: 401 });
}
const body = await req.json().catch(() => null);
if (!body || typeof body !== "object") {
return NextResponse.json({ error: "Invalid JSON body" }, { status: 400 });
}
const inviteeEmail = String(body.inviteeEmail ?? "")
.trim()
.toLowerCase();
const roleAssigned = String(body.roleAssigned ?? "").trim() as
| "trainer"
| "client"
| "admin";
let requestedGymId: string | null = body.gymId ? String(body.gymId) : null;
if (!inviteeEmail || !roleAssigned) {
return NextResponse.json(
{ error: "inviteeEmail and roleAssigned are required" },
{ status: 400 },
);
}
// Fetch inviter user from Clerk
const client = await clerkClient();
const inviter = await client.users.getUser(userId);
const inviterRole =
(inviter.publicMetadata?.role as
| "superAdmin"
| "admin"
| "trainer"
| "client"
| "generalUser") ?? "client";
const inviterGymId =
(inviter.publicMetadata?.gymId as string | undefined) ?? undefined;
// Enforce role-based rules and resolve target gymId for the invitation
let gymIdForInvite: string | null = null;
switch (inviterRole) {
case "admin": {
if (roleAssigned !== "trainer" && roleAssigned !== "client") {
return NextResponse.json(
{ error: "Admin can only invite trainer or client" },
{ status: 403 },
);
}
if (!inviterGymId) {
return NextResponse.json(
{ error: "Inviter admin must be assigned to a gym" },
{ status: 400 },
);
}
gymIdForInvite = inviterGymId;
break;
}
case "trainer": {
if (roleAssigned !== "client") {
return NextResponse.json(
{ error: "Trainer can only invite client" },
{ status: 403 },
);
}
if (!inviterGymId) {
return NextResponse.json(
{ error: "Inviter trainer must be assigned to a gym" },
{ status: 400 },
);
}
gymIdForInvite = inviterGymId;
break;
}
case "superAdmin": {
if (
roleAssigned !== "admin" &&
roleAssigned !== "trainer" &&
roleAssigned !== "client"
) {
return NextResponse.json(
{ error: "Invalid roleAssigned for SuperAdmin" },
{ status: 400 },
);
}
// Prefer explicitly provided gymId, otherwise fall back to inviter's gymId if present
gymIdForInvite = requestedGymId || inviterGymId || null;
if (!gymIdForInvite) {
return NextResponse.json(
{ error: "gymId is required for SuperAdmin when inviting" },
{ status: 400 },
);
}
break;
}
default: {
return NextResponse.json(
{ error: "Inviter role not permitted to create invitations" },
{ status: 403 },
);
}
}
// Create Clerk invitation with metadata needed by webhook to assign role & gym
// reuse existing Clerk client instance
const invitation = await client.invitations.createInvitation({
emailAddress: inviteeEmail,
publicMetadata: {
roleAssigned,
gymId: gymIdForInvite,
inviterUserId: inviter.id,
},
});
return NextResponse.json(invitation, { status: 201 });
} catch (error: any) {
// Surface Clerk errors where possible
const message =
error?.errors?.[0]?.message ||
error?.message ||
"Failed to create invitation";
console.error("POST /api/invitations error:", error);
return NextResponse.json({ error: message }, { status: 500 });
}
}
+55
View File
@@ -0,0 +1,55 @@
import { NextResponse } from 'next/server'
import { auth } from '@clerk/nextjs/server'
import { db, users as usersTable, gyms as gymsTable, eq } from '@fitai/database'
/**
* PATCH /api/users/gym
* Body: { gymId: string | null }
* - Updates the current authenticated user's gym selection.
* - gymId can be null to proceed without a gym.
* - If gymId is provided, it must exist and be active.
*/
export async function PATCH(req: Request) {
try {
const { userId } = await auth()
if (!userId) return new NextResponse('Unauthorized', { status: 401 })
const body = await req.json().catch(() => null)
if (!body || typeof body !== 'object' || !('gymId' in body)) {
return NextResponse.json({ error: 'gymId is required in body (can be null)' }, { status: 400 })
}
const gymId = body.gymId === null ? null : String(body.gymId)
// Ensure user exists
const user = await db.select().from(usersTable).where(eq(usersTable.id, userId)).get()
if (!user) return new NextResponse('User not found', { status: 404 })
// Validate gym when provided
if (gymId) {
const gym = await db.select().from(gymsTable).where(eq(gymsTable.id, gymId)).get()
if (!gym) {
return NextResponse.json({ error: 'Gym not found' }, { status: 404 })
}
if (gym.status !== 'active') {
return NextResponse.json({ error: 'Gym is not active' }, { status: 400 })
}
}
// Update user's gym selection
await db
.update(usersTable)
.set({
gymId: gymId ?? null,
updatedAt: new Date(),
})
.where(eq(usersTable.id, userId))
.run()
const updated = await db.select().from(usersTable).where(eq(usersTable.id, userId)).get()
return NextResponse.json(updated)
} catch (error) {
console.error('PATCH /users/gym error:', error)
return new NextResponse('Internal Server Error', { status: 500 })
}
}
+90 -24
View File
@@ -45,14 +45,14 @@ export async function GET(request: NextRequest) {
const weekAgo = new Date();
weekAgo.setDate(weekAgo.getDate() - 7);
checkInsThisWeek = attendanceHistory.filter(
a => new Date(a.checkInTime) >= weekAgo
(a) => new Date(a.checkInTime) >= weekAgo,
).length;
// Calculate check-ins in last 30 days
const monthAgo = new Date();
monthAgo.setDate(monthAgo.getDate() - 30);
checkInsThisMonth = attendanceHistory.filter(
a => new Date(a.checkInTime) >= monthAgo
(a) => new Date(a.checkInTime) >= monthAgo,
).length;
}
@@ -63,7 +63,7 @@ export async function GET(request: NextRequest) {
checkInTime,
lastCheckInTime,
checkInsThisWeek,
checkInsThisMonth
checkInsThisMonth,
};
}),
);
@@ -88,12 +88,12 @@ export async function POST(request: NextRequest) {
const db = await getDatabase();
// Get current user to check role
// Note: In a real app, we'd map Clerk ID to our DB ID.
// Note: In a real app, we'd map Clerk ID to our DB ID.
// For now, we'll assume we can find the user by some means or trust the Clerk metadata if we synced it.
// Since we don't have Clerk ID in our local DB users table yet (we only have our own ID),
// we might need to rely on the user being synced.
// Let's assume the user calling this API is already in our DB.
// For the prototype, we'll fetch the user by matching the Clerk ID if we stored it,
// For the prototype, we'll fetch the user by matching the Clerk ID if we stored it,
// OR we'll assume the first user is Super Admin if no users exist?
// Actually, we should look up the user by email if we can't by ID, or add a clerkId column.
// For this step, let's assume we can get the user.
@@ -105,7 +105,10 @@ export async function POST(request: NextRequest) {
const currentUser = await db.getUserById(clerkUserId);
if (!currentUser) {
return NextResponse.json({ error: "Current user not found in database" }, { status: 403 });
return NextResponse.json(
{ error: "Current user not found in database" },
{ status: 403 },
);
}
const body = await request.json();
@@ -123,14 +126,14 @@ export async function POST(request: NextRequest) {
superAdmin: ["admin", "trainer", "client"],
admin: ["trainer", "client"],
trainer: ["client"],
client: []
client: [],
};
const userRole = currentUser.role as keyof typeof allowed;
if (!allowed[userRole] || !allowed[userRole].includes(role)) {
return NextResponse.json(
{ error: `You are not authorized to create a ${role}` },
{ status: 403 }
{ status: 403 },
);
}
@@ -152,20 +155,24 @@ export async function POST(request: NextRequest) {
publicMetadata: {
role,
},
ignoreExisting: true // Don't fail if invite exists
ignoreExisting: true, // Don't fail if invite exists
});
} catch (clerkError: any) {
console.error("Clerk invitation error:", clerkError);
// If user already exists in Clerk, we might want to handle it.
// But for now, let's proceed to create local record if invite sent or if they exist.
if (clerkError.errors?.[0]?.code === 'form_identifier_exists') {
if (clerkError.errors?.[0]?.code === "form_identifier_exists") {
return NextResponse.json(
{ error: "User already exists in Clerk system" },
{ status: 409 },
);
}
return NextResponse.json(
{ error: "Failed to send invitation: " + (clerkError.message || "Unknown error") },
{
error:
"Failed to send invitation: " +
(clerkError.message || "Unknown error"),
},
{ status: 500 },
);
}
@@ -182,16 +189,19 @@ export async function POST(request: NextRequest) {
});
// If creating a client, create the client record too
if (role === 'client') {
if (role === "client") {
await db.createClient({
userId: newUserId.id,
membershipType: 'basic',
membershipStatus: 'active',
joinDate: new Date()
membershipType: "basic",
membershipStatus: "active",
joinDate: new Date(),
});
}
return NextResponse.json({ userId: newUserId.id, message: "Invitation sent" }, { status: 201 });
return NextResponse.json(
{ userId: newUserId.id, message: "Invitation sent" },
{ status: 201 },
);
} catch (error) {
console.error("Create user error:", error);
return NextResponse.json(
@@ -205,7 +215,7 @@ export async function PUT(request: NextRequest) {
try {
const db = await getDatabase();
const body = await request.json();
const { id, email, firstName, lastName, role, phone } = body;
const { id, email, firstName, lastName, role, phone, gymId } = body;
if (!id) {
return NextResponse.json(
@@ -214,12 +224,43 @@ export async function PUT(request: NextRequest) {
);
}
// Get existing user
// Authenticate requester
const { userId: requesterId } = await auth();
if (!requesterId) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
// Fetch requester and target user
const requester = await db.getUserById(requesterId);
if (!requester) {
return NextResponse.json(
{ error: "Requester not found" },
{ status: 403 },
);
}
const existingUser = await db.getUserById(id);
if (!existingUser) {
return NextResponse.json({ error: "User not found" }, { status: 404 });
}
// Authorization: determine allowed role changes
const requesterRole = requester.role;
const allowedByRole: Record<string, string[]> = {
superAdmin: ["superAdmin", "admin", "trainer", "client", "generalUser"],
admin: ["admin", "trainer", "client", "generalUser"],
trainer: [], // trainers cannot change roles
client: [], // clients cannot change roles
generalUser: [], // general users cannot change roles
};
if (role && !allowedByRole[requesterRole]?.includes(role)) {
return NextResponse.json(
{ error: `Not authorized to assign role '${role}'` },
{ status: 403 },
);
}
// Check if email is being changed and if it's already taken
if (email && email !== existingUser.email) {
const userWithEmail = await db.getUserByEmail(email);
@@ -231,14 +272,39 @@ export async function PUT(request: NextRequest) {
}
}
// Update user
// Update Clerk publicMetadata (role/gymId) to propagate via webhook
// Note: Only update metadata when a change is requested
try {
const client = await clerkClient();
const publicMetadata: Record<string, unknown> = {};
if (role) {
publicMetadata.role = role;
}
if (gymId !== undefined) {
publicMetadata.gymId = gymId === null ? null : String(gymId);
}
if (Object.keys(publicMetadata).length > 0) {
await client.users.updateUser(id, { publicMetadata });
}
} catch (clerkErr: any) {
console.error("Clerk metadata update error:", clerkErr);
return NextResponse.json(
{ error: "Failed to update role/gym in identity provider" },
{ status: 500 },
);
}
// Update local DB for immediate UI feedback (webhook will also sync)
await db.updateUser(id, {
email: email || existingUser.email,
firstName: firstName || existingUser.firstName,
lastName: lastName || existingUser.lastName,
role: role || existingUser.role,
email: email ?? existingUser.email,
firstName: firstName ?? existingUser.firstName,
lastName: lastName ?? existingUser.lastName,
role: role ?? existingUser.role,
phone: phone !== undefined ? phone : existingUser.phone,
});
gymId: gymId !== undefined ? gymId : existingUser.gymId,
} as any);
return NextResponse.json({ success: true });
} catch (error) {
+99 -8
View File
@@ -78,15 +78,31 @@ export async function POST(req: Request) {
);
}
// Determine role from metadata or default to 'client'
// Determine role & gym from metadata
const role =
(public_metadata?.role as "admin" | "trainer" | "client") || "client";
(public_metadata?.role as
| "superAdmin"
| "admin"
| "trainer"
| "client"
| "generalUser") || "client";
const gymId = (public_metadata?.gymId as string | null) ?? null;
const inviterUserId =
(public_metadata?.inviterUserId as string | undefined) ?? undefined;
const roleAssigned =
(public_metadata?.roleAssigned as
| "superAdmin"
| "admin"
| "trainer"
| "client"
| "generalUser"
| undefined) ?? role;
// Insert user into database with Clerk's user ID
const now = new Date().toISOString();
const stmt = db.prepare(`
INSERT INTO users (id, email, firstName, lastName, password, phone, role, createdAt, updatedAt)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
INSERT INTO users (id, email, first_name, last_name, password, phone, role, gym_id, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`);
stmt.run(
@@ -97,11 +113,57 @@ export async function POST(req: Request) {
"", // Clerk handles authentication
null, // phone
role,
gymId,
now,
now,
);
console.log(`✅ User ${id} created in database`);
// If this is a client invited by a trainer, create trainer-client link
if (roleAssigned === "client" && inviterUserId && gymId) {
const inviterRow = db
.prepare("SELECT role FROM users WHERE id = ?")
.get(inviterUserId) as { role?: string } | undefined;
if (inviterRow?.role === "trainer") {
const linkId = `${Date.now().toString(36)}${Math.random().toString(36).slice(2, 8)}`;
const linkStmt = db.prepare(`
INSERT INTO trainer_clients (id, trainer_user_id, client_user_id, gym_id, created_at)
VALUES (?, ?, ?, ?, ?)
`);
linkStmt.run(
linkId,
inviterUserId,
id,
gymId,
new Date().toISOString(),
);
}
}
// If this is a trainer without a gymId but has an inviter, inherit inviter's gymId
if (
(roleAssigned === "trainer" || role === "trainer") &&
!gymId &&
inviterUserId
) {
const inviterGymRow = db
.prepare("SELECT gymId FROM users WHERE id = ?")
.get(inviterUserId) as { gymId?: string } | undefined;
if (inviterGymRow?.gym_id) {
const inheritStmt = db.prepare(`
UPDATE users
SET gym_id = ?, updated_at = ?
WHERE id = ?
`);
inheritStmt.run(inviterGymRow.gym_id, new Date().toISOString(), id);
gymId = inviterGymRow.gym_id;
}
}
console.log(
`✅ User ${id} created in database (role=${role}, gymId=${gymId ?? "null"})`,
);
db.close();
break;
}
@@ -124,15 +186,21 @@ export async function POST(req: Request) {
);
}
// Determine role from metadata
// Determine role & gym from metadata
const role =
(public_metadata?.role as "admin" | "trainer" | "client") || "client";
(public_metadata?.role as
| "superAdmin"
| "admin"
| "trainer"
| "client"
| "generalUser") || "client";
const gymId = (public_metadata?.gymId as string | null) ?? null;
// Update user in database
const now = new Date().toISOString();
const stmt = db.prepare(`
UPDATE users
SET email = ?, firstName = ?, lastName = ?, role = ?, updatedAt = ?
SET email = ?, first_name = ?, last_name = ?, role = ?, gym_id = ?, updated_at = ?
WHERE id = ?
`);
@@ -141,10 +209,33 @@ export async function POST(req: Request) {
first_name || "",
last_name || "",
role,
gymId,
now,
id,
);
// If user is a trainer and gymId is missing, attempt to inherit from inviter when available
if (
role === "trainer" &&
!gymId &&
evt.data.public_metadata?.inviterUserId
) {
const inviterUserId = String(evt.data.public_metadata.inviterUserId);
const inviterGymRow = db
.prepare("SELECT gym_id FROM users WHERE id = ?")
.get(inviterUserId) as { gym_id?: string } | undefined;
if (inviterGymRow?.gymId) {
const inheritStmt = db.prepare(`
UPDATE users
SET gym_id = ?, updated_at = ?
WHERE id = ?
`);
inheritStmt.run(inviterGymRow.gym_id, new Date().toISOString(), id);
gymId = inviterGymRow.gym_id;
}
}
console.log(`✅ User ${id} updated in database`);
db.close();
break;
+463 -152
View File
@@ -2,174 +2,485 @@
import { useEffect, useState } from "react";
import axios from "axios";
import { Database, Download, RefreshCw, AlertTriangle, Check, Loader2 } from "lucide-react";
import {
Database,
Download,
RefreshCw,
AlertTriangle,
Check,
Loader2,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { useUser } from "@clerk/nextjs";
interface Backup {
name: string;
size: number;
createdAt: string;
name: string;
size: number;
createdAt: string;
}
interface Gym {
id: string;
name: string;
location?: string | null;
status: "active" | "inactive";
adminUserId: string;
}
export default function SettingsPage() {
const [backups, setBackups] = useState<Backup[]>([]);
const [loading, setLoading] = useState(true);
const [creatingBackup, setCreatingBackup] = useState(false);
const [restoring, setRestoring] = useState<string | null>(null);
const [message, setMessage] = useState<{ type: 'success' | 'error', text: string } | null>(null);
const { user } = useUser();
const [backups, setBackups] = useState<Backup[]>([]);
const [loading, setLoading] = useState(true);
const [creatingBackup, setCreatingBackup] = useState(false);
const [restoring, setRestoring] = useState<string | null>(null);
const [message, setMessage] = useState<{
type: "success" | "error";
text: string;
} | null>(null);
const fetchBackups = async () => {
try {
const response = await axios.get("/api/admin/backups");
setBackups(response.data);
} catch (error) {
console.error("Failed to fetch backups:", error);
} finally {
setLoading(false);
}
};
// Gym picker state
const [gyms, setGyms] = useState<Gym[]>([]);
const [gymsLoading, setGymsLoading] = useState<boolean>(true);
const [gymMessage, setGymMessage] = useState<{
type: "success" | "error";
text: string;
} | null>(null);
// Create Gym modal state
const [showCreateGym, setShowCreateGym] = useState(false);
const [gymName, setGymName] = useState("");
const [gymLocation, setGymLocation] = useState("");
const [creatingGym, setCreatingGym] = useState(false);
useEffect(() => {
fetchBackups();
}, []);
const fetchBackups = async () => {
try {
const response = await axios.get("/api/admin/backups");
setBackups(response.data);
} catch (error) {
console.error("Failed to fetch backups:", error);
} finally {
setLoading(false);
}
};
const handleCreateBackup = async () => {
setCreatingBackup(true);
setMessage(null);
try {
await axios.post("/api/admin/backups");
await fetchBackups();
setMessage({ type: 'success', text: 'Backup created successfully' });
} catch (error) {
console.error("Failed to create backup:", error);
setMessage({ type: 'error', text: 'Failed to create backup' });
} finally {
setCreatingBackup(false);
}
};
const fetchGyms = async () => {
setGymsLoading(true);
setGymMessage(null);
try {
const res = await axios.get("/api/gyms");
setGyms(Array.isArray(res.data) ? res.data : []);
} catch (error) {
console.error("Failed to fetch gyms:", error);
setGymMessage({ type: "error", text: "Failed to load gyms" });
} finally {
setGymsLoading(false);
}
};
const handleRestore = async (filename: string) => {
if (!window.confirm(`Are you sure you want to restore from ${filename}? This will overwrite the current database.`)) {
return;
}
useEffect(() => {
fetchBackups();
fetchGyms();
}, []);
setRestoring(filename);
setMessage(null);
try {
await axios.post("/api/admin/backups/restore", { filename });
setMessage({ type: 'success', text: 'Database restored successfully' });
// Optional: Refresh page or force re-login if session is invalidated
} catch (error) {
console.error("Failed to restore backup:", error);
setMessage({ type: 'error', text: 'Failed to restore backup' });
} finally {
setRestoring(null);
}
};
const handleCreateBackup = async () => {
setCreatingBackup(true);
setMessage(null);
try {
await axios.post("/api/admin/backups");
await fetchBackups();
setMessage({ type: "success", text: "Backup created successfully" });
} catch (error) {
console.error("Failed to create backup:", error);
setMessage({ type: "error", text: "Failed to create backup" });
} finally {
setCreatingBackup(false);
}
};
const formatSize = (bytes: number) => {
const units = ['B', 'KB', 'MB', 'GB'];
let size = bytes;
let unitIndex = 0;
while (size >= 1024 && unitIndex < units.length - 1) {
size /= 1024;
unitIndex++;
}
return `${size.toFixed(2)} ${units[unitIndex]}`;
};
const handleRestore = async (filename: string) => {
if (
!window.confirm(
`Are you sure you want to restore from ${filename}? This will overwrite the current database.`,
)
) {
return;
}
const formatDate = (dateString: string) => {
return new Date(dateString).toLocaleString();
};
setRestoring(filename);
setMessage(null);
try {
await axios.post("/api/admin/backups/restore", { filename });
setMessage({ type: "success", text: "Database restored successfully" });
// Optional: Refresh page or force re-login if session is invalidated
} catch (error) {
console.error("Failed to restore backup:", error);
setMessage({ type: "error", text: "Failed to restore backup" });
} finally {
setRestoring(null);
}
};
return (
<div className="space-y-8 p-8">
const formatSize = (bytes: number) => {
const units = ["B", "KB", "MB", "GB"];
let size = bytes;
let unitIndex = 0;
while (size >= 1024 && unitIndex < units.length - 1) {
size /= 1024;
unitIndex++;
}
return `${size.toFixed(2)} ${units[unitIndex]}`;
};
const formatDate = (dateString: string) => {
return new Date(dateString).toLocaleString();
};
const handleSelectGym = async (gymId: string | null) => {
setGymMessage(null);
try {
// Update current user's gym selection
await axios.patch("/api/users/gym", { gymId });
setGymMessage({
type: "success",
text: gymId ? "Gym selected successfully" : "Proceeding without gym",
});
} catch (error) {
console.error("Failed to set gym:", error);
setGymMessage({ type: "error", text: "Failed to set gym" });
}
};
return (
<div className="space-y-8 p-8">
<div>
<h2 className="text-3xl font-bold text-slate-900">Settings</h2>
<p className="text-slate-500 mt-2">
Manage your application settings and database.
</p>
</div>
{/* Gym Picker */}
<div className="bg-white rounded-xl shadow-sm border border-slate-100 p-6">
<div className="flex items-center justify-between mb-6">
<div className="flex items-center gap-3">
<div className="p-2 bg-blue-50 rounded-lg">
<Database className="w-6 h-6 text-blue-600" />
</div>
<div>
<h2 className="text-3xl font-bold text-slate-900">Settings</h2>
<p className="text-slate-500 mt-2">Manage your application settings and database.</p>
</div>
<div className="bg-white rounded-xl shadow-sm border border-slate-100 p-6">
<div className="flex items-center justify-between mb-6">
<div className="flex items-center gap-3">
<div className="p-2 bg-blue-50 rounded-lg">
<Database className="w-6 h-6 text-blue-600" />
</div>
<div>
<h3 className="text-xl font-bold text-slate-900">Database Management</h3>
<p className="text-sm text-slate-500">Create backups and restore your database</p>
</div>
</div>
<Button
onClick={handleCreateBackup}
disabled={creatingBackup}
className="flex items-center gap-2"
>
{creatingBackup ? <Loader2 className="w-4 h-4 animate-spin" /> : <Download className="w-4 h-4" />}
Create Backup
</Button>
</div>
{message && (
<div className={`p-4 rounded-lg mb-6 flex items-center gap-2 ${message.type === 'success' ? 'bg-green-50 text-green-700' : 'bg-red-50 text-red-700'
}`}>
{message.type === 'success' ? <Check className="w-5 h-5" /> : <AlertTriangle className="w-5 h-5" />}
{message.text}
</div>
<h3 className="text-xl font-bold text-slate-900">
Gym Selection
</h3>
<p className="text-sm text-slate-500">
Select your gym or proceed without a gym
</p>
<p className="text-xs text-blue-600 mt-1">
{user ? (
<>
Current role:{" "}
<span className="font-medium">
{String(user.publicMetadata?.role ?? "unknown")}
</span>
{" • "}
Gym ID:{" "}
<span className="font-medium">
{String(user.publicMetadata?.gymId ?? "none")}
</span>
</>
) : (
"Loading user metadata..."
)}
<div className="border rounded-lg overflow-hidden">
<table className="w-full text-left text-sm">
<thead className="bg-slate-50 border-b">
<tr>
<th className="px-6 py-4 font-semibold text-slate-900">Filename</th>
<th className="px-6 py-4 font-semibold text-slate-900">Size</th>
<th className="px-6 py-4 font-semibold text-slate-900">Created At</th>
<th className="px-6 py-4 font-semibold text-slate-900 text-right">Actions</th>
</tr>
</thead>
<tbody className="divide-y">
{loading ? (
<tr>
<td colSpan={4} className="px-6 py-8 text-center text-slate-500">
Loading backups...
</td>
</tr>
) : backups.length === 0 ? (
<tr>
<td colSpan={4} className="px-6 py-8 text-center text-slate-500">
No backups found
</td>
</tr>
) : (
backups.map((backup) => (
<tr key={backup.name} className="hover:bg-slate-50 transition-colors">
<td className="px-6 py-4 font-medium text-slate-900">{backup.name}</td>
<td className="px-6 py-4 text-slate-600">{formatSize(backup.size)}</td>
<td className="px-6 py-4 text-slate-600">{formatDate(backup.createdAt)}</td>
<td className="px-6 py-4 text-right">
<Button
variant="ghost"
size="sm"
onClick={() => handleRestore(backup.name)}
disabled={!!restoring}
className="text-red-600 hover:text-red-700 hover:bg-red-50"
>
{restoring === backup.name ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
<RefreshCw className="w-4 h-4 mr-2" />
)}
Restore
</Button>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
</p>
</div>
</div>
<div className="flex items-center gap-2">
<Button
onClick={fetchGyms}
disabled={gymsLoading}
className="flex items-center gap-2"
>
{gymsLoading ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
<RefreshCw className="w-4 h-4" />
)}
Refresh Gyms
</Button>
<Button
onClick={() => setShowCreateGym(true)}
className="flex items-center gap-2"
>
Create Gym
</Button>
</div>
</div>
);
{gymMessage && (
<div
className={`p-4 rounded-lg mb-6 flex items-center gap-2 ${gymMessage.type === "success" ? "bg-green-50 text-green-700" : "bg-red-50 text-red-700"}`}
>
{gymMessage.type === "success" ? (
<Check className="w-5 h-5" />
) : (
<AlertTriangle className="w-5 h-5" />
)}
{gymMessage.text}
</div>
)}
{showCreateGym && (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div className="bg-white p-6 rounded-lg shadow-lg max-w-md w-full">
<h3 className="text-lg font-semibold mb-4">Create Gym</h3>
<form
onSubmit={async (e) => {
e.preventDefault();
try {
setCreatingGym(true);
await axios.post("/api/gyms", {
name: gymName.trim(),
location: gymLocation.trim() || undefined,
});
setGymMessage({
type: "success",
text: "Gym created successfully",
});
setShowCreateGym(false);
setGymName("");
setGymLocation("");
fetchGyms();
} catch (error) {
console.error("Failed to create gym:", error);
setGymMessage({
type: "error",
text: "Failed to create gym",
});
} finally {
setCreatingGym(false);
}
}}
>
<div className="mb-4">
<label className="block text-sm font-medium mb-1">
Gym Name
</label>
<input
type="text"
value={gymName}
onChange={(e) => setGymName(e.target.value)}
className="w-full border border-gray-300 rounded px-3 py-2"
required
/>
</div>
<div className="mb-4">
<label className="block text-sm font-medium mb-1">
Location (optional)
</label>
<input
type="text"
value={gymLocation}
onChange={(e) => setGymLocation(e.target.value)}
className="w-full border border-gray-300 rounded px-3 py-2"
placeholder="Enter location"
/>
</div>
<div className="flex justify-end gap-2">
<button
type="button"
onClick={() => {
setShowCreateGym(false);
setGymName("");
setGymLocation("");
}}
className="px-4 py-2 bg-gray-300 text-gray-700 rounded hover:bg-gray-400"
>
Cancel
</button>
<button
type="submit"
disabled={creatingGym}
className="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700 flex items-center gap-2"
>
{creatingGym ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : null}
Create
</button>
</div>
</form>
</div>
</div>
)}
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
<div className="border rounded-lg p-4 flex flex-col justify-between">
<div>
<h4 className="font-semibold text-slate-900">
Proceed without gym
</h4>
<p className="text-sm text-slate-600 mt-1">
You can select a gym later.
</p>
</div>
<Button
variant="outline"
className="mt-4"
onClick={() => handleSelectGym(null)}
>
Proceed without gym
</Button>
</div>
{gymsLoading ? (
<div className="col-span-full flex items-center justify-center p-8 text-slate-500">
<Loader2 className="w-5 h-5 animate-spin mr-2" />
Loading gyms...
</div>
) : gyms.length === 0 ? (
<div className="col-span-full p-8 text-center text-slate-500">
No active gyms found.
</div>
) : (
gyms.map((gym) => (
<div
key={gym.id}
className="border rounded-lg p-4 flex flex-col justify-between"
>
<div>
<h4 className="font-semibold text-slate-900">{gym.name}</h4>
<p className="text-sm text-slate-600 mt-1">
{gym.location || "No location provided"}
</p>
</div>
<Button
variant="default"
className="mt-4"
onClick={() => handleSelectGym(gym.id)}
>
Select this gym
</Button>
</div>
))
)}
</div>
</div>
<div className="bg-white rounded-xl shadow-sm border border-slate-100 p-6">
<div className="flex items-center justify-between mb-6">
<div className="flex items-center gap-3">
<div className="p-2 bg-blue-50 rounded-lg">
<Database className="w-6 h-6 text-blue-600" />
</div>
<div>
<h3 className="text-xl font-bold text-slate-900">
Database Management
</h3>
<p className="text-sm text-slate-500">
Create backups and restore your database
</p>
</div>
</div>
<Button
onClick={handleCreateBackup}
disabled={creatingBackup}
className="flex items-center gap-2"
>
{creatingBackup ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
<Download className="w-4 h-4" />
)}
Create Backup
</Button>
</div>
{message && (
<div
className={`p-4 rounded-lg mb-6 flex items-center gap-2 ${
message.type === "success"
? "bg-green-50 text-green-700"
: "bg-red-50 text-red-700"
}`}
>
{message.type === "success" ? (
<Check className="w-5 h-5" />
) : (
<AlertTriangle className="w-5 h-5" />
)}
{message.text}
</div>
)}
<div className="border rounded-lg overflow-hidden">
<table className="w-full text-left text-sm">
<thead className="bg-slate-50 border-b">
<tr>
<th className="px-6 py-4 font-semibold text-slate-900">
Filename
</th>
<th className="px-6 py-4 font-semibold text-slate-900">Size</th>
<th className="px-6 py-4 font-semibold text-slate-900">
Created At
</th>
<th className="px-6 py-4 font-semibold text-slate-900 text-right">
Actions
</th>
</tr>
</thead>
<tbody className="divide-y">
{loading ? (
<tr>
<td
colSpan={4}
className="px-6 py-8 text-center text-slate-500"
>
Loading backups...
</td>
</tr>
) : backups.length === 0 ? (
<tr>
<td
colSpan={4}
className="px-6 py-8 text-center text-slate-500"
>
No backups found
</td>
</tr>
) : (
backups.map((backup) => (
<tr
key={backup.name}
className="hover:bg-slate-50 transition-colors"
>
<td className="px-6 py-4 font-medium text-slate-900">
{backup.name}
</td>
<td className="px-6 py-4 text-slate-600">
{formatSize(backup.size)}
</td>
<td className="px-6 py-4 text-slate-600">
{formatDate(backup.createdAt)}
</td>
<td className="px-6 py-4 text-right">
<Button
variant="ghost"
size="sm"
onClick={() => handleRestore(backup.name)}
disabled={!!restoring}
className="text-red-600 hover:text-red-700 hover:bg-red-50"
>
{restoring === backup.name ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
<RefreshCw className="w-4 h-4 mr-2" />
)}
Restore
</Button>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
</div>
</div>
);
}
+68 -13
View File
@@ -16,13 +16,12 @@ function getTimeAgo(date: Date): string {
const diffHours = Math.floor(diffMins / 60);
const diffDays = Math.floor(diffHours / 24);
if (diffMins < 1) return 'just now';
if (diffMins < 1) return "just now";
if (diffMins < 60) return `${diffMins}m ago`;
if (diffHours < 24) return `${diffHours}h ago`;
return `${diffDays}d ago`;
}
interface User {
id: string;
email: string;
@@ -30,6 +29,7 @@ interface User {
lastName: string;
role: string;
phone?: string;
gymId?: string;
createdAt: Date;
isCheckedIn?: boolean;
checkInTime?: Date;
@@ -61,6 +61,31 @@ export function UserGrid({
}: UserGridProps) {
const [selectedUsers, setSelectedUsers] = useState<User[]>([]);
const [searchQuery, setSearchQuery] = useState<string>("");
const [gymNames, setGymNames] = useState<Record<string, string>>({});
React.useEffect(() => {
let isMounted = true;
(async () => {
try {
const res = await fetch("/api/gyms");
const data = await res.json();
if (isMounted && Array.isArray(data)) {
const map: Record<string, string> = {};
for (const g of data) {
if (g && g.id) {
map[g.id] = g.name || g.id;
}
}
setGymNames(map);
}
} catch (e) {
// silently fail; we'll show gymId if name not available
}
})();
return () => {
isMounted = false;
};
}, []);
const columnDefs: ColDef<User>[] = useMemo(
() => [
@@ -95,16 +120,34 @@ export function UserGrid({
roleColors[params.value as keyof typeof roleColors] ||
"bg-gray-100 text-gray-800";
const label = params.value === 'superAdmin' ? 'Super Admin' : params.value.charAt(0).toUpperCase() + params.value.slice(1);
const label =
params.value === "superAdmin"
? "Super Admin"
: params.value.charAt(0).toUpperCase() + params.value.slice(1);
return (
<span className={`px-2 py-1 rounded-full text-xs font-medium ${colorClass}`}>
<span
className={`px-2 py-1 rounded-full text-xs font-medium ${colorClass}`}
>
{label}
</span>
);
},
minWidth: 120,
},
{
headerName: "Gym",
field: "gymId",
filter: "agTextColumnFilter",
sortable: true,
minWidth: 160,
valueFormatter: (params: any) => {
const gymId = params.value;
if (!gymId) return "None";
return gymNames[gymId] || gymId;
},
},
{
headerName: "Phone",
field: "phone",
@@ -119,7 +162,8 @@ export function UserGrid({
filter: "agTextColumnFilter",
sortable: true,
cellRenderer: (params: any) => {
if (!params.value || params.value === "N/A") return <span className="text-gray-400">N/A</span>;
if (!params.value || params.value === "N/A")
return <span className="text-gray-400">N/A</span>;
const membershipColors = {
vip: "bg-yellow-100 text-yellow-800 border-yellow-200",
@@ -130,10 +174,13 @@ export function UserGrid({
membershipColors[params.value as keyof typeof membershipColors] ||
"bg-gray-100 text-gray-800";
const label = params.value.charAt(0).toUpperCase() + params.value.slice(1);
const label =
params.value.charAt(0).toUpperCase() + params.value.slice(1);
return (
<span className={`px-2 py-1 rounded-full text-xs font-medium border ${colorClass}`}>
<span
className={`px-2 py-1 rounded-full text-xs font-medium border ${colorClass}`}
>
{label}
</span>
);
@@ -146,7 +193,8 @@ export function UserGrid({
filter: "agTextColumnFilter",
sortable: true,
cellRenderer: (params: any) => {
if (!params.value || params.value === "N/A") return <span className="text-gray-400">N/A</span>;
if (!params.value || params.value === "N/A")
return <span className="text-gray-400">N/A</span>;
const statusColors = {
active: "bg-green-100 text-green-800",
@@ -158,10 +206,13 @@ export function UserGrid({
statusColors[params.value as keyof typeof statusColors] ||
"bg-gray-100 text-gray-800";
const label = params.value.charAt(0).toUpperCase() + params.value.slice(1);
const label =
params.value.charAt(0).toUpperCase() + params.value.slice(1);
return (
<span className={`px-2 py-1 rounded-full text-xs font-medium ${colorClass}`}>
<span
className={`px-2 py-1 rounded-full text-xs font-medium ${colorClass}`}
>
{label}
</span>
);
@@ -178,8 +229,10 @@ export function UserGrid({
return <span className="text-gray-400">—</span>;
}
const checkInTime = params.data.checkInTime ? new Date(params.data.checkInTime) : null;
const timeAgo = checkInTime ? getTimeAgo(checkInTime) : '';
const checkInTime = params.data.checkInTime
? new Date(params.data.checkInTime)
: null;
const timeAgo = checkInTime ? getTimeAgo(checkInTime) : "";
return (
<span className="px-2 py-1 rounded-full text-xs font-medium bg-green-100 text-green-800 border border-green-200">
@@ -231,7 +284,9 @@ export function UserGrid({
rowSelection: "multiple" as const,
onSelectionChanged: () => {
const selectedNodes = gridRef.current?.api.getSelectedNodes();
const selectedData = selectedNodes?.map((node) => node.data).filter((u): u is User => !!u) || [];
const selectedData =
selectedNodes?.map((node) => node.data).filter((u): u is User => !!u) ||
[];
setSelectedUsers(selectedData);
if (selectedData.length === 1 && onUserSelect) {
onUserSelect(selectedData[0]);
@@ -5,6 +5,7 @@ import { UserGrid } from "@/components/users/UserGrid";
// import { Button } from "@/components/ui/button";
import { Card, CardHeader, CardContent } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { useUser } from "@clerk/nextjs";
interface User {
id: string;
@@ -13,6 +14,7 @@ interface User {
lastName: string;
role: string;
phone?: string;
gymId?: string;
createdAt: Date;
isCheckedIn?: boolean;
checkInTime?: Date;
@@ -29,6 +31,7 @@ interface User {
}
export function UserManagement() {
const { user } = useUser();
const [users, setUsers] = useState<User[]>([]);
const [loading, setLoading] = useState(true);
const [filter, setFilter] = useState<string>("all");
@@ -41,8 +44,32 @@ export function UserManagement() {
email: string;
role: string;
phone: string;
gymId: string;
} | null>(null);
// Active gyms for dropdown
const [gyms, setGyms] = useState<Array<{ id: string; name: string }>>([]);
// Load gyms when modal opens or refreshes
useEffect(() => {
if (isEditing) {
(async () => {
try {
const res = await fetch("/api/gyms");
const data = await res.json();
if (Array.isArray(data)) {
// map down to id and name to avoid extra payload use here
setGyms(data.map((g: any) => ({ id: g.id, name: g.name })));
} else {
setGyms([]);
}
} catch {
setGyms([]);
}
})();
}
}, [isEditing]);
useEffect(() => {
fetchUsers();
}, [filter]);
@@ -74,6 +101,7 @@ export function UserManagement() {
email: user.email,
role: user.role,
phone: user.phone || "",
gymId: user.gymId || "",
});
setIsEditing(true);
};
@@ -150,10 +178,14 @@ export function UserManagement() {
try {
if (selectedUser) {
// Update existing user
const response = await fetch("/api/users", {
method: "PUT",
const response = await fetch("/api/admin/set-user-metadata", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ id: selectedUser.id, ...editForm }),
body: JSON.stringify({
targetUserId: selectedUser.id,
role: editForm.role,
gymId: editForm.gymId === "" ? null : editForm.gymId,
}),
});
if (response.ok) {
setIsEditing(false);
@@ -164,10 +196,14 @@ export function UserManagement() {
}
} else {
// Create (Invite) new user
const response = await fetch("/api/users", {
const response = await fetch("/api/invitations", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(editForm),
body: JSON.stringify({
inviteeEmail: editForm.email,
roleAssigned: editForm.role,
gymId: editForm.gymId || undefined,
}),
});
if (response.ok) {
@@ -232,6 +268,7 @@ export function UserManagement() {
email: "",
role: "client",
phone: "",
gymId: String((user?.publicMetadata as any)?.gymId ?? ""),
});
setSelectedUser(null);
setIsEditing(true);
@@ -308,7 +345,9 @@ export function UserManagement() {
{isEditing && editForm && (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div className="bg-white p-6 rounded-lg shadow-lg max-w-md w-full">
<h3 className="text-lg font-semibold mb-4">{selectedUser ? 'Edit User' : 'Invite New User'}</h3>
<h3 className="text-lg font-semibold mb-4">
{selectedUser ? "Edit User" : "Invite New User"}
</h3>
<form
onSubmit={(e) => {
e.preventDefault();
@@ -366,8 +405,8 @@ export function UserManagement() {
className="w-full border border-gray-300 rounded px-3 py-2"
required
>
{/* Ideally we fetch current user role to filter these.
For now, we show all but the API will enforce it.
{/* Ideally we fetch current user role to filter these.
For now, we show all but the API will enforce it.
We can add a visual indicator or fetch "me" to filter. */}
<option value="client">Client</option>
<option value="trainer">Trainer</option>
@@ -389,6 +428,26 @@ export function UserManagement() {
className="w-full border border-gray-300 rounded px-3 py-2"
/>
</div>
<div className="mb-4">
<label className="block text-sm font-medium mb-1">Gym</label>
<select
value={editForm.gymId}
onChange={(e) =>
setEditForm({ ...editForm, gymId: e.target.value })
}
className="w-full border border-gray-300 rounded px-3 py-2"
>
<option value="">Proceed without gym</option>
{gyms.map((g) => (
<option key={g.id} value={g.id}>
{g.name}
</option>
))}
</select>
<p className="text-xs text-gray-500 mt-1">
Select an active gym or proceed without a gym.
</p>
</div>
<div className="flex justify-end gap-2">
<button
type="button"
@@ -404,7 +463,7 @@ export function UserManagement() {
type="submit"
className="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700"
>
{selectedUser ? 'Save Changes' : 'Send Invitation'}
{selectedUser ? "Save Changes" : "Send Invitation"}
</button>
</div>
</form>
@@ -499,8 +558,8 @@ export function UserManagement() {
<span className="font-medium">Last Visit:</span>{" "}
{selectedUser.client.lastVisit
? new Date(
selectedUser.client.lastVisit,
).toLocaleDateString()
selectedUser.client.lastVisit,
).toLocaleDateString()
: "Never"}
</p>
</div>
@@ -513,9 +572,7 @@ export function UserManagement() {
<p>
<span className="font-medium">Last Check-In:</span>{" "}
{selectedUser.lastCheckInTime
? new Date(
selectedUser.lastCheckInTime,
).toLocaleString()
? new Date(selectedUser.lastCheckInTime).toLocaleString()
: "Never"}
</p>
<p>
+96 -5
View File
@@ -1,4 +1,4 @@
import React, { useState } from "react";
import React, { useState, useEffect } from "react";
import {
View,
Text,
@@ -18,6 +18,32 @@ export default function OnboardingScreen() {
const { getToken } = useAuth();
const router = useRouter();
const [isSubmitting, setIsSubmitting] = useState(false);
const [gyms, setGyms] = useState<
Array<{ id: string; name: string; location?: string }>
>([]);
const [gymsLoading, setGymsLoading] = useState<boolean>(false);
const [selectedGymId, setSelectedGymId] = useState<string | null>(null);
useEffect(() => {
const loadGyms = async () => {
try {
setGymsLoading(true);
const token = await getToken();
const res = await fetch("/api/gyms", {
headers: token ? { Authorization: `Bearer ${token}` } : undefined,
});
const data = await res.json();
if (Array.isArray(data)) {
setGyms(data);
}
} catch (e) {
console.error("Failed to fetch gyms:", e);
} finally {
setGymsLoading(false);
}
};
loadGyms();
}, []);
const [fitnessProfile, setFitnessProfile] = useState({
height: "",
weight: "",
@@ -41,6 +67,26 @@ export default function OnboardingScreen() {
return;
}
const token = await getToken();
if (!token) {
throw new Error("Authentication token not available");
}
// If gym was selected or cleared, patch user's gym selection first
// selectedGymId: string gym id, or null to proceed without gym
try {
await fetch("/api/users/gym", {
method: "PATCH",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({ gymId: selectedGymId }),
});
} catch (e) {
console.warn("Failed to update gym selection:", e);
}
const fitnessData = {
clientId: user.id,
height: parseFloat(fitnessProfile.height),
@@ -59,10 +105,6 @@ export default function OnboardingScreen() {
workoutFrequency: parseInt(fitnessProfile.workoutFrequency) || 3,
};
const token = await getToken();
if (!token) {
throw new Error("Authentication token not available");
}
await fitnessProfileApi.createFitnessProfile(fitnessData, token);
router.replace("/(tabs)");
} catch (error) {
@@ -213,6 +255,55 @@ export default function OnboardingScreen() {
placeholder="Number of workouts per week"
/>
<Text style={styles.label}>Select a Gym</Text>
{gymsLoading ? (
<ActivityIndicator />
) : (
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
style={{ marginBottom: 16 }}
>
<View style={{ flexDirection: "row" }}>
<TouchableOpacity
style={[
styles.levelButton,
selectedGymId === null && styles.selectedButton,
]}
onPress={() => setSelectedGymId(null)}
>
<Text
style={[
styles.levelButtonText,
selectedGymId === null && styles.selectedButtonText,
]}
>
Proceed without gym
</Text>
</TouchableOpacity>
{gyms.map((gym) => (
<TouchableOpacity
key={gym.id}
style={[
styles.levelButton,
selectedGymId === gym.id && styles.selectedButton,
]}
onPress={() => setSelectedGymId(gym.id)}
>
<Text
style={[
styles.levelButtonText,
selectedGymId === gym.id && styles.selectedButtonText,
]}
>
{gym.name}
</Text>
</TouchableOpacity>
))}
</View>
</ScrollView>
)}
<TouchableOpacity
style={styles.submitButton}
onPress={handleSubmit}
+224 -48
View File
@@ -1,5 +1,14 @@
import { View, Text, StyleSheet, TouchableOpacity, Image, Alert } from "react-native";
import { useUser, useClerk } from "@clerk/clerk-expo";
import {
View,
Text,
StyleSheet,
TouchableOpacity,
Image,
Alert,
ScrollView,
ActivityIndicator,
} from "react-native";
import { useUser, useClerk, useAuth } from "@clerk/clerk-expo";
import { useRouter } from "expo-router";
import { Ionicons } from "@expo/vector-icons";
import { LinearGradient } from "expo-linear-gradient";
@@ -21,14 +30,55 @@ export default function ProfileScreen() {
};
const confirmSignOut = () => {
Alert.alert(
"Sign Out",
"Are you sure you want to sign out?",
[
{ text: "Cancel", style: "cancel" },
{ text: "Sign Out", style: "destructive", onPress: handleSignOut },
]
);
Alert.alert("Sign Out", "Are you sure you want to sign out?", [
{ text: "Cancel", style: "cancel" },
{ text: "Sign Out", style: "destructive", onPress: handleSignOut },
]);
};
// Gym selection state and handlers
const { getToken } = useAuth();
const [gyms, setGyms] = useState<
Array<{ id: string; name: string; location?: string }>
>([]);
const [gymsLoading, setGymsLoading] = useState(false);
const [selectedGymId, setSelectedGymId] = useState<string | null>(null);
const loadGyms = async () => {
try {
setGymsLoading(true);
const token = await getToken();
const res = await fetch("/api/gyms", {
headers: token ? { Authorization: `Bearer ${token}` } : undefined,
});
const data = await res.json();
setGyms(Array.isArray(data) ? data : []);
} catch (err) {
console.error("Failed to fetch gyms:", err);
} finally {
setGymsLoading(false);
}
};
const handleApplyGym = async () => {
try {
const token = await getToken();
await fetch("/api/users/gym", {
method: "PATCH",
headers: {
"Content-Type": "application/json",
...(token ? { Authorization: `Bearer ${token}` } : {}),
},
body: JSON.stringify({ gymId: selectedGymId }),
});
Alert.alert(
"Success",
selectedGymId ? "Gym selected successfully" : "Proceeding without gym",
);
} catch (err) {
console.error("Failed to update gym selection:", err);
Alert.alert("Error", "Failed to update gym selection");
}
};
return (
@@ -48,7 +98,9 @@ export default function ProfileScreen() {
</View>
</View>
<Text style={styles.name}>{user?.fullName || "User"}</Text>
<Text style={styles.email}>{user?.primaryEmailAddress?.emailAddress}</Text>
<Text style={styles.email}>
{user?.primaryEmailAddress?.emailAddress}
</Text>
<View style={styles.memberBadge}>
<Text style={styles.memberText}>Premium Member</Text>
</View>
@@ -59,39 +111,144 @@ export default function ProfileScreen() {
<View style={styles.section}>
<Text style={styles.sectionTitle}>Account</Text>
<View style={[styles.infoCard, theme.shadows.subtle]}>
<TouchableOpacity style={styles.infoRow} onPress={() => router.push('/personal-details')}>
<TouchableOpacity
style={styles.infoRow}
onPress={() => router.push("/personal-details")}
>
<LinearGradient
colors={['rgba(59, 130, 246, 0.1)', 'rgba(59, 130, 246, 0.05)']}
colors={["rgba(59, 130, 246, 0.1)", "rgba(59, 130, 246, 0.05)"]}
style={styles.iconContainer}
>
<Ionicons name="person-outline" size={20} color={theme.colors.primary} />
<Ionicons
name="person-outline"
size={20}
color={theme.colors.primary}
/>
</LinearGradient>
<Text style={styles.infoLabel}>Personal Details</Text>
<Ionicons name="chevron-forward" size={20} color={theme.colors.gray400} />
<Ionicons
name="chevron-forward"
size={20}
color={theme.colors.gray400}
/>
</TouchableOpacity>
<View style={styles.divider} />
<TouchableOpacity style={styles.infoRow} onPress={() => router.push('/fitness-profile')}>
<TouchableOpacity
style={styles.infoRow}
onPress={() => router.push("/fitness-profile")}
>
<LinearGradient
colors={['rgba(16, 185, 129, 0.1)', 'rgba(16, 185, 129, 0.05)']}
colors={["rgba(16, 185, 129, 0.1)", "rgba(16, 185, 129, 0.05)"]}
style={styles.iconContainer}
>
<Ionicons name="fitness-outline" size={20} color={theme.colors.success} />
<Ionicons
name="fitness-outline"
size={20}
color={theme.colors.success}
/>
</LinearGradient>
<Text style={styles.infoLabel}>Fitness Profile</Text>
<Ionicons name="chevron-forward" size={20} color={theme.colors.gray400} />
<Ionicons
name="chevron-forward"
size={20}
color={theme.colors.gray400}
/>
</TouchableOpacity>
<View style={styles.divider} />
<TouchableOpacity style={styles.infoRow}>
<LinearGradient
colors={['rgba(245, 158, 11, 0.1)', 'rgba(245, 158, 11, 0.05)']}
colors={["rgba(245, 158, 11, 0.1)", "rgba(245, 158, 11, 0.05)"]}
style={styles.iconContainer}
>
<Ionicons name="notifications-outline" size={20} color={theme.colors.warning} />
<Ionicons
name="notifications-outline"
size={20}
color={theme.colors.warning}
/>
</LinearGradient>
<Text style={styles.infoLabel}>Notifications</Text>
<Ionicons name="chevron-forward" size={20} color={theme.colors.gray400} />
<Ionicons
name="chevron-forward"
size={20}
color={theme.colors.gray400}
/>
</TouchableOpacity>
</View>
{/* Gym Selection */}
<View
style={[styles.infoCard, theme.shadows.subtle, { marginTop: 12 }]}
>
<View
style={{
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
padding: 12,
}}
>
<Text style={styles.infoLabel}>Gym</Text>
<TouchableOpacity onPress={loadGyms}>
<Text style={{ color: theme.colors.primary }}>
Refresh Gyms
</Text>
</TouchableOpacity>
</View>
{gymsLoading ? (
<ActivityIndicator style={{ padding: 12 }} />
) : (
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
style={{ paddingHorizontal: 12, paddingBottom: 12 }}
>
<View style={{ flexDirection: "row" }}>
<TouchableOpacity
style={[
styles.infoRow,
selectedGymId === null && {
backgroundColor: "rgba(59, 130, 246, 0.1)",
},
]}
onPress={() => setSelectedGymId(null)}
>
<Text style={styles.infoLabel}>Proceed without gym</Text>
</TouchableOpacity>
{gyms.map((gym) => (
<TouchableOpacity
key={gym.id}
style={[
styles.infoRow,
selectedGymId === gym.id && {
backgroundColor: "rgba(59, 130, 246, 0.1)",
},
]}
onPress={() => setSelectedGymId(gym.id)}
>
<Text style={styles.infoLabel}>{gym.name}</Text>
</TouchableOpacity>
))}
</View>
</ScrollView>
)}
<View style={{ padding: 12 }}>
<AnimatedButton
title="Apply Gym Selection"
onPress={handleApplyGym}
variant="primary"
style={{ marginTop: 4 }}
icon={
<Ionicons
name="checkmark-outline"
size={20}
color={theme.colors.white}
/>
}
/>
</View>
</View>
</View>
<View style={styles.section}>
@@ -99,24 +256,43 @@ export default function ProfileScreen() {
<View style={[styles.infoCard, theme.shadows.subtle]}>
<TouchableOpacity style={styles.infoRow}>
<LinearGradient
colors={['rgba(139, 92, 246, 0.1)', 'rgba(139, 92, 246, 0.05)']}
colors={["rgba(139, 92, 246, 0.1)", "rgba(139, 92, 246, 0.05)"]}
style={styles.iconContainer}
>
<Ionicons name="help-circle-outline" size={20} color={theme.colors.secondary} />
<Ionicons
name="help-circle-outline"
size={20}
color={theme.colors.secondary}
/>
</LinearGradient>
<Text style={styles.infoLabel}>Help Center</Text>
<Ionicons name="chevron-forward" size={20} color={theme.colors.gray400} />
<Ionicons
name="chevron-forward"
size={20}
color={theme.colors.gray400}
/>
</TouchableOpacity>
<View style={styles.divider} />
<TouchableOpacity style={styles.infoRow}>
<LinearGradient
colors={['rgba(107, 114, 128, 0.1)', 'rgba(107, 114, 128, 0.05)']}
colors={[
"rgba(107, 114, 128, 0.1)",
"rgba(107, 114, 128, 0.05)",
]}
style={styles.iconContainer}
>
<Ionicons name="shield-checkmark-outline" size={20} color={theme.colors.gray600} />
<Ionicons
name="shield-checkmark-outline"
size={20}
color={theme.colors.gray600}
/>
</LinearGradient>
<Text style={styles.infoLabel}>Privacy & Security</Text>
<Ionicons name="chevron-forward" size={20} color={theme.colors.gray400} />
<Ionicons
name="chevron-forward"
size={20}
color={theme.colors.gray400}
/>
</TouchableOpacity>
</View>
</View>
@@ -145,13 +321,13 @@ const styles = StyleSheet.create({
paddingBottom: 30,
borderBottomLeftRadius: theme.borderRadius.xl,
borderBottomRightRadius: theme.borderRadius.xl,
alignItems: 'center',
alignItems: "center",
},
profileCard: {
alignItems: 'center',
alignItems: "center",
},
avatarContainer: {
position: 'relative',
position: "relative",
marginBottom: 16,
},
avatar: {
@@ -159,28 +335,28 @@ const styles = StyleSheet.create({
height: 100,
borderRadius: 50,
borderWidth: 4,
borderColor: 'rgba(255, 255, 255, 0.3)',
borderColor: "rgba(255, 255, 255, 0.3)",
},
placeholderAvatar: {
width: 100,
height: 100,
borderRadius: 50,
backgroundColor: 'rgba(255, 255, 255, 0.2)',
justifyContent: 'center',
alignItems: 'center',
backgroundColor: "rgba(255, 255, 255, 0.2)",
justifyContent: "center",
alignItems: "center",
borderWidth: 4,
borderColor: 'rgba(255, 255, 255, 0.3)',
borderColor: "rgba(255, 255, 255, 0.3)",
},
editBadge: {
position: 'absolute',
position: "absolute",
bottom: 0,
right: 0,
backgroundColor: theme.colors.white,
width: 32,
height: 32,
borderRadius: 16,
justifyContent: 'center',
alignItems: 'center',
justifyContent: "center",
alignItems: "center",
shadowColor: "#000",
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.1,
@@ -188,23 +364,23 @@ const styles = StyleSheet.create({
elevation: 3,
},
name: {
fontSize: theme.typography.fontSize['2xl'],
fontSize: theme.typography.fontSize["2xl"],
fontWeight: theme.typography.fontWeight.bold,
color: theme.colors.white,
marginBottom: 4,
},
email: {
fontSize: theme.typography.fontSize.sm,
color: 'rgba(255, 255, 255, 0.8)',
color: "rgba(255, 255, 255, 0.8)",
marginBottom: 12,
},
memberBadge: {
backgroundColor: 'rgba(255, 255, 255, 0.2)',
backgroundColor: "rgba(255, 255, 255, 0.2)",
paddingHorizontal: 12,
paddingVertical: 6,
borderRadius: theme.borderRadius.full,
borderWidth: 1,
borderColor: 'rgba(255, 255, 255, 0.3)',
borderColor: "rgba(255, 255, 255, 0.3)",
},
memberText: {
color: theme.colors.white,
@@ -234,16 +410,16 @@ const styles = StyleSheet.create({
borderColor: theme.colors.gray100,
},
infoRow: {
flexDirection: 'row',
alignItems: 'center',
flexDirection: "row",
alignItems: "center",
padding: 12,
},
iconContainer: {
width: 36,
height: 36,
borderRadius: 10,
justifyContent: 'center',
alignItems: 'center',
justifyContent: "center",
alignItems: "center",
marginRight: 12,
},
infoLabel: {
@@ -261,7 +437,7 @@ const styles = StyleSheet.create({
marginTop: 8,
},
version: {
textAlign: 'center',
textAlign: "center",
marginTop: 24,
color: theme.colors.gray400,
fontSize: theme.typography.fontSize.xs,
+15 -14
View File
@@ -1,29 +1,30 @@
export const API_BASE_URL = __DEV__
? 'https://e0877d294c41.ngrok-free.app'
: 'https://your-production-url.com'
? "https://e0877d294c41.ngrok-free.app"
: "https://your-production-url.com";
export const API_ENDPOINTS = {
AUTH: {
LOGIN: '/api/auth/login',
REGISTER: '/api/auth/register',
LOGIN: "/api/auth/login",
REGISTER: "/api/auth/register",
},
PROFILE: {
FITNESS: '/api/profile/fitness',
FITNESS: "/api/profile/fitness",
},
CLIENTS: '/api/clients',
USERS: '/api/users',
CLIENTS: "/api/clients",
USERS: "/api/users",
GYMS: "/api/gyms",
ATTENDANCE: {
CHECK_IN: '/api/attendance/check-in',
CHECK_OUT: '/api/attendance/check-out',
HISTORY: '/api/attendance/history',
CHECK_IN: "/api/attendance/check-in",
CHECK_OUT: "/api/attendance/check-out",
HISTORY: "/api/attendance/history",
},
RECOMMENDATIONS: '/api/recommendations',
RECOMMENDATIONS: "/api/recommendations",
FITNESS_GOALS: {
LIST: '/api/fitness-goals',
CREATE: '/api/fitness-goals',
LIST: "/api/fitness-goals",
CREATE: "/api/fitness-goals",
GET: (id: string) => `/api/fitness-goals/${id}`,
UPDATE: (id: string) => `/api/fitness-goals/${id}`,
DELETE: (id: string) => `/api/fitness-goals/${id}`,
COMPLETE: (id: string) => `/api/fitness-goals/${id}/complete`,
},
}
};