diff --git a/.gitignore b/.gitignore index 46877753e..eb035e481 100644 --- a/.gitignore +++ b/.gitignore @@ -91,3 +91,6 @@ distapps/bot/.env .env apps/codebility/public/assets/onboardingvideos/*.mp4 .planning/ +.cursor/ +docs/agents +skills-lock.json \ No newline at end of file diff --git a/CLAUDE.md b/CLAUDE.md index 894eae34b..5e9e1a3bc 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -199,4 +199,18 @@ All database migrations are located in `apps/codebility/supabase/migrations/` **Legacy Migrations:** The `migration-scripts/` folder in the root contains deprecated migration tools and scripts. -Do not add new migrations there. See `migration-scripts/DEPRECATION_NOTICE.md` for details. \ No newline at end of file +Do not add new migrations there. See `migration-scripts/DEPRECATION_NOTICE.md` for details. + +## Agent skills + +### Issue tracker + +Issues and specs live in GitHub Issues for `CodebilityDev/codebility-plus`. See `docs/agents/issue-tracker.md`. + +### Triage labels + +Five canonical triage roles mapped to GitHub label strings. See `docs/agents/triage-labels.md`. + +### Domain docs + +Single-context layout: root `CONTEXT.md` and `docs/adr/`. See `docs/agents/domain.md`. \ No newline at end of file diff --git a/apps/codebility/__tests__/hooks/useLeaderboard.test.ts b/apps/codebility/__tests__/hooks/useLeaderboard.test.ts index 96d923e44..564c0e36b 100644 --- a/apps/codebility/__tests__/hooks/useLeaderboard.test.ts +++ b/apps/codebility/__tests__/hooks/useLeaderboard.test.ts @@ -1,5 +1,5 @@ import { renderHook, waitFor } from "@testing-library/react"; -import { useLeaderboard } from "@/hooks/useLeaderboard"; +import { useLeaderboard } from "@/hooks/leaderboard/useLeaderboard"; // Mock the API calls global.fetch = jest.fn(); diff --git a/apps/codebility/app/home/account-settings/action.ts b/apps/codebility/actions/account-settings/actions.ts similarity index 94% rename from apps/codebility/app/home/account-settings/action.ts rename to apps/codebility/actions/account-settings/actions.ts index 2eef8c3e3..b89164f4a 100644 --- a/apps/codebility/app/home/account-settings/action.ts +++ b/apps/codebility/actions/account-settings/actions.ts @@ -2,6 +2,7 @@ import { createClientServerComponent } from "@/utils/supabase/server"; import { revalidatePath } from "next/cache"; +import { requireUser } from "@/lib/server/auth-guard"; export const updatePassword = async (formData: FormData) => { const email = formData.get("email") as string; @@ -112,7 +113,12 @@ export const getUsernameData = async (userId: string) => { }; // Update username -export const updateUsername = async (userId: string, newUsername: string) => { +export const updateUsername = async (newUsername: string) => { + // Identity from the session. This previously took a `userId` parameter and + // wrote to that row unchecked, so any caller could rename another user. + const { user } = await requireUser(); + const userId = user.id; + const supabase = await createClientServerComponent(); // 1. Validate username format diff --git a/apps/codebility/app/home/admin-controls/appointments/actions.ts b/apps/codebility/actions/admin/appointments.ts similarity index 96% rename from apps/codebility/app/home/admin-controls/appointments/actions.ts rename to apps/codebility/actions/admin/appointments.ts index 6cd94c111..90d56dfce 100644 --- a/apps/codebility/app/home/admin-controls/appointments/actions.ts +++ b/apps/codebility/actions/admin/appointments.ts @@ -3,7 +3,7 @@ // Import your actual server utility file relative to the utils folder mapping import { createClientServerComponent } from "@/utils/supabase/server"; import { revalidatePath } from "next/cache"; -import { appointmentStatusSchema } from "./types"; +import { appointmentStatusSchema } from "@/app/home/admin-controls/appointments/types"; /** * Server Action to securely update the status text field of a client appointment. diff --git a/apps/codebility/app/home/admin-controls/client-tracker/actions.ts b/apps/codebility/actions/admin/client-tracker.ts similarity index 96% rename from apps/codebility/app/home/admin-controls/client-tracker/actions.ts rename to apps/codebility/actions/admin/client-tracker.ts index 2223a378d..9277be47a 100644 --- a/apps/codebility/app/home/admin-controls/client-tracker/actions.ts +++ b/apps/codebility/actions/admin/client-tracker.ts @@ -2,7 +2,7 @@ import { createClientServerComponent } from "@/utils/supabase/server"; import { revalidatePath } from "next/cache"; -import { getCurrentWeekStart } from "./utils"; +import { getCurrentWeekStart } from "@/app/home/admin-controls/client-tracker/utils"; export interface ClientOutreach { id: string; @@ -206,8 +206,7 @@ export async function getAdminOutreachHistory( let query = supabase .from('client_outreach') - .select('*') - .eq('admin_id', adminId) + .select('id, admin_id, client_name, client_email, client_company, job_link, outreach_date, notes, conversation_image, week_start, created_at') .order('outreach_date', { ascending: false }); if (weekStart) { diff --git a/apps/codebility/app/home/admin-controls/ticket-support/actions.ts b/apps/codebility/actions/admin/ticket-support.ts similarity index 93% rename from apps/codebility/app/home/admin-controls/ticket-support/actions.ts rename to apps/codebility/actions/admin/ticket-support.ts index e4f095ea4..99e3fcb05 100644 --- a/apps/codebility/app/home/admin-controls/ticket-support/actions.ts +++ b/apps/codebility/actions/admin/ticket-support.ts @@ -2,7 +2,8 @@ import { createClientServerComponent } from "@/utils/supabase/server"; import { revalidatePath } from "next/cache"; -import type { TicketSupport, TicketStatus, TicketPriority } from "./types"; +import { requireRole } from "@/lib/server/auth-guard"; +import type { TicketSupport, TicketStatus, TicketPriority } from "@/app/home/admin-controls/ticket-support/types"; export async function getTickets(): Promise { const supabase = await createClientServerComponent(); @@ -69,6 +70,10 @@ export async function updateTicketStatus( ticketId: string, status: TicketStatus ) { + // Ticket administration is gated by the `applicants` permission, matching the + // route permission middleware applies to /home/admin-controls. + await requireRole("applicants"); + const supabase = await createClientServerComponent(); const { data, error, count } = await supabase @@ -97,6 +102,8 @@ export async function updateTicketPriority( ticketId: string, priority: TicketPriority ) { + await requireRole("applicants"); + const supabase = await createClientServerComponent(); const { data, error } = await supabase @@ -125,6 +132,8 @@ export async function updateTicketAssignment( ticketId: string, assignedToId: string | null ) { + await requireRole("applicants"); + const supabase = await createClientServerComponent(); const { data, error } = await supabase @@ -150,6 +159,8 @@ export async function updateTicketAssignment( } export async function deleteTicket(ticketId: string) { + await requireRole("applicants"); + const supabase = await createClientServerComponent(); // First verify the ticket exists @@ -182,6 +193,8 @@ export async function deleteTicket(ticketId: string) { } export async function archiveTicket(ticketId: string) { + await requireRole("applicants"); + const supabase = await createClientServerComponent(); const { data, error } = await supabase @@ -202,6 +215,8 @@ export async function archiveTicket(ticketId: string) { } export async function unarchiveTicket(ticketId: string) { + await requireRole("applicants"); + const supabase = await createClientServerComponent(); const { data, error } = await supabase diff --git a/apps/codebility/app/applicant/onboarding/_service/action.ts b/apps/codebility/actions/applicant-onboarding/actions.ts similarity index 80% rename from apps/codebility/app/applicant/onboarding/_service/action.ts rename to apps/codebility/actions/applicant-onboarding/actions.ts index 8ccc274d2..0505bd637 100644 --- a/apps/codebility/app/applicant/onboarding/_service/action.ts +++ b/apps/codebility/actions/applicant-onboarding/actions.ts @@ -2,14 +2,42 @@ import { createClientServerComponent } from "@/utils/supabase/server"; import { revalidatePath } from "next/cache"; +import { requireUser } from "@/lib/server/auth-guard"; + +/** + * Onboarding progress is self-service, so a write is only allowed against the + * signed-in user's own `applicant` row. + * + * These actions used to take `applicantId` and write to it unchecked, so any + * caller could mark another applicant's videos complete or overwrite their quiz + * and commitment results. + */ +async function assertOwnApplicant( + supabase: Awaited>, + applicantId: string, +) { + const { user } = await requireUser(); + + const { data } = await supabase + .from("applicant") + .select("codev_id") + .eq("id", applicantId) + .maybeSingle(); + + if (!data || data.codev_id !== user.id) { + throw new Error("Forbidden"); + } +} export async function getOnboardingProgress(applicantId: string) { try { const supabase = await createClientServerComponent(); + await assertOwnApplicant(supabase, applicantId); + const { data, error } = await supabase .from("onboarding_videos") - .select("*") + .select("id, applicant_id, video_number, completed, completed_at") .eq("applicant_id", applicantId) .order("video_number", { ascending: true }); @@ -56,10 +84,12 @@ export async function updateVideoProgress({ try { const supabase = await createClientServerComponent(); + await assertOwnApplicant(supabase, applicantId); + // Check if record exists const { data: existingRecord } = await supabase .from("onboarding_videos") - .select("*") + .select("id") .eq("applicant_id", applicantId) .eq("video_number", videoNumber) .single(); @@ -119,6 +149,8 @@ export async function saveQuizProgress({ try { const supabase = await createClientServerComponent(); + await assertOwnApplicant(supabase, applicantId); + const { error } = await supabase .from("applicant") .update({ @@ -159,6 +191,8 @@ export async function saveQuizAndCommitment({ try { const supabase = await createClientServerComponent(); + await assertOwnApplicant(supabase, applicantId); + const { error } = await supabase .from("applicant") .update({ @@ -185,6 +219,20 @@ export async function saveQuizAndCommitment({ export async function completeOnboarding(codevId: string, newStatus: string = "waitlist") { try { + // Self only, and the status is constrained: the parameter used to accept any + // string, so a caller could set their own application_status to "passed" + // and skip the pipeline. + const { user } = await requireUser(); + + if (user.id !== codevId) { + throw new Error("Forbidden"); + } + + const ALLOWED_STATUSES = ["onboarding", "waitlist"]; + if (!ALLOWED_STATUSES.includes(newStatus)) { + throw new Error("Forbidden"); + } + const supabase = await createClientServerComponent(); // Update codev status to waitlist (or specified status) diff --git a/apps/codebility/app/applicant/waiting/_service/action.ts b/apps/codebility/actions/applicant-waiting/actions.ts similarity index 70% rename from apps/codebility/app/applicant/waiting/_service/action.ts rename to apps/codebility/actions/applicant-waiting/actions.ts index 947833acc..84edef168 100644 --- a/apps/codebility/app/applicant/waiting/_service/action.ts +++ b/apps/codebility/actions/applicant-waiting/actions.ts @@ -1,9 +1,28 @@ - "use server"; import { createClientServerComponent } from "@/utils/supabase/server"; import { revalidatePath } from "next/cache"; +import { requireUser } from "@/lib/server/auth-guard"; + +/** + * Applicant pipeline actions are self-service: an applicant moves their own + * record through testing/onboarding/waitlist. + * + * `codevId` used to be trusted from the caller, so any caller could set another + * user's `application_status` (e.g. push themselves or someone else to + * "waitlist"). The codev row written is now always the signed-in user; the + * caller's `codevId` argument must match it. + */ +async function requireSelfCodev(codevId: string): Promise { + const { user } = await requireUser(); + + if (user.id !== codevId) { + throw new Error("Forbidden"); + } + + return user.id; +} export async function applicantTakeTest({ @@ -14,6 +33,7 @@ export async function applicantTakeTest({ codevId: string; }) { try { + const selfCodevId = await requireSelfCodev(codevId); const supabase = await createClientServerComponent(); const { data, error } = await supabase @@ -36,7 +56,7 @@ export async function applicantTakeTest({ application_status: "testing", updated_at: new Date(), }) - .eq("id", codevId); + .eq("id", selfCodevId); if (codevError) { console.error("Error updating codev test:", codevError); @@ -49,12 +69,34 @@ export async function applicantTakeTest({ } } +/** + * Confirms an `applicant` row belongs to the signed-in user before it is + * written. `applicantId` arrives from the client, so without this any caller + * could edit another applicant's fork URL or join flags. + */ +async function assertOwnApplicant( + supabase: Awaited>, + applicantId: string, + codevId: string, +) { + const { data } = await supabase + .from("applicant") + .select("codev_id") + .eq("id", applicantId) + .maybeSingle(); + + if (!data || data.codev_id !== codevId) { + throw new Error("Forbidden"); + } +} + export async function applicantMoveToOnboard({ codevId, }: { codevId: string; }) { try { + const selfCodevId = await requireSelfCodev(codevId); const supabase = await createClientServerComponent(); const { data: codevData, error: codevError } = await supabase @@ -63,7 +105,7 @@ export async function applicantMoveToOnboard({ application_status: "onboarding", updated_at: new Date(), }) - .eq("id", codevId); + .eq("id", selfCodevId); if (codevError) { console.error("Error updating codev test:", codevError); @@ -84,8 +126,11 @@ export async function applicantSubmitTest({ forkUrl: string; }) { try { + const { user } = await requireUser(); const supabase = await createClientServerComponent(); + await assertOwnApplicant(supabase, applicantId, user.id); + const { data, error } = await supabase .from("applicant") .update({ @@ -113,8 +158,11 @@ export async function applicantUpdateTestSubmission({ forkUrl: string; }) { try { + const { user } = await requireUser(); const supabase = await createClientServerComponent(); + await assertOwnApplicant(supabase, applicantId, user.id); + const { data, error } = await supabase .from("applicant") .update({ @@ -144,8 +192,11 @@ export async function applicantUpdateJoinedStatus({ joinedMessenger?: boolean; }) { try { + const { user } = await requireUser(); const supabase = await createClientServerComponent(); + await assertOwnApplicant(supabase, applicantId, user.id); + const updateData: any = { updated_at: new Date(), }; diff --git a/apps/codebility/app/home/applicants/_service/action.ts b/apps/codebility/actions/applicants/applicant.ts similarity index 99% rename from apps/codebility/app/home/applicants/_service/action.ts rename to apps/codebility/actions/applicants/applicant.ts index 9e7d52942..bf8c331f0 100644 --- a/apps/codebility/app/home/applicants/_service/action.ts +++ b/apps/codebility/actions/applicants/applicant.ts @@ -2,7 +2,7 @@ import { revalidatePath } from "next/cache"; -import { NewApplicantType } from "./types"; +import { NewApplicantType } from "@/types/applicants"; import { createAdminClient } from "@/utils/supabase/admin"; import { createClientServerComponent } from "@/utils/supabase/server"; import { invalidateCache } from "@/lib/server/redis-cache"; diff --git a/apps/codebility/app/home/applicants/_service/emailAction.ts b/apps/codebility/actions/applicants/email-action.ts similarity index 99% rename from apps/codebility/app/home/applicants/_service/emailAction.ts rename to apps/codebility/actions/applicants/email-action.ts index 8fc708273..115f8f2bf 100644 --- a/apps/codebility/app/home/applicants/_service/emailAction.ts +++ b/apps/codebility/actions/applicants/email-action.ts @@ -1546,7 +1546,7 @@ export const sendDenyEmail = async ({ email, name }: { email: string, name: stri // New wrapper functions that handle both email sending and database updates export const sendTestReminderWithUpdate = async ({ email, name, applicantId }: { email: string, name: string, applicantId: string }) => { - const { updateReminderCountAction } = await import('./action'); + const { updateReminderCountAction } = await import("@/actions/applicants/applicant"); try { // Send the email first @@ -1561,7 +1561,7 @@ export const sendTestReminderWithUpdate = async ({ email, name, applicantId }: { } export const sendOnboardingReminderWithUpdate = async ({ email, name, applicantId }: { email: string, name: string, applicantId: string }) => { - const { updateReminderCountAction } = await import('./action'); + const { updateReminderCountAction } = await import("@/actions/applicants/applicant"); try { // Send the email first @@ -1580,7 +1580,7 @@ export const sendTestReminderForConfig = async (email: string) => { // This function will be called from useApplicantActions, but we need applicant data // We'll need to get the applicant data from the database first const { createClientServerComponent } = await import('@/utils/supabase/server'); - const { updateReminderCountAction } = await import('./action'); + const { updateReminderCountAction } = await import("@/actions/applicants/applicant"); try { const supabase = await createClientServerComponent(); @@ -1620,7 +1620,7 @@ export const sendOnboardingReminderForConfig = async (email: string) => { // This function will be called from useApplicantActions, but we need applicant data // We'll need to get the applicant data from the database first const { createClientServerComponent } = await import('@/utils/supabase/server'); - const { updateReminderCountAction } = await import('./action'); + const { updateReminderCountAction } = await import("@/actions/applicants/applicant"); try { const supabase = await createClientServerComponent(); @@ -1732,7 +1732,7 @@ export const sendDenyEmailForConfig = async (email: string) => { } export const sendMultipleTestReminderEmailWithUpdate = async (applicants: { email: string, applicantId: string }[]) => { - const { updateMultipleReminderCountAction } = await import('./action'); + const { updateMultipleReminderCountAction } = await import("@/actions/applicants/applicant"); try { // Send the emails first @@ -1749,7 +1749,7 @@ export const sendMultipleTestReminderEmailWithUpdate = async (applicants: { emai } export const sendMultipleOnboardingReminderWithUpdate = async (applicants: { email: string, applicantId: string }[]) => { - const { updateMultipleReminderCountAction } = await import('./action'); + const { updateMultipleReminderCountAction } = await import("@/actions/applicants/applicant"); try { // Send the emails first diff --git a/apps/codebility/app/home/applicants/_service/email/actions/accepted.ts b/apps/codebility/actions/applicants/email/actions/accepted.ts similarity index 100% rename from apps/codebility/app/home/applicants/_service/email/actions/accepted.ts rename to apps/codebility/actions/applicants/email/actions/accepted.ts diff --git a/apps/codebility/app/home/applicants/_service/email/actions/deny.ts b/apps/codebility/actions/applicants/email/actions/deny.ts similarity index 100% rename from apps/codebility/app/home/applicants/_service/email/actions/deny.ts rename to apps/codebility/actions/applicants/email/actions/deny.ts diff --git a/apps/codebility/app/home/applicants/_service/email/actions/failedTest.ts b/apps/codebility/actions/applicants/email/actions/failedTest.ts similarity index 100% rename from apps/codebility/app/home/applicants/_service/email/actions/failedTest.ts rename to apps/codebility/actions/applicants/email/actions/failedTest.ts diff --git a/apps/codebility/app/home/applicants/_service/email/actions/onboardingReminder.ts b/apps/codebility/actions/applicants/email/actions/onboardingReminder.ts similarity index 94% rename from apps/codebility/app/home/applicants/_service/email/actions/onboardingReminder.ts rename to apps/codebility/actions/applicants/email/actions/onboardingReminder.ts index f632e12a0..d64a939da 100644 --- a/apps/codebility/app/home/applicants/_service/email/actions/onboardingReminder.ts +++ b/apps/codebility/actions/applicants/email/actions/onboardingReminder.ts @@ -76,7 +76,7 @@ export const sendOnboardingReminderWithUpdate = async ({ name, applicantId, }: EmailRecipient & { applicantId: string }) => { - const { updateReminderCountAction } = await import("../../action"); + const { updateReminderCountAction } = await import("@/actions/applicants/applicant"); try { await sendOnboardingReminder({ email, name }); @@ -90,7 +90,7 @@ export const sendOnboardingReminderWithUpdate = async ({ export const sendMultipleOnboardingReminderWithUpdate = async ( applicants: { email: string; applicantId: string }[] ) => { - const { updateMultipleReminderCountAction } = await import("../../action"); + const { updateMultipleReminderCountAction } = await import("@/actions/applicants/applicant"); try { const emails = applicants.map((applicant) => applicant.email); @@ -106,7 +106,7 @@ export const sendMultipleOnboardingReminderWithUpdate = async ( export const sendOnboardingReminderForConfig = async (email: string) => { const { createClientServerComponent } = await import("@/utils/supabase/server"); - const { updateReminderCountAction } = await import("../../action"); + const { updateReminderCountAction } = await import("@/actions/applicants/applicant"); try { const supabase = await createClientServerComponent(); diff --git a/apps/codebility/app/home/applicants/_service/email/actions/passedTest.ts b/apps/codebility/actions/applicants/email/actions/passedTest.ts similarity index 100% rename from apps/codebility/app/home/applicants/_service/email/actions/passedTest.ts rename to apps/codebility/actions/applicants/email/actions/passedTest.ts diff --git a/apps/codebility/app/home/applicants/_service/email/actions/testReminder.ts b/apps/codebility/actions/applicants/email/actions/testReminder.ts similarity index 93% rename from apps/codebility/app/home/applicants/_service/email/actions/testReminder.ts rename to apps/codebility/actions/applicants/email/actions/testReminder.ts index ebc7da785..8fb8a0a14 100644 --- a/apps/codebility/app/home/applicants/_service/email/actions/testReminder.ts +++ b/apps/codebility/actions/applicants/email/actions/testReminder.ts @@ -63,7 +63,7 @@ export const sendTestReminderWithUpdate = async ({ name, applicantId, }: EmailRecipient & { applicantId: string }) => { - const { updateReminderCountAction } = await import("../../action"); + const { updateReminderCountAction } = await import("@/actions/applicants/applicant"); try { await sendTestReminder({ email, name }); @@ -77,7 +77,7 @@ export const sendTestReminderWithUpdate = async ({ export const sendMultipleTestReminderEmailWithUpdate = async ( applicants: { email: string; applicantId: string }[] ) => { - const { updateMultipleReminderCountAction } = await import("../../action"); + const { updateMultipleReminderCountAction } = await import("@/actions/applicants/applicant"); try { const emails = applicants.map((applicant) => applicant.email); @@ -93,7 +93,7 @@ export const sendMultipleTestReminderEmailWithUpdate = async ( export const sendTestReminderForConfig = async (email: string) => { const { createClientServerComponent } = await import("@/utils/supabase/server"); - const { updateReminderCountAction } = await import("../../action"); + const { updateReminderCountAction } = await import("@/actions/applicants/applicant"); try { const supabase = await createClientServerComponent(); diff --git a/apps/codebility/app/home/applicants/_service/email/config.ts b/apps/codebility/actions/applicants/email/config.ts similarity index 100% rename from apps/codebility/app/home/applicants/_service/email/config.ts rename to apps/codebility/actions/applicants/email/config.ts diff --git a/apps/codebility/app/home/applicants/_service/email/index.ts b/apps/codebility/actions/applicants/email/index.ts similarity index 100% rename from apps/codebility/app/home/applicants/_service/email/index.ts rename to apps/codebility/actions/applicants/email/index.ts diff --git a/apps/codebility/app/home/applicants/_service/email/templates/accepted.ts b/apps/codebility/actions/applicants/email/templates/accepted.ts similarity index 100% rename from apps/codebility/app/home/applicants/_service/email/templates/accepted.ts rename to apps/codebility/actions/applicants/email/templates/accepted.ts diff --git a/apps/codebility/app/home/applicants/_service/email/templates/deny.ts b/apps/codebility/actions/applicants/email/templates/deny.ts similarity index 100% rename from apps/codebility/app/home/applicants/_service/email/templates/deny.ts rename to apps/codebility/actions/applicants/email/templates/deny.ts diff --git a/apps/codebility/app/home/applicants/_service/email/templates/failedTest.ts b/apps/codebility/actions/applicants/email/templates/failedTest.ts similarity index 100% rename from apps/codebility/app/home/applicants/_service/email/templates/failedTest.ts rename to apps/codebility/actions/applicants/email/templates/failedTest.ts diff --git a/apps/codebility/app/home/applicants/_service/email/templates/onboardingReminder.ts b/apps/codebility/actions/applicants/email/templates/onboardingReminder.ts similarity index 100% rename from apps/codebility/app/home/applicants/_service/email/templates/onboardingReminder.ts rename to apps/codebility/actions/applicants/email/templates/onboardingReminder.ts diff --git a/apps/codebility/app/home/applicants/_service/email/templates/passedTest.ts b/apps/codebility/actions/applicants/email/templates/passedTest.ts similarity index 100% rename from apps/codebility/app/home/applicants/_service/email/templates/passedTest.ts rename to apps/codebility/actions/applicants/email/templates/passedTest.ts diff --git a/apps/codebility/app/home/applicants/_service/email/templates/testReminder.ts b/apps/codebility/actions/applicants/email/templates/testReminder.ts similarity index 100% rename from apps/codebility/app/home/applicants/_service/email/templates/testReminder.ts rename to apps/codebility/actions/applicants/email/templates/testReminder.ts diff --git a/apps/codebility/app/home/applicants/_service/email/types.ts b/apps/codebility/actions/applicants/email/types.ts similarity index 100% rename from apps/codebility/app/home/applicants/_service/email/types.ts rename to apps/codebility/actions/applicants/email/types.ts diff --git a/apps/codebility/actions/applicants/index.ts b/apps/codebility/actions/applicants/index.ts new file mode 100644 index 000000000..e97c6550a --- /dev/null +++ b/apps/codebility/actions/applicants/index.ts @@ -0,0 +1,2 @@ +export * from "@/actions/applicants/applicant"; +export * from "@/actions/applicants/email"; diff --git a/apps/codebility/actions/applicants/queries.ts b/apps/codebility/actions/applicants/queries.ts new file mode 100644 index 000000000..6152e6bee --- /dev/null +++ b/apps/codebility/actions/applicants/queries.ts @@ -0,0 +1,156 @@ +"use server"; + +import { createClientServerComponent } from "@/utils/supabase/server"; +import { newApplicantsSchema, NewApplicantType } from "@/types/applicants"; +import { resolvePageArgs, toPage, type Page, type PageArgs } from "@/lib/server/paginate"; + +// Every codev field newApplicantsSchema requires, plus the applicant relation it +// requires and the table renders. The schema (types/applicants.ts) parses each +// row, so a column missing here fails the whole page, not just one cell. +const APPLICANT_LIST_COLUMNS = ` + id, + first_name, + last_name, + email_address, + phone_number, + address, + about, + positions, + display_position, + portfolio_website, + tech_stacks, + image_url, + availability_status, + nda_status, + level, + application_status, + rejected_count, + facebook, + linkedin, + github, + discord, + years_of_experience, + role_id, + internal_status, + mentor_id, + nda_signature, + nda_document, + nda_signed_at, + nda_request_sent, + date_applied, + created_at, + updated_at, + applicant ( + id, + codev_id, + test_taken, + fork_url, + reminded_count, + last_reminded_date, + quiz_score, + quiz_total, + quiz_passed, + quiz_completed_at, + can_do_mobile, + commitment_signed_at, + signature_data, + created_at, + updated_at + ) +`; + +export async function getNewApplicants(): Promise { + try { + const supabase = await createClientServerComponent(); + + const { data: newApplicants, error } = await supabase + .from("codev") + .select(APPLICANT_LIST_COLUMNS) + .not("application_status", "eq", "passed") + .order("date_applied", { ascending: false }); + + if (error) { + console.error("Error fetching new applicants:", error); + return []; + } + + const parsedNewApplicants = newApplicantsSchema.array().safeParse(newApplicants); + + if (parsedNewApplicants.error) { + console.error("Error parsing new applicants:", parsedNewApplicants.error); + return []; + } + + return parsedNewApplicants.data; + } catch (error) { + console.error("Error fetching new applicants:", error); + return []; + } +} + +/** + * One page of applicants for a single pipeline tab. `status` is the tab's + * application_status, so the tab counts and the page come from the database + * rather than from filtering the whole table in the browser. + */ +export const getApplicantsPage = async ({ + status, + search, + page, + pageSize, +}: PageArgs & { status?: string; search?: string } = {}): Promise> => { + const supabase = await createClientServerComponent(); + const { page: current, pageSize: size, from, to } = resolvePageArgs({ page, pageSize }); + + let query = supabase + .from("codev") + .select(APPLICANT_LIST_COLUMNS, { count: "exact" }) + .not("application_status", "eq", "passed"); + + if (status) query = query.eq("application_status", status); + if (search) { + const term = `%${search}%`; + query = query.or( + `first_name.ilike.${term},last_name.ilike.${term},email_address.ilike.${term}`, + ); + } + + const { data, error, count } = await query + .order("date_applied", { ascending: false, nullsFirst: false }) + .order("id", { ascending: true }) + .range(from, to); + + if (error) { + console.error("Error fetching applicants page:", error); + return toPage(null, 0, current, size); + } + + const parsed = newApplicantsSchema.array().safeParse(data); + if (parsed.error) { + console.error("Error parsing applicants page:", parsed.error); + return toPage(null, 0, current, size); + } + + return toPage(parsed.data, count, current, size); +}; + +/** Per-status totals for the tab badges, counted in the database. */export const getApplicantStatusCounts = async (): Promise> => { + const supabase = await createClientServerComponent(); + const statuses = ["applying", "testing", "onboarding", "waitlist", "denied"]; + + const results = await Promise.all( + statuses.map((status) => + supabase + .from("codev") + .select("id", { count: "exact", head: true }) + .eq("application_status", status), + ), + ); + + return Object.fromEntries( + statuses.map((status, i) => [status, results[i]?.count ?? 0]), + ); +}; + +/** Client-facing alias for the table's page changes (F1: interaction-driven). */ +export const getApplicantsPageAction = getApplicantsPage; diff --git a/apps/codebility/app/auth/declined/_service/actions.ts b/apps/codebility/actions/auth/declined.ts similarity index 93% rename from apps/codebility/app/auth/declined/_service/actions.ts rename to apps/codebility/actions/auth/declined.ts index 3edab90c5..d419326dc 100644 --- a/apps/codebility/app/auth/declined/_service/actions.ts +++ b/apps/codebility/actions/auth/declined.ts @@ -1,5 +1,6 @@ -"use server"; +"use server"; +import { revalidatePath } from "next/cache"; import { createClientServerComponent } from "@/utils/supabase/server"; import { redirect } from "next/navigation"; @@ -48,6 +49,7 @@ export async function reApplyAction({ user }: { user: any }) { if (error) throw error; + revalidatePath("/auth/waiting"); redirect("/auth/waiting"); } catch (error) { console.error("Error reapplying:", error); diff --git a/apps/codebility/actions/auth/index.ts b/apps/codebility/actions/auth/index.ts new file mode 100644 index 000000000..63a107e35 --- /dev/null +++ b/apps/codebility/actions/auth/index.ts @@ -0,0 +1,4 @@ +export * from "./session"; +export * from "./password-reset"; +export * from "./declined"; +export * from "./onboarding-team"; diff --git a/apps/codebility/app/auth/onboarding/_components/Team/actions.ts b/apps/codebility/actions/auth/onboarding-team.ts similarity index 100% rename from apps/codebility/app/auth/onboarding/_components/Team/actions.ts rename to apps/codebility/actions/auth/onboarding-team.ts diff --git a/apps/codebility/app/auth/password-reset/action.ts b/apps/codebility/actions/auth/password-reset.ts similarity index 100% rename from apps/codebility/app/auth/password-reset/action.ts rename to apps/codebility/actions/auth/password-reset.ts diff --git a/apps/codebility/app/auth/actions.ts b/apps/codebility/actions/auth/session.ts similarity index 98% rename from apps/codebility/app/auth/actions.ts rename to apps/codebility/actions/auth/session.ts index 080a76c1a..88c9634d4 100644 --- a/apps/codebility/app/auth/actions.ts +++ b/apps/codebility/actions/auth/session.ts @@ -1,10 +1,11 @@ -"use server"; +"use server"; +import { revalidatePath } from "next/cache"; import { redirect } from "next/navigation"; import { useUserStore } from "@/store/codev-store"; import { Codev } from "@/types/home/codev"; import { createClientServerComponent } from "@/utils/supabase/server"; -import { checkRateLimit, recordRateLimitAttempt, resetRateLimit } from "@/lib/rate-limiter"; +import { checkRateLimit, recordRateLimitAttempt, resetRateLimit } from "@/utils/rate-limiter"; import { uploadNdaToStorage, updateCodevNdaUrls } from "@/utils/ndaStorageService"; const uploadProfileImage = async ( @@ -267,6 +268,7 @@ export const signupUser = async (formData: FormData) => { throw error; } + revalidatePath("/home/applicants"); setUser(userData); // **ENHANCED: Return additional information about NDA processing** @@ -338,7 +340,10 @@ export const signinUser = async (email: string, password: string) => { redirectTo = "/auth/declined"; } - return { success: true, redirectTo }; + return { + success: true, + redirectTo, + }; } catch (error: any) { console.error("Sign in error:", error); return { success: false, error: error.message || "Failed to sign in" }; diff --git a/apps/codebility/app/home/clients/action.ts b/apps/codebility/actions/clients/actions.ts similarity index 87% rename from apps/codebility/app/home/clients/action.ts rename to apps/codebility/actions/clients/actions.ts index 9f5acaaa6..a61a70a3b 100644 --- a/apps/codebility/app/home/clients/action.ts +++ b/apps/codebility/actions/clients/actions.ts @@ -1,8 +1,14 @@ "use server"; -import { revalidatePath } from "next/cache"; +import "server-only"; + +import { revalidatePath, revalidateTag } from "next/cache"; import { deleteImage, uploadImage } from "@/utils/uploadImage"; import { createClientServerComponent } from "@/utils/supabase/server"; +import { getClientsPage } from "@/lib/server/codev.service"; +import { requireRole } from "@/lib/server/auth-guard"; +import type { Page, PageArgs } from "@/lib/server/paginate"; +import type { Client } from "@/types/home/codev"; // Simple type definitions interface ActionResult { @@ -11,11 +17,19 @@ interface ActionResult { data?: any; } +export const fetchClientsPageAction = async ( + args: PageArgs & { status?: string; search?: string }, +): Promise> => getClientsPage(args); + /** * CREATE CLIENT ACTION - Simplified with better error handling */ export const createClientAction = async (formData: FormData): Promise => { try { + // Client records are gated by the `clients` role permission, matching the + // route permission middleware applies to /home/clients. + await requireRole("clients"); + const supabase = await createClientServerComponent(); // Extract form data with defaults @@ -53,6 +67,7 @@ export const createClientAction = async (formData: FormData): Promise => { try { + await requireRole("clients"); + const supabase = await createClientServerComponent(); // Get existing client data @@ -124,6 +141,7 @@ export const updateClientAction = async ( } revalidatePath("/home/clients"); + revalidateTag("landing-testimonials"); return { success: true, data }; } catch (error) { @@ -137,6 +155,8 @@ export const updateClientAction = async ( */ export const toggleClientStatusAction = async (clientId: string): Promise => { try { + await requireRole("clients"); + const supabase = await createClientServerComponent(); const { data: client, error: fetchError } = await supabase @@ -164,6 +184,7 @@ export const toggleClientStatusAction = async (clientId: string): Promise => { try { + await requireRole("clients"); + const supabase = await createClientServerComponent(); // Get client data to check for logo @@ -208,6 +231,7 @@ export const deleteClientAction = async (clientId: string): Promise { if (error) throw error; await stopUserTimer(codevId); + + revalidatePath("/home"); }; export const updateUserTaskOnHand = async (codevId: string, taskId: string) => { @@ -132,6 +135,7 @@ export const updateUserAvailabilityStatus = async ({ .eq("id", userId); if (error) throw error; + revalidatePath("/home"); } catch (error) { console.error(error); } diff --git a/apps/codebility/app/home/feeds/_services/action.ts b/apps/codebility/actions/feeds/post.ts similarity index 82% rename from apps/codebility/app/home/feeds/_services/action.ts rename to apps/codebility/actions/feeds/post.ts index f96bdd7d5..0fab10d6b 100644 --- a/apps/codebility/app/home/feeds/_services/action.ts +++ b/apps/codebility/actions/feeds/post.ts @@ -1,10 +1,12 @@ "use server"; +import { revalidatePath } from "next/cache"; import { createClientServerComponent } from "@/utils/supabase/server"; import { deleteImage, getImagePath } from "@/utils/uploadImage"; -import { createPostSchema, editPostSchema } from "./validation"; +import { createPostSchema, editPostSchema } from "@/utils/validations/feeds"; import { ZodError } from "zod"; -import { UserMention } from "./types"; -import { createNotificationAction } from "@/lib/actions/notification.actions"; +import type { UserMention } from "@/types/feeds"; +import { createNotificationAction } from "@/actions/notifications/notification.actions"; +import { requireUser } from "@/lib/server/auth-guard"; export const getUserRole = async (userId: Number | null): Promise => { @@ -48,12 +50,16 @@ type AddPostParams = { export const addPost = async (payload: AddPostParams) => { try { + // Identity comes from the session, never from the caller. `author_id` in the + // payload is deliberately ignored: accepting it let any caller attribute a + // post to another user. + const { user } = await requireUser(); + const parsed = createPostSchema.parse(payload); const { title, content, - author_id, image_url, content_image_ids, tag_ids, @@ -68,7 +74,7 @@ export const addPost = async (payload: AddPostParams) => { { title, content, - ...(author_id !== undefined && { author_id }), + author_id: user.id, ...(image_url !== undefined && { image_url }), }, ]) @@ -103,6 +109,7 @@ export const addPost = async (payload: AddPostParams) => { if (tagError) throw tagError; } + revalidatePath("/home/feeds"); return newPost; } catch (error) { if (error instanceof ZodError) { @@ -112,12 +119,44 @@ export const addPost = async (payload: AddPostParams) => { } }; +/** + * Authorizes a write against a post the caller must own (admins may moderate). + * + * Feeds are not project-scoped, so `requireProjectMember` does not apply here. + * Ownership is the right check: a member may only touch their own posts. + */ +async function requirePostOwner( + supabase: Awaited>, + postId: string, +) { + const { user, roleId } = await requireUser(); + + const { data: post, error } = await supabase + .from("posts") + .select("author_id") + .eq("id", postId) + .single(); + + if (error || !post) { + throw new Error("Post not found"); + } + + // role_id 1 is admin, matching the bypass used across auth-guard. + if (post.author_id !== user.id && roleId !== 1) { + throw new Error("Forbidden"); + } + + return { user, roleId, post }; +} + export const deletePost = async (post_id: string) => { try { - await deletePostContentImages(post_id); - const supabase = await createClientServerComponent(); + await requirePostOwner(supabase, post_id); + + await deletePostContentImages(post_id); + const { data: postData, error: fetchError } = await supabase .from("posts") .select("image_url") @@ -139,6 +178,7 @@ export const deletePost = async (post_id: string) => { if (error) throw error; + revalidatePath("/home/feeds"); return data; } catch (error) { throw error; @@ -163,7 +203,6 @@ export const editPost = async (payload: EditPostParams) => { id, title, content, - author_id, image_url, content_image_ids, tag_ids, @@ -171,6 +210,10 @@ export const editPost = async (payload: EditPostParams) => { const supabase = await createClientServerComponent(); + // Ownership first, and `author_id` from the payload is ignored so an edit + // cannot reassign authorship to another user. + await requirePostOwner(supabase, id); + // 1. Fetch existing post for old image cleanup const { data: post, error: fetchError } = await supabase .from("posts") @@ -184,7 +227,6 @@ export const editPost = async (payload: EditPostParams) => { const updates: Record = {}; if (title !== undefined) updates.title = title; if (content !== undefined) updates.content = content; - if (author_id !== undefined) updates.author_id = author_id; if (image_url !== undefined) updates.image_url = image_url; if (image_url === null) updates.image_url = null; @@ -250,6 +292,7 @@ export const editPost = async (payload: EditPostParams) => { } } + revalidatePath("/home/feeds"); return updatedPost; } catch (error) { if (error instanceof ZodError) { @@ -259,15 +302,18 @@ export const editPost = async (payload: EditPostParams) => { } }; -export const AddPostUpvote = async (postId: string, userId: string) => { +export const AddPostUpvote = async (postId: string) => { try { + // Upvoter identity from the session; the parameter it used to take let any + // caller cast or clear votes on another user's behalf. + const { user } = await requireUser(); const supabase = await createClientServerComponent(); const { data: postUpvote, error: fetchError } = await supabase .from("post_upvotes") - .select("*") + .select("id") .eq("post_id", postId) - .eq("upvoter_id", userId) + .eq("upvoter_id", user.id) .maybeSingle(); if (fetchError) throw fetchError; @@ -278,48 +324,33 @@ export const AddPostUpvote = async (postId: string, userId: string) => { const { data: newUpvote, error: insertError } = await supabase .from("post_upvotes") - .insert([{ post_id: postId, upvoter_id: userId }]) + .insert([{ post_id: postId, upvoter_id: user.id }]) .select() .single(); if (insertError) throw insertError; + revalidatePath("/home/feeds"); return newUpvote; } catch (error) { throw error; } }; -export const removePostUpvote = async (postId: string, userId: string) => { +export const removePostUpvote = async (postId: string) => { try { + const { user } = await requireUser(); const supabase = await createClientServerComponent(); const { error } = await supabase .from("post_upvotes") .delete() .eq("post_id", postId) - .eq("upvoter_id", userId); - - if (error) throw error; - } catch (error) { - throw error; - } -}; - -export const hasUserUpvoted = async (postId: string, userId: string): Promise => { - try { - const supabase = await createClientServerComponent(); - - const { data: postUpvote, error } = await supabase - .from("post_upvotes") - .select("*") - .eq("post_id", postId) - .eq("upvoter_id", userId) - .maybeSingle(); + .eq("upvoter_id", user.id); if (error) throw error; - return !!postUpvote; + revalidatePath("/home/feeds"); } catch (error) { throw error; } @@ -453,9 +484,26 @@ export const getSocialPoints = async (userId: string): Promise => }; export async function deletePostComment(comment_id: string) { + const { user, roleId } = await requireUser(); const supabase = await createClientServerComponent(); try { + // Ownership check: comments are not project-scoped, so only the author + // (or an admin) may delete. Previously any caller could delete any comment. + const { data: comment, error: fetchError } = await supabase + .from("post_comments") + .select("commenter_id") + .eq("id", comment_id) + .single(); + + if (fetchError || !comment) { + throw new Error("Comment not found"); + } + + if (comment.commenter_id !== user.id && roleId !== 1) { + throw new Error("Forbidden"); + } + const { error } = await supabase .from("post_comments") .delete() @@ -463,14 +511,18 @@ export async function deletePostComment(comment_id: string) { if (error) throw error; + revalidatePath("/home/feeds"); return { success: true }; } catch (error) { throw error; } } -export const hasReachedDailyPostLimit = async (author_id: string, limit = 2) => { +export const hasReachedDailyPostLimit = async (limit = 2) => { try { + // Own posts only; the parameter it used to take let a caller probe or + // sidestep another user's daily limit. + const { user } = await requireUser(); const supabase = await createClientServerComponent(); const twentyFourHoursAgo = new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString(); @@ -478,7 +530,7 @@ export const hasReachedDailyPostLimit = async (author_id: string, limit = 2) => const { count, error } = await supabase .from("posts") .select("id", { count: "exact", head: true }) - .eq("author_id", author_id) + .eq("author_id", user.id) .gte("created_at", twentyFourHoursAgo); if (error) throw error; @@ -489,14 +541,15 @@ export const hasReachedDailyPostLimit = async (author_id: string, limit = 2) => } }; -export const hasNotPostedYet = async (user_id: string) => { +export const hasNotPostedYet = async () => { try { + const { user } = await requireUser(); const supabase = await createClientServerComponent(); const { count, error } = await supabase .from("posts") .select("id", { count: "exact", head: true }) - .eq("author_id", user_id); + .eq("author_id", user.id); if (error) throw error; @@ -535,10 +588,13 @@ export async function searchUsers(query: string): Promise { */ export async function createComment( post_id: string, - commenter_id: string, content: string, mentions: string[] = [] ) { + // Commenter identity from the session, not the caller. + const { user } = await requireUser(); + const commenter_id = user.id; + const supabase = await createClientServerComponent(); try { @@ -599,6 +655,7 @@ export async function createComment( } } + revalidatePath("/home/feeds"); return comment; } catch (error) { throw error; diff --git a/apps/codebility/actions/feeds/queries.ts b/apps/codebility/actions/feeds/queries.ts new file mode 100644 index 000000000..96e1df835 --- /dev/null +++ b/apps/codebility/actions/feeds/queries.ts @@ -0,0 +1,96 @@ +"use server"; + +import { createClientServerComponent } from "@/utils/supabase/server"; +import type { PostType } from "@/types/feeds"; + +export const getPosts = async (): Promise => { + try { + const supabase = await createClientServerComponent(); + + const { + data: { user }, + } = await supabase.auth.getUser(); + + // The viewer's own upvotes are per-user and absent from the post rows, so + // they are resolved once here. Each card used to issue its own + // hasUserUpvoted round trip on mount. + const upvotedIds = new Set( + user + ? (( + await supabase + .from("post_upvotes") + .select("post_id") + .eq("upvoter_id", user.id) + ).data?.map((row) => row.post_id) ?? []) + : [], + ); + + const { data: posts, error } = await supabase + .from("posts") + .select(` + id, + created_at, + title, + content, + image_url, + author_id ( + id, + first_name, + last_name, + image_url + ), + post_upvotes (id), + post_comments (id), + post_tags ( + tag_id, + post_tags_lookup ( + name + ) + ) + `) + .order("created_at", { ascending: false }); + + if (error) throw error; + + // The explicit column list above mirrors PostType; an untyped builder + // cannot infer it, so widen here rather than at each caller. + const rows = posts as unknown as (PostType & { + post_upvotes?: { id: string }[]; + post_comments?: { id: string }[]; + post_tags?: { tag_id: string; post_tags_lookup?: { name: string | null } }[]; + })[]; + + return rows.map((post) => ({ + ...post, + upvote_count: post.post_upvotes?.length ?? 0, + comment_count: post.post_comments?.length ?? 0, + has_upvoted: upvotedIds.has(post.id), + tags: + post.post_tags?.map((t) => ({ + tag_id: t.tag_id, + name: t.post_tags_lookup?.name ?? null, + })) ?? [], + })); + } catch (error) { + console.error(error); + throw error; + } +}; + +export const getPostTagsLookup = async () => { + try { + const supabase = await createClientServerComponent(); + + const { data: tags, error } = await supabase + .from("post_tags_lookup") + .select(`id, name`) + .order("name", { ascending: true }); + + if (error) throw error; + + return tags; + } catch (error) { + console.error(error); + throw error; + } +}; diff --git a/apps/codebility/app/home/hire/actions.ts b/apps/codebility/actions/hire/actions.ts similarity index 98% rename from apps/codebility/app/home/hire/actions.ts rename to apps/codebility/actions/hire/actions.ts index c667bab12..ac4171c4f 100644 --- a/apps/codebility/app/home/hire/actions.ts +++ b/apps/codebility/actions/hire/actions.ts @@ -1,5 +1,6 @@ "use server"; +import { revalidatePath } from "next/cache"; import { createClientServerComponent } from "@/utils/supabase/server"; import { createClient } from "@supabase/supabase-js"; @@ -105,6 +106,8 @@ export async function updateJobListing( return { success: false, error: `Failed to update: ${error.message}` }; } + revalidatePath("/home/hire"); + return { success: true, data: updatedJob }; } catch (error) { return { @@ -197,6 +200,8 @@ export async function deleteJobListing(jobId: string) { return { success: false, error: `Failed to delete: ${deleteError.message}` }; } + revalidatePath("/home/hire"); + return { success: true }; } catch (error) { return { @@ -278,6 +283,8 @@ export async function updateApplicationStatus( return { success: false, error: error.message }; } + revalidatePath("/home/hire"); + return { success: true, data: updatedApplication }; } catch (error) { return { success: false, error: "An unexpected error occurred" }; @@ -337,6 +344,8 @@ export async function deleteJobApplication(applicationId: string, jobId: string) return { success: false, error: error.message }; } + revalidatePath("/home/hire"); + return { success: true }; } catch (error) { return { success: false, error: "An unexpected error occurred" }; @@ -404,6 +413,8 @@ export async function updateJobListingStatus( return { success: false, error: error.message }; } + revalidatePath("/home/hire"); + return { success: true, data: updatedJob }; } catch (error) { return { success: false, error: "An unexpected error occurred" }; diff --git a/apps/codebility/actions/home/codev-promote.ts b/apps/codebility/actions/home/codev-promote.ts new file mode 100644 index 000000000..1abd0990c --- /dev/null +++ b/apps/codebility/actions/home/codev-promote.ts @@ -0,0 +1,92 @@ +"use server"; +import { revalidatePath } from "next/cache"; +import { createClientServerComponent } from "@/utils/supabase/server"; +import { requireUser } from "@/lib/server/auth-guard"; + +// Role and promote_declined drive the shell, so the whole /home layout must +// refresh, not just the current page. +const revalidateHome = () => revalidatePath("/home", "layout"); + +/** + * Resolves the row a promotion decision may touch. + * + * These are self-service actions: a user accepts or declines their OWN + * promotion, and the promote modals pass the signed-in user's id. The userId + * parameter used to be trusted outright, which let any caller set another + * user's (or their own) role_id to Mentor or Codev. + * + * There is deliberately no admin override: promoting is a grant the user + * accepts, and an admin path would need its own separate action. + */ +async function requireSelf(userId: string): Promise { + const { user } = await requireUser(); + + if (user.id !== userId) { + throw new Error("Forbidden"); + } + + return user.id; +} + +export const declinePromotion = async (userId: string): Promise => { + if (!userId) { + return; + } + + const targetId = await requireSelf(userId); + const supabase = await createClientServerComponent(); + + const { error } = await supabase + .from("codev") + .update({ promote_declined: true }) + .eq("id", targetId); + + if (error) { + console.error("Error updating promote_declined:", error); + throw error; + } + + revalidateHome(); +}; + +export const acceptPromotionToCodev = async (userId: string): Promise => { + if (!userId) { + return; + } + + const targetId = await requireSelf(userId); + const supabase = await createClientServerComponent(); + + const { error } = await supabase + .from("codev") + .update({ role_id: 10 , promote_declined: null}) + .eq("id", targetId); + + if (error) { + console.error("Error updating role_id:", error); + throw error; + } + + revalidateHome(); +}; + +export const acceptPromotionToMentor = async (userId: string): Promise => { + if (!userId) { + return; + } + + const targetId = await requireSelf(userId); + const supabase = await createClientServerComponent(); + + const { error } = await supabase + .from("codev") + .update({ role_id: 5 , promote_declined: null}) + .eq("id", targetId); + + if (error) { + console.error("Error updating role_id:", error); + throw error; + } + + revalidateHome(); +}; \ No newline at end of file diff --git a/apps/codebility/app/home/in-house/actions.ts b/apps/codebility/actions/in-house/actions.ts similarity index 92% rename from apps/codebility/app/home/in-house/actions.ts rename to apps/codebility/actions/in-house/actions.ts index c8062c812..b4c53bcbd 100644 --- a/apps/codebility/app/home/in-house/actions.ts +++ b/apps/codebility/actions/in-house/actions.ts @@ -5,6 +5,34 @@ import { Codev } from "@/types/home/codev"; import { createClientServerComponent } from "@/utils/supabase/server"; import { requireRole } from "@/lib/server/auth-guard"; import { revalidatePath } from "next/cache"; +import { + getCodevDetail, + getCodevsPage, + getInternsPage, + type CodevCardRow, + type CodevListFilters, + type CodevListRow, +} from "@/lib/server/codev.service"; +import type { Page, PageArgs } from "@/lib/server/paginate"; + +export const fetchCodevsAction = async ( + args: PageArgs & { filters?: CodevListFilters } = {}, +): Promise> => { + await requireRole("inhouse"); + return getCodevsPage(args); +}; + +export const fetchInternsAction = async ( + args: PageArgs & { filters?: CodevListFilters } = {}, +): Promise> => { + await requireRole("inhouse"); + return getInternsPage(args); +}; + +export const fetchCodevDetailAction = async (id: string) => { + await requireRole("inhouse"); + return getCodevDetail(id); +}; /** * Updates codev information with enhanced NDA URL storage support diff --git a/apps/codebility/actions/kanban/board.ts b/apps/codebility/actions/kanban/board.ts new file mode 100644 index 000000000..4b15db5b2 --- /dev/null +++ b/apps/codebility/actions/kanban/board.ts @@ -0,0 +1,115 @@ +"use server"; + +import { Task, TaskDraft } from "@/types/home/codev"; +import { createClientServerComponent } from "@/utils/supabase/server"; +import { requireUser } from "@/lib/server/auth-guard"; +import { createNotificationAction } from "@/actions/notifications/notification.actions"; +import { + fetchTaskDetail, + type KanbanTaskDetail, +} from "@/lib/server/kanban-task-query"; +import { fetchMemberBoardTasks as fetchMemberBoardTasksQuery } from "@/lib/server/kanban-member-tasks-query"; +import { fetchColumnTasksPage as fetchColumnTasksPageQuery } from "@/lib/server/kanban-column-tasks-query"; +import { KANBAN_COLUMN_TASK_PAGE_SIZE } from "@/constants/kanban/pagination"; +import type { BoardSnapshotInput } from "@/lib/kanban/board-snapshot-types"; +import { persistBoardSnapshot } from "@/lib/server/kanban-board-snapshot-sync"; +import { updateDeveloperLevels } from "@/lib/kanban/update-developer-levels"; +import { + type CodevMember, + type GuardSupabase, + assertProjectMembership, + buildPositionUpdates, + fetchColumnTasks, + getProjectIdForBoard, + getProjectIdForColumn, + getProjectIdForTask, + persistTaskPositions, + revalidateKanbanBoardLists, +} from "@/lib/kanban/action-helpers"; + +export type { BoardSnapshotInput } from "@/lib/kanban/board-snapshot-types"; + +export async function moveTask({ + taskId, + columnId, + position, + opId: _opId, +}: { + taskId: string; + columnId: string; + position: number; + opId?: string; +}): Promise<{ success: boolean; error?: string }> { + let supabase: GuardSupabase; + let user; + try { + ({ supabase, user } = await requireUser()); + } catch { + return { success: false, error: "Unauthorized" }; + } + + const projectResolution = await getProjectIdForTask(supabase, taskId); + try { + await assertProjectMembership(supabase, user.id, projectResolution); + } catch { + return { success: false, error: "Forbidden" }; + } + + try { + const { data: task, error: taskError } = await supabase + .from("tasks") + .select("id, kanban_column_id, position") + .eq("id", taskId) + .single(); + + if (taskError || !task) { + return { success: false, error: "Task not found" }; + } + + const sourceColumnId = task.kanban_column_id; + const [sourceTasks, targetTasks] = await Promise.all([ + sourceColumnId ? fetchColumnTasks(supabase, sourceColumnId) : Promise.resolve([]), + fetchColumnTasks(supabase, columnId), + ]); + + const updates = buildPositionUpdates( + sourceColumnId, + sourceTasks, + columnId, + targetTasks, + taskId, + position, + ); + + await persistTaskPositions(supabase, updates); + + return { success: true }; + } catch (error) { + console.error("moveTask error:", error); + return { + success: false, + error: error instanceof Error ? error.message : "Failed to move task", + }; + } +} + +export async function syncBoardSnapshot( + snapshot: BoardSnapshotInput, +): Promise<{ success: boolean; error?: string }> { + return persistBoardSnapshot(snapshot); +} + +export async function batchUpdateTasks( + updates: Array<{ taskId: string; newColumnId: string }>, +): Promise<{ success: boolean; error?: string }> { + if (updates.length === 0) { + return { success: true }; + } + + const last = updates[updates.length - 1]!; + return moveTask({ + taskId: last.taskId, + columnId: last.newColumnId, + position: 0, + }); +} diff --git a/apps/codebility/app/home/kanban/actions.ts b/apps/codebility/actions/kanban/boards-list.ts similarity index 100% rename from apps/codebility/app/home/kanban/actions.ts rename to apps/codebility/actions/kanban/boards-list.ts diff --git a/apps/codebility/actions/kanban/columns.ts b/apps/codebility/actions/kanban/columns.ts new file mode 100644 index 000000000..8e5418043 --- /dev/null +++ b/apps/codebility/actions/kanban/columns.ts @@ -0,0 +1,212 @@ +"use server"; + +import { Task, TaskDraft } from "@/types/home/codev"; +import { createClientServerComponent } from "@/utils/supabase/server"; +import { requireUser } from "@/lib/server/auth-guard"; +import { createNotificationAction } from "@/actions/notifications/notification.actions"; +import { + fetchTaskDetail, + type KanbanTaskDetail, +} from "@/lib/server/kanban-task-query"; +import { fetchMemberBoardTasks as fetchMemberBoardTasksQuery } from "@/lib/server/kanban-member-tasks-query"; +import { fetchColumnTasksPage as fetchColumnTasksPageQuery } from "@/lib/server/kanban-column-tasks-query"; +import { KANBAN_COLUMN_TASK_PAGE_SIZE } from "@/constants/kanban/pagination"; +import type { BoardSnapshotInput } from "@/lib/kanban/board-snapshot-types"; +import { persistBoardSnapshot } from "@/lib/server/kanban-board-snapshot-sync"; +import { updateDeveloperLevels } from "@/lib/kanban/update-developer-levels"; +import { + type CodevMember, + type GuardSupabase, + assertProjectMembership, + buildPositionUpdates, + fetchColumnTasks, + getProjectIdForBoard, + getProjectIdForColumn, + getProjectIdForTask, + persistTaskPositions, + revalidateKanbanBoardLists, +} from "@/lib/kanban/action-helpers"; + +export const createNewColumn = async ( + columnName: string, + boardId: string, +): Promise<{ success: boolean; error?: string }> => { + let supabase: GuardSupabase; + let user; + try { + ({ supabase, user } = await requireUser()); + } catch { + return { success: false, error: "Unauthorized" }; + } + + const projectId = await getProjectIdForBoard(supabase, boardId); + try { + await assertProjectMembership(supabase, user.id, projectId); + } catch { + return { success: false, error: "Forbidden" }; + } + + try { + const { data: existingColumns, error: queryError } = await supabase + .from("kanban_columns") + .select("position") + .eq("board_id", boardId) + .order("position", { ascending: false }) + .limit(1); + + if (queryError) { + return { success: false, error: queryError.message }; + } + + const nextPosition = (existingColumns?.[0]?.position ?? -1) + 1; + + const { error: insertError } = await supabase + .from("kanban_columns") + .insert({ + name: columnName.trim(), + board_id: boardId, + position: nextPosition, + created_at: new Date().toISOString(), + updated_at: new Date().toISOString(), + }); + + if (insertError) { + console.error("Database error:", insertError); + return { success: false, error: insertError.message }; + } + + revalidateKanbanBoardLists(projectId.found ? projectId.projectId : null); + return { success: true }; + } catch (error) { + console.error("Unexpected error:", error); + return { + success: false, + error: error instanceof Error ? error.message : "Failed to create column", + }; + } +}; + +export const updateColumnPosition = async ( + columnId: string, + newPosition: number, +): Promise<{ success: boolean; error?: string }> => { + let supabase: GuardSupabase; + let user; + try { + ({ supabase, user } = await requireUser()); + } catch { + return { success: false, error: "Unauthorized" }; + } + + const projectId = await getProjectIdForColumn(supabase, columnId); + try { + await assertProjectMembership(supabase, user.id, projectId); + } catch { + return { success: false, error: "Forbidden" }; + } + + try { + const { error } = await supabase + .from("kanban_columns") + .update({ + position: newPosition, + updated_at: new Date().toISOString(), + }) + .eq("id", columnId); + + if (error) { + throw error; + } + + revalidateKanbanBoardLists(projectId.found ? projectId.projectId : null); + return { success: true }; + } catch (error) { + console.error("Error updating column position:", error); + return { + success: false, + error: error instanceof Error ? error.message : "Failed to update column position", + }; + } +}; + +export const deleteColumn = async ( + columnId: string, +): Promise<{ success: boolean; error?: string }> => { + let supabase: GuardSupabase; + let user; + try { + ({ supabase, user } = await requireUser()); + } catch { + return { success: false, error: "Unauthorized" }; + } + + const projectId = await getProjectIdForColumn(supabase, columnId); + try { + await assertProjectMembership(supabase, user.id, projectId); + } catch { + return { success: false, error: "Forbidden" }; + } + + try { + const { error } = await supabase + .from("kanban_columns") + .delete() + .eq("id", columnId); + + if (error) { + return { success: false, error: error.message }; + } + + revalidateKanbanBoardLists(projectId.found ? projectId.projectId : null); + return { success: true }; + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : "Failed to delete column", + }; + } +}; + +export const updateColumnName = async ( + columnId: string, + newName: string, +): Promise<{ success: boolean; error?: string }> => { + let supabase: GuardSupabase; + let user; + try { + ({ supabase, user } = await requireUser()); + } catch { + return { success: false, error: "Unauthorized" }; + } + + const projectId = await getProjectIdForColumn(supabase, columnId); + try { + await assertProjectMembership(supabase, user.id, projectId); + } catch { + return { success: false, error: "Forbidden" }; + } + + try { + const { error } = await supabase + .from("kanban_columns") + .update({ + name: newName.trim(), + updated_at: new Date().toISOString(), + }) + .eq("id", columnId); + + if (error) { + return { success: false, error: error.message }; + } + + revalidateKanbanBoardLists(projectId.found ? projectId.projectId : null); + return { success: true }; + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : "Failed to update column name", + }; + } +}; + +// OPTIMIZED: Complete task with parallel operations to prevent timeouts diff --git a/apps/codebility/actions/kanban/drafts.ts b/apps/codebility/actions/kanban/drafts.ts new file mode 100644 index 000000000..c1a932af5 --- /dev/null +++ b/apps/codebility/actions/kanban/drafts.ts @@ -0,0 +1,423 @@ +"use server"; + +import { Task, TaskDraft } from "@/types/home/codev"; +import { createClientServerComponent } from "@/utils/supabase/server"; +import { requireUser } from "@/lib/server/auth-guard"; +import { createNotificationAction } from "@/actions/notifications/notification.actions"; +import { + fetchTaskDetail, + type KanbanTaskDetail, +} from "@/lib/server/kanban-task-query"; +import { fetchMemberBoardTasks as fetchMemberBoardTasksQuery } from "@/lib/server/kanban-member-tasks-query"; +import { fetchColumnTasksPage as fetchColumnTasksPageQuery } from "@/lib/server/kanban-column-tasks-query"; +import { KANBAN_COLUMN_TASK_PAGE_SIZE } from "@/constants/kanban/pagination"; +import type { BoardSnapshotInput } from "@/lib/kanban/board-snapshot-types"; +import { persistBoardSnapshot } from "@/lib/server/kanban-board-snapshot-sync"; +import { updateDeveloperLevels } from "@/lib/kanban/update-developer-levels"; +import { + type CodevMember, + type GuardSupabase, + assertProjectMembership, + buildPositionUpdates, + fetchColumnTasks, + getProjectIdForBoard, + getProjectIdForColumn, + getProjectIdForTask, + persistTaskPositions, + revalidateKanbanBoardLists, +} from "@/lib/kanban/action-helpers"; + +export const saveDraft = async ( + formData: FormData, + draftId?: string | null +): Promise<{ success: boolean; draftId?: string; error?: string }> => { + let supabase: GuardSupabase; + let user; + try { + ({ supabase, user } = await requireUser()); + } catch { + return { success: false, error: "Unauthorized" }; + } + + try { + // Never trust a client-supplied creator — derive it from the session. + const created_by = user.id; + const project_id = formData.get("project_id")?.toString(); + const intended_column_id = formData.get("intended_column_id")?.toString(); + + // Validate required fields + if (!project_id || !intended_column_id) { + return { + success: false, + error: "Missing required fields: project_id or intended_column_id" + }; + } + + // Only project members (or admins) may create/edit drafts in this project. + try { + await assertProjectMembership(supabase, user.id, { found: true, projectId: project_id ?? null }); + } catch { + return { success: false, error: "Forbidden" }; + } + + // Extract all form fields + const draftData = { + created_by, + project_id, + intended_column_id, + title: formData.get("title")?.toString() || null, + description: formData.get("description")?.toString() || null, + priority: formData.get("priority")?.toString() || null, + difficulty: formData.get("difficulty")?.toString() || null, + type: formData.get("type")?.toString() || null, + pr_link: formData.get("pr_link")?.toString() || null, + points: formData.get("points") ? Number(formData.get("points")) : null, + deadline: formData.get("deadline")?.toString() || null, + skill_category_id: formData.get("skill_category_id")?.toString() || null, + codev_id: formData.get("codev_id")?.toString() || null, + sidekick_ids: formData.get("sidekick_ids") + ?.toString() + .split(",") + .filter(Boolean) || [], + last_saved_at: new Date().toISOString(), + }; + + let result; + + if (draftId) { + // Update existing draft + const { data, error } = await supabase + .from("task_drafts") + .update(draftData) + .eq("id", draftId) + .select("id") + .single(); + + if (error) { + console.error("Error updating draft:", error); + return { success: false, error: error.message }; + } + + result = data; + } else { + // Create new draft (upsert to handle unique constraint) + const { data, error } = await supabase + .from("task_drafts") + .upsert(draftData, { + onConflict: "created_by,intended_column_id", + }) + .select("id") + .single(); + + if (error) { + console.error("Error creating draft:", error); + return { success: false, error: error.message }; + } + + result = data; + } + + revalidateKanbanBoardLists(project_id); + return { success: true, draftId: result.id }; + } catch (error) { + console.error("Error saving draft:", error); + return { + success: false, + error: error instanceof Error ? error.message : "Failed to save draft", + }; + } +}; + +/** + * Get all drafts for a user in a specific project + * @param projectId - Project UUID + * @param userId - User's codev.id + * @returns Array of TaskDraft objects + */ +export const getUserDrafts = async ( + projectId: string, + userId: string +): Promise<{ success: boolean; drafts?: TaskDraft[]; error?: string }> => { + const supabase = await createClientServerComponent(); + + try { + const { data, error } = await supabase + .from("task_drafts") + .select(` + *, + skill_category:skill_category_id (id, name), + codev:codev_id (id, first_name, last_name, image_url) + `) + .eq("project_id", projectId) + .eq("created_by", userId) + .order("last_saved_at", { ascending: false }); + + if (error) { + console.error("Error fetching drafts:", error); + return { success: false, error: error.message }; + } + + return { success: true, drafts: data as any }; + } catch (error) { + console.error("Error fetching drafts:", error); + return { + success: false, + error: error instanceof Error ? error.message : "Failed to fetch drafts", + }; + } +}; + +/** + * Load a single draft by ID + * @param draftId - Draft UUID + * @returns Single TaskDraft object + */ +export const loadDraft = async ( + draftId: string +): Promise<{ success: boolean; draft?: TaskDraft; error?: string }> => { + const supabase = await createClientServerComponent(); + + try { + const { data, error } = await supabase + .from("task_drafts") + .select(` + *, + skill_category:skill_category_id (id, name), + codev:codev_id (id, first_name, last_name, image_url) + `) + .eq("id", draftId) + .single(); + + if (error) { + console.error("Error loading draft:", error); + return { success: false, error: error.message }; + } + + return { success: true, draft: data as any }; + } catch (error) { + console.error("Error loading draft:", error); + return { + success: false, + error: error instanceof Error ? error.message : "Failed to load draft", + }; + } +}; + +/** + * Delete a draft permanently + * @param draftId - Draft UUID to delete + * @returns Success status + */ +export const deleteDraft = async ( + draftId: string +): Promise<{ success: boolean; error?: string }> => { + let supabase: GuardSupabase; + let user; + try { + ({ supabase, user } = await requireUser()); + } catch { + return { success: false, error: "Unauthorized" }; + } + + const { data: draftRow } = await supabase + .from("task_drafts") + .select("project_id") + .eq("id", draftId) + .single(); + try { + await assertProjectMembership(supabase, user.id, draftRow ? { found: true, projectId: draftRow.project_id ?? null } : { found: false }); + } catch { + return { success: false, error: "Forbidden" }; + } + + try { + const { error } = await supabase + .from("task_drafts") + .delete() + .eq("id", draftId); + + if (error) { + console.error("Error deleting draft:", error); + return { success: false, error: error.message }; + } + + if (draftRow?.project_id) revalidateKanbanBoardLists(draftRow.project_id); + return { success: true }; + } catch (error) { + console.error("Error deleting draft:", error); + return { + success: false, + error: error instanceof Error ? error.message : "Failed to delete draft", + }; + } +}; + +/** + * Promote a draft to an active task and delete the draft + * This is a transactional operation + * @param draftId - Draft UUID to promote + * @returns Success status and new task ID + */ +export const promoteDraft = async ( + draftId: string +): Promise<{ success: boolean; taskId?: string; error?: string }> => { + let supabase: GuardSupabase; + let user; + try { + ({ supabase, user } = await requireUser()); + } catch { + return { success: false, error: "Unauthorized" }; + } + + try { + // Step 1: Fetch the draft + const { data: draft, error: fetchError } = await supabase + .from("task_drafts") + .select( + "id, project_id, created_by, codev_id, title, description, type, priority, difficulty, points, deadline, intended_column_id, skill_category_id, sidekick_ids, pr_link, created_at", + ) + .eq("id", draftId) + .single(); + + if (fetchError || !draft) { + return { success: false, error: "Draft not found" }; + } + + // Only project members (or admins) may promote a draft into a task. + try { + await assertProjectMembership(supabase, user.id, { found: true, projectId: draft.project_id ?? null }); + } catch { + return { success: false, error: "Forbidden" }; + } + + // Step 2: Validate required fields for task creation + if (!draft.title || !draft.intended_column_id || !draft.skill_category_id) { + return { + success: false, + error: "Draft is incomplete. Title, column, and skill category are required.", + }; + } + + // Step 3: Create the task + const { data: newTask, error: createError } = await supabase + .from("tasks") + .insert({ + title: draft.title, + description: draft.description || "

No description provided

", + priority: draft.priority, + difficulty: draft.difficulty, + type: draft.type, + pr_link: draft.pr_link, + points: draft.points, + deadline: draft.deadline, + kanban_column_id: draft.intended_column_id, + skill_category_id: draft.skill_category_id, + codev_id: draft.codev_id, + sidekick_ids: draft.sidekick_ids || [], + created_by: draft.created_by, + created_at: new Date().toISOString(), + updated_at: new Date().toISOString(), + }) + .select("id") + .single(); + + if (createError) { + console.error("Error creating task from draft:", createError); + return { success: false, error: createError.message }; + } + + // Step 3.5: Trigger Notification if assignee exists in the draft + if (draft.codev_id) { + // Reuse the already-authenticated caller (checked before the mutation). + // Only send notification if assigning to someone else + if (user && user.id !== draft.codev_id) { + await createNotificationAction({ + recipientId: draft.codev_id, + title: "Draft Published & Assigned", + message: `Task "${draft.title}" has been published and assigned to you.`, + type: "task", + priority: "normal", + projectId: draft.project_id, + actionUrl: `/home/kanban/${draft.project_id}/${draft.intended_column_id}?taskId=${newTask.id}`, + metadata: { + taskId: newTask.id, + assignedAt: new Date().toISOString() + } + }); + } + } + + // Step 4: Delete the draft + const { error: deleteError } = await supabase + .from("task_drafts") + .delete() + .eq("id", draftId); + + if (deleteError) { + console.error("Error deleting draft after promotion:", deleteError); + // Task was created, so we still return success + } + + // Step 5: Revalidate the kanban board + const { data: columnData } = await supabase + .from("kanban_columns") + .select("board_id") + .eq("id", draft.intended_column_id) + .single(); + + if (columnData?.board_id) { + const { data: boardData } = await supabase + .from("kanban_boards") + .select("project_id") + .eq("id", columnData.board_id) + .single(); + + if (boardData?.project_id) { + revalidateKanbanBoardLists(boardData.project_id); + } + } + + return { success: true, taskId: newTask.id }; + } catch (error) { + console.error("Error promoting draft:", error); + return { + success: false, + error: error instanceof Error ? error.message : "Failed to promote draft", + }; + } +}; + +/** + * Get draft count for a user in a project (for badge display) + * @param projectId - Project UUID + * @param userId - User's codev.id + * @returns Count of user's drafts in project + */ +export const getDraftCount = async ( + projectId: string, + userId: string +): Promise<{ success: boolean; count?: number; error?: string }> => { + const supabase = await createClientServerComponent(); + + try { + const { count, error } = await supabase + .from("task_drafts") + .select("*", { count: "exact", head: true }) + .eq("project_id", projectId) + .eq("created_by", userId); + + if (error) { + console.error("Error counting drafts:", error); + return { success: false, error: error.message }; + } + + return { success: true, count: count || 0 }; + } catch (error) { + console.error("Error counting drafts:", error); + return { + success: false, + error: error instanceof Error ? error.message : "Failed to count drafts", + }; + } +}; + diff --git a/apps/codebility/actions/kanban/index.ts b/apps/codebility/actions/kanban/index.ts new file mode 100644 index 000000000..b7d039d47 --- /dev/null +++ b/apps/codebility/actions/kanban/index.ts @@ -0,0 +1,7 @@ +export * from "./board"; +export * from "./tasks"; +export * from "./columns"; +export * from "./drafts"; +export * from "./queries"; +export * from "./boards-list"; +export * from "./sprints"; diff --git a/apps/codebility/actions/kanban/queries.ts b/apps/codebility/actions/kanban/queries.ts new file mode 100644 index 000000000..e67d8a762 --- /dev/null +++ b/apps/codebility/actions/kanban/queries.ts @@ -0,0 +1,130 @@ +"use server"; + +import { Task, TaskDraft } from "@/types/home/codev"; +import { createClientServerComponent } from "@/utils/supabase/server"; +import { requireUser } from "@/lib/server/auth-guard"; +import { createNotificationAction } from "@/actions/notifications/notification.actions"; +import { + fetchTaskDetail, + type KanbanTaskDetail, +} from "@/lib/server/kanban-task-query"; +import { fetchMemberBoardTasks as fetchMemberBoardTasksQuery } from "@/lib/server/kanban-member-tasks-query"; +import { fetchColumnTasksPage as fetchColumnTasksPageQuery } from "@/lib/server/kanban-column-tasks-query"; +import { KANBAN_COLUMN_TASK_PAGE_SIZE } from "@/constants/kanban/pagination"; +import type { BoardSnapshotInput } from "@/lib/kanban/board-snapshot-types"; +import { persistBoardSnapshot } from "@/lib/server/kanban-board-snapshot-sync"; +import { updateDeveloperLevels } from "@/lib/kanban/update-developer-levels"; +import { + type CodevMember, + type GuardSupabase, + assertProjectMembership, + buildPositionUpdates, + fetchColumnTasks, + getProjectIdForBoard, + getProjectIdForColumn, + getProjectIdForTask, + persistTaskPositions, + revalidateKanbanBoardLists, +} from "@/lib/kanban/action-helpers"; + +export async function fetchColumnTasksPage( + columnId: string, + offset: number, + limit: number = KANBAN_COLUMN_TASK_PAGE_SIZE, +): Promise<{ success: boolean; tasks?: Task[]; error?: string }> { + let supabase: GuardSupabase; + let user; + try { + ({ supabase, user } = await requireUser()); + } catch { + return { success: false, error: "Unauthorized" }; + } + + try { + const projectResolution = await getProjectIdForColumn(supabase, columnId); + await assertProjectMembership(supabase, user.id, projectResolution); + + const tasks = await fetchColumnTasksPageQuery( + supabase, + columnId, + offset, + limit, + ); + return { success: true, tasks }; + } catch (error) { + if (error instanceof Error && error.message === "Forbidden") { + return { success: false, error: "Forbidden" }; + } + console.error("Error fetching column tasks page:", error); + return { + success: false, + error: + error instanceof Error ? error.message : "Failed to fetch column tasks", + }; + } +} + +export async function fetchMemberBoardTasks( + boardId: string, + memberId: string, +): Promise<{ success: boolean; tasks?: Task[]; error?: string }> { + let supabase: GuardSupabase; + let user; + try { + ({ supabase, user } = await requireUser()); + } catch { + return { success: false, error: "Unauthorized" }; + } + + try { + const projectResolution = await getProjectIdForBoard(supabase, boardId); + await assertProjectMembership(supabase, user.id, projectResolution); + + const tasks = await fetchMemberBoardTasksQuery(supabase, boardId, memberId); + return { success: true, tasks }; + } catch (error) { + if (error instanceof Error && error.message === "Forbidden") { + return { success: false, error: "Forbidden" }; + } + console.error("Error fetching member board tasks:", error); + return { + success: false, + error: + error instanceof Error ? error.message : "Failed to fetch member tasks", + }; + } +} + +export async function getTaskDetail( + taskId: string, +): Promise<{ success: boolean; data?: KanbanTaskDetail; error?: string }> { + let supabase: GuardSupabase; + let user; + try { + ({ supabase, user } = await requireUser()); + } catch { + return { success: false, error: "Unauthorized" }; + } + + try { + const projectResolution = await getProjectIdForTask(supabase, taskId); + await assertProjectMembership(supabase, user.id, projectResolution); + + const data = await fetchTaskDetail(supabase, taskId); + if (!data) { + return { success: false, error: "Task not found" }; + } + + return { success: true, data }; + } catch (error) { + if (error instanceof Error && error.message === "Forbidden") { + return { success: false, error: "Forbidden" }; + } + console.error("Error fetching task detail:", error); + return { + success: false, + error: error instanceof Error ? error.message : "Failed to fetch task", + }; + } +} + diff --git a/apps/codebility/app/home/kanban/[projectId]/actions.ts b/apps/codebility/actions/kanban/sprints.ts similarity index 83% rename from apps/codebility/app/home/kanban/[projectId]/actions.ts rename to apps/codebility/actions/kanban/sprints.ts index 3fba38afb..335291529 100644 --- a/apps/codebility/app/home/kanban/[projectId]/actions.ts +++ b/apps/codebility/actions/kanban/sprints.ts @@ -1,6 +1,7 @@ "use server"; import { requireProjectMember } from "@/lib/server/auth-guard"; +import { createClientServerComponent } from "@/utils/supabase/server"; import { revalidatePath } from "next/cache"; export async function createNewSprint(formData: FormData) { @@ -157,4 +158,40 @@ export async function EditSprint(formData: FormData) { return { isSuccess: false, error: "Failed to edit sprint" }; } -} \ No newline at end of file +} + +export const getSprintsData = async (projectId: string) => { + try { + const supabase = await createClientServerComponent(); + + const { data, error } = await supabase + .from("projects") + .select( + ` + id, + name, + kanban_sprints!kanban_sprints_project_id_fkey ( + id, + name, + start_at, + end_at, + project_id, + board_id, + kanban_board:kanban_boards!kanban_sprints_board_id_fkey ( + id, + name + ) + ) + `, + ) + .eq("id", projectId) + .single(); + + if (error) throw error; + + return data; + } catch (error) { + console.error(error); + throw error; + } +}; \ No newline at end of file diff --git a/apps/codebility/app/home/kanban/[projectId]/[id]/actions.ts b/apps/codebility/actions/kanban/tasks.ts similarity index 53% rename from apps/codebility/app/home/kanban/[projectId]/[id]/actions.ts rename to apps/codebility/actions/kanban/tasks.ts index ece2ba893..d590b2b11 100644 --- a/apps/codebility/app/home/kanban/[projectId]/[id]/actions.ts +++ b/apps/codebility/actions/kanban/tasks.ts @@ -1,174 +1,32 @@ "use server"; -import { revalidatePath } from "next/cache"; import { Task, TaskDraft } from "@/types/home/codev"; import { createClientServerComponent } from "@/utils/supabase/server"; import { requireUser } from "@/lib/server/auth-guard"; -import { createNotificationAction } from "@/lib/actions/notification.actions"; - -interface CodevMember { - id: string; - first_name: string; - last_name: string; - image_url?: string | null; -} - -type GuardSupabase = Awaited>; - -// ── Authorization helpers ──────────────────────────────────────────────────── -// All kanban actions here operate on a task/column/draft id, so we resolve the -// owning project and verify membership (admins bypass) before mutating. -// -// Resolvers return a discriminated result so assertProjectMembership can -// distinguish "target row doesn't exist" (safe no-op) from "target exists but -// no project could be resolved" (deny — fail closed). See PR #609 review. - -type ProjectResolution = - | { found: false } - | { found: true; projectId: string | null }; - -async function getProjectIdForColumn( - supabase: GuardSupabase, - columnId: string | null | undefined, -): Promise { - if (!columnId) return { found: false }; - const { data: column } = await supabase - .from("kanban_columns") - .select("board_id") - .eq("id", columnId) - .single(); - if (!column) return { found: false }; - if (!column.board_id) return { found: true, projectId: null }; - const { data: board } = await supabase - .from("kanban_boards") - .select("project_id") - .eq("id", column.board_id) - .single(); - return { found: true, projectId: board?.project_id ?? null }; -} - -async function getProjectIdForTask( - supabase: GuardSupabase, - taskId: string, -): Promise { - const { data: task } = await supabase - .from("tasks") - .select("kanban_column_id") - .eq("id", taskId) - .single(); - if (!task) return { found: false }; - return getProjectIdForColumn(supabase, task.kanban_column_id); -} - -async function getProjectIdForBoard( - supabase: GuardSupabase, - boardId: string, -): Promise { - const { data: board } = await supabase - .from("kanban_boards") - .select("project_id") - .eq("id", boardId) - .single(); - if (!board) return { found: false }; - return { found: true, projectId: board.project_id ?? null }; -} - -/** - * Throws "Forbidden" unless the caller is a member of the resolved project or - * an admin. Fail-closed: if the target row exists but no project could be - * resolved (orphaned board, null FK, transient query failure) access is DENIED. - * Only when the target row itself doesn't exist (genuine no-op) is the check - * skipped — the subsequent mutation will harmlessly match zero rows. - */ -async function assertProjectMembership( - supabase: GuardSupabase, - userId: string, - resolution: ProjectResolution, -): Promise { - // Target row doesn't exist → the mutation will be a real no-op. - if (!resolution.found) return; - - // Target exists but project could not be resolved → DENY (fail closed). - if (!resolution.projectId) throw new Error("Forbidden"); - - const projectId = resolution.projectId; - - const { data: me } = await supabase - .from("codev") - .select("role_id") - .eq("id", userId) - .single(); - - if (me?.role_id === 1) return; // admin bypass - - const { data: member } = await supabase - .from("project_members") - .select("id") - .eq("project_id", projectId) - .eq("codev_id", userId) - .maybeSingle(); - - if (!member) { - throw new Error("Forbidden"); - } -} - -const updateDeveloperLevels = async (codevId?: string) => { - if (!codevId) return; - - const supabase = await createClientServerComponent(); - - const { data: pointsData, error: pointsError } = await supabase - .from("codev_points") - .select("skill_category_id, points") - .eq("codev_id", codevId); - - if (pointsError) { - console.error("Error fetching points:", pointsError); - return; - } - - const levels: Record = {}; - - // Fetch all levels in parallel instead of sequential - const levelPromises = pointsData.map(async (pointRecord) => { - const { data: levelData, error: levelError } = await supabase - .from("levels") - .select("*") - .eq("skill_category_id", pointRecord.skill_category_id) - .lte("min_points", pointRecord.points) - .order("level", { ascending: false }) - .limit(1) - .maybeSingle(); - - if (!levelError && levelData) { - return { - skillCategoryId: pointRecord.skill_category_id, - level: levelData.level - }; - } - return null; - }); - - const levelResults = await Promise.all(levelPromises); - - levelResults.forEach((result) => { - if (result) { - levels[result.skillCategoryId] = result.level; - } - }); - - if (Object.keys(levels).length > 0) { - const { error: updateError } = await supabase - .from("codev") - .update({ level: levels }) - .eq("id", codevId); - - if (updateError) { - console.error("Error updating levels:", updateError); - } - } -}; +import { createNotificationAction } from "@/actions/notifications/notification.actions"; +import { + fetchTaskDetail, + type KanbanTaskDetail, +} from "@/lib/server/kanban-task-query"; +import { fetchMemberBoardTasks as fetchMemberBoardTasksQuery } from "@/lib/server/kanban-member-tasks-query"; +import { fetchColumnTasksPage as fetchColumnTasksPageQuery } from "@/lib/server/kanban-column-tasks-query"; +import { KANBAN_COLUMN_TASK_PAGE_SIZE } from "@/constants/kanban/pagination"; +import type { BoardSnapshotInput } from "@/lib/kanban/board-snapshot-types"; +import { persistBoardSnapshot } from "@/lib/server/kanban-board-snapshot-sync"; +import { updateDeveloperLevels } from "@/lib/kanban/update-developer-levels"; +import { + type CodevMember, + type GuardSupabase, + assertProjectMembership, + buildPositionUpdates, + fetchColumnTasks, + getProjectIdForBoard, + getProjectIdForColumn, + getProjectIdForTask, + persistTaskPositions, + revalidateKanbanBoardLists, +} from "@/lib/kanban/action-helpers"; +import { moveTask } from "./board"; export const updateTaskColumnId = async ( taskId: string, @@ -178,22 +36,25 @@ export const updateTaskColumnId = async ( const projectId = await getProjectIdForTask(supabase, taskId); await assertProjectMembership(supabase, user.id, projectId); - try { - const { data, error } = await supabase - .from("tasks") - .update({ - kanban_column_id: newColumnId, - updated_at: new Date().toISOString(), - }) - .eq("id", taskId) - .select("*") - .single(); + const targetTasks = await fetchColumnTasks(supabase, newColumnId); + const result = await moveTask({ + taskId, + columnId: newColumnId, + position: targetTasks.length, + }); - if (error) throw error; - return data as Task; - } catch (error) { - throw error; + if (!result.success) { + throw new Error(result.error ?? "Failed to move task"); } + + const { data, error } = await supabase + .from("tasks") + .select("*") + .eq("id", taskId) + .single(); + + if (error || !data) throw error ?? new Error("Task not found"); + return data as Task; }; export const fetchAvailableMembers = async ( @@ -299,6 +160,9 @@ export const createNewTask = async ( return { success: false, error: "Forbidden" }; } + const targetTasks = await fetchColumnTasks(supabase, kanban_column_id); + const nextPosition = targetTasks.length; + const { data: newTask, error } = await supabase.from("tasks").insert([ { title, @@ -309,6 +173,7 @@ export const createNewTask = async ( pr_link, points, kanban_column_id, + position: nextPosition, codev_id, sidekick_ids, skill_category_id, @@ -336,7 +201,7 @@ export const createNewTask = async ( .single(); if (boardData?.project_id) { - revalidatePath(`/home/kanban/${boardData.project_id}/${columnData.board_id}`); + revalidateKanbanBoardLists(boardData.project_id); if (codev_id && newTask?.id) { // Reuse the already-authenticated caller (checked before the mutation). @@ -443,6 +308,8 @@ export const updateTask = async ( return { success: false, error: updateError.message }; } + revalidateKanbanBoardLists(projectId.found ? projectId.projectId : null); + // 3. NOTIFICATION LOGIC: Trigger if assignee changed and isn't null if (codev_id && codev_id !== existingTask.codev_id) { // Reuse the already-authenticated caller (checked before the mutation). @@ -531,7 +398,7 @@ export const deleteTask = async ( .single(); if (boardData?.project_id) { - revalidatePath(`/home/kanban/${boardData.project_id}/${columnData.board_id}`); + revalidateKanbanBoardLists(boardData.project_id); } } } @@ -546,185 +413,6 @@ export const deleteTask = async ( } }; -export const createNewColumn = async ( - columnName: string, - boardId: string, -): Promise<{ success: boolean; error?: string }> => { - let supabase: GuardSupabase; - let user; - try { - ({ supabase, user } = await requireUser()); - } catch { - return { success: false, error: "Unauthorized" }; - } - - const projectId = await getProjectIdForBoard(supabase, boardId); - try { - await assertProjectMembership(supabase, user.id, projectId); - } catch { - return { success: false, error: "Forbidden" }; - } - - try { - const { data: existingColumns, error: queryError } = await supabase - .from("kanban_columns") - .select("position") - .eq("board_id", boardId) - .order("position", { ascending: false }) - .limit(1); - - if (queryError) { - return { success: false, error: queryError.message }; - } - - const nextPosition = (existingColumns?.[0]?.position ?? -1) + 1; - - const { error: insertError } = await supabase - .from("kanban_columns") - .insert({ - name: columnName.trim(), - board_id: boardId, - position: nextPosition, - created_at: new Date().toISOString(), - updated_at: new Date().toISOString(), - }); - - if (insertError) { - console.error("Database error:", insertError); - return { success: false, error: insertError.message }; - } - - return { success: true }; - } catch (error) { - console.error("Unexpected error:", error); - return { - success: false, - error: error instanceof Error ? error.message : "Failed to create column", - }; - } -}; - -export const updateColumnPosition = async ( - columnId: string, - newPosition: number, -): Promise<{ success: boolean; error?: string }> => { - let supabase: GuardSupabase; - let user; - try { - ({ supabase, user } = await requireUser()); - } catch { - return { success: false, error: "Unauthorized" }; - } - - const projectId = await getProjectIdForColumn(supabase, columnId); - try { - await assertProjectMembership(supabase, user.id, projectId); - } catch { - return { success: false, error: "Forbidden" }; - } - - try { - const { error } = await supabase - .from("kanban_columns") - .update({ - position: newPosition, - updated_at: new Date().toISOString(), - }) - .eq("id", columnId); - - if (error) { - throw error; - } - - return { success: true }; - } catch (error) { - console.error("Error updating column position:", error); - return { - success: false, - error: error instanceof Error ? error.message : "Failed to update column position", - }; - } -}; - -export const deleteColumn = async ( - columnId: string, -): Promise<{ success: boolean; error?: string }> => { - let supabase: GuardSupabase; - let user; - try { - ({ supabase, user } = await requireUser()); - } catch { - return { success: false, error: "Unauthorized" }; - } - - const projectId = await getProjectIdForColumn(supabase, columnId); - try { - await assertProjectMembership(supabase, user.id, projectId); - } catch { - return { success: false, error: "Forbidden" }; - } - - try { - const { error } = await supabase - .from("kanban_columns") - .delete() - .eq("id", columnId); - - if (error) { - return { success: false, error: error.message }; - } - - return { success: true }; - } catch (error) { - return { - success: false, - error: error instanceof Error ? error.message : "Failed to delete column", - }; - } -}; - -export const updateColumnName = async ( - columnId: string, - newName: string, -): Promise<{ success: boolean; error?: string }> => { - let supabase: GuardSupabase; - let user; - try { - ({ supabase, user } = await requireUser()); - } catch { - return { success: false, error: "Unauthorized" }; - } - - const projectId = await getProjectIdForColumn(supabase, columnId); - try { - await assertProjectMembership(supabase, user.id, projectId); - } catch { - return { success: false, error: "Forbidden" }; - } - - try { - const { error } = await supabase - .from("kanban_columns") - .update({ - name: newName.trim(), - updated_at: new Date().toISOString(), - }) - .eq("id", columnId); - - if (error) { - return { success: false, error: error.message }; - } - - return { success: true }; - } catch (error) { - return { - success: false, - error: error instanceof Error ? error.message : "Failed to update column name", - }; - } -}; - -// OPTIMIZED: Complete task with parallel operations to prevent timeouts export const completeTask = async ( task: Task, ): Promise<{ success: boolean; error?: string }> => { @@ -818,7 +506,7 @@ export const completeTask = async ( const pointsPromises = allMemberIds.map(memberId => supabase .from("codev_points") - .select("*") + .select("id, points") .eq("codev_id", memberId) .eq("skill_category_id", skillCategoryId) .maybeSingle() @@ -917,13 +605,7 @@ export const completeTask = async ( } // Single revalidation at the end - revalidatePath("/home/kanban"); - if (projectId) { - revalidatePath(`/home/kanban/${projectId}`); - } - if (projectId && boardId) { - revalidatePath(`/home/kanban/${projectId}/${boardId}`); - } + revalidateKanbanBoardLists(projectId); return { success: true }; } catch (error) { @@ -935,69 +617,6 @@ export const completeTask = async ( } }; -export async function batchUpdateTasks( - updates: Array<{ taskId: string; newColumnId: string }>, -) { - let supabase: GuardSupabase; - let user; - try { - ({ supabase, user } = await requireUser()); - } catch { - return { success: false, error: "Unauthorized" }; - } - - try { - // Verify membership for every distinct project touched by the batch. - const resolutions = await Promise.all( - updates.map((u) => getProjectIdForTask(supabase, u.taskId)), - ); - - // Fail-closed: if any target exists but has no resolvable project, deny. - for (const res of resolutions) { - if (res.found && !res.projectId) { - throw new Error("Forbidden"); - } - } - - const uniqueProjectIds = [ - ...new Set( - resolutions - .filter((r): r is { found: true; projectId: string } => r.found && r.projectId !== null) - .map((r) => r.projectId), - ), - ]; - for (const projectId of uniqueProjectIds) { - await assertProjectMembership(supabase, user.id, { found: true, projectId }); - } - } catch { - return { success: false, error: "Forbidden" }; - } - - try { - const updatePromises = updates.map(async (update) => { - const { error } = await supabase - .from("tasks") - .update({ - kanban_column_id: update.newColumnId, - updated_at: new Date().toISOString(), - }) - .eq("id", update.taskId); - return error; - }); - const errors = await Promise.all(updatePromises); - return { - success: !errors.some((error) => error !== null), - error: errors.some((error) => error !== null) ? "Some updates failed" : undefined, - }; - } catch (error) { - console.error("Batch update error:", error); - return { - success: false, - error: error instanceof Error ? error.message : "Batch update failed", - }; - } -} - export async function updateTaskPRLink(taskId: string, prLink: string) { let supabase: GuardSupabase; let user; @@ -1046,7 +665,7 @@ export async function updateTaskPRLink(taskId: string, prLink: string) { .single(); if (boardData?.project_id) { - revalidatePath(`/home/kanban/${boardData.project_id}/${columnData.board_id}`); + revalidateKanbanBoardLists(boardData.project_id); } } } @@ -1112,7 +731,7 @@ export async function unarchiveTask(taskId: string) { .single(); if (boardData?.project_id) { - revalidatePath(`/home/kanban/${boardData.project_id}/${columnData.board_id}`); + revalidateKanbanBoardLists(boardData.project_id); } } } @@ -1141,396 +760,6 @@ export async function unarchiveTask(taskId: string) { * @param draftId - Optional draft ID for updates * @returns Success status and draft ID */ -export const saveDraft = async ( - formData: FormData, - draftId?: string | null -): Promise<{ success: boolean; draftId?: string; error?: string }> => { - let supabase: GuardSupabase; - let user; - try { - ({ supabase, user } = await requireUser()); - } catch { - return { success: false, error: "Unauthorized" }; - } - - try { - // Never trust a client-supplied creator — derive it from the session. - const created_by = user.id; - const project_id = formData.get("project_id")?.toString(); - const intended_column_id = formData.get("intended_column_id")?.toString(); - - // Validate required fields - if (!project_id || !intended_column_id) { - return { - success: false, - error: "Missing required fields: project_id or intended_column_id" - }; - } - - // Only project members (or admins) may create/edit drafts in this project. - try { - await assertProjectMembership(supabase, user.id, { found: true, projectId: project_id ?? null }); - } catch { - return { success: false, error: "Forbidden" }; - } - - // Extract all form fields - const draftData = { - created_by, - project_id, - intended_column_id, - title: formData.get("title")?.toString() || null, - description: formData.get("description")?.toString() || null, - priority: formData.get("priority")?.toString() || null, - difficulty: formData.get("difficulty")?.toString() || null, - type: formData.get("type")?.toString() || null, - pr_link: formData.get("pr_link")?.toString() || null, - points: formData.get("points") ? Number(formData.get("points")) : null, - deadline: formData.get("deadline")?.toString() || null, - skill_category_id: formData.get("skill_category_id")?.toString() || null, - codev_id: formData.get("codev_id")?.toString() || null, - sidekick_ids: formData.get("sidekick_ids") - ?.toString() - .split(",") - .filter(Boolean) || [], - last_saved_at: new Date().toISOString(), - }; - - let result; - - if (draftId) { - // Update existing draft - const { data, error } = await supabase - .from("task_drafts") - .update(draftData) - .eq("id", draftId) - .select("id") - .single(); - - if (error) { - console.error("Error updating draft:", error); - return { success: false, error: error.message }; - } - - result = data; - } else { - // Create new draft (upsert to handle unique constraint) - const { data, error } = await supabase - .from("task_drafts") - .upsert(draftData, { - onConflict: "created_by,intended_column_id", - }) - .select("id") - .single(); - - if (error) { - console.error("Error creating draft:", error); - return { success: false, error: error.message }; - } - - result = data; - } - - return { success: true, draftId: result.id }; - } catch (error) { - console.error("Error saving draft:", error); - return { - success: false, - error: error instanceof Error ? error.message : "Failed to save draft", - }; - } -}; - -/** - * Get all drafts for a user in a specific project - * @param projectId - Project UUID - * @param userId - User's codev.id - * @returns Array of TaskDraft objects - */ -export const getUserDrafts = async ( - projectId: string, - userId: string -): Promise<{ success: boolean; drafts?: TaskDraft[]; error?: string }> => { - const supabase = await createClientServerComponent(); - - try { - const { data, error } = await supabase - .from("task_drafts") - .select(` - *, - skill_category:skill_category_id (id, name), - codev:codev_id (id, first_name, last_name, image_url) - `) - .eq("project_id", projectId) - .eq("created_by", userId) - .order("last_saved_at", { ascending: false }); - - if (error) { - console.error("Error fetching drafts:", error); - return { success: false, error: error.message }; - } - - return { success: true, drafts: data as any }; - } catch (error) { - console.error("Error fetching drafts:", error); - return { - success: false, - error: error instanceof Error ? error.message : "Failed to fetch drafts", - }; - } -}; - -/** - * Load a single draft by ID - * @param draftId - Draft UUID - * @returns Single TaskDraft object - */ -export const loadDraft = async ( - draftId: string -): Promise<{ success: boolean; draft?: TaskDraft; error?: string }> => { - const supabase = await createClientServerComponent(); - - try { - const { data, error } = await supabase - .from("task_drafts") - .select(` - *, - skill_category:skill_category_id (id, name), - codev:codev_id (id, first_name, last_name, image_url) - `) - .eq("id", draftId) - .single(); - - if (error) { - console.error("Error loading draft:", error); - return { success: false, error: error.message }; - } - - return { success: true, draft: data as any }; - } catch (error) { - console.error("Error loading draft:", error); - return { - success: false, - error: error instanceof Error ? error.message : "Failed to load draft", - }; - } -}; - -/** - * Delete a draft permanently - * @param draftId - Draft UUID to delete - * @returns Success status - */ -export const deleteDraft = async ( - draftId: string -): Promise<{ success: boolean; error?: string }> => { - let supabase: GuardSupabase; - let user; - try { - ({ supabase, user } = await requireUser()); - } catch { - return { success: false, error: "Unauthorized" }; - } - - const { data: draftRow } = await supabase - .from("task_drafts") - .select("project_id") - .eq("id", draftId) - .single(); - try { - await assertProjectMembership(supabase, user.id, draftRow ? { found: true, projectId: draftRow.project_id ?? null } : { found: false }); - } catch { - return { success: false, error: "Forbidden" }; - } - - try { - const { error } = await supabase - .from("task_drafts") - .delete() - .eq("id", draftId); - - if (error) { - console.error("Error deleting draft:", error); - return { success: false, error: error.message }; - } - - return { success: true }; - } catch (error) { - console.error("Error deleting draft:", error); - return { - success: false, - error: error instanceof Error ? error.message : "Failed to delete draft", - }; - } -}; - -/** - * Promote a draft to an active task and delete the draft - * This is a transactional operation - * @param draftId - Draft UUID to promote - * @returns Success status and new task ID - */ -export const promoteDraft = async ( - draftId: string -): Promise<{ success: boolean; taskId?: string; error?: string }> => { - let supabase: GuardSupabase; - let user; - try { - ({ supabase, user } = await requireUser()); - } catch { - return { success: false, error: "Unauthorized" }; - } - - try { - // Step 1: Fetch the draft - const { data: draft, error: fetchError } = await supabase - .from("task_drafts") - .select("*") - .eq("id", draftId) - .single(); - - if (fetchError || !draft) { - return { success: false, error: "Draft not found" }; - } - - // Only project members (or admins) may promote a draft into a task. - try { - await assertProjectMembership(supabase, user.id, { found: true, projectId: draft.project_id ?? null }); - } catch { - return { success: false, error: "Forbidden" }; - } - - // Step 2: Validate required fields for task creation - if (!draft.title || !draft.intended_column_id || !draft.skill_category_id) { - return { - success: false, - error: "Draft is incomplete. Title, column, and skill category are required.", - }; - } - - // Step 3: Create the task - const { data: newTask, error: createError } = await supabase - .from("tasks") - .insert({ - title: draft.title, - description: draft.description || "

No description provided

", - priority: draft.priority, - difficulty: draft.difficulty, - type: draft.type, - pr_link: draft.pr_link, - points: draft.points, - deadline: draft.deadline, - kanban_column_id: draft.intended_column_id, - skill_category_id: draft.skill_category_id, - codev_id: draft.codev_id, - sidekick_ids: draft.sidekick_ids || [], - created_by: draft.created_by, - created_at: new Date().toISOString(), - updated_at: new Date().toISOString(), - }) - .select("id") - .single(); - - if (createError) { - console.error("Error creating task from draft:", createError); - return { success: false, error: createError.message }; - } - - // Step 3.5: Trigger Notification if assignee exists in the draft - if (draft.codev_id) { - // Reuse the already-authenticated caller (checked before the mutation). - // Only send notification if assigning to someone else - if (user && user.id !== draft.codev_id) { - await createNotificationAction({ - recipientId: draft.codev_id, - title: "Draft Published & Assigned", - message: `Task "${draft.title}" has been published and assigned to you.`, - type: "task", - priority: "normal", - projectId: draft.project_id, - actionUrl: `/home/kanban/${draft.project_id}/${draft.intended_column_id}?taskId=${newTask.id}`, - metadata: { - taskId: newTask.id, - assignedAt: new Date().toISOString() - } - }); - } - } - - // Step 4: Delete the draft - const { error: deleteError } = await supabase - .from("task_drafts") - .delete() - .eq("id", draftId); - - if (deleteError) { - console.error("Error deleting draft after promotion:", deleteError); - // Task was created, so we still return success - } - - // Step 5: Revalidate the kanban board - const { data: columnData } = await supabase - .from("kanban_columns") - .select("board_id") - .eq("id", draft.intended_column_id) - .single(); - - if (columnData?.board_id) { - const { data: boardData } = await supabase - .from("kanban_boards") - .select("project_id") - .eq("id", columnData.board_id) - .single(); - - if (boardData?.project_id) { - revalidatePath(`/home/kanban/${boardData.project_id}/${columnData.board_id}`); - } - } - - return { success: true, taskId: newTask.id }; - } catch (error) { - console.error("Error promoting draft:", error); - return { - success: false, - error: error instanceof Error ? error.message : "Failed to promote draft", - }; - } -}; - -/** - * Get draft count for a user in a project (for badge display) - * @param projectId - Project UUID - * @param userId - User's codev.id - * @returns Count of user's drafts in project - */ -export const getDraftCount = async ( - projectId: string, - userId: string -): Promise<{ success: boolean; count?: number; error?: string }> => { - const supabase = await createClientServerComponent(); - - try { - const { count, error } = await supabase - .from("task_drafts") - .select("*", { count: "exact", head: true }) - .eq("project_id", projectId) - .eq("created_by", userId); - - if (error) { - console.error("Error counting drafts:", error); - return { success: false, error: error.message }; - } - - return { success: true, count: count || 0 }; - } catch (error) { - console.error("Error counting drafts:", error); - return { - success: false, - error: error instanceof Error ? error.message : "Failed to count drafts", - }; - } -}; - export async function transferTaskToSprint( taskId: string, destinationSprintId: string, @@ -1679,9 +908,7 @@ export async function transferTaskToSprint( // ------------------------------------------------------------------------- // 6. Revalidate both boards so changes are visible immediately // ------------------------------------------------------------------------- - revalidatePath(`/home/kanban/${projectId}/${sourceBoardId}`); - revalidatePath(`/home/kanban/${projectId}/${destinationBoardId}`); - revalidatePath(`/home/kanban/${projectId}`); + revalidateKanbanBoardLists(projectId); return { success: true }; } catch (error) { @@ -1692,4 +919,4 @@ export async function transferTaskToSprint( error instanceof Error ? error.message : "Failed to transfer task.", }; } -} \ No newline at end of file +} diff --git a/apps/codebility/app/home/kanban/ticket/[ticketCode]/actions.ts b/apps/codebility/actions/kanban/ticket.ts similarity index 100% rename from apps/codebility/app/home/kanban/ticket/[ticketCode]/actions.ts rename to apps/codebility/actions/kanban/ticket.ts diff --git a/apps/codebility/actions/leaderboard/actions.ts b/apps/codebility/actions/leaderboard/actions.ts new file mode 100644 index 000000000..c03a659a6 --- /dev/null +++ b/apps/codebility/actions/leaderboard/actions.ts @@ -0,0 +1,15 @@ +"use server"; + +import { revalidateTag } from "next/cache"; + +/** + * Drops the cached all-time technical leaderboard. + * + * The board is served from `unstable_cache` so a page load does not repeat the + * whole codev_points aggregation. Without this, a realtime points change would + * update the open tab but the next server render would still paint the stale + * cached board, so postgres realtime is what invalidates the cache too. + */ +export async function revalidateTechnicalLeaderboard() { + revalidateTag("technical-leaderboard"); +} diff --git a/apps/codebility/app/(marketing)/profiles/_service/emailAction.ts b/apps/codebility/actions/marketing/profiles-email.ts similarity index 87% rename from apps/codebility/app/(marketing)/profiles/_service/emailAction.ts rename to apps/codebility/actions/marketing/profiles-email.ts index 4cd47e3ab..5d4917d83 100644 --- a/apps/codebility/app/(marketing)/profiles/_service/emailAction.ts +++ b/apps/codebility/actions/marketing/profiles-email.ts @@ -1,9 +1,9 @@ "use server"; import { Codev } from "@/types/home/codev"; +import type { HireCodevEmail } from "@/types/marketing/hire-codev-email"; +import { HTMLTemplate, TextTemplate } from "@/lib/marketing/hire-codev-template"; import { Resend } from "resend"; -import { HireCodevEmail } from "../_components/CodevHireCodevModal"; -import { HTMLTemplate, TextTemplate } from "./template/hire-codev-template"; type EmailProps = HireCodevEmail & { codev: Codev; diff --git a/apps/codebility/app/(marketing)/profiles/_service/actions.ts b/apps/codebility/actions/marketing/profiles.ts similarity index 100% rename from apps/codebility/app/(marketing)/profiles/_service/actions.ts rename to apps/codebility/actions/marketing/profiles.ts diff --git a/apps/codebility/app/home/my-team/actions.ts b/apps/codebility/actions/my-team/actions.ts similarity index 100% rename from apps/codebility/app/home/my-team/actions.ts rename to apps/codebility/actions/my-team/actions.ts diff --git a/apps/codebility/app/home/my-team/[projectId]/actions/attendance-sync.ts b/apps/codebility/actions/my-team/attendance-sync.ts similarity index 57% rename from apps/codebility/app/home/my-team/[projectId]/actions/attendance-sync.ts rename to apps/codebility/actions/my-team/attendance-sync.ts index 5d23ec305..c23485c08 100644 --- a/apps/codebility/app/home/my-team/[projectId]/actions/attendance-sync.ts +++ b/apps/codebility/actions/my-team/attendance-sync.ts @@ -1,15 +1,64 @@ "use server"; +import { revalidatePath } from "next/cache"; + import { createClientServerComponent } from "@/utils/supabase/server"; +import { requireUser } from "@/lib/server/auth-guard"; + +/** + * Attendance point writes are restricted to the project's own team. + * + * Both entry points used to run with no check at all, so any caller could + * rewrite attendance points for any codev. `requireUser` establishes the + * caller; the membership check inside each function scopes them to a project + * they belong to. + */ +async function assertProjectMember( + supabase: Awaited>, + projectId: string, + callerId: string, + roleId: number | null, +) { + // role_id 1 is admin, consistent with auth-guard. + if (roleId === 1) return; + + const { data: membership } = await supabase + .from("project_members") + .select("id") + .eq("project_id", projectId) + .eq("codev_id", callerId) + .maybeSingle(); + + if (!membership) { + throw new Error("Forbidden"); + } +} export async function syncAttendancePoints(codevId: string) { + const { user, roleId } = await requireUser(); const supabase = await createClientServerComponent(); - + try { - // Count all present/late days for this codev - const { data: attendanceRecords, error: attendanceError } = await supabase + // Resolve which project this codev belongs to and confirm the caller is on + // it, so a member cannot rewrite another team's points. + const { data: membership } = await supabase + .from("project_members") + .select("project_id") + .eq("codev_id", codevId) + .limit(1) + .maybeSingle(); + + if (membership?.project_id) { + await assertProjectMember(supabase, membership.project_id, user.id, roleId); + } else if (roleId !== 1 && user.id !== codevId) { + throw new Error("Forbidden"); + } + + // Count all present/late days for this codev. Only the count is used, so + // ask Postgres for the count and transfer no rows. + const { count: attendanceCount, error: attendanceError } = await supabase .from("attendance") - .select("*") + .select("id", { count: "exact", head: true }) .eq("codev_id", codevId) .in("status", ["present", "late"]); @@ -19,12 +68,12 @@ export async function syncAttendancePoints(codevId: string) { } // Calculate total points (2 points per day) - const totalPoints = (attendanceRecords?.length || 0) * 2; + const totalPoints = (attendanceCount || 0) * 2; // Update or insert attendance points const { data: existing } = await supabase .from("attendance_points") - .select("*") + .select("id") .eq("codev_id", codevId) .single(); @@ -59,10 +108,12 @@ export async function syncAttendancePoints(codevId: string) { return { success: false, error: result.error.message }; } + revalidatePath("/home/my-team"); + return { success: true, data: result.data, - attendanceCount: attendanceRecords?.length || 0, + attendanceCount: attendanceCount || 0, totalPoints }; } catch (error) { @@ -72,9 +123,12 @@ export async function syncAttendancePoints(codevId: string) { } export async function syncAllTeamAttendancePoints(projectId: string) { + const { user, roleId } = await requireUser(); const supabase = await createClientServerComponent(); - + try { + await assertProjectMember(supabase, projectId, user.id, roleId); + // Get all team members for this project const { data: members, error: membersError } = await supabase .from("project_members") @@ -100,6 +154,7 @@ export async function syncAllTeamAttendancePoints(projectId: string) { }; } + revalidatePath("/home/my-team"); return { success: true, results }; } catch (error) { console.error("Error in syncAllTeamAttendancePoints:", error); diff --git a/apps/codebility/app/home/my-team/[projectId]/actions/attendance-warnings.ts b/apps/codebility/actions/my-team/attendance-warnings.ts similarity index 95% rename from apps/codebility/app/home/my-team/[projectId]/actions/attendance-warnings.ts rename to apps/codebility/actions/my-team/attendance-warnings.ts index c7b73831d..ac1186668 100644 --- a/apps/codebility/app/home/my-team/[projectId]/actions/attendance-warnings.ts +++ b/apps/codebility/actions/my-team/attendance-warnings.ts @@ -1,5 +1,6 @@ "use server"; +import { revalidatePath } from "next/cache"; import { createNotification } from "@/lib/server/notification.service"; import { createClientServerComponent } from "@/utils/supabase/server"; @@ -65,7 +66,7 @@ export async function checkAttendanceWarnings( // Verify user is team lead for this project const { data: teamLead } = await supabase .from("project_members") - .select("*") + .select("codev_id") .eq("project_id", projectId) .eq("role", "team_leader") .single(); @@ -102,7 +103,7 @@ export async function checkAttendanceWarnings( const { data: attendance } = await supabase .from("attendance") - .select("*") + .select("id, codev_id, date, status") .eq("project_id", projectId) .gte("date", startDate) .lte("date", endDate); @@ -151,10 +152,11 @@ export async function checkAttendanceWarnings( const absences = absenceCounts.get(member.codev_id) || 0; if (absences >= ABSENCE_WARNING_THRESHOLD) { - // Check if warning already sent this month - const { data: existingWarning } = await supabase + // Check if warning already sent this month. Only existence matters, so + // ask for the count and transfer no rows. + const { count: existingWarningCount } = await supabase .from("notifications") - .select("*") + .select("id", { count: "exact", head: true }) .eq("recipient_id", member.codev_id) .eq("project_id", projectId) .eq("type", "attendance") @@ -162,7 +164,7 @@ export async function checkAttendanceWarnings( .lte("created_at", endDate) .like("metadata->month", `${year}-${month + 1}`); - if (!existingWarning || existingWarning.length === 0) { + if (!existingWarningCount) { // Send warning notification const notification = { recipient_id: member.codev_id, @@ -220,6 +222,7 @@ export async function checkAttendanceWarnings( } } + revalidatePath("/home/my-team"); return { success: true, warnings }; } catch (error) { console.error("Error checking attendance warnings:", error); @@ -261,7 +264,7 @@ export async function getAttendanceWarningStatus( const { data: attendance } = await supabase .from("attendance") - .select("*") + .select("id, codev_id, date, status") .eq("project_id", projectId) .gte("date", startDate) .lte("date", endDate); diff --git a/apps/codebility/app/home/my-team/[projectId]/leaderboard/actions.ts b/apps/codebility/actions/my-team/leaderboard.ts similarity index 98% rename from apps/codebility/app/home/my-team/[projectId]/leaderboard/actions.ts rename to apps/codebility/actions/my-team/leaderboard.ts index de0ef9117..acbe88d8b 100644 --- a/apps/codebility/app/home/my-team/[projectId]/leaderboard/actions.ts +++ b/apps/codebility/actions/my-team/leaderboard.ts @@ -9,7 +9,7 @@ import { subWeeks, subMonths } from "date-fns"; -import { getWeekRange, getMonthRange } from "@/lib/leaderboard-utils"; +import { getWeekRange, getMonthRange } from "@/utils/leaderboard-utils"; export type LeaderboardTimeRange = "this-week" | "last-week" | "this-month" | "last-month"; @@ -128,7 +128,7 @@ export async function getLeaderboardData( // 2. Fetch Attendance for the period const { data: attendanceData, error: attendanceError } = await supabase .from("attendance") - .select("*") + .select("id, codev_id, date, status") .eq("project_id", projectId) .in("codev_id", codevIds) .gte("date", startDate.toISOString().split("T")[0]) diff --git a/apps/codebility/app/home/my-team/[projectId]/actions.ts b/apps/codebility/actions/my-team/project.ts similarity index 98% rename from apps/codebility/app/home/my-team/[projectId]/actions.ts rename to apps/codebility/actions/my-team/project.ts index 8abadbe7c..4e44b26a5 100644 --- a/apps/codebility/app/home/my-team/[projectId]/actions.ts +++ b/apps/codebility/actions/my-team/project.ts @@ -3,8 +3,7 @@ import { revalidatePath } from "next/cache"; import { createClientServerComponent } from "@/utils/supabase/server"; import { requireProjectMember, requireUser } from "@/lib/server/auth-guard"; - -const ATTENDANCE_POINTS_PER_DAY = 2; +import { ATTENDANCE_POINTS_PER_DAY } from "@/constants/my-team/attendance"; // ─── INTERNAL: Core save logic only — no point recalc (trigger handles it) ─── async function saveAttendanceRecord( @@ -259,6 +258,7 @@ export async function syncAllAttendancePoints(projectId?: string) { }) ); + revalidatePath("/home/my-team"); return { success: true, message: `Successfully synced ${uniqueCodevIds.length} members`, @@ -282,7 +282,7 @@ export async function getMonthlyAttendance( const { data, error } = await supabase .from("attendance") - .select("*") + .select("id, codev_id, project_id, date, status, check_in, check_out") .eq("project_id", projectId) .gte("date", startDate) .lte("date", endDate); @@ -299,7 +299,7 @@ export async function getCodevAttendancePoints(codevId: string) { try { const { data, error } = await supabase .from("attendance_points") - .select("*") + .select("points, last_updated") .eq("codev_id", codevId) .maybeSingle(); diff --git a/apps/codebility/lib/actions/notification.actions.ts b/apps/codebility/actions/notifications/notification.actions.ts similarity index 88% rename from apps/codebility/lib/actions/notification.actions.ts rename to apps/codebility/actions/notifications/notification.actions.ts index e6fc2e13c..2bf020fcb 100644 --- a/apps/codebility/lib/actions/notification.actions.ts +++ b/apps/codebility/actions/notifications/notification.actions.ts @@ -1,6 +1,7 @@ "use server"; import * as notificationService from "@/lib/server/notification.service"; +import type { Notification } from "@/types/notifications"; export async function fetchNotificationsAction(limit: number = 50) { return notificationService.getNotifications(limit); @@ -26,8 +27,8 @@ export async function createNotificationAction(data: { recipientId: string; title: string; message: string; - type: string; - priority?: string; + type: Notification["type"]; + priority?: Notification["priority"]; actionUrl?: string; metadata?: any; senderId?: string; diff --git a/apps/codebility/lib/actions/notification.ts b/apps/codebility/actions/notifications/notification.ts similarity index 85% rename from apps/codebility/lib/actions/notification.ts rename to apps/codebility/actions/notifications/notification.ts index 7c2eea9d4..c5eb27abd 100644 --- a/apps/codebility/lib/actions/notification.ts +++ b/apps/codebility/actions/notifications/notification.ts @@ -1,4 +1,5 @@ import { createClientServerComponent } from "@/utils/supabase/server"; +import { requireUser } from "@/lib/server/auth-guard"; import { createNotificationAction } from "./notification.actions"; import { revalidatePath } from "next/cache"; @@ -15,6 +16,11 @@ export async function assignTaskAction({ taskTitle, projectId }: AssignTaskParams) { + // Authorize before writing. Previously the task row was updated first and + // auth was only consulted to decide whether to send a notification, so any + // caller could reassign any task. + const { user } = await requireUser(); + const supabase = await createClientServerComponent(); // 1. Update the database @@ -29,7 +35,6 @@ export async function assignTaskAction({ // We don't "await" this if we want the UI to respond faster, // but awaiting is safer for error handling. - const { data: { user } } = await supabase.auth.getUser(); if (user && user.id !== assigneeId) { const { error: notifyError } = await createNotificationAction({ recipientId: assigneeId, diff --git a/apps/codebility/app/home/overflow/actions.ts b/apps/codebility/actions/overflow/actions.ts similarity index 98% rename from apps/codebility/app/home/overflow/actions.ts rename to apps/codebility/actions/overflow/actions.ts index 3034b148d..a072db8e5 100644 --- a/apps/codebility/app/home/overflow/actions.ts +++ b/apps/codebility/actions/overflow/actions.ts @@ -55,7 +55,11 @@ export interface Question { updated_at: string; } -export async function fetchQuestions(page: number = 1, pageSize: number = 5): Promise<{ +export async function fetchQuestions( + page: number = 1, + pageSize: number = 5, + search?: string, +): Promise<{ questions: Question[]; currentPage: number; totalPages: number; @@ -68,9 +72,16 @@ export async function fetchQuestions(page: number = 1, pageSize: number = 5): Pr const end = start + pageSize - 1 // Step 1: Fetch questions with pagination (no codev join to avoid RLS filtering) - const { data, error, count } = await supabase + let query = supabase .from('overflow_post') - .select('*', { count: 'exact' }) + .select('id, codev_id, title, question_details, tags, image_url, likes, comments, created_at, updated_at', { count: 'exact' }) + + if (search) { + const term = `%${search}%` + query = query.or(`title.ilike.${term},question_details.ilike.${term}`) + } + + const { data, error, count } = await query .order('created_at', { ascending: false }) .range(start, end) diff --git a/apps/codebility/app/home/projects/actions.ts b/apps/codebility/actions/projects/actions.ts similarity index 94% rename from apps/codebility/app/home/projects/actions.ts rename to apps/codebility/actions/projects/actions.ts index 75d81b3be..f3b9a6c52 100644 --- a/apps/codebility/app/home/projects/actions.ts +++ b/apps/codebility/actions/projects/actions.ts @@ -1,9 +1,13 @@ "use server"; -import { revalidatePath } from "next/cache"; +import { revalidatePath, revalidateTag } from "next/cache"; import { Client, Codev, Project } from "@/types/home/codev"; import { deleteImage, getImagePath } from "@/utils/uploadImage"; import { createClientServerComponent } from "@/utils/supabase/server"; +import { fetchProjectMembers } from "@/lib/server/project-members-query"; +import { getProjectsPage } from "@/lib/server/project.service"; +import { requireRole } from "@/lib/server/auth-guard"; +import type { Page, PageArgs } from "@/lib/server/paginate"; import { invalidateCache } from "@/lib/server/redis-cache"; import { cacheKeys } from "@/lib/server/redis-cache-keys"; @@ -301,6 +305,7 @@ export async function createProject( revalidatePath("/projects"); revalidatePath("/services"); + revalidateTag("services-projects"); return { success: true, data: project }; } catch (error) { console.error("Error creating project:", error); @@ -330,6 +335,7 @@ export async function updateStatus( revalidatePath("/home/projects"); revalidatePath("/services"); + revalidateTag("services-projects"); return { success: true, projectId, status }; } @@ -369,6 +375,7 @@ export async function updatePublicDisplaySwitch( revalidatePath("/home/projects"); revalidatePath("/services"); + revalidateTag("services-projects"); return { success: true, projectId, publicDisplay }; } @@ -516,6 +523,7 @@ export async function updateProject(projectId: string, formData: FormData) { revalidatePath("/projects"); revalidatePath("/services"); + revalidateTag("services-projects"); return { success: true, data: projectData }; } catch (error) { console.error("Error updating project:", error); @@ -557,6 +565,7 @@ export async function deleteProject(projectId: string) { revalidatePath("/projects"); revalidatePath("/services"); + revalidateTag("services-projects"); return { success: true }; } catch (error) { console.error("Error deleting project:", error); @@ -577,6 +586,8 @@ export const getTeamLead = async ( const supabase = await createClientServerComponent(); try { + // maybeSingle, not single: a project with no team_leader row is a normal + // state, and single() reports it as PGRST116 "0 rows" on every load. const { data, error } = (await supabase .from("project_members") .select( @@ -595,7 +606,7 @@ export const getTeamLead = async ( ) .eq("project_id", projectId) .eq("role", "team_leader") - .single()) as { data: DbProjectMemberResponse | null; error: any }; + .maybeSingle()) as { data: DbProjectMemberResponse | null; error: any }; if (error) { console.error("Error fetching team lead:", error); @@ -689,54 +700,8 @@ export const getMembers = async ( const supabase = await createClientServerComponent(); try { - const { data: projectMembers, error: pmError } = await supabase - .from("project_members") - .select("codev_id, role, joined_at") - .eq("project_id", projectId) - .eq("role", "member"); - - if (pmError) { - console.error("❌ Error fetching project members:", pmError); - return { error: pmError, data: null }; - } - - if (!projectMembers || projectMembers.length === 0) { - return { error: null, data: [] }; - } - - const codevIds = projectMembers.map(pm => pm.codev_id); - - const { data: codevs, error: codevError } = await supabase - .from("codev") - .select("id, first_name, last_name, email_address, display_position, image_url") - .in("id", codevIds); - - if (codevError) { - console.error("❌ Error fetching codev details:", codevError); - return { error: codevError, data: null }; - } - - const members = projectMembers.map(pm => { - const codev = codevs?.find(c => c.id === pm.codev_id); - - if (!codev) { - console.warn(`⚠️ Missing codev record for member: ${pm.codev_id}`); - return null; - } - - return { - id: codev.id, - first_name: codev.first_name, - last_name: codev.last_name, - email_address: codev.email_address, - display_position: codev.display_position, - image_url: codev.image_url, - role: pm.role, - joined_at: pm.joined_at, - }; - }).filter(Boolean) as SimpleMemberData[]; - - return { error: null, data: members }; + const data = await fetchProjectMembers(supabase, projectId); + return { error: null, data }; } catch (error) { console.error("❌ Unexpected error fetching members:", error); return { error, data: null }; @@ -745,7 +710,9 @@ export const getMembers = async ( export const updateProjectMembers = async ( projectId: string, - members: Codev[], + // Only the id is read (below), so callers that page a narrow column list can + // pass their rows without widening them back to a full Codev. + members: { id: string }[], teamLeaderId: string, ): Promise<{ success: boolean; error?: string }> => { console.log('🔧 [updateProjectMembers] Server-side update starting'); @@ -815,6 +782,8 @@ export const updateProjectMembers = async ( console.log('✅ [updateProjectMembers] Successfully inserted', memberInserts.length, 'members'); revalidatePath("/projects"); + revalidatePath("/services"); + revalidateTag("services-projects"); return { success: true }; } catch (error) { console.error("❌ [updateProjectMembers] Error updating project members:", error); @@ -1156,4 +1125,11 @@ export async function getProjectByID(id: string) { project_members: projectMembersWithCodev, categories: data.categories?.map((cat: any) => cat.projects_category).filter(Boolean) || [], }; -} \ No newline at end of file +} + +export const getProjectsPageAction = async ( + args: PageArgs & { categoryId?: number }, +): Promise> => { + await requireRole("inhouse"); + return getProjectsPage(args) as Promise>; +}; \ No newline at end of file diff --git a/apps/codebility/app/home/promote-modal/actions.ts b/apps/codebility/actions/promote-modal/actions.ts similarity index 87% rename from apps/codebility/app/home/promote-modal/actions.ts rename to apps/codebility/actions/promote-modal/actions.ts index 0bbe95eee..e1fd9e361 100644 --- a/apps/codebility/app/home/promote-modal/actions.ts +++ b/apps/codebility/actions/promote-modal/actions.ts @@ -1,8 +1,13 @@ "use server"; +import { revalidatePath } from "next/cache"; import { createClientServerComponent } from "@/utils/supabase/server"; import { requireRole } from "@/lib/server/auth-guard"; -import { FeatureModal } from "./type"; +import { FeatureModal } from "@/app/home/promote-modal/type"; + +// Every column FeatureModal declares, and no others. +const FEATURE_MODAL_COLUMNS = + "id, badge, headline, subheadline, cta_label, cta_href, dismiss_label, features, is_active, image_url, created_at, updated_at"; export async function fetchActiveModal(): Promise { try { @@ -10,7 +15,7 @@ export async function fetchActiveModal(): Promise { const { data, error } = await supabase .from("feature_modals") - .select("*") + .select(FEATURE_MODAL_COLUMNS) .eq("is_active", true); if (error) throw error; @@ -28,7 +33,7 @@ export async function fetchAllModals(): Promise { const supabase = await createClientServerComponent(); const { data, error } = await supabase .from("feature_modals") - .select("*") + .select(FEATURE_MODAL_COLUMNS) .order("created_at", { ascending: false }); if (error) throw error; return (data as FeatureModal[]) ?? []; @@ -42,7 +47,7 @@ export async function fetchModalById(id: string): Promise { const supabase = await createClientServerComponent(); const { data, error } = await supabase .from("feature_modals") - .select("*") + .select(FEATURE_MODAL_COLUMNS) .eq("id", id) .single(); if (error) throw error; @@ -62,6 +67,8 @@ export async function upsertActiveModal( updated_at: new Date().toISOString(), }); if (error) throw error; + revalidatePath("/home/promote-modal"); + return { error: null }; } catch { return { error: "Failed to save changes." }; @@ -92,6 +99,8 @@ export async function createModal(): Promise<{ .select("id") .single(); if (error) throw error; + revalidatePath("/home/promote-modal"); + return { id: data.id, error: null }; } catch { return { id: null, error: "Failed to create modal." }; @@ -136,6 +145,8 @@ export async function deleteModal( .delete() .eq("id", id); if (error) throw error; + revalidatePath("/home/promote-modal"); + return { error: null }; } catch { return { error: "Failed to delete modal." }; @@ -155,6 +166,8 @@ export async function toggleModalActive( .eq("id", id); if (error) throw error; + revalidatePath("/home/promote-modal"); + return { error: null }; } catch { return { error: "Failed to update modal." }; diff --git a/apps/codebility/app/home/settings/news-banners/actions.ts b/apps/codebility/actions/settings/news-banners.ts similarity index 92% rename from apps/codebility/app/home/settings/news-banners/actions.ts rename to apps/codebility/actions/settings/news-banners.ts index d3ecf441c..1049ffa56 100644 --- a/apps/codebility/app/home/settings/news-banners/actions.ts +++ b/apps/codebility/actions/settings/news-banners.ts @@ -1,5 +1,6 @@ "use server"; +import { revalidatePath } from "next/cache"; import { createClientServerComponent } from "@/utils/supabase/server"; import { deleteImage, getImagePath } from "@/utils/uploadImage"; import { z } from "zod"; @@ -59,10 +60,11 @@ export async function createNewsBanner(formData: z.infer) { return { error: error.message }; } + revalidatePath("/home/settings/news-banners"); return { data, success: true }; } catch (error) { if (error instanceof z.ZodError) { - return { error: error.errors[0].message }; + return { error: error.errors[0]?.message ?? "Validation failed" }; } return { error: "Failed to create banner" }; } @@ -116,10 +118,11 @@ export async function updateNewsBanner( return { error: error.message }; } + revalidatePath("/home/settings/news-banners"); return { data, success: true }; } catch (error) { if (error instanceof z.ZodError) { - return { error: error.errors[0].message }; + return { error: error.errors[0]?.message ?? "Validation failed" }; } return { error: "Failed to update banner" }; } @@ -170,7 +173,7 @@ export async function deleteNewsBanner(bannerId: string) { // Clean up image from storage if it exists if (banner.image_url) { try { - const imagePath = getImagePath(banner.image_url); + const imagePath = await getImagePath(banner.image_url); if (imagePath) { await deleteImage(imagePath, "codebility"); } @@ -180,6 +183,7 @@ export async function deleteNewsBanner(bannerId: string) { } } + revalidatePath("/home/settings/news-banners"); return { success: true }; } catch (error) { return { error: "Failed to delete banner" }; @@ -221,6 +225,7 @@ export async function toggleBannerStatus(bannerId: string, isActive: boolean) { return { error: error.message }; } + revalidatePath("/home/settings/news-banners"); return { data, success: true }; } catch (error) { return { error: "Failed to update banner status" }; @@ -234,7 +239,7 @@ export async function getActiveBanners() { const { data, error } = await supabase .from("news_banners") - .select("*") + .select("id, title, message, type, image_url, is_active, priority, start_date, end_date, created_at") .eq("is_active", true) .or(`end_date.is.null,end_date.gte.${now}`) .lte("start_date", now) @@ -274,7 +279,7 @@ export async function getAllBanners() { const { data, error } = await supabase .from("news_banners") - .select("*") + .select("id, title, message, type, image_url, is_active, priority, start_date, end_date, created_at") .order("priority", { ascending: false }) .order("created_at", { ascending: false }); diff --git a/apps/codebility/app/home/settings/profile/action.ts b/apps/codebility/actions/settings/profile.ts similarity index 88% rename from apps/codebility/app/home/settings/profile/action.ts rename to apps/codebility/actions/settings/profile.ts index 267256a8c..3602a03ff 100644 --- a/apps/codebility/app/home/settings/profile/action.ts +++ b/apps/codebility/actions/settings/profile.ts @@ -1,5 +1,6 @@ "use server"; +import { revalidatePath } from "next/cache"; import { cachedUser } from "@/lib/server/supabase-action"; import { Codev, @@ -27,9 +28,10 @@ export async function updateCodev(updatedData: Partial) { updated_at: new Date().toISOString(), }) .eq("id", user.id) - .select("*"); + .select("id"); if (error) throw error; + revalidatePath("/home/settings/profile"); return data; } catch (error) { console.error("Error updating codev:", error); @@ -56,9 +58,10 @@ export async function updateSocialLinks(socialData: { updated_at: new Date().toISOString(), }) .eq("id", user.id) - .select("*"); + .select("id"); if (error) throw error; + revalidatePath("/home/settings/profile"); return data; } catch (error) { console.error("Error updating social links:", error); @@ -84,6 +87,7 @@ export async function createWorkExperience( .select(); if (error) throw error; + revalidatePath("/home/settings/profile"); return data; } catch (error) { console.error("Error creating work experience:", error); @@ -108,6 +112,7 @@ export async function updateWorkExperience( .select(); if (error) throw error; + revalidatePath("/home/settings/profile"); return data; } catch (error) { console.error("Error updating work experience:", error); @@ -128,6 +133,7 @@ export async function deleteWorkExperience(id: string) { .eq("id", id); if (error) throw error; + revalidatePath("/home/settings/profile"); } catch (error) { console.error("Error deleting work experience:", error); throw new Error("Failed to delete work experience"); @@ -152,6 +158,7 @@ export async function createEducation( .select(); if (error) throw error; + revalidatePath("/home/settings/profile"); return data; } catch (error) { console.error("Error creating education:", error); @@ -176,6 +183,7 @@ export async function updateEducation( .select(); if (error) throw error; + revalidatePath("/home/settings/profile"); return data; } catch (error) { console.error("Error updating education:", error); @@ -196,6 +204,7 @@ export async function deleteEducation(id: string) { .eq("id", id); if (error) throw error; + revalidatePath("/home/settings/profile"); } catch (error) { console.error("Error deleting education:", error); throw new Error("Failed to delete education"); @@ -208,7 +217,7 @@ export async function getEducation(codevId: string) { const { data, error } = await supabase .from("education") - .select("*") + .select("id, codev_id, institution, degree, major_subject, category, current, achievements, description, start_date, end_date") .eq("codev_id", codevId) .order("start_date", { ascending: false }); @@ -280,6 +289,7 @@ export async function updateWorkSchedule( throw new Error("Failed to insert new schedule"); } + revalidatePath("/home/settings/profile"); return data[0]; } catch (error) { if (error instanceof Error) { @@ -297,7 +307,7 @@ export async function getWorkSchedule(codevId: string) { const { data, error } = await supabase .from("work_schedules") - .select("*") + .select("id, codev_id, days_of_week, start_time, end_time") .eq("codev_id", codevId) .single(); @@ -324,10 +334,13 @@ export async function createJobStatus(jobStatus: Omit) { created_at: new Date().toISOString(), updated_at: new Date().toISOString(), }) - .select("*") + .select( + "id, job_title, company_name, employment_type, description, status, salary_range, work_setup, shift, codev_id, hours_per_week", + ) .single(); if (error) throw error; + revalidatePath("/home/settings/profile"); return [data]; // Return as array to match expected format } catch (error) { console.error("Error creating job status:", error); @@ -351,10 +364,13 @@ export async function updateJobStatus( updated_at: new Date().toISOString(), }) .eq("id", id) - .select("*") + .select( + "id, job_title, company_name, employment_type, description, status, salary_range, work_setup, shift, codev_id, hours_per_week", + ) .single(); if (error) throw error; + revalidatePath("/home/settings/profile"); return data; } catch (error) { console.error("Error updating job status:", error); @@ -375,6 +391,7 @@ export async function deleteJobStatus(id: string) { .eq("id", id); if (error) throw error; + revalidatePath("/home/settings/profile"); } catch (error) { console.error("Error deleting job status:", error); throw new Error("Failed to delete job status"); @@ -386,7 +403,7 @@ export async function getJobStatuses(codevId: string) { const supabase = await createClientServerComponent(); const { data, error } = await supabase .from("job_status") - .select("*") + .select("id, job_title, company_name, employment_type, description, status, salary_range, work_setup, shift, codev_id, hours_per_week, created_at, updated_at") .eq("codev_id", codevId); if (error) throw error; @@ -395,19 +412,4 @@ export async function getJobStatuses(codevId: string) { console.error("Error fetching job statuses:", error); throw error; } -} - -export const getPositions = async (): Promise<{ - error: any; - data: Position[] | null; -}> => { - const supabase = await createClientServerComponent(); - - const { data, error } = await supabase.from("positions").select("*"); - if (error) { - console.error("Error fetching positions:", error); - return { error, data: null }; - } - - return { error: null, data: data || null }; -}; \ No newline at end of file +} \ No newline at end of file diff --git a/apps/codebility/app/home/settings/services/actions.ts b/apps/codebility/actions/settings/services.ts similarity index 51% rename from apps/codebility/app/home/settings/services/actions.ts rename to apps/codebility/actions/settings/services.ts index f4dc1cbf6..3f70eb653 100644 --- a/apps/codebility/app/home/settings/services/actions.ts +++ b/apps/codebility/actions/settings/services.ts @@ -1,7 +1,9 @@ "use server"; +import { revalidatePath } from "next/cache"; import { createClientServerComponent } from "@/utils/supabase/server"; import { Codev } from "@/types/home/codev"; +import { z } from "zod"; export interface RealProject { id: string; @@ -105,9 +107,99 @@ export async function getCodevProfiles() { return isValidStatus; }); - return { data: activeCodevs as Codev[], error: null }; + return { data: activeCodevs as unknown as Codev[], error: null }; } catch (error) { console.error('❌ Error fetching codev profiles:', error); return { data: [], error: 'Failed to fetch codev profiles' }; } +} + +const serviceWriteSchema = z.object({ + name: z.string().min(1, "Service name is required"), + description: z.string().min(1, "Description is required"), + price: z.number().min(0, "Price must be positive"), + duration: z.string().min(1, "Duration is required"), + features: z.array(z.string().min(1)).min(1, "At least one feature is required"), + category: z.string().min(1, "Category is required"), + is_active: z.boolean(), +}); + +type ServiceWriteInput = z.infer; + +async function requireAdminUser(): + Promise< + | { ok: true; supabase: Awaited>; userId: string } + | { ok: false; error: string } + > { + const supabase = await createClientServerComponent(); + const { data: user, error: userError } = await supabase.auth.getUser(); + if (userError || !user.user) { + return { ok: false, error: "Authentication required" }; + } + + const { data: userRole, error: roleError } = await supabase + .from("codev") + .select("role_id") + .eq("id", user.user.id) + .single(); + + if (roleError || userRole?.role_id !== 1) { + return { ok: false, error: "Admin access required" }; + } + + return { ok: true, supabase, userId: user.user.id }; +} + +export async function createService(formData: ServiceWriteInput) { + try { + const validated = serviceWriteSchema.parse(formData); + const auth = await requireAdminUser(); + if (!auth.ok) { + return { error: auth.error }; + } + + const { error } = await auth.supabase.from("services").insert({ + ...validated, + created_by: auth.userId, + }); + + if (error) { + console.error("Error creating service:", error); + return { error: "Failed to create service" }; + } + + revalidatePath("/home/settings/services"); + + return { error: null }; + } catch (error) { + console.error("Error creating service:", error); + return { error: "Failed to create service" }; + } +} + +export async function updateService(id: string, formData: ServiceWriteInput) { + try { + const validated = serviceWriteSchema.parse(formData); + const auth = await requireAdminUser(); + if (!auth.ok) { + return { error: auth.error }; + } + + const { error } = await auth.supabase + .from("services") + .update(validated) + .eq("id", id); + + if (error) { + console.error("Error updating service:", error); + return { error: "Failed to update service" }; + } + + revalidatePath("/home/settings/services"); + + return { error: null }; + } catch (error) { + console.error("Error updating service:", error); + return { error: "Failed to update service" }; + } } \ No newline at end of file diff --git a/apps/codebility/app/home/settings/surveys/questions/actions.ts b/apps/codebility/actions/settings/survey-questions.ts similarity index 92% rename from apps/codebility/app/home/settings/surveys/questions/actions.ts rename to apps/codebility/actions/settings/survey-questions.ts index a31826efb..5622a730c 100644 --- a/apps/codebility/app/home/settings/surveys/questions/actions.ts +++ b/apps/codebility/actions/settings/survey-questions.ts @@ -1,5 +1,6 @@ "use server"; +import { revalidatePath } from "next/cache"; import { createClientServerComponent } from "@/utils/supabase/server"; import { z } from "zod"; @@ -70,10 +71,12 @@ export async function createQuestion(surveyId: string, formData: z.infer) { return { data, success: true }; } catch (error) { if (error instanceof z.ZodError) { - return { error: error.errors[0].message }; + return { error: error.errors[0]?.message ?? "Validation failed" }; } return { error: "Failed to create survey" }; } @@ -125,7 +126,7 @@ export async function updateSurvey( return { data, success: true }; } catch (error) { if (error instanceof z.ZodError) { - return { error: error.errors[0].message }; + return { error: error.errors[0]?.message ?? "Validation failed" }; } return { error: "Failed to update survey" }; } @@ -176,7 +177,7 @@ export async function deleteSurvey(surveyId: string) { // Clean up image from storage if it exists if (survey.image_url) { try { - const imagePath = getImagePath(survey.image_url); + const imagePath = await getImagePath(survey.image_url); if (imagePath) { await deleteImage(imagePath, "codebility"); } @@ -240,7 +241,7 @@ export async function getActiveSurveys() { const { data, error } = await supabase .from("surveys") - .select("*") + .select("id, title, description, type, image_url, target_audience, is_active, priority, start_date, end_date, created_at") .eq("is_active", true) .or(`end_date.is.null,end_date.gte.${now}`) .lte("start_date", now) @@ -280,7 +281,7 @@ export async function getAllSurveys() { const { data, error } = await supabase .from("surveys") - .select("*") + .select("id, title, description, type, image_url, target_audience, is_active, priority, start_date, end_date, created_at") .order("priority", { ascending: false }) .order("created_at", { ascending: false }); @@ -294,6 +295,26 @@ export async function getAllSurveys() { } } +export async function getSurveyById(surveyId: string) { + try { + const supabase = await createClientServerComponent(); + + const { data, error } = await supabase + .from("surveys") + .select("id, title, description, type, image_url, target_audience, is_active, priority, start_date, end_date, created_at") + .eq("id", surveyId) + .single(); + + if (error) { + return { error: error.message }; + } + + return { data, success: true }; + } catch (error) { + return { error: "Failed to fetch survey" }; + } +} + export async function dismissSurvey(surveyId: string) { try { const supabase = await createClientServerComponent(); @@ -314,6 +335,7 @@ export async function dismissSurvey(surveyId: string) { return { error: error.message }; } + revalidatePath("/home", "layout"); return { success: true }; } catch (error) { return { error: "Failed to dismiss survey" }; @@ -339,6 +361,7 @@ export async function undismissSurvey(surveyId: string) { return { error: error.message }; } + revalidatePath("/home", "layout"); return { success: true }; } catch (error) { return { error: "Failed to reopen survey" }; @@ -432,7 +455,7 @@ export async function getPendingSurveyForUser() { // Get questions for this survey const { data: questions, error: questionsError } = await supabase .from("survey_questions") - .select("*") + .select("id, survey_id, question_text, description, question_type, options, settings, order_index") .eq("survey_id", pendingSurvey.id) .order("order_index", { ascending: true }); diff --git a/apps/codebility/actions/tasks/actions.ts b/apps/codebility/actions/tasks/actions.ts new file mode 100644 index 000000000..d4aa19533 --- /dev/null +++ b/apps/codebility/actions/tasks/actions.ts @@ -0,0 +1,8 @@ +"use server"; + +import { getTasksPage, type TaskPageRow } from "@/lib/server/task.service"; +import type { Page, PageArgs } from "@/lib/server/paginate"; + +export const fetchTasksPageAction = async ( + args: PageArgs & { codevId: string }, +): Promise> => getTasksPage(args); diff --git a/apps/codebility/app/home/test-notifications/actions.ts b/apps/codebility/actions/test-notifications/actions.ts similarity index 100% rename from apps/codebility/app/home/test-notifications/actions.ts rename to apps/codebility/actions/test-notifications/actions.ts diff --git a/apps/codebility/app/home/ticket-support/actions.ts b/apps/codebility/actions/ticket-support/actions.ts similarity index 94% rename from apps/codebility/app/home/ticket-support/actions.ts rename to apps/codebility/actions/ticket-support/actions.ts index b9355c21d..e59d14809 100644 --- a/apps/codebility/app/home/ticket-support/actions.ts +++ b/apps/codebility/actions/ticket-support/actions.ts @@ -1,5 +1,6 @@ "use server"; +import { revalidatePath } from "next/cache"; import { createClientServerComponent } from "@/utils/supabase/server"; import { requireUser } from "@/lib/server/auth-guard"; @@ -44,5 +45,7 @@ export async function submitTicket(data: SubmitTicketData) { return { success: false, error: error.message }; } + revalidatePath("/home/ticket-support"); + return { success: true }; } diff --git a/apps/codebility/app/(marketing)/_components/MarketingCalendly.tsx b/apps/codebility/app/(marketing)/_components/MarketingCalendly.tsx index 769499de0..885815b33 100644 --- a/apps/codebility/app/(marketing)/_components/MarketingCalendly.tsx +++ b/apps/codebility/app/(marketing)/_components/MarketingCalendly.tsx @@ -1,58 +1,112 @@ "use client"; -import React from "react"; import { Clock, Lightbulb, Rocket } from "lucide-react"; import { InlineWidget } from "react-calendly"; +import MarketingProgressiveSection from "../_shared/MarketingProgressiveSection"; +import ProgressiveMotion from "../_shared/ProgressiveMotion"; import Container from "./MarketingContainer"; import Section from "./MarketingSection"; const Calendly = () => { + const skeleton = ( +
+
+

+ Let's Connect +

+

+ Schedule a meeting with us to discuss your needs and have solutions. +

+
+
+ {[ + { icon: Clock, text: "Free 30-minute consultation" }, + { icon: Rocket, text: "Tailored solutions for your business" }, + { icon: Lightbulb, text: "Expert advice from our team" }, + ].map(({ icon: Icon, text }, index) => ( +
+ + {text} +
+ ))} +
+
+
+
+
+
+
+
+
+ ); + return ( -
- -
-

- Let's Connect -

-

- Schedule a meeting with us to discuss your needs and have solutions. -

+
+ + +
+ +

+ Let's Connect +

+

+ Schedule a meeting with us to discuss your needs and have solutions. +

- {/* Enhanced CTA elements */} -
- {/* Feature highlights */} -
- {[ - { icon: Clock, text: "Free 30-minute consultation" }, - { icon: Rocket, text: "Tailored solutions for your business" }, - { icon: Lightbulb, text: "Expert advice from our team" }, - ].map(({ icon: Icon, text }, index) => ( -
- - {text} +
+
+ {[ + { icon: Clock, text: "Free 30-minute consultation" }, + { icon: Rocket, text: "Tailored solutions for your business" }, + { icon: Lightbulb, text: "Expert advice from our team" }, + ].map(({ icon: Icon, text }, index) => ( +
+ + {text} +
+ ))}
- ))} -
-
-
+
+
-
- {/* Widget wrapper with enhanced styling */} -
- {/* Calendar widget */} -
- -
+ +
+
+ +
+
+
-
+
); diff --git a/apps/codebility/app/(marketing)/_components/MarketingContainer.tsx b/apps/codebility/app/(marketing)/_components/MarketingContainer.tsx index 7a63482d8..d0c2dca67 100644 --- a/apps/codebility/app/(marketing)/_components/MarketingContainer.tsx +++ b/apps/codebility/app/(marketing)/_components/MarketingContainer.tsx @@ -7,7 +7,7 @@ const Container = ({ children: ReactNode; className?: string; }) => { - return
{children}
; + return
{children}
; }; export default Container; diff --git a/apps/codebility/app/(marketing)/_components/MarketingFooter.tsx b/apps/codebility/app/(marketing)/_components/MarketingFooter.tsx index 5b111df80..05f8e1881 100644 --- a/apps/codebility/app/(marketing)/_components/MarketingFooter.tsx +++ b/apps/codebility/app/(marketing)/_components/MarketingFooter.tsx @@ -3,7 +3,7 @@ import Link from "next/link"; import Logo from "@/components/shared/Logo"; import { footerLinks } from "@/constants/links"; -import { ModalType, useModal } from "@/hooks/use-modal"; +import { ModalType, useModal } from "@/hooks/modals/use-modal"; import { IconFacebookWhite } from "@/public/assets/svgs"; import { aboutUsData, connectUsData } from "@/constants/landing_data"; diff --git a/apps/codebility/app/(marketing)/_components/MarketingNavigation.tsx b/apps/codebility/app/(marketing)/_components/MarketingNavigation.tsx index 7b6a573e6..71a6a0f94 100644 --- a/apps/codebility/app/(marketing)/_components/MarketingNavigation.tsx +++ b/apps/codebility/app/(marketing)/_components/MarketingNavigation.tsx @@ -1,326 +1,46 @@ "use client"; -import { useEffect, useState } from "react"; -import Image from "next/image"; -import Link from "next/link"; -import { usePathname } from "next/navigation"; -import { signOut } from "@/app/auth/actions"; -import Logo from "@/components/shared/home/Logo"; -import { Button } from "@/components/ui/button"; -import useChangeBgNavigation from "@/hooks/useChangeBgNavigation"; -import { defaultAvatar } from "@/public/assets/images"; -import { - IconCog, - IconDashboard, - IconFourDotsMenu, - IconLogout, - IconProfile, -} from "@/public/assets/svgs"; -import applicationStatusIcon from "@/public/assets/svgs/icon-applicant.svg"; -import { createClientClientComponent } from "@/utils/supabase/client"; -import { ChevronDown, ChevronUp, SettingsIcon } from "lucide-react"; - -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuSeparator, - DropdownMenuTrigger, -} from "@codevs/ui"; - -import { - Sheet, - SheetContent, - SheetDescription, - SheetTitle, - SheetTrigger, -} from "@codevs/ui"; +import { Suspense, useState } from "react"; -// Navigation items for top navbar and mobile drawer -const NAV_ITEMS = [ - { id: "1", title: "Our Services", path: "/services" }, - { id: "2", title: "About Us", path: "/#whychooseus" }, - { id: "3", title: "Book a Call", path: "/bookacall" }, - { id: "4", title: "Be a Codev", path: "/codevs" }, // ✅ ADDED: New link after Book a Call - { id: "5", title: "Hire a CoDevs", path: "/hire-a-codev" }, -] as const; - -// Get menu items based on user application status -const getMenuItems = ( - status: string, - role_id: number, - applicant: { - id: string; - codev_id: string; - } | null, -) => { - if ( - status === "rejected" || - status === "applying" || - status === "testing" || - status === "onboarding" || - status === "denied" - ) { - return [ - { - href: - status === "rejected" || status === "denied" - ? "/auth/declined" - : applicant?.id - ? "/applicant/waiting" - : "/auth/waiting", - icon: applicationStatusIcon, - label: "Status", - }, - { - href: "/applicant/account-settings", - icon: IconCog, - label: "Settings", - }, - { href: "/applicant/profile", icon: IconProfile, label: "Profile" }, - ]; - } - return [ - { href: "/home", icon: IconDashboard, label: "Dashboard" }, - { href: "/home/settings/profile", icon: IconProfile, label: "Profile" }, - ]; -}; - -const MobileDrawer = ({ - isLoggedIn, - openSheet, - setOpenSheet, - handleLogout, - userData, -}: { - isLoggedIn: boolean; - openSheet: boolean; - setOpenSheet: (open: boolean) => void; - handleLogout: () => void; - userData?: { - application_status: string; - role_id: number; - applicant: { - id: string; - codev_id: string; - } | null; - } | null; -}) => ( - - - - - - Mobile Navbar - - Navbar that contains links - - {NAV_ITEMS.map((item) => { - if (isLoggedIn && ["Sign In", "Sign Up"].includes(item.title)) { - return null; - } - return ( - setOpenSheet(false)} - href={item.path} - key={item.id} - > -

- {item.title} -

- - ); - })} - - {/* User menu items */} - {isLoggedIn && userData && ( - <> -
- {getMenuItems(userData.application_status, userData.role_id, userData.applicant).map((item) => ( - setOpenSheet(false)} - href={item.href} - key={item.label} - > -
- - {item.label} -
- - ))} -
- - )} - - {isLoggedIn && ( - - )} - - +import useChangeBgNavigation from "@/hooks/navigation/useChangeBgNavigation"; +import Logo from "@/components/shared/home/Logo"; +import { CareersSignIn, MobileDrawer } from "./MarkitingNavigationSubComponents"; +import dynamic from "next/dynamic"; +import { signOut } from "@/actions/auth"; +import { removeLocalStorageValue } from "@/hooks/data/useLocalStorageValue"; + + +const UserMenu = dynamic( + () => + import("./MarkitingNavigationSubComponents").then((m) => m.UserMenu), + { + ssr: false, + loading: () => , + }, +); +const DrawerAuthSection = dynamic( + () => + import("./MarkitingNavigationSubComponents").then( + (m) => m.DrawerAuthSection, + ), + { + ssr: false, + loading: () => null, + }, ); -const UserMenu = ({ - first_name, - last_name, - email, - image_url, - application_status, - role_id, - applicant, - handleLogout, -}: { - first_name: string; - last_name: string; - email: string; - image_url: string; - application_status: string; - role_id: number; - applicant: { - id: string; - codev_id: string; - } | null; - handleLogout: () => void; -}) => { - const [isOpen, setIsOpen] = useState(false); - const [isHovered, setIsHovered] = useState(null); - - return ( - - -
-

- {first_name} {last_name} -

-

{email}

-
-
- Avatar -
- - {isOpen ? ( - - ) : ( - - )} -
- - - {getMenuItems(application_status, role_id, applicant).map((item) => ( - - setIsHovered(item.label)} - onMouseLeave={() => setIsHovered(null)} - > - - {item.label} - - - ))} - - setIsHovered("logout")} - onMouseLeave={() => setIsHovered(null)} - > - - Logout - - -
- ); -}; const Navigation = () => { const { color } = useChangeBgNavigation(); - const pathname = usePathname(); const [openSheet, setOpenSheet] = useState(false); - const [userData, setUserData] = useState(null); - const [isLoading, setIsLoading] = useState(true); - const [supabase, setSupabase] = useState(null); - - useEffect(() => { - const supabaseClient = createClientClientComponent(); - setSupabase(supabaseClient); - }, []); - - useEffect(() => { - if (!supabase) return; - - async function fetchUser() { - try { - // Use getUser() for proper auth check - const { data: { user }, error: authError } = await supabase.auth.getUser(); - - if (authError) { - setUserData(null); - return; - } - - if (!user) { - setUserData(null); - return; - } - - const { data: userRow, error: fetchError } = await supabase - .from("codev") - .select(`*, applicant (id, codev_id)`) - .eq("id", user.id) - .single(); - if (fetchError) { - console.error("Error fetching codev:", fetchError.message); - setUserData(null); - } else { - setUserData(userRow); - } - } catch (err) { - console.error("Unexpected error fetching user data:", err); - setUserData(null); - } finally { - setIsLoading(false); - } - } - - fetchUser(); - }, [supabase]); - - const handleLogout = async () => { + const handleLogout = async () => { try { await signOut(); - setUserData(null); - setOpenSheet(false); - } catch (error) { - console.error("Failed to log out:", error); + } finally { + removeLocalStorageValue("user_profile") } - }; - - if (isLoading) return null; + } return (
{
- {!userData ? ( - <> - {pathname === "/careers" && ( - - - - )} - - ) : ( - - )} -
+ + + +
+ + + } />
diff --git a/apps/codebility/app/(marketing)/_components/MarketingSidenavMenu.tsx b/apps/codebility/app/(marketing)/_components/MarketingSidenavMenu.tsx index b0853004c..99a4c4449 100644 --- a/apps/codebility/app/(marketing)/_components/MarketingSidenavMenu.tsx +++ b/apps/codebility/app/(marketing)/_components/MarketingSidenavMenu.tsx @@ -1,9 +1,7 @@ "use client"; -import { useEffect, useState } from "react"; import Link from "next/link"; -import ScrollToHash from "@/app/(marketing)/_components/landing/LandingScrollToHash"; - +import { useSyncExternalStore } from "react"; import { Briefcase, Info, Phone, CodeXml, Search, Rocket } from "lucide-react"; const links = [ @@ -13,27 +11,34 @@ const links = [ { href: "/codevs", label: "Be a Codev", icon: CodeXml }, { href: "/hire-a-codev", label: "Hire Codevs", icon: Search }, { href: "/careers", label: "Careers", icon: Rocket }, -] +]; -const SideNavMenu = () => { - const [isScrolling, setIsScrolling] = useState(false); +const SCROLL_SHRINK_Y = 200; - // Handle scroll animation - shrink links when scrolling - const handleNav = () => { - if (window.scrollY >= 200) { - setIsScrolling(true); - } else { - setIsScrolling(false); - } +function subscribeScrollShrink(onStoreChange: () => void) { + window.addEventListener("scroll", onStoreChange, { passive: true }); + return () => { + window.removeEventListener("scroll", onStoreChange); }; +} + +function getScrollShrinkSnapshot() { + return window.scrollY >= SCROLL_SHRINK_Y; +} - useEffect(() => { - window.addEventListener("scroll", handleNav); - }, []); +function getServerScrollShrinkSnapshot() { + return false; +} + +const SideNavMenu = () => { + const isScrolling = useSyncExternalStore( + subscribeScrollShrink, + getScrollShrinkSnapshot, + getServerScrollShrinkSnapshot, + ); return (
- {/* */} {links.map((link, index) => ( {
- +

{ ); }; -export default SideNavMenu; \ No newline at end of file +export default SideNavMenu; diff --git a/apps/codebility/app/(marketing)/_components/MarkitingNavigationSubComponents.tsx b/apps/codebility/app/(marketing)/_components/MarkitingNavigationSubComponents.tsx new file mode 100644 index 000000000..3bb08108d --- /dev/null +++ b/apps/codebility/app/(marketing)/_components/MarkitingNavigationSubComponents.tsx @@ -0,0 +1,314 @@ +"use client"; + +import React, { useState, use, useMemo } from "react"; +import Image from "next/image"; +import Link from "next/link"; +import { usePathname } from "next/navigation"; +import { Button } from "@/components/ui/button"; + +import { defaultAvatar } from "@/public/assets/images"; +import { + IconCog, + IconDashboard, + IconFourDotsMenu, + IconLogout, + IconProfile, +} from "@/public/assets/svgs"; +import applicationStatusIcon from "@/public/assets/svgs/icon-applicant.svg"; +import { ChevronDown, ChevronUp } from "lucide-react"; + +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@codevs/ui"; + +import { + Sheet, + SheetContent, + SheetDescription, + SheetTitle, + SheetTrigger, +} from "@codevs/ui"; +import { createClientClientComponent } from "@/utils/supabase/client"; +import { setLocalStorageValue, useLocalStorageValue } from "@/hooks/data/useLocalStorageValue"; +import { NavUserProfile } from "@/types/database"; + +// Navigation items for top navbar and mobile drawer +const NAV_ITEMS = [ + { id: "1", title: "Our Services", path: "/services" }, + { id: "2", title: "About Us", path: "/#whychooseus" }, + { id: "3", title: "Book a Call", path: "/bookacall" }, + { id: "4", title: "Be a Codev", path: "/codevs" }, // ✅ ADDED: New link after Book a Call + { id: "5", title: "Hire a CoDevs", path: "/hire-a-codev" }, +] as const; + +const NAV_USER_PROFILE_KEY = "user_profile"; + +async function getNavUser() { + const supabase = createClientClientComponent(); + if (!supabase) return null; + + const { data: { user } } = await supabase.auth.getUser(); + if (!user) return null; + + const { data: profile } = await supabase + .from("codev") + .select(`*, applicant (id, codev_id)`) + .eq("id", user.id) + .single(); + + if (!profile) return null; + + const cleanUserData = { + first_name: profile.first_name, + last_name: profile.last_name, + email: profile.email_address, + image_url: profile.image_url, + application_status: profile.application_status, + role_id: profile.role_id, + applicant: profile.applicant ?? null, + }; + + if (typeof window !== "undefined") { + setLocalStorageValue(NAV_USER_PROFILE_KEY, cleanUserData); + } + + return cleanUserData; +} + + +let navUserPromise: ReturnType | null = null; + +function getNavUserPromise() { + if (!navUserPromise) navUserPromise = getNavUser() + return navUserPromise; +} + +// Get menu items based on user application status +const getMenuItems = ( + status: string, + role_id: number, + applicant: { + id: string; + codev_id: string; + } | null, +) => { + if ( + status === "rejected" || + status === "applying" || + status === "testing" || + status === "onboarding" || + status === "denied" + ) { + return [ + { + href: + status === "rejected" || status === "denied" + ? "/auth/declined" + : applicant?.id + ? "/applicant/waiting" + : "/auth/waiting", + icon: applicationStatusIcon, + label: "Status", + }, + { + href: "/applicant/account-settings", + icon: IconCog, + label: "Settings", + }, + { href: "/applicant/profile", icon: IconProfile, label: "Profile" }, + ]; + } + return [ + { href: "/home", icon: IconDashboard, label: "Dashboard" }, + { href: "/home/settings/profile", icon: IconProfile, label: "Profile" }, + ]; +}; + +export const DrawerAuthSection = ({handleLogout}: {handleLogout: () => void}) => + { + const cachedUserData = useLocalStorageValue(NAV_USER_PROFILE_KEY); + + const userPromise = useMemo(() => { + if (cachedUserData) { + return Promise.resolve(cachedUserData); + } + return getNavUserPromise(); + }, [cachedUserData]); + + const userData = use(userPromise) + + if (!userData) return null; + + return ( + <> +

+ {getMenuItems( + userData.application_status, + userData.role_id, + userData.applicant, + ).map((item) => ( + +
+ + {item.label} +
+ + ))} +
+ + +)}; + +export const MobileDrawer = ({ + openSheet, + setOpenSheet, + drawerAuth, +}: { + openSheet: boolean; + setOpenSheet: (open: boolean) => void; + drawerAuth?: React.ReactNode; +}) => ( + + + + + + Mobile Navbar + + Navbar that contains links + + {NAV_ITEMS.map((item) => ( + setOpenSheet(false)} + href={item.path} + key={item.id} + > +

+ {item.title} +

+ + ))} +
{ + if ((e.target as HTMLElement).closest("a, button")) { + setOpenSheet(false); + } + }} + > + {drawerAuth} +
+
+
+); + +export const CareersSignIn = () => { + const pathname = usePathname(); + if (pathname !== "/careers") return null; + return ( + + + + ); +}; + +export const UserMenu = ({handleLogout}: {handleLogout: () => void}) => { + const [isOpen, setIsOpen] = useState(false); + const [isHovered, setIsHovered] = useState(null); + + const cachedUserData = useLocalStorageValue(NAV_USER_PROFILE_KEY); + + const userPromise = useMemo(() => { + if (cachedUserData) { + return Promise.resolve(cachedUserData); + } + return getNavUserPromise(); + }, [cachedUserData]); + + const userData = use(userPromise) + + if (!userData) return ; + + const {first_name, last_name, email, image_url, application_status, role_id, applicant } = userData + + return ( + + +
+

+ {first_name} {last_name} +

+

{email}

+
+
+ Avatar +
+ + {isOpen ? ( + + ) : ( + + )} +
+ + + {getMenuItems(application_status, role_id, applicant).map((item) => ( + + setIsHovered(item.label)} + onMouseLeave={() => setIsHovered(null)} + > + + {item.label} + + + ))} + + setIsHovered("logout")} + onMouseLeave={() => setIsHovered(null)} + > + + Logout + + +
+ ); +}; \ No newline at end of file diff --git a/apps/codebility/app/(marketing)/_components/landing/AnimatedAdminsSection.tsx b/apps/codebility/app/(marketing)/_components/landing/AnimatedAdminsSection.tsx index ace31f9e6..19b89f7a1 100644 --- a/apps/codebility/app/(marketing)/_components/landing/AnimatedAdminsSection.tsx +++ b/apps/codebility/app/(marketing)/_components/landing/AnimatedAdminsSection.tsx @@ -1,6 +1,10 @@ +"use client"; + import { Codev } from "@/types/home/codev"; + import AdminCard from "./LandingAdminCard"; import BlueBg from "./LandingBlueBg"; +import ProgressiveMotion from "./ProgressiveMotion"; interface AnimatedAdminsSectionProps { title: string; @@ -9,41 +13,56 @@ interface AnimatedAdminsSectionProps { sectionId: string; } -const AnimatedAdminsSection = ({ - title, - description, - members, - sectionId +const AnimatedAdminsSection = ({ + title, + description, + members, + sectionId, }: AnimatedAdminsSectionProps) => { return ( -
-

+
+ +

{title}

- +
-

+

{description}

- +
- -
- {members.map((member: Codev, index: number) => ( -
- + +
+ {members.map((member: Codev) => ( +
+
))}
+
); }; -export default AnimatedAdminsSection; \ No newline at end of file +export default AnimatedAdminsSection; diff --git a/apps/codebility/app/(marketing)/_components/landing/AnimatedMetrics.tsx b/apps/codebility/app/(marketing)/_components/landing/AnimatedMetrics.tsx new file mode 100644 index 000000000..7fac05745 --- /dev/null +++ b/apps/codebility/app/(marketing)/_components/landing/AnimatedMetrics.tsx @@ -0,0 +1,107 @@ +"use client"; + +import { useCallback, useRef } from "react"; +import { animate, useMotionValue, useTransform, motion } from "framer-motion"; + +import { + attachProgressiveInView, + isElementIntersecting, +} from "./progressive-in-view"; + +type AnimatedMetricsProps = { + value: number; + suffix?: string; + prefix?: string; + label?: string; + format?: "number" | "decimal"; + delay?: number; + variant?: "hero" | "stat"; +}; + +const VARIANT_CONFIG = { + hero: { + countDuration: 2, + }, + stat: { + countDuration: 2.5, + }, +} as const; + +const AnimatedMetrics = ({ + value, + suffix = "", + prefix = "", + label, + format = "number", + delay = 0, + variant = "hero", +}: AnimatedMetricsProps) => { + const count = useMotionValue(value); + const hasStarted = useRef(false); + const stopRef = useRef<(() => void) | undefined>(undefined); + const display = useTransform(count, (v) => { + const formatted = + format === "decimal" ? v.toFixed(1) : Math.floor(v).toString(); + return `${prefix}${formatted}${suffix}`; + }); + + const config = VARIANT_CONFIG[variant]; + + const startCount = useCallback( + (alreadyVisible: boolean) => { + if (hasStarted.current) return; + hasStarted.current = true; + + if (alreadyVisible) { + count.set(value); + return; + } + + count.set(0); + animate(count, value, { + duration: config.countDuration, + delay: delay / 1000, + ease: "easeOut", + }); + }, + [config.countDuration, count, delay, value], + ); + + const metricRef = useCallback( + (element: HTMLElement | null) => { + stopRef.current?.(); + stopRef.current = undefined; + if (!element) return; + + if (isElementIntersecting(element)) { + startCount(true); + return; + } + + count.set(0); + stopRef.current = attachProgressiveInView(element, startCount); + }, + [count, startCount], + ); + + if (variant === "stat") { + return ( +
+
+ {display} +
+ {label ? ( +

{label}

+ ) : null} +
+ ); + } + + return ( +

+ {display} +

+ ); +}; + +export default AnimatedMetrics; diff --git a/apps/codebility/app/(marketing)/_components/landing/FloatingParticles.tsx b/apps/codebility/app/(marketing)/_components/landing/FloatingParticles.tsx index b62ea967a..6b369690a 100644 --- a/apps/codebility/app/(marketing)/_components/landing/FloatingParticles.tsx +++ b/apps/codebility/app/(marketing)/_components/landing/FloatingParticles.tsx @@ -1,7 +1,7 @@ "use client"; -import { useEffect, useRef, useState } from "react"; -import { motion, useMotionValue, useSpring } from "framer-motion"; +import { useRef, useSyncExternalStore } from "react"; +import { useAnimationFrame } from "framer-motion"; interface Particle { id: number; @@ -14,91 +14,152 @@ interface Particle { direction: number; } -const FloatingParticles = () => { - const containerRef = useRef(null); - const mouseX = useMotionValue(0); - const mouseY = useMotionValue(0); - const scrollY = useMotionValue(0); - - const mouseXSpring = useSpring(mouseX, { damping: 25, stiffness: 150 }); - const mouseYSpring = useSpring(mouseY, { damping: 25, stiffness: 150 }); - const scrollYSpring = useSpring(scrollY, { damping: 30, stiffness: 100 }); - - // State for particles - generated only on client - const [particles, setParticles] = useState([]); - const [isMounted, setIsMounted] = useState(false); - - // Generate particles on client side only - useEffect(() => { - const colors = [ - "rgba(147, 71, 255, 0.4)", // Purple - "rgba(2, 255, 226, 0.3)", // Cyan - "rgba(106, 120, 242, 0.3)", // Blue - "rgba(255, 255, 255, 0.2)", // White - ]; - - const generatedParticles: Particle[] = Array.from({ length: 15 }, (_, i) => { - const randomColorIndex = Math.floor(Math.random() * colors.length); - return { - id: i, - x: Math.random() * 100, - y: Math.random() * 100, - size: Math.random() * 8 + 4, - color: colors[randomColorIndex]!, - opacity: Math.random() * 0.5 + 0.2, - speed: Math.random() * 0.5 + 0.2, - direction: Math.random() * Math.PI * 2, - }; - }); - - setParticles(generatedParticles); - setIsMounted(true); - }, []); - - useEffect(() => { - const handleMouseMove = (e: MouseEvent) => { - if (containerRef.current) { - const rect = containerRef.current.getBoundingClientRect(); - const x = (e.clientX - rect.left) / rect.width; - const y = (e.clientY - rect.top) / rect.height; - mouseX.set(x); - mouseY.set(y); - } +const EMPTY_PARTICLES: Particle[] = []; + +const PARTICLE_COLORS = [ + "rgba(147, 71, 255, 0.4)", + "rgba(2, 255, 226, 0.3)", + "rgba(106, 120, 242, 0.3)", + "rgba(255, 255, 255, 0.2)", +] as const; + +const ORB_COLORS = [ + "rgba(147, 71, 255, 0.55)", + "rgba(2, 255, 226, 0.4)", +] as const; + +const LITE_QUERY = "(max-width: 767px), (prefers-reduced-motion: reduce)"; + +let cachedParticles: Particle[] | null = null; + +function getClientParticles(): Particle[] { + if (cachedParticles) return cachedParticles; + + cachedParticles = Array.from({ length: 8 }, (_, i) => { + const randomColorIndex = Math.floor(Math.random() * PARTICLE_COLORS.length); + return { + id: i, + x: Math.random() * 100, + y: Math.random() * 100, + size: Math.random() * 6 + 3, + color: PARTICLE_COLORS[randomColorIndex]!, + opacity: Math.random() * 0.4 + 0.15, + speed: Math.random() * 0.4 + 0.2, + direction: Math.random() * Math.PI * 2, }; + }); - const handleScroll = () => { - scrollY.set(window.scrollY); - }; + return cachedParticles; +} - window.addEventListener("mousemove", handleMouseMove); - window.addEventListener("scroll", handleScroll); +function subscribeParticles() { + return () => {}; +} - return () => { - window.removeEventListener("mousemove", handleMouseMove); - window.removeEventListener("scroll", handleScroll); - }; - }, [mouseX, mouseY, scrollY]); +function subscribeLiteMode(onStoreChange: () => void) { + const media = window.matchMedia(LITE_QUERY); + media.addEventListener("change", onStoreChange); + return () => media.removeEventListener("change", onStoreChange); +} - // Don't render particles until mounted on client - if (!isMounted) { - return ( +function getLiteModeSnapshot() { + return window.matchMedia(LITE_QUERY).matches; +} + +function wave01(timeMs: number, durationSec: number, delaySec: number) { + const elapsed = Math.max(0, timeMs / 1000 - delaySec); + const cycle = (elapsed % durationSec) / durationSec; + return (1 - Math.cos(cycle * Math.PI * 2)) / 2; +} + +function LiteAtmosphere() { + return ( +
- ); - } +
+
+ ); +} + +function DesktopParticles({ particles }: { particles: Particle[] }) { + const particleRefs = useRef<(HTMLDivElement | null)[]>([]); + const orbRefs = useRef<(HTMLDivElement | null)[]>([]); + const ambientARef = useRef(null); + const ambientBRef = useRef(null); + + useAnimationFrame((time) => { + if (typeof document !== "undefined" && document.hidden) return; + + for (let i = 0; i < particles.length; i++) { + const el = particleRefs.current[i]; + const particle = particles[i]; + if (!el || !particle) continue; + + const w = wave01(time, 10 + particle.speed * 4, particle.id * 0.15); + const dx = Math.cos(particle.direction) * 24 * w; + const dy = Math.sin(particle.direction) * 24 * w; + el.style.transform = `translate3d(${dx}px, ${dy}px, 0)`; + el.style.opacity = String(particle.opacity * (1 - 0.45 * w)); + } + + for (let i = 0; i < 3; i++) { + const el = orbRefs.current[i]; + if (!el) continue; + + const w = wave01(time, 14 + i * 2, 0); + const dx = (i * 20 - 40) * w; + const dy = (i * 14 - 24) * w; + el.style.transform = `translate3d(${dx}px, ${dy}px, 0)`; + el.style.opacity = String(0.5 + 0.35 * w); + } + + const ambientA = ambientARef.current; + if (ambientA) { + const w = wave01(time, 14, 0); + const scale = 1 + 0.2 * w; + ambientA.style.transform = `scale(${scale})`; + ambientA.style.opacity = String(0.35 + 0.2 * w); + } + + const ambientB = ambientBRef.current; + if (ambientB) { + const w = wave01(time, 12, 0); + const scale = 1.15 - 0.15 * w; + ambientB.style.transform = `scale(${scale})`; + ambientB.style.opacity = String(0.4 + 0.2 * w); + } + }); return (
- {particles.map((particle) => ( - ( +
{ + particleRefs.current[index] = node; + }} className="absolute rounded-full" style={{ width: particle.size, @@ -107,132 +168,83 @@ const FloatingParticles = () => { left: `${particle.x}%`, top: `${particle.y}%`, opacity: particle.opacity, + willChange: "transform, opacity", }} - animate={{ - x: [0, Math.cos(particle.direction) * 30, 0], - y: [0, Math.sin(particle.direction) * 30, 0], - scale: [1, 1.2, 1], - opacity: [particle.opacity, particle.opacity * 0.5, particle.opacity], - }} - transition={{ - duration: 8 + particle.speed * 4, - repeat: Infinity, - ease: "easeInOut", - delay: particle.id * 0.2, - }} - > - {/* Inner glow effect */} - - + /> ))} - {/* Additional floating orbs with mouse interaction */} - {Array.from({ length: 8 }, (_, i) => ( - ( +
{ + orbRefs.current[i] = node; }} - animate={{ - x: [0, (i * 30 - 120), (i * 30 + 120), 0], - y: [0, (i * 20 - 80), (i * 20 + 80), 0], - rotate: [0, 360], - scale: [1, 1.1, 1], - }} - transition={{ - x: { - duration: 15 + i * 2, - repeat: Infinity, - ease: "easeInOut", - }, - y: { - duration: 12 + i * 2, - repeat: Infinity, - ease: "easeInOut", - }, - rotate: { - duration: 20 + i * 2, - repeat: Infinity, - ease: "linear", - }, - scale: { - duration: 6 + i, - repeat: Infinity, - ease: "easeInOut", - }, + className="absolute h-2.5 w-2.5 rounded-full" + style={{ + left: `${25 + i * 22}%`, + top: `${28 + (i % 3) * 18}%`, + background: `radial-gradient(circle, ${ORB_COLORS[i % 2]} 0%, transparent 70%)`, + opacity: 0.5, + willChange: "transform, opacity", }} - > -
- + /> ))} - {/* Ambient light orbs */} - -
); +} + +const FloatingParticles = () => { + const isLite = useSyncExternalStore( + subscribeLiteMode, + getLiteModeSnapshot, + () => true, + ); + + const particles = useSyncExternalStore( + subscribeParticles, + getClientParticles, + () => EMPTY_PARTICLES, + ); + + if (isLite) { + return ; + } + + if (particles.length === 0) { + return ( +
+ ); + } + + return ; }; -export default FloatingParticles; \ No newline at end of file +export default FloatingParticles; diff --git a/apps/codebility/app/(marketing)/_components/landing/ForceScrollTop.tsx b/apps/codebility/app/(marketing)/_components/landing/ForceScrollTop.tsx index 76dc5a5f6..0cb8f244f 100644 --- a/apps/codebility/app/(marketing)/_components/landing/ForceScrollTop.tsx +++ b/apps/codebility/app/(marketing)/_components/landing/ForceScrollTop.tsx @@ -1,50 +1,61 @@ "use client"; -import { useEffect } from "react"; +import { useSyncExternalStore } from "react"; -const ForceScrollTop = () => { - useEffect(() => { - // Prevent any scrolling during initial render - document.documentElement.style.scrollBehavior = 'auto'; - document.body.style.scrollBehavior = 'auto'; - - // Force immediate scroll to top - document.documentElement.scrollTop = 0; - document.body.scrollTop = 0; - window.scrollTo({ top: 0, behavior: 'auto' }); - - // Clear any hash from URL immediately - if (window.location.hash) { - const newUrl = window.location.pathname + window.location.search; - window.history.replaceState({}, document.title, newUrl); - } - - // Disable all scroll restoration - if ('scrollRestoration' in history) { - history.scrollRestoration = 'manual'; - } - - // Force scroll again after a short delay to override any other scroll behavior - const timeouts = [0, 16, 50, 100].map(delay => - setTimeout(() => { - document.documentElement.scrollTop = 0; - document.body.scrollTop = 0; - window.scrollTo({ top: 0, behavior: 'auto' }); - }, delay) - ); - - // Restore smooth scrolling after initial load +function forceScrollTop() { + document.documentElement.style.scrollBehavior = "auto"; + document.body.style.scrollBehavior = "auto"; + document.documentElement.scrollTop = 0; + document.body.scrollTop = 0; + window.scrollTo({ top: 0, behavior: "auto" }); + + if (window.location.hash) { + const newUrl = window.location.pathname + window.location.search; + window.history.replaceState({}, document.title, newUrl); + } + + if ("scrollRestoration" in history) { + history.scrollRestoration = "manual"; + } +} + +function subscribeForceScrollTop() { + forceScrollTop(); + + const timeouts = [0, 16, 50, 100].map((delay) => setTimeout(() => { - document.documentElement.style.scrollBehavior = 'smooth'; - document.body.style.scrollBehavior = 'smooth'; - }, 500); - - return () => { - timeouts.forEach(clearTimeout); - }; - }, []); + document.documentElement.scrollTop = 0; + document.body.scrollTop = 0; + window.scrollTo({ top: 0, behavior: "auto" }); + }, delay), + ); + const restore = setTimeout(() => { + document.documentElement.style.scrollBehavior = "smooth"; + document.body.style.scrollBehavior = "smooth"; + }, 500); + + return () => { + timeouts.forEach(clearTimeout); + clearTimeout(restore); + }; +} + +function getSnapshot() { + return true; +} + +function getServerSnapshot() { + return false; +} + +const ForceScrollTop = () => { + useSyncExternalStore( + subscribeForceScrollTop, + getSnapshot, + getServerSnapshot, + ); return null; }; -export default ForceScrollTop; \ No newline at end of file +export default ForceScrollTop; diff --git a/apps/codebility/app/(marketing)/_components/landing/LandingAdminCard.tsx b/apps/codebility/app/(marketing)/_components/landing/LandingAdminCard.tsx index c533594ee..4392e2d1e 100644 --- a/apps/codebility/app/(marketing)/_components/landing/LandingAdminCard.tsx +++ b/apps/codebility/app/(marketing)/_components/landing/LandingAdminCard.tsx @@ -1,92 +1,106 @@ "use client"; +import { useCallback, useState } from "react"; +import Image from "next/image"; +import Link from "next/link"; import { Codev } from "@/types/home/codev"; -import { cn } from "@codevs/ui"; import { motion } from "framer-motion"; -import Image from "next/image"; -import { useState } from "react"; -// Utility function to capitalize the first letter of each word const capitalizeWords = (text: string) => { - return text - .split(" ") - .map((word) => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase()) - .join(" "); + return text + .split(" ") + .map((word) => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase()) + .join(" "); +}; + +const getInitials = (firstName?: string, lastName?: string) => { + const first = (firstName ?? "").trim().charAt(0); + const last = (lastName ?? "").trim().charAt(0); + const initials = `${first}${last}`.toUpperCase(); + return initials || "?"; }; const AdminCard = ({ admin }: { admin: Codev }) => { - const [imageLoaded, setImageLoaded] = useState(false); - const defaultImage = "/assets/svgs/icon-codebility-black.svg"; + const [loaded, setLoaded] = useState(false); + const [errored, setErrored] = useState(false); + const src = admin.image_url; + const showPhoto = Boolean(src) && !errored; + const initials = getInitials(admin.first_name, admin.last_name); - // Line 18: Enhanced with onClick redirect and hover effects - return ( - window.location.href = `https://www.codebility.tech/profiles/${admin.id}`} - whileHover={{ scale: 1.02 }} - transition={{ duration: 0.2 }} - > -
-
- {/* Image */} -
- {`${admin.first_name} setImageLoaded(true)} - onError={(e) => { - e.currentTarget.src = defaultImage; - }} - /> -
+ const markLoaded = useCallback(() => { + setLoaded(true); + }, []); + + const handleImgRef = useCallback( + (node: HTMLImageElement | null) => { + if (node?.complete && node.naturalWidth > 0) { + markLoaded(); + } + }, + [markLoaded], + ); - {/* Loading skeleton */} - {!imageLoaded && ( - - )} -
+ return ( + + +
+
+
+ + {initials} + +
+ {showPhoto ? ( + {`${admin.first_name} setErrored(true)} + ref={handleImgRef} + /> + ) : null} +
- {/* Text content */} - -

- {capitalizeWords(admin.first_name)}{" "} - {capitalizeWords(admin.last_name)} -

-
- {admin.display_position ? ( -

- {admin.display_position} -

- ) : ( -
 
- )} -
-
-
+ +

+ {capitalizeWords(admin.first_name)}{" "} + {capitalizeWords(admin.last_name)} +

+
+ {admin.display_position ? ( +

+ {admin.display_position} +

+ ) : ( +
 
+ )} +
- ); +
+ +
+ ); }; -export default AdminCard; \ No newline at end of file +export default AdminCard; diff --git a/apps/codebility/app/(marketing)/_components/landing/LandingAdmins.tsx b/apps/codebility/app/(marketing)/_components/landing/LandingAdmins.tsx index cbe416386..8e1df93c2 100644 --- a/apps/codebility/app/(marketing)/_components/landing/LandingAdmins.tsx +++ b/apps/codebility/app/(marketing)/_components/landing/LandingAdmins.tsx @@ -1,130 +1,47 @@ -import React from "react"; -import { createClient } from "@supabase/supabase-js"; -import { Codev } from "@/types/home/codev"; +import { Suspense } from "react"; +import { getCachedLandingAdminsData } from "@/lib/server/landing-admins-cached"; import Section from "../MarketingSection"; import AnimatedAdminsSection from "./AnimatedAdminsSection"; +import { LandingAdminsSkeleton } from "./LandingAdminsSkeleton"; +import { ADMINS_SECTION_COPY } from "@/constants/marketing/admins-section-copy"; -const FOUNDER_USER_ID = process.env.NEXT_PUBLIC_FOUNDER_USER_ID || ""; - -export default async function Admins() { - /** - * Use anon Supabase client — NO cookie access, NO session reading. - * This is a public marketing page. Using createClientServerComponent() - * here was causing GoTrueClient to attempt a token refresh on every - * page load for users with expired sessions → AuthApiError: refresh_token_not_found - * - * Fix: createClient() with anon key = no auth context = no token refresh attempt. - */ - const supabase = createClient( - process.env.NEXT_PUBLIC_SUPABASE_URL!, - process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY! - ); - - // Fetch admins (role_id: 1) and mentors (role_id: 5) in parallel - const [ - { data: admins, error: adminError }, - { data: mentors, error: mentorError }, - ] = await Promise.all([ - supabase.from("codev").select("*").eq("role_id", 1), - supabase.from("codev").select("*").eq("role_id", 5), - ]); - - if (adminError || mentorError) return
ERROR
; - - const sortedAdmins = admins - ? [...admins] - .filter((admin) => admin.availability_status !== false) - .sort((a, b) => { - // Founder always first - if (a.id === FOUNDER_USER_ID) return -1; - if (b.id === FOUNDER_USER_ID) return 1; - - // Admins with profile pictures come before those without - const aHasImage = !!a.image_url; - const bHasImage = !!b.image_url; - - if (aHasImage && !bHasImage) return -1; - if (!aHasImage && bHasImage) return 1; - - return 0; - }) - .filter((admin) => !admin.display_position?.includes("Developer")) - : []; - - const sortedMentors = mentors - ? [...mentors] - .filter((mentor) => mentor.availability_status !== false) - .sort((a, b) => { - const aHasImage = !!a.image_url; - const bHasImage = !!b.image_url; - - if (aHasImage && !bHasImage) return -1; - if (!aHasImage && bHasImage) return 1; - - return 0; - }) - : []; - - // Format position text — handles UI/UX abbreviations and slashes - const formatPosition = (position: string) => { - const specialCases: Record = { - "ui/ux": "UI/UX", - ui: "UI", - ux: "UX", - }; - - return position - .split(" ") - .map((word) => { - const lowerWord = word.toLowerCase(); - if (specialCases[lowerWord]) return specialCases[lowerWord]; - - if (word.includes("/")) { - return word - .split("/") - .map((part) => { - const lowerPart = part.toLowerCase(); - return ( - specialCases[lowerPart] || - part.charAt(0).toUpperCase() + part.slice(1).toLowerCase() - ); - }) - .join("/"); - } - - return word.charAt(0).toUpperCase() + word.slice(1).toLowerCase(); - }) - .join(" "); - }; +async function LandingAdminsContent() { + const data = await getCachedLandingAdminsData(); + if (!data) return
ERROR
; return ( -
+ <> ({ - ...admin, - display_position: admin.display_position - ? formatPosition(admin.display_position) - : admin.display_position, - }))} + {...ADMINS_SECTION_COPY.admins} + members={data.admins} sectionId="admins" />
({ - ...mentor, - display_position: mentor.display_position - ? formatPosition(mentor.display_position) - : mentor.display_position, - }))} + {...ADMINS_SECTION_COPY.mentors} + members={data.mentors} sectionId="mentors" />
+ + ); +} + +export default function Admins() { + return ( +
+
+
+ +
+
+ }> + + +
+
); -} \ No newline at end of file +} diff --git a/apps/codebility/app/(marketing)/_components/landing/LandingAdminsSkeleton.tsx b/apps/codebility/app/(marketing)/_components/landing/LandingAdminsSkeleton.tsx new file mode 100644 index 000000000..ce32c053a --- /dev/null +++ b/apps/codebility/app/(marketing)/_components/landing/LandingAdminsSkeleton.tsx @@ -0,0 +1,87 @@ +"use client"; + +import { Skeleton } from "@/components/ui/skeleton/skeleton"; + +import { ADMINS_SECTION_COPY } from "@/constants/marketing/admins-section-copy"; +import BlueBg from "./LandingBlueBg"; + +const ADMIN_CARD_COUNT = 9; +const MENTOR_CARD_COUNT = 8; + +function AdminCardSkeleton() { + return ( + + ); +} + +function AdminsSectionSkeleton({ + title, + description, + cardCount, +}: { + title: string; + description: string; + cardCount: number; +}) { + return ( + + ); +} + +export function LandingAdminsSkeleton() { + return ( + + ); +} diff --git a/apps/codebility/app/(marketing)/_components/landing/LandingBlueBg.tsx b/apps/codebility/app/(marketing)/_components/landing/LandingBlueBg.tsx index 87e7d1340..f19907914 100644 --- a/apps/codebility/app/(marketing)/_components/landing/LandingBlueBg.tsx +++ b/apps/codebility/app/(marketing)/_components/landing/LandingBlueBg.tsx @@ -5,8 +5,9 @@ import { cn } from "@codevs/ui"; const BlueBg = ({ className }: { className?: string }) => { return ( diff --git a/apps/codebility/app/(marketing)/_components/landing/LandingDiamond.tsx b/apps/codebility/app/(marketing)/_components/landing/LandingDiamond.tsx index 7bb963594..d3ffa93c5 100644 --- a/apps/codebility/app/(marketing)/_components/landing/LandingDiamond.tsx +++ b/apps/codebility/app/(marketing)/_components/landing/LandingDiamond.tsx @@ -1,12 +1,12 @@ import React from "react"; -import Image from "next/image"; +import LandingImage from "./LandingImage"; const Diamond = ({ color }: { color?: string }) => { return ( <> {color === "violet" ? (
- diamond-icon {
) : color === "teal" ? (
- diamond-icon { - const ref = useRef(null); - const isInView = useInView(ref, { once: true, amount: 0.2 }); - return (
- - - - In the Tech Industry - - - Codebility sparks a passion for{" "} - +
+ + + +
+
+ + - Technology and Innovation. - - - - Our programs go beyond skill acquisition, - because we believe in the transformative power of coding - - +

+ In the Tech Industry +

+

+ Codebility sparks a passion for{" "} + + Technology and Innovation. + +

+

+ Our programs go beyond skill acquisition, because we believe in the + transformative power of coding +

+
- - {ServicesCardData.map((data, index) => ( - - - - ))} - - - - - -
+ key={index} + data-progressive-child + whileHover={{ + scale: 1.05, + y: -10, + rotateY: 5, + transition: { duration: 0.3 }, + }} + whileTap={{ scale: 0.95 }} + style={{ perspective: "1000px" }} + > + + + ))} + + + + + + +
+
+ +

{description} - +

); diff --git a/apps/codebility/app/(marketing)/_components/landing/LandingImage.tsx b/apps/codebility/app/(marketing)/_components/landing/LandingImage.tsx new file mode 100644 index 000000000..e73e7b5db --- /dev/null +++ b/apps/codebility/app/(marketing)/_components/landing/LandingImage.tsx @@ -0,0 +1,15 @@ +import Image, { type ImageProps } from "next/image"; + +export default function LandingImage({ + priority, + loading, + ...props +}: ImageProps) { + return ( + + ); +} diff --git a/apps/codebility/app/(marketing)/_components/landing/LandingIntern-CodevCard.tsx b/apps/codebility/app/(marketing)/_components/landing/LandingIntern-CodevCard.tsx index 16d6675d5..ea19d32fa 100644 --- a/apps/codebility/app/(marketing)/_components/landing/LandingIntern-CodevCard.tsx +++ b/apps/codebility/app/(marketing)/_components/landing/LandingIntern-CodevCard.tsx @@ -1,127 +1,110 @@ -// components/landing/LandingIntern-CodevCard.tsx - "use client"; -import { useState, useEffect, useRef } from "react"; -import { motion, useInView } from "framer-motion"; + +import { useState } from "react"; +import Link from "next/link"; +import { motion } from "framer-motion"; import { Card, CardContent } from "@/components/ui/card"; +import ProgressiveMotion from "./ProgressiveMotion"; + type Person = { - id: string; // ✅ Added id field for navigation + id: string; name: string; - role: 'Intern' | 'Codev'; // Only two possible roles from database - image?: string; // image URL (optional) + role: "Intern" | "Codev"; + image?: string; display_position?: string; }; -// Role configuration for easy identification and maintenance const ROLE_CONFIG = { - INTERN: 'Intern' as const, - CODEV: 'Codev' as const, + INTERN: "Intern" as const, + CODEV: "Codev" as const, } as const; const ROLE_STYLES = { [ROLE_CONFIG.INTERN]: { - cardClass: 'bg-black-800 border-neutral-700', - badgeClass: 'bg-green-600/30 text-green-300 border border-green-500/30', - label: 'Intern' + cardClass: "bg-black-800 border-neutral-700", + badgeClass: "bg-green-600/30 text-green-300 border border-green-500/30", + label: "Intern", }, [ROLE_CONFIG.CODEV]: { - cardClass: 'bg-gradient-to-br from-blue-900/90 to-black-800 border-blue-700/50', - badgeClass: 'bg-blue-600/30 text-blue-300 border border-blue-500/30', - label: 'Codev' - } + cardClass: + "bg-gradient-to-br from-blue-900/90 to-black-800 border-blue-700/50", + badgeClass: "bg-blue-600/30 text-blue-300 border border-blue-500/30", + label: "Codev", + }, } as const; function Avatar({ person, size = 80, - position = "center top" + position = "center top", }: { person: Person; size?: number; position?: string; }) { - // Updated initials logic - assumes last word is last_name, everything else is first_name const getInitials = () => { const fullName = (person.name ?? "").trim(); - + if (!fullName) return ""; - + const nameParts = fullName.split(/\s+/).filter((p) => p.length > 0); - + if (nameParts.length >= 2) { - // First letter of first_name (first word) + First letter of last_name (last word) const firstName = nameParts[0]; const lastName = nameParts[nameParts.length - 1]; - + if (firstName && lastName) { - const firstNameInitial = firstName.charAt(0); - const lastNameInitial = lastName.charAt(0); - return (firstNameInitial + lastNameInitial).toUpperCase(); + return (firstName.charAt(0) + lastName.charAt(0)).toUpperCase(); } } else if (nameParts.length === 1) { - // If only one name, use first two letters const singleName = nameParts[0]; if (singleName) { return singleName.substring(0, 2).toUpperCase(); } } - + return ""; }; const initials = getInitials(); const [imgError, setImgError] = useState(false); const [imgLoaded, setImgLoaded] = useState(false); - const [forceRender, setForceRender] = useState(false); const hasImage = Boolean(person.image) && !imgError; - // Force image preloading and rendering - useEffect(() => { - if (person.image && person.image !== "/avatars/default.png") { - const img = new Image(); - img.onload = () => { - setImgLoaded(true); - setForceRender(true); - }; - img.onerror = () => { - setImgError(true); - }; - img.src = person.image; - } - }, [person.image]); - if (hasImage) { return (
{person.name} setImgError(true)} - onLoad={() => { - setImgLoaded(true); + onLoad={() => setImgLoaded(true)} + ref={(node) => { + if (node?.complete && node.naturalWidth > 0) { + setImgLoaded(true); + } }} - className={`absolute top-1/2 left-1/2 object-cover transition-opacity duration-200 ${ - imgLoaded ? 'opacity-100' : 'opacity-0' + className={`absolute left-1/2 top-1/2 object-cover transition-opacity duration-200 ${ + imgLoaded ? "opacity-100" : "opacity-0" }`} style={{ objectPosition: position, width: `${size * 1.2}px`, height: `${size * 1.2}px`, transform: "translate(-50%, -50%)", - display: "block" + display: "block", }} - loading="eager" - decoding="sync" - fetchPriority="high" + loading="lazy" + decoding="async" /> - {/* Loading placeholder */} - {!imgLoaded && ( + {!imgLoaded ? (
- )} + ) : null}
); } return (
@@ -151,206 +134,168 @@ function Avatar({ ); } -// Animation variants -const containerVariants = { - hidden: { opacity: 0 }, - visible: { - opacity: 1, - transition: { - staggerChildren: 0.1, - delayChildren: 0.1, - }, - }, -}; - -const cardVariants = { - hidden: { - opacity: 0, - y: 60, - scale: 0.9, - rotateY: -15, - }, - visible: { - opacity: 1, - y: 0, - scale: 1, - rotateY: 0, - transition: { - duration: 0.8, - ease: [0.25, 0.46, 0.45, 0.94], - }, - }, -}; - -export default function InternCards({ interns }: { interns: Person[] }) { - const ref = useRef(null); - const isInView = useInView(ref, { once: true, amount: 0.1 }); - - // Helper function to check if person is Codev - const isCodev = (person: Person): boolean => person.role === ROLE_CONFIG.CODEV; - - // Helper function to check if person is Intern - const isIntern = (person: Person): boolean => person.role === ROLE_CONFIG.INTERN; - - // Preload all images for current page immediately - useEffect(() => { - interns.forEach(intern => { - if (intern.image && intern.image !== "/avatars/default.png") { - const img = new Image(); - img.src = intern.image; - } - }); - }, [interns]); +type RoleStyle = (typeof ROLE_STYLES)[keyof typeof ROLE_STYLES]; +function InternCard({ + intern, + roleStyles, + isCodev, + index, + progressive, +}: { + intern: Person; + roleStyles: RoleStyle; + isCodev: boolean; + index: number; + progressive?: boolean; +}) { return ( - - {/* Responsive grid with proper mobile layout */} -
- {interns.map((intern, idx) => { - const roleStyles = ROLE_STYLES[intern.role] || ROLE_STYLES[ROLE_CONFIG.INTERN]; - - return ( + + + + + + + + window.location.href = `https://www.codebility.tech/profiles/${intern.id}`} - className="cursor-pointer" + className="flex items-center justify-center pb-0 pt-6" + whileHover={{ scale: 1.1 }} + transition={{ duration: 0.3 }} > - - + + +
+
+ - {/* Hover gradient overlay */} - + {intern.name} + +
- {/* Status indicator */} - +
+ + {roleStyles.label} + +
- - {/* Avatar Section - Fixed positioning with minimal spacing */} - - - - - {/* Content Section - Close to avatar with minimal spacing */} -
- {/* Name - Responsive text and spacing */} - - - {intern.name} - - - - {/* Role Badge - Responsive sizing */} - - - {roleStyles.label} - - - - {/* Position - Responsive text and spacing */} - -

- {intern.display_position || roleStyles.label} -

-
-
-
- -
- +
+

+ {intern.display_position || roleStyles.label} +

+
+
+
+
+
+ + + ); +} + +export default function InternCards({ + interns, + playOnMount = false, +}: { + interns: Person[]; + playOnMount?: boolean; +}) { + const isCodev = (person: Person): boolean => person.role === ROLE_CONFIG.CODEV; + + return ( + +
+ {interns.map((intern, idx) => { + const roleStyles = + ROLE_STYLES[intern.role] || ROLE_STYLES[ROLE_CONFIG.INTERN]; + + return ( + ); })}
- +
); -} \ No newline at end of file +} diff --git a/apps/codebility/app/(marketing)/_components/landing/LandingIntern-CodevPagination.tsx b/apps/codebility/app/(marketing)/_components/landing/LandingIntern-CodevPagination.tsx index b5fec0554..ff4525dda 100644 --- a/apps/codebility/app/(marketing)/_components/landing/LandingIntern-CodevPagination.tsx +++ b/apps/codebility/app/(marketing)/_components/landing/LandingIntern-CodevPagination.tsx @@ -1,230 +1,262 @@ -// components/landing/LandingIntern-CodevPagination.tsx -// -// This component displays a paginated list of team members (Interns and Codevs) -// with prioritization applied using the prioritizeCodevs utility function. -// Team members are automatically sorted by: -// 1. Total codev points (highest first) -// 2. Level 2+ badges (prioritized) -// 3. Number of valid badges -// 4. Has profile image -// 5. Has work experience -// 6. Years of experience - "use client"; -import { useState, useEffect } from "react"; +import { Suspense, use, useState, useTransition } from "react"; import { ChevronLeft, ChevronRight } from "lucide-react"; -import { Button } from "@/components/ui/button"; -import { cn } from "@/lib/utils"; +import { useMarketingPageUrl } from "@/hooks/marketing/use-marketing-page-url"; +import type { LandingInternsPage } from "@/lib/server/landing-interns-cached"; +import { fetchApiJson } from "@/utils/api-fetch"; + import InternCards from "./LandingIntern-CodevCard"; -import { prioritizeCodevs } from "@/utils/codev-priority"; +import { LandingInternCardsSkeleton } from "./LandingInternSkeleton"; -// Type definitions for the two roles from database -type PersonRole = 'Intern' | 'Codev'; +export type PersonRole = "Intern" | "Codev"; -type TeamMember = { +export type LandingInternMember = { id: string; name: string; - role: PersonRole; // Either Intern or Codev from roles table + role: PersonRole; image?: string; display_position?: string; - years_of_experience?: number; - internal_status?: string; - level?: Record; - codev_points?: Array<{ - id: string; - codev_id?: string; - skill_category_id?: string; - points: number; - }>; - work_experience?: Array; }; -interface TeamMembersApiResponse { - TEAM_MEMBERS: { // Changed from INTERNS to TEAM_MEMBERS to match new API - id: string; - name: string; - role: string; - image?: string; - display_position?: string; - years_of_experience?: number; - internal_status?: string; - level?: Record; - codev_points?: Array<{ - id: string; - codev_id?: string; - skill_category_id?: string; - points: number; - }>; - work_experience?: Array; - }[]; - error?: string; +const pagePromises = new Map>(); +const pageMetaCache = new Map(); + +function pageCacheKey(page: number, pageSize: number) { + return `rank:${page}:${pageSize}`; } -// Role constants for better code maintainability -const TEAM_ROLES = { - INTERN: 'Intern' as const, - CODEV: 'Codev' as const, -} as const; - -export default function TeamMembersPagination() { - const [currentPage, setCurrentPage] = useState(1); - const [allTeamMembers, setAllTeamMembers] = useState([]); - const [isLoading, setIsLoading] = useState(true); - const [fetchError, setFetchError] = useState(null); - - const ITEMS_PER_PAGE = 10; - - // Helper functions for role identification - const isInternRole = (role: string): boolean => role === TEAM_ROLES.INTERN; - const isCodevRole = (role: string): boolean => role === TEAM_ROLES.CODEV; - const validateTeamRole = (role: string): PersonRole => { - return isCodevRole(role) ? TEAM_ROLES.CODEV : TEAM_ROLES.INTERN; - }; +function rememberPagination( + page: number, + pageSize: number, + pagination: LandingInternsPage["pagination"], +) { + pageMetaCache.set(pageCacheKey(page, pageSize), pagination); + pageMetaCache.set(`rank:${pageSize}`, pagination); +} - useEffect(() => { - let isMounted = true; - - const fetchAllTeamMembers = async () => { - setIsLoading(true); - setFetchError(null); - - try { - const response = await fetch("/api/all-active-interns-codev-prioritized"); - if (!response.ok) { - const errorText = await response.text(); - throw new Error(`Failed to fetch team members: ${response.status} ${errorText}`); - } - - const apiData = (await response.json()) as TeamMembersApiResponse; - - if (!apiData || !Array.isArray(apiData.TEAM_MEMBERS)) { - console.warn("Unexpected API response shape for /api/all-active-interns-codev-prioritized:", apiData); - if (!isMounted) return; - setAllTeamMembers([]); - } else { - // Map API response to TeamMember objects with proper role validation - const mappedTeamMembers = apiData.TEAM_MEMBERS.map((member) => ({ - id: member.id, - name: member.name, - role: validateTeamRole(member.role), // Ensures only Intern or Codev roles - image: member.image, - display_position: member.display_position, - years_of_experience: member.years_of_experience, - internal_status: member.internal_status, - level: member.level, - codev_points: member.codev_points, - work_experience: member.work_experience, - })); - - if (!isMounted) return; - setAllTeamMembers(mappedTeamMembers); - setCurrentPage(1); // Reset to first page when new data loads - } - } catch (fetchErr: any) { - console.error("Error fetching team members:", fetchErr); - if (!isMounted) return; - setFetchError(fetchErr?.message ?? "Unknown error occurred"); - setAllTeamMembers([]); - } finally { - if (!isMounted) return; - setIsLoading(false); - } - }; - - fetchAllTeamMembers(); - - return () => { - isMounted = false; - }; - }, []); - - const totalPages = Math.max(1, Math.ceil(allTeamMembers.length / ITEMS_PER_PAGE)); - - // Ensure current page stays within valid range - useEffect(() => { - if (currentPage > totalPages) setCurrentPage(totalPages); - if (currentPage < 1) setCurrentPage(1); - }, [currentPage, totalPages]); - - // Get current page team members (both Interns and Codevs) - const currentPageTeamMembers = allTeamMembers.slice( - (currentPage - 1) * ITEMS_PER_PAGE, - currentPage * ITEMS_PER_PAGE +function resolvePagination( + page: number, + pageSize: number, + initialData: LandingInternsPage, +): LandingInternsPage["pagination"] { + return ( + pageMetaCache.get(pageCacheKey(page, pageSize)) ?? + pageMetaCache.get(`rank:${pageSize}`) ?? + initialData.pagination ); +} - // Navigation functions - const goToPreviousPage = () => { - setCurrentPage((prevPage) => Math.max(1, prevPage - 1)); - }; +function toTeamMembers( + members: LandingInternsPage["TEAM_MEMBERS"], +): LandingInternMember[] { + return members.map((member) => ({ + id: member.id, + name: member.name, + role: member.role === "Codev" ? "Codev" : "Intern", + image: member.image, + display_position: member.display_position, + })); +} - const goToNextPage = () => { - setCurrentPage((prevPage) => Math.min(totalPages, prevPage + 1)); - }; +function loadPage( + page: number, + pageSize: number, + initialData: LandingInternsPage, +): Promise { + const key = pageCacheKey(page, pageSize); + const cached = pagePromises.get(key); + if (cached) return cached; - const handlePreviousPageClick = () => { - goToPreviousPage(); - }; + if (page === initialData.pagination.page) { + rememberPagination(page, pageSize, initialData.pagination); + const resolved = Promise.resolve(initialData); + pagePromises.set(key, resolved); + return resolved; + } - const handleNextPageClick = () => { - goToNextPage(); - }; + const promise = fetchApiJson( + `/api/landing-interns?page=${page}&limit=${pageSize}`, + { cache: "force-cache" }, + ).then((result) => { + if (!result.ok) { + console.error("Error fetching landing interns page:", result.error); + const fallback = { + TEAM_MEMBERS: [], + pagination: { + page, + limit: pageSize, + total: 0, + totalPages: Math.max(1, initialData.pagination.totalPages), + }, + }; + rememberPagination(page, pageSize, fallback.pagination); + return fallback; + } + + rememberPagination(page, pageSize, result.data.pagination); + return result.data; + }); + + pagePromises.set(key, promise); + return promise; +} - // Button disabled states - const isPreviousPageDisabled = currentPage <= 1 || isLoading; - const isNextPageDisabled = currentPage >= totalPages || isLoading; +function PaginationControls({ + page, + totalPages, + onPageChange, +}: { + page: number; + totalPages: number; + onPageChange: (page: number) => void; +}) { + if (totalPages <= 1) { + return
diff --git a/apps/codebility/app/(marketing)/_components/landing/LandingScrollToHash.tsx b/apps/codebility/app/(marketing)/_components/landing/LandingScrollToHash.tsx index 923e29f26..34a4b3fa1 100644 --- a/apps/codebility/app/(marketing)/_components/landing/LandingScrollToHash.tsx +++ b/apps/codebility/app/(marketing)/_components/landing/LandingScrollToHash.tsx @@ -1,52 +1,59 @@ "use client"; -import { useEffect } from "react"; +import { useSyncExternalStore } from "react"; -const ScrollToHash = () => { - useEffect(() => { - // Force scroll to top on initial page load - window.scrollTo(0, 0); - - // Clear any hash on initial page load to prevent auto-scrolling - if (window.location.hash) { - history.replaceState(null, null, window.location.pathname); - // Force scroll to top again after clearing hash - setTimeout(() => window.scrollTo(0, 0), 100); - return; +function jumpToHash() { + const hash = window.location.hash; + if (!hash) return; + + const isValidSelector = /^#[a-zA-Z][a-zA-Z0-9_-]*$/.test(hash); + if (!isValidSelector) return; + + try { + const element = document.querySelector(hash); + if (element) { + element.scrollIntoView({ behavior: "smooth" }); } + } catch { + console.warn("Invalid CSS selector:", hash); + } +} + +function subscribeHashScroll(onStoreChange: () => void) { + window.scrollTo(0, 0); + + let clearHashTimeout: ReturnType | undefined; + + if (window.location.hash) { + history.replaceState(null, "", window.location.pathname); + clearHashTimeout = setTimeout(() => window.scrollTo(0, 0), 100); + } - const jumpToHash = () => { - const hash = window.location.hash; - - if (hash) { - // Check if hash is a valid CSS selector (should start with # and contain only valid characters) - // Skip hashes that contain URL parameters (= signs, & signs, etc.) - const isValidSelector = /^#[a-zA-Z][a-zA-Z0-9_-]*$/.test(hash); - - if (isValidSelector) { - try { - const element = document.querySelector(hash); - if (element) { - element.scrollIntoView({ behavior: "smooth" }); - } - } catch (error) { - // Silently catch any invalid selector errors - console.warn("Invalid CSS selector:", hash); - } - } - } - }; - - // Only listen for hash changes after initial load - window.addEventListener("popstate", jumpToHash); - window.addEventListener("hashchange", jumpToHash); - - return () => { - window.removeEventListener("popstate", jumpToHash); - window.removeEventListener("hashchange", jumpToHash); - }; - }, []); + const onHash = () => { + jumpToHash(); + onStoreChange(); + }; + window.addEventListener("popstate", onHash); + window.addEventListener("hashchange", onHash); + + return () => { + if (clearHashTimeout) clearTimeout(clearHashTimeout); + window.removeEventListener("popstate", onHash); + window.removeEventListener("hashchange", onHash); + }; +} + +function getSnapshot() { + return window.location.hash; +} + +function getServerSnapshot() { + return ""; +} + +const ScrollToHash = () => { + useSyncExternalStore(subscribeHashScroll, getSnapshot, getServerSnapshot); return null; }; diff --git a/apps/codebility/app/(marketing)/_components/landing/LandingSectionSkeletons.tsx b/apps/codebility/app/(marketing)/_components/landing/LandingSectionSkeletons.tsx new file mode 100644 index 000000000..cc2bee89f --- /dev/null +++ b/apps/codebility/app/(marketing)/_components/landing/LandingSectionSkeletons.tsx @@ -0,0 +1,237 @@ +"use client"; + +import { Skeleton } from "@/components/ui/skeleton/skeleton"; +import { ServicesCardData } from "@/constants/landing_data"; + +export function LandingFeaturesSkeleton() { + return ( +
+
+

+ In the Tech Industry +

+

+ Codebility sparks a passion for{" "} + + Technology and Innovation. + +

+

+ Our programs go beyond skill acquisition, because we believe in the + transformative power of coding +

+
+ +
+ {ServicesCardData.map((data) => ( +
+
+
+ +
+

{data.title}

+

{data.description}

+
+
+ ))} +
+ +
+
+ Book a call +
+
+
+ ); +} + +export function LandingWhyChooseSkeleton() { + return ( +
+
+

Why Choose Codebility?

+
+ {[ + { value: "120+", label: "Projects Completed" }, + { value: "98%", label: "Client Satisfaction" }, + { value: "100+", label: "Expert Developers" }, + { value: "24/7", label: "Support Available" }, + ].map((stat) => ( +
+
+ {stat.value} +
+

{stat.label}

+
+ ))} +
+
+ +
+
+
+ +
+

+ Innovative Approach +

+

+ Embrace innovation with Codebility. Crafting revolutionary digital + solutions that create new posibilites +

+
+
+
+ +
+
+ +
+

+ Reliable and Trusted +

+

+ Codebility has a proven track record across diverse industries, + trusted for our reliability, consistency, and on-time + delivery—your dependable digital partner. +

+
+
+
+ +
+ +
+ +
+
+ +
+

+ Customer - Centric Solution +

+

+ Understanding your vision and helping you bring your online vision + to life.{" "} +

+
+
+
+ +
+

+ Your Uniqueness is our focus +

+
+
+
+ ); +} + +const WORK_WITH_US_CARDS = [ + { + id: "portfolio", + title: "Our Portfolio", + description: + "Build your next digital experience with a team that delivers reliable, cutting-edge work from discovery to launch.", + linkText: "Explore Work", + }, + { + id: "hire-developers", + title: "Hire Developers", + description: + "Accelerate delivery with vetted engineers, designers, and strategists who integrate seamlessly with your roadmap.", + linkText: "Hire CoDevs", + }, + { + id: "developer-journey", + title: "Be a Codev", + description: + "Join our community, sharpen your craft through real projects, and unlock career opportunities across the globe.", + linkText: "Join the Program", + }, + { + id: "careers", + title: "Career Opportunities", + description: + "Explore exciting career opportunities with our team. Join a dynamic environment where innovation meets collaboration.", + linkText: "View Careers", + }, +] as const; + +export function LandingWorkWithUsSkeleton() { + return ( +
+
+ + Work With Us + +

+ Build meaningful products with a partner that keeps momentum high. +

+

+ Whether you need a seasoned project team, specialist talent, or a + launchpad for your own journey, Codebility brings world-class execution, + mentorship, and community to every collaboration. +

+
+ +
+ {WORK_WITH_US_CARDS.map((card, index) => ( +
+
+
+ + + {String(index + 1).padStart(2, "0")} + +
+
+

{card.title}

+

+ {card.description} +

+
+
+ {card.linkText} +
+
+
+
+
+ ))} +
+
+ ); +} + +export function LandingPartnersSkeleton() { + return ( +
+
+

+ Our Partners +

+

+ Meet Our Trusted Partners +

+
+
+ {Array.from({ length: 12 }).map((_, i) => ( +
+ +
+ ))} +
+
+ ); +} diff --git a/apps/codebility/app/(marketing)/_components/landing/LandingServicesCard.tsx b/apps/codebility/app/(marketing)/_components/landing/LandingServicesCard.tsx index 4d12fab74..9e94fef11 100644 --- a/apps/codebility/app/(marketing)/_components/landing/LandingServicesCard.tsx +++ b/apps/codebility/app/(marketing)/_components/landing/LandingServicesCard.tsx @@ -1,5 +1,5 @@ import React from "react"; -import Image from "next/image"; +import LandingImage from "./LandingImage"; import { cn } from "@codevs/ui"; @@ -26,12 +26,11 @@ const ServicesCard: React.FC = ({ )} >
- {imageAlt}
diff --git a/apps/codebility/app/(marketing)/_components/landing/LandingWhyChoose-us.tsx b/apps/codebility/app/(marketing)/_components/landing/LandingWhyChoose-us.tsx index 334dae19b..2343250b4 100644 --- a/apps/codebility/app/(marketing)/_components/landing/LandingWhyChoose-us.tsx +++ b/apps/codebility/app/(marketing)/_components/landing/LandingWhyChoose-us.tsx @@ -1,123 +1,15 @@ "use client"; -import Image from "next/image"; -import { motion, useInView, useMotionValue, useSpring, useTransform } from "framer-motion"; -import { useRef, useEffect, useState } from "react"; +import LandingImage from "./LandingImage"; +import { motion } from "framer-motion"; import Container from "../MarketingContainer"; import Section from "../MarketingSection"; - -// Animated counter component for stats -const AnimatedStat = ({ - value, - suffix = "", - prefix = "", - label, - delay = 0 -}: { - value: number; - suffix?: string; - prefix?: string; - label: string; - delay?: number; -}) => { - const ref = useRef(null); - const isInView = useInView(ref, { once: true, amount: 0.5 }); - const [displayValue, setDisplayValue] = useState(0); - - useEffect(() => { - if (isInView) { - const timer = setTimeout(() => { - const duration = 2500; - const steps = 80; - let current = 0; - let step = 0; - - const counter = setInterval(() => { - step++; - current = (step / steps) * value; - - if (step >= steps) { - setDisplayValue(value); - clearInterval(counter); - } else { - setDisplayValue(current); - } - }, duration / steps); - - return () => clearInterval(counter); - }, delay); - - return () => clearTimeout(timer); - } - }, [isInView, value, delay]); - - return ( - - - {prefix}{Math.floor(displayValue)}{suffix} - - - {label} - - - ); -}; - -// Animation variants -const containerVariants = { - hidden: { opacity: 0 }, - visible: { - opacity: 1, - transition: { - staggerChildren: 0.2, - delayChildren: 0.1, - }, - }, -}; - -const cardVariants = { - hidden: { - opacity: 0, - y: 60, - scale: 0.9, - }, - visible: { - opacity: 1, - y: 0, - scale: 1, - transition: { - duration: 0.8, - ease: [0.25, 0.46, 0.45, 0.94], - }, - }, -}; +import AnimatedMetrics from "./AnimatedMetrics"; +import ProgressiveMotion from "./ProgressiveMotion"; +import { LandingWhyChooseSkeleton } from "./LandingSectionSkeletons"; const WhyChooseUs = () => { - const ref = useRef(null); - const isInView = useInView(ref, { once: true, amount: 0.2 }); - - // Stats data const stats = [ { value: 120, suffix: "+", label: "Projects Completed", delay: 200 }, { value: 98, suffix: "%", label: "Client Satisfaction", delay: 400 }, @@ -127,50 +19,38 @@ const WhyChooseUs = () => { return (
- -
- {/* Header with stats */} - - - Why Choose Codebility? - - - {/* Animated stats */} - - {stats.map((stat, index) => ( - - ))} - - - +
+ + + +
+
+ +
+ +

Why Choose Codebility?

+ +
+ {stats.map((stat, index) => ( + + ))} +
+
+ + - -
+
- innovative approach { className="h-[150px] w-[150px] object-contain lg:h-[300px] lg:w-[300px]" />
- +

Innovative Approach

@@ -191,17 +66,17 @@ const WhyChooseUs = () => { Embrace innovation with Codebility. Crafting revolutionary digital solutions that create new posibilites

- +
- +
- -
+
- shield { className="h-[150px] w-[150px] object-contain lg:h-[400px] lg:w-[400px]" />
- +

Reliable and Trusted

@@ -223,41 +93,37 @@ const WhyChooseUs = () => { industries, trusted for our reliability, consistency, and on-time delivery—your dependable digital partner.

- +
- +
- -
- codevs -
-
+ +
- -
+
- tailored { className="h-[150px] w-[150px] object-contain lg:h-[200px] lg:w-[200px]" /> - +

Customer - Centric Solution

@@ -278,15 +139,14 @@ const WhyChooseUs = () => { Understanding your vision and helping you bring your online vision to life.{" "}

- +
- +
- - {/* Animated gradient overlay */} { transition={{ duration: 4, repeat: Infinity, - ease: "easeInOut" + ease: "easeInOut", }} /> - - + +

Your Uniqueness is our focus - - - +

+
+ +
+
- +

); }; diff --git a/apps/codebility/app/(marketing)/_components/landing/LandingWhyChoose.tsx b/apps/codebility/app/(marketing)/_components/landing/LandingWhyChoose.tsx index df4c306af..31035776a 100644 --- a/apps/codebility/app/(marketing)/_components/landing/LandingWhyChoose.tsx +++ b/apps/codebility/app/(marketing)/_components/landing/LandingWhyChoose.tsx @@ -1,4 +1,4 @@ -import Image from "next/image"; +import LandingImage from "./LandingImage"; import Link from "next/link"; import { Button } from "@/components/ui/button"; @@ -30,7 +30,7 @@ const WhyChoose = () => {
- A guy sitting { ensure you get top notch results.

- Laptop Browsing {
- projects
@@ -123,13 +122,12 @@ const WhyChoose = () => {
- codevs
@@ -148,7 +146,7 @@ const WhyChoose = () => { > Hire a CoDev{" "} - arrow-right-icon ( -
+const WorkWithUsCard = ({ + card, + index, +}: { + card: (typeof WORK_WITH_US_CARDS)[number]; + index: number; +}) => ( +
- {card.image.alt}
@@ -86,10 +94,17 @@ const WorkWithUsCard = ({ card, index }: { card: (typeof WORK_WITH_US_CARDS)[num

{card.title}

-

{card.description}

+

+ {card.description} +

- @@ -103,29 +118,62 @@ const WorkWithUs = () => { return (
-
diff --git a/apps/codebility/app/(marketing)/_components/landing/ProgressiveMotion.tsx b/apps/codebility/app/(marketing)/_components/landing/ProgressiveMotion.tsx new file mode 100644 index 000000000..01e34ec12 --- /dev/null +++ b/apps/codebility/app/(marketing)/_components/landing/ProgressiveMotion.tsx @@ -0,0 +1 @@ +export { default } from "../../_shared/ProgressiveMotion"; diff --git a/apps/codebility/app/(marketing)/_components/landing/landing-motion-ready.ts b/apps/codebility/app/(marketing)/_components/landing/landing-motion-ready.ts new file mode 100644 index 000000000..7101e3c0b --- /dev/null +++ b/apps/codebility/app/(marketing)/_components/landing/landing-motion-ready.ts @@ -0,0 +1 @@ +export { markMarketingMotionReady as markLandingMotionReady } from "../../_shared/marketing-motion-ready"; diff --git a/apps/codebility/app/(marketing)/_components/landing/progressive-in-view.ts b/apps/codebility/app/(marketing)/_components/landing/progressive-in-view.ts new file mode 100644 index 000000000..aa30e51c5 --- /dev/null +++ b/apps/codebility/app/(marketing)/_components/landing/progressive-in-view.ts @@ -0,0 +1,54 @@ +"use client"; + +import { inView } from "framer-motion/dom"; + +const ALREADY_IN_VIEW_MS = 150; + +export function isElementIntersecting(element: HTMLElement) { + const rect = element.getBoundingClientRect(); + return ( + rect.bottom > 0 && + rect.top < window.innerHeight && + rect.right > 0 && + rect.left < window.innerWidth + ); +} + +export function attachProgressiveInView( + element: HTMLElement, + onEnter: (alreadyVisible: boolean) => void, + amount: number | "some" | "all" = 0.2, +) { + let cancelled = false; + let stopInView: (() => void) | undefined; + const startedAt = performance.now(); + + const attach = () => { + if (cancelled) return; + + if (isElementIntersecting(element)) { + onEnter(true); + return; + } + + stopInView = inView( + element, + (entry) => { + const immediate = + performance.now() - startedAt < ALREADY_IN_VIEW_MS || + entry.time - startedAt < ALREADY_IN_VIEW_MS; + onEnter(immediate); + }, + { amount }, + ); + }; + + requestAnimationFrame(() => { + requestAnimationFrame(attach); + }); + + return () => { + cancelled = true; + stopInView?.(); + }; +} diff --git a/apps/codebility/app/(marketing)/_components/marketing_modals/MarketingFaqsModal.tsx b/apps/codebility/app/(marketing)/_components/marketing_modals/MarketingFaqsModal.tsx index 9abc96c3e..5bbfc1eaa 100644 --- a/apps/codebility/app/(marketing)/_components/marketing_modals/MarketingFaqsModal.tsx +++ b/apps/codebility/app/(marketing)/_components/marketing_modals/MarketingFaqsModal.tsx @@ -1,7 +1,7 @@ "use client"; import React, { ReactNode, useState } from "react"; -import { useModal } from "@/hooks/use-modal"; +import { useModal } from "@/hooks/modals/use-modal"; import { Dialog, DialogContent, DialogTitle } from "@codevs/ui"; diff --git a/apps/codebility/app/(marketing)/_components/marketing_modals/MarketingPrivacyPolicyModal.tsx b/apps/codebility/app/(marketing)/_components/marketing_modals/MarketingPrivacyPolicyModal.tsx index 9a75ce620..7ce00b590 100644 --- a/apps/codebility/app/(marketing)/_components/marketing_modals/MarketingPrivacyPolicyModal.tsx +++ b/apps/codebility/app/(marketing)/_components/marketing_modals/MarketingPrivacyPolicyModal.tsx @@ -2,7 +2,7 @@ import React, { useState } from "react"; import Link from "next/link"; -import { useModal } from "@/hooks/use-modal"; +import { useModal } from "@/hooks/modals/use-modal"; import { Dialog, DialogContent, DialogTitle } from "@codevs/ui"; diff --git a/apps/codebility/app/(marketing)/_components/marketing_modals/MarketingTermsAndConditionModal.tsx b/apps/codebility/app/(marketing)/_components/marketing_modals/MarketingTermsAndConditionModal.tsx index 0b9dd210f..9597ef6eb 100644 --- a/apps/codebility/app/(marketing)/_components/marketing_modals/MarketingTermsAndConditionModal.tsx +++ b/apps/codebility/app/(marketing)/_components/marketing_modals/MarketingTermsAndConditionModal.tsx @@ -2,7 +2,7 @@ import React, { useState } from "react"; import Link from "next/link"; -import { useModal } from "@/hooks/use-modal"; +import { useModal } from "@/hooks/modals/use-modal"; import { Dialog, DialogContent, DialogTitle } from "@codevs/ui/dialog"; diff --git a/apps/codebility/app/(marketing)/_components/testimonial/Testimonials.tsx b/apps/codebility/app/(marketing)/_components/testimonial/Testimonials.tsx index 2d99c2b10..64ead8a1a 100644 --- a/apps/codebility/app/(marketing)/_components/testimonial/Testimonials.tsx +++ b/apps/codebility/app/(marketing)/_components/testimonial/Testimonials.tsx @@ -1,21 +1,8 @@ -"use client"; +import { Suspense } from "react"; +import { unstable_cache } from "next/cache"; +import { createClientAnon } from "@/utils/supabase/anon"; -import type { SupabaseClient } from "@supabase/supabase-js"; -import React, { use, useEffect, useState } from "react"; -import Image from "next/image"; -import { Card, CardContent } from "@/components/ui/card"; -import { - Carousel, - CarouselContent, - CarouselItem, - CarouselNext, - CarouselPrevious, -} from "@/components/ui/carousel/carousel"; -import { createClientClientComponent } from "@/utils/supabase/client"; -import Autoplay from "embla-carousel-autoplay"; -import { Quote } from "lucide-react"; - -import BlueBg from "../landing/LandingBlueBg"; +import TestimonialsCarousel from "./TestimonialsCarousel"; export type ClientTestimonyType = { id: string; @@ -24,121 +11,33 @@ export type ClientTestimonyType = { company_logo: string; }; -export default function Testimonials() { - const [supabase, setSupabase] = useState(null); - const [testimonials, setTestimonials] = useState([]); - - useEffect(() => { - const supabaseClient = createClientClientComponent(); - setSupabase(supabaseClient); - }, []); - - useEffect(() => { - const fetchTestimonials = async () => { - const { data, error } = await supabase - .from("clients") - .select("id, testimony, name, company_logo") - .not("testimony", "is", null); - if (error) { - console.error("Error fetching client testimonials:", error); - return; - } - if (!data || data.length === 0) { - console.warn("No client testimonials found."); - return; - } - setTestimonials(data); - }; - if (supabase) { - fetchTestimonials(); - } - }, [supabase]); - - if (!testimonials || testimonials.length === 0) { - return null; - } - - return ( -
- -
-
-

- What Our Partners Say -

- {/*

- Discover why leading companies trust Codebility to deliver - exceptional digital solutions -

*/} -
- -
- - - {testimonials.map((testimonial, index) => ( - - - - ))} - - - {testimonials.length >= 3 && ( - <> - - - - )} - -
-
-
- ); +const getLandingTestimonials = unstable_cache( + async () => { + const supabase = createClientAnon(); + const { data, error } = await supabase + .from("clients") + .select("id, testimony, name, company_logo") + // .not("testimony", "is", null) + // .not("company_logo", "is", null); + + if (error) return null; + return (data ?? []) as ClientTestimonyType[]; + }, + ["landing-testimonials"], + { revalidate: 3600, tags: ["landing-testimonials"] }, +); + +async function TestimonialsContent() { + const testimonials = await getLandingTestimonials(); + if (!testimonials || testimonials.length === 0) return null; + + return ; } -export function TestimonialCard({ client }: { client: ClientTestimonyType }) { +export default function Testimonials() { return ( - - -
-
- -
- -
- {client.testimony} -
- -
-
- {`${client.name} -
-
-

{client.name}

-
-
-
-
-
+ + + ); } diff --git a/apps/codebility/app/(marketing)/_components/testimonial/TestimonialsCarousel.tsx b/apps/codebility/app/(marketing)/_components/testimonial/TestimonialsCarousel.tsx new file mode 100644 index 000000000..fac07aa4f --- /dev/null +++ b/apps/codebility/app/(marketing)/_components/testimonial/TestimonialsCarousel.tsx @@ -0,0 +1,131 @@ +"use client"; + +import Image from "next/image"; +import { Card, CardContent } from "@/components/ui/card"; +import { + Carousel, + CarouselContent, + CarouselItem, + CarouselNext, + CarouselPrevious, +} from "@/components/ui/carousel/carousel"; +import Autoplay from "embla-carousel-autoplay"; +import { Quote } from "lucide-react"; + +import MarketingProgressiveSection from "../../_shared/MarketingProgressiveSection"; +import ProgressiveMotion from "../../_shared/ProgressiveMotion"; +import BlueBg from "../landing/LandingBlueBg"; +import type { ClientTestimonyType } from "./Testimonials"; + +export default function TestimonialsCarousel({ + testimonials, +}: { + testimonials: ClientTestimonyType[]; +}) { + const skeleton = ( +
+
+

+ What Our Partners Say +

+
+
+
+ {testimonials.slice(0, 3).map((testimonial) => ( + + ))} +
+
+
+ ); + + return ( +
+ + +
+ +

+ What Our Partners Say +

+
+ + +
+ + + {testimonials.map((testimonial) => ( + + + + ))} + + + {testimonials.length >= 3 && ( + <> + + + + )} + +
+
+
+
+
+ ); +} + +function TestimonialCard({ client }: { client: ClientTestimonyType }) { + return ( + + +
+
+ +
+ +
+ {client.testimony} +
+ +
+
+ {`${client.name} +
+
+

{client.name}

+
+
+
+
+
+ ); +} diff --git a/apps/codebility/app/(marketing)/_shared/CodevsAbout.tsx b/apps/codebility/app/(marketing)/_shared/CodevsAbout.tsx index 5b95eb1b7..b1c25c96f 100644 --- a/apps/codebility/app/(marketing)/_shared/CodevsAbout.tsx +++ b/apps/codebility/app/(marketing)/_shared/CodevsAbout.tsx @@ -1,14 +1,71 @@ +"use client"; + import Image from "next/image"; import H2 from "@/components/shared/home/H2"; import IntroText from "@/components/shared/home/IntroText"; import SectionWrapper from "@/components/shared/home/SectionWrapper"; import { services } from "@/constants/services"; -import { motion } from "framer-motion"; import moon from "public/assets/images/moon.png"; import AboutCard from "./CodevsAboutCard"; +import MarketingProgressiveSection from "./MarketingProgressiveSection"; +import ProgressiveMotion from "./ProgressiveMotion"; const AboutSection = () => { + const skeleton = ( +
+
+
+

What We Do

+ + Codebility sparks a passion for technology and innovation. Beyond + teaching coding, we immerse learners in the coding culture, + replicating real-world company standards. Our programs go beyond + skill acquisition, offering gateways to new horizons in the tech + industry. + +
+
+ {services.map((service, index) => ( +
+

{service.number}

+
+

{service.label}

+
+ ))} +
+
+
+
+
+ + +
+
+ + +
+
+
+
+ ); + return (
@@ -18,108 +75,89 @@ const AboutSection = () => { className="invisible absolute -left-72 -top-60 h-auto w-[400px] lg:visible" />
-
-
-
-

What We Do

- - Codebility sparks a passion for technology and innovation. Beyond - teaching coding, we immerse learners in the coding culture, - replicating real-world company standards. Our programs go beyond - skill acquisition, offering gateways to new horizons in the tech - industry. - -
- -
- {services.map((service, index) => ( -
-

{service.number}

-
-

{service.label}

-
- ))} -
+ +
+ +
+

What We Do

+ + Codebility sparks a passion for technology and innovation. Beyond + teaching coding, we immerse learners in the coding culture, + replicating real-world company standards. Our programs go beyond + skill acquisition, offering gateways to new horizons in the tech + industry. + +
- - We believe in the transformative power of coding. With Codebility, - you won{`'`}t just learn coding; you{`'`} - ll wield a tool for a brighter future. Join us in sculpting your - path as tomorrow{`'`}s digital architect. - -
-
-
-
- - - +
+ {services.map((service, index) => ( +
+

{service.number}

+
+

{service.label}

+
+ ))} +
- - - +
+ + We believe in the transformative power of coding. With Codebility, + you won't just learn coding; you'll wield a tool for a + brighter future. Join us in sculpting your path as tomorrow's + digital architect. +
+ -
- - - +
+ +
+
+ +
+
+ +
+
- - - -
+
+
+ +
+
+ +
+
+
-
+ ); }; diff --git a/apps/codebility/app/(marketing)/_shared/CodevsCta.tsx b/apps/codebility/app/(marketing)/_shared/CodevsCta.tsx index de399515d..b126014d4 100644 --- a/apps/codebility/app/(marketing)/_shared/CodevsCta.tsx +++ b/apps/codebility/app/(marketing)/_shared/CodevsCta.tsx @@ -1,39 +1,79 @@ -import React from "react"; -import Image from "next/image"; -import Link from "next/link"; +import Image from "next/image";import Link from "next/link"; import { H2, Paragraph } from "@/components/shared/home"; import { Button } from "@/components/ui/button"; -import pathsConfig from "@/config/paths.config"; +import pathsConfig from "@/types/zod/paths.config"; +import MarketingProgressiveSection from "./MarketingProgressiveSection"; +import ProgressiveMotion from "./ProgressiveMotion"; import Section from "./CodevsSection"; -export default async function CTA() { +export default function CTA() { + const skeleton = ( +
+ Codebility Devices +

+ Become A Codev! +

+ + Unlock your potential and embark on a journey of innovation and mastery + with Codebility. + + + + +
+ ); + return (
-
- Codebility Devices -

- Become A Codev! -

- - - Unlock your potential and embark on a journey of innovation and - mastery with Codebility. - - - - - -
+ + +
+ Codebility Devices +
+

+ Become A Codev! +

+ + Unlock your potential and embark on a journey of innovation and + mastery with Codebility. + +
+ + + +
+
+
); } diff --git a/apps/codebility/app/(marketing)/_shared/CodevsFeaturedCection.tsx b/apps/codebility/app/(marketing)/_shared/CodevsFeaturedCection.tsx index 392078776..cbdfab529 100644 --- a/apps/codebility/app/(marketing)/_shared/CodevsFeaturedCection.tsx +++ b/apps/codebility/app/(marketing)/_shared/CodevsFeaturedCection.tsx @@ -1,72 +1,95 @@ "use client"; -import { Inter, Outfit } from "next/font/google"; import Link from "next/link"; -import { fadeInOutDownToUp } from "@/components/FramerAnimation/Framer"; import H2 from "@/components/shared/home/H2"; import Heading3 from "@/components/shared/home/Heading3"; import { Button } from "@/components/ui/button"; -import { motion } from "framer-motion"; import { FeaturedSectiondata } from "@/constants/landing_data"; import FeaturedCard from "./CodevsFeaturedCard"; +import MarketingProgressiveSection from "./MarketingProgressiveSection"; +import ProgressiveMotion from "./ProgressiveMotion"; import Section from "./CodevsSection"; -/* const inter = Inter({ - subsets: ["latin"], - display: "swap", - }); - - const outfit = Outfit({ - subsets: ["latin"], - display: "swap", - }); */ - export const inter = { className: "font-sans" }; export const outfit = { className: "font-sans" }; const FeaturedSection = () => { + const skeleton = ( +
+
+
+

WHO WE ARE

+
+
+ At Codebility we're +

+ MORE THAN JUST A COMMUNITY +

+
+
+
+ {FeaturedSectiondata.map((data, index) => ( + + ))} +
+
+ ); + return (
-
-
- -

WHO WE ARE

-
- +
+ +
+

WHO WE ARE

+
+
+ At Codebility we're +

+ MORE THAN JUST A COMMUNITY +

+
+
+ + - At Codebility we're -

- MORE THAN JUST A COMMUNITY -

- -
-
- {FeaturedSectiondata.map((data, index) => ( - - ))} + {FeaturedSectiondata.map((data, index) => ( +
+ +
+ ))} + + + +
+ + + +
+
- - - -
+
); }; -export default CodevsRoadmapStatic; \ No newline at end of file +export default CodevsRoadmapStatic; diff --git a/apps/codebility/app/(marketing)/codevs/_components/CodevsServiceCard.tsx b/apps/codebility/app/(marketing)/codevs/_components/CodevsServiceCard.tsx index f406f18fc..73de2583c 100644 --- a/apps/codebility/app/(marketing)/codevs/_components/CodevsServiceCard.tsx +++ b/apps/codebility/app/(marketing)/codevs/_components/CodevsServiceCard.tsx @@ -1,6 +1,6 @@ import Image from "next/image"; import { fadeInOutLeftToRight } from "@/components/FramerAnimation/Framer"; -import { useModal } from "@/hooks/use-modal-services"; +import { useModal } from "@/hooks/modals/use-modal-services"; import { IconArrowRight } from "@/public/assets/svgs"; import { motion } from "framer-motion"; diff --git a/apps/codebility/app/(marketing)/codevs/page.tsx b/apps/codebility/app/(marketing)/codevs/page.tsx index 53cb630e2..578c50cb2 100644 --- a/apps/codebility/app/(marketing)/codevs/page.tsx +++ b/apps/codebility/app/(marketing)/codevs/page.tsx @@ -1,14 +1,12 @@ import { Metadata } from "next"; -import Footer from "../_components/MarketingFooter"; -import Navigation from "../_components/MarketingNavigation"; import FeaturedSection from "../_shared/CodevsFeaturedCection"; +import CodevsFeaturedProjectsSection from "../_shared/CodevsFeaturedProjectsSection"; +import CodevsProfiles from "../_shared/CodevsProfiles"; +import CTA from "../_shared/CodevsCta"; import Hero from "./_components/CodevsHero"; -import Project from "./_components/CodevsProject"; import CodevsRoadmapStatic from "./_components/CodevsRoadmapStatic"; import MissionVision from "./_components/CodevsMissionVision"; -import CTA from "../_shared/CodevsCta"; -import CodevsProfiles from "./_components/CodevsProfiles"; export const metadata: Metadata = { title: "Our Developers — Meet the Codebility Team", @@ -28,21 +26,16 @@ export const metadata: Metadata = { }, }; -export const dynamic = "force-dynamic"; -export const revalidate = 0; - export default function Codevs() { return (
- - + -
); } \ No newline at end of file diff --git a/apps/codebility/app/(marketing)/contact/_components/ContactAppointment.tsx b/apps/codebility/app/(marketing)/contact/_components/ContactAppointment.tsx index c50b38ee0..d4641114b 100644 --- a/apps/codebility/app/(marketing)/contact/_components/ContactAppointment.tsx +++ b/apps/codebility/app/(marketing)/contact/_components/ContactAppointment.tsx @@ -2,7 +2,7 @@ import { useState } from "react"; import { Button } from "@/components/ui/button"; -import { ContactFormData } from "../page"; +import { ContactFormData } from "./ContactPage"; const meetingTypes = [ { value: "zoom", label: "Zoom", sub: "Via Zoom meeting link" }, diff --git a/apps/codebility/app/(marketing)/contact/_components/ContactInquiryInform.tsx b/apps/codebility/app/(marketing)/contact/_components/ContactInquiryInform.tsx index eae3e79f7..3a1ccddaf 100644 --- a/apps/codebility/app/(marketing)/contact/_components/ContactInquiryInform.tsx +++ b/apps/codebility/app/(marketing)/contact/_components/ContactInquiryInform.tsx @@ -13,7 +13,7 @@ import { SelectValue, } from "@radix-ui/react-select"; import { IconDropdown } from "@/public/assets/svgs"; -import { ContactFormData } from "../page"; +import { ContactFormData } from "./ContactPage"; const industries = [ "Technology", "Healthcare", "Finance", diff --git a/apps/codebility/app/(marketing)/contact/_components/ContactPage.tsx b/apps/codebility/app/(marketing)/contact/_components/ContactPage.tsx index fbfcfa310..749e95548 100644 --- a/apps/codebility/app/(marketing)/contact/_components/ContactPage.tsx +++ b/apps/codebility/app/(marketing)/contact/_components/ContactPage.tsx @@ -10,6 +10,8 @@ import { import InquiryForm from "./ContactInquiryInform"; import ShortSurvey from "./ContactShortSurvey"; import Appointment from "./ContactAppointment"; +import MarketingProgressiveSection from "../../_shared/MarketingProgressiveSection"; +import ProgressiveMotion from "../../_shared/ProgressiveMotion"; const steps = [ { label: "Inquiry Form", icon: }, @@ -51,12 +53,39 @@ const ContactPage = () => { const progressWidth = `${(currentStep / (steps.length - 1)) * 100}%`; + const skeleton = ( +
+
+
+ {steps.map((step, i) => ( +
+
+ {step.icon} +
+

+ {step.label} +

+
+ ))} +
+
+
+
+
+ ); + return (
-
+ + {/* Stepper */} -
+
{/* Connector: circle 1 → circle 2 */}
0 ? "bg-violet-600" : "bg-white/5" @@ -110,7 +139,7 @@ const ContactPage = () => {
{/* Progress Bar */} -
+
{
{/* Step Panels */} +
{currentStep === 0 && ( { onBack={goBack} /> )} +
{/* Security note */} -
+
@@ -149,7 +180,8 @@ const ContactPage = () => {

-
+ +
); }; diff --git a/apps/codebility/app/(marketing)/contact/_components/ContactShortSurvey.tsx b/apps/codebility/app/(marketing)/contact/_components/ContactShortSurvey.tsx index 286a59ddb..d85fbcf14 100644 --- a/apps/codebility/app/(marketing)/contact/_components/ContactShortSurvey.tsx +++ b/apps/codebility/app/(marketing)/contact/_components/ContactShortSurvey.tsx @@ -10,7 +10,7 @@ import { } from "@radix-ui/react-select"; import { Label } from "@codevs/ui/label"; import { Textarea } from "@codevs/ui/textarea"; -import { ContactFormData } from "../page"; +import { ContactFormData } from "./ContactPage"; const referralSources = [ "Search engine", "Social media", "Referral / Word of mouth", diff --git a/apps/codebility/app/(marketing)/hire-a-codev/_components/CodevsHero.tsx b/apps/codebility/app/(marketing)/hire-a-codev/_components/CodevsHero.tsx index 17cf9e1d9..8d4633044 100644 --- a/apps/codebility/app/(marketing)/hire-a-codev/_components/CodevsHero.tsx +++ b/apps/codebility/app/(marketing)/hire-a-codev/_components/CodevsHero.tsx @@ -1,11 +1,8 @@ -"use client"; - import { Rowdies } from "next/font/google"; import Link from "next/link"; import { Button } from "@/components/ui/button"; -import pathsConfig from "@/config/paths.config"; -import SideNavMenu from "../../_components/MarketingSidenavMenu"; +import MarketingProgressiveSection from "../../_shared/MarketingProgressiveSection"; import OrbitingCirclesBackground from "./CodevsOrbitingCirclesBg"; const rowdies = Rowdies({ @@ -13,46 +10,78 @@ const rowdies = Rowdies({ subsets: ["latin"], }); +const heroContentClassName = + "absolute inset-0 z-10 flex flex-col items-center justify-center gap-3 p-4 text-center text-white"; + export default function Hero() { + const skeleton = ( +
+

Find Your Next Developer

+
+

+ Codebility +

+

+ Hire Talented Codevs +

+
+

+ Connect with skilled developers ready to bring your projects to life +

+
+ + + +
+
+ ); + return (
-
+ /> -
-

Find Your Next Developer

-
-

- Codebility -

-

- Hire Talented Codevs -

+ +
+

Find Your Next Developer

+
+

+ Codebility +

+

+ Hire Talented Codevs +

+
+

+ Connect with skilled developers ready to bring your projects to life +

+
+ + + +
-

- Connect with skilled developers ready to bring your projects to life -

-
- - - -
-
+
{Array.from({ length: 8 }, (_, index) => ( diff --git a/apps/codebility/app/(marketing)/hire-a-codev/_components/CodevsNavbar.tsx b/apps/codebility/app/(marketing)/hire-a-codev/_components/CodevsNavbar.tsx deleted file mode 100644 index 1ec2527c0..000000000 --- a/apps/codebility/app/(marketing)/hire-a-codev/_components/CodevsNavbar.tsx +++ /dev/null @@ -1,172 +0,0 @@ -import Image from "next/image"; -import Link from "next/link"; -import { usePathname } from "next/navigation"; -import useUser from "@/app/home/_hooks/use-user"; -import Logo from "@/components/shared/Logo"; -import { Button } from "@/components/ui/button"; -import { navLinks } from "@/constants/navigation"; -import { useModal } from "@/hooks/use-modal"; -import useChangeBgNavigation from "@/hooks/useChangeBgNavigation"; -import useHideSidebarOnResize from "@/hooks/useHideSidebarOnResize"; -import { MenuIcon } from "lucide-react"; - -import { - Sheet, - SheetContent, - SheetHeader, - SheetTitle, - SheetTrigger, -} from "@codevs/ui/sheet"; - -const Navbar = () => { - const { color } = useChangeBgNavigation(); - const { isSheetOpen, setIsSheetOpen } = useHideSidebarOnResize(); - const { onOpen } = useModal(); - const pathname = usePathname(); - const isAuthenticated = !useUser(); // if there is logged user - - return ( - <> - - -
- - Codebility Logo - - - - - - - - - Navigation Menu -
Mobile navigation menu
-
- - Codebility Logo - -
- {navLinks.map((link, id) => ( -
- {pathname === link.href ? ( - {link.name} - ) : link.name === "Contact Us" ? ( - - ) : ( - - {link.name} - - )} -
- ))} -
-
- -
- - - - - - -
-
-
-
-
- - ); -}; - -export default Navbar; diff --git a/apps/codebility/app/(marketing)/hire-a-codev/_components/CodevsProject.tsx b/apps/codebility/app/(marketing)/hire-a-codev/_components/CodevsProject.tsx deleted file mode 100644 index 48f999050..000000000 --- a/apps/codebility/app/(marketing)/hire-a-codev/_components/CodevsProject.tsx +++ /dev/null @@ -1,132 +0,0 @@ -"use server"; - -import React from "react"; -import { createClientServerComponent } from "@/utils/supabase/server"; -import { EmblaOptionsType } from "embla-carousel"; - -import EmblaCarousel from "../../_shared/CodevsCarousel"; -import Section from "../../_shared/CodevsSection"; - -interface Project { - id: string; - name: string; - description?: string; - thumbnail?: string; - main_image?: string; - status?: string; -} - -export default async function ProjectSection() { - const supabase = await createClientServerComponent(); - - // First, check if we can get ANY projects (without filters) - const { data: allProjects, error: allProjectsError } = await supabase - .from("projects") - .select("id, name, description, main_image, status") - .order("created_at", { ascending: false }) - .limit(10); - - // Then, try with the status filter - const { data: activeProjects, error } = await supabase - .from("projects") - .select("id, name, description, main_image, status") - .order("created_at", { ascending: false }); - - if (error || allProjectsError || !activeProjects || !allProjects) { - console.error("Error loading projects:", error); - return ( -
-
-

- Our Featured -

-

internal

-

- Projects -

-
-
- Error loading projects. Please try again. -
-
- ); - } - - const OPTIONS: EmblaOptionsType = { - loop: true, - align: "center", - containScroll: "trimSnaps", - }; - - // Process the main_image URLs and create slides array - const slides: string[] = []; - const projectsWithImages: (Project & { imageUrl: string })[] = []; - - if (activeProjects && activeProjects.length > 0) { - for (const project of activeProjects) { - let imageUrl = ''; - - if (project.main_image && project.main_image.trim()) { - imageUrl = project.main_image.trim(); - - // Transform URL if needed - handle various URL formats - if (imageUrl.startsWith("public/")) { - // Handle public/ prefix - imageUrl = `${process.env.NEXT_PUBLIC_SUPABASE_URL}/storage/v1/object/public/services-image/${imageUrl}`; - } else if (imageUrl.startsWith("public")) { - // Handle public prefix without slash - imageUrl = `${process.env.NEXT_PUBLIC_SUPABASE_URL}/storage/v1/object/public/services-image/${imageUrl}`; - } else if (!imageUrl.startsWith("http")) { - // If it's a relative path, construct the full URL - imageUrl = `${process.env.NEXT_PUBLIC_SUPABASE_URL}/storage/v1/object/public/services-image/${imageUrl}`; - } - - // Validate the URL before adding to carousel - try { - new URL(imageUrl); - } catch (error) { - // Use fallback image for invalid URLs - imageUrl = '/assets/images/index/projects-large.jpg'; - } - } else { - // Use fallback image for projects without images - imageUrl = '/assets/images/index/projects-large.jpg'; - } - - // Store both the image URL and project data - projectsWithImages.push({ - ...project, - imageUrl, - }); - - // Add to slides for the carousel - slides.push(imageUrl); - } - } - - return ( -
-
-

- Our Featured -

-

internal

-

- Projects -

-
- - {slides.length > 0 ? ( - - ) : ( -
-

No projects available

-

- (Found {allProjects?.length || 0} total projects,{" "} - {activeProjects?.length || 0} active projects) -

-
- )} -
- ); -} \ No newline at end of file diff --git a/apps/codebility/app/(marketing)/hire-a-codev/_components/CodevsRoadmap.tsx b/apps/codebility/app/(marketing)/hire-a-codev/_components/CodevsRoadmap.tsx index 33bc897c2..118e45246 100644 --- a/apps/codebility/app/(marketing)/hire-a-codev/_components/CodevsRoadmap.tsx +++ b/apps/codebility/app/(marketing)/hire-a-codev/_components/CodevsRoadmap.tsx @@ -1,7 +1,7 @@ import { SectionWrapper } from "@/components/shared/home"; -import { roadmapData } from "../_lib/dummy-data"; -import RoadmapCard from "./CodevsRoadmapCard"; +import { roadmapData } from "@/constants/hire-a-codev/roadmap-data"; +import RoadmapCard from "@/components/marketing/CodevsRoadmapCard"; // Define the type for the roadmap step interface RoadmapStep { diff --git a/apps/codebility/app/(marketing)/hire-a-codev/_components/CodevsServiceCard.tsx b/apps/codebility/app/(marketing)/hire-a-codev/_components/CodevsServiceCard.tsx index f406f18fc..73de2583c 100644 --- a/apps/codebility/app/(marketing)/hire-a-codev/_components/CodevsServiceCard.tsx +++ b/apps/codebility/app/(marketing)/hire-a-codev/_components/CodevsServiceCard.tsx @@ -1,6 +1,6 @@ import Image from "next/image"; import { fadeInOutLeftToRight } from "@/components/FramerAnimation/Framer"; -import { useModal } from "@/hooks/use-modal-services"; +import { useModal } from "@/hooks/modals/use-modal-services"; import { IconArrowRight } from "@/public/assets/svgs"; import { motion } from "framer-motion"; diff --git a/apps/codebility/app/(marketing)/hire-a-codev/_components/HiringProcess.tsx b/apps/codebility/app/(marketing)/hire-a-codev/_components/HiringProcess.tsx index e466bfc6e..80bb038bb 100644 --- a/apps/codebility/app/(marketing)/hire-a-codev/_components/HiringProcess.tsx +++ b/apps/codebility/app/(marketing)/hire-a-codev/_components/HiringProcess.tsx @@ -2,10 +2,12 @@ import { useEffect, useRef } from "react"; import { motion } from "framer-motion"; -import { fadeInOutDownToUp } from "@/components/FramerAnimation/Framer"; import { SectionWrapper } from "@/components/shared/home"; import { User, MessageSquare, UserCheck, Handshake } from "lucide-react"; +import MarketingProgressiveSection from "../../_shared/MarketingProgressiveSection"; +import ProgressiveMotion from "../../_shared/ProgressiveMotion"; + // ─── Static per-card color classes (dynamic Tailwind classes fail at build) ─── // Each card gets explicit static classes instead of `text-${color}` interpolation. const hiringSteps = [ @@ -251,80 +253,93 @@ const HiringProcess = () => {
{/* Call to Action */} - -
- - {/* Ambient animated glow background */} -
- + +
+
+
+

+ Ready to Build Your Dream Team? +

+

+ Start your hiring journey today. Get matched with skilled developers who can bring your vision to life. +

+
+ +
+
+ } + > + +
+
+ +
-
- - {/* Column 1: Text */} - -

- Ready to Build Your Dream Team? -

-

- Start your hiring journey today. Get matched with skilled developers who can bring your vision to life. -

-
+
+
+

+ Ready to Build Your Dream Team? +

+

+ Start your hiring journey today. Get matched with skilled developers who can bring your vision to life. +

+
- {/* Column 2: Buttons */} - - - Start Hiring - - - Schedule a Call - - + + Start Hiring + + + Schedule a Call + +
+
-
-
+ +
{/* Background decoration (unchanged from original) */} diff --git a/apps/codebility/app/(marketing)/hire-a-codev/page.tsx b/apps/codebility/app/(marketing)/hire-a-codev/page.tsx index 8e2125617..fcda19eac 100644 --- a/apps/codebility/app/(marketing)/hire-a-codev/page.tsx +++ b/apps/codebility/app/(marketing)/hire-a-codev/page.tsx @@ -1,11 +1,10 @@ import { Metadata } from "next"; -import Footer from "../_components/MarketingFooter"; -import Navigation from "../_components/MarketingNavigation"; -import Profiles from "../profiles/page"; import FeaturedSection from "../_shared/CodevsFeaturedCection"; +import CodevsFeaturedProjectsSection from "../_shared/CodevsFeaturedProjectsSection"; +import CodevsProfiles from "../_shared/CodevsProfiles"; +import { CodevHireCodevModal } from "../profiles/_components/CodevHireCodevModal"; import Hero from "./_components/CodevsHero"; -import Project from "./_components/CodevsProject"; import HiringProcess from "./_components/HiringProcess"; export const metadata: Metadata = { @@ -26,19 +25,15 @@ export const metadata: Metadata = { }, }; -export const dynamic = "force-dynamic"; -export const revalidate = 0; - export default function HireACodev() { return (
- - + - -
+ +
); -} \ No newline at end of file +} diff --git a/apps/codebility/app/(marketing)/layout.tsx b/apps/codebility/app/(marketing)/layout.tsx index c20c3f822..94c32fad6 100644 --- a/apps/codebility/app/(marketing)/layout.tsx +++ b/apps/codebility/app/(marketing)/layout.tsx @@ -1,6 +1,7 @@ -"use server"; - import { ModalProviderMarketing } from "@/components/providers/modal-provider-marketing"; +import Footer from "./_components/MarketingFooter"; +import Navigation from "./_components/MarketingNavigation"; +import SideNavMenu from "./_components/MarketingSidenavMenu"; export default async function MarketingLayout({ children, @@ -8,11 +9,12 @@ export default async function MarketingLayout({ children: React.ReactNode; }) { return ( -
-
+
+ + {children} +
-
); } diff --git a/apps/codebility/app/(marketing)/page.tsx b/apps/codebility/app/(marketing)/page.tsx index 11d76954e..831682b42 100644 --- a/apps/codebility/app/(marketing)/page.tsx +++ b/apps/codebility/app/(marketing)/page.tsx @@ -1,6 +1,5 @@ import { Metadata } from "next"; -import ForceScrollTop from "./_components/landing/ForceScrollTop"; import Admins from "./_components/landing/LandingAdmins"; import Features from "./_components/landing/LandingFeatures"; import Hero from "./_components/landing/LandingHero"; @@ -9,45 +8,41 @@ import Partners from "./_components/landing/LandingPartners"; import WhyChooseUs from "./_components/landing/LandingWhyChoose-us"; import WorkWithUs from "./_components/landing/LandingWorkWithUs"; import Calendly from "./_components/MarketingCalendly"; -import Footer from "./_components/MarketingFooter"; -import Navigation from "./_components/MarketingNavigation"; -import Testimonials from "./_components/testimonial/Testimonials"; export const metadata: Metadata = { + title: "Codebility — Hire Skilled Filipino Developers", + description: + "Codebility connects you with vetted Filipino software developers. Build your team faster with top-tier tech talent.", + alternates: { canonical: "https://www.codebility.tech" }, + openGraph: { title: "Codebility — Hire Skilled Filipino Developers", - description: "Codebility connects you with vetted Filipino software developers. Build your team faster with top-tier tech talent.", - alternates: { canonical: "https://www.codebility.tech" }, - openGraph: { - title: "Codebility — Hire Skilled Filipino Developers", - description: "Codebility connects you with vetted Filipino software developers. Build your team faster with top-tier tech talent.", - url: "https://www.codebility.tech", - images: [{ url: "/og-image.jpg", width: 1200, height: 630, alt: "Codebility" }], - }, - twitter: { - card: "summary_large_image", - title: "Codebility — Hire Skilled Filipino Developers", - description: "Codebility connects you with vetted Filipino software developers.", - images: ["/og-image.jpg"], - }, + description: + "Codebility connects you with vetted Filipino software developers. Build your team faster with top-tier tech talent.", + url: "https://www.codebility.tech", + images: [ + { url: "/og-image.jpg", width: 1200, height: 630, alt: "Codebility" }, + ], + }, + twitter: { + card: "summary_large_image", + title: "Codebility — Hire Skilled Filipino Developers", + description: + "Codebility connects you with vetted Filipino software developers.", + images: ["/og-image.jpg"], + }, }; -export const dynamic = "force-dynamic"; -export const revalidate = 0; - -export default async function Index() { - return ( -
- - - - - - - - - - -
-
- ); -} \ No newline at end of file +export default function Index() { + return ( + <> + + + + + + + + + + ); +} diff --git a/apps/codebility/app/(marketing)/privacy-policy/_components/PrivacyPolicyContent.tsx b/apps/codebility/app/(marketing)/privacy-policy/_components/PrivacyPolicyContent.tsx new file mode 100644 index 000000000..ee1814d13 --- /dev/null +++ b/apps/codebility/app/(marketing)/privacy-policy/_components/PrivacyPolicyContent.tsx @@ -0,0 +1,239 @@ +import Link from "next/link"; + +import MarketingProgressiveSection from "../../_shared/MarketingProgressiveSection"; +import ProgressiveMotion from "../../_shared/ProgressiveMotion"; + +export default function PrivacyPolicyContent() { + const skeleton = ( +
+

Privacy Policy

+

+ Thank you for choosing to be part of our community at Codebility. We are + committed to protecting your personal information and your right to + privacy. +

+
+ ); + + return ( +
+
+ + +

+ Privacy Policy +

+

+ Thank you for choosing to be part of our community at Codebility + ("Company", "we", "us", or + "our"). We are committed to protecting your personal + information and your right to privacy. If you have any questions + or concerns about our policy or our practices with regard to your + personal information, please contact us at{" "} + + codebility.dev@gmail.com + + . When you visit our website and use our services, you trust us + with your personal information. +

+

+ We take your privacy very seriously. In this privacy policy, we + describe our privacy policy. We seek to explain to you in the + clearest way possible what information we collect, how we use it, + and what rights you have in relation to it. We hope you take some + time to read through it carefully, as it is important. If there + are any terms in this privacy policy that you do not agree with, + please discontinue the use of our Sites and our services. +

+

+ This privacy policy applies to all information collected through + our website ( + https://www.codebility.tech/), and/or any + related services, sales, marketing, or events (we refer to them + collectively in this privacy policy as the "Services"). +

+

+ Please read this privacy policy carefully as it will help you make + informed decisions about sharing your personal information with us. +

+
    +
  1. +

    + What information do we collect? +

    +

    + Personal information you disclose to us +

    +

    + In Short: We collect personal information that you provide to + us. We collect personal information that you voluntarily + provide to us when you register on the Services, express an + interest in obtaining information about us or our products and + Services, when you participate in activities on the Services or + otherwise when you contact us. The personal information that + we collect depends on the context of your interactions with us + and the Services, the choices you make and the products and + features you use. The personal information we collect may + include the following:- Personal Information Provided by You. + We collect names; phone numbers; email addresses; usernames; + passwords; contact preferences; contact or authentication data; + and other similar information. +

    +
  2. +
  3. +

    + How do we use your information? +

    +

    + In Short: We process your information for purposes based on + legitimate business interests, the fulfillment of our contract + with you, compliance with our legal obligations, and/or your + consent. We use personal information collected via our Services + for a variety of business purposes described below. We process + your personal information for these purposes in reliance on + our legitimate business interests, in order to enter into or + perform a contract with you, with your consent, and/or for + compliance with our legal obligations. We indicate the + specific processing grounds we rely on next to each purpose + listed below. We use the information we collect or receive: - + To facilitate account creation and logon process. If you + choose to link your account with us to a third-party account + (such as your Google or Facebook account), we use the + information you allowed us to collect from those third + parties to facilitate account creation and logon process for + the performance of the contract. +

    +
  4. +
  5. +

    + Will your information be shared with anyone? +

    +

    + In Short: We only share information with your consent, to + comply with laws, to provide you with services, to protect your + rights, or to fulfill business obligations. We may process or + share your data that we hold based on the following legal + basis: - Consent: We may process your data if you have given + us specific consent to use your personal information in a + specific purpose. +

    +
  6. +
  7. +

    + Who will your information be shared with? +

    +

    + In Short: We only share information with the following third + parties. We only share and disclose your information with the + following third parties. We have categorized each party so + that you may be easily understand the purpose of our data + collection and processing practices.-{" "} + + Advertising, Direct Marketing, and Lead Generation - + Communicate and Chat with Users - Content Optimization - + User Account Registration and Authentication. + +

    +
  8. +
  9. +

    + Do we use cookies and other tracking technologies? +

    +

    + In Short: We may use cookies and other tracking technologies + to collect and store your information. We may use cookies and + similar tracking technologies (like web beacons and pixels) to + access or store information. Specific information about how we + use such technologies and how you can refuse certain cookies + is set out in our Cookie Policy. +

    +
  10. +
  11. +

    + How long do we keep your information? +

    +

    + In Short: We keep your information for as long as necessary to + fulfill the purposes outlined in this privacy policy unless + otherwise required by law. We will only keep your personal + information for as long as it is necessary for the purposes + set out in this privacy policy, unless a longer retention + period is required or permitted by law (such as tax, + accounting or other legal requirements). No purpose in this + policy will require us keeping your personal information for + longer than the period of time in which users have an account + with us. +

    +
  12. +
  13. +

    + How do we keep your information safe? +

    +

    + In Short: We aim to protect your personal information through a + system of organizational and technical security measures. We + have implemented appropriate technical and organizational + security measures designed to protect the security of any + personal information we process. However, please also remember + that we cannot guarantee that the internet itself is 100% + secure. Although we will do our best to protect your personal + information, transmission of personal information to and from + our Services is at your own risk. You should only access the + services within a secure environment. +

    +
  14. +
  15. +

    + Do we collect information from minors? +

    +

    + In Short: We do not knowingly collect data from or market to + children under 18 years of age. We do not knowingly solicit + data from or market to children under 18 years of age. By + using the Services, you represent that you are at least 18 or + that you are the parent or guardian of such a minor and + consent to such minor dependent's use of the Services. If + we learn that personal information from users less than 18 + years of age has been collected, we will deactivate the + account and take reasonable measures to promptly delete such + data from our records. If you become aware of any data we have + collected from children under age 18, please contact us at{" "} + + codebility.dev@gmail.com + + . +

    +
  16. +
  17. +

    What are your privacy rights?

    +

    + In Short: You may review, change, or terminate your account at + any time. +

    +

    + You have certain rights under applicable data protection laws. + These may include the right (i) to request access and obtain a + copy of your personal information, (ii) to request + rectification or erasure; (iii) to restrict the processing of + your personal information. +

    +
  18. +
+

Last Updated: March 2024

+
+
+
+
+ ); +} diff --git a/apps/codebility/app/(marketing)/privacy-policy/page.tsx b/apps/codebility/app/(marketing)/privacy-policy/page.tsx index b2f7d6c19..0af0e5e04 100644 --- a/apps/codebility/app/(marketing)/privacy-policy/page.tsx +++ b/apps/codebility/app/(marketing)/privacy-policy/page.tsx @@ -1,5 +1,6 @@ import { Metadata } from "next"; -import Link from "next/link"; + +import PrivacyPolicyContent from "./_components/PrivacyPolicyContent"; export const metadata: Metadata = { title: "Privacy Policy — Codebility", @@ -13,194 +14,6 @@ export const metadata: Metadata = { }, }; -const PrivacyPolicy = () => { - return ( - <> -
-
-
-

Privacy Policy

-

- Thank you for choosing to be part of our community at Codebility - ("Company", "we", "us", or - "our"). We are committed to protecting your personal - information and your right to privacy. If you have any questions - or concerns about our policy or our practices with regard to your - personal information, please contact us at{" "} - - codebility.dev@gmail.com - - . When you visit our website and use our services, you trust us - with your personal information. -

-

- We take your privacy very seriously. In this privacy policy, we - describe our privacy policy. We seek to explain to you in the - clearest way possible what information we collect, how we use it, - and what rights you have in relation to it. We hope you take some - time to read through it carefully, as it is important. If there - are any terms in this privacy policy that you do not agree with, - please discontinue the use of our Sites and our services. -

-

- This privacy policy applies to all information collected through - our website (https://www.codebility.tech/), - and/or any related services, sales, marketing, or events (we refer - to them collectively in this privacy policy as the - "Services"). -

-

- Please read this privacy policy carefully as it will help you make - informed decisions about sharing your personal information with us. -

-
    -
  1. -

    What information do we collect?

    -

    Personal information you disclose to us

    -

    - In Short: We collect personal information that you provide to - us. We collect personal information that you voluntarily - provide to us when you register on the Services, express an - interest in obtaining information about us or our products and - Services, when you participate in activities on the Services - or otherwise when you contact us. The personal information - that we collect depends on the context of your interactions - with us and the Services, the choices you make and the - products and features you use. The personal information we - collect may include the following:- Personal Information - Provided by You. We collect names; phone numbers; email - addresses; usernames; passwords; contact preferences; contact - or authentication data; and other similar information. -

    -
  2. -
  3. -

    How do we use your information?

    -

    - In Short: We process your information for purposes based on - legitimate business interests, the fulfillment of our contract - with you, compliance with our legal obligations, and/or your - consent. We use personal information collected via our - Services for a variety of business purposes described below. - We process your personal information for these purposes in - reliance on our legitimate business interests, in order to - enter into or perform a contract with you, with your consent, - and/or for compliance with our legal obligations. We indicate - the specific processing grounds we rely on next to each - purpose listed below. We use the information we collect or - receive: - To facilitate account creation and logon process. - If you choose to link your account with us to a third-party - account (such as your Google or Facebook account), we use the - information you allowed us to collect from those third parties - to facilitate account creation and logon process for the - performance of the contract. -

    -
  4. -
  5. -

    Will your information be shared with anyone?

    -

    - In Short: We only share information with your consent, to - comply with laws, to provide you with services, to protect - your rights, or to fulfill business obligations. We may - process or share your data that we hold based on the following - legal basis: - Consent: We may process your data if you have - given us specific consent to use your personal information in - a specific purpose. -

    -
  6. -
  7. -

    Who will your information be shared with?

    -

    - In Short: We only share information with the following third - parties. We only share and disclose your information with the - following third parties. We have categorized each party so - that you may be easily understand the purpose of our data - collection and processing practices.-{" "} - - Advertising, Direct Marketing, and Lead Generation - - Communicate and Chat with Users - Content Optimization - - User Account Registration and Authentication. - -

    -
  8. -
  9. -

    Do we use cookies and other tracking technologies?

    -

    - In Short: We may use cookies and other tracking technologies - to collect and store your information. We may use cookies and - similar tracking technologies (like web beacons and pixels) to - access or store information. Specific information about how we - use such technologies and how you can refuse certain cookies - is set out in our Cookie Policy. -

    -
  10. -
  11. -

    How long do we keep your information?

    -

    - In Short: We keep your information for as long as necessary to - fulfill the purposes outlined in this privacy policy unless - otherwise required by law. We will only keep your personal - information for as long as it is necessary for the purposes - set out in this privacy policy, unless a longer retention - period is required or permitted by law (such as tax, - accounting or other legal requirements). No purpose in this - policy will require us keeping your personal information for - longer than the period of time in which users have an account - with us. -

    -
  12. -
  13. -

    How do we keep your information safe?

    -

    - In Short: We aim to protect your personal information through - a system of organizational and technical security measures. We - have implemented appropriate technical and organizational - security measures designed to protect the security of any - personal information we process. However, please also remember - that we cannot guarantee that the internet itself is 100% - secure. Although we will do our best to protect your personal - information, transmission of personal information to and from - our Services is at your own risk. You should only access the - services within a secure environment. -

    -
  14. -
  15. -

    Do we collect information from minors?

    -

    - In Short: We do not knowingly collect data from or market to - children under 18 years of age. We do not knowingly solicit - data from or market to children under 18 years of age. By - using the Services, you represent that you are at least 18 or - that you are the parent or guardian of such a minor and - consent to such minor dependent's use of the Services. If - we learn that personal information from users less than 18 - years of age has been collected, we will deactivate the - account and take reasonable measures to promptly delete such - data from our records. If you become aware of any data we have - collected from children under age 18, please contact us at{" "} - - codebility.dev@gmail.com - - . -

    -
  16. -
  17. -

    What are your privacy rights?

    -

    In Short: You may review, change, or terminate your account at any time.

    -

    - You have certain rights under applicable data protection laws. - These may include the right (i) to request access and obtain a - copy of your personal information, (ii) to request - rectification or erasure; (iii) to restrict the processing of - your personal information. -

    -
  18. -
-

Last Updated: March 2024

-
-
-
- - ); -}; - -export default PrivacyPolicy; \ No newline at end of file +export default function PrivacyPolicy() { + return ; +} diff --git a/apps/codebility/app/(marketing)/profiles/[id]/_components/ProfileCloseButton.tsx b/apps/codebility/app/(marketing)/profiles/[id]/_components/ProfileCloseButton.tsx index 38f67274a..8c93d701a 100644 --- a/apps/codebility/app/(marketing)/profiles/[id]/_components/ProfileCloseButton.tsx +++ b/apps/codebility/app/(marketing)/profiles/[id]/_components/ProfileCloseButton.tsx @@ -8,18 +8,18 @@ export default function ProfileCloseButton() { const router = useRouter(); const handleClose = () => { - // If they came from somewhere within the app, go back. - // In rare cases where they opened directly, they might need to go home, but router.back() handles history. router.back(); }; return ( - + + ); } diff --git a/apps/codebility/app/(marketing)/profiles/[id]/_components/ProfileContent.tsx b/apps/codebility/app/(marketing)/profiles/[id]/_components/ProfileContent.tsx index f3a01abcf..6ac72ba69 100644 --- a/apps/codebility/app/(marketing)/profiles/[id]/_components/ProfileContent.tsx +++ b/apps/codebility/app/(marketing)/profiles/[id]/_components/ProfileContent.tsx @@ -1,460 +1,382 @@ "use client"; -import React, { useEffect, useState } from "react"; +import React from "react"; import Image from "next/image"; import Link from "next/link"; -import { motion } from "framer-motion"; import { ClockIcon } from "lucide-react"; import { cn } from "@codevs/ui"; import { - IconAbout, - IconGithub, - IconLink, - IconSkills, - IconLinkedInWhiteSmall, + IconAbout, + IconGithub, + IconLink, + IconSkills, + IconLinkedInWhiteSmall, } from "@/public/assets/svgs"; import { Codev } from "@/types/home/codev"; import CodevBadge from "@/components/CodevBadge"; -import { getMemberProjects, getMemberRatingScore, ProjectInfo } from "../_services/query"; -import StarRating from "./StarRating"; -import ProjectList from "./ProjectList"; +import MarketingProgressiveSection from "../../../_shared/MarketingProgressiveSection"; +import ProgressiveMotion from "../../../_shared/ProgressiveMotion"; +import ProfileProjectsSection from "./ProfileProjectsSection"; +import ProfileRatingSection from "./ProfileRatingSection"; type LevelMap = Record; function getFilteredLevel(level?: LevelMap): LevelMap { - if (!level) return {}; + if (!level) return {}; - return Object.fromEntries( - Object.entries(level).filter(([_, value]) => value > 0), - ); + return Object.fromEntries( + Object.entries(level).filter(([_, value]) => value > 0), + ); } -const fadeInUp = { - hidden: { opacity: 0, y: 20 }, - visible: { - opacity: 1, - y: 0, - transition: { - duration: 0.6, - ease: "easeOut" - } - } -}; - -const fadeInScale = { - hidden: { opacity: 0, scale: 0.9 }, - visible: { - opacity: 1, - scale: 1, - transition: { - duration: 0.5, - ease: "easeOut" - } - } -}; - -const staggerContainer = { - hidden: { opacity: 0 }, - visible: { - opacity: 1, - transition: { - staggerChildren: 0.1, - delayChildren: 0.2 - } - } -}; - interface ProfileContentProps { - codev: Codev; - availableSchedule: any; + codev: Codev; + availableSchedule: NonNullable[number] | null; } export default function ProfileContent({ - codev, - availableSchedule + codev, + availableSchedule, }: ProfileContentProps) { - const [ratingScore, setRatingScore] = useState(null); - const [memberProjects, setMemberProjects] = useState(null); - - useEffect(() => { - if (!codev.id) return; - - const fetchScore = async () => { - try { - const score = await getMemberRatingScore(codev.id); - setRatingScore(score ?? 0); - } catch (err) { - console.error("Failed to fetch member score:", err); - } - }; - - fetchScore(); - - const fetchProjects = async () => { - try { - const projects = await getMemberProjects(codev.id); - setMemberProjects(projects); - } catch (err) { - console.error("Failed to fetch projects:", err); - } - }; - - fetchProjects(); - - }, [codev.id]); - - - const allDays = ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"]; - - // FIXED: Deterministic color based on codev.id to prevent hydration mismatch - const getBgColor = (id: string) => { - const colors = ['bg-bg-codeviolet', 'bg-bg-codemean', 'bg-bg-codegreen', 'bg-bg-codepink']; - // Create a consistent hash from the ID - const hash = id.split('').reduce((acc, char) => acc + char.charCodeAt(0), 0); - return colors[hash % colors.length]; - }; - - const getStatusBadge = () => - codev.availability_status - ? "Available" - : codev.nda_status - ? "Under NDA" - : "Unavailable"; - - const sanitizeUrl = (url: string | undefined): string => { - if (!url) return "#"; - return url - .replace(process.env.NEXT_PUBLIC_APP_BASE_URL || "", "") - .replace(process.env.NEXT_PUBLIC_APP_BASE_URL || "", ""); - }; - - const formatTime = (time: string) => { - const [hours, minutes] = time.split(":"); - const hour = Number.parseInt(hours!); - const ampm = hour >= 12 ? "PM" : "AM"; - const displayHour = hour % 12 || 12; - return `${displayHour}:${minutes} ${ampm}`; - }; - - const { - first_name, - last_name, - image_url, - display_position, - portfolio_website, - about, - github, - linkedin, - tech_stacks, - education, - work_experience, - availability_status, - } = codev; - - const filteredLevel = React.useMemo( - () => getFilteredLevel(codev.level), - [codev.level], - ); - - - return ( -
- {/* Left Section */} - - - {`${first_name} - -

- {getStatusBadge()} -

-
-
- - {first_name || "Unknown"} {last_name || "Unknown"} - - {display_position && ( - -

- {display_position} -

-
- )} - {codev.headline && ( -

- {codev.headline} -

- )} - - {linkedin && ( - - - - - - )} - {github && ( - - - - - - )} - {portfolio_website && ( - - - - - - )} - - - {codev.level && ( - - - )} - - {ratingScore !== null && } - - {tech_stacks && tech_stacks.length > 0 && ( - -
- -

Skills

-
- - {tech_stacks.map((stack, index) => ( - - {stack} - - ))} - -
- )} - - {memberProjects && ( -
- -
- )} - - {/* Work Schedule Section */} - {availableSchedule && ( - -

Work Schedule

- - - {/* Time Display */} - {availableSchedule.start_time && availableSchedule.end_time && ( -
-
- - - {formatTime(availableSchedule.start_time)} - {formatTime(availableSchedule.end_time)} - - - PST - -
-
- )} - - {/* Week Days Grid */} -
- {allDays.map((day, index) => { - const isAvailable = availableSchedule.days_of_week.includes(day) - - return ( - - {day.charAt(0)} - - ) - })} -
-
-
- )} -
- - {/* Right Section */} - - {about && ( - -
- -

About

-
-

{about}

-
- )} - - {education && education.length > 0 && ( - -

Education

- - {education.map((edu, index) => ( - -

{edu.institution}

- {edu.degree && ( -

- {edu.degree} ({edu.start_date || "N/A"} -{" "} - {edu.end_date || "Present"}) -

- )} -
- ))} -
-
- )} - {work_experience && work_experience.length > 0 && ( - -

- Experience -

- - {work_experience.map((exp, index) => ( - -

- {exp.position} -

-

- - {exp.company_name.toUpperCase()} - - ({exp.date_from} - {exp.date_to || "Present"}) -

-

- @{exp.location} -

-

{exp.description}

-
- ))} -
-
- )} -
-
- ); -} \ No newline at end of file + const allDays = [ + "Monday", + "Tuesday", + "Wednesday", + "Thursday", + "Friday", + "Saturday", + "Sunday", + ]; + + const getBgColor = (id: string) => { + const colors = [ + "bg-bg-codeviolet", + "bg-bg-codemean", + "bg-bg-codegreen", + "bg-bg-codepink", + ]; + const hash = id + .split("") + .reduce((acc, char) => acc + char.charCodeAt(0), 0); + return colors[hash % colors.length]; + }; + + const getStatusBadge = () => + codev.availability_status + ? "Available" + : codev.nda_status + ? "Under NDA" + : "Unavailable"; + + const sanitizeUrl = (url: string | undefined): string => { + if (!url) return "#"; + return url + .replace(process.env.NEXT_PUBLIC_APP_BASE_URL || "", "") + .replace(process.env.NEXT_PUBLIC_APP_BASE_URL || "", ""); + }; + + const formatTime = (time: string) => { + const [hours, minutes] = time.split(":"); + const hour = Number.parseInt(hours!); + const ampm = hour >= 12 ? "PM" : "AM"; + const displayHour = hour % 12 || 12; + return `${displayHour}:${minutes} ${ampm}`; + }; + + const { + first_name, + last_name, + image_url, + display_position, + portfolio_website, + about, + github, + linkedin, + tech_stacks, + education, + work_experience, + availability_status, + } = codev; + + const filteredLevel = React.useMemo( + () => getFilteredLevel(codev.level), + [codev.level], + ); + + const sidebarSkeleton = ( +
+
+ {`${first_name} +
+

+ {getStatusBadge()} +

+
+
+

+ {first_name || "Unknown"} {last_name || "Unknown"} +

+ {display_position && ( +
+

+ {display_position} +

+
+ )} +
+ ); + + const sidebarContent = ( +
+
+ {`${first_name} +
+

+ {getStatusBadge()} +

+
+
+

+ {first_name || "Unknown"} {last_name || "Unknown"} +

+ {display_position && ( +
+

+ {display_position} +

+
+ )} + {codev.headline && ( +

+ {codev.headline} +

+ )} +
+ {linkedin && ( + + + + )} + {github && ( + + + + )} + {portfolio_website && ( + + + + )} +
+ + {codev.level && ( + + )} + + + + {tech_stacks && tech_stacks.length > 0 && ( +
+
+ +

Skills

+
+
+ {tech_stacks.map((stack) => ( +
+ {stack} +
+ ))} +
+
+ )} + + + + {availableSchedule && ( +
+

+ Work Schedule +

+ +
+ {availableSchedule.start_time && availableSchedule.end_time && ( +
+
+ + + {formatTime(availableSchedule.start_time)} -{" "} + {formatTime(availableSchedule.end_time)} + + PST +
+
+ )} + +
+ {allDays.map((day) => { + const isAvailable = + availableSchedule.days_of_week.includes(day); + + return ( +
+ {day.charAt(0)} +
+ ); + })} +
+
+
+ )} +
+ ); + + const detailsSkeleton = ( +
+ {about && ( +
+
+ +

About

+
+

{about}

+
+ )} +
+ ); + + return ( +
+ + {sidebarContent} + + + + + {about && ( +
+
+ +

About

+
+

{about}

+
+ )} + + {education && education.length > 0 && ( +
+

+ Education +

+
+ {education.map((edu) => ( +
+

{edu.institution}

+ {edu.degree && ( +

+ {edu.degree} ({edu.start_date || "N/A"} -{" "} + {edu.end_date || "Present"}) +

+ )} +
+ ))} +
+
+ )} + + {work_experience && work_experience.length > 0 && ( +
+

+ Experience +

+
+ {work_experience.map((exp) => ( +
+

+ {exp.position} +

+

+ + {exp.company_name.toUpperCase()} + + ({exp.date_from} - {exp.date_to || "Present"}) +

+

+ @{exp.location} +

+

{exp.description}

+
+ ))} +
+
+ )} +
+
+
+ ); +} diff --git a/apps/codebility/app/(marketing)/profiles/[id]/_components/ProfileDetailSkeleton.tsx b/apps/codebility/app/(marketing)/profiles/[id]/_components/ProfileDetailSkeleton.tsx new file mode 100644 index 000000000..e2795b948 --- /dev/null +++ b/apps/codebility/app/(marketing)/profiles/[id]/_components/ProfileDetailSkeleton.tsx @@ -0,0 +1,145 @@ +import { Skeleton } from "@/components/ui/skeleton/skeleton"; + +function ProfileSidebarSkeleton() { + return ( + + ); +} + +function ProfileMainSkeleton() { + return ( + + ); +} + +export function ProfileDetailSkeleton() { + return ( +
+ + +
+ ); +} + +export function ProfileRatingSkeleton() { + return ( + + ); +} + +export function ProfileProjectsSkeleton({ count = 2 }: { count?: number }) { + return ( + + ); +} diff --git a/apps/codebility/app/(marketing)/profiles/[id]/_components/ProfileProjectsSection.tsx b/apps/codebility/app/(marketing)/profiles/[id]/_components/ProfileProjectsSection.tsx new file mode 100644 index 000000000..35b51c55a --- /dev/null +++ b/apps/codebility/app/(marketing)/profiles/[id]/_components/ProfileProjectsSection.tsx @@ -0,0 +1,47 @@ +"use client"; + +import { Suspense, use } from "react"; +import { fetchApiJson } from "@/utils/api-fetch"; + +import ProjectList, { type ProjectInfo } from "./ProjectList"; +import { ProfileProjectsSkeleton } from "./ProfileDetailSkeleton"; + +const projectsPromises = new Map>(); + +function loadProjects(codevId: string): Promise { + const cached = projectsPromises.get(codevId); + if (cached) return cached; + + const promise = fetchApiJson<{ projects: ProjectInfo[] }>( + `/api/profile-projects/${codevId}`, + { cache: "force-cache" }, + ).then((result) => { + if (!result.ok) return []; + return result.data.projects ?? []; + }); + + projectsPromises.set(codevId, promise); + return promise; +} + +function ProfileProjectsContent({ codevId }: { codevId: string }) { + const projects = use(loadProjects(codevId)); + + if (projects.length === 0) { + return null; + } + + return ; +} + +export default function ProfileProjectsSection({ + codevId, +}: { + codevId: string; +}) { + return ( + }> + + + ); +} diff --git a/apps/codebility/app/(marketing)/profiles/[id]/_components/ProfileRatingSection.tsx b/apps/codebility/app/(marketing)/profiles/[id]/_components/ProfileRatingSection.tsx new file mode 100644 index 000000000..683445fb1 --- /dev/null +++ b/apps/codebility/app/(marketing)/profiles/[id]/_components/ProfileRatingSection.tsx @@ -0,0 +1,38 @@ +"use client"; + +import { Suspense, use } from "react"; +import { fetchApiJson } from "@/utils/api-fetch"; + +import StarRating from "./StarRating"; +import { ProfileRatingSkeleton } from "./ProfileDetailSkeleton"; + +const ratingPromises = new Map>(); + +function loadRating(codevId: string): Promise { + const cached = ratingPromises.get(codevId); + if (cached) return cached; + + const promise = fetchApiJson<{ rating: number }>( + `/api/profile-rating/${codevId}`, + { cache: "force-cache" }, + ).then((result) => { + if (!result.ok) return 0; + return result.data.rating ?? 0; + }); + + ratingPromises.set(codevId, promise); + return promise; +} + +function ProfileRatingContent({ codevId }: { codevId: string }) { + const rating = use(loadRating(codevId)); + return ; +} + +export default function ProfileRatingSection({ codevId }: { codevId: string }) { + return ( + }> + + + ); +} diff --git a/apps/codebility/app/(marketing)/profiles/[id]/_services/query.ts b/apps/codebility/app/(marketing)/profiles/[id]/_services/query.ts deleted file mode 100644 index ac7d06de8..000000000 --- a/apps/codebility/app/(marketing)/profiles/[id]/_services/query.ts +++ /dev/null @@ -1,71 +0,0 @@ -"use server"; - -import { createClientServerComponent } from "@/utils/supabase/server"; - -export const getMemberRatingScore = async (memberId: string): Promise => { - try { - const supabase = await createClientServerComponent(); - - const { data, error } = await supabase - .rpc("calculate_member_rating_score", { member_uuid: memberId }) - .single(); - - if (error) throw error; - - // unwrap the actual numeric value from the returned object - const score = - typeof data === "number" ? data : (data as any)?.calculate_member_rating_score; - - return score ?? 0; // default to 0 if null or undefined - } catch (error) { - console.error("Error fetching member score:", error); - return 0; // fail gracefully - } -}; - - -export interface ProjectInfo { - project_id: string; - name: string; - main_image: string | null; -} - -export const getMemberProjects = async (memberId: string): Promise => { - try { - const supabase = await createClientServerComponent(); - - // Step 1: Get all project_ids for the member - const { data: memberProjects, error: memberError } = await supabase - .from("project_members") - .select("project_id") - .eq("codev_id", memberId); - - if (memberError) throw memberError; - if (!memberProjects || memberProjects.length === 0) return []; - - const projectIds = memberProjects.map((row) => row.project_id); - - - - // Step 2: Fetch project info for those IDs - const { data: projects, error: projectError } = await supabase - .from("projects") - .select("id, name, main_image") // Use actual column names - .in("id", projectIds); - - if (projectError) throw projectError; - if (!projects) return []; - - // Map to the ProjectInfo shape - const result: ProjectInfo[] = projects.map((p) => ({ - project_id: p.id, - name: p.name, - main_image: p.main_image, - })); - - return result; - } catch (error) { - console.error("Error fetching member projects:", error); - return []; - } -}; \ No newline at end of file diff --git a/apps/codebility/app/(marketing)/profiles/[id]/page.tsx b/apps/codebility/app/(marketing)/profiles/[id]/page.tsx index cb7d16201..e31316a55 100644 --- a/apps/codebility/app/(marketing)/profiles/[id]/page.tsx +++ b/apps/codebility/app/(marketing)/profiles/[id]/page.tsx @@ -1,90 +1,108 @@ import { Metadata } from "next"; -import React from "react"; +import { notFound } from "next/navigation"; import { Paragraph } from "@/components/shared/home"; import Logo from "@/components/shared/Logo"; -import { getCodevs } from "@/lib/server/codev.service"; -import { Codev } from "@/types/home/codev"; +import { + getCachedProfileDetail, + getCachedProfileDetailMeta, +} from "@/lib/server/profile-detail-cached"; +import { + getCachedLandingAdminsData, + getLandingAdminsProfileIds, +} from "@/lib/server/landing-admins-cached"; +import { getCachedLandingInternsPage } from "@/lib/server/landing-interns-cached"; import ProfileCloseButton from "./_components/ProfileCloseButton"; import ProfileContent from "./_components/ProfileContent"; interface Props { - params: Promise<{ id: string }>; + params: Promise<{ id: string }>; } -// Generates a unique title/description for each individual profile page -export async function generateMetadata({ params }: Props): Promise { - const { id } = await params; - const { data, error } = await getCodevs({ filters: { id } }); - - // Fallback metadata if fetch fails or codev not found - if (error || !data || data.length === 0) { - return { - title: "Developer Profile | Codebility", - description: "View this developer's profile on Codebility.", - }; +const LANDING_PAGE_SIZE = 10; + +export async function generateStaticParams() { + const [landing, adminsData] = await Promise.all([ + getCachedLandingInternsPage(1, LANDING_PAGE_SIZE), + getCachedLandingAdminsData(), + ]); + + const ids = new Set(); + + for (const member of landing?.TEAM_MEMBERS ?? []) { + ids.add(member.id); + } + + if (adminsData) { + for (const id of getLandingAdminsProfileIds(adminsData)) { + ids.add(id); } + } + + return [...ids].map((id) => ({ id })); +} - const codev = data[0] as Codev; - const name = `${codev.first_name} ${codev.last_name}`; - const image = codev.image_url || "/og-image.jpg"; +export async function generateMetadata({ params }: Props): Promise { + const { id } = await params; + const profile = await getCachedProfileDetailMeta(id); + if (!profile) { return { - title: `${name} — Developer Profile | Codebility`, - description: `View ${name}'s developer profile on Codebility. Skills, availability, and portfolio.`, - alternates: { - canonical: `https://www.codebility.tech/profiles/${id}`, - }, - openGraph: { - title: `${name} | Codebility`, - description: `View ${name}'s developer profile on Codebility.`, - url: `https://www.codebility.tech/profiles/${id}`, - images: [{ url: image, width: 400, height: 400, alt: name }], - }, - twitter: { - card: "summary_large_image", - title: `${name} | Codebility`, - description: `View ${name}'s developer profile on Codebility.`, - images: [image], - }, + title: "Developer Profile | Codebility", + description: "View this developer's profile on Codebility.", }; + } + + const name = `${profile.first_name} ${profile.last_name}`.trim(); + const image = profile.image_url || "/og-image.jpg"; + + return { + title: `${name} — Developer Profile | Codebility`, + description: `View ${name}'s developer profile on Codebility. Skills, availability, and portfolio.`, + alternates: { + canonical: `https://www.codebility.tech/profiles/${id}`, + }, + openGraph: { + title: `${name} | Codebility`, + description: `View ${name}'s developer profile on Codebility.`, + url: `https://www.codebility.tech/profiles/${id}`, + images: [{ url: image, width: 400, height: 400, alt: name }], + }, + twitter: { + card: "summary_large_image", + title: `${name} | Codebility`, + description: `View ${name}'s developer profile on Codebility.`, + images: [image], + }, + }; } export default async function CodevBioPage(props: Props) { - const params = await props.params; - const id = params.id; + const { id } = await props.params; + const codev = await getCachedProfileDetail(id); - const { data, error } = await getCodevs({ filters: { id } }); + if (!codev) { + notFound(); + } - if (error) { - console.error("Failed to fetch Codev data:", error); - return
ERROR: Failed to fetch Codev data. {error.message}
; - } + const availableSchedule = codev.work_schedules?.[0] ?? null; - if (!data || data.length === 0) { - return
No profiles found.
; - } - - const codev = data[0] as Codev; - const availableSchedule = codev.work_schedules ? codev.work_schedules[0] : null; - - return ( -
-
-
-
- - -
- -
-
- - © 2023 Codebility. All Rights Reserved -
-
- ); -} \ No newline at end of file + return ( +
+
+
+
+ +
+ +
+
+ + © 2023 Codebility. All Rights Reserved +
+
+ ); +} diff --git a/apps/codebility/app/(marketing)/profiles/_components/CodevCard.tsx b/apps/codebility/app/(marketing)/profiles/_components/CodevCard.tsx index 154ac60c0..dd17c26e1 100644 --- a/apps/codebility/app/(marketing)/profiles/_components/CodevCard.tsx +++ b/apps/codebility/app/(marketing)/profiles/_components/CodevCard.tsx @@ -17,7 +17,6 @@ import { import { BookOpenIcon } from "lucide-react"; import { cn } from "@codevs/ui"; -import { Button } from "@codevs/ui/button"; import { Tooltip, TooltipContent, @@ -30,6 +29,7 @@ import { CodevHireCodevButton } from "./CodevHireCodevButton"; interface Props { codev: Codev; color: string; + animateEntrance?: boolean; } const STATUS_CONFIG: Record = { @@ -65,7 +65,7 @@ const STATUS_CONFIG: Record { +const CodevCard = ({ codev, color, animateEntrance = true }: Props) => { const [hovered, setHovered] = useState(false); const springConfig = { stiffness: 100, damping: 5 }; const x = useMotionValue(0); @@ -116,16 +116,10 @@ const CodevCard = ({ codev, color }: Props) => { : {}; }, [codev.level, codev.codev_points]); - return ( - setHovered(true)} - onMouseLeave={() => setHovered(false)} - > -
window.location.href = `https://www.codebility.tech/profiles/${codev.id}`} + const cardBody = ( + {/* Background decoration */} @@ -201,15 +195,9 @@ const CodevCard = ({ codev, color }: Props) => { - e.stopPropagation()}> - - + + + Read Bio @@ -241,9 +229,32 @@ const CodevCard = ({ codev, color }: Props) => { ) : null}
- +
+ + ); + + if (!animateEntrance) { + return ( +
setHovered(true)} + onMouseLeave={() => setHovered(false)} + > + {cardBody}
+ ); + } + + return ( + setHovered(true)} + onMouseLeave={() => setHovered(false)} + > + {cardBody} ); }; diff --git a/apps/codebility/app/(marketing)/profiles/_components/CodevContainer.tsx b/apps/codebility/app/(marketing)/profiles/_components/CodevContainer.tsx index 71b90f475..a613de168 100644 --- a/apps/codebility/app/(marketing)/profiles/_components/CodevContainer.tsx +++ b/apps/codebility/app/(marketing)/profiles/_components/CodevContainer.tsx @@ -1,33 +1,49 @@ "use client"; -import { fadeInOutDownToUp } from "@/components/FramerAnimation/Framer"; + import H2 from "@/components/shared/home/H2"; -import Heading3 from "@/components/shared/home/Heading3"; -import IntroText from "@/components/shared/home/IntroText"; -import { motion } from "framer-motion"; + +import MarketingProgressiveSection from "../../_shared/MarketingProgressiveSection"; +import ProgressiveMotion from "../../_shared/ProgressiveMotion"; export default function CodevContainer() { + const skeleton = ( +
+
+

Our Skilled Network

+
+
+

+ Discover our carefully vetted professionals, ready to join your team + and deliver exceptional results. Every member brings proven expertise, + strong communication, and a passion for getting things done right. +

+
+
+ ); + return ( -
- -

Our Skilled Network

-
- -

- Discover our carefully vetted professionals, ready to join your team and deliver exceptional results. Every member brings proven expertise, strong communication, and a passion for getting things done right. -

-
-
+ + +
+

Our Skilled Network

+
+
+

+ Discover our carefully vetted professionals, ready to join your team + and deliver exceptional results. Every member brings proven + expertise, strong communication, and a passion for getting things + done right. +

+
+
+
); -} \ No newline at end of file +} diff --git a/apps/codebility/app/(marketing)/profiles/_components/CodevHireCodevButton.tsx b/apps/codebility/app/(marketing)/profiles/_components/CodevHireCodevButton.tsx index 331bc9001..cc160c59a 100644 --- a/apps/codebility/app/(marketing)/profiles/_components/CodevHireCodevButton.tsx +++ b/apps/codebility/app/(marketing)/profiles/_components/CodevHireCodevButton.tsx @@ -1,30 +1,17 @@ "use client"; -import { useModal } from "@/hooks/use-modal"; - import { Button } from "@codevs/ui/button"; -interface CodevHireCodevButtonProps { - codevId: string; -} - -export function CodevHireCodevButton({ codevId }: CodevHireCodevButtonProps) { - const { onOpen } = useModal(); - +export function CodevHireCodevButton() { return ( ); -} \ No newline at end of file +} diff --git a/apps/codebility/app/(marketing)/profiles/_components/CodevHireCodevModal.tsx b/apps/codebility/app/(marketing)/profiles/_components/CodevHireCodevModal.tsx index 0aa53e5ac..400338b99 100644 --- a/apps/codebility/app/(marketing)/profiles/_components/CodevHireCodevModal.tsx +++ b/apps/codebility/app/(marketing)/profiles/_components/CodevHireCodevModal.tsx @@ -2,27 +2,23 @@ import { Button } from "@/components/ui/button"; import { Dialog, DialogHeader, DialogContent, DialogTitle, DialogFooter } from "@/components/ui/dialog"; -import { useModal } from "@/hooks/use-modal"; +import { useModal } from "@/hooks/modals/use-modal"; // Removed form imports to fix version conflict import { Input } from "@codevs/ui/input"; import { Textarea } from "@codevs/ui/textarea"; import { zodResolver } from "@hookform/resolvers/zod"; import { useForm } from "react-hook-form"; -import { getCodev } from "../_service/actions"; -import { z } from "zod"; +import { getCodev } from "@/actions/marketing/profiles"; import { toast } from "@/components/ui/use-toast"; import { Codev } from "@/types/home/codev"; import { useEffect, useState } from "react"; -import { sentHireCodevEmail } from "../_service/emailAction"; +import { sentHireCodevEmail } from "@/actions/marketing/profiles-email"; +import { + hireCodevEmailSchema, + type HireCodevEmail, +} from "@/types/marketing/hire-codev-email"; -const formSchema = z.object({ - name: z.string().min(1, { message: "Name is required" }), - email: z.string().email({ message: "Invalid email" }), - message: z.string().min(1, { message: "Message is required" }), -}); - -type FormValues = z.infer; -export type HireCodevEmail = FormValues; +export type { HireCodevEmail } from "@/types/marketing/hire-codev-email"; export function CodevHireCodevModal() { const { isOpen, onClose, type, data: codevId } = useModal(); @@ -35,8 +31,8 @@ export function CodevHireCodevModal() { }); }, [codevId]); - const form = useForm({ - resolver: zodResolver(formSchema), + const form = useForm({ + resolver: zodResolver(hireCodevEmailSchema), defaultValues: { name: "", email: "", @@ -44,7 +40,7 @@ export function CodevHireCodevModal() { }, }); - const onSubmit = async (values: FormValues) => { + const onSubmit = async (values: HireCodevEmail) => { try { // Prepare template parameters const templateParams = { diff --git a/apps/codebility/app/(marketing)/profiles/_components/CodevList.tsx b/apps/codebility/app/(marketing)/profiles/_components/CodevList.tsx index 3a0af25a8..42ec80c91 100644 --- a/apps/codebility/app/(marketing)/profiles/_components/CodevList.tsx +++ b/apps/codebility/app/(marketing)/profiles/_components/CodevList.tsx @@ -4,8 +4,8 @@ import { useEffect, useState } from "react"; import { motion } from "framer-motion"; import { box } from "@/components/FramerAnimation/Framer"; import DefaultPagination from "@/components/ui/pagination"; -import usePagination from "@/hooks/use-pagination"; -import getRandomColor from "@/lib/getRandomColor"; +import usePagination from "@/hooks/data/use-pagination"; +import { getStableColor } from "@/utils/getRandomColor"; import { Codev } from "@/types/home/codev"; import CodevCard from "./CodevCard"; @@ -69,7 +69,7 @@ export default function CodevList({ codevs }: Props) { > {paginatedUsers.map((codev) => ( diff --git a/apps/codebility/app/(marketing)/profiles/_components/CodevListFilter.tsx b/apps/codebility/app/(marketing)/profiles/_components/CodevListFilter.tsx index ab69f7df3..71b9df1be 100644 --- a/apps/codebility/app/(marketing)/profiles/_components/CodevListFilter.tsx +++ b/apps/codebility/app/(marketing)/profiles/_components/CodevListFilter.tsx @@ -10,25 +10,28 @@ import { } from "@/components/ui/select"; import { Codev } from "@/types/home/codev"; -import { removeArrayDuplicate } from "../../_lib/util"; +import { removeArrayDuplicate } from "@/utils/marketing/array"; interface Props { selectedPosition: string; setSelectedPosition: (position: string) => void; - users: Codev[]; // Ensure the users array uses the correct type + users: Codev[]; + positions?: string[]; } const CodevListFilter: React.FC = ({ selectedPosition, setSelectedPosition, users, + positions: positionsProp, }) => { const selectGroupRef = useRef(null); - // Extract unique display_position values - const positions = removeArrayDuplicate( - users.map((user) => user.display_position).filter(Boolean), // Safely access display_position - ); + const positions = + positionsProp ?? + removeArrayDuplicate( + users.map((user) => user.display_position).filter(Boolean), + ); const handleSelectPosition = () => { if (selectGroupRef.current) { diff --git a/apps/codebility/app/(marketing)/profiles/_components/ProfilesListBlock.tsx b/apps/codebility/app/(marketing)/profiles/_components/ProfilesListBlock.tsx new file mode 100644 index 000000000..d9eb005aa --- /dev/null +++ b/apps/codebility/app/(marketing)/profiles/_components/ProfilesListBlock.tsx @@ -0,0 +1,19 @@ +import { Suspense } from "react"; +import { pageSize } from "@/constants"; + +import ProfilesListSection from "./ProfilesListSection"; +import ProfilesListShell from "./ProfilesListShell"; + +const PAGE_SIZE = pageSize.profilesListing; + +export function ProfilesListFallback() { + return ; +} + +export function ProfilesListBlock() { + return ( + }> + + + ); +} diff --git a/apps/codebility/app/(marketing)/profiles/_components/ProfilesListPagination.tsx b/apps/codebility/app/(marketing)/profiles/_components/ProfilesListPagination.tsx new file mode 100644 index 000000000..9f4024d6d --- /dev/null +++ b/apps/codebility/app/(marketing)/profiles/_components/ProfilesListPagination.tsx @@ -0,0 +1,331 @@ +"use client"; + +import { Suspense, use, useState, useTransition } from "react"; +import DefaultPagination from "@/components/ui/pagination"; +import { useMarketingPageUrl } from "@/hooks/marketing/use-marketing-page-url"; +import { getStableColor } from "@/utils/getRandomColor"; +import type { ProfilesListingPage } from "@/types/marketing/profiles-listing"; +import { fetchApiJson } from "@/utils/api-fetch"; + +import CodevCard from "./CodevCard"; +import CodevListFilter from "./CodevListFilter"; +import { ProfilesListSkeleton } from "./ProfilesListSkeleton"; +import ProgressiveMotion from "../../_shared/ProgressiveMotion"; + +const pagePromises = new Map>(); +const pageMetaCache = new Map(); + +function pageCacheKey(position: string, page: number, pageSize: number) { + return `${position}:${page}:${pageSize}`; +} + +function filterCacheKey(position: string, pageSize: number) { + return `${position}:${pageSize}`; +} + +function rememberPagination( + position: string, + page: number, + pageSize: number, + pagination: ProfilesListingPage["pagination"], +) { + pageMetaCache.set(pageCacheKey(position, page, pageSize), pagination); + pageMetaCache.set(filterCacheKey(position, pageSize), pagination); +} + +function loadPage( + position: string, + page: number, + pageSize: number, + initialData: ProfilesListingPage, +): Promise { + const key = pageCacheKey(position, page, pageSize); + const cached = pagePromises.get(key); + if (cached) return cached; + + if ( + page === initialData.pagination.page && + position === initialData.position + ) { + rememberPagination( + position, + page, + pageSize, + initialData.pagination, + ); + const resolved = Promise.resolve(initialData); + pagePromises.set(key, resolved); + return resolved; + } + + const params = new URLSearchParams({ + page: String(page), + limit: String(pageSize), + }); + if (position) { + params.set("position", position); + } + + const promise = fetchApiJson( + `/api/profiles-listing?${params.toString()}`, + { cache: "force-cache" }, + ).then((result) => { + if (!result.ok) { + console.error("Error fetching profiles listing page:", result.error); + return { + codevs: [], + pagination: { + page, + limit: pageSize, + total: 0, + totalPages: 0, + }, + positions: initialData.positions, + position, + }; + } + + rememberPagination( + position, + page, + pageSize, + result.data.pagination, + ); + return result.data; + }); + + pagePromises.set(key, promise); + return promise; +} + +function resolvePagination( + position: string, + page: number, + pageSize: number, + initialData: ProfilesListingPage, +): ProfilesListingPage["pagination"] { + return ( + pageMetaCache.get(pageCacheKey(position, page, pageSize)) ?? + pageMetaCache.get(filterCacheKey(position, pageSize)) ?? + (position === initialData.position + ? initialData.pagination + : { page, limit: pageSize, total: 0, totalPages: 0 }) + ); +} + +function AnimatedProfilesGrid({ + codevs, + animationKey, +}: { + codevs: ProfilesListingPage["codevs"]; + animationKey: string; +}) { + if (codevs.length === 0) { + return ( +

+ Sorry, no data found. +

+ ); + } + + return ( + + {codevs.map((codev) => ( +
+ +
+ ))} +
+ ); +} + +function ProfilesGrid({ + codevs, + animationKey, +}: { + codevs: ProfilesListingPage["codevs"]; + animationKey: string; +}) { + return ; +} + +function ProfilesPaginationSlot({ + page, + totalPages, + onPageChange, +}: { + page: number; + totalPages: number; + onPageChange: (page: number) => void; +}) { + if (totalPages <= 1) { + return
-
- - - ); -} \ No newline at end of file diff --git a/apps/codebility/app/home/_components/PageContainer.tsx b/apps/codebility/app/home/_components/PageContainer.tsx index d3dd79052..fbb701629 100644 --- a/apps/codebility/app/home/_components/PageContainer.tsx +++ b/apps/codebility/app/home/_components/PageContainer.tsx @@ -1,10 +1,10 @@ import { ReactNode } from "react"; -import { cn } from "@/lib/utils"; +import { cn } from "@/utils/utils"; interface PageContainerProps { children: ReactNode; className?: string; - maxWidth?: "sm" | "md" | "lg" | "xl" | "2xl" | "7xl" | "full"; + maxWidth?: "sm" | "md" | "lg" | "xl" | "2xl" | "3xl" | "6xl" | "7xl" | "full"; noPadding?: boolean; } @@ -20,6 +20,8 @@ export default function PageContainer({ lg: "max-w-screen-lg", xl: "max-w-screen-xl", "2xl": "max-w-screen-2xl", + "3xl": "max-w-3xl", + "6xl": "max-w-6xl", "7xl": "max-w-7xl", full: "max-w-full" }; diff --git a/apps/codebility/app/home/_components/PageLoadingAnimation.tsx b/apps/codebility/app/home/_components/PageLoadingAnimation.tsx deleted file mode 100644 index 5d2f6d0f4..000000000 --- a/apps/codebility/app/home/_components/PageLoadingAnimation.tsx +++ /dev/null @@ -1,369 +0,0 @@ -// apps/codebility/app/home/_components/PageLoadingAnimation.tsx -"use client"; - -import { motion } from "framer-motion"; -import Image from "next/image"; -import { useEffect, useState } from "react"; - -export default function PageLoadingAnimation() { - const [particles, setParticles] = useState>([]); - const [isMobile, setIsMobile] = useState(false); - - // Generate random particles for background effect and check if mobile - useEffect(() => { - const newParticles = Array.from({ length: 50 }, (_, i) => ({ - id: i, - x: Math.random() * 100, - y: Math.random() * 100, - delay: Math.random() * 3, - })); - setParticles(newParticles); - - // Check if mobile - const checkIsMobile = () => { - setIsMobile(window.innerWidth < 640); - }; - - checkIsMobile(); - window.addEventListener('resize', checkIsMobile); - return () => window.removeEventListener('resize', checkIsMobile); - }, []); - - return ( -
- {/* Animated grid background */} -
-
-
- - {/* Floating particles */} - {particles.map((particle) => ( - - ))} - - {/* Scanning line effect */} - - - {/* - FIX (CBP-131): Changed from `relative` to `relative flex flex-col items-center gap-8` - so the polygon and the status group stack vertically and center naturally. - Previously, the status group was `absolute` positioned relative to this div, - which was only as wide as the hexagon (128px), causing the wider status content - to overflow rightward ("lower-right drift"). - */} -
- {/* Hexagonal container */} - - {/* Outer hexagon ring */} - - - - - - - - - - - - - - {/* Inner rotating elements */} -
- {/* Triple ring system */} - {[0, 1, 2].map((ring) => ( - -
- - ))} - - {/* Center logo with glitch effect */} - - {/* Glitch layers */} - - - - - - - - -
-
- - {/* Matrix-style code rain — kept as absolute so it doesn't affect flex layout */} -
- {['01010', '11001', '10110', '00111'].map((code, i) => ( - - {code.split('').map((char, j) => ( - - {char} - - ))} - - ))} -
- - {/* - FIX (CBP-131): Removed `absolute -bottom-20 left-1/2 transform -translate-x-1/2`. - Now a normal flex child — centers automatically under the polygon via the - parent's `flex flex-col items-center`. `w-full max-w-xs sm:max-w-sm md:max-w-md` - is preserved for responsive width capping. - */} - -
- {/* Typewriter loading text */} - - - {isMobile ? "Loading..." : "Initializing quantum framework..."} - - - - {/* Holographic progress bar */} -
- - - {/* Progress bar glow */} - -
- - {/* Status indicators */} -
- {['CPU', 'GPU', 'RAM'].map((label, i) => ( - - - {label} - - ))} -
-
-
-
- - {/* Corner accents */} - {[ - { corner: 'top-left', rotate: 0 }, - { corner: 'top-right', rotate: 90 }, - { corner: 'bottom-left', rotate: 270 }, - { corner: 'bottom-right', rotate: 180 }, - ].map(({ corner, rotate }) => ( - - - - - - - - ))} -
- ); -} \ No newline at end of file diff --git a/apps/codebility/app/home/_components/PageTransitionSettings.tsx b/apps/codebility/app/home/_components/PageTransitionSettings.tsx deleted file mode 100644 index a8c9b22a1..000000000 --- a/apps/codebility/app/home/_components/PageTransitionSettings.tsx +++ /dev/null @@ -1,139 +0,0 @@ -"use client"; - -import { useEffect, useState } from "react"; -import { motion } from "framer-motion"; -import { Loader2, Settings, X } from "lucide-react"; - -export function PageTransitionSettings() { - const [isOpen, setIsOpen] = useState(false); - const [animationEnabled, setAnimationEnabled] = useState(true); - const [animationDuration, setAnimationDuration] = useState(800); - - useEffect(() => { - // Load preferences from localStorage - const savedEnabled = localStorage.getItem('pageAnimationEnabled'); - const savedDuration = localStorage.getItem('pageAnimationDuration'); - - if (savedEnabled !== null) { - setAnimationEnabled(savedEnabled === 'true'); - } - if (savedDuration !== null) { - setAnimationDuration(parseInt(savedDuration)); - } - }, []); - - const handleToggleAnimation = (enabled: boolean) => { - setAnimationEnabled(enabled); - localStorage.setItem('pageAnimationEnabled', enabled.toString()); - - // Dispatch event to notify PageTransitionWrapper - window.dispatchEvent(new CustomEvent('pageAnimationSettingsChanged', { - detail: { enabled, duration: animationDuration } - })); - }; - - const handleDurationChange = (duration: number) => { - setAnimationDuration(duration); - localStorage.setItem('pageAnimationDuration', duration.toString()); - - // Dispatch event to notify PageTransitionWrapper - window.dispatchEvent(new CustomEvent('pageAnimationSettingsChanged', { - detail: { enabled: animationEnabled, duration } - })); - }; - - return ( - <> - {/* Settings button - only visible on larger screens */} - - - {/* Settings modal */} - {isOpen && ( - setIsOpen(false)} - > - e.stopPropagation()} - > -
-

- Page Transition Settings -

- -
- -
- {/* Enable/Disable animation */} -
-
- - -
- -
- - {/* Animation duration */} -
- - handleDurationChange(parseInt(e.target.value))} - className="w-full" - disabled={!animationEnabled} - /> -
- Fast (200ms) - Slow (1500ms) -
-
- - {/* Info */} -
- Page animations help create smooth transitions between pages. - Disable them if you prefer instant page loads or are experiencing performance issues. -
-
-
-
- )} - - ); -} \ No newline at end of file diff --git a/apps/codebility/app/home/_components/PageTransitionWrapper.tsx b/apps/codebility/app/home/_components/PageTransitionWrapper.tsx deleted file mode 100644 index c4f87a5f4..000000000 --- a/apps/codebility/app/home/_components/PageTransitionWrapper.tsx +++ /dev/null @@ -1,127 +0,0 @@ -"use client"; - -import { useEffect, useState, useMemo } from "react"; -import { usePathname } from "next/navigation"; -import { motion, AnimatePresence } from "framer-motion"; -import { useNavStore } from "@/hooks/use-sidebar"; -import PageLoadingAnimation from "./PageLoadingAnimation"; - -// Hook to detect if we're on desktop -function useIsDesktop() { - const [isDesktop, setIsDesktop] = useState(false); - - useEffect(() => { - const checkIsDesktop = () => { - setIsDesktop(window.innerWidth >= 1024); - }; - - checkIsDesktop(); - window.addEventListener('resize', checkIsDesktop); - return () => window.removeEventListener('resize', checkIsDesktop); - }, []); - - return isDesktop; -} - -interface PageTransitionWrapperProps { - children: React.ReactNode; -} - -export default function PageTransitionWrapper({ children }: PageTransitionWrapperProps) { - const pathname = usePathname(); - const { isToggleOpen } = useNavStore(); - const isDesktop = useIsDesktop(); - const [isLoading, setIsLoading] = useState(true); - const [displayedPathname, setDisplayedPathname] = useState(pathname); - const [animationEnabled, setAnimationEnabled] = useState(true); - const [animationDuration, setAnimationDuration] = useState(1500); - - // Load user preferences - useEffect(() => { - const savedEnabled = localStorage.getItem('pageAnimationEnabled'); - const savedDuration = localStorage.getItem('pageAnimationDuration'); - - if (savedEnabled !== null) { - setAnimationEnabled(savedEnabled === 'true'); - } - if (savedDuration !== null) { - setAnimationDuration(parseInt(savedDuration)); - } - - // Listen for settings changes - const handleSettingsChange = (event: CustomEvent) => { - setAnimationEnabled(event.detail.enabled); - setAnimationDuration(event.detail.duration); - }; - - window.addEventListener('pageAnimationSettingsChanged', handleSettingsChange as EventListener); - return () => { - window.removeEventListener('pageAnimationSettingsChanged', handleSettingsChange as EventListener); - }; - }, []); - - useEffect(() => { - // Skip animation if disabled - if (!animationEnabled) { - setIsLoading(false); - setDisplayedPathname(pathname); - return; - } - - // Show loading animation when pathname changes - if (pathname !== displayedPathname) { - setIsLoading(true); - const timer = setTimeout(() => { - setIsLoading(false); - setDisplayedPathname(pathname); - }, animationDuration); - - return () => clearTimeout(timer); - } else { - // Initial load - const timer = setTimeout(() => { - setIsLoading(false); - }, animationDuration); - return () => clearTimeout(timer); - } - }, [pathname, displayedPathname, animationEnabled, animationDuration]); - - return ( -
- - {isLoading && ( - - - - )} - - - - {children} - -
- ); -} \ No newline at end of file diff --git a/apps/codebility/app/home/_components/SurveyWidget.tsx b/apps/codebility/app/home/_components/SurveyWidget.tsx index e163e29a0..7eb253ae6 100644 --- a/apps/codebility/app/home/_components/SurveyWidget.tsx +++ b/apps/codebility/app/home/_components/SurveyWidget.tsx @@ -1,88 +1,66 @@ "use client"; -import { useEffect, useState } from "react"; -import { useModal } from "@/hooks/use-modal"; -import { Button } from "@codevs/ui/button"; +import { useEffect, useRef, useState } from "react"; +import { useMutation } from "@tanstack/react-query"; import { MessageSquare, ChevronDown, ChevronUp } from "lucide-react"; -import { - getPendingSurveyForUser, - getDismissedSurveys, - undismissSurvey, -} from "../settings/surveys/actions"; -import { getSurveyQuestions } from "../settings/surveys/questions/actions"; -interface Survey { +import { useModal } from "@/hooks/modals/use-modal"; +import { Button } from "@codevs/ui/button"; +import { undismissSurvey } from "@/actions/settings/surveys"; +import { getSurveyQuestions } from "@/actions/settings/survey-questions"; + +export interface Survey { id: string; title: string; description: string; type: string; } -export default function SurveyWidget() { +export default function SurveyWidget({ + pendingSurvey, + initialDismissed, +}: { + pendingSurvey: unknown; + initialDismissed: Survey[]; +}) { const { onOpen } = useModal(); - const [pendingSurvey, setPendingSurvey] = useState(null); - const [dismissedSurveys, setDismissedSurveys] = useState([]); const [showDismissed, setShowDismissed] = useState(false); - const [loading, setLoading] = useState(true); + const [dismissed, setDismissed] = useState(initialDismissed); + // The modal reads its content from the store, so opening it is imperative. + // One-shot guard keeps a re-render from re-opening a survey already dismissed. + const autoOpened = useRef(false); useEffect(() => { - fetchSurveys(); - }, []); - - const fetchSurveys = async () => { - setLoading(true); - - try { - // Check for pending survey - const pendingResult = await getPendingSurveyForUser(); - - if (pendingResult.data) { - setPendingSurvey(pendingResult.data); - // Auto-open modal for pending survey - onOpen("surveyModal", pendingResult.data); - } - - // Get dismissed surveys - const dismissedResult = await getDismissedSurveys(); - - if (dismissedResult.data) { - setDismissedSurveys(dismissedResult.data); - } - } catch (error) { - // Error fetching surveys - } - - setLoading(false); - }; + if (!pendingSurvey || autoOpened.current) return; + autoOpened.current = true; + onOpen("surveyModal", pendingSurvey); + }, [pendingSurvey, onOpen]); - const handleReopenSurvey = async (survey: Survey) => { - const result = await undismissSurvey(survey.id); - if (result.success) { - // Get questions for the survey - const questionsResult = await getSurveyQuestions(survey.id); - if (questionsResult.data) { - const surveyWithQuestions = { - ...survey, - questions: questionsResult.data, - }; - onOpen("surveyModal", surveyWithQuestions); - // Remove from dismissed list - setDismissedSurveys((prev) => prev.filter((s) => s.id !== survey.id)); - } - } - }; + const reopen = useMutation({ + mutationFn: async (survey: Survey) => { + const result = await undismissSurvey(survey.id); + if (!result.success) throw new Error("Could not reopen survey"); + const questions = await getSurveyQuestions(survey.id); + return { ...survey, questions: questions.data ?? [] }; + }, + onSuccess: (surveyWithQuestions) => { + onOpen("surveyModal", surveyWithQuestions); + setDismissed((prev) => + prev.filter((s) => s.id !== surveyWithQuestions.id), + ); + }, + }); - // Don't show widget UI if no dismissed surveys, but component still needs to run to auto-open modal - if (loading || dismissedSurveys.length === 0) return null; + if (dismissed.length === 0) return null; return (
-
+
{showDismissed && ( -
- {dismissedSurveys.map((survey) => ( +
+ {dismissed.map((survey) => (
-
-

+

+

{survey.title}

-

+

{survey.description}

diff --git a/apps/codebility/app/home/_components/UserInactiveGate.tsx b/apps/codebility/app/home/_components/UserInactiveGate.tsx new file mode 100644 index 000000000..a56eb49f9 --- /dev/null +++ b/apps/codebility/app/home/_components/UserInactiveGate.tsx @@ -0,0 +1,16 @@ +"use client"; + +import { useState } from "react"; +import UserInactiveModal from "@/components/modals/UserInactiveModal"; + +/** + * Rendered only when the server already determined the user is inactive, so it + * just owns the open/closed state of the dialog. + */ +export default function UserInactiveGate() { + const [isOpen, setIsOpen] = useState(true); + + return ( + setIsOpen(false)} /> + ); +} diff --git a/apps/codebility/app/home/_components/news-banner-cookie.ts b/apps/codebility/app/home/_components/news-banner-cookie.ts new file mode 100644 index 000000000..fcd2213c2 --- /dev/null +++ b/apps/codebility/app/home/_components/news-banner-cookie.ts @@ -0,0 +1 @@ +export const DISMISSED_BANNERS_COOKIE = "dismissedBanners"; diff --git a/apps/codebility/app/home/_components/skeletons/RouteSkeletons.tsx b/apps/codebility/app/home/_components/skeletons/RouteSkeletons.tsx new file mode 100644 index 000000000..06687985f --- /dev/null +++ b/apps/codebility/app/home/_components/skeletons/RouteSkeletons.tsx @@ -0,0 +1,115 @@ +import Box from "@/components/shared/dashboard/Box"; +import { Skeleton } from "@/components/ui/skeleton/skeleton"; + +/** + * Shared route skeletons. Four shapes cover all 20 routes that lacked a + * `loading.tsx`, so each route needs only a three-line file rather than a + * bespoke skeleton. Server components: no "use client", no effects. + */ + +const Shell = ({ children }: { children: React.ReactNode }) => ( +
+ {children} +
+); + +/** Header bar + 8 table rows. Tables and list views. */ +export const ListSkeleton = () => ( + +
+ + +
+ + + {Array(8) + .fill(null) + .map((_, i) => ( +
+ + + + + +
+ ))} +
+
+); + +/** Title + 6 labelled field blocks. Forms and detail/edit panes. */ +export const FormSkeleton = () => ( + +
+ + +
+ + {Array(6) + .fill(null) + .map((_, i) => ( +
+ + +
+ ))} +
+ + +
+
+
+); + +/** Responsive grid of 6 cards. Feed and gallery views. */ +export const CardsSkeleton = () => ( + +
+ + +
+
+ {Array(6) + .fill(null) + .map((_, i) => ( + + + + +
+ + +
+
+ ))} +
+
+); + +/** Sidebar column + main pane. Master/detail routes. */ +export const DetailSkeleton = () => ( + + +
+
+ + + + + + + +
+
+ + + + + + + + +
+
+
+); diff --git a/apps/codebility/app/home/_services/actions.ts b/apps/codebility/app/home/_services/actions.ts deleted file mode 100644 index 053abdd50..000000000 --- a/apps/codebility/app/home/_services/actions.ts +++ /dev/null @@ -1,56 +0,0 @@ -"use server"; -import { createClientServerComponent } from "@/utils/supabase/server"; - -export const declinePromotion = async (userId: string): Promise => { - if (!userId) { - return; - } - - const supabase = await createClientServerComponent(); - - const { error } = await supabase - .from("codev") - .update({ promote_declined: true }) - .eq("id", userId); - - if (error) { - console.error("Error updating promote_declined:", error); - throw error; - } -}; - -export const acceptPromotionToCodev = async (userId: string): Promise => { - if (!userId) { - return; - } - - const supabase = await createClientServerComponent(); - - const { error } = await supabase - .from("codev") - .update({ role_id: 10 , promote_declined: null}) - .eq("id", userId); - - if (error) { - console.error("Error updating role_id:", error); - throw error; - } -}; - -export const acceptPromotionToMentor = async (userId: string): Promise => { - if (!userId) { - return; - } - - const supabase = await createClientServerComponent(); - - const { error } = await supabase - .from("codev") - .update({ role_id: 5 , promote_declined: null}) - .eq("id", userId); - - if (error) { - console.error("Error updating role_id:", error); - throw error; - } -}; \ No newline at end of file diff --git a/apps/codebility/app/home/account-settings/_components/AccountSettings2FA.tsx b/apps/codebility/app/home/account-settings/_components/AccountSettings2FA.tsx index b812ad373..a25ad7f07 100644 --- a/apps/codebility/app/home/account-settings/_components/AccountSettings2FA.tsx +++ b/apps/codebility/app/home/account-settings/_components/AccountSettings2FA.tsx @@ -1,7 +1,8 @@ "use client"; -import { useEffect, useState } from "react"; -import { createClientClientComponent } from "@/utils/supabase/client"; +import { useState } from "react"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; +import { getClientSupabase } from "@/utils/supabase/client"; import { ShieldCheck, ShieldAlert, CheckCircle2, Copy, QrCode, Key, Lock } from "lucide-react"; import toast from "react-hot-toast"; @@ -25,10 +26,7 @@ interface Factor { } export default function AccountSettings2FA() { - const [supabase] = useState(() => createClientClientComponent()); - const [loading, setLoading] = useState(true); - const [factors, setFactors] = useState([]); - const [activeFactor, setActiveFactor] = useState(null); + const queryClient = useQueryClient(); // Dialog States const [isEnrollOpen, setIsEnrollOpen] = useState(false); @@ -42,29 +40,31 @@ export default function AccountSettings2FA() { const [verificationCode, setVerificationCode] = useState(""); const [submitting, setSubmitting] = useState(false); - const fetchMfaFactors = async () => { - try { - setLoading(true); - const { data, error } = await supabase.auth.mfa.listFactors(); + // MFA factors live in auth state, so they are read through a query that the + // enrolment and verification paths invalidate. + const { data: factorsData, isPending: loading } = useQuery({ + queryKey: ["accountSettings", "mfaFactors"], + staleTime: 60_000, + queryFn: async () => { + const { data, error } = await getClientSupabase().auth.mfa.listFactors(); if (error) throw error; + return (data?.totp || []) as Factor[]; + }, + }); - const totpFactors = (data?.totp || []) as Factor[]; - setFactors(totpFactors); + const factors = factorsData ?? []; + const activeFactor = factors.find((f) => f.status === "verified") ?? null; - const verified = totpFactors.find((f) => f.status === "verified"); - setActiveFactor(verified || null); - } catch (err) { - console.error("Error fetching 2FA factors:", err); - } finally { - setLoading(false); - } - }; - - useEffect(() => { - fetchMfaFactors(); - }, []); + const fetchMfaFactors = () => + queryClient.invalidateQueries({ queryKey: ["accountSettings", "mfaFactors"] }); const handleStartEnrollment = async () => { + const supabase = getClientSupabase(); + if (!supabase) { + toast.error("Authentication service unavailable"); + return; + } + try { setSubmitting(true); setVerificationCode(""); @@ -96,6 +96,12 @@ export default function AccountSettings2FA() { return; } + const supabase = getClientSupabase(); + if (!supabase) { + toast.error("Authentication service unavailable"); + return; + } + try { setSubmitting(true); @@ -125,6 +131,12 @@ export default function AccountSettings2FA() { const handleDisable2FA = async () => { if (!activeFactor) return; + const supabase = getClientSupabase(); + if (!supabase) { + toast.error("Authentication service unavailable"); + return; + } + try { setSubmitting(true); const { error } = await supabase.auth.mfa.unenroll({ diff --git a/apps/codebility/app/home/account-settings/_components/AccountSettingsChangePassword.tsx b/apps/codebility/app/home/account-settings/_components/AccountSettingsChangePassword.tsx index c0e551ad0..b8ec8540e 100644 --- a/apps/codebility/app/home/account-settings/_components/AccountSettingsChangePassword.tsx +++ b/apps/codebility/app/home/account-settings/_components/AccountSettingsChangePassword.tsx @@ -1,7 +1,6 @@ "use client"; -import { useEffect, useState } from "react"; -import { createClientClientComponent } from "@/utils/supabase/client"; +import { getClientSupabase } from "@/utils/supabase/client"; import { zodResolver } from "@hookform/resolvers/zod"; import { useForm } from "react-hook-form"; import toast from "react-hot-toast"; @@ -16,7 +15,7 @@ import { FormMessage, } from "@codevs/ui/form"; import { Input } from "@codevs/ui/input"; -import { updatePassword } from "../action"; +import { updatePassword } from "@/actions/account-settings/actions"; // Password validation schema const passwordChangeSchema = z @@ -34,13 +33,6 @@ const passwordChangeSchema = z }); export default function AccountSettingsChangePassword() { - const [supabase, setSupabase] = useState(null); - - useEffect(() => { - const supabaseClient = createClientClientComponent(); - setSupabase(supabaseClient); - }, []); - const form = useForm>({ resolver: zodResolver(passwordChangeSchema), defaultValues: { @@ -52,7 +44,7 @@ export default function AccountSettingsChangePassword() { const onSubmit = async (values: z.infer) => { try { - const { error } = await supabase.auth.updateUser({ + const { error } = await getClientSupabase().auth.updateUser({ password: values.password, }); diff --git a/apps/codebility/app/home/account-settings/_components/AccountSettingsDelete.tsx b/apps/codebility/app/home/account-settings/_components/AccountSettingsDelete.tsx index 53d0feea9..2b1f7792e 100644 --- a/apps/codebility/app/home/account-settings/_components/AccountSettingsDelete.tsx +++ b/apps/codebility/app/home/account-settings/_components/AccountSettingsDelete.tsx @@ -1,8 +1,8 @@ "use client"; -import { useEffect, useState } from "react"; +import { useState } from "react"; import { useRouter } from "next/navigation"; -import { createClientClientComponent } from "@/utils/supabase/client"; +import { getClientSupabase } from "@/utils/supabase/client"; import { zodResolver } from "@hookform/resolvers/zod"; import { DialogClose } from "@radix-ui/react-dialog"; import { AlertCircle, HelpCircleIcon, Trash2 } from "lucide-react"; @@ -58,12 +58,6 @@ export default function AccountSettingsDelete() { const [isOpen, setIsOpen] = useState(false); const [isLoading, setIsLoading] = useState(false); const router = useRouter(); - const [supabase, setSupabase] = useState(null); - - useEffect(() => { - const supabaseClient = createClientClientComponent(); - setSupabase(supabaseClient); - }, []); const form = useForm({ resolver: zodResolver(userDeletionSchema), @@ -74,6 +68,7 @@ export default function AccountSettingsDelete() { }); const onSubmit = async (values: UserDeletionFormValues) => { + const supabase = getClientSupabase(); try { setIsLoading(true); diff --git a/apps/codebility/app/home/account-settings/_components/AccountSettingsDialog.tsx b/apps/codebility/app/home/account-settings/_components/AccountSettingsDialog.tsx index 52e8d374f..4fc85afe2 100644 --- a/apps/codebility/app/home/account-settings/_components/AccountSettingsDialog.tsx +++ b/apps/codebility/app/home/account-settings/_components/AccountSettingsDialog.tsx @@ -1,6 +1,6 @@ "use client"; -import { useEffect, useState } from "react"; +import { useState } from "react"; import { zodResolver } from "@hookform/resolvers/zod"; import { useForm } from "react-hook-form"; @@ -28,8 +28,7 @@ import { import { Input } from "@codevs/ui/input"; import AccountSettingsBackdrop from "./AccountSettingsBackDrop"; -import { updateEmail } from "../action"; -import { createClientClientComponent } from "@/utils/supabase/client"; +import { getClientSupabase } from "@/utils/supabase/client"; const emailChangeSchema = z.object({ email: z.string().email("Please enter a valid email address"), @@ -38,13 +37,6 @@ const emailChangeSchema = z.object({ export default function AccountSettingsDialog() { const [isOpen, setIsOpen] = useState(false); const [isLoading, setIsLoading] = useState(false); - const [supabase, setSupabase] = useState(null); - - useEffect(() => { - const supabaseClient = createClientClientComponent(); - setSupabase(supabaseClient); - }, []); - const form = useForm>({ resolver: zodResolver(emailChangeSchema), @@ -54,6 +46,7 @@ export default function AccountSettingsDialog() { }); const onSubmit = async (values: z.infer) => { + const supabase = getClientSupabase(); try { setIsLoading(true); diff --git a/apps/codebility/app/home/account-settings/_components/AccountSettingsUsername.tsx b/apps/codebility/app/home/account-settings/_components/AccountSettingsUsername.tsx index c9a5e3ac3..63d777357 100644 --- a/apps/codebility/app/home/account-settings/_components/AccountSettingsUsername.tsx +++ b/apps/codebility/app/home/account-settings/_components/AccountSettingsUsername.tsx @@ -1,6 +1,8 @@ "use client"; -import { useState, useEffect } from "react"; +import { useState } from "react"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; +import { useDebounce } from "use-debounce"; import { Button } from "@codevs/ui/button"; import { Input } from "@codevs/ui/input"; import { Label } from "@codevs/ui/label"; @@ -11,7 +13,7 @@ import { TooltipProvider, TooltipTrigger, } from "@codevs/ui/tooltip"; -import { checkUsernameAvailability, updateUsername, getUsernameData } from "../action"; +import { checkUsernameAvailability, updateUsername, getUsernameData } from "@/actions/account-settings/actions"; import { useToast } from "@codevs/ui/use-toast"; interface AccountSettingsUsernameProps { @@ -20,76 +22,55 @@ interface AccountSettingsUsernameProps { export default function AccountSettingsUsername({ userId }: AccountSettingsUsernameProps) { const [username, setUsername] = useState(""); - const [currentUsername, setCurrentUsername] = useState(""); - const [copied, setCopied] = useState(false); - const [checking, setChecking] = useState(false); - const [updating, setUpdating] = useState(false); - const [availabilityStatus, setAvailabilityStatus] = useState<{ - available: boolean | null; - message: string; - }>({ available: null, message: "" }); - const [cooldownDays, setCooldownDays] = useState(null); - + const [copied, setCopied] = useState(false); const { toast } = useToast(); + const queryClient = useQueryClient(); + const [debouncedUsername] = useDebounce(username, 500); + const [updating, setUpdating] = useState(false); - // Fetch current username data on mount - useEffect(() => { - const fetchUserData = async () => { - const result = await getUsernameData(userId); - if (result.success && result.data) { - setCurrentUsername(result.data.username || ""); - - // Calculate cooldown - if (result.data.username_updated_at) { - const lastUpdate = new Date(result.data.username_updated_at); - const now = new Date(); - const daysSinceUpdate = Math.floor((now.getTime() - lastUpdate.getTime()) / (1000 * 60 * 60 * 24)); - const remaining = Math.max(0, 30 - daysSinceUpdate); - setCooldownDays(remaining); - } else { - setCooldownDays(0); - } - } - }; - fetchUserData(); - }, [userId]); - - // Debounced username availability check - useEffect(() => { - if (!username || username.length < 8) { - setAvailabilityStatus({ available: null, message: "" }); - return; - } - - // Don't check if it's the same as current username - if (username.toLowerCase() === currentUsername.toLowerCase()) { - setAvailabilityStatus({ - available: null, - message: "This is your current username" - }); - return; - } - - const timeoutId = setTimeout(async () => { - setChecking(true); - const result = await checkUsernameAvailability(username, userId); - setChecking(false); - - if (result.available) { - setAvailabilityStatus({ - available: true, - message: "Username is available", - }); - } else { - setAvailabilityStatus({ - available: false, - message: result.error || "Username is not available", - }); - } - }, 500); // 500ms debounce - - return () => clearTimeout(timeoutId); - }, [username, userId, currentUsername]); + const { data: usernameData } = useQuery({ + queryKey: ["accountSettings", "username", userId], + enabled: Boolean(userId), + staleTime: 60_000, + queryFn: () => getUsernameData(userId), + }); + + const currentUsername = usernameData?.success ? (usernameData.data?.username ?? "") : ""; + const cooldownDays = (() => { + const updatedAt = usernameData?.success ? usernameData.data?.username_updated_at : null; + if (!updatedAt) return usernameData ? 0 : null; + const daysSince = Math.floor( + (Date.now() - new Date(updatedAt).getTime()) / (1000 * 60 * 60 * 24), + ); + return Math.max(0, 30 - daysSince); + })(); + + // Typeahead check: per-interaction, so it is a query keyed on the debounced + // value rather than a timer-managed effect. + const shouldCheck = + debouncedUsername.length >= 8 && + debouncedUsername.toLowerCase() !== currentUsername.toLowerCase(); + + const { data: availability, isFetching: checking } = useQuery({ + queryKey: ["accountSettings", "usernameAvailability", debouncedUsername, userId], + enabled: shouldCheck, + staleTime: 30_000, + queryFn: () => checkUsernameAvailability(debouncedUsername, userId), + }); + + const availabilityStatus = + debouncedUsername.length < 8 + ? { available: null as boolean | null, message: "" } + : !shouldCheck + ? { available: null as boolean | null, message: "This is your current username" } + : availability + ? { + available: availability.available, + message: availability.available + ? "Username is available" + : availability.error || "Username is not available", + } + : { available: null as boolean | null, message: "" }; const handleChangeUsername = async () => { if (!username || username.length < 8) return; @@ -104,7 +85,7 @@ export default function AccountSettingsUsername({ userId }: AccountSettingsUsern } setUpdating(true); - const result = await updateUsername(userId, username); + const result = await updateUsername(username); setUpdating(false); if (result.success) { @@ -112,9 +93,10 @@ export default function AccountSettingsUsername({ userId }: AccountSettingsUsern title: "Success", description: "Username updated successfully", }); - setCurrentUsername(username); setUsername(""); - setCooldownDays(30); + await queryClient.invalidateQueries({ + queryKey: ["accountSettings", "username", userId], + }); } else { toast({ title: "Error", @@ -167,7 +149,7 @@ export default function AccountSettingsUsername({ userId }: AccountSettingsUsern {cooldownDays !== null && cooldownDays > 0 && (

- ⏳ You can change your username again in {cooldownDays} day{cooldownDays !== 1 ? 's' : ''} + ⏳ You can change your username again in {cooldownDays} day{cooldownDays !== 1 ? 's' : ''}

)} diff --git a/apps/codebility/app/home/account-settings/loading.tsx b/apps/codebility/app/home/account-settings/loading.tsx new file mode 100644 index 000000000..44758374e --- /dev/null +++ b/apps/codebility/app/home/account-settings/loading.tsx @@ -0,0 +1,7 @@ +import { FormSkeleton } from "@/app/home/_components/skeletons/RouteSkeletons"; + +const Loading = () => { + return ; +}; + +export default Loading; diff --git a/apps/codebility/app/home/account-settings/page.tsx b/apps/codebility/app/home/account-settings/page.tsx index 2bc710458..f1074f5e5 100644 --- a/apps/codebility/app/home/account-settings/page.tsx +++ b/apps/codebility/app/home/account-settings/page.tsx @@ -1,12 +1,10 @@ -"use client"; - -import { useEffect, useState } from "react"; -import { useRouter } from "next/navigation"; -import { createClientClientComponent } from "@/utils/supabase/client"; +import { Suspense } from "react"; +import { redirect } from "next/navigation"; import { H1 } from "@/components/shared/dashboard"; import CustomBreadcrumb from "@/components/shared/dashboard/CustomBreadcrumb"; +import { createClientServerComponent } from "@/utils/supabase/server"; -import { Card, CardContent, CardFooter } from "@codevs/ui/card"; +import { Card, CardContent } from "@codevs/ui/card"; import { Separator } from "@codevs/ui/separator"; import AccountSettings2FA from "./_components/AccountSettings2FA"; @@ -15,158 +13,111 @@ import AccountSettingsDelete from "./_components/AccountSettingsDelete"; import AccountSettingsHeader from "./_components/AccountSettingsHeader"; import AccountSettingsUsername from "./_components/AccountSettingsUsername"; -export const dynamic = "force-dynamic"; const items = [ { label: "Settings", href: "/home/settings" }, { label: "Account Settings" }, ]; -interface User { - id: string; - email?: string; -} - -export default function AccountSettingsPage({ showBreadcrumb = true }: { showBreadcrumb?: boolean }) { - const [user, setUser] = useState(null); - const [loading, setLoading] = useState(true); - const [supabase, setSupabase] = useState(null); - const router = useRouter(); - - useEffect(() => { - const supabaseClient = createClientClientComponent(); - setSupabase(supabaseClient); - }, []); - - // Initialize Supabase client safely on the client side - /* useEffect(() => { - try { - const client = createClientClientComponent(); - setSupabase(client); - } catch (error) { - console.error("Failed to initialize Supabase client:", error); - router.push("/auth/login"); - } - }, [router]); */ - - useEffect(() => { - if (!supabase) return; - - async function getUser() { - try { - const { - data: { user: authUser }, - error, - } = await supabase.auth.getUser(); - - if (error || !authUser) { - router.push("/auth/login"); - return; - } - - setUser({ - id: authUser.id, - email: authUser.email, - }); - } catch (error) { - console.error("Error getting user:", error); - router.push("/auth/login"); - } finally { - setLoading(false); - } - } - - getUser(); - }, [router, supabase]); - - const LoadingContent = () => ( -
- - -
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+const LoadingContent = () => ( +
+ + +
+
+
+
+
+
+
- - - - - -
-
-
-
+
+
+
+
+
+
+
+
+
+
+
-
-
+
+
+ + + + + +
+
+
+
+
+
+
+
+
+
+
+); + +async function AccountSettingsContent() { + const supabase = await createClientServerComponent(); + const { + data: { user }, + error, + } = await supabase.auth.getUser(); + + if (error || !user) { + redirect("/auth/sign-in"); + } + + return ( +
+ + + + + + + + +
+ + + + + + + +
); +} - if (!user) { - return null; // Will redirect in useEffect - } - +export default function AccountSettingsPage({ + showBreadcrumb = true, +}: { + showBreadcrumb?: boolean; +}) { return (
- {showBreadcrumb && ( - - )} + {showBreadcrumb && }

Account Settings

Manage your account credentials and security settings. - - {loading ? ( - - ) : ( -
- - - - - - - - - - -
- - - - - - - -
- - -
- )} + + }> + +
); diff --git a/apps/codebility/app/home/admin-controls/appointments/_components/AppointmentTable.tsx b/apps/codebility/app/home/admin-controls/appointments/_components/AppointmentTable.tsx index c802b6550..5b0153c8b 100644 --- a/apps/codebility/app/home/admin-controls/appointments/_components/AppointmentTable.tsx +++ b/apps/codebility/app/home/admin-controls/appointments/_components/AppointmentTable.tsx @@ -2,7 +2,7 @@ import { useState } from "react"; import { useMutation } from "@tanstack/react-query"; -import { updateAppointmentStatus } from "../actions"; +import { updateAppointmentStatus } from "@/actions/admin/appointments"; import { format } from "date-fns"; import { toast } from "sonner"; import { useRouter } from "next/navigation"; diff --git a/apps/codebility/app/home/admin-controls/appointments/loading.tsx b/apps/codebility/app/home/admin-controls/appointments/loading.tsx new file mode 100644 index 000000000..344408774 --- /dev/null +++ b/apps/codebility/app/home/admin-controls/appointments/loading.tsx @@ -0,0 +1,7 @@ +import { ListSkeleton } from "@/app/home/_components/skeletons/RouteSkeletons"; + +const Loading = () => { + return ; +}; + +export default Loading; diff --git a/apps/codebility/app/home/admin-controls/appointments/page.tsx b/apps/codebility/app/home/admin-controls/appointments/page.tsx index 27255544e..39206900d 100644 --- a/apps/codebility/app/home/admin-controls/appointments/page.tsx +++ b/apps/codebility/app/home/admin-controls/appointments/page.tsx @@ -39,7 +39,9 @@ export default async function AppointmentsAdminPage() { // 4. Authorized query execution matching the validated database columns const { data: appointmentsData, error } = await supabase .from("appointments") - .select("*") + .select( + "id, first_name, last_name, email, phone_number, company_name, industry, service_interest, project_type, features_needed, referral_source, interest_level, other_requirements, appointment_date, appointment_time, meeting_type, meeting_tool_other, status, created_at", + ) .order("created_at", { ascending: false }); if (error) { diff --git a/apps/codebility/app/home/admin-controls/client-tracker/_components/ClientTrackerContent.tsx b/apps/codebility/app/home/admin-controls/client-tracker/_components/ClientTrackerContent.tsx index 6973ca0bb..22bac8f7f 100644 --- a/apps/codebility/app/home/admin-controls/client-tracker/_components/ClientTrackerContent.tsx +++ b/apps/codebility/app/home/admin-controls/client-tracker/_components/ClientTrackerContent.tsx @@ -1,6 +1,7 @@ "use client"; -import { useEffect, useState } from "react"; +import { useState } from "react"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; import { Plus, TrendingUp, Users, Calendar, Eye, Trash2 } from "lucide-react"; import { Button } from "@/components/ui/button"; import { @@ -30,13 +31,14 @@ import { deleteClientOutreach, type AdminOutreachStats, type ClientOutreach, -} from "../actions"; +} from "@/actions/admin/client-tracker"; import { getCurrentWeekStart } from "../utils"; import { Skeleton } from "@/components/ui/skeleton/skeleton"; +const outreachStatsKey = ["client-tracker", "stats"] as const; + export default function ClientTrackerContent() { - const [stats, setStats] = useState([]); - const [loading, setLoading] = useState(true); + const queryClient = useQueryClient(); const [addDialogOpen, setAddDialogOpen] = useState(false); const [historyDialogOpen, setHistoryDialogOpen] = useState(false); const [selectedAdmin, setSelectedAdmin] = useState(null); @@ -59,20 +61,18 @@ export default function ClientTrackerContent() { const weekEnd = new Date(weekStart); weekEnd.setDate(weekStart.getDate() + 6); - const fetchStats = async () => { - setLoading(true); - const result = await getAdminOutreachStats(); - if (result.success && result.data) { - setStats(result.data); - } else { - toast.error(result.error || "Failed to load stats"); - } - setLoading(false); - }; + const { data: stats = [], isLoading: loading } = useQuery({ + queryKey: outreachStatsKey, + queryFn: async () => { + const result = await getAdminOutreachStats(); + + if (!result.success || !result.data) { + throw new Error(result.error || "Failed to load stats"); + } - useEffect(() => { - fetchStats(); - }, []); + return result.data; + }, + }); const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); @@ -92,7 +92,7 @@ export default function ClientTrackerContent() { }); setImagePreview(""); setAddDialogOpen(false); - fetchStats(); // Refresh stats + queryClient.invalidateQueries({ queryKey: outreachStatsKey }); // Refresh stats } else { toast.error(result.error || "Failed to add outreach"); } @@ -166,7 +166,7 @@ export default function ClientTrackerContent() { if (result.success) { toast.success("Outreach deleted"); setHistory(prev => prev.filter(h => h.id !== outreachId)); - fetchStats(); // Refresh stats + queryClient.invalidateQueries({ queryKey: outreachStatsKey }); // Refresh stats } else { toast.error(result.error || "Failed to delete"); } diff --git a/apps/codebility/app/home/admin-controls/client-tracker/loading.tsx b/apps/codebility/app/home/admin-controls/client-tracker/loading.tsx new file mode 100644 index 000000000..344408774 --- /dev/null +++ b/apps/codebility/app/home/admin-controls/client-tracker/loading.tsx @@ -0,0 +1,7 @@ +import { ListSkeleton } from "@/app/home/_components/skeletons/RouteSkeletons"; + +const Loading = () => { + return ; +}; + +export default Loading; diff --git a/apps/codebility/app/home/admin-controls/page.tsx b/apps/codebility/app/home/admin-controls/page.tsx index 4166daaf2..4ee7a2b82 100644 --- a/apps/codebility/app/home/admin-controls/page.tsx +++ b/apps/codebility/app/home/admin-controls/page.tsx @@ -4,8 +4,6 @@ import { H1 } from "@/components/shared/dashboard"; import { adminControlsCardData } from "@/constants/settings"; import PageContainer from "../_components/PageContainer"; -export const dynamic = "force-dynamic"; -export const revalidate = 0; const AdminControls = () => { diff --git a/apps/codebility/app/home/admin-controls/ticket-support/_components/TicketManagementView.tsx b/apps/codebility/app/home/admin-controls/ticket-support/_components/TicketManagementView.tsx index 11083cd90..c9bb6a438 100644 --- a/apps/codebility/app/home/admin-controls/ticket-support/_components/TicketManagementView.tsx +++ b/apps/codebility/app/home/admin-controls/ticket-support/_components/TicketManagementView.tsx @@ -1,11 +1,11 @@ "use client"; -import { useState, useMemo, useEffect } from "react"; +import { useState, useMemo } from "react"; import { useRouter } from "next/navigation"; import type { TicketSupport } from "../types"; import TicketDataTable from "./TicketDataTable"; import TicketPreviewSidebar from "./TicketPreviewSidebar"; -import { useNavStore } from "@/hooks/use-sidebar"; +import { useNavStore } from "@/hooks/navigation/use-sidebar"; interface TicketManagementViewProps { initialTickets: TicketSupport[]; @@ -36,7 +36,7 @@ export default function TicketManagementView({ }: TicketManagementViewProps) { const router = useRouter(); const { } = useNavStore(); - const [selectedTicket, setSelectedTicket] = useState(null); + const [selectedTicketId, setSelectedTicketId] = useState(null); const [searchQuery, setSearchQuery] = useState(""); const [sortField, setSortField] = useState("created_at"); const [sortDirection, setSortDirection] = useState("desc"); @@ -45,19 +45,12 @@ export default function TicketManagementView({ const [filterAssignedTo, setFilterAssignedTo] = useState("ALL"); const [showArchived, setShowArchived] = useState(false); - - useEffect(() => { - if (selectedTicket) { - const updated = initialTickets.find((t) => t.id === selectedTicket.id); - if (updated) { - - setSelectedTicket(updated); - } else { - - handleCloseSidebar(); - } - } - }, [initialTickets]); + // Resolved from the latest server props, so a refresh updates the open + // sidebar and a deleted ticket closes it without a sync effect. + const selectedTicket = useMemo( + () => initialTickets.find((t) => t.id === selectedTicketId) ?? null, + [initialTickets, selectedTicketId], + ); const filteredAndSortedTickets = useMemo(() => { let result = [...initialTickets]; @@ -127,11 +120,11 @@ export default function TicketManagementView({ }, [initialTickets, searchQuery, sortField, sortDirection, filterStatus, filterPriority, filterAssignedTo, showArchived]); const handleTicketSelect = (ticket: TicketSupport) => { - setSelectedTicket(ticket); + setSelectedTicketId(ticket.id); }; const handleCloseSidebar = () => { - setSelectedTicket(null); + setSelectedTicketId(null); }; const handleTicketUpdated = () => { diff --git a/apps/codebility/app/home/admin-controls/ticket-support/_components/TicketPreviewSidebar.tsx b/apps/codebility/app/home/admin-controls/ticket-support/_components/TicketPreviewSidebar.tsx index 4e2c140e6..180bb8577 100644 --- a/apps/codebility/app/home/admin-controls/ticket-support/_components/TicketPreviewSidebar.tsx +++ b/apps/codebility/app/home/admin-controls/ticket-support/_components/TicketPreviewSidebar.tsx @@ -27,7 +27,7 @@ import { deleteTicket, archiveTicket, unarchiveTicket, -} from "../actions"; +} from "@/actions/admin/ticket-support"; import { Archive, ArchiveRestore } from "lucide-react"; interface TicketPreviewSidebarProps { diff --git a/apps/codebility/app/home/admin-controls/ticket-support/loading.tsx b/apps/codebility/app/home/admin-controls/ticket-support/loading.tsx new file mode 100644 index 000000000..344408774 --- /dev/null +++ b/apps/codebility/app/home/admin-controls/ticket-support/loading.tsx @@ -0,0 +1,7 @@ +import { ListSkeleton } from "@/app/home/_components/skeletons/RouteSkeletons"; + +const Loading = () => { + return ; +}; + +export default Loading; diff --git a/apps/codebility/app/home/admin-controls/ticket-support/page.tsx b/apps/codebility/app/home/admin-controls/ticket-support/page.tsx index 59b3c525d..4e8b92174 100644 --- a/apps/codebility/app/home/admin-controls/ticket-support/page.tsx +++ b/apps/codebility/app/home/admin-controls/ticket-support/page.tsx @@ -3,11 +3,9 @@ import { Ticket } from "lucide-react"; import { Skeleton } from "@/components/ui/skeleton/skeleton"; import H1 from "@/components/shared/dashboard/H1"; import PageContainer from "../../_components/PageContainer"; -import { getTickets, getCodevList } from "./actions"; +import { getTickets, getCodevList } from "@/actions/admin/ticket-support"; import TicketManagementView from "./_components/TicketManagementView"; -export const dynamic = "force-dynamic"; -export const revalidate = 0; function TicketManagementLoading() { return ( diff --git a/apps/codebility/app/home/admin-dashboard/_components/AdminDashboardApplicantStatusPie.tsx b/apps/codebility/app/home/admin-dashboard/_components/AdminDashboardApplicantStatusPie.tsx index 4ed732282..713a650a8 100644 --- a/apps/codebility/app/home/admin-dashboard/_components/AdminDashboardApplicantStatusPie.tsx +++ b/apps/codebility/app/home/admin-dashboard/_components/AdminDashboardApplicantStatusPie.tsx @@ -15,7 +15,7 @@ import { ChartTooltip, ChartTooltipContent, } from "@/components/ui/chart"; -import { cn } from "@/lib/utils"; +import { cn } from "@/utils/utils"; import { Cell, Pie, PieChart } from "recharts"; interface ApplicantStatusCounts extends Record {} diff --git a/apps/codebility/app/home/admin-dashboard/_components/AdminDashboardMonthlyApplicantsLineChart.tsx b/apps/codebility/app/home/admin-dashboard/_components/AdminDashboardMonthlyApplicantsLineChart.tsx index 1403d1c1e..3eeca0ba7 100644 --- a/apps/codebility/app/home/admin-dashboard/_components/AdminDashboardMonthlyApplicantsLineChart.tsx +++ b/apps/codebility/app/home/admin-dashboard/_components/AdminDashboardMonthlyApplicantsLineChart.tsx @@ -14,7 +14,7 @@ import { ChartTooltip, ChartTooltipContent, } from "@/components/ui/chart"; -import { cn } from "@/lib/utils"; +import { cn } from "@/utils/utils"; import { TrendingDown, TrendingUp, Minus } from "lucide-react"; import { Area, @@ -84,11 +84,13 @@ export default function AdminDashboardMonthlyApplicantsLineChart({ ? "text-red-400" : "text-muted-foreground"; const trendLabel = - trendDiff > 0 - ? `Up ${trendPercent}% from last month` - : trendDiff < 0 - ? `Down ${trendPercent}% from last month` - : "No change from last month"; + trendPercent === null + ? "No prior month to compare" + : trendDiff > 0 + ? `Up ${trendPercent}% from last month` + : trendDiff < 0 + ? `Down ${trendPercent}% from last month` + : "No change from last month"; // Y-axis: nice rounded max with some breathing room const maxApplicants = Math.max(...chartData.map((d) => d.applicants), 0); diff --git a/apps/codebility/app/home/admin-dashboard/_components/AdminDashboardProjectsPie.tsx b/apps/codebility/app/home/admin-dashboard/_components/AdminDashboardProjectsPie.tsx index 454adde93..0da066596 100644 --- a/apps/codebility/app/home/admin-dashboard/_components/AdminDashboardProjectsPie.tsx +++ b/apps/codebility/app/home/admin-dashboard/_components/AdminDashboardProjectsPie.tsx @@ -15,7 +15,7 @@ import { ChartTooltip, ChartTooltipContent, } from "@/components/ui/chart"; -import { cn } from "@/lib/utils"; +import { cn } from "@/utils/utils"; import { Cell, Pie, PieChart } from "recharts"; interface ProjectCategoryCounts extends Record {} diff --git a/apps/codebility/app/home/admin-dashboard/_components/AdminDashboardStatsCard.tsx b/apps/codebility/app/home/admin-dashboard/_components/AdminDashboardStatsCard.tsx index dc0513133..d473c0f74 100644 --- a/apps/codebility/app/home/admin-dashboard/_components/AdminDashboardStatsCard.tsx +++ b/apps/codebility/app/home/admin-dashboard/_components/AdminDashboardStatsCard.tsx @@ -9,7 +9,7 @@ import { Users, } from "lucide-react"; import { useEffect, useRef, useState } from "react"; -import { cn } from "@/lib/utils"; +import { cn } from "@/utils/utils"; // Icon mapping const ICON_MAP = { diff --git a/apps/codebility/app/home/admin-dashboard/_components/AdminDashboardTotalActiveIntern.tsx b/apps/codebility/app/home/admin-dashboard/_components/AdminDashboardTotalActiveIntern.tsx index fc73f0f08..1391500cf 100644 --- a/apps/codebility/app/home/admin-dashboard/_components/AdminDashboardTotalActiveIntern.tsx +++ b/apps/codebility/app/home/admin-dashboard/_components/AdminDashboardTotalActiveIntern.tsx @@ -2,7 +2,7 @@ import { Card, CardContent, CardHeader } from "@/components/ui/card"; import { UserCheck } from "lucide-react"; -import { cn } from "@/lib/utils"; +import { cn } from "@/utils/utils"; interface AdminDashboardTotalActiveInternProps { count?: number; diff --git a/apps/codebility/app/home/admin-dashboard/_components/AdminDashboardTotalAdmin.tsx b/apps/codebility/app/home/admin-dashboard/_components/AdminDashboardTotalAdmin.tsx index ae7abefb2..067716b56 100644 --- a/apps/codebility/app/home/admin-dashboard/_components/AdminDashboardTotalAdmin.tsx +++ b/apps/codebility/app/home/admin-dashboard/_components/AdminDashboardTotalAdmin.tsx @@ -2,7 +2,7 @@ import { Card, CardContent, CardHeader } from "@/components/ui/card"; import { UserCog } from "lucide-react"; -import { cn } from "@/lib/utils"; +import { cn } from "@/utils/utils"; interface AdminDashboardTotalAdminProps { count?: number; diff --git a/apps/codebility/app/home/admin-dashboard/_components/AdminDashboardTotalCodev.tsx b/apps/codebility/app/home/admin-dashboard/_components/AdminDashboardTotalCodev.tsx index 6bb3ce9a2..e8bfd5ebd 100644 --- a/apps/codebility/app/home/admin-dashboard/_components/AdminDashboardTotalCodev.tsx +++ b/apps/codebility/app/home/admin-dashboard/_components/AdminDashboardTotalCodev.tsx @@ -2,7 +2,7 @@ import { Card, CardContent, CardHeader } from "@/components/ui/card"; import { UserPen } from "lucide-react"; -import { cn } from "@/lib/utils"; +import { cn } from "@/utils/utils"; interface AdminDashboardTotalMentorProps { count?: number; diff --git a/apps/codebility/app/home/admin-dashboard/_components/AdminDashboardTotalInactiveIntern.tsx b/apps/codebility/app/home/admin-dashboard/_components/AdminDashboardTotalInactiveIntern.tsx index 82b37497e..d3219bc26 100644 --- a/apps/codebility/app/home/admin-dashboard/_components/AdminDashboardTotalInactiveIntern.tsx +++ b/apps/codebility/app/home/admin-dashboard/_components/AdminDashboardTotalInactiveIntern.tsx @@ -2,7 +2,7 @@ import { Card, CardContent, CardHeader } from "@/components/ui/card"; import { UserX } from "lucide-react"; -import { cn } from "@/lib/utils"; +import { cn } from "@/utils/utils"; interface AdminDashboardTotalInactiveInternProps { count?: number; diff --git a/apps/codebility/app/home/admin-dashboard/_components/AdminDashboardTotalMentor.tsx b/apps/codebility/app/home/admin-dashboard/_components/AdminDashboardTotalMentor.tsx index 6bb3ce9a2..e8bfd5ebd 100644 --- a/apps/codebility/app/home/admin-dashboard/_components/AdminDashboardTotalMentor.tsx +++ b/apps/codebility/app/home/admin-dashboard/_components/AdminDashboardTotalMentor.tsx @@ -2,7 +2,7 @@ import { Card, CardContent, CardHeader } from "@/components/ui/card"; import { UserPen } from "lucide-react"; -import { cn } from "@/lib/utils"; +import { cn } from "@/utils/utils"; interface AdminDashboardTotalMentorProps { count?: number; diff --git a/apps/codebility/app/home/admin-dashboard/_components/AdminDashboardTotalProject.tsx b/apps/codebility/app/home/admin-dashboard/_components/AdminDashboardTotalProject.tsx index 4ee72675d..352163369 100644 --- a/apps/codebility/app/home/admin-dashboard/_components/AdminDashboardTotalProject.tsx +++ b/apps/codebility/app/home/admin-dashboard/_components/AdminDashboardTotalProject.tsx @@ -2,7 +2,7 @@ import { Card, CardContent, CardHeader } from "@/components/ui/card"; import { FolderKanban } from "lucide-react"; -import { cn } from "@/lib/utils"; +import { cn } from "@/utils/utils"; interface AdminDashboardTotalProjectsProps { count?: number; diff --git a/apps/codebility/app/home/admin-dashboard/page.tsx b/apps/codebility/app/home/admin-dashboard/page.tsx index ffda2f55c..8cee034d8 100644 --- a/apps/codebility/app/home/admin-dashboard/page.tsx +++ b/apps/codebility/app/home/admin-dashboard/page.tsx @@ -8,10 +8,8 @@ import AdminDashboardMonthlyApplicantsLineChart from "./_components/AdminDashboa import AdminDashboardProjectsPie from "./_components/AdminDashboardProjectsPie"; import AdminDashboardStatsCard from "./_components/AdminDashboardStatsCard"; -// Force dynamic rendering to avoid prerender errors with Supabase env vars +// Reads cookies via the Supabase server client, so this route is already dynamic. export const dynamic = "force-dynamic"; -// Use ISR with 60 second revalidation for dashboard stats -export const revalidate = 60; async function getDashboardData() { const supabase = await createClientServerComponent(); diff --git a/apps/codebility/app/home/announcements/AnnouncementButton.tsx b/apps/codebility/app/home/announcements/AnnouncementButton.tsx index 068176dd2..b63781f73 100644 --- a/apps/codebility/app/home/announcements/AnnouncementButton.tsx +++ b/apps/codebility/app/home/announcements/AnnouncementButton.tsx @@ -4,7 +4,7 @@ import React, { useState } from "react"; import { Megaphone } from "lucide-react"; import { AnnouncementModal } from "./AnnouncementModal"; import { useNotificationStore } from "@/store/notification-store"; -import { cn } from "@/lib/utils"; +import { cn } from "@/utils/utils"; export const AnnouncementButton: React.FC = () => { const [isModalOpen, setIsModalOpen] = useState(false); diff --git a/apps/codebility/app/home/announcements/AnnouncementModal.tsx b/apps/codebility/app/home/announcements/AnnouncementModal.tsx index 3089ad71f..6a871fe0b 100644 --- a/apps/codebility/app/home/announcements/AnnouncementModal.tsx +++ b/apps/codebility/app/home/announcements/AnnouncementModal.tsx @@ -3,11 +3,13 @@ import React, { useState, useEffect } from "react"; import { createPortal } from "react-dom"; import { X, Edit, ChevronLeft, Plus, Check, Trash2 } from "lucide-react"; +import { useQuery } from "@tanstack/react-query"; import { AnnouncementTab } from "./AnnouncementTab"; import { AnnouncementContent } from "./AnnouncementContent"; import { AnnouncementEditor } from "./AnnouncementEditor"; import { AnnouncementCategory, AnnouncementPage, AnnouncementTab as TabType } from "./types"; -import { createClientClientComponent } from "@/utils/supabase/client"; +import { getClientSupabase } from "@/utils/supabase/client"; +import { useUserStore } from "@/store/codev-store"; import { slugToLabel, labelToSlug } from "./utils"; interface AnnouncementModalProps { @@ -20,12 +22,7 @@ export const AnnouncementModal: React.FC = ({ onClose, }) => { const [activeTab, setActiveTab] = useState(""); - const [mounted, setMounted] = useState(false); - const [pages, setPages] = useState([]); - const [tabs, setTabs] = useState([]); const [isEditorOpen, setIsEditorOpen] = useState(false); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); const [showSidebar, setShowSidebar] = useState(true); // Category management state @@ -35,110 +32,88 @@ export const AnnouncementModal: React.FC = ({ const [categoryActionLoading, setCategoryActionLoading] = useState(false); const [deleteConfirm, setDeleteConfirm] = useState(null); - const supabase = createClientClientComponent(); - - const [isAdmin, setIsAdmin] = useState(false); - -// Add this useEffect alongside your existing ones - useEffect(() => { - const checkAdminStatus = async () => { - if (!supabase || !isOpen) return; - - try { - // Get current authenticated user - const { data: { user } } = await supabase.auth.getUser(); - - if (!user) return; - - // Find the codev record matching the auth user - const { data: codevData, error: codevError } = await supabase - .from("codev") - .select("id") - .eq("id", user.id) - .single(); - - if (codevError || !codevData) return; - - // Check if the codev id exists in admin_users - const { data: adminData, error: adminError } = await supabase - .from("admin_users") - .select("id") - .eq("id", codevData.id) - .single(); - - if (adminError || !adminData) return; - - setIsAdmin(true); - } catch (err) { - console.error("Error checking admin status:", err); - } - }; - - checkAdminStatus(); - }, [isOpen, supabase]); - - useEffect(() => { - setMounted(true); - }, []); - - // Fetch announcements from database - useEffect(() => { - const fetchAnnouncements = async () => { - if (!isOpen || !supabase) return; - - try { - setLoading(true); - setError(null); - - const { data, error: fetchError } = await supabase - .from("announcements") - .select("*") - .order("created_at"); - - if (fetchError) throw fetchError; - - const mappedData: AnnouncementPage[] = (data || []).map((item) => ({ - id: item.id, - category: item.category as AnnouncementCategory, - title: item.title, - banner_image: item.banner_image, - content: item.content, - last_updated: item.updated_at || item.created_at, - })); - - setPages(mappedData); - - // Build tabs from DB rows - const derivedTabs: TabType[] = mappedData.map((p) => ({ - id: p.category, - label: slugToLabel(p.category), - })); - setTabs(derivedTabs); - - if (derivedTabs.length > 0 && !activeTab) { - setActiveTab(derivedTabs[0]?.id ?? ""); - } - } catch (err) { - console.error("Error fetching announcements:", err); - setError( - `Failed to load announcements: ${err instanceof Error ? err.message : "Unknown error"}` - ); - } finally { - setLoading(false); - } - }; - - fetchAnnouncements(); - }, [isOpen]); - - useEffect(() => { - if (isOpen) { - setShowSidebar(true); - } - }, [isOpen]); + const supabase = getClientSupabase(); + const currentCodevId = useUserStore((s) => s.user?.id ?? null); + const [isAdminState, setIsAdmin] = useState(false); + + const { data: isAdminChecked = false } = useQuery({ + queryKey: ["announcements", "isAdmin", currentCodevId], + enabled: Boolean(isOpen && currentCodevId), + staleTime: 5 * 60_000, + queryFn: async () => { + const { data: adminData } = await getClientSupabase() + .from("admin_users") + .select("id") + .eq("id", currentCodevId) + .single(); + return Boolean(adminData); + }, + }); + + const isAdmin = isAdminState || isAdminChecked; + + const { data: fetchedPagesData, isPending: isPendingPages, error: pagesError } = useQuery({ + queryKey: ["announcements", "pages"], + enabled: Boolean(isOpen), + staleTime: 60_000, + queryFn: async () => { + const { data, error: fetchError } = await getClientSupabase() + .from("announcements") + .select("id, category, title, banner_image, content, updated_at, created_at") + .order("created_at"); + + if (fetchError) throw fetchError; + + return (data || []).map((item) => ({ + id: item.id, + category: item.category as AnnouncementCategory, + title: item.title, + banner_image: item.banner_image, + content: item.content, + last_updated: item.updated_at || item.created_at, + })) as AnnouncementPage[]; + }, + }); + + const [mutationError, setError] = useState(null); + + const fetchedPages = fetchedPagesData ?? null; + const loading = isPendingPages; + const error: string | null = mutationError + ? mutationError + : pagesError + ? `Failed to load announcements: ${pagesError instanceof Error ? pagesError.message : "Unknown error"}` + : null; + + // The saved rows seed editable drafts; edits after that stay local. Deriving + // the drafts during render rather than copying them in an effect keeps the + // first paint correct. + const [pagesDraftState, setPagesDraftState] = useState(null); + const [tabsDraft, setTabsDraft] = useState(null); + + const pages = pagesDraftState ?? fetchedPages ?? []; + const tabs: TabType[] = + tabsDraft ?? + (fetchedPages ?? []).map((p) => ({ + id: p.category, + label: slugToLabel(p.category), + })); + + const setPages = ( + update: AnnouncementPage[] | ((prev: AnnouncementPage[]) => AnnouncementPage[]), + ) => + setPagesDraftState((prev) => + typeof update === "function" ? update(prev ?? fetchedPages ?? []) : update, + ); + + const setTabs = ( + update: TabType[] | ((prev: TabType[]) => TabType[]), + ) => { + const current = tabs; + setTabsDraft(typeof update === "function" ? update(current) : update); + }; const activeContent = pages.find((page) => page.category === activeTab); - if (!supabase) throw new Error("Supabase client not initialized"); // ── Save existing page content ────────────────────────────────────────── const handleSave = async (updatedPage: AnnouncementPage) => { @@ -362,7 +337,12 @@ export const AnnouncementModal: React.FC = ({ if (window.innerWidth < 768) setShowSidebar(false); }; - if (!isOpen || !mounted) return null; + // The portal target only exists in the browser. Reading it during render is + // safe because this module is client-only, and it removes the mount effect + // that used to flip a `mounted` flag. + const canPortal = typeof document !== "undefined"; + + if (!isOpen || !canPortal) return null; const modalContent = ( <> diff --git a/apps/codebility/app/home/announcements/AnnouncementRichTextEditor.tsx b/apps/codebility/app/home/announcements/AnnouncementRichTextEditor.tsx index e5fd82b1e..5e1b72b98 100644 --- a/apps/codebility/app/home/announcements/AnnouncementRichTextEditor.tsx +++ b/apps/codebility/app/home/announcements/AnnouncementRichTextEditor.tsx @@ -43,6 +43,7 @@ export default function AnnouncementRichTextEditor({ }), ], content: value, + immediatelyRender: false, editorProps: { attributes: { class: diff --git a/apps/codebility/app/home/applicants/_components/ApplicantClientWrapper.tsx b/apps/codebility/app/home/applicants/_components/ApplicantClientWrapper.tsx index 832da2a79..2786da29c 100644 --- a/apps/codebility/app/home/applicants/_components/ApplicantClientWrapper.tsx +++ b/apps/codebility/app/home/applicants/_components/ApplicantClientWrapper.tsx @@ -4,7 +4,7 @@ import { ReactNode, useState, createContext, useContext } from "react"; import ApplicantProfileModal from "./ApplicantProfileModal"; -import { NewApplicantType } from "../_service/types"; +import { NewApplicantType } from "@/types/applicants"; interface ModalContextType { isModalOpen: boolean; diff --git a/apps/codebility/app/home/applicants/_components/ApplicantDataWrapper.tsx b/apps/codebility/app/home/applicants/_components/ApplicantDataWrapper.tsx index 7cba2764e..ffc2feefe 100644 --- a/apps/codebility/app/home/applicants/_components/ApplicantDataWrapper.tsx +++ b/apps/codebility/app/home/applicants/_components/ApplicantDataWrapper.tsx @@ -1,23 +1,11 @@ -import getNewApplicants from "../_service/query"; -import { NewApplicantType } from "../_service/types"; +import { getApplicantStatusCounts, getApplicantsPage } from "@/actions/applicants/queries"; import ApplicantLists from "./applicantLists"; export default async function ApplicantDataWrapper() { - // Fetch applicants directly without caching - const applicants: NewApplicantType[] = await getNewApplicants(); + const [initialData, counts] = await Promise.all([ + getApplicantsPage({ status: "applying", page: 1 }), + getApplicantStatusCounts(), + ]); - // Handle no applicants case - if (!applicants || applicants.length === 0) { - return ( -
-
📋
-

No applicants found

-

- There are currently no applicants in the system. -

-
- ); - } - - return ; -} \ No newline at end of file + return ; +} diff --git a/apps/codebility/app/home/applicants/_components/ApplicantProcessTimeline.tsx b/apps/codebility/app/home/applicants/_components/ApplicantProcessTimeline.tsx index 6666c1602..a7104e5e3 100644 --- a/apps/codebility/app/home/applicants/_components/ApplicantProcessTimeline.tsx +++ b/apps/codebility/app/home/applicants/_components/ApplicantProcessTimeline.tsx @@ -8,15 +8,15 @@ "use client"; import React, { Component, ReactNode, useEffect } from "react"; -import { cn } from "@/lib/utils"; +import { cn } from "@/utils/utils"; import { AlertCircle, Check, Circle, Dot, X } from "lucide-react"; -import { NewApplicantType } from "../_service/types"; +import { NewApplicantType } from "@/types/applicants"; import { deriveTimeline, formatStageDate, StageState, -} from "../_service/processTimeline"; +} from "@/utils/applicants/process-timeline"; const STATE_STYLES: Record< StageState, diff --git a/apps/codebility/app/home/applicants/_components/ApplicantProfileModal.tsx b/apps/codebility/app/home/applicants/_components/ApplicantProfileModal.tsx index c165bf049..110c56e73 100644 --- a/apps/codebility/app/home/applicants/_components/ApplicantProfileModal.tsx +++ b/apps/codebility/app/home/applicants/_components/ApplicantProfileModal.tsx @@ -12,7 +12,8 @@ import { DialogHeader, DialogTitle, } from "@/components/ui/dialog"; -import { IconFigma, IconGithub, IconLink } from "@/public/assets/svgs"; +import { IconFigma } from "@/public/assets/svgs/techstack"; +import { IconGithub, IconLink } from "@/public/assets/svgs"; import { Briefcase, Facebook, diff --git a/apps/codebility/app/home/applicants/_components/_table/applicantActionButton.original.tsx b/apps/codebility/app/home/applicants/_components/_table/applicantActionButton.original.tsx index d2de8d21d..127b8edd2 100644 --- a/apps/codebility/app/home/applicants/_components/_table/applicantActionButton.original.tsx +++ b/apps/codebility/app/home/applicants/_components/_table/applicantActionButton.original.tsx @@ -38,15 +38,15 @@ import { moveApplicantToOnboardingAction, moveApplicantToTestingAction, passApplicantTestAction, -} from "../../_service/action"; +} from "@/actions/applicants/applicant"; import { sendDenyEmail, sendFailedTestEmail, sendOnboardingReminder, sendPassedTestEmail, sendTestReminder, -} from "../../_service/email"; -import { NewApplicantType } from "../../_service/types"; +} from "@/actions/applicants/email"; +import { NewApplicantType } from "@/types/applicants"; import { useApplicantModal } from "../ApplicantClientWrapper"; export default function ApplicantActionButton({ diff --git a/apps/codebility/app/home/applicants/_components/_table/applicantActionButton.tsx b/apps/codebility/app/home/applicants/_components/_table/applicantActionButton.tsx index eeb5edf2d..facc332e3 100644 --- a/apps/codebility/app/home/applicants/_components/_table/applicantActionButton.tsx +++ b/apps/codebility/app/home/applicants/_components/_table/applicantActionButton.tsx @@ -15,7 +15,7 @@ import { DialogTitle, } from "@/components/ui/dialog"; import { useToast } from "@/components/ui/use-toast"; -import { cn } from "@/lib/utils"; +import { cn } from "@/utils/utils"; import { CheckCircle2Icon, Loader2Icon, @@ -40,15 +40,15 @@ import { moveApplicantToOnboardingAction, moveApplicantToTestingAction, passApplicantTestAction, -} from "../../_service/action"; +} from "@/actions/applicants/applicant"; import { sendDenyEmail, sendFailedTestEmail, sendOnboardingReminderWithUpdate, sendPassedTestEmail, sendTestReminderWithUpdate, -} from "../../_service/email"; -import { NewApplicantType } from "../../_service/types"; +} from "@/actions/applicants/email"; +import { NewApplicantType } from "@/types/applicants"; export default function ApplicantActionButton({ applicant, diff --git a/apps/codebility/app/home/applicants/_components/_table/applicantActionConfig.ts b/apps/codebility/app/home/applicants/_components/_table/applicantActionConfig.ts index 59eeaa8fb..e2c05e19b 100644 --- a/apps/codebility/app/home/applicants/_components/_table/applicantActionConfig.ts +++ b/apps/codebility/app/home/applicants/_components/_table/applicantActionConfig.ts @@ -7,7 +7,7 @@ import { moveApplicantToOnboardingAction, moveApplicantToTestingAction, passApplicantTestAction, -} from "../../_service/action"; +} from "@/actions/applicants/applicant"; import { sendAcceptedEmailForConfig, sendDenyEmailForConfig, @@ -15,7 +15,7 @@ import { sendOnboardingReminderForConfig, sendPassedTestEmailForConfig, sendTestReminderForConfig, -} from "../../_service/email"; +} from "@/actions/applicants/email"; export const ACTION_CONFIG: Record = { applying: { diff --git a/apps/codebility/app/home/applicants/_components/_table/applicantColumns.tsx b/apps/codebility/app/home/applicants/_components/_table/applicantColumns.tsx index 303be1d18..6dcd4566b 100644 --- a/apps/codebility/app/home/applicants/_components/_table/applicantColumns.tsx +++ b/apps/codebility/app/home/applicants/_components/_table/applicantColumns.tsx @@ -10,7 +10,7 @@ import { ArrowUpDown } from "lucide-react"; import { Checkbox } from "@codevs/ui/checkbox"; -import { NewApplicantType } from "../../_service/types"; +import { NewApplicantType } from "@/types/applicants"; import ApplicantReapplyTime from "../applicantReapplyTime"; import ApplicantTechStack from "../applicantTechStack"; import ApplicantTestTimeRemaining from "../applicantTestTimeRemaining"; diff --git a/apps/codebility/app/home/applicants/_components/_table/applicantDataTable.tsx b/apps/codebility/app/home/applicants/_components/_table/applicantDataTable.tsx index 500ec0c69..f847aba78 100644 --- a/apps/codebility/app/home/applicants/_components/_table/applicantDataTable.tsx +++ b/apps/codebility/app/home/applicants/_components/_table/applicantDataTable.tsx @@ -1,7 +1,7 @@ "use client"; import React, { useCallback, useMemo } from "react"; -import { getTestDate } from "@/app/applicant/waiting/_service/util"; +import { getTestDate } from "@/utils/applicant-waiting"; import { Box } from "@/components/shared/dashboard"; import DefaultPagination from "@/components/ui/pagination"; import { @@ -13,7 +13,7 @@ import { TableRow, } from "@/components/ui/table"; import { pageSize } from "@/constants"; -import { cn } from "@/lib/utils"; +import { cn } from "@/utils/utils"; import { ColumnDef, flexRender, @@ -25,7 +25,7 @@ import { VisibilityState, } from "@tanstack/react-table"; -import { NewApplicantType } from "../../_service/types"; +import { NewApplicantType } from "@/types/applicants"; import ApplicantEmailAction from "../applicantEmailAction"; import ApplicantMobileTable from "./applicantMobileTable"; import ApplicantRowActionButton from "./applicantRowActionButton"; diff --git a/apps/codebility/app/home/applicants/_components/_table/applicantMobileTable.tsx b/apps/codebility/app/home/applicants/_components/_table/applicantMobileTable.tsx index c5d8b9e3e..1f71126a0 100644 --- a/apps/codebility/app/home/applicants/_components/_table/applicantMobileTable.tsx +++ b/apps/codebility/app/home/applicants/_components/_table/applicantMobileTable.tsx @@ -1,7 +1,7 @@ "use client"; import React from "react"; -import { getTestDate } from "@/app/applicant/waiting/_service/util"; +import { getTestDate } from "@/utils/applicant-waiting"; import { Table, TableBody, @@ -10,10 +10,10 @@ import { TableHeader, TableRow, } from "@/components/ui/table"; -import { cn } from "@/lib/utils"; +import { cn } from "@/utils/utils"; import { flexRender, Table as ReactTable } from "@tanstack/react-table"; -import { NewApplicantType } from "../../_service/types"; +import { NewApplicantType } from "@/types/applicants"; interface ApplicantMobileTableProps { table: ReactTable; diff --git a/apps/codebility/app/home/applicants/_components/_table/applicantProfileColSec.tsx b/apps/codebility/app/home/applicants/_components/_table/applicantProfileColSec.tsx index 755ea749e..1d880f513 100644 --- a/apps/codebility/app/home/applicants/_components/_table/applicantProfileColSec.tsx +++ b/apps/codebility/app/home/applicants/_components/_table/applicantProfileColSec.tsx @@ -23,7 +23,7 @@ import { HoverCardTrigger, } from "@codevs/ui/hover-card"; -import { NewApplicantType } from "../../_service/types"; +import { NewApplicantType } from "@/types/applicants"; import ApplicantReapplyTime from "../applicantReapplyTime"; import ApplicantTestTimeRemaining from "../applicantTestTimeRemaining"; import ApplicantActionButton from "./applicantActionButton"; diff --git a/apps/codebility/app/home/applicants/_components/_table/applicantRowActionButton.tsx b/apps/codebility/app/home/applicants/_components/_table/applicantRowActionButton.tsx index 4b95430e9..311674df6 100644 --- a/apps/codebility/app/home/applicants/_components/_table/applicantRowActionButton.tsx +++ b/apps/codebility/app/home/applicants/_components/_table/applicantRowActionButton.tsx @@ -10,7 +10,7 @@ import { DialogTitle, } from "@/components/ui/dialog"; import { useToast } from "@/components/ui/use-toast"; -import { cn } from "@/lib/utils"; +import { cn } from "@/utils/utils"; import { set } from "date-fns"; import { Loader2Icon, MailIcon, MoreHorizontalIcon } from "lucide-react"; @@ -31,14 +31,14 @@ import { multipleMoveApplicantToOnboardingAction, multipleMoveApplicantToTestingAction, multiplePassApplicantTestAction, -} from "../../_service/action"; +} from "@/actions/applicants/applicant"; import { sendMultipleDenyEmail, sendMultipleOnboardingReminderWithUpdate, sendMultiplePassedTestEmail, sendMultipleTestReminderEmailWithUpdate, -} from "../../_service/email"; -import { NewApplicantType } from "../../_service/types"; +} from "@/actions/applicants/email"; +import { NewApplicantType } from "@/types/applicants"; export default function ApplicantRowActionButton({ applicants, diff --git a/apps/codebility/app/home/applicants/_components/_table/useApplicantActions.ts b/apps/codebility/app/home/applicants/_components/_table/useApplicantActions.ts index 567ce39bd..11b2d25a3 100644 --- a/apps/codebility/app/home/applicants/_components/_table/useApplicantActions.ts +++ b/apps/codebility/app/home/applicants/_components/_table/useApplicantActions.ts @@ -2,7 +2,7 @@ import { useState } from "react"; import { useToast } from "@codevs/ui/use-toast"; import { ActionType } from "./applicantActionTypes"; import { ACTION_CONFIG } from "./applicantActionConfig"; -import { NewApplicantType } from "../../_service/types"; +import { NewApplicantType } from "@/types/applicants"; export function useApplicantActions(applicant: NewApplicantType) { const { toast } = useToast(); diff --git a/apps/codebility/app/home/applicants/_components/applicantEmailAction.tsx b/apps/codebility/app/home/applicants/_components/applicantEmailAction.tsx index 0357fe70a..179c55279 100644 --- a/apps/codebility/app/home/applicants/_components/applicantEmailAction.tsx +++ b/apps/codebility/app/home/applicants/_components/applicantEmailAction.tsx @@ -23,8 +23,8 @@ import { import { sendMultipleOnboardingReminderWithUpdate, sendMultipleTestReminderEmailWithUpdate, -} from "../_service/email"; -import { NewApplicantType } from "../_service/types"; +} from "@/actions/applicants/email"; +import { NewApplicantType } from "@/types/applicants"; export default function ApplicantEmailAction({ applicants, diff --git a/apps/codebility/app/home/applicants/_components/applicantFilters.tsx b/apps/codebility/app/home/applicants/_components/applicantFilters.tsx deleted file mode 100644 index 81d3508b8..000000000 --- a/apps/codebility/app/home/applicants/_components/applicantFilters.tsx +++ /dev/null @@ -1,518 +0,0 @@ -import React, { memo, useState } from "react"; -import { Button } from "@/components/ui/button"; -import { CalendarIcon, ChevronDown, ChevronRight, Filter, X } from "lucide-react"; -import { move } from "react-big-calendar"; - -import { Badge } from "@codevs/ui/badge"; -import { - DropdownMenu, - DropdownMenuCheckboxItem, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuLabel, - DropdownMenuSeparator, - DropdownMenuTrigger, -} from "@codevs/ui/dropdown-menu"; -import { Input } from "@codevs/ui/input"; -import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@codevs/ui/collapsible"; - -import { ExperienceRanges } from "../_service/types"; -import { ApplicantFilters } from "./applicantHeaders"; - -const ApplicantFiltersComponent = ({ - activeFilterCount, - filters, - uniquePositions, - uniqueTechStacks, - updateFilter, - updateExperienceFilter, - updatePositionFilter, - updateTechStackFilter, - updateDateRangeFilter, - updateReminderFilter, - updateTestStatusFilter, - onResetFilters, -}: { - activeFilterCount: number; - filters: { - hasPortfolio: boolean; - noPortfolio: boolean; - hasGithub: boolean; - noGithub: boolean; - experienceRanges: { - novice: boolean; - intermediate: boolean; - expert: boolean; - }; - positions: { - [key: string]: boolean; - }; - techStacks: { - [key: string]: boolean; - }; - testStatus: { - taken: boolean; - notTaken: boolean; - overdue: boolean; - }; - reminderCount: { - none: boolean; - low: boolean; - medium: boolean; - high: boolean; - }; - applicationDate: { - last7Days: boolean; - last30Days: boolean; - last90Days: boolean; - custom: boolean; - startDate: string; - endDate: string; - }; - }; - uniquePositions: string[]; - uniqueTechStacks: string[]; - updateFilter: (key: keyof ApplicantFilters, value: boolean) => void; - updateExperienceFilter: (key: keyof ExperienceRanges, value: boolean) => void; - updatePositionFilter: (key: string, value: boolean) => void; - updateTechStackFilter: (key: string, value: boolean) => void; - updateDateRangeFilter: (key: string, value: boolean | string) => void; - updateReminderFilter: (key: string, value: boolean) => void; - updateTestStatusFilter: (key: string, value: boolean) => void; - onResetFilters: () => void; -}) => { - const [expandedSections, setExpandedSections] = useState({ - basic: true, - skills: false, - status: false, - dates: false, - }); - - const toggleSection = (section: keyof typeof expandedSections) => { - setExpandedSections(prev => ({ - ...prev, - [section]: !prev[section] - })); - }; - - const getActiveFiltersInSection = (section: string) => { - switch (section) { - case 'basic': - return [filters.hasPortfolio, filters.noPortfolio, filters.hasGithub, filters.noGithub].filter(Boolean).length + - [filters.experienceRanges.novice, filters.experienceRanges.intermediate, filters.experienceRanges.expert].filter(Boolean).length + - Object.values(filters.positions).filter(Boolean).length; - case 'skills': - return Object.values(filters.techStacks).filter(Boolean).length; - case 'status': - return [filters.testStatus.taken, filters.testStatus.notTaken, filters.testStatus.overdue].filter(Boolean).length + - [filters.reminderCount.none, filters.reminderCount.low, filters.reminderCount.medium, filters.reminderCount.high].filter(Boolean).length; - case 'dates': - return [filters.applicationDate.last7Days, filters.applicationDate.last30Days, filters.applicationDate.last90Days, filters.applicationDate.custom].filter(Boolean).length; - default: - return 0; - } - }; - return ( - <> - - - - - -
- - Filter Applicants - -
- -
- {/* Basic Filters Section */} - toggleSection('basic')}> - -
- - Basic Filters - - {getActiveFiltersInSection('basic') > 0 && ( - - {getActiveFiltersInSection('basic')} - - )} -
- {expandedSections.basic ? ( - - ) : ( - - )} -
- -
- {/* Portfolio Filters */} -
-
- Portfolio -
-
- - -
-
- - {/* GitHub Filters */} -
-
- GitHub -
-
- - -
-
-
- - {/* Experience Filters */} -
-
- Experience Level -
-
- - - -
-
- - {/* Position Filters */} - {uniquePositions.length > 0 && ( -
-
- Positions -
-
- {uniquePositions.map( - (position) => - position && ( - - ), - )} -
-
- )} -
-
- - {/* Skills & Tech Section */} - toggleSection('skills')}> - -
- - Skills & Tech - - {getActiveFiltersInSection('skills') > 0 && ( - - {getActiveFiltersInSection('skills')} - - )} -
- {expandedSections.skills ? ( - - ) : ( - - )} -
- - {uniqueTechStacks.length > 0 && ( -
-
- Tech Stacks -
-
- {uniqueTechStacks.slice(0, 15).map( - (techStack) => - techStack && ( - - ), - )} -
- {uniqueTechStacks.length > 15 && ( -
- +{uniqueTechStacks.length - 15} more technologies available -
- )} -
- )} -
-
- - {/* Status & Progress Section */} - toggleSection('status')}> - -
- - Status & Progress - - {getActiveFiltersInSection('status') > 0 && ( - - {getActiveFiltersInSection('status')} - - )} -
- {expandedSections.status ? ( - - ) : ( - - )} -
- -
- {/* Test Status Filters */} -
-
- Test Status -
-
- - - -
-
- - {/* Reminder Count Filters */} -
-
- Reminders -
-
- - - - -
-
-
-
-
- - {/* Date Filters Section */} - toggleSection('dates')}> - -
- - Date Filters - - {getActiveFiltersInSection('dates') > 0 && ( - - {getActiveFiltersInSection('dates')} - - )} -
- {expandedSections.dates ? ( - - ) : ( - - )} -
- -
-
- Application Date -
-
- - - -
-
-
-
-
- - {/* Reset Filters Button */} - {activeFilterCount > 0 && ( -
- -
- )} -
-
- - ); -}; - -export default memo(ApplicantFiltersComponent); diff --git a/apps/codebility/app/home/applicants/_components/applicantFiltersBadge.tsx b/apps/codebility/app/home/applicants/_components/applicantFiltersBadge.tsx deleted file mode 100644 index c92c780f9..000000000 --- a/apps/codebility/app/home/applicants/_components/applicantFiltersBadge.tsx +++ /dev/null @@ -1,140 +0,0 @@ -import React from "react"; -import { X } from "lucide-react"; - -import { Badge } from "@codevs/ui/badge"; - -import { ApplicantFilters } from "./applicantHeaders"; -import { ExperienceRanges } from "../_service/types"; - -export default function ApplicantFiltersBadge({ - filters, - setFilter, - onFilterChange, -}: { - filters: ApplicantFilters; - setFilter: React.Dispatch>; - onFilterChange: (filters: ApplicantFilters) => void; -}) { - // Update a specific filter - const updateFilter = (key: keyof ApplicantFilters, value: boolean) => { - onFilterChange({ - ...filters, - [key]: value, - }); - }; - - // Update experience filter - const updateExperienceFilter = ( - key: keyof ExperienceRanges, - value: boolean, - ) => { - onFilterChange({ - ...filters, - experienceRanges: { - ...filters.experienceRanges, - [key]: value, - }, - }); - }; - - // Update position filter - const updatePositionFilter = (position: string, value: boolean) => { - onFilterChange({ - ...filters, - positions: { - ...filters.positions, - [position]: value, - }, - }); - }; - - return ( -
- {/* Portfolio Badges */} - {filters.hasPortfolio && ( - - Has Portfolio - updateFilter("hasPortfolio", false)} - /> - - )} - {filters.noPortfolio && ( - - No Portfolio - updateFilter("noPortfolio", false)} - /> - - )} - - {/* GitHub Badges */} - {filters.hasGithub && ( - - Has GitHub - updateFilter("hasGithub", false)} - /> - - )} - {filters.noGithub && ( - - No GitHub - updateFilter("noGithub", false)} - /> - - )} - - {/* Experience Badges */} - {filters.experienceRanges.novice && ( - - 0-2 Years Experience - updateExperienceFilter("novice", false)} - /> - - )} - {filters.experienceRanges.intermediate && ( - - 3-5 Years Experience - updateExperienceFilter("intermediate", false)} - /> - - )} - {filters.experienceRanges.expert && ( - - 5+ Years Experience - updateExperienceFilter("expert", false)} - /> - - )} - - {/* Position Badges */} - {Object.entries(filters.positions).map( - ([position, isActive]) => - isActive && ( - - Position: {position} - updatePositionFilter(position, false)} - /> - - ), - )} -
- ); -} diff --git a/apps/codebility/app/home/applicants/_components/applicantHeaders.tsx b/apps/codebility/app/home/applicants/_components/applicantHeaders.tsx index 40650bc49..e4d456355 100644 --- a/apps/codebility/app/home/applicants/_components/applicantHeaders.tsx +++ b/apps/codebility/app/home/applicants/_components/applicantHeaders.tsx @@ -1,654 +1,35 @@ -import React, { memo, useCallback, useEffect, useMemo, useState } from "react"; -import { H1 } from "@/components/shared/dashboard"; - -import { ExperienceRanges, NewApplicantType } from "../_service/types"; -import ApplicantFiltersComponent from "./applicantFilters"; -import ApplicantFiltersBadge from "./applicantFiltersBadge"; -import ApplicantSorters, { SortOption } from "./applicantSorters"; +"use client"; -export type ApplicantFilters = { - hasPortfolio: boolean; - noPortfolio: boolean; - hasGithub: boolean; - noGithub: boolean; - experienceRanges: ExperienceRanges; - positions: Record; - techStacks: Record; - testStatus: { - taken: boolean; - notTaken: boolean; - overdue: boolean; - }; - reminderCount: { - none: boolean; - low: boolean; - medium: boolean; - high: boolean; - }; - applicationDate: { - last7Days: boolean; - last30Days: boolean; - last90Days: boolean; - custom: boolean; - startDate: string; - endDate: string; - }; -}; +import React from "react"; +import { H1 } from "@/components/shared/dashboard"; +// Search is the only filter wired to the server. The richer per-column filters +// that used to live here re-filtered the whole table in the browser, which the +// server-side pagination makes meaningless; they come back as query parameters +// when the applicant query grows a filter surface to match. export default function ApplicantFilterHeaders({ - applicants, - setApplicants, - setCurrentTab, + search, + onSearchChange, }: { - applicants: NewApplicantType[]; - setApplicants: React.Dispatch>; - setCurrentTab: React.Dispatch>; + search: string; + onSearchChange: (value: string) => void; }) { - const [searchTerm, setSearchTerm] = React.useState(""); - const onSearch = (value: string) => { - setSearchTerm(value); - - const filteredApplicants = applicants.filter((applicant) => { - const fullName = `${applicant.first_name} ${applicant.last_name}`; - const email = applicant.email_address; - - return ( - fullName.toLowerCase().includes(value.toLowerCase()) || - email.toLowerCase().includes(value.toLowerCase()) - ); - }); - setApplicants(filteredApplicants); - moveTab(filteredApplicants[0]?.application_status || "applying"); - }; - - const [sortField, setSortField] = useState(null); - const [sortDirection, setSortDirection] = useState<"asc" | "desc">("desc"); - const [sortOptions, setSortOptions] = useState([]); - - // Add a new sort option - const addSort = useCallback((field: string, label: string) => { - setSortOptions(prev => [...prev, { field, direction: "desc", label }]); - }, []); - - // Remove a sort option - const removeSort = useCallback((field: string) => { - setSortOptions(prev => prev.filter(option => option.field !== field)); - }, []); - - // Toggle sort direction for a specific field - const toggleSortDirection = useCallback((field: string) => { - setSortOptions(prev => - prev.map(option => - option.field === field - ? { ...option, direction: option.direction === "asc" ? "desc" : "asc" } - : option - ) - ); - }, []); - - // Reorder sort options (for priority) - const reorderSorts = useCallback((newSortOptions: SortOption[]) => { - setSortOptions(newSortOptions); - }, []); - - // Legacy toggle sort function (for backward compatibility) - const toggleSort = useCallback( - (field: string) => { - if (sortField === field) { - setSortDirection((prev) => (prev === "asc" ? "desc" : "asc")); - } else { - setSortField(field); - setSortDirection("desc"); - } - }, - [sortField], - ); - - const moveTab = (status: string) => { - switch (status) { - case "applying": - setCurrentTab("applying"); - break; - case "testing": - setCurrentTab("testing"); - break; - case "onboarding": - setCurrentTab("onboarding"); - break; - case "waitlist": - setCurrentTab("waitlist"); - break; - case "denied": - setCurrentTab("denied"); - break; - default: - setCurrentTab("applying"); - break; - } - }; - - // Update a specific filter // Update a specific filter - const [filters, setFilters] = useState({ - hasPortfolio: false, - noPortfolio: false, - hasGithub: false, - noGithub: false, - experienceRanges: { - novice: false, - intermediate: false, - expert: false, - }, - positions: {}, - techStacks: {}, - testStatus: { - taken: false, - notTaken: false, - overdue: false, - }, - reminderCount: { - none: false, - low: false, - medium: false, - high: false, - }, - applicationDate: { - last7Days: false, - last30Days: false, - last90Days: false, - custom: false, - startDate: "", - endDate: "", - }, - }); - - useEffect(() => { - const filteredApplicants = applicants.filter((applicant) => { - if (searchTerm) { - const fullName = `${applicant.first_name} ${applicant.last_name}`; - const email = applicant.email_address; - - if ( - !fullName.toLowerCase().includes(searchTerm.toLowerCase()) && - !email.toLowerCase().includes(searchTerm.toLowerCase()) - ) { - return false; - } - } - - // Filter by portfolio - if (filters.hasPortfolio && !applicant.portfolio_website) return false; - if (filters.noPortfolio && applicant.portfolio_website) return false; - - // Filter by GitHub - if (filters.hasGithub && !applicant.github) return false; - if (filters.noGithub && applicant.github) return false; - - // Filter by experience - if (filters.experienceRanges.novice && applicant.years_of_experience > 2) - return false; - if ( - filters.experienceRanges.intermediate && - (applicant.years_of_experience < 3 || applicant.years_of_experience > 5) - ) - return false; - if (filters.experienceRanges.expert && applicant.years_of_experience < 5) - return false; - - // Filter by position - const positionFilter = Object.entries(filters.positions).find( - ([position, isChecked]) => { - if (isChecked) { - return applicant.display_position === position; - } - return false; - }, - ); - - if (positionFilter) { - return true; - } - if (Object.values(filters.positions).some((isChecked) => isChecked)) { - return false; - } - - // Filter by tech stacks - const techStackFilter = Object.entries(filters.techStacks).find( - ([techStack, isChecked]) => { - if (isChecked) { - return applicant.tech_stacks?.includes(techStack); - } - return false; - }, - ); - - if (techStackFilter) { - return true; - } - if (Object.values(filters.techStacks).some((isChecked) => isChecked)) { - return false; - } - - // Filter by test status - if (filters.testStatus.taken && !applicant.applicant?.test_taken) return false; - if (filters.testStatus.notTaken && applicant.applicant?.test_taken) return false; - if (filters.testStatus.overdue) { - // Consider overdue if test not taken and applied more than 7 days ago - const isOverdue = !applicant.applicant?.test_taken && - applicant.date_applied && - new Date(applicant.date_applied) < new Date(Date.now() - 7 * 24 * 60 * 60 * 1000); - if (!isOverdue) return false; - } - - // Filter by reminder count - const reminderCount = applicant.applicant?.reminded_count || 0; - if (filters.reminderCount.none && reminderCount !== 0) return false; - if (filters.reminderCount.low && (reminderCount < 1 || reminderCount > 2)) return false; - if (filters.reminderCount.medium && (reminderCount < 3 || reminderCount > 5)) return false; - if (filters.reminderCount.high && reminderCount < 5) return false; - - // Filter by application date - if (filters.applicationDate.last7Days || filters.applicationDate.last30Days || filters.applicationDate.last90Days) { - const applicationDate = applicant.date_applied ? new Date(applicant.date_applied) : null; - if (!applicationDate) return false; - - const now = new Date(); - if (filters.applicationDate.last7Days) { - const sevenDaysAgo = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000); - if (applicationDate < sevenDaysAgo) return false; - } - if (filters.applicationDate.last30Days) { - const thirtyDaysAgo = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000); - if (applicationDate < thirtyDaysAgo) return false; - } - if (filters.applicationDate.last90Days) { - const ninetyDaysAgo = new Date(now.getTime() - 90 * 24 * 60 * 60 * 1000); - if (applicationDate < ninetyDaysAgo) return false; - } - } - - return true; - }); - - // Apply sorting - if (sortOptions.length > 0) { - filteredApplicants.sort((a, b) => { - // Apply multiple sorts in priority order - for (const sortOption of sortOptions) { - let valueA: any, valueB: any; - - switch (sortOption.field) { - case "name": - valueA = `${a.first_name || ""} ${a.last_name || ""}`.toLowerCase(); - valueB = `${b.first_name || ""} ${b.last_name || ""}`.toLowerCase(); - break; - case "position": - valueA = (a.display_position || "").toLowerCase(); - valueB = (b.display_position || "").toLowerCase(); - break; - case "experience": - valueA = a.years_of_experience || 0; - valueB = b.years_of_experience || 0; - break; - case "dateApplied": - valueA = a.date_applied ? new Date(a.date_applied).getTime() : 0; - valueB = b.date_applied ? new Date(b.date_applied).getTime() : 0; - break; - case "testTaken": - valueA = a.applicant?.test_taken ? new Date(a.applicant.test_taken).getTime() : 0; - valueB = b.applicant?.test_taken ? new Date(b.applicant.test_taken).getTime() : 0; - break; - case "reminderCount": - valueA = a.applicant?.reminded_count || 0; - valueB = b.applicant?.reminded_count || 0; - break; - case "techStackCount": - valueA = a.tech_stacks?.length || 0; - valueB = b.tech_stacks?.length || 0; - break; - default: - continue; // Skip unknown fields - } - - let comparison = 0; - - // For numeric values - if (typeof valueA === "number" && typeof valueB === "number") { - comparison = sortOption.direction === "asc" ? valueA - valueB : valueB - valueA; - } else { - // For string values - if (valueA < valueB) comparison = sortOption.direction === "asc" ? -1 : 1; - else if (valueA > valueB) comparison = sortOption.direction === "asc" ? 1 : -1; - } - - // If this sort criteria produces a difference, return it - if (comparison !== 0) return comparison; - } - - // If all sort criteria are equal, maintain original order - return 0; - }); - } else if (sortField) { - // Legacy single sort fallback - filteredApplicants.sort((a, b) => { - let valueA: any, valueB: any; - - switch (sortField) { - case "name": - valueA = `${a.first_name || ""} ${a.last_name || ""}`.toLowerCase(); - valueB = `${b.first_name || ""} ${b.last_name || ""}`.toLowerCase(); - break; - case "position": - valueA = (a.display_position || "").toLowerCase(); - valueB = (b.display_position || "").toLowerCase(); - break; - case "experience": - valueA = a.years_of_experience || 0; - valueB = b.years_of_experience || 0; - break; - case "dateApplied": - valueA = a.date_applied ? new Date(a.date_applied).getTime() : 0; - valueB = b.date_applied ? new Date(b.date_applied).getTime() : 0; - break; - case "testTaken": - valueA = a.applicant?.test_taken ? new Date(a.applicant.test_taken).getTime() : 0; - valueB = b.applicant?.test_taken ? new Date(b.applicant.test_taken).getTime() : 0; - break; - case "reminderCount": - valueA = a.applicant?.reminded_count || 0; - valueB = b.applicant?.reminded_count || 0; - break; - case "techStackCount": - valueA = a.tech_stacks?.length || 0; - valueB = b.tech_stacks?.length || 0; - break; - default: - return 0; - } - - // For numeric values - if (typeof valueA === "number" && typeof valueB === "number") { - return sortDirection === "asc" ? valueA - valueB : valueB - valueA; - } - - // For string values - if (valueA < valueB) return sortDirection === "asc" ? -1 : 1; - if (valueA > valueB) return sortDirection === "asc" ? 1 : -1; - return 0; - }); - } - - // Update the applicants state with the filtered and sorted applicants - if (searchTerm.length > 0) { - moveTab(filteredApplicants[0]?.application_status || "applying"); - } - - setApplicants(filteredApplicants); - }, [filters, sortOptions, sortField, sortDirection, searchTerm, applicants]); - - const activeFilterCount = useMemo(() => { - let count = 0; - - // Count basic filters - if (filters.hasPortfolio) count++; - if (filters.noPortfolio) count++; - if (filters.hasGithub) count++; - if (filters.noGithub) count++; - - // Count experience filters - if (filters.experienceRanges.novice) count++; - if (filters.experienceRanges.intermediate) count++; - if (filters.experienceRanges.expert) count++; - - // Count position filters - for (const pos in filters.positions) { - if (filters.positions[pos]) count++; - } - - // Count tech stack filters - for (const tech in filters.techStacks) { - if (filters.techStacks[tech]) count++; - } - - // Count test status filters - if (filters.testStatus.taken) count++; - if (filters.testStatus.notTaken) count++; - if (filters.testStatus.overdue) count++; - - // Count reminder filters - if (filters.reminderCount.none) count++; - if (filters.reminderCount.low) count++; - if (filters.reminderCount.medium) count++; - if (filters.reminderCount.high) count++; - - // Count date filters - if (filters.applicationDate.last7Days) count++; - if (filters.applicationDate.last30Days) count++; - if (filters.applicationDate.last90Days) count++; - if (filters.applicationDate.custom) count++; - - return count; - }, [filters]); - - const uniquePositions = useMemo(() => { - return [ - ...new Set( - applicants?.map((a) => a.display_position).filter(Boolean) || [], - ), - ]; - }, [applicants]); - - const uniqueTechStacks = useMemo(() => { - const allTechStacks = applicants?.flatMap((a) => a.tech_stacks || []) || []; - return [...new Set(allTechStacks)].filter(Boolean); - }, [applicants]); - - const onFilterChange = (newFilters: ApplicantFilters) => { - setFilters(newFilters); - }; - - const updateFilter = (key: keyof ApplicantFilters, value: boolean) => { - onFilterChange({ - ...filters, - [key]: value, - }); - }; - - // Update experience filter - const updateExperienceFilter = ( - key: keyof ExperienceRanges, - value: boolean, - ) => { - onFilterChange({ - ...filters, - experienceRanges: { - ...filters.experienceRanges, - [key]: value, - }, - }); - }; - - // Update position filter - const updatePositionFilter = (position: string, value: boolean) => { - onFilterChange({ - ...filters, - positions: { - ...filters.positions, - [position]: value, - }, - }); - }; - - // Update tech stack filter - const updateTechStackFilter = (techStack: string, value: boolean) => { - onFilterChange({ - ...filters, - techStacks: { - ...filters.techStacks, - [techStack]: value, - }, - }); - }; - - // Update date range filter - const updateDateRangeFilter = (key: string, value: boolean | string) => { - onFilterChange({ - ...filters, - applicationDate: { - ...filters.applicationDate, - [key]: value, - }, - }); - }; - - // Update reminder filter - const updateReminderFilter = (key: string, value: boolean) => { - onFilterChange({ - ...filters, - reminderCount: { - ...filters.reminderCount, - [key]: value, - }, - }); - }; - - // Update test status filter - const updateTestStatusFilter = (key: string, value: boolean) => { - onFilterChange({ - ...filters, - testStatus: { - ...filters.testStatus, - [key]: value, - }, - }); - }; - - const onResetFilters = () => { - setFilters({ - hasPortfolio: false, - noPortfolio: false, - hasGithub: false, - noGithub: false, - experienceRanges: { - novice: false, - intermediate: false, - expert: false, - }, - positions: {}, - techStacks: {}, - testStatus: { - taken: false, - notTaken: false, - overdue: false, - }, - reminderCount: { - none: false, - low: false, - medium: false, - high: false, - }, - applicationDate: { - last7Days: false, - last30Days: false, - last90Days: false, - custom: false, - startDate: "", - endDate: "", - }, - }); - setApplicants(applicants); - }; - - const resetSort = () => { - setSortField(null); - setSortDirection("desc"); - setSortOptions([]); - }; - return (

Applicants Management

-
- {/* Mobile: Stack search input on top, buttons below */} -
- onSearch(e.target.value)} - className="h-11 w-full rounded-lg border border-gray-300 bg-gray-50 px-4 text-sm text-gray-900 placeholder-gray-500 focus:border-customBlue-500 focus:outline-none focus:ring-1 focus:ring-customBlue-500 dark:border-gray-600 dark:bg-gray-700 dark:text-white dark:placeholder-gray-400 dark:focus:border-customBlue-400 dark:focus:ring-customBlue-400" - /> -
- - -
-
- - {/* Desktop: Inline layout */} -
- onSearch(e.target.value)} - className="h-11 w-full max-w-80 rounded-lg border border-gray-300 bg-gray-50 px-4 text-sm text-gray-900 placeholder-gray-500 focus:border-customBlue-500 focus:outline-none focus:ring-1 focus:ring-customBlue-500 dark:border-gray-600 dark:bg-gray-700 dark:text-white dark:placeholder-gray-400 dark:focus:border-customBlue-400 dark:focus:ring-customBlue-400" - /> - - -
+
+ onSearchChange(e.target.value)} + className="h-11 w-full max-w-80 rounded-lg border border-gray-300 bg-gray-50 px-4 text-sm text-gray-900 placeholder-gray-500 focus:border-customBlue-500 focus:outline-none focus:ring-1 focus:ring-customBlue-500 dark:border-gray-600 dark:bg-gray-700 dark:text-white dark:placeholder-gray-400 dark:focus:border-customBlue-400 dark:focus:ring-customBlue-400" + />
- - {/* Filter badges */} -
); } - -memo(ApplicantFilterHeaders); diff --git a/apps/codebility/app/home/applicants/_components/applicantLists.tsx b/apps/codebility/app/home/applicants/_components/applicantLists.tsx index f7e4e8bb2..2c8546989 100644 --- a/apps/codebility/app/home/applicants/_components/applicantLists.tsx +++ b/apps/codebility/app/home/applicants/_components/applicantLists.tsx @@ -1,176 +1,114 @@ "use client"; -import React, { memo, useMemo } from "react"; -import { Codev } from "@/types/home/codev"; -import { prioritizeCodevs } from "@/utils/codev-priority"; -import { ColumnDef } from "@tanstack/react-table"; +import React from "react"; +import type { Page } from "@/lib/server/paginate"; +import { qk } from "@/lib/shared/query-keys"; +import { usePaginatedQuery } from "@/hooks/query/use-paginated-query"; +import { useDebouncedValue } from "@/hooks/ui/use-debounced-value"; -import { Badge } from "@codevs/ui/badge"; -import { Tabs, TabsContent, TabsList, TabsTrigger } from "@codevs/ui/tabs"; - -import { NewApplicantType } from "../_service/types"; +import { NewApplicantType } from "@/types/applicants"; +import { getApplicantsPageAction } from "@/actions/applicants/queries"; +import DefaultPagination from "@/components/ui/pagination"; import { ApplicantDataTable } from "./_table/applicantDataTable"; import { getApplicantColumns } from "./_table/applicantColumns"; import ApplicantFilterHeaders from "./applicantHeaders"; -function ApplicantLists({ - applicants, +const TABS = ["applying", "testing", "onboarding", "waitlist", "denied"] as const; +type Tab = (typeof TABS)[number]; + +export default function ApplicantLists({ + initialData, + counts, }: { - applicants: NewApplicantType[]; + initialData: Page; + counts: Record; }) { - const [filteredApplicants, setFilteredApplicants] = React.useState(applicants); - const [currentTab, setCurrentTab] = React.useState("applying"); - - // Always use the latest applicants data for filtering - React.useEffect(() => { - setFilteredApplicants(applicants); - }, [applicants]); - - const applicantsApplying = React.useMemo( - () => - filteredApplicants.filter( - (applicant) => applicant.application_status === "applying", - ), - [filteredApplicants], - ); - - const applicantsTesting = React.useMemo( - () => - filteredApplicants.filter( - (applicant) => applicant.application_status === "testing", - ), - [filteredApplicants], - ); - - const applicantsOnboarding = React.useMemo( - () => - filteredApplicants.filter( - (applicant) => applicant.application_status === "onboarding", - ), - [filteredApplicants], - ); + const [currentTab, setCurrentTab] = React.useState("applying"); + const [page, setPage] = React.useState(1); + const [search, setSearch] = React.useState(""); + const debouncedSearch = useDebouncedValue(search); - const applicantsWaitlist = React.useMemo( + const { data, showSkeleton } = usePaginatedQuery( + qk.applicants.list({ status: currentTab, page, search: debouncedSearch }), () => - filteredApplicants.filter( - (applicant) => applicant.application_status === "waitlist", - ), - [filteredApplicants], + getApplicantsPageAction({ + status: currentTab, + page, + search: debouncedSearch || undefined, + }), + { + initialData, + initialDataKey: qk.applicants.list({ status: "applying", page: 1, search: "" }), + }, ); - const applicantsDenied = React.useMemo( - () => - filteredApplicants.filter( - (applicant) => applicant.application_status === "denied", - ), - [filteredApplicants], - ); + const rows = data?.rows ?? []; + const totalPages = Math.max(Math.ceil((data?.total ?? 0) / (data?.pageSize ?? 1)), 1); return (
- { - setCurrentTab(value); + search={search} + onSearchChange={(value) => { + setSearch(value); + setPage(1); }} - className="w-full" - > - - - Applicants - {applicantsApplying.length > 0 && ( - - {applicantsApplying.length} - - )} - - - Testing - {applicantsTesting.length > 0 && ( - - {applicantsTesting.length} - - )} - - - Onboarding - {applicantsOnboarding.length > 0 && ( - - {applicantsOnboarding.length} - - )} - - - Waitlist - {applicantsWaitlist.length > 0 && ( - - {applicantsWaitlist.length} - - )} - - - Denied - {applicantsDenied.length > 0 && ( - - {applicantsDenied.length} - - )} - - - - - - - - - - - - - - - - - - + /> + +
+
+ {TABS.map((tab) => ( + + ))} +
+ +
+ {showSkeleton ? ( +
+ {Array.from({ length: 10 }).map((_, i) => ( +
+ ))} +
+ ) : ( + + )} +
+ + {totalPages > 1 && ( +
+ setPage((p) => Math.min(p + 1, totalPages))} + handlePreviousPage={() => setPage((p) => Math.max(p - 1, 1))} + setCurrentPage={(target: number) => + setPage(Math.max(1, Math.min(target, totalPages))) + } + totalPages={totalPages} + /> +
+ )} +
); } - -export default ApplicantLists; diff --git a/apps/codebility/app/home/applicants/_components/applicantReapplyTime.tsx b/apps/codebility/app/home/applicants/_components/applicantReapplyTime.tsx index e797a43db..8199d21c7 100644 --- a/apps/codebility/app/home/applicants/_components/applicantReapplyTime.tsx +++ b/apps/codebility/app/home/applicants/_components/applicantReapplyTime.tsx @@ -1,7 +1,7 @@ import React, { useEffect, useMemo, useState } from "react"; -import { getReApplyDate } from "@/app/auth/declined/_service/util"; +import { getReApplyDate } from "@/utils/auth/declined"; -import { NewApplicantType } from "../_service/types"; +import { NewApplicantType } from "@/types/applicants"; export default function ApplicantReapplyTime({ applicant, diff --git a/apps/codebility/app/home/applicants/_components/applicantSorters.tsx b/apps/codebility/app/home/applicants/_components/applicantSorters.tsx deleted file mode 100644 index 561263630..000000000 --- a/apps/codebility/app/home/applicants/_components/applicantSorters.tsx +++ /dev/null @@ -1,243 +0,0 @@ -import React, { memo } from "react"; -import { Button } from "@/components/ui/button"; -import { ChevronDown, Plus, SortAsc, SortDesc, X, GripVertical } from "lucide-react"; - -import { Badge } from "@codevs/ui/badge"; -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuLabel, - DropdownMenuSeparator, - DropdownMenuTrigger, -} from "@codevs/ui/dropdown-menu"; - -export type SortOption = { - field: string; - direction: "asc" | "desc"; - label: string; -}; - -const ApplicantSorters = ({ - sortOptions, - onAddSort, - onRemoveSort, - onToggleSortDirection, - onReorderSorts, - resetSort, -}: { - sortOptions: SortOption[]; - onAddSort: (field: string, label: string) => void; - onRemoveSort: (field: string) => void; - onToggleSortDirection: (field: string) => void; - onReorderSorts: (sortOptions: SortOption[]) => void; - resetSort: () => void; -}) => { - const availableFields = [ - { field: "name", label: "Name" }, - { field: "position", label: "Position" }, - { field: "experience", label: "Experience" }, - { field: "dateApplied", label: "Date Applied" }, - { field: "testTaken", label: "Test Taken" }, - { field: "reminderCount", label: "Reminders" }, - { field: "techStackCount", label: "Tech Stacks" }, - ]; - - const getAvailableFields = () => { - const usedFields = sortOptions.map(option => option.field); - return availableFields.filter(field => !usedFields.includes(field.field)); - }; - - const moveSortUp = (index: number) => { - if (index > 0 && index < sortOptions.length) { - const newSorts = [...sortOptions]; - const current = newSorts[index]; - const previous = newSorts[index - 1]; - if (current && previous) { - newSorts[index - 1] = current; - newSorts[index] = previous; - onReorderSorts(newSorts); - } - } - }; - - const moveSortDown = (index: number) => { - if (index >= 0 && index < sortOptions.length - 1) { - const newSorts = [...sortOptions]; - const current = newSorts[index]; - const next = newSorts[index + 1]; - if (current && next) { - newSorts[index] = next; - newSorts[index + 1] = current; - onReorderSorts(newSorts); - } - } - }; - - return ( - <> - - - - - -
- - Sort Options - -
- -
- {/* Active Sort Options */} - {sortOptions.length > 0 && ( -
-
- Active Sorts (Priority Order) -
- {sortOptions.map((option, index) => ( -
-
-
- - -
-
- - {index + 1} - - - {option.label} - -
-
-
- - -
-
- ))} -
- )} - - {/* Add New Sort */} - {getAvailableFields().length > 0 && ( -
-
- Add Sort Criteria -
-
- {getAvailableFields().map((field) => ( - - ))} -
-
- )} - - {/* Sort Instructions */} - {sortOptions.length === 0 && ( -
- -

No sorting criteria selected

-

Click on a field above to add your first sort criteria

-

You can add multiple sorts with priority order

-
- )} - - {sortOptions.length > 0 && ( -
-
-
How sorting works:
-
    -
  • • Higher priority sorts (lower numbers) are applied first
  • -
  • • Use ↑↓ buttons to change priority order
  • -
  • • Click direction button to toggle A→Z / Z→A sorting
  • -
  • • Multiple criteria create sophisticated sorting behavior
  • -
-
-
- )} -
- - {/* Reset Button */} - {sortOptions.length > 0 && ( -
- -
- )} -
-
- - ); -}; - -export default ApplicantSorters; diff --git a/apps/codebility/app/home/applicants/_components/applicantTechStack.tsx b/apps/codebility/app/home/applicants/_components/applicantTechStack.tsx index b226a6b5e..581164cce 100644 --- a/apps/codebility/app/home/applicants/_components/applicantTechStack.tsx +++ b/apps/codebility/app/home/applicants/_components/applicantTechStack.tsx @@ -2,7 +2,7 @@ import React from "react"; import Image from "next/image"; import { getTechStackIconName } from "@/constants/techstack"; -import { NewApplicantType } from "../_service/types"; +import { NewApplicantType } from "@/types/applicants"; export default function ApplicantTechStack({ applicant, diff --git a/apps/codebility/app/home/applicants/_components/applicantTestTimeRemaining.tsx b/apps/codebility/app/home/applicants/_components/applicantTestTimeRemaining.tsx index 0cf249073..e912161dc 100644 --- a/apps/codebility/app/home/applicants/_components/applicantTestTimeRemaining.tsx +++ b/apps/codebility/app/home/applicants/_components/applicantTestTimeRemaining.tsx @@ -1,7 +1,7 @@ import React, { useEffect, useMemo, useState } from "react"; -import { getTestDate } from "@/app/applicant/waiting/_service/util"; +import { getTestDate } from "@/utils/applicant-waiting"; -import { NewApplicantType } from "../_service/types"; +import { NewApplicantType } from "@/types/applicants"; export default function ApplicantTestTimeRemaining({ applicant, diff --git a/apps/codebility/app/home/applicants/_service/query.ts b/apps/codebility/app/home/applicants/_service/query.ts deleted file mode 100644 index c4bd5ddc3..000000000 --- a/apps/codebility/app/home/applicants/_service/query.ts +++ /dev/null @@ -1,34 +0,0 @@ - -import { createClientServerComponent } from "@/utils/supabase/server"; -import { newApplicantsSchema, NewApplicantType } from "./types"; - -export default async function getNewApplicants(): Promise { - try { - const supabase = await createClientServerComponent(); - - const { data: newApplicants, error } = await supabase - .from("codev") - .select(`*, - applicant (*) - `) - .not("application_status", "eq", "passed") - .order("date_applied", { ascending: false }) - - if (error) { - console.error("Error fetching new applicants:", error); - return [] - } - - const parsedNewApplicants = newApplicantsSchema.array().safeParse(newApplicants); - - if (parsedNewApplicants.error) { - console.error("Error parsing new applicants:", parsedNewApplicants.error); - return [] - } - - return parsedNewApplicants.data - } catch (error) { - console.error("Error fetching new applicants:", error); - return [] - } -} \ No newline at end of file diff --git a/apps/codebility/app/home/applicants/page.tsx b/apps/codebility/app/home/applicants/page.tsx index 6e3f0ab9b..01da91be6 100644 --- a/apps/codebility/app/home/applicants/page.tsx +++ b/apps/codebility/app/home/applicants/page.tsx @@ -5,8 +5,8 @@ import PageContainer from "../_components/PageContainer"; import NewApplicantFetchComp from "./_components/applicantFetchComp"; import ApplicantsLoading from "./loading"; + export const dynamic = "force-dynamic"; -export const revalidate = 0; export default async function NewApplicants() { return ( diff --git a/apps/codebility/app/home/certificate-preview/_components/CertificatePreviewClient.tsx b/apps/codebility/app/home/certificate-preview/_components/CertificatePreviewClient.tsx new file mode 100644 index 000000000..d6062df24 --- /dev/null +++ b/apps/codebility/app/home/certificate-preview/_components/CertificatePreviewClient.tsx @@ -0,0 +1,389 @@ +"use client"; + +import { useRef, useState } from "react"; +import { useQuery } from "@tanstack/react-query"; +import { Button } from "@codevs/ui/button"; +import { Card } from "@codevs/ui/card"; +import { useUserStore } from "@/store/codev-store"; +import { getClientSupabase } from "@/utils/supabase/client"; +import { ArrowLeft } from "lucide-react"; +import { useRouter } from "next/navigation"; +import Certificate, { CertificateProps } from "../../(dashboard)/_components/DashboardDownloadCertificate"; + +export interface SeedPoints { + skillPoints: Record; + attendancePoints: number; + totalPoints: number; +} + +export default function CertificatePreview({ + viewerId, + initialPoints, +}: { + /** The signed-in codev, known on the server before the client store hydrates. */ + viewerId: string; + /** The viewer's own totals, resolved on the server so first paint needs no + * client fetch. Admins switching to another user still fetch that user. */ + initialPoints: SeedPoints; +}) { + const router = useRouter(); + const certRef = useRef(null); + const { user, userLevel } = useUserStore(); + const [selectedUserId, setSelectedUserId] = useState(""); + const [selectedUser, setSelectedUser] = useState(null); + const [selectedUserLevel, setSelectedUserLevel] = useState(0); + + const isAdmin = user?.role_id === 1; + + // Determine initial certificate type based on user role and points + const getInitialCertType = () => { + // Based on roadmap: 0-100pts = Intern, 100-200pts = Codev, 200+ = Mentor + const currentUser = isAdmin && selectedUser ? selectedUser : user; + const currentLevel = isAdmin && selectedUser ? selectedUserLevel : userLevel; + + if (!currentUser) return "intern"; + + // Check if user has mentor role + if (currentUser.role_id === 5) return "mentor"; + + // For Codev role, check level + if (currentUser.role_id === 10) { + return (currentLevel ?? 0) >= 2 ? "codev" : "intern"; + } + + return "intern"; + }; + + const [certificateType, setCertificateType] = useState<"intern" | "codev" | "mentor">(getInitialCertType()); + + const activeUser = isAdmin && selectedUser ? selectedUser : user; + const activeLevel = isAdmin && selectedUser ? selectedUserLevel : userLevel; + + // Derived during render rather than synced in an effect; the field is + // editable, so a draft holds user overrides and the derived value is the + // fallback until one is made. + const derivedName = activeUser + ? `${activeUser.first_name} ${activeUser.last_name}` + : ""; + const [nameDraft, setNameDraft] = useState(null); + const name = nameDraft ?? derivedName; + const setName = (value: string) => setNameDraft(value); + + const { data: allUsers = [] } = useQuery({ + queryKey: ["certificate", "users"], + enabled: Boolean(isAdmin), + staleTime: 5 * 60_000, + queryFn: async () => { + const { data } = await getClientSupabase() + .from("codev") + .select("id, first_name, last_name, role_id, level") + .in("application_status", ["passed"]) + .order("first_name"); + return data ?? []; + }, + }); + + // The store hydrates after first paint, so the server's viewerId is the + // fallback until it does. Without it this query is disabled on mount and the + // seeded totals would never be used. + const pointsUserId = activeUser?.id ?? viewerId; + + const { data: pointsData, isPending: loading } = useQuery({ + queryKey: ["certificate", "points", pointsUserId], + enabled: Boolean(pointsUserId), + staleTime: 60_000, + // Seeded only for the viewer's own id: the server rendered exactly that key. + // No initialDataUpdatedAt override here (unlike the paginated tables): this + // is a plain per-user read with no filter dimension, so the seed is current + // and refetching it on mount would just duplicate the server's work. + initialData: pointsUserId === viewerId ? initialPoints : undefined, + queryFn: async () => { + const supabase = getClientSupabase(); + + const { data: codevPoints } = await supabase + .from("codev_points") + .select("points, skill_category_id") + .eq("codev_id", pointsUserId); + + const { data: attendanceData } = await supabase + .from("attendance_points") + .select("points") + .eq("codev_id", pointsUserId) + .maybeSingle(); + + const skillPoints: Record = {}; + codevPoints?.forEach((cp) => { + if (cp.skill_category_id) skillPoints[cp.skill_category_id] = cp.points; + }); + + const attendance = attendanceData?.points ?? 0; + const skillTotal = codevPoints?.reduce((acc, cp) => acc + (cp.points || 0), 0) ?? 0; + + return { skillPoints, attendancePoints: attendance, totalPoints: skillTotal + attendance }; + }, + }); + + const skillPoints = pointsData?.skillPoints ?? {}; + const attendancePoints = pointsData?.attendancePoints ?? 0; + const totalPoints = pointsData?.totalPoints ?? 0; + + // Handle user selection for admin + const handleUserSelect = (userId: string) => { + const selected = allUsers.find(u => u.id === userId); + setSelectedUserId(userId); + setSelectedUser(selected); + setSelectedUserLevel(selected?.level || 0); + }; + + const getCertData = (): CertificateProps => { + switch (certificateType) { + case "codev": + return { + title: "ACHIEVEMENT", + name: name, + mainSentence: ( +

+ has achieved Codev Status at{" "} + Codebility +

+ ), + description1: ( +

+ The recipient has achieved 200 points, demonstrating proficiency in language frameworks, + completed portfolio projects, and shown commitment to continuous + learning and development practices. +

+ ), + }; + case "mentor": + return { + title: "LEADERSHIP", + name: name, + mainSentence: ( +

+ has achieved Mentor Status at{" "} + Codebility +

+ ), + description1: ( +

+ The recipient has achieved 300 points, demonstrating specialization, + advanced concepts mastery, and leadership capabilities. Qualified to + lead teams, mentor codevs, and earn commissions on project deployments. +

+ ), + }; + default: + return { + title: "GRADUATION", + name: name, + mainSentence: ( +

+ has successfully completed the{" "} + Intern Graduate Path at{" "} + Codebility +

+ ), + description1: ( +

+ The recipient has mastered the basics, demonstrated hands-on practice, + and achieved proficiency in version control. This achievement represents + 100 points of dedicated learning and growth. +

+ ), + }; + } + }; + + const certData = getCertData(); + + // Check if user has enough points (admins can bypass this requirement) + const hasEnoughPoints = isAdmin || totalPoints >= 100; + const pointsNeeded = Math.max(0, 100 - totalPoints); + + // Determine which certificate level user has earned + const getEarnedCertificate = () => { + if (totalPoints >= 300) return "mentor"; + if (totalPoints >= 200) return "codev"; + if (totalPoints >= 100) return "intern"; + return null; + }; + + const earnedCertificate = getEarnedCertificate(); + + return ( +
+
+ +

+ Certificate Preview {isAdmin && selectedUser && `- ${selectedUser.first_name} ${selectedUser.last_name}`} +

+
+
+ + {loading ? ( + +
+
+

Loading your progress...

+
+
+ ) : !hasEnoughPoints ? ( + +
+
+ + + +
+
+

+ Certificate Locked +

+

+ You need to earn {pointsNeeded} more points to unlock your first certificate. +

+
+
+ Your Progress + {totalPoints} / 100 pts +
+
+
+
+
+
+ +
+ + ) : ( + <> + {/* Controls */} + +

Certificate Details

+
+ {isAdmin && ( +
+ + +
+ )} + +
+ + setName(e.target.value)} + className="w-full px-4 py-2.5 border border-gray-300 dark:border-gray-600 rounded-lg + bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 + focus:ring-2 focus:ring-customBlue-500 focus:border-transparent + transition-all duration-200" + placeholder="Enter recipient name" + /> +
+ +
+ + +
+
+ + {earnedCertificate && ( +
+

+ Congratulations! You have {totalPoints} points and have earned up to the {earnedCertificate} certificate. +

+
+ )} + +
+ +
+
+ + {/* Certificate Preview */} +
+
+
+
+ +
+
+
+
+ + )} +
+ ); +} \ No newline at end of file diff --git a/apps/codebility/app/home/certificate-preview/loading.tsx b/apps/codebility/app/home/certificate-preview/loading.tsx new file mode 100644 index 000000000..44758374e --- /dev/null +++ b/apps/codebility/app/home/certificate-preview/loading.tsx @@ -0,0 +1,7 @@ +import { FormSkeleton } from "@/app/home/_components/skeletons/RouteSkeletons"; + +const Loading = () => { + return ; +}; + +export default Loading; diff --git a/apps/codebility/app/home/certificate-preview/page.tsx b/apps/codebility/app/home/certificate-preview/page.tsx index b8b7e7c03..942659a1b 100644 --- a/apps/codebility/app/home/certificate-preview/page.tsx +++ b/apps/codebility/app/home/certificate-preview/page.tsx @@ -1,385 +1,13 @@ -"use client"; +import { getCodevPointsSummary } from "@/lib/server/codev-points.service"; +import { getCurrentCodev } from "@/lib/server/current-codev"; -import { useRef, useState, useEffect } from "react"; -import { Button } from "@codevs/ui/button"; -import { Card } from "@codevs/ui/card"; -import { useUserStore } from "@/store/codev-store"; -import { ArrowLeft } from "lucide-react"; -import { useRouter } from "next/navigation"; -import Certificate, { CertificateProps } from "../(dashboard)/_components/DashboardDownloadCertificate"; +import CertificatePreview from "./_components/CertificatePreviewClient"; -export const dynamic = "force-dynamic"; - -export default function CertificatePreview() { - const router = useRouter(); - const certRef = useRef(null); - const { user, userLevel } = useUserStore(); - const [loading, setLoading] = useState(true); - const [totalPoints, setTotalPoints] = useState(0); - const [skillPoints, setSkillPoints] = useState>({}); - const [attendancePoints, setAttendancePoints] = useState(0); - - // Admin-only state - const [allUsers, setAllUsers] = useState([]); - const [selectedUserId, setSelectedUserId] = useState(""); - const [selectedUser, setSelectedUser] = useState(null); - const [selectedUserLevel, setSelectedUserLevel] = useState(0); - - const isAdmin = user?.role_id === 1; - - // Determine initial certificate type based on user role and points - const getInitialCertType = () => { - // Based on roadmap: 0-100pts = Intern, 100-200pts = Codev, 200+ = Mentor - const currentUser = isAdmin && selectedUser ? selectedUser : user; - const currentLevel = isAdmin && selectedUser ? selectedUserLevel : userLevel; - - if (!currentUser) return "intern"; - - // Check if user has mentor role - if (currentUser.role_id === 5) return "mentor"; - - // For Codev role, check level - if (currentUser.role_id === 10) { - return currentLevel >= 2 ? "codev" : "intern"; - } - - return "intern"; - }; - - const [certificateType, setCertificateType] = useState<"intern" | "codev" | "mentor">(getInitialCertType()); - const [name, setName] = useState(""); - - useEffect(() => { - const currentUser = isAdmin && selectedUser ? selectedUser : user; - if (currentUser) { - setName(`${currentUser.first_name} ${currentUser.last_name}`); - setCertificateType(getInitialCertType()); - } - }, [user, userLevel, selectedUser, selectedUserLevel, isAdmin]); - - // Load all users for admin selection - useEffect(() => { - const fetchUsers = async () => { - if (!isAdmin) return; - - const supabase = await import('@/utils/supabase/client').then(m => m.createClientClientComponent()); - - try { - const { data: users } = await supabase - .from('codev') - .select('id, first_name, last_name, role_id, level') - .in('application_status', ['passed']) - .order('first_name'); - - setAllUsers(users || []); - } catch (error) { - console.error('Error fetching users:', error); - } - }; - - fetchUsers(); - }, [isAdmin]); - - // Fetch user points - useEffect(() => { - const fetchPoints = async () => { - const currentUser = isAdmin && selectedUser ? selectedUser : user; - if (!currentUser) return; - - setLoading(true); - const supabase = await import('@/utils/supabase/client').then(m => m.createClientClientComponent()); - - try { - // Fetch skill points - const { data: codevPoints } = await supabase - .from('codev_points') - .select('points, skill_category_id') - .eq('codev_id', currentUser.id); - - // Fetch attendance points - const { data: attendanceData } = await supabase - .from('attendance_points') - .select('points') - .eq('codev_id', currentUser.id) - .single(); - - const points = codevPoints?.reduce((acc, cp) => { - acc[cp.skill_category_id] = cp.points; - return acc; - }, {} as Record) || {}; - - setSkillPoints(points); - setAttendancePoints(attendanceData?.points || 0); - - const totalSkillPoints = Object.values(points).reduce((sum, point) => sum + point, 0); - setTotalPoints(totalSkillPoints + (attendanceData?.points || 0)); - } catch (error) { - console.error('Error fetching points:', error); - } finally { - setLoading(false); - } - }; - - fetchPoints(); - }, [user, selectedUser, isAdmin]); - - // Handle user selection for admin - const handleUserSelect = (userId: string) => { - const selected = allUsers.find(u => u.id === userId); - setSelectedUserId(userId); - setSelectedUser(selected); - setSelectedUserLevel(selected?.level || 0); - }; - - const getCertData = (): CertificateProps => { - switch (certificateType) { - case "codev": - return { - title: "ACHIEVEMENT", - name: name, - mainSentence: ( -

- has achieved Codev Status at{" "} - Codebility -

- ), - description1: ( -

- The recipient has achieved 200 points, demonstrating proficiency in language frameworks, - completed portfolio projects, and shown commitment to continuous - learning and development practices. -

- ), - }; - case "mentor": - return { - title: "LEADERSHIP", - name: name, - mainSentence: ( -

- has achieved Mentor Status at{" "} - Codebility -

- ), - description1: ( -

- The recipient has achieved 300 points, demonstrating specialization, - advanced concepts mastery, and leadership capabilities. Qualified to - lead teams, mentor codevs, and earn commissions on project deployments. -

- ), - }; - default: - return { - title: "GRADUATION", - name: name, - mainSentence: ( -

- has successfully completed the{" "} - Intern Graduate Path at{" "} - Codebility -

- ), - description1: ( -

- The recipient has mastered the basics, demonstrated hands-on practice, - and achieved proficiency in version control. This achievement represents - 100 points of dedicated learning and growth. -

- ), - }; - } - }; - - const certData = getCertData(); - - // Check if user has enough points (admins can bypass this requirement) - const hasEnoughPoints = isAdmin || totalPoints >= 100; - const pointsNeeded = Math.max(0, 100 - totalPoints); - - // Determine which certificate level user has earned - const getEarnedCertificate = () => { - if (totalPoints >= 300) return "mentor"; - if (totalPoints >= 200) return "codev"; - if (totalPoints >= 100) return "intern"; - return null; - }; - - const earnedCertificate = getEarnedCertificate(); +export default async function CertificatePreviewPage() { + const codev = await getCurrentCodev(); + const initialPoints = await getCodevPointsSummary(codev?.id ?? ""); return ( -
-
- -

- Certificate Preview {isAdmin && selectedUser && `- ${selectedUser.first_name} ${selectedUser.last_name}`} -

-
-
- - {loading ? ( - -
-
-

Loading your progress...

-
-
- ) : !hasEnoughPoints ? ( - -
-
- - - -
-
-

- Certificate Locked -

-

- You need to earn {pointsNeeded} more points to unlock your first certificate. -

-
-
- Your Progress - {totalPoints} / 100 pts -
-
-
-
-
-
- -
- - ) : ( - <> - {/* Controls */} - -

Certificate Details

-
- {isAdmin && ( -
- - -
- )} - -
- - setName(e.target.value)} - className="w-full px-4 py-2.5 border border-gray-300 dark:border-gray-600 rounded-lg - bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 - focus:ring-2 focus:ring-customBlue-500 focus:border-transparent - transition-all duration-200" - placeholder="Enter recipient name" - /> -
- -
- - -
-
- - {earnedCertificate && ( -
-

- Congratulations! You have {totalPoints} points and have earned up to the {earnedCertificate} certificate. -

-
- )} - -
- -
-
- - {/* Certificate Preview */} -
-
-
-
- -
-
-
-
- - )} -
+ ); -} \ No newline at end of file +} diff --git a/apps/codebility/app/home/clients/_components/ClientAddModal.tsx b/apps/codebility/app/home/clients/_components/ClientAddModal.tsx index a5642bf4e..d047e205e 100644 --- a/apps/codebility/app/home/clients/_components/ClientAddModal.tsx +++ b/apps/codebility/app/home/clients/_components/ClientAddModal.tsx @@ -6,8 +6,8 @@ import { ClientFormValues, clientSchema, getFormItemLabels, -} from "@/app/home/clients/_lib/schema"; -import { createClientAction } from "@/app/home/clients/action"; +} from "@/utils/validations/clients"; +import { createClientAction } from "@/actions/clients/actions"; import DefaultAvatar from "@/components/DefaultAvatar"; import { Button } from "@codevs/ui/button"; import { @@ -17,8 +17,8 @@ import { DialogHeader, DialogTitle, } from "@codevs/ui/dialog"; -import { useModal } from "@/hooks/use-modal-clients"; -import { useCountries } from "@/hooks/useCountries"; // Import new hook +import { useModal } from "@/hooks/modals/use-modal-clients"; +import { useCountries } from "@/hooks/data/useCountries"; // Import new hook import { zodResolver } from "@hookform/resolvers/zod"; import { useForm } from "react-hook-form"; import toast from "react-hot-toast"; diff --git a/apps/codebility/app/home/clients/_components/ClientEditModal.tsx b/apps/codebility/app/home/clients/_components/ClientEditModal.tsx index 1adfa341f..edd2a8940 100644 --- a/apps/codebility/app/home/clients/_components/ClientEditModal.tsx +++ b/apps/codebility/app/home/clients/_components/ClientEditModal.tsx @@ -6,7 +6,7 @@ import { clientSchema, ClientWithStatusFormValues, getFormItemLabels, -} from "@/app/home/clients/_lib/schema"; +} from "@/utils/validations/clients"; import DefaultAvatar from "@/components/DefaultAvatar"; import { Button } from "@codevs/ui/button"; import { @@ -16,7 +16,7 @@ import { DialogHeader, DialogTitle, } from "@codevs/ui/dialog"; -import { useModal } from "@/hooks/use-modal-clients"; +import { useModal } from "@/hooks/modals/use-modal-clients"; import { zodResolver } from "@hookform/resolvers/zod"; import { useForm } from "react-hook-form"; import toast from "react-hot-toast"; @@ -37,8 +37,8 @@ import { SelectValue, } from "@codevs/ui/select"; -import { updateClientAction } from "../action"; -import { useCountries } from "@/hooks/useCountries"; // New centralized hook +import { updateClientAction } from "@/actions/clients/actions"; +import { useCountries } from "@/hooks/data/useCountries"; // New centralized hook export default function ClientEditModal() { const { isOpen, onClose, type, data } = useModal(); diff --git a/apps/codebility/app/home/clients/_components/ClientsButton.tsx b/apps/codebility/app/home/clients/_components/ClientsButton.tsx index 295859e1a..743945dd1 100644 --- a/apps/codebility/app/home/clients/_components/ClientsButton.tsx +++ b/apps/codebility/app/home/clients/_components/ClientsButton.tsx @@ -1,7 +1,7 @@ "use client"; import AddNewButton from "@/components/ui/AddNewButton"; -import { useModal } from "@/hooks/use-modal-clients"; +import { useModal } from "@/hooks/modals/use-modal-clients"; import { useUserStore } from "@/store/codev-store"; export default function ClientButtons() { diff --git a/apps/codebility/app/home/clients/_components/ClientsCard.tsx b/apps/codebility/app/home/clients/_components/ClientsCard.tsx index 408a96e7b..2ecd3ed7f 100644 --- a/apps/codebility/app/home/clients/_components/ClientsCard.tsx +++ b/apps/codebility/app/home/clients/_components/ClientsCard.tsx @@ -9,8 +9,10 @@ import { Button } from "@/components/ui/button"; import DefaultPagination from "@/components/ui/pagination"; import SwitchStatusButton from "@/components/ui/SwitchStatusButton"; import { pageSize } from "@/constants"; -import { useModal } from "@/hooks/use-modal-clients"; -import usePagination from "@/hooks/use-pagination"; +import { useModal } from "@/hooks/modals/use-modal-clients"; +import { usePaginatedQuery } from "@/hooks/query/use-paginated-query"; +import { qk } from "@/lib/shared/query-keys"; +import type { Page } from "@/lib/server/paginate"; import { IconCopy, IconMail, @@ -21,15 +23,20 @@ import { useUserStore } from "@/store/codev-store"; import { Client } from "@/types/home/codev"; import toast from "react-hot-toast"; -import { copyToClipboard, handleDownload } from "../_lib/utils"; -import { deleteClientAction, toggleClientStatusAction } from "../action"; +import { copyToClipboard, handleDownload } from "@/utils/clients/utils"; +import { + deleteClientAction, + fetchClientsPageAction, + toggleClientStatusAction, +} from "@/actions/clients/actions"; interface Props { - clients: Client[]; + initialData: Page; } -export default function ClientCards({ clients }: Props) { +export default function ClientCards({ initialData }: Props) { const [isLoading, setIsLoading] = useState(false); + const [page, setPage] = useState(1); const { onOpen } = useModal(); const pathname = usePathname(); const { user } = useUserStore(); @@ -39,14 +46,17 @@ export default function ClientCards({ clients }: Props) { user?.role_id === 3 || user?.role_id === 5; - const { - currentPage, - totalPages, - paginatedData: paginatedClients, - handleNextPage, - handlePreviousPage, - setCurrentPage, - } = usePagination(clients, pageSize.clients); + const { data, showSkeleton } = usePaginatedQuery( + qk.clients.list({ page }), + () => fetchClientsPageAction({ page, pageSize: pageSize.clients }), + { + initialData, + initialDataKey: qk.clients.list({ page: 1 }), + }, + ); + + const clients = data?.rows ?? []; + const totalPages = Math.max(Math.ceil((data?.total ?? 0) / (data?.pageSize ?? 1)), 1); /** * Toggle client status between 'active' and 'inactive'. @@ -101,8 +111,15 @@ export default function ClientCards({ clients }: Props) { return ( <>
- {clients?.length > 0 ? ( - paginatedClients?.map((client) => { + {showSkeleton ? ( + Array.from({ length: pageSize.clients }).map((_, i) => ( +
+ )) + ) : clients?.length > 0 ? ( + clients.map((client) => { const isActive = client.status === "active"; return (
{/* Pagination */} - {clients.length > pageSize.clients && ( + {totalPages > 1 && ( setPage((p) => Math.min(p + 1, totalPages))} + handlePreviousPage={() => setPage((p) => Math.max(p - 1, 1))} + setCurrentPage={(target: number) => + setPage(Math.max(1, Math.min(target, totalPages))) + } totalPages={totalPages} /> )} diff --git a/apps/codebility/app/home/clients/page.tsx b/apps/codebility/app/home/clients/page.tsx index a2dd2351a..c77b9a7ff 100644 --- a/apps/codebility/app/home/clients/page.tsx +++ b/apps/codebility/app/home/clients/page.tsx @@ -1,25 +1,15 @@ import H1 from "@/components/shared/dashboard/H1"; import AsyncErrorBoundary from "@/components/AsyncErrorBoundary"; -import { getClients } from "@/lib/server/codev.service"; -import { Client } from "@/types/home/codev"; +import { pageSize } from "@/constants"; +import { getClientsPage } from "@/lib/server/codev.service"; import PageContainer from "../_components/PageContainer"; import ClientButtons from "./_components/ClientsButton"; import ClientCards from "./_components/ClientsCard"; -export const dynamic = "force-dynamic"; -export const revalidate = 0; export default async function Clients() { - const { data, error } = await getClients(); - - let clients: Client[] = data ?? []; - - clients.sort((a, b) => { - if (a.status === b.status) return 0; - if (a.status === "active") return -1; - return 1; - }); + const initialData = await getClientsPage({ page: 1, pageSize: pageSize.clients }); return ( @@ -42,12 +32,7 @@ export default async function Clients() {
- {error ? ( -
ERROR
- ) : ( - // Pass our sorted array to the client-card component - - )} +
diff --git a/apps/codebility/app/home/error.tsx b/apps/codebility/app/home/error.tsx new file mode 100644 index 000000000..61a1e7fdf --- /dev/null +++ b/apps/codebility/app/home/error.tsx @@ -0,0 +1,36 @@ +"use client"; + +import { useEffect } from "react"; + +import { Button } from "@codevs/ui/button"; + +export default function HomeError({ + error, + reset, +}: { + error: Error & { digest?: string }; + reset: () => void; +}) { + useEffect(() => { + console.error("Home route error:", error); + }, [error]); + + return ( +
+

+ Something went wrong +

+

+ This page failed to load. You can retry without leaving the app. +

+ + {process.env.NODE_ENV === "development" && error.message && ( +
+          {error.message}
+        
+ )} + + +
+ ); +} diff --git a/apps/codebility/app/home/feeds/_components/CreatePostForm.tsx b/apps/codebility/app/home/feeds/_components/CreatePostForm.tsx index a1931cfae..d45a515e9 100644 --- a/apps/codebility/app/home/feeds/_components/CreatePostForm.tsx +++ b/apps/codebility/app/home/feeds/_components/CreatePostForm.tsx @@ -6,7 +6,7 @@ import { useFeedsStore } from "@/store/feeds-store"; import { uploadImage } from "@/utils/uploadImage"; import toast from "react-hot-toast"; -import { addPost } from "../_services/action"; +import { addPost } from "@/actions/feeds/post"; import MarkdownEditor from "./MarkdownEditor"; import TagSelector from "./TagSelector"; import ThumbnailUpload from "./ThumbnailUpload"; diff --git a/apps/codebility/app/home/feeds/_components/EditPostForm.tsx b/apps/codebility/app/home/feeds/_components/EditPostForm.tsx index 5b7eace78..14f7dba31 100644 --- a/apps/codebility/app/home/feeds/_components/EditPostForm.tsx +++ b/apps/codebility/app/home/feeds/_components/EditPostForm.tsx @@ -8,8 +8,8 @@ import toast from "react-hot-toast"; import { Textarea } from "@codevs/ui/textarea"; -import { addPost, editPost } from "../_services/action"; -import { PostType } from "../_services/query"; +import { addPost, editPost } from "@/actions/feeds/post"; +import type { PostType } from "@/types/feeds"; import MarkdownEditor from "./MarkdownEditor"; import TagSelector from "./TagSelector"; import ThumbnailUpload from "./ThumbnailUpload"; diff --git a/apps/codebility/app/home/feeds/_components/Feed.tsx b/apps/codebility/app/home/feeds/_components/Feed.tsx index d64779945..8fecd825d 100644 --- a/apps/codebility/app/home/feeds/_components/Feed.tsx +++ b/apps/codebility/app/home/feeds/_components/Feed.tsx @@ -1,10 +1,10 @@ "use client"; -import { useEffect, useMemo, useRef, useState } from "react"; +import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; import { useFeedsStore } from "@/store/feeds-store"; +import type { PostType } from "@/types/feeds"; -import { POSTS_PER_PAGE } from "../_constants"; -import { SYSTEM_POST } from "../_constants/system-post"; +import { POSTS_PER_PAGE, SYSTEM_POST } from "@/constants/feeds"; import Post from "./PostCard"; import PostCardSkeleton from "./PostCardSkeleton"; @@ -13,6 +13,7 @@ interface FeedProp { searchQuery?: string; sortField: "title" | "date" | "upvotes" | "comments"; sortOrder: "asc" | "desc"; + initialPosts: PostType[]; } export default function Feed({ @@ -20,17 +21,38 @@ export default function Feed({ searchQuery, sortField, sortOrder, + initialPosts, }: FeedProp) { const posts = useFeedsStore((state) => state.posts); - const fetchPosts = useFeedsStore((state) => state.fetchPosts); const isFetchingPosts = useFeedsStore((state) => state.isFetchingPosts); const [visibleCount, setVisibleCount] = useState(POSTS_PER_PAGE); const loaderRef = useRef(null); - useEffect(() => { - fetchPosts(); - }, []); + // Seed from the server render instead of fetching on mount. + // + // This runs in a layout effect, not during render. Writing to the store while + // rendering is only safe when the write is on this component's own behalf; a + // post mutation elsewhere (CreatePostForm -> fetchPosts) updates the store and + // re-renders Feed, and the render-time setState then counted as updating a + // component during another component's render, which React reports as + // "Cannot update a component (Feed) while rendering a different component". + // + // The `seededFrom` ref still guards against clobbering fresher store data: + // fetchPosts() replaces `posts` wholesale, so re-seeding from a stale + // `initialPosts` would discard the new post. + const seededFrom = useRef(null); + useLayoutEffect(() => { + if (seededFrom.current === initialPosts) return; + seededFrom.current = initialPosts; + + // Only adopt the server payload if nothing has fetched since it was + // produced. Once a mutation refreshes the store, the store wins. + const { isFetchingPosts: stillFetching } = useFeedsStore.getState(); + if (stillFetching) { + useFeedsStore.setState({ posts: initialPosts, isFetchingPosts: false }); + } + }, [initialPosts]); // Always prepend system post to regular posts const allPosts = useMemo(() => { @@ -94,11 +116,6 @@ export default function Feed({ const visiblePosts = sortedPosts.slice(0, visibleCount); - // Reset visible count when search changes - useEffect(() => { - setVisibleCount(POSTS_PER_PAGE); - }, [searchQuery]); - // Infinite scroll useEffect(() => { const observer = new IntersectionObserver( diff --git a/apps/codebility/app/home/feeds/_components/FeedsPageClient.tsx b/apps/codebility/app/home/feeds/_components/FeedsPageClient.tsx new file mode 100644 index 000000000..3d1ba2581 --- /dev/null +++ b/apps/codebility/app/home/feeds/_components/FeedsPageClient.tsx @@ -0,0 +1,306 @@ +"use client"; + +import { useDeferredValue, useRef, useState } from "react"; +import CreatePostModal from "@/components/modals/CreatePostModal"; +import { defaultAvatar } from "@/public/assets/images"; +import Image from "next/image"; +import { AnimatePresence, motion } from "framer-motion"; +import type { CurrentCodev } from "@/lib/server/current-codev"; +import type { PostType } from "@/types/feeds"; + +import Feed from "./Feed"; +import SortMenu from "./SortMenu"; +import { hasReachedDailyPostLimit } from "@/actions/feeds/post"; +import { useOutsideClick } from "@/hooks/ui/use-outside-click"; +import toast from "react-hot-toast"; +import { + PlusCircle, + Search, + MessageSquareText, + Sparkles, + TrendingUp, + SlidersHorizontal, + X, +} from "lucide-react"; + +type SortField = "title" | "date" | "upvotes" | "comments"; +type SortOrder = "asc" | "desc"; + +export default function FeedsPageClient({ + user, + isAdmin, + userHasNotPostedYet, + socialPoints, + initialPosts, +}: { + user: CurrentCodev | null; + isAdmin: boolean; + userHasNotPostedYet: boolean; + socialPoints: number | null; + initialPosts: PostType[]; +}) { + const [isModalOpen, setIsModalOpen] = useState(false); + const [searchQuery, setSearchQuery] = useState(""); + const [sortField, setSortField] = useState("date"); + const [sortOrder, setSortOrder] = useState("desc"); + const [filterOpen, setFilterOpen] = useState(false); + const filterRef = useRef(null); + + // React keeps the previous list on screen while the new one renders, so no + // timer or effect is needed to debounce the query. + const deferredQuery = useDeferredValue(searchQuery); + + useOutsideClick(filterRef, filterOpen, () => setFilterOpen(false)); + + const openModal = async () => { + if (!user) return; + try { + const unallowed = await hasReachedDailyPostLimit(); + if (unallowed) { + toast.error("Daily post limit reached. You're not allowed to create a post right now."); + return; + } + setIsModalOpen(true); + } catch (error) { + console.error("Failed to check posting permission", error); + } + }; + + const closeModal = () => setIsModalOpen(false); + + const cardClass = + "rounded-2xl border border-gray-200 bg-white shadow-sm dark:border-gray-700 dark:bg-gray-800"; + + return ( + <> +
+
+ + {/* ── PAGE HEADER ── */} +
+
+

+ Feeds +

+

+ Stay updated with the latest announcements and discussions from the Codebility community. +

+
+ + {/* Filter toggle button — hidden on xl (sidebar handles it) */} +
+ + + {/* Collapsible filter drawer */} + + {filterOpen && ( + +

+ Search & Filter +

+ + {/* Search input */} +
+ + setSearchQuery(e.target.value)} + className="w-full rounded-lg border border-gray-200 bg-gray-50 py-2 pl-9 pr-3 text-sm text-gray-900 placeholder-gray-400 outline-none transition focus:border-cyan-500 focus:ring-1 focus:ring-cyan-500/30 dark:border-gray-600 dark:bg-gray-700 dark:text-white dark:placeholder-gray-500" + /> +
+ + {/* Sort controls */} + +
+ )} +
+
+
+ +
+ + {/* ── LEFT SIDEBAR — xl+ only ── */} + + + {/* ── MAIN CONTENT ── */} +
+ + {/* Top row: Create Post + Trending */} +
+ + {/* Create Post Card */} + + + {/* Trending / Widget Card */} +
+
+ +

+ Trending +

+
+
+ {[1, 2, 3].map((i) => ( +
+
+
+
+
+
+
+ ))} +
+

+ Top users & trends coming soon +

+
+
+ + {/* Feed grid: + - mobile (default) : 1 column + - tablet (md) : 2 columns + - desktop (xl) : 3 columns + */} + {/* Keyed on the query so a new search resets the pagination + window by remounting, instead of an effect resetting state. */} + +
+
+
+
+ + + + ); +} diff --git a/apps/codebility/app/home/feeds/_components/MarkdownEditor.tsx b/apps/codebility/app/home/feeds/_components/MarkdownEditor.tsx index 11e0c486b..239a5cc70 100644 --- a/apps/codebility/app/home/feeds/_components/MarkdownEditor.tsx +++ b/apps/codebility/app/home/feeds/_components/MarkdownEditor.tsx @@ -4,7 +4,7 @@ import React, { useEffect, useRef, useState } from "react"; import ReactMarkdown from "react-markdown"; import remarkGfm from "remark-gfm"; -import { uploadPostContentImage } from "../_services/action"; +import { uploadPostContentImage } from "@/actions/feeds/post"; export default function MarkdownEditor({ initialValue = "", diff --git a/apps/codebility/app/home/feeds/_components/PostCard.tsx b/apps/codebility/app/home/feeds/_components/PostCard.tsx index 906b40b9f..6c42d9af3 100644 --- a/apps/codebility/app/home/feeds/_components/PostCard.tsx +++ b/apps/codebility/app/home/feeds/_components/PostCard.tsx @@ -7,8 +7,8 @@ import { useUserStore } from "@/store/codev-store"; import { format } from "date-fns"; import toast from "react-hot-toast"; -import { deletePost } from "../_services/action"; -import { PostType } from "../_services/query"; +import { deletePost } from "@/actions/feeds/post"; +import type { PostType } from "@/types/feeds"; import { DeleteDialog } from "./DeleteDialog"; import PostCommentCount from "./PostCommentCount"; import PostTags from "./PostTags"; @@ -124,7 +124,12 @@ export default function Post({ post, isAdmin, onDelete }: PostProps) {
- + setShowDeleteDialog(false)} diff --git a/apps/codebility/app/home/feeds/_components/PostCommentCount.tsx b/apps/codebility/app/home/feeds/_components/PostCommentCount.tsx index db0fda115..b8adf1841 100644 --- a/apps/codebility/app/home/feeds/_components/PostCommentCount.tsx +++ b/apps/codebility/app/home/feeds/_components/PostCommentCount.tsx @@ -1,25 +1,18 @@ -import { useEffect, useState } from "react"; -import { useFeedsStore } from "@/store/feeds-store"; import { MessageSquare } from "lucide-react"; -import { PostType } from "../_services/query"; +import type { PostType } from "@/types/feeds"; interface PostCommentCountProps { post: PostType; } export default function PostCommentCount({ post }: PostCommentCountProps) { - const [count, setCount] = useState(post.comment_count ?? 0); - - // Update local state whenever the post prop changes - useEffect(() => { - setCount(post.comment_count ?? 0); - }, [post]); - + // Rendered straight from the prop: the previous version mirrored it into + // state via an effect keyed on `post`, which re-ran on every store write. return (
- {count} + {post.comment_count ?? 0}
); } diff --git a/apps/codebility/app/home/feeds/_components/PostTags.tsx b/apps/codebility/app/home/feeds/_components/PostTags.tsx index 4d1bc2cde..19e9a941a 100644 --- a/apps/codebility/app/home/feeds/_components/PostTags.tsx +++ b/apps/codebility/app/home/feeds/_components/PostTags.tsx @@ -1,6 +1,6 @@ "use client"; -import type { PostType } from "../_services/query"; +import type { PostType } from "@/types/feeds"; interface PostTagsProps { post: PostType; diff --git a/apps/codebility/app/home/feeds/_components/PostUpvote.tsx b/apps/codebility/app/home/feeds/_components/PostUpvote.tsx index 775c11bd1..8b2565850 100644 --- a/apps/codebility/app/home/feeds/_components/PostUpvote.tsx +++ b/apps/codebility/app/home/feeds/_components/PostUpvote.tsx @@ -1,60 +1,25 @@ "use client"; -import { useEffect, useState } from "react"; +import { useState } from "react"; import { useUserStore } from "@/store/codev-store"; -import { useFeedsStore } from "@/store/feeds-store"; import { ArrowBigUp } from "lucide-react"; -import { - AddPostUpvote, - countUpvotes, - hasUserUpvoted, - removePostUpvote, -} from "../_services/action"; -import { PostType } from "../_services/query"; +import { AddPostUpvote, removePostUpvote } from "@/actions/feeds/post"; +import type { PostType } from "@/types/feeds"; interface PostUpvoteProps { post: PostType; } export default function PostUpvote({ post }: PostUpvoteProps) { - const { user } = useUserStore(); + const userId = useUserStore((state) => state.user?.id); - const [isUpvoted, setIsUpvoted] = useState(false); - const [upvotes, setUpvotes] = useState(0); - const { posts, fetchPosts } = useFeedsStore((state) => ({ - posts: state.posts, - fetchPosts: state.fetchPosts, - })); - - useEffect(() => { - const checkUpvote = async () => { - if (user?.id && post?.id) { - try { - const result = await hasUserUpvoted(post.id, user.id); - setIsUpvoted(result); - } catch (error) { - console.error("Error checking upvote status:", error); - setIsUpvoted(false); - } - } else { - setIsUpvoted(false); - } - - if (post?.id) { - try { - const upvotesCount = await countUpvotes(post.id); - setUpvotes(upvotesCount); - } catch (error) { - console.error("Error counting upvotes:", error); - } - } - }; - checkUpvote(); - }, [user?.id, post?.id, posts]); + // Both seeded from the server payload, so a card issues no request on mount. + const [isUpvoted, setIsUpvoted] = useState(post.has_upvoted ?? false); + const [upvotes, setUpvotes] = useState(post.upvote_count ?? 0); const handleUpvote = async (e: React.MouseEvent) => { - if (user) { + if (userId) { e.stopPropagation(); e.preventDefault(); @@ -62,17 +27,9 @@ export default function PostUpvote({ post }: PostUpvoteProps) { setUpvotes((prev) => prev + (isUpvoted ? -1 : 1)); if (!isUpvoted) { - const postUpvote = await AddPostUpvote(post.id, user.id); - - useFeedsStore.getState().updatePost(post.id, { - upvote_count: post.comment_count! + 1, - }); + await AddPostUpvote(post.id); } else { - await removePostUpvote(post.id, user.id); - - useFeedsStore.getState().updatePost(post.id, { - upvote_count: post.comment_count! - 1, - }); + await removePostUpvote(post.id); } } }; diff --git a/apps/codebility/app/home/feeds/_components/PostView.tsx b/apps/codebility/app/home/feeds/_components/PostView.tsx index f48d1cd40..26bd5d834 100644 --- a/apps/codebility/app/home/feeds/_components/PostView.tsx +++ b/apps/codebility/app/home/feeds/_components/PostView.tsx @@ -1,6 +1,6 @@ "use client"; -import { useEffect, useState } from "react"; +import { useMemo, useState } from "react"; import Image from "next/image"; import EditPostModal from "@/components/modals/EditPostModal"; import { Box } from "@/components/shared/dashboard"; @@ -13,62 +13,47 @@ import { format } from "date-fns"; import ReactMarkdown from "react-markdown"; import remarkGfm from "remark-gfm"; -import type { PostType } from "../_services/query"; -import { SYSTEM_POST } from "../_constants/system-post"; +import type { PostType } from "@/types/feeds"; +import { SYSTEM_POST } from "@/constants/feeds/system-post"; import PostUpvote from "../_components/PostUpvote"; -import { getUserRole } from "../_services/action"; import PostCommentCount from "./PostCommentCount"; import PostTags from "./PostTags"; import PostViewCommentList from "./PostViewCommentList"; import PostViewCreateComment from "./PostViewCreateComment"; +const SYSTEM_POST_ID = "00000000-0000-0000-0000-000000000001"; + interface PostViewProps { postId: string; + /** + * Passed down from the server render. This component used to run its own + * getUserRole query on open, re-deriving a value the feed tree already had. + */ + isAdmin: boolean; } -export default function PostView({ postId }: PostViewProps) { - const [post, setPost] = useState(null); +export default function PostView({ postId, isAdmin }: PostViewProps) { const [isModalOpen, setIsModalOpen] = useState(false); - const [isAdmin, setIsAdmin] = useState(true); - const [isAuthor, setIsAuthor] = useState(false); - const { user } = useUserStore(); const [refreshComments, setRefreshComments] = useState(0); + const user = useUserStore((state) => state.user); + const roleId = user?.role_id ?? null; + const userId = user?.id ?? null; + + const posts = useFeedsStore((state) => state.posts); + + // The post is a lookup in data already in the store, so it is derived during + // render rather than copied into state by an effect. + const post: PostType | null = useMemo(() => { + if (postId === SYSTEM_POST_ID) return SYSTEM_POST; + return posts.find((p) => p.id === postId) ?? null; + }, [posts, postId]); - const { posts } = useFeedsStore(); + const isAuthor = userId !== null && userId === post?.author_id?.id; const triggerRefreshComments = () => { setRefreshComments((prev) => prev + 1); }; - // Find the post in the store or use system post - useEffect(() => { - if (postId === "00000000-0000-0000-0000-000000000001") { - setPost(SYSTEM_POST); - } else { - const foundPost = posts.find((p) => p.id === postId) || null; - setPost(foundPost); - } - }, [posts, postId]); - - // Check roles - useEffect(() => { - const fetchRole = async () => { - if (!user) return; - const role = await getUserRole(user.role_id ?? null); - setIsAdmin(role === "Admin"); - }; - - const checkIfAuthor = () => { - if (!user) return; - setIsAuthor(user.id === post?.author_id?.id); - }; - - if (user) { - fetchRole(); - checkIfAuthor(); - } - }, [user, post]); - if (!post) { return (
@@ -82,7 +67,7 @@ export default function PostView({ postId }: PostViewProps) { const openModal = () => setIsModalOpen(true); const closeModal = () => setIsModalOpen(false); - const isSystemPost = post.id === "00000000-0000-0000-0000-000000000001"; + const isSystemPost = post.id === SYSTEM_POST_ID; return ( <> diff --git a/apps/codebility/app/home/feeds/_components/PostViewCommentItem.tsx b/apps/codebility/app/home/feeds/_components/PostViewCommentItem.tsx index 35ebcb3f8..ba4efc6b4 100644 --- a/apps/codebility/app/home/feeds/_components/PostViewCommentItem.tsx +++ b/apps/codebility/app/home/feeds/_components/PostViewCommentItem.tsx @@ -4,7 +4,7 @@ import Image from "next/image"; import { useRouter } from "next/navigation"; import { useFeedsStore } from "@/store/feeds-store"; import { X } from "lucide-react"; -import { deletePostComment } from "../_services/action"; +import { deletePostComment } from "@/actions/feeds/post"; interface PostViewCommentItemProps { postId: string; diff --git a/apps/codebility/app/home/feeds/_components/PostViewCommentList.tsx b/apps/codebility/app/home/feeds/_components/PostViewCommentList.tsx index 88d486a4f..83df4a18b 100644 --- a/apps/codebility/app/home/feeds/_components/PostViewCommentList.tsx +++ b/apps/codebility/app/home/feeds/_components/PostViewCommentList.tsx @@ -1,8 +1,8 @@ "use client"; -import { useEffect, useState } from "react"; +import { useQuery } from "@tanstack/react-query"; import { defaultAvatar } from "@/public/assets/images"; import { useUserStore } from "@/store/codev-store"; -import { getPostComments } from "../_services/action"; +import { getPostComments } from "@/actions/feeds/post"; import PostViewCommentItem from "./PostViewCommentItem"; interface PostViewCommentListProps { @@ -36,37 +36,25 @@ export default function PostViewCommentList({ refresh, hasDeleteCommentPrivilege, }: PostViewCommentListProps) { - const [comments, setComments] = useState([]); - const [loading, setLoading] = useState(true); - const { user } = useUserStore(); + const user = useUserStore((state) => state.user); + const currentUserId = user?.id ?? null; - useEffect(() => { - const fetchComments = async () => { - try { + const { data: comments = [], isPending: loading } = useQuery({ + queryKey: ["feeds", "comments", postId, refresh], + queryFn: async (): Promise => { + const data = await getPostComments(postId); - const data = await getPostComments(postId); - - // Transform the data to ensure correct structure - const transformedComments = (data || []).map((comment: any) => ({ - id: comment.id, - content: comment.content, - created_at: comment.created_at, - commenter: Array.isArray(comment.commenter) - ? comment.commenter[0] - : comment.commenter, - mentions: comment.mentions || [], - })); - - setComments(transformedComments); - } catch (error) { - console.error("Error fetching comments:", error); - setComments([]); // Set empty array on error - } finally { - setLoading(false); - } - }; - fetchComments(); - }, [postId, refresh]); + return (data || []).map((comment: any) => ({ + id: comment.id, + content: comment.content, + created_at: comment.created_at, + commenter: Array.isArray(comment.commenter) + ? comment.commenter[0] + : comment.commenter, + mentions: comment.mentions || [], + })); + }, + }); if (loading) { return ( @@ -87,18 +75,17 @@ export default function PostViewCommentList({ return (
{comments.map((c) => { - const userImage = c.commenter?.image_url || defaultAvatar; return ( diff --git a/apps/codebility/app/home/feeds/_components/PostViewCreateComment.tsx b/apps/codebility/app/home/feeds/_components/PostViewCreateComment.tsx index 7fd524175..60678ec00 100644 --- a/apps/codebility/app/home/feeds/_components/PostViewCreateComment.tsx +++ b/apps/codebility/app/home/feeds/_components/PostViewCreateComment.tsx @@ -1,12 +1,14 @@ "use client"; import { useState, useRef, useEffect } from "react"; import Image from "next/image"; +import { useQuery } from "@tanstack/react-query"; +import { useDebounce } from "use-debounce"; import { defaultAvatar } from "@/public/assets/images"; import { useUserStore } from "@/store/codev-store"; import { useFeedsStore } from "@/store/feeds-store"; -import { createComment, searchUsers } from "../_services/action"; -import { PostType } from "../_services/query"; -import { UserMention } from "../_services/types"; +import { createComment, searchUsers } from "@/actions/feeds/post"; +import type { PostType } from "@/types/feeds"; +import { UserMention } from "@/types/feeds"; interface CreateCommentProps { post: PostType; @@ -19,42 +21,24 @@ export default function PostViewCreateComment({ }: CreateCommentProps) { const [comment, setComment] = useState(""); const [isSubmitting, setIsSubmitting] = useState(false); - const { user } = useUserStore(); + const user = useUserStore((state) => state.user); // Mention-related state const [showMentionDropdown, setShowMentionDropdown] = useState(false); const [mentionSearch, setMentionSearch] = useState(""); - const [mentionUsers, setMentionUsers] = useState([]); const [selectedMentionIndex, setSelectedMentionIndex] = useState(0); const [mentionStartPos, setMentionStartPos] = useState(null); - const [isSearching, setIsSearching] = useState(false); const inputRef = useRef(null); const dropdownRef = useRef(null); - // Search for users when mention search changes - useEffect(() => { - const fetchUsers = async () => { - if (!mentionSearch || mentionSearch.length < 1) { - setMentionUsers([]); - return; - } - - setIsSearching(true); - try { - const users = await searchUsers(mentionSearch); - setMentionUsers(users); - setSelectedMentionIndex(0); - } catch { - setMentionUsers([]); - } finally { - setIsSearching(false); - } - }; + const [debouncedMentionSearch] = useDebounce(mentionSearch, 200); - const debounceTimer = setTimeout(fetchUsers, 200); - return () => clearTimeout(debounceTimer); - }, [mentionSearch]); + const { data: mentionUsers = [], isFetching: isSearching } = useQuery({ + queryKey: ["feeds", "mentionUsers", debouncedMentionSearch], + enabled: debouncedMentionSearch.length > 0, + queryFn: () => searchUsers(debouncedMentionSearch), + }); // Handle clicks outside dropdown useEffect(() => { @@ -88,6 +72,7 @@ export default function PostViewCreateComment({ if (!textAfterAt.includes(" ") && textAfterAt.length >= 0) { setMentionStartPos(lastAtIndex); setMentionSearch(textAfterAt); + setSelectedMentionIndex(0); setShowMentionDropdown(true); } else { setShowMentionDropdown(false); @@ -108,6 +93,7 @@ export default function PostViewCreateComment({ setShowMentionDropdown(false); setMentionStartPos(null); setMentionSearch(""); + setSelectedMentionIndex(0); // Focus back on input setTimeout(() => { @@ -172,7 +158,7 @@ export default function PostViewCreateComment({ .map((match) => match[1]) .filter((mention): mention is string => !!mention); - await createComment(post.id, user.id, comment, mentions); + await createComment(post.id, comment, mentions); setComment(""); onCommentCreated?.(); diff --git a/apps/codebility/app/home/feeds/_components/SocialPointsCard.tsx b/apps/codebility/app/home/feeds/_components/SocialPointsCard.tsx deleted file mode 100644 index d6d3575ca..000000000 --- a/apps/codebility/app/home/feeds/_components/SocialPointsCard.tsx +++ /dev/null @@ -1,73 +0,0 @@ -"use client"; - -import { useEffect, useState } from "react"; -import { useUserStore } from "@/store/codev-store"; -import { useFeedsStore } from "@/store/feeds-store"; -import { MessageSquareText } from "lucide-react"; -import { shallow } from "zustand/shallow"; - -import { getSocialPoints } from "../_services/action"; - -export default function SocialPointsCard() { - const posts = useFeedsStore((state) => state.posts, shallow); - const { user } = useUserStore(); - const [points, setPoints] = useState(null); - const [loading, setLoading] = useState(true); - - useEffect(() => { - const fetchSocialPoints = async () => { - if (!user) { - setLoading(false); - return; - } - setLoading(true); - try { - const fetchedPoints = await getSocialPoints(user.id); - setPoints(fetchedPoints || 0); - } finally { - setLoading(false); - } - }; - - if (user) fetchSocialPoints(); - }, [user, posts]); - - return ( -
-
-
-
-
- -
-
-

Social Points

-

Feed Engagement

-
-
- -
- {loading ? ( -
-
-
- ) : ( - <> -

- {points ?? 0} - points -

- - )} -
-
- -
-

- Earn more points by posting and getting likes and comments! -

-
-
-
- ); -} diff --git a/apps/codebility/app/home/feeds/_components/TagSelector.tsx b/apps/codebility/app/home/feeds/_components/TagSelector.tsx index eb2d72a37..312f01e4d 100644 --- a/apps/codebility/app/home/feeds/_components/TagSelector.tsx +++ b/apps/codebility/app/home/feeds/_components/TagSelector.tsx @@ -1,60 +1,34 @@ "use client"; -import { useEffect, useState } from "react"; +import { useState } from "react"; +import { useQuery } from "@tanstack/react-query"; import { Button } from "@/components/ui/button"; import { Checkbox } from "@codevs/ui/checkbox"; -import { getPostTagsLookup } from "../_services/query"; +import { getPostTagsLookup } from "@/actions/feeds/queries"; interface TagSelectorProps { selectedTags?: number[]; onChange?: (selectedTagIds: number[]) => void; } -interface Tag { - id: number; - name: string; - selected: boolean; -} - const TagSelector = ({ selectedTags = [], onChange }: TagSelectorProps) => { - const [tags, setTags] = useState([]); + const [selectedIds, setSelectedIds] = useState(selectedTags); const [isOpen, setIsOpen] = useState(false); - useEffect(() => { - const fetchTags = async () => { - try { - const data = await getPostTagsLookup(); - if (!data) return; - - const mappedTags: Tag[] = data.map((t) => ({ - id: t.id, - name: t.name, - selected: selectedTags.includes(t.id), - })); - - setTags(mappedTags); - } catch (error) { - console.error("Failed to fetch tags:", error); - } - }; - - fetchTags(); - }, []); + const { data: tags = [] } = useQuery({ + queryKey: ["feeds", "tags", "lookup"], + queryFn: () => getPostTagsLookup(), + }); const toggleTag = (id: number) => { - const updatedTags = tags.map((t) => - t.id === id ? { ...t, selected: !t.selected } : t, - ); - setTags(updatedTags); + const next = selectedIds.includes(id) + ? selectedIds.filter((tagId) => tagId !== id) + : [...selectedIds, id]; - if (onChange) { - const selectedIds = updatedTags - .filter((t) => t.selected) - .map((t) => t.id); - onChange(selectedIds); - } + setSelectedIds(next); + onChange?.(next); }; return ( @@ -72,7 +46,7 @@ const TagSelector = ({ selectedTags = [], onChange }: TagSelectorProps) => { {tags.map((tag) => (
- )} -
- ); -} diff --git a/apps/codebility/app/home/in-house/_components/shared/ProjectSelect.tsx b/apps/codebility/app/home/in-house/_components/shared/ProjectSelect.tsx deleted file mode 100644 index 10c698281..000000000 --- a/apps/codebility/app/home/in-house/_components/shared/ProjectSelect.tsx +++ /dev/null @@ -1,140 +0,0 @@ -"use client"; - -import { useEffect, useState } from "react"; -import { Project } from "@/types/home/codev"; // Ensure Project is typed with { id: string; name: string; ... } - -import { createClientClientComponent } from "@/utils/supabase/client"; - -import { Badge } from "@codevs/ui/badge"; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "@codevs/ui/select"; - -interface ProjectSelectProps { - value: Project[]; - onChange: (projects: Project[]) => void; - disabled?: boolean; - compact?: boolean; -} - -export function ProjectSelect({ - value = [], - onChange, - disabled, - compact = false, -}: ProjectSelectProps) { - const [availableProjects, setAvailableProjects] = useState([]); - const [supabase, setSupabase] = useState(null); - - useEffect(() => { - const supabaseClient = createClientClientComponent(); - setSupabase(supabaseClient); - }, []); - - useEffect(() => { - if (!supabase) return; - - async function fetchProjects() { - // Make sure your table name is correct: - const { data: projects, error } = await supabase - .from("projects") // "projects", not "project", unless your DB table is singular - .select("*") - .order("name"); - - if (!error && projects) { - setAvailableProjects(projects); - } - } - fetchProjects(); - }, [supabase]); - - const handleProjectChange = (projectId: string) => { - const project = availableProjects.find((p) => p.id === projectId); - if (!project) return; - - const isSelected = value.some((p) => p.id === projectId); - const newProjects = isSelected - ? value.filter((p) => p.id !== projectId) // remove - : [...value, project]; // add - - onChange(newProjects); - }; - - // Compact mode: just show a dropdown with selected count, no badges - if (compact) { - return ( - - ); - } - - // Normal mode: show dropdown + badges - return ( -
- {/* Dropdown for selecting more projects */} - - - {/* Show selected projects as badges */} - {value.length > 0 && ( -
- {value.map((project) => ( - - {project.name} - - - ))} -
- )} -
- ); -} diff --git a/apps/codebility/app/home/in-house/_components/table/EditableRow.tsx b/apps/codebility/app/home/in-house/_components/table/EditableRow.tsx deleted file mode 100644 index 7b4fdd85c..000000000 --- a/apps/codebility/app/home/in-house/_components/table/EditableRow.tsx +++ /dev/null @@ -1,435 +0,0 @@ -"use client"; - -import { useEffect, useState } from "react"; -import SwitchStatusButton from "@/components/ui/SwitchStatusButton"; -import { INTERNAL_STATUS } from "@/constants/internal_status"; -import { - Codev, - Education, - InternalStatus, - Position, - Project, - WorkExperience, -} from "@/types/home/codev"; -import { createClientClientComponent } from "@/utils/supabase/client"; -import { uploadImage } from "@/utils/uploadImage"; -import { Check, Plus, Upload, X } from "lucide-react"; -import { toast } from "react-hot-toast"; - -import { Button } from "@codevs/ui/button"; -import { Input } from "@codevs/ui/input"; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "@codevs/ui/select"; -import { TableCell, TableRow } from "@codevs/ui/table"; - -import { ProjectSelect } from "../shared/ProjectSelect"; - -export interface Role { - id: number; - name: string; -} - -interface EditableRowProps { - data: Codev; - onSave: (data: Codev) => void; - onCancel: () => void; - roles: Role[]; -} - -/** - * COMPACT EditableRow - Minimized to Match Table Layout - * - * CHANGES: - * - Reduced all padding to px-2 py-2 to match compact table design - * - Smaller input fields and selects - * - No horizontal scroll - */ -export function EditableRow({ - data, - onSave, - onCancel, - roles, -}: EditableRowProps) { - const [supabase, setSupabase] = useState(null); - - useEffect(() => { - const supabaseClient = createClientClientComponent(); - setSupabase(supabaseClient); - }, []); - - const [editForm, setEditForm] = useState(data); - const [uploadedImage, setUploadedImage] = useState( - data.image_url || null, - ); - const [positions, setPositions] = useState([]); - const [isSubmitting, setIsSubmitting] = useState(false); - const [isUploading, setIsUploading] = useState(false); - - useEffect(() => { - if (!supabase) return; - - async function fetchPositions() { - const { data: positionsData, error } = await supabase - .from("positions") - .select("id, name"); - if (error) { - console.error("Failed to fetch positions:", error); - } else if (positionsData) { - setPositions(positionsData); - } - } - fetchPositions(); - }, [supabase]); - - const handleLocalChange = (key: keyof Codev, value: any) => { - setEditForm((prev) => ({ - ...prev, - [key]: value, - })); - }; - - const handleImageUpload = async (file: File) => { - try { - setIsUploading(true); - const reader = new FileReader(); - reader.onloadend = () => { - setUploadedImage(reader.result as string); - }; - reader.readAsDataURL(file); - - const publicUrl = await uploadImage(file, { - bucket: "codebility", - folder: "profileImage", - }); - - handleLocalChange("image_url", publicUrl); - } catch (error) { - console.error("Image upload failed:", error); - toast.error("Failed to upload image"); - } finally { - setIsUploading(false); - } - }; - - const handleSave = async () => { - try { - setIsSubmitting(true); - - const { id, projects, ...rest } = editForm; - - if (projects) { - const { error: deleteError } = await supabase - .from("project_members") - .delete() - .eq("codev_id", id); - if (deleteError) throw deleteError; - - for (const project of projects ?? []) { - const { error: insertError } = await supabase - .from("project_members") - .insert({ - codev_id: id, - project_id: project.id, - role: project.role || "Developer", - }); - if (insertError) throw insertError; - } - } - - const allowedFields: (keyof Codev)[] = [ - "first_name", - "last_name", - "email_address", - "image_url", - "internal_status", - "role_id", - "display_position", - "nda_status", - "portfolio_website", - "availability_status", - "application_status", - "level", - "tech_stacks", - "phone_number", - "date_joined", - ]; - - const updateFields: Record = {}; - const restTyped = rest as Partial; - - for (const key of allowedFields) { - if (Object.prototype.hasOwnProperty.call(restTyped, key)) { - updateFields[key] = restTyped[key]; - } - } - - if (Object.keys(updateFields).length > 0) { - const { error } = await supabase - .from("codev") - .update(updateFields) - .eq("id", id); - if (error) throw error; - } - - toast.success("Member updated successfully"); - onSave(editForm); - } catch (error) { - console.error("Error updating member:", error); - toast.error("Failed to update member"); - } finally { - setIsSubmitting(false); - } - }; - - const renderCell = (key: keyof Codev) => { - switch (key) { - case "email_address": - return ( - handleLocalChange(key, e.target.value)} - disabled={isSubmitting || isUploading} - className="border-light-700 dark:border-dark-200 bg-light-800 dark:bg-dark-300 h-9 w-full border px-3 text-xs text-black dark:text-white" - /> - ); - - case "image_url": - return ( -
- {uploadedImage ? ( -
- Profile - -
- ) : ( - - )} -
- ); - - case "internal_status": - return ( - - ); - - case "role_id": - return ( - - ); - - case "display_position": - return ( - - ); - - case "projects": - return ( -
- handleLocalChange("projects", projects)} - disabled={isSubmitting || isUploading} - compact={true} - /> -
- ); - - case "nda_status": - return ( - - ); - - case "availability_status": - return ( - - handleLocalChange( - "availability_status", - !editForm.availability_status, - ) - } - disabled={isSubmitting || isUploading} - /> - ); - - case "date_joined": - return ( - { - const dateValue = e.target.value - ? new Date(e.target.value).toISOString() - : null; - handleLocalChange("date_joined", dateValue); - }} - disabled={isSubmitting || isUploading} - className="border-light-700 dark:border-dark-200 bg-light-800 dark:bg-dark-300 h-9 border px-3 text-xs text-black dark:text-white" - /> - ); - - default: - return ( - handleLocalChange(key, e.target.value)} - disabled={isSubmitting || isUploading} - className="border-light-700 dark:border-dark-200 bg-light-800 dark:bg-dark-300 h-9 w-full border px-3 text-xs text-black dark:text-white" - /> - ); - } - }; - - return ( - - {/* COMPACT: All columns px-2 py-2, constrained widths to match table */} - {renderCell("image_url")} - {renderCell("first_name")} - {renderCell("last_name")} - {renderCell("email_address")} - {renderCell("internal_status")} - {renderCell("role_id")} - {renderCell("display_position")} - {renderCell("projects")} - {renderCell("nda_status")} - {renderCell("portfolio_website")} - {renderCell("date_joined")} - {renderCell("availability_status")} - - {/* COMPACT: Actions - smaller buttons */} - -
- - -
-
-
- ); -} \ No newline at end of file diff --git a/apps/codebility/app/home/in-house/_components/table/InHousePreviewSidebar.tsx b/apps/codebility/app/home/in-house/_components/table/InHousePreviewSidebar.tsx index 9d195ac18..081dc8577 100644 --- a/apps/codebility/app/home/in-house/_components/table/InHousePreviewSidebar.tsx +++ b/apps/codebility/app/home/in-house/_components/table/InHousePreviewSidebar.tsx @@ -3,12 +3,15 @@ import { useTransition } from "react"; import Image from "next/image"; import { X, Mail, Briefcase, Folder, Link2, Calendar, Download, AlertCircle } from "lucide-react"; +import { useQuery } from "@tanstack/react-query"; import { Codev, InternalStatus } from "@/types/home/codev"; +import { qk } from "@/lib/shared/query-keys"; import { Button } from "@codevs/ui/button"; import { Badge } from "@codevs/ui/badge"; import { StatusBadge } from "../shared/StatusBadge"; import SwitchStatusButton from "@/components/ui/SwitchStatusButton"; +import { fetchCodevDetailAction } from "@/actions/in-house/actions"; interface Role { id: string | number; @@ -41,6 +44,18 @@ export default function InHousePreviewSidebar({ }: InHousePreviewSidebarProps) { const [isPending, startTransition] = useTransition(); + // The list row carries only the six columns the table renders, so the full + // record (education, work history, projects) is fetched when a row is opened. + // Keyed by id, so closing and reopening the same row costs nothing. + const { data: detail } = useQuery({ + queryKey: qk.codevs.detail(member.id), + queryFn: () => fetchCodevDetailAction(member.id), + staleTime: 60_000, + gcTime: 5 * 60_000, + }); + + const record = detail ?? member; + const handleNdaSend = async () => { startTransition(async () => { const subject = "NDA Signing Request - Codebility Plus"; @@ -142,8 +157,8 @@ export default function InHousePreviewSidebar({ Assigned Projects
- {member.projects && member.projects.length > 0 ? ( - member.projects.map((project) => ( + {record.projects && record.projects.length > 0 ? ( + record.projects.map((project) => ( {project.name} @@ -160,9 +175,9 @@ export default function InHousePreviewSidebar({ Portfolio Website
- {member.portfolio_website ? ( - - {member.portfolio_website} + {record.portfolio_website ? ( + + {record.portfolio_website} ) : ( Not provided diff --git a/apps/codebility/app/home/in-house/_components/table/InHouseTable.tsx b/apps/codebility/app/home/in-house/_components/table/InHouseTable.tsx index cc8b99b25..1849a6bef 100644 --- a/apps/codebility/app/home/in-house/_components/table/InHouseTable.tsx +++ b/apps/codebility/app/home/in-house/_components/table/InHouseTable.tsx @@ -1,31 +1,16 @@ // app/home/in-house/_components/table/InHouseTable.tsx "use client"; -import { useCallback, useEffect, useMemo, useState } from "react"; +import { useState } from "react"; import Image from "next/image"; -import SwitchStatusButton from "@/components/ui/SwitchStatusButton"; import DefaultPagination from "@/components/ui/pagination"; -import { pageSize } from "@/constants"; +import type { PositionOption, ProjectOption } from "@/lib/server/reference-data"; import { Codev, InternalStatus } from "@/types/home/codev"; -import { createClientClientComponent } from "@/utils/supabase/client"; -import { ArrowUpDown, Download, Link2, Mail } from "lucide-react"; +import { getClientSupabase } from "@/utils/supabase/client"; +import { ArrowUpDown, Mail } from "lucide-react"; import { toast } from "react-hot-toast"; import { Button } from "@codevs/ui/button"; -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuGroup, - DropdownMenuItem, - DropdownMenuLabel, - DropdownMenuPortal, - DropdownMenuSeparator, - DropdownMenuShortcut, - DropdownMenuSub, - DropdownMenuSubContent, - DropdownMenuSubTrigger, - DropdownMenuTrigger, -} from "@codevs/ui/dropdown-menu"; import { Table, TableBody, @@ -54,13 +39,18 @@ interface NdaEmailDialogProps { interface InHouseTableProps { data: Codev[]; - onDataChange: (updatedItem: Codev) => void; + roles: Role[]; + positions: PositionOption[]; + projects: ProjectOption[]; + isFetching?: boolean; + onDataChange: (updatedItem?: Codev) => void; onDelete: (deletedId: string) => void; pagination: { currentPage: number; totalPages: number; onNextPage: () => void; onPreviousPage: () => void; + onGoToPage: (page: number) => void; }; sortConfig?: { key: "date_joined" | "display_position" | null; @@ -126,45 +116,18 @@ const SendNdaButton = ({ codev, onSendNdaEmail }: NdaEmailDialogProps) => { */ export function InHouseTable({ data, + roles, + positions, + projects, onDataChange, pagination, onDelete, sortConfig, onSort, }: InHouseTableProps) { - const [supabase, setSupabase] = useState(null); const [editingMember, setEditingMember] = useState(null); - const [roles, setRoles] = useState([]); const [selectedMember, setSelectedMember] = useState(null); - const totalPages = useMemo( - () => Math.ceil(data.length / pageSize.applicants), - [data.length], - ); - - useEffect(() => { - const supabaseClient = createClientClientComponent(); - setSupabase(supabaseClient); - }, []); - - useEffect(() => { - if (!supabase) return; - - async function fetchRoles() { - const { data: rolesData, error } = await supabase - .from("roles") - .select("id, name"); - - if (error) { - console.error("Failed to fetch roles:", error); - } else if (rolesData) { - setRoles(rolesData); - } - } - - fetchRoles(); - }, [supabase]); - const handleDelete = (deletedId: string) => { onDelete(deletedId); }; @@ -180,7 +143,9 @@ export function InHouseTable({ const expiresAt = new Date(); expiresAt.setDate(expiresAt.getDate() + 7); - const { error: dbError } = await supabase.from("nda_requests").insert({ + const { error: dbError } = await getClientSupabase() + .from("nda_requests") + .insert({ codev_id: codevId, token: token, expires_at: expiresAt.toISOString(), @@ -224,7 +189,7 @@ export function InHouseTable({ const handleDownloadNda = async (codevId: string) => { try { - const { data, error } = await supabase + const { data, error } = await getClientSupabase() .from("codev") .select("nda_document, nda_signature, first_name, last_name") .eq("id", codevId) @@ -266,7 +231,7 @@ export function InHouseTable({ const handleDownloadSignature = async (codevId: string) => { try { - const { data, error } = await supabase + const { data, error } = await getClientSupabase() .from("codev") .select("nda_signature, first_name, last_name") .eq("id", codevId) @@ -298,16 +263,13 @@ export function InHouseTable({ } }; - const handleNextPage = useCallback(() => { + const handleNextPage = () => { if (pagination.currentPage < pagination.totalPages) { pagination.onNextPage(); } - }, [pagination]); + }; - const handlePreviousPage = useCallback( - () => pagination.onPreviousPage(), - [pagination], - ); + const handlePreviousPage = () => pagination.onPreviousPage(); const SortIndicator = ({ columnKey, @@ -324,28 +286,16 @@ export function InHouseTable({ ); }; - const setCurrentPage = useCallback( - (pageOrFunction: number | ((page: number) => number)) => { - const page = - typeof pageOrFunction === "function" - ? pageOrFunction(pagination.currentPage) - : pageOrFunction; - - const targetPage = Math.max(1, Math.min(page, pagination.totalPages)); - const currentPage = pagination.currentPage; - - if (targetPage > currentPage) { - for (let i = currentPage; i < targetPage; i++) { - pagination.onNextPage(); - } - } else if (targetPage < currentPage) { - for (let i = currentPage; i > targetPage; i--) { - pagination.onPreviousPage(); - } - } - }, - [pagination], - ); + const setCurrentPage = (pageOrFunction: number | ((page: number) => number)) => { + const page = + typeof pageOrFunction === "function" + ? pageOrFunction(pagination.currentPage) + : pageOrFunction; + + pagination.onGoToPage( + Math.max(1, Math.min(page, pagination.totalPages)), + ); + }; return (
@@ -520,17 +470,15 @@ export function InHouseTable({
- {pagination && data.length > 0 && ( + {pagination && data.length > 0 && pagination.totalPages > 1 && (
- {data.length > pageSize.applicants && ( - - )} +
)} @@ -545,6 +493,8 @@ export function InHouseTable({ setEditingMember(null); }} roles={roles} + positions={positions} + projects={projects} /> )}
diff --git a/apps/codebility/app/home/in-house/_components/table/MobileEditableForm.tsx b/apps/codebility/app/home/in-house/_components/table/MobileEditableForm.tsx deleted file mode 100644 index 44c009b76..000000000 --- a/apps/codebility/app/home/in-house/_components/table/MobileEditableForm.tsx +++ /dev/null @@ -1,465 +0,0 @@ -"use client"; - -import { useEffect, useState } from "react"; -import Image from "next/image"; -import SwitchStatusButton from "@/components/ui/SwitchStatusButton"; -import { INTERNAL_STATUS } from "@/constants/internal_status"; -import { Codev, InternalStatus, Position, Project } from "@/types/home/codev"; -import { createClientClientComponent } from "@/utils/supabase/client"; -import { uploadImage } from "@/utils/uploadImage"; -import { Check, Plus, Upload, X } from "lucide-react"; -import { toast } from "react-hot-toast"; - -import { Button } from "@codevs/ui/button"; -import { Input } from "@codevs/ui/input"; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "@codevs/ui/select"; - -import { ProjectSelect } from "../shared/ProjectSelect"; - -export interface Role { - id: number; - name: string; -} - -interface MobileEditableFormProps { - data: Codev; - onSave: (data: Codev) => void; - onCancel: () => void; - roles: Role[]; -} - -const defaultImage = "/assets/svgs/icon-codebility-black.svg"; - -export function MobileEditableForm({ - data, - onSave, - onCancel, - roles, -}: MobileEditableFormProps) { - const [supabase, setSupabase] = useState(null); - const [editForm, setEditForm] = useState(data); - const [uploadedImage, setUploadedImage] = useState( - data.image_url || null, - ); - const [positions, setPositions] = useState([]); - const [isSubmitting, setIsSubmitting] = useState(false); - const [isUploading, setIsUploading] = useState(false); - - useEffect(() => { - const supabaseClient = createClientClientComponent(); - setSupabase(supabaseClient); - }, []); - - useEffect(() => { - if (!supabase) return; - - async function fetchPositions() { - const { data: positionsData, error } = await supabase - .from("positions") - .select("id, name"); - if (error) { - console.error("Failed to fetch positions:", error); - } else if (positionsData) { - setPositions(positionsData); - } - } - fetchPositions(); - }, [supabase]); - - const handleLocalChange = (key: keyof Codev, value: any) => { - setEditForm((prev) => ({ - ...prev, - [key]: value, - })); - }; - - const handleImageUpload = async (file: File) => { - try { - setIsUploading(true); - const reader = new FileReader(); - reader.onloadend = () => { - setUploadedImage(reader.result as string); - }; - reader.readAsDataURL(file); - - const publicUrl = await uploadImage(file, { - bucket: "codebility", - folder: "profileImage", - }); - - handleLocalChange("image_url", publicUrl); - } catch (error) { - console.error("Image upload failed:", error); - toast.error("Failed to upload image"); - } finally { - setIsUploading(false); - } - }; - - const handleSave = async () => { - try { - setIsSubmitting(true); - - const { id, projects, ...rest } = editForm; - - if (projects) { - const { error: deleteError } = await supabase - .from("project_members") - .delete() - .eq("codev_id", id); - if (deleteError) throw deleteError; - - for (const project of projects ?? []) { - const { error: insertError } = await supabase - .from("project_members") - .insert({ - codev_id: id, - project_id: project.id, - role: project.role || "Developer", - }); - if (insertError) throw insertError; - } - } - - const allowedFields: (keyof Codev)[] = [ - "first_name", - "last_name", - "email_address", - "image_url", - "internal_status", - "role_id", - "display_position", - "nda_status", - "portfolio_website", - "availability_status", - "date_joined", - ]; - - const updateFields: Record = {}; - const restTyped = rest as Partial; - - for (const key of allowedFields) { - if (Object.prototype.hasOwnProperty.call(restTyped, key)) { - updateFields[key] = restTyped[key]; - } - } - - if (Object.keys(updateFields).length > 0) { - const { error } = await supabase - .from("codev") - .update(updateFields) - .eq("id", id); - if (error) throw error; - } - - toast.success("Member updated successfully"); - onSave(editForm); - } catch (error) { - console.error("Error updating member:", error); - toast.error("Failed to update member"); - } finally { - setIsSubmitting(false); - } - }; - - const capitalize = (str: string) => - str ? str.charAt(0).toUpperCase() + str.slice(1).toLowerCase() : "-"; - - return ( -
- {/* Header with photo upload */} -
-
-
- {uploadedImage ? ( -
- Profile - -
- ) : ( - - )} -
-
-

- Edit Member -

-

- Update information -

-
-
-
- - -
-
- - {/* Form fields in grid */} -
- {/* Name fields */} -
-
- - handleLocalChange("first_name", e.target.value)} - disabled={isSubmitting || isUploading} - className="h-8 text-sm dark:text-white" - /> -
-
- - handleLocalChange("last_name", e.target.value)} - disabled={isSubmitting || isUploading} - className="h-8 text-sm dark:text-white" - /> -
-
- - {/* Email */} -
- - handleLocalChange("email_address", e.target.value)} - disabled={isSubmitting || isUploading} - className="h-8 text-sm dark:text-white" - /> -
- - {/* Status and Role */} -
-
- - -
-
- - -
-
- - {/* Position */} -
- - -
- - {/* Projects */} -
- - handleLocalChange("projects", projects)} - disabled={isSubmitting || isUploading} - /> -
- - {/* NDA Status and Availability */} -
-
- - -
-
- -
- - handleLocalChange( - "availability_status", - !editForm.availability_status, - ) - } - disabled={isSubmitting || isUploading} - /> -
-
-
- - {/* Portfolio */} -
- - - handleLocalChange("portfolio_website", e.target.value) - } - disabled={isSubmitting || isUploading} - placeholder="https://..." - className="h-8 text-sm text-gray-900 dark:text-white" - /> -
- - {/* Date Joined */} -
- - { - const dateValue = e.target.value - ? new Date(e.target.value).toISOString() - : null; - handleLocalChange("date_joined", dateValue); - }} - disabled={isSubmitting || isUploading} - className="h-8 text-sm text-gray-900 dark:text-white" - /> -
-
-
- ); -} diff --git a/apps/codebility/app/home/in-house/_components/table/TableActions.tsx b/apps/codebility/app/home/in-house/_components/table/TableActions.tsx index a98703e80..3b9721374 100644 --- a/apps/codebility/app/home/in-house/_components/table/TableActions.tsx +++ b/apps/codebility/app/home/in-house/_components/table/TableActions.tsx @@ -1,7 +1,7 @@ -import { useEffect, useState } from "react"; +import { useState } from "react"; import { Codev, InternalStatus } from "@/types/home/codev"; -import { createClientClientComponent } from "@/utils/supabase/client"; -import { useModal } from "@/hooks/use-modal-users"; +import { getClientSupabase } from "@/utils/supabase/client"; +import { useModal } from "@/hooks/modals/use-modal-users"; import { Edit2, Eye, MoreHorizontal, Trash2 } from "lucide-react"; import { toast } from "react-hot-toast"; @@ -14,6 +14,7 @@ import { } from "@codevs/ui/dropdown-menu"; import { DeleteDialog } from "../shared/DeleteDialog"; +import { fetchCodevDetailAction } from "@/actions/in-house/actions"; interface TableActionsProps { item: Codev; @@ -24,14 +25,8 @@ interface TableActionsProps { export function TableActions({ item, onEdit, onDelete }: TableActionsProps) { const [showDeleteDialog, setShowDeleteDialog] = useState(false); const [isDeleting, setIsDeleting] = useState(false); - const [supabase, setSupabase] = useState(null); const { onOpen: openProfileModal } = useModal(); - useEffect(() => { - const supabaseClient = createClientClientComponent(); - setSupabase(supabaseClient); - }, []); - // Calculate years from work experience const calculateYearsFromExperience = (workExperience: any[]): number => { if (!workExperience || workExperience.length === 0) return 0; @@ -49,81 +44,50 @@ export function TableActions({ item, onEdit, onDelete }: TableActionsProps) { return Math.round(totalYears); }; - // Fetch complete profile and open modal + // Opens the profile modal with the full record, fetched on demand (C3). const handleViewProfile = async () => { try { - if (!supabase) { - console.error('Supabase client not available'); - return; - } + const data = await fetchCodevDetailAction(item.id); - const { data, error } = await supabase - .from("codev") - .select(` - *, - education:education(*), - work_experience:work_experience(*), - projects:project_members( - project:project_id(*) - ), - codev_points:codev_points(*) - `) - .eq('id', item.id) - .single(); - - if (error || !data) { - console.error('Error fetching complete profile:', error); - // Use fallback data - const fallbackProfile: Codev = { + if (!data) { + openProfileModal("profileModal", { ...item, years_of_experience: item.years_of_experience || 0, availability_status: item.availability_status ?? true, internal_status: (item.internal_status || 'GRADUATED') as InternalStatus - }; - openProfileModal("profileModal", fallbackProfile); + }); return; } - // Handle years_of_experience calculation - let finalYearsOfExperience = data.years_of_experience; - - if (finalYearsOfExperience === null || finalYearsOfExperience === undefined) { - finalYearsOfExperience = calculateYearsFromExperience(data.work_experience || []); - } - - const safeInternalStatus = data.internal_status as InternalStatus | undefined; - - // Create enhanced profile const enhancedProfile: Codev = { ...data, - years_of_experience: finalYearsOfExperience, - internal_status: safeInternalStatus, + years_of_experience: + data.years_of_experience ?? + calculateYearsFromExperience(data.work_experience || []), work_experience: data.work_experience || [], education: data.education || [], projects: data.projects || [], codev_points: data.codev_points || [], tech_stacks: data.tech_stacks || [], - positions: data.positions || [] + positions: data.positions || [], }; openProfileModal("profileModal", enhancedProfile); } catch (error) { console.error('Error in profile click handler:', error); - // Final fallback - const safeFallback: Codev = { + openProfileModal("profileModal", { ...item, years_of_experience: item.years_of_experience || 0, availability_status: item.availability_status ?? true, internal_status: (item.internal_status || 'GRADUATED') as InternalStatus - }; - openProfileModal("profileModal", safeFallback); + }); } }; const handleDelete = async () => { try { setIsDeleting(true); - const { error } = await supabase.from("codev").delete().eq("id", item.id); + const { error } = await getClientSupabase().from("codev").delete().eq("id", item.id); if (error) throw error; diff --git a/apps/codebility/app/home/in-house/_components/table/table-filters.tsx b/apps/codebility/app/home/in-house/_components/table/table-filters.tsx index 826f4eaa0..a7b5fd90d 100644 --- a/apps/codebility/app/home/in-house/_components/table/table-filters.tsx +++ b/apps/codebility/app/home/in-house/_components/table/table-filters.tsx @@ -1,9 +1,6 @@ "use client"; -import { useEffect, useState } from "react"; import { INTERNAL_STATUS } from "@/constants/internal_status"; -import { Project } from "@/types/home/codev"; -import { createClientClientComponent } from "@/utils/supabase/client"; import { Card } from "@codevs/ui/card"; import { Input } from "@codevs/ui/input"; @@ -24,83 +21,34 @@ interface Role { interface TableFiltersProps { filters: { status: string; + position: string; project: string; internal_status: string; nda_status: string; display_position: string; availability_status: string; role: string; - search: string; // <-- search field + search: string; }; onFilterChange: ( key: keyof TableFiltersProps["filters"], value: string, ) => void; + roles: Role[]; + positions: { id: string; name: string | null }[]; + projects: { id: string; name: string }[]; } -export function TableFilters({ filters, onFilterChange }: TableFiltersProps) { - const [projects, setProjects] = useState([]); - const [displayPositions, setDisplayPositions] = useState([]); - const [roles, setRoles] = useState([]); - const [supabase, setSupabase] = useState(null); - - useEffect(() => { - const supabaseClient = createClientClientComponent(); - setSupabase(supabaseClient); - }, []); - - useEffect(() => { - if (!supabase) return; - - async function fetchProjects() { - const { data, error } = await supabase - .from("projects") - .select("id, name, start_date"); - - if (error) { - console.error("Failed to fetch projects:", error); - } else if (data) { - setProjects( - data.map((proj: any) => ({ - ...proj, - start_date: proj.start_date || "", - })) as Project[], - ); - } - } - - async function fetchDisplayPositions() { - const { data, error } = await supabase - .from("codev") - .select("display_position"); - - if (error) { - console.error("Failed to fetch display positions:", error); - } else if (data) { - const distinctPositions = Array.from( - new Set( - data - .map((row: any) => row.display_position) - .filter((pos: any) => pos !== null && pos !== ""), - ), - ) as string[]; - setDisplayPositions(distinctPositions); - } - } - - async function fetchRoles() { - const { data, error } = await supabase.from("roles").select("id, name"); - if (error) { - console.error("Failed to fetch roles:", error); - } else if (data) { - setRoles(data as Role[]); - } - } - - fetchProjects(); - fetchDisplayPositions(); - fetchRoles(); - }, [supabase]); +export function TableFilters({ + filters, + onFilterChange, + roles, + positions, + projects, +}: TableFiltersProps) { + const displayPositions = positions + .map((pos) => pos.name) + .filter((name): name is string => Boolean(name)); return ( diff --git a/apps/codebility/app/home/in-house/page.tsx b/apps/codebility/app/home/in-house/page.tsx index 5c412d8e2..3dcd5927c 100644 --- a/apps/codebility/app/home/in-house/page.tsx +++ b/apps/codebility/app/home/in-house/page.tsx @@ -1,43 +1,44 @@ -import { getCodevs } from "@/lib/server/codev.service"; -import { getOrSetCache } from "@/lib/server/redis-cache"; -import { cacheKeys } from "@/lib/server/redis-cache-keys"; -import PageContainer from "../_components/PageContainer"; +import { getCodevStatusCounts, getCodevsPage } from "@/lib/server/codev.service"; +import { getPositions, getProjectOptions, getRoles } from "@/lib/server/reference-data"; +import { pageSize } from "@/constants"; import InHouseView from "./_components/InHouseView"; -export const dynamic = "force-dynamic"; -export const revalidate = 0; +const parsePage = (value: string | string[] | undefined) => { + const raw = Array.isArray(value) ? value[0] : value; + const parsed = Number(raw); + return Number.isFinite(parsed) && parsed > 0 ? Math.trunc(parsed) : 1; +}; -export default async function InHousePage() { - const { data, error } = await getOrSetCache( - cacheKeys.codevs.inhouse, - // Fetch Codev data with the desired filter - () => - getCodevs({ - filters: { application_status: "passed" }, - }), - ); +export default async function InHousePage({ + searchParams, +}: { + searchParams: Promise<{ page?: string | string[] }>; +}) { + const { page } = await searchParams; - // Check for errors or missing data - if (error || !data) { - return ( -
-
-
-
⚠️
-

Error fetching in-house data

-

Failed to load the in-house team members.

-
-
-
- ); - } + const [initialData, stats, roles, positions, projects] = await Promise.all([ + getCodevsPage({ + page: parsePage(page), + pageSize: pageSize.applicants, + filters: { application_status: "passed", availability_status: true }, + }), + getCodevStatusCounts({ application_status: "passed" }), + getRoles(), + getPositions(), + getProjectOptions(), + ]); - // Pass the fully prepared data to the view return (
- +
); diff --git a/apps/codebility/app/home/interns/_components/CodevCard.tsx b/apps/codebility/app/home/interns/_components/CodevCard.tsx index 26efad056..f28fc1812 100644 --- a/apps/codebility/app/home/interns/_components/CodevCard.tsx +++ b/apps/codebility/app/home/interns/_components/CodevCard.tsx @@ -4,8 +4,9 @@ import { useState } from "react"; import CodevBadge from "@/components/CodevBadge"; import DefaultAvatar from "@/components/DefaultAvatar"; import Box from "@/components/shared/dashboard/Box"; -import { useModal } from "@/hooks/use-modal-users"; -import { ApplicantStatus, Codev, CodevPoints } from "@/types/home/codev"; +import { useModal } from "@/hooks/modals/use-modal-users"; +import type { CodevCardRow } from "@/lib/server/codev.service"; +import { ApplicantStatus, CodevPoints } from "@/types/home/codev"; import { AnimatePresence, motion, @@ -23,7 +24,7 @@ import TechStacks from "./TechStacks"; const MAX_VISIBLE_PROJECTS = 2; interface CodevCardProps { - codev: Codev; + codev: CodevCardRow; } const STATUS_CONFIG: Record< @@ -87,6 +88,14 @@ export default function CodevCard({ codev }: CodevCardProps) { STATUS_CONFIG[applicationStatus as ApplicantStatus] || STATUS_CONFIG.applying; + // The card row carries only what the card renders; the modal needs the full + // record, so fetch it on open (C3). Cached by id, so reopening is free. + const handleOpen = async () => { + const { fetchCodevDetailAction } = await import("@/actions/in-house/actions"); + const detail = await fetchCodevDetailAction(codev.id); + onOpen("profileModal", (detail ?? codev) as never); + }; + return (
setHovered(true)} @@ -99,7 +108,7 @@ export default function CodevCard({ codev }: CodevCardProps) {
onOpen("profileModal", codev)} + onClick={handleOpen} > {/* Header Section */}
diff --git a/apps/codebility/app/home/interns/_components/CodevContainer.tsx b/apps/codebility/app/home/interns/_components/CodevContainer.tsx index cf5cb9b92..f041392e3 100644 --- a/apps/codebility/app/home/interns/_components/CodevContainer.tsx +++ b/apps/codebility/app/home/interns/_components/CodevContainer.tsx @@ -1,30 +1,99 @@ "use client"; -import { useState } from "react"; +import { useMemo, useState } from "react"; import H1 from "@/components/shared/dashboard/H1"; -import { Codev } from "@/types/home/codev"; +import { pageSize } from "@/constants"; +import type { CodevCardRow } from "@/lib/server/codev.service"; +import type { PositionOption, ProjectOption } from "@/lib/server/reference-data"; +import type { Page } from "@/lib/server/paginate"; +import { qk } from "@/lib/shared/query-keys"; +import { usePaginatedQuery } from "@/hooks/query/use-paginated-query"; import CodevList from "./CodevList"; import CodevSearchbar from "./CodevSearchbar"; import FilterCodevs from "./FilterCodevs"; import InternalProjects from "./InternalProjects"; import { Tabs, TabsList, TabsTrigger } from "@codevs/ui/tabs"; +import { fetchInternsAction } from "@/actions/in-house/actions"; -export default function CodevContainer({ data }: { data: Codev[] }) { - const [filters, setFilters] = useState({ - positions: [] as string[], - projects: [] as string[], - availability: [] as string[], - }); +export type InternsFilters = { + positions: string[]; + projects: string[]; + availability: string[]; +}; + +// Matches what page.tsx renders: active sub-tab, no filters, page 1. +// getInternsPage pins application_status itself, so it is not part of the key's +// server-side filter set, but the client sends it and both sides must hash equal. +const EMPTY_QUERY_FILTERS = { + application_status: "passed", + availability_status: true, + display_position: undefined, + internal_status: undefined, + search: undefined, +}; - const [codevs, setCodevs] = useState(data); +export default function CodevContainer({ + initialData, + counts, + positions, + projects, +}: { + initialData: Page; + counts: { total: number; active: number; inactive: number }; + positions: PositionOption[]; + projects: ProjectOption[]; +}) { + const [filters, setFilters] = useState({ + positions: [], + projects: [], + availability: [], + }); + const [page, setPage] = useState(1); const [activeTab, setActiveTab] = useState<"members" | "projects">("members"); const [membersSubTab, setMembersSubTab] = useState<"active" | "inactive" | "all">("active"); - const [isSearching, setIsSearching] = useState(false); + const [search, setSearch] = useState(""); - // Calculate counts based on availability_status (same as in-house page) - const activeCount = data.filter(codev => codev.availability_status === true).length; - const inactiveCount = data.filter(codev => codev.availability_status !== true).length; + const queryFilters = useMemo( + () => ({ + application_status: "passed", + availability_status: + membersSubTab === "all" ? undefined : membersSubTab === "active", + display_position: filters.positions[0], + internal_status: filters.availability[0]?.toUpperCase(), + search: search || undefined, + }), + [membersSubTab, filters, search], + ); + + const { data, showSkeleton } = usePaginatedQuery( + qk.codevs.list({ ...queryFilters, page }), + () => + fetchInternsAction({ + page, + pageSize: pageSize.codevsList, + filters: queryFilters, + }), + { + initialData, + // page.tsx renders exactly this key: no filters, active sub-tab, page 1. + initialDataKey: qk.codevs.list({ ...EMPTY_QUERY_FILTERS, page: 1 }), + }, + ); + + const rows = data?.rows ?? []; + const total = data?.total ?? 0; + const totalPages = Math.max(Math.ceil(total / pageSize.codevsList), 1); + + const handleFilterChange = (next: InternsFilters) => { + setFilters(next); + setPage(1); + }; + + const handleSearch = (value: string) => { + setSearch(value); + setPage(1); + }; return (
@@ -48,7 +117,7 @@ export default function CodevContainer({ data }: { data: Codev[] }) { Members - {data.length} + {counts.total} @@ -61,24 +130,24 @@ export default function CodevContainer({ data }: { data: Codev[] }) { {/* Sub-tabs for Members (Active/Inactive/All) */} {activeTab === "members" && (
- setMembersSubTab(value as "active" | "inactive" | "all")} className="w-full max-w-xl"> + { setMembersSubTab(value as "active" | "inactive" | "all"); setPage(1); }} className="w-full max-w-xl"> Active - {activeCount} + {counts.active} Inactive - {inactiveCount} + {counts.inactive} All - {data.length} + {counts.total} @@ -91,15 +160,15 @@ export default function CodevContainer({ data }: { data: Codev[] }) {
- +
- +
@@ -109,11 +178,17 @@ export default function CodevContainer({ data }: { data: Codev[] }) { {activeTab === "projects" ? ( ) : ( - setPage((p) => p + 1), + onPreviousPage: () => setPage((p) => Math.max(p - 1, 1)), + onGoToPage: (target: number) => setPage(target), + }} /> )}
diff --git a/apps/codebility/app/home/interns/_components/CodevList.tsx b/apps/codebility/app/home/interns/_components/CodevList.tsx index fbd38f544..7bd92e8ed 100644 --- a/apps/codebility/app/home/interns/_components/CodevList.tsx +++ b/apps/codebility/app/home/interns/_components/CodevList.tsx @@ -1,76 +1,43 @@ "use client"; -import { useMemo, useState, useEffect } from "react"; import DefaultPagination from "@/components/ui/pagination"; -import { pageSize } from "@/constants"; -import usePagination from "@/hooks/use-pagination"; -import { Codev } from "@/types/home/codev"; -import { getPrioritizedAndFilteredCodevs } from "@/utils/codev-priority"; // Import the utility +import type { CodevCardRow } from "@/lib/server/codev.service"; import CodevCard from "./CodevCard"; import AnimatedCodevCardSkeleton from "./AnimatedCodevCardSkeleton"; interface CodevListProps { - data: Codev[]; - filters: { - positions: string[]; - projects: string[]; - availability: string[]; + data: CodevCardRow[]; + isFetching?: boolean; + pagination: { + currentPage: number; + totalPages: number; + onNextPage: () => void; + onPreviousPage: () => void; + onGoToPage: (page: number) => void; }; - activeTab?: "all" | "active" | "inactive"; - isSearching?: boolean; } -export default function CodevList({ data, filters, activeTab = "active", isSearching = false }: CodevListProps) { - const [isFiltering, setIsFiltering] = useState(false); - - // Add a small delay to show loading when filters change - useEffect(() => { - if (Object.values(filters).some(arr => arr.length > 0) || isSearching) { - setIsFiltering(true); - const timer = setTimeout(() => { - setIsFiltering(false); - }, 300); - return () => clearTimeout(timer); - } - }, [filters, isSearching]); - // Use the new utility function to get prioritized and filtered codevs - const filteredCodevs = useMemo(() => { - // First filter by tab selection based on availability_status (same as in-house) - let tabFilteredData = data; - if (activeTab === "active") { - tabFilteredData = data.filter(codev => codev.availability_status === true); - } else if (activeTab === "inactive") { - tabFilteredData = data.filter(codev => codev.availability_status !== true); - } - // activeTab === "all" shows everyone - - const result = getPrioritizedAndFilteredCodevs(tabFilteredData, filters, true); - - return result; - }, [data, filters, activeTab]); - - const { - currentPage, - totalPages, - paginatedData, - handleNextPage, - handlePreviousPage, - setCurrentPage, - } = usePagination(filteredCodevs, pageSize.codevsList); +export default function CodevList({ data, isFetching, pagination }: CodevListProps) { + const setCurrentPage = (pageOrFunction: number | ((page: number) => number)) => { + const page = + typeof pageOrFunction === "function" + ? pageOrFunction(pagination.currentPage) + : pageOrFunction; + pagination.onGoToPage(Math.max(1, Math.min(page, pagination.totalPages))); + }; return (
- {isFiltering ? ( - // Show skeleton while filtering + {isFetching ? (
{Array.from({ length: 8 }).map((_, index) => ( ))}
- ) : paginatedData.length > 0 ? ( + ) : data.length > 0 ? (
- {paginatedData.map((codev) => ( + {data.map((codev) => ( ))}
@@ -87,14 +54,14 @@ export default function CodevList({ data, filters, activeTab = "active", isSearc
)} - {!isFiltering && filteredCodevs.length > pageSize.codevsList && ( + {pagination.totalPages > 1 && (
)} diff --git a/apps/codebility/app/home/interns/_components/CodevSearchbar.tsx b/apps/codebility/app/home/interns/_components/CodevSearchbar.tsx index a230f122a..306a8a5c8 100644 --- a/apps/codebility/app/home/interns/_components/CodevSearchbar.tsx +++ b/apps/codebility/app/home/interns/_components/CodevSearchbar.tsx @@ -1,37 +1,20 @@ -import React, { useCallback } from "react"; -import { Codev } from "@/types/home/codev"; +"use client"; + +import React from "react"; import { SearchIcon } from "lucide-react"; -import { useDebounce, useDebouncedCallback } from "use-debounce"; +import { useDebouncedCallback } from "use-debounce"; export default function CodevSearchbar({ - allCodevs, - codevs, - setCodevs, - setIsSearching, + value, + onSearch, }: { - allCodevs: Codev[]; - codevs: Codev[]; - setCodevs: React.Dispatch>; - setIsSearching?: React.Dispatch>; + value: string; + onSearch: (value: string) => void; }) { - const [searchValue, setSearchValue] = React.useState(""); - - const handleSearch = useDebouncedCallback((value: string) => { - setIsSearching?.(true); - - if (value === "") { - setCodevs(allCodevs); - setIsSearching?.(false); - return; - } - - const filteredCodevs = allCodevs.filter((codev) => { - const fullName = `${codev.first_name} ${codev.last_name}`; - return fullName.toLowerCase().includes(value.toLowerCase()); - }); + const [searchValue, setSearchValue] = React.useState(value); - setCodevs(filteredCodevs); - setIsSearching?.(false); + const handleSearch = useDebouncedCallback((next: string) => { + onSearch(next); }, 300); return ( diff --git a/apps/codebility/app/home/interns/_components/FilterCodevs.tsx b/apps/codebility/app/home/interns/_components/FilterCodevs.tsx index 67dc0ab9b..0a2106475 100644 --- a/apps/codebility/app/home/interns/_components/FilterCodevs.tsx +++ b/apps/codebility/app/home/interns/_components/FilterCodevs.tsx @@ -4,12 +4,11 @@ import { useEffect, useState } from "react"; import { createPortal } from "react-dom"; import { Box } from "@/components/shared/dashboard"; import { Button } from "@/components/ui/button"; -import { Position, Project, SkillCategory } from "@/types/home/codev"; -import { createClientClientComponent } from "@/utils/supabase/client"; +import type { PositionOption, ProjectOption } from "@/lib/server/reference-data"; import { Checkbox } from "@codevs/ui/checkbox"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@codevs/ui/tabs"; -import { cn } from "@/lib/utils"; +import { cn } from "@/utils/utils"; interface Filters { positions: string[]; @@ -19,7 +18,9 @@ interface Filters { interface FilterCodevsProps { filters: Filters; - setFilters: React.Dispatch>; + setFilters: (filters: Filters) => void; + positions: PositionOption[]; + projects: ProjectOption[]; } const AVAILABILITY_STATUS = [ @@ -34,45 +35,15 @@ const AVAILABILITY_STATUS = [ export default function FilterCodevs({ filters, setFilters, + positions, + projects, }: FilterCodevsProps) { const [showFilter, setShowFilter] = useState(false); - const [positions, setPositions] = useState([]); - const [projects, setProjects] = useState([]); - const [supabase, setSupabase] = useState(null); // Track button position for dropdown placement const [buttonRef, setButtonRef] = useState(null); const [dropdownPosition, setDropdownPosition] = useState({ top: 0, right: 0 }); - useEffect(() => { - const supabaseClient = createClientClientComponent(); - setSupabase(supabaseClient); - }, []); - - useEffect(() => { - if (!supabase) return; - const fetchData = async () => { - try { - const [positionsRes, projectsRes] = await Promise.all([ - supabase.from("positions").select("*"), - supabase.from("projects").select("*"), - ]); - - if (positionsRes.data) { - setPositions(positionsRes.data.filter((p) => p.name)); - } - - if (projectsRes.data) { - setProjects(projectsRes.data.filter((p) => p.name)); - } - } catch (err) { - console.error("Error fetching filter data:", err); - } - }; - - fetchData(); - }, [supabase]); - // Calculate dropdown position when button ref changes or filter opens useEffect(() => { if (buttonRef && showFilter) { @@ -103,13 +74,11 @@ export default function FilterCodevs({ }); const handleCheckedChange = (key: keyof Filters, value: string) => { - setFilters((prev: Filters) => { - const current = prev[key]; - const updated = current.includes(value) - ? current.filter((v: string) => v !== value) - : [...current, value]; - return { ...prev, [key]: updated }; - }); + const current = filters[key]; + const updated = current.includes(value) + ? current.filter((v: string) => v !== value) + : [...current, value]; + setFilters({ ...filters, [key]: updated }); }; // Render dropdown using portal to escape stacking context diff --git a/apps/codebility/app/home/interns/_components/InternalProjects.tsx b/apps/codebility/app/home/interns/_components/InternalProjects.tsx index 5c62aed35..2164a559f 100644 --- a/apps/codebility/app/home/interns/_components/InternalProjects.tsx +++ b/apps/codebility/app/home/interns/_components/InternalProjects.tsx @@ -1,204 +1,109 @@ "use client"; -import { useEffect, useState } from "react"; +import { useState } from "react"; import Image from "next/image"; -import { defaultAvatar } from "@/public/assets/images"; import { Project, ProjectMember } from "@/types/home/codev"; -import { createClientClientComponent } from "@/utils/supabase/client"; +import { getClientSupabase } from "@/utils/supabase/client"; +import { useQuery } from "@tanstack/react-query"; + +// Members are fetched without a codev join, then codev rows are read +// separately: PostgREST applies RLS to the join and silently drops members. +const PROJECT_SELECT = ` + *, + project_members ( id, codev_id, role, joined_at ) +`; + +const attachCodevs = async (rows: any[]): Promise => { + const ids = new Set(); + rows.forEach((project) => { + project.project_members?.forEach((pm: any) => { + if (pm.codev_id) ids.add(pm.codev_id); + }); + }); + + const codevMap = new Map(); + if (ids.size > 0) { + const { data: codevs, error } = await getClientSupabase() + .from("codev") + .select("id, first_name, last_name, image_url") + .in("id", Array.from(ids)); + + if (error) { + console.error("Error fetching codev records for internal projects:", error); + } else { + codevs?.forEach((c: any) => codevMap.set(c.id, c)); + } + } -import { Tabs, TabsContent, TabsList, TabsTrigger } from "@codevs/ui/tabs"; + return rows.map((project) => ({ + ...project, + project_members: project.project_members?.map((pm: any) => ({ + ...pm, + codev: codevMap.get(pm.codev_id) ?? null, + })), + })) as Project[]; +}; + +const fetchInternalProjects = async (): Promise => { + const supabase = getClientSupabase(); + + const { data: clients, error: clientError } = await supabase + .from("clients") + .select("id, name") + .or("name.ilike.%codebility%") + .limit(1); + + if (clientError) throw new Error("Failed to find client"); + + const clientId = clients?.[0]?.id; + + if (clientId) { + const { data, error } = await supabase + .from("projects") + .select(PROJECT_SELECT) + .eq("client_id", clientId) + .not("name", "ilike", "%design%") + .not("name", "ilike", "%product%") + .order("created_at", { ascending: false }); + + if (error) throw new Error("Failed to fetch projects"); + return attachCodevs(data ?? []); + } -interface InternalProjectsProps { - // We'll receive projects from the parent component -} + // No Codebility client row: fall back to matching project names. + const { data, error } = await supabase + .from("projects") + .select(PROJECT_SELECT) + .or( + "name.ilike.%tapup%,name.ilike.%applete%,name.ilike.%codebility%,name.ilike.%portal%,name.ilike.%sariverse%", + ) + .not("name", "ilike", "%design%") + .not("name", "ilike", "%product%") + .order("created_at", { ascending: false }); + + if (error) throw new Error("Failed to fetch projects"); + return attachCodevs(data ?? []); +}; export default function InternalProjects() { - const [projects, setProjects] = useState([]); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); const [selectedProject, setSelectedProject] = useState(""); - useEffect(() => { - fetchInternalProjects(); - }, []); - - const fetchInternalProjects = async () => { - try { - setLoading(true); - setError(null); - - const supabase = createClientClientComponent(); - - if (!supabase) { - throw new Error("Failed to initialize Supabase client"); - } - - // Try to find Codebility client with various patterns - const { data: clients, error: clientError } = await supabase - .from("clients") - .select("id, name") - .or( - "name.ilike.%codebility%,name.ilike.%Codebility%,name.ilike.%CODEBILITY%", - ) - .limit(1); - - if (clientError) { - throw new Error("Failed to find client"); - } - - // If no Codebility client found, try to get internal projects another way - if (!clients || clients.length === 0) { - // Alternative: fetch all projects and filter by name patterns - // Step 1: Fetch projects with project_members (no codev join!) - const { data: allProjects, error: projectsError } = await supabase - .from("projects") - .select( - ` - *, - project_members ( - id, - codev_id, - role, - joined_at - ) - `, - ) - .or( - "name.ilike.%tapup%,name.ilike.%applete%,name.ilike.%codebility%,name.ilike.%portal%,name.ilike.%sariverse%", - ) - .not("name", "ilike", "%design%") - .not("name", "ilike", "%product%") - .order("created_at", { ascending: false }); - - if (projectsError) { - throw new Error("Failed to fetch projects"); - } - - if (!allProjects || allProjects.length === 0) { - setProjects([]); - return; - } - - // Step 2: Collect all unique codev_ids from all projects - const allCodevIds = new Set(); - allProjects.forEach((project) => { - project.project_members?.forEach((pm: any) => { - if (pm.codev_id) allCodevIds.add(pm.codev_id); - }); - }); - - // Step 3: Fetch codev records separately to bypass RLS join filtering - let codevMap = new Map(); - - if (allCodevIds.size > 0) { - const { data: codevs, error: codevError } = await supabase - .from("codev") - .select("id, first_name, last_name, image_url") - .in("id", Array.from(allCodevIds)); - - if (codevError) { - console.error("Error fetching codev records for internal projects:", codevError); - } else { - codevs?.forEach((c: any) => codevMap.set(c.id, c)); - } - } - - // Step 4: Merge codev data back into project_members - const projectsWithMembers = allProjects.map((project) => ({ - ...project, - project_members: project.project_members?.map((pm: any) => ({ - ...pm, - codev: codevMap.get(pm.codev_id) ?? null, - })), - })); - - setProjects(projectsWithMembers); - return; - } - - const codebilityClientId = clients[0]?.id; - if (!codebilityClientId) { - throw new Error("Invalid client ID"); - } - - // Now fetch all projects for this client with their members - // Step 1: Fetch projects with project_members (no codev join!) - const { data: projectsData, error: projectError } = await supabase - .from("projects") - .select( - ` - *, - project_members ( - id, - codev_id, - role, - joined_at - ) - `, - ) - .eq("client_id", codebilityClientId) - .not("name", "ilike", "%design%") - .not("name", "ilike", "%product%") - .order("created_at", { ascending: false }); - - if (projectError) { - throw new Error("Failed to fetch projects"); - } - - if (!projectsData || projectsData.length === 0) { - setProjects([]); - } else { - // Step 2: Collect all unique codev_ids from all projects - const allCodevIds = new Set(); - projectsData.forEach((project) => { - project.project_members?.forEach((pm: any) => { - if (pm.codev_id) allCodevIds.add(pm.codev_id); - }); - }); - - // Step 3: Fetch codev records separately to bypass RLS join filtering - let codevMap = new Map(); - - if (allCodevIds.size > 0) { - const { data: codevs, error: codevError } = await supabase - .from("codev") - .select("id, first_name, last_name, image_url") - .in("id", Array.from(allCodevIds)); - - if (codevError) { - console.error("Error fetching codev records for client projects:", codevError); - } else { - codevs?.forEach((c: any) => codevMap.set(c.id, c)); - } - } - - // Step 4: Merge codev data back into project_members - const projectsWithMembers = projectsData.map((project) => ({ - ...project, - project_members: project.project_members?.map((pm: any) => ({ - ...pm, - codev: codevMap.get(pm.codev_id) ?? null, - })), - })); - - setProjects(projectsWithMembers); - } - - // Set first project as selected by default - if (projectsData && projectsData.length > 0) { - setSelectedProject(projectsData[0].id || projectsData[0].name || ""); - } - } catch (err) { - setError(err instanceof Error ? err.message : "An error occurred"); - } finally { - setLoading(false); - } - }; + const { + data: projects = [], + isPending: loading, + error, + } = useQuery({ + queryKey: ["interns", "internalProjects"], + staleTime: 5 * 60_000, + queryFn: fetchInternalProjects, + }); - // Get member count for a single project - const getProjectMemberCount = (project: Project) => { - return project.project_members?.length || 0; - }; + // The first project stays selected until the user picks another. + const activeProject = + selectedProject || projects[0]?.id || projects[0]?.name || ""; + + const getProjectMemberCount = (project: Project) => + project.project_members?.length || 0; if (loading) { return ( @@ -213,7 +118,9 @@ export default function InternalProjects() { if (error) { return (
-
Error: {error}
+
+ Error: {error instanceof Error ? error.message : "An error occurred"} +
); } @@ -228,9 +135,8 @@ export default function InternalProjects() { ); } - // Get selected project const selectedProjectData = projects.find( - (p) => p.id === selectedProject || p.name === selectedProject, + (p) => p.id === activeProject || p.name === activeProject, ); return ( @@ -246,7 +152,7 @@ export default function InternalProjects() { key={projectKey} onClick={() => setSelectedProject(projectKey)} className={`rounded-lg px-4 py-2 text-sm transition-all ${ - selectedProject === projectKey + activeProject === projectKey ? "border-customBlue-500 text-customBlue-500 border-2 font-medium" : "border-2 border-gray-300 text-gray-700 hover:border-gray-400 dark:border-gray-600 dark:text-gray-300 dark:hover:border-gray-500" }`} @@ -260,16 +166,13 @@ export default function InternalProjects() { {/* Team Members View */}
- {selectedProjectData && ( - - )} + {selectedProjectData && }
); } function TeamMembersView({ project }: { project: Project }) { - // Get all members from the project const members = project.project_members || []; if (members.length === 0) { @@ -296,7 +199,6 @@ function TeamMembersView({ project }: { project: Project }) {

- {/* Team Leaders */} {leaders.length > 0 && (

@@ -316,7 +218,6 @@ function TeamMembersView({ project }: { project: Project }) {

)} - {/* Team Members */} {regularMembers.length > 0 && (

@@ -410,33 +311,3 @@ function MemberCard({

); } - -function MemberBadge({ member }: { member: ProjectMember }) { - const memberName = member.codev - ? `${member.codev.first_name || ""} ${member.codev.last_name || ""}`.trim() - : "Unknown Member"; - - return ( -
- {member.codev?.image_url ? ( - {memberName} - ) : ( -
- )} - - {memberName} - - {member.role && member.role !== "member" && ( - - ({member.role}) - - )} -
- ); -} diff --git a/apps/codebility/app/home/interns/_components/ProfileModal.tsx b/apps/codebility/app/home/interns/_components/ProfileModal.tsx index ad80a43dd..992d22d77 100644 --- a/apps/codebility/app/home/interns/_components/ProfileModal.tsx +++ b/apps/codebility/app/home/interns/_components/ProfileModal.tsx @@ -12,7 +12,7 @@ import { DialogTitle, } from "@/components/ui/dialog"; import SwitchStatusButton from "@/components/ui/SwitchStatusButton"; -import { useModal } from "@/hooks/use-modal-users"; +import { useModal } from "@/hooks/modals/use-modal-users"; import { IconGithub, IconLink } from "@/public/assets/svgs"; import { IconFigma } from "@/public/assets/svgs/techstack"; import { Codev, InternalStatus } from "@/types/home/codev"; diff --git a/apps/codebility/app/home/interns/_lib/codevpriority.ts b/apps/codebility/app/home/interns/_lib/codevpriority.ts deleted file mode 100644 index ab4340bc4..000000000 --- a/apps/codebility/app/home/interns/_lib/codevpriority.ts +++ /dev/null @@ -1,63 +0,0 @@ -// utils/codevPriority.ts - -import { Codev } from "@/types/home/codev"; - -// Function to calculate total level score -function calculateLevelScore(level: Record | undefined): number { - if (!level) return 0; - return Object.values(level).reduce((sum, levelNum) => sum + levelNum, 0); -} - -// Function to get number of skill badges -function getNumberOfBadges(level: Record | undefined): number { - if (!level) return 0; - return Object.keys(level).length; -} - -// Function to filter and prioritize Codevs -export function prioritizeCodevs(codevs: Codev[]): Codev[] { - const adminRoleId = 1; - - // Step 1: Filter out unwanted profiles - const filteredCodevs = codevs.filter( - (codev) => - codev.role_id !== adminRoleId && - codev.application_status !== "failed" && - codev.application_status !== "applying" - ); - - // Step 2: Sort codevs based on priority criteria - return filteredCodevs.sort((a, b) => { - // Priority 1: Has image_url - if (a.image_url && !b.image_url) return -1; - if (b.image_url && !a.image_url) return 1; - - // Priority 2: Is available - if (a.availability_status && !b.availability_status) return -1; - if (b.availability_status && !a.availability_status) return 1; - - // Priority 3: Total level score - const aLevelScore = calculateLevelScore(a.level); - const bLevelScore = calculateLevelScore(b.level); - if (aLevelScore !== bLevelScore) return bLevelScore - aLevelScore; - - // Priority 4: Number of badges - const aNumBadges = getNumberOfBadges(a.level); - const bNumBadges = getNumberOfBadges(b.level); - if (aNumBadges !== bNumBadges) return bNumBadges - aNumBadges; - - // Priority 5: Has work experience - const aHasExperience = Array.isArray(a.work_experience) && a.work_experience.length > 0; - const bHasExperience = Array.isArray(b.work_experience) && b.work_experience.length > 0; - if (aHasExperience && !bHasExperience) return -1; - if (bHasExperience && !aHasExperience) return 1; - - // Priority 6: Years of experience - const aYears = a.years_of_experience || 0; - const bYears = b.years_of_experience || 0; - if (aYears !== bYears) return bYears - aYears; - - // Maintain original order if all are equal - return 0; - }); -} diff --git a/apps/codebility/app/home/interns/page.tsx b/apps/codebility/app/home/interns/page.tsx index da9f275b3..9279f2be2 100644 --- a/apps/codebility/app/home/interns/page.tsx +++ b/apps/codebility/app/home/interns/page.tsx @@ -1,42 +1,42 @@ -import { getCodevs } from "@/lib/server/codev.service"; -import { getOrSetCache } from "@/lib/server/redis-cache"; -import { cacheKeys } from "@/lib/server/redis-cache-keys"; import PageContainer from "../_components/PageContainer"; +import { getCodevStatusCounts, getInternsPage } from "@/lib/server/codev.service"; +import { getPositions, getProjectOptions } from "@/lib/server/reference-data"; +import { pageSize } from "@/constants"; import CodevContainer from "./_components/CodevContainer"; -// Codevs list updates periodically, use 60 second revalidation -export const revalidate = 60; +const parsePage = (value: string | string[] | undefined) => { + const raw = Array.isArray(value) ? value[0] : value; + const parsed = Number(raw); + return Number.isFinite(parsed) && parsed > 0 ? Math.trunc(parsed) : 1; +}; -export default async function CodevsPage() { - const { data: allCodevs, error } = await getOrSetCache( - cacheKeys.codevs.members, - () => getCodevs(), // Removed { filters: { role_id: 4 } } - ); - - if (error) { - return ( - -
-
-
⚠️
-

Error fetching interns

-

{error.message}

-
-
-
- ); - } +export default async function CodevsPage({ + searchParams, +}: { + searchParams: Promise<{ page?: string | string[] }>; +}) { + const { page } = await searchParams; - // Filter only codevs with application_status = "passed" - // Include all passed members regardless of their internal_status (active or inactive) - const passedCodevs = (allCodevs || []).filter( - (codev) => codev.application_status === "passed" - ); + const [initialData, counts, positions, projects] = await Promise.all([ + getInternsPage({ + page: parsePage(page), + pageSize: pageSize.codevsList, + filters: { availability_status: true }, + }), + getCodevStatusCounts({ application_status: "passed" }), + getPositions(), + getProjectOptions(), + ]); return ( - + ); } diff --git a/apps/codebility/app/home/kanban/[projectId]/[id]/KanbanBoardPageClient.tsx b/apps/codebility/app/home/kanban/[projectId]/[id]/KanbanBoardPageClient.tsx new file mode 100644 index 000000000..317f902e7 --- /dev/null +++ b/apps/codebility/app/home/kanban/[projectId]/[id]/KanbanBoardPageClient.tsx @@ -0,0 +1,95 @@ +"use client"; + + + +import { useMemo } from "react"; + + + +import type { SimpleMemberData } from "@/actions/projects/actions"; + +import { filterBoardByQuery } from "@/lib/kanban/board-mappers"; + +import { + + useKanbanBoardMeta, + + useKanbanColumns, + +} from "@/store/kanban-board/KanbanBoardProvider"; + + + +import KanbanBoard from "./_components/KanbanBoard"; + + + +type KanbanBoardPageClientProps = { + + projectId: string; + + query?: string; + + members: SimpleMemberData[]; + + draftCount: number; + +}; + + + +export default function KanbanBoardPageClient({ + + projectId, + + query = "", + + members, + + draftCount, + +}: KanbanBoardPageClientProps) { + + const columns = useKanbanColumns(); + + const { board } = useKanbanBoardMeta(); + + + + const boardData = useMemo(() => { + + const withColumns = { ...board, kanban_columns: columns }; + + return filterBoardByQuery(withColumns, query); + + }, [board, columns, query]); + + + + return ( + +
+ +
+ + + +
+ +
+ + ); + +} + + diff --git a/apps/codebility/app/home/kanban/[projectId]/[id]/_components/ArchiveColumn.tsx b/apps/codebility/app/home/kanban/[projectId]/[id]/_components/ArchiveColumn.tsx index a9bba2491..540c49468 100644 --- a/apps/codebility/app/home/kanban/[projectId]/[id]/_components/ArchiveColumn.tsx +++ b/apps/codebility/app/home/kanban/[projectId]/[id]/_components/ArchiveColumn.tsx @@ -6,7 +6,7 @@ import { createClientClientComponent } from "@/utils/supabase/client"; import { Task } from "@/types/home/codev"; import { IconArchive } from "@/public/assets/svgs"; -import { useModal } from "@/hooks/use-modal"; +import { useKanbanModal } from "@/hooks/kanban/use-modal-kanban"; import toast from "react-hot-toast"; import ArchiveTask from "./ArchiveTask"; @@ -19,7 +19,7 @@ export default function ArchiveColumn({ projectId, boardId }: ArchiveColumnProps const [archivedTasks, setArchivedTasks] = useState([]); const [isLoading, setIsLoading] = useState(true); const [supabase, setSupabase] = useState(null); - const { onOpen } = useModal(); + const { onOpen } = useKanbanModal(); const searchParams = useSearchParams(); const highlightTaskId = searchParams.get("taskId"); diff --git a/apps/codebility/app/home/kanban/[projectId]/[id]/_components/KanbanBoard.tsx b/apps/codebility/app/home/kanban/[projectId]/[id]/_components/KanbanBoard.tsx index 7f052adc8..0044a62e4 100644 --- a/apps/codebility/app/home/kanban/[projectId]/[id]/_components/KanbanBoard.tsx +++ b/apps/codebility/app/home/kanban/[projectId]/[id]/_components/KanbanBoard.tsx @@ -1,15 +1,15 @@ "use client"; -import { memo, useCallback, useEffect, useMemo, useState } from "react"; +import { memo, useEffect, useMemo, useState } from "react"; +import dynamic from "next/dynamic"; import { useSearchParams } from "next/navigation"; import Link from "next/link"; import KanbanBoardsSearch from "@/app/home/kanban/_components/KanbanBoardsSearch"; -import { getMembers } from "@/app/home/projects/actions"; -import pathsConfig from "@/config/paths.config"; +import type { SimpleMemberData } from "@/actions/projects/actions"; +import pathsConfig from "@/types/zod/paths.config"; import { ArrowRightIcon, IconArchive, IconSearch } from "@/public/assets/svgs"; import { useUserStore } from "@/store/codev-store"; import { KanbanBoardType, KanbanColumnType } from "@/types/home/codev"; -import { useQuery } from "@tanstack/react-query"; import toast from "react-hot-toast"; import { Archive } from "lucide-react"; @@ -19,77 +19,88 @@ import ArchiveColumn from "./ArchiveColumn"; import KanbanAddMembersButton from "./kanban_modals/KanbanAddMembersButton"; import KanbanColumnAddButton from "./kanban_modals/KanbanColumnAddButton"; import KanbanColumnAddModal from "./kanban_modals/KanbanColumnAddModal"; -import KanbanBoardColumnContainer from "./KanbanBoardColumnContainer"; import UserTaskFilter from "./UserTaskFilter"; -import { useKanbanTaskUrlModal } from "@/hooks/useKanbanTaskUrlModal"; -import { getDraftCount } from "../actions"; +import KanbanBoardSyncIndicator from "./KanbanBoardSyncIndicator"; +import { useKanbanTaskUrlModal } from "@/hooks/kanban/useKanbanTaskUrlModal"; +import { useMemberTaskFilter } from "@/hooks/kanban/use-member-task-filter"; + +const KanbanBoardColumnContainer = dynamic( + () => import("./KanbanBoardColumnContainer"), + { + ssr: false, + loading: () => ( +
+
    + {Array.from({ length: 4 }).map((_, index) => ( +
  1. +
    +
    +
    +
    + {Array.from({ length: 2 }).map((__, taskIndex) => ( +
    + ))} +
    +
  2. + ))} +
+
+ ), + }, +); interface KanbanBoardProps { boardData: KanbanBoardType & { kanban_columns: KanbanColumnType[] }; projectId: string; + members: SimpleMemberData[]; + draftCount: number; } -function KanbanBoard({ boardData, projectId }: KanbanBoardProps) { +function KanbanBoard({ + boardData, + projectId, + members: allMembers, + draftCount, +}: KanbanBoardProps) { const user = useUserStore((state) => state.user); useKanbanTaskUrlModal(boardData); + const { activeFilter, handleFilterClick, loadingMemberId } = + useMemberTaskFilter(); + const canAddColumn = user?.role_id === 1 || user?.role_id === 5; const canAddMember = canAddColumn; const searchParams = useSearchParams(); const viewParam = searchParams.get("view"); - const [activeFilter, setActiveFilter] = useState(null); const [showArchive, setShowArchive] = useState(viewParam === "archive"); - const [draftCount, setDraftCount] = useState(0); - - const { data: allMembers = [], isLoading: isMembersLoading } = useQuery({ - queryKey: ["members", projectId], - queryFn: async () => { - const result = await getMembers(projectId); - return result.data || []; - }, - enabled: !!projectId, - staleTime: 5 * 60 * 1000, - refetchOnWindowFocus: false, - }); const members = useMemo( () => - allMembers.map((user) => ({ - userId: user.id, - userName: `${user.first_name} ${user.last_name}`, - imageUrl: user.image_url, - isActive: activeFilter === user.id, + allMembers.map((member) => ({ + userId: member.id, + userName: `${member.first_name} ${member.last_name}`, + imageUrl: member.image_url, + isActive: activeFilter === member.id, + isLoading: loadingMemberId === member.id, })), - [allMembers, activeFilter], + [allMembers, activeFilter, loadingMemberId], ); - useEffect(() => { - if (user?.id && projectId) { - loadDraftCount(); - } - }, [user?.id, projectId]); - - const loadDraftCount = async () => { - if (!user?.id || !projectId) return; - const result = await getDraftCount(projectId, user.id); - if (result.success && result.count !== undefined) { - setDraftCount(result.count); - } - }; - useEffect(() => { if (viewParam === "archive") { setShowArchive(true); } }, [viewParam]); - const handleFilterClick = useCallback((userId: string) => { - setActiveFilter((prev) => (prev === userId ? null : userId)); - }, []); - if (!boardData) { return (
Board data not found.
@@ -150,9 +161,12 @@ function KanbanBoard({ boardData, projectId }: KanbanBoardProps) {
-

- {boardData.name} -

+
+

+ {boardData.name} +

+ +

Organize and track your project tasks efficiently

@@ -277,7 +291,6 @@ function KanbanBoard({ boardData, projectId }: KanbanBoardProps) { {!showArchive ? ( ) : ( diff --git a/apps/codebility/app/home/kanban/[projectId]/[id]/_components/KanbanBoardColumnContainer.tsx b/apps/codebility/app/home/kanban/[projectId]/[id]/_components/KanbanBoardColumnContainer.tsx index 3d8537ed1..5d853257d 100644 --- a/apps/codebility/app/home/kanban/[projectId]/[id]/_components/KanbanBoardColumnContainer.tsx +++ b/apps/codebility/app/home/kanban/[projectId]/[id]/_components/KanbanBoardColumnContainer.tsx @@ -1,12 +1,7 @@ "use client"; -import { useCallback, useEffect, useMemo, useState } from "react"; -import { useRouter } from "next/navigation"; -import { useKanbanStore } from "@/store/kanban-store"; -import { KanbanColumnType, Task } from "@/types/home/codev"; -import { debounce } from "@/utils/debounce"; +import { useCallback, useMemo } from "react"; import { - closestCenter, closestCorners, DndContext, DragEndEvent, @@ -24,19 +19,27 @@ import { } from "@dnd-kit/sortable"; import toast from "react-hot-toast"; -import { batchUpdateTasks, updateColumnPosition } from "../actions"; +import { broadcastColumnsReorder } from "@/lib/kanban/board-broadcast"; +import { + commitTaskMove, + filterColumnsByMember, +} from "@/lib/kanban/board-mutations"; +import { queueOriginatorColumnsReorder } from "@/lib/kanban/board-snapshot-save"; +import { + useKanbanBoardActions, + useKanbanBoardMeta, + useKanbanColumns, +} from "@/store/kanban-board/KanbanBoardProvider"; + import KanbanColumn from "./KanbanColumn"; -// Styles const styles = { container: "overflow-x-auto overflow-y-hidden", columnList: "flex flex-wrap min-h-[calc(100vh-12rem)] w-full gap-4 p-2 md:p-4", } as const; -// Types interface Props { - columns: KanbanColumnType[]; projectId: string; activeFilter: string | null; } @@ -46,134 +49,38 @@ interface DnDData { columnId?: string; } -interface DragAndDropHandlers { +function useDragAndDrop(handlers: { onDragEnd: (event: DragEndEvent) => void | Promise; onDragStart: (event: DragStartEvent) => void; onDragOver: (event: DragOverEvent) => void; -} - -// Custom Hook with proper types -function useDragAndDrop(handlers: DragAndDropHandlers) { +}) { const sensors = useSensors( useSensor(PointerSensor, { - activationConstraint: { distance: 3 }, // Very responsive + activationConstraint: { distance: 3 }, }), useSensor(TouchSensor, { - activationConstraint: { distance: 3 }, // Very responsive + activationConstraint: { distance: 3 }, }), ); - return { - sensors, - handleDragEnd: handlers.onDragEnd, - handleDragStart: handlers.onDragStart, - handleDragOver: handlers.onDragOver, - }; + return { sensors, ...handlers }; } export default function KanbanBoardColumnContainer({ - columns, projectId, activeFilter, }: Props) { - const router = useRouter(); + const columns = useKanbanColumns(); + const { boardId } = useKanbanBoardMeta(); + const { setColumns, removeTaskLocal } = useKanbanBoardActions(); - // Sort columns by their "position" (lowest to highest) - const [orderedColumns, setOrderedColumns] = useState(() => - [...columns].sort((a, b) => (a.position ?? 0) - (b.position ?? 0)), + const boardData = useMemo( + () => filterColumnsByMember(columns, activeFilter), + [columns, activeFilter], ); - // Our board data: ensure every column has a tasks array. - const [boardData, setBoardData] = useState([]); - // For batching task updates (when a task moves between columns) - const [pendingUpdates, setPendingUpdates] = useState< - Array<{ taskId: string; newColumnId: string }> - >([]); - - // Track dragging state for immediate feedback - const [isDragging, setIsDragging] = useState(false); - - // Sort function for tasks (descending by updated_at) - const sortByUpdatedAtDesc = (a: Task, b: Task) => { - const dateA = a.updated_at ? new Date(a.updated_at).getTime() : 0; - const dateB = b.updated_at ? new Date(b.updated_at).getTime() : 0; - return dateB - dateA; - }; - - // Debounced batch update with improved error handling - const debouncedBatchUpdate = useMemo( - () => - debounce( - async (updates: Array<{ taskId: string; newColumnId: string }>) => { - if (updates.length === 0) return; - try { - const result = await batchUpdateTasks(updates); - if (result.success) { - setPendingUpdates([]); - // Success - no refresh needed - } else { - console.error("Batch update failed:", result.error); - toast.error(result.error || "Failed to update tasks"); - } - } catch (error) { - console.error("Batch update error:", error); - toast.error("Failed to update tasks. Changes may not be saved."); - } - }, - 500, // Faster than before but still debounced - ), - [], - ); - - useEffect(() => { - if (pendingUpdates.length > 0) { - debouncedBatchUpdate(pendingUpdates); - } - }, [pendingUpdates, debouncedBatchUpdate]); - - // Filter and sort tasks - const filterAndSortTasks = useCallback( - (tasks: Task[] = []) => { - return tasks - .filter( - (task) => - !activeFilter || - task.codev?.id === activeFilter || - task.sidekick_ids?.includes(activeFilter), - ) - .sort(sortByUpdatedAtDesc); - }, - [activeFilter], - ); - - // Update orderedColumns when columns change - useEffect(() => { - const sortedColumns = [...columns].sort( - (a, b) => (a.position ?? 0) - (b.position ?? 0), - ); - setOrderedColumns(sortedColumns); - }, [columns]); - - // Initialize board data from orderedColumns - useEffect(() => { - setBoardData( - orderedColumns.map((col) => ({ - ...col, - tasks: filterAndSortTasks(col.tasks), - })), - ); - }, [orderedColumns, activeFilter, filterAndSortTasks]); - - const handleDragStart = useCallback((event: DragStartEvent) => { - // Optional: set active item state for custom drag overlays. - }, []); - - const handleDragOver = useCallback((event: DragOverEvent) => { - // Optional: implement live feedback if needed. - }, []); - const handleDragEnd = useCallback( - async (event: DragEndEvent) => { + (event: DragEndEvent) => { const { active, over } = event; if (!over) return; @@ -181,34 +88,36 @@ export default function KanbanBoardColumnContainer({ const overData = over.data.current as DnDData | undefined; if (!activeData || !overData) return; - // Column Reordering if (activeData.type === "Column" && overData.type === "Column") { - const oldIndex = boardData.findIndex((col) => col.id === active.id); - const newIndex = boardData.findIndex((col) => col.id === over.id); + const orderedColumns = [...columns].sort( + (a, b) => (a.position ?? 0) - (b.position ?? 0), + ); + const oldIndex = orderedColumns.findIndex((col) => col.id === active.id); + const newIndex = orderedColumns.findIndex((col) => col.id === over.id); if (oldIndex === -1 || newIndex === -1 || oldIndex === newIndex) return; - const newCols = arrayMove(boardData, oldIndex, newIndex); - - // Optimistic update - update UI immediately - setBoardData(newCols); - setOrderedColumns(newCols); - - // Save to database in background without blocking UI - Promise.all( - newCols.map((column, index) => - updateColumnPosition(column.id, index), - ), - ).catch((error) => { - console.error("Column reorder error:", error); - toast.error("Failed to reorder columns"); - // Only refresh on error to revert changes - router.refresh(); + const newCols = arrayMove(orderedColumns, oldIndex, newIndex).map( + (column, index) => ({ ...column, position: index }), + ); + setColumns(newCols); + broadcastColumnsReorder(boardId, { + columns: newCols.map((column) => ({ + id: column.id, + position: column.position ?? 0, + })), }); + queueOriginatorColumnsReorder( + boardId, + projectId, + newCols.map((column) => ({ + id: column.id, + position: column.position ?? 0, + })), + ); return; } - // Task Drag if ( activeData.type === "Task" && (overData.type === "Column" || overData.type === "Task") @@ -220,90 +129,64 @@ export default function KanbanBoardColumnContainer({ if (!activeColId || !overColId) return; - const updatedBoard = structuredClone(boardData); - const oldColumnIndex = updatedBoard.findIndex( - (col) => col.id === activeColId, - ); - const newColumnIndex = updatedBoard.findIndex( - (col) => col.id === overColId, - ); - - if (oldColumnIndex === -1 || newColumnIndex === -1) return; - - const oldColumn = updatedBoard[oldColumnIndex]; - const newColumn = updatedBoard[newColumnIndex]; - - if (!oldColumn || !newColumn) return; + const sourceColumn = columns.find((col) => col.id === activeColId); + const targetColumn = columns.find((col) => col.id === overColId); + if (!sourceColumn || !targetColumn) return; - oldColumn.tasks = oldColumn.tasks ?? []; - newColumn.tasks = newColumn.tasks ?? []; + const sourceTasks = sourceColumn.tasks ?? []; + const targetTasks = + activeColId === overColId + ? sourceTasks + : (targetColumn.tasks ?? []); - const activeTaskIndex = oldColumn.tasks.findIndex( - (t) => t.id === active.id, - ); + const activeTaskIndex = sourceTasks.findIndex((t) => t.id === active.id); if (activeTaskIndex === -1) return; - const movedTask = oldColumn.tasks.splice(activeTaskIndex, 1)[0]; - if (!movedTask) return; - - movedTask.kanban_column_id = overColId; - - // If dragging over another task, insert at that task's index + let targetPosition = targetTasks.length; if (overData.type === "Task") { - const overTaskIndex = newColumn.tasks.findIndex( - (t) => t.id === over.id, - ); - newColumn.tasks.splice(overTaskIndex, 0, movedTask); - } else { - // If dragging to column, add to the end - newColumn.tasks.push(movedTask); + const overTaskIndex = targetTasks.findIndex((t) => t.id === over.id); + if (overTaskIndex !== -1) { + targetPosition = overTaskIndex; + } } - setBoardData(updatedBoard); + const result = commitTaskMove( + String(active.id), + overColId, + targetPosition, + ); - // Add to pending updates for debounced batch processing - setPendingUpdates((prev) => { - // Remove any existing update for this task to avoid duplicates - const filtered = prev.filter( - (update) => update.taskId !== movedTask.id, - ); - return [ - ...filtered, - { taskId: movedTask.id, newColumnId: overColId }, - ]; - }); + if (!result.success) { + toast.error("Failed to move task"); + } } }, - [boardData, router], + [boardId, columns, setColumns], ); const { sensors } = useDragAndDrop({ onDragEnd: handleDragEnd, - onDragStart: handleDragStart, - onDragOver: handleDragOver, + onDragStart: () => {}, + onDragOver: () => {}, }); - // Handle task completion - const handleTaskComplete = useCallback((completedTaskId: string) => { - setBoardData((prevData) => - prevData.map((column) => ({ - ...column, - tasks: - column.tasks?.filter((task) => task.id !== completedTaskId) || [], - })), - ); - }, []); + const handleTaskComplete = useCallback( + (completedTaskId: string) => { + removeTaskLocal(completedTaskId); + }, + [removeTaskLocal], + ); - // IDs for the SortableContext for columns const columnIds = boardData.map((col) => col.id); return (
{}} + onDragOver={() => {}} onDragEnd={handleDragEnd} > ({ id: col.id, name: col.name }))} + availableColumns={boardData.map((col) => ({ + id: col.id, + name: col.name, + }))} /> ))} diff --git a/apps/codebility/app/home/kanban/[projectId]/[id]/_components/KanbanBoardSyncIndicator.tsx b/apps/codebility/app/home/kanban/[projectId]/[id]/_components/KanbanBoardSyncIndicator.tsx new file mode 100644 index 000000000..25150804e --- /dev/null +++ b/apps/codebility/app/home/kanban/[projectId]/[id]/_components/KanbanBoardSyncIndicator.tsx @@ -0,0 +1,32 @@ +"use client"; + +import { useSyncExternalStore } from "react"; +import { Loader2 } from "lucide-react"; + +import { + getIsBoardSnapshotSyncing, + subscribeBoardSnapshotSync, +} from "@/lib/kanban/board-sync-status"; + +export default function KanbanBoardSyncIndicator() { + const isSyncing = useSyncExternalStore( + subscribeBoardSnapshotSync, + getIsBoardSnapshotSyncing, + () => false, + ); + + if (!isSyncing) { + return null; + } + + return ( + + + ); +} diff --git a/apps/codebility/app/home/kanban/[projectId]/[id]/_components/KanbanColumn.tsx b/apps/codebility/app/home/kanban/[projectId]/[id]/_components/KanbanColumn.tsx index 92a878ac8..cd05ba320 100644 --- a/apps/codebility/app/home/kanban/[projectId]/[id]/_components/KanbanColumn.tsx +++ b/apps/codebility/app/home/kanban/[projectId]/[id]/_components/KanbanColumn.tsx @@ -1,17 +1,15 @@ "use client"; -import { useMemo, useState } from "react"; +import { useState } from "react"; import { Button } from "@/components/ui/button"; -import { useModal } from "@/hooks/use-modal"; +import { useKanbanModal } from "@/hooks/kanban/use-modal-kanban"; +import { useModal } from "@/hooks/modals/use-modal"; +import { useKanbanBoardSync } from "@/hooks/kanban/use-kanban-board-sync"; import { useUserStore } from "@/store/codev-store"; -import { useKanbanStore } from "@/store/kanban-store"; +import { useKanbanBoardStore } from "@/store/kanban-board/KanbanBoardProvider"; import { ExtendedTask } from "@/types/home/codev"; import { useDroppable } from "@dnd-kit/core"; -import { - SortableContext, - useSortable, - verticalListSortingStrategy, -} from "@dnd-kit/sortable"; +import { useSortable } from "@dnd-kit/sortable"; import { CSS } from "@dnd-kit/utilities"; import { Check, @@ -31,10 +29,9 @@ import { } from "@codevs/ui/dropdown-menu"; import { Input } from "@codevs/ui/input"; -import { deleteColumn, updateColumnName } from "../actions"; +import { deleteColumn, updateColumnName } from "@/actions/kanban"; import KanbanTaskAddModal from "./kanban_modals/KanbanTaskAddModal"; -/** IMPORTANT: This is where you import the sortable task component. */ -import KanbanTask from "./KanbanTask"; // (see #2 below) +import KanbanColumnVirtualList from "./KanbanColumnVirtualList"; interface Props { column: { @@ -67,9 +64,13 @@ export default function KanbanColumn({ const [isEditing, setIsEditing] = useState(false); const [newName, setNewName] = useState(column.name); - const { fetchBoardData } = useKanbanStore(); + const { refreshBoard } = useKanbanBoardSync(); + const columnLoadMeta = useKanbanBoardStore( + (state) => state.columnLoadMeta[column.id], + ); const safeTasks = tasks ?? []; + const totalTaskCount = columnLoadMeta?.totalCount ?? safeTasks.length; // 1) Make the whole column "sortable" so we can reorder columns horizontally const { @@ -103,30 +104,16 @@ export default function KanbanColumn({ zIndex: isColumnDragging ? 40 : undefined, }; - // Sorted tasks by updated_at (DESC) using safeTasks array - const sortedTasks = useMemo(() => { - return [...safeTasks].sort((a, b) => { - const dateA = a.updated_at ? new Date(a.updated_at).getTime() : 0; - const dateB = b.updated_at ? new Date(b.updated_at).getTime() : 0; - return dateB - dateA; - }); - }, [safeTasks]); - - // The array of task IDs for our SortableContext - const taskIds = useMemo( - () => sortedTasks.map((task) => task.id), - [sortedTasks], - ); - // Handle column deletion - const { onOpen } = useModal(); + const { onOpen } = useKanbanModal(); + const { onOpen: onOpenGlobal } = useModal(); const confirmDelete = async () => { const response = await deleteColumn(column.id); if (response.success) { toast.success("Column deleted successfully"); // Refetch the board data - await fetchBoardData(); + await refreshBoard(); } else { toast.error(response.error || "Failed to delete column"); } @@ -138,12 +125,12 @@ export default function KanbanColumn({ return; } - if (safeTasks.length > 0) { + if (totalTaskCount > 0) { toast.error("Cannot delete column with tasks"); return; } - onOpen("deleteWarningModal", {}, {}, () => confirmDelete()); + onOpenGlobal("deleteWarningModal", {}, {}, () => confirmDelete()); }; // Handle column rename @@ -163,7 +150,7 @@ export default function KanbanColumn({ toast.success("Column name updated"); setIsEditing(false); // Refetch the board data - await fetchBoardData(); + await refreshBoard(); } else { toast.error(response.error || "Failed to update column name"); } @@ -229,7 +216,7 @@ export default function KanbanColumn({ {column.name}
- {safeTasks.length} + {totalTaskCount}
)} @@ -253,7 +240,7 @@ export default function KanbanColumn({ 0} + disabled={totalTaskCount > 0} className="text-red-500 focus:text-red-500" > @@ -276,26 +263,12 @@ export default function KanbanColumn({ ${isOver ? "border-2 border-customBlue-300 bg-customBlue-50/30 backdrop-blur-sm dark:bg-customBlue-900/20" : ""} `} > - - {sortedTasks.length === 0 ? ( -
- No tasks in this column -
- ) : ( - sortedTasks.map((task) => ( - - )) - )} -
+
{canAddTask && ( @@ -304,7 +277,7 @@ export default function KanbanColumn({ listId={column.id} listName={column.name} projectId={projectId} - totalTask={safeTasks.length} + totalTask={totalTaskCount} />
)} diff --git a/apps/codebility/app/home/kanban/[projectId]/[id]/_components/KanbanColumnVirtualList.tsx b/apps/codebility/app/home/kanban/[projectId]/[id]/_components/KanbanColumnVirtualList.tsx new file mode 100644 index 000000000..00f9927c6 --- /dev/null +++ b/apps/codebility/app/home/kanban/[projectId]/[id]/_components/KanbanColumnVirtualList.tsx @@ -0,0 +1,127 @@ +"use client"; + +import { useEffect, useMemo, useRef } from "react"; +import { useVirtualizer } from "@tanstack/react-virtual"; +import { SortableContext, verticalListSortingStrategy } from "@dnd-kit/sortable"; +import { Loader2 } from "lucide-react"; + +import { useColumnTasksInfinite } from "@/hooks/kanban/use-column-tasks-infinite"; +import { ExtendedTask } from "@/types/home/codev"; + +import KanbanTask from "./KanbanTask"; + +const ESTIMATED_TASK_ROW_HEIGHT = 132; + +interface Props { + columnId: string; + tasks: ExtendedTask[]; + onTaskComplete: (taskId: string) => void; + availableColumns: Array<{ id: string; name: string }>; +} + +export default function KanbanColumnVirtualList({ + columnId, + tasks, + onTaskComplete, + availableColumns, +}: Props) { + const scrollRef = useRef(null); + const { hasMore, isLoadingMore, loadMore } = useColumnTasksInfinite(columnId); + + const sortedTasks = useMemo( + () => + [...tasks].sort((a, b) => (a.position ?? 0) - (b.position ?? 0)), + [tasks], + ); + + const taskIds = useMemo( + () => sortedTasks.map((task) => task.id), + [sortedTasks], + ); + + const rowCount = hasMore ? sortedTasks.length + 1 : sortedTasks.length; + + const virtualizer = useVirtualizer({ + count: rowCount, + getScrollElement: () => scrollRef.current, + estimateSize: () => ESTIMATED_TASK_ROW_HEIGHT, + gap: 8, + overscan: 4, + }); + + const virtualItems = virtualizer.getVirtualItems(); + + useEffect(() => { + const lastItem = virtualItems[virtualItems.length - 1]; + if (!lastItem) { + return; + } + + if ( + lastItem.index >= sortedTasks.length - 1 && + hasMore && + !isLoadingMore + ) { + void loadMore(); + } + }, [ + hasMore, + isLoadingMore, + loadMore, + sortedTasks.length, + virtualItems, + ]); + + if (sortedTasks.length === 0 && !hasMore) { + return ( +
+ No tasks in this column +
+ ); + } + + return ( + +
+
+ {virtualItems.map((virtualRow) => { + const isLoaderRow = virtualRow.index >= sortedTasks.length; + const task = sortedTasks[virtualRow.index]; + + return ( +
+ {isLoaderRow ? ( +
+ + Loading more tasks... +
+ ) : task ? ( + + ) : null} +
+ ); + })} +
+
+
+ ); +} diff --git a/apps/codebility/app/home/kanban/[projectId]/[id]/_components/KanbanTask.tsx b/apps/codebility/app/home/kanban/[projectId]/[id]/_components/KanbanTask.tsx index 78f86e7fa..a3b10ee0a 100644 --- a/apps/codebility/app/home/kanban/[projectId]/[id]/_components/KanbanTask.tsx +++ b/apps/codebility/app/home/kanban/[projectId]/[id]/_components/KanbanTask.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useState, useMemo, memo } from "react"; +import React, { useMemo, memo } from "react"; import DefaultAvatar from "@/components/DefaultAvatar"; import { IconPriority1, @@ -8,23 +8,14 @@ import { IconPriority5, } from "@/public/assets/svgs"; import { Task } from "@/types/home/codev"; -import { createClientClientComponent } from "@/utils/supabase/client"; import { useSortable } from "@dnd-kit/sortable"; import { CSS } from "@dnd-kit/utilities"; import KanbanTaskViewEditModal from "./kanban_modals/KanbanTaskViewEditModal"; import MobileTaskMoveModal from "./kanban_modals/MobileTaskMoveModal"; -// Local interface for fetched sidekick data -interface SidekickMember { - id: string; - first_name: string; - last_name: string; - image_url?: string | null; -} - interface Props { - task: Task; // Use the global Task type, not ExtendedTask + task: Task; columnId?: string; onComplete?: (taskId: string) => void; availableColumns?: Array<{ @@ -57,8 +48,7 @@ function KanbanTask({ task, columnId, onComplete, availableColumns = [] }: Props const primaryImage = task.codev?.image_url; const primaryName = task.codev?.first_name || "Assignee"; - - const sidekicks = task.sidekick_ids ?? []; + const sidekickCount = task.sidekick_ids?.length ?? 0; const PriorityIconMap: { [key: string]: React.FC> } = useMemo(() => ({ critical: IconPriority1, @@ -73,10 +63,6 @@ function KanbanTask({ task, columnId, onComplete, availableColumns = [] }: Props : IconPriority5 , [task.priority, PriorityIconMap]); - const [sidekickDetails, setSidekickDetails] = useState([]); - const [supabase, setSupabase] = useState(null); - - // Calculate deadline urgency const getDeadlineStatus = useMemo(() => { if (!task.deadline) return null; @@ -103,7 +89,6 @@ function KanbanTask({ task, columnId, onComplete, availableColumns = [] }: Props } }, [task.deadline]); - // Get priority-based styles const priorityStyles = useMemo(() => { const baseClasses = "absolute top-0 left-0 w-1 h-full rounded-l-lg"; switch (task.priority?.toLowerCase()) { @@ -120,7 +105,6 @@ function KanbanTask({ task, columnId, onComplete, availableColumns = [] }: Props } }, [task.priority]); - // Get skill category color const getSkillCategoryColor = useMemo(() => (category: string): string => { const colorMap: Record = { "UI/UX Designer": @@ -141,7 +125,6 @@ function KanbanTask({ task, columnId, onComplete, availableColumns = [] }: Props ); }, []); - // Get task type color const getTaskTypeColor = useMemo(() => (type: string): string => { const typeColorMap: Record = { BUG: "bg-red-200 text-red-900 border-red-300 dark:bg-red-900/60 dark:text-red-200 dark:border-red-800", @@ -156,30 +139,6 @@ function KanbanTask({ task, columnId, onComplete, availableColumns = [] }: Props ); }, []); - useEffect(() => { - const supabaseClient = createClientClientComponent(); - setSupabase(supabaseClient); - }, []); - - useEffect(() => { - if (!supabase) return; - - async function fetchSidekickDetails() { - if (sidekicks.length === 0) return; - - const { data, error } = await supabase - .from("codev") - .select("id, first_name, last_name, image_url") - .in("id", sidekicks); - if (error) { - console.error("Error fetching sidekick details:", error.message); - } else if (data) { - setSidekickDetails(data as SidekickMember[]); - } - } - fetchSidekickDetails(); - }, [sidekicks, supabase]); - return (
- {/* Priority Indicator Bar */}
- {/* Task Header */}

- {/* Mobile Move Button */} {columnId && availableColumns.length > 0 && ( {task.priority ?? "Low"}

- {/* PRIMARY AVATAR */}
- {/* Skill Category Badge */} {task.skill_category && (
)} - {/* Task Details */}
{task.difficulty && ( )} - {/* Deadline Badge */} {getDeadlineStatus && ( )} + {sidekickCount > 0 && ( + + +{sidekickCount} sidekick{sidekickCount === 1 ? "" : "s"} + + )}
- - {/* Sidekicks */} - {sidekicks.length > 0 && ( -
- {sidekicks.slice(0, 3).map((sidekickId) => { - const member = sidekickDetails.find((m) => m.id === sidekickId); - return ( -
- {member && member.image_url ? ( - {member.first_name} - ) : ( - <> - - - - )} -
- ); - })} - {sidekicks.length > 3 && ( -
- +{sidekicks.length - 3} -
- )} -
- )}
); } -export default memo(KanbanTask); \ No newline at end of file +export default memo(KanbanTask); diff --git a/apps/codebility/app/home/kanban/[projectId]/[id]/_components/UserTaskFilter.tsx b/apps/codebility/app/home/kanban/[projectId]/[id]/_components/UserTaskFilter.tsx index c5ecf705a..5353e4c1d 100644 --- a/apps/codebility/app/home/kanban/[projectId]/[id]/_components/UserTaskFilter.tsx +++ b/apps/codebility/app/home/kanban/[projectId]/[id]/_components/UserTaskFilter.tsx @@ -13,6 +13,7 @@ interface Member { userName: string; imageUrl?: string | null; isActive: boolean; + isLoading?: boolean; } interface Props { @@ -47,7 +48,7 @@ export default function UserTaskFilter({ members, onFilterClick }: Props) { : shouldUseGrid ? "hover:scale-105" : `hover:scale-105 ${index > 0 ? "sm:-ml-2" : ""}` - }`} + } ${member.isLoading ? "pointer-events-none opacity-60" : ""}`} style={{ zIndex: shouldUseGrid ? 1 : visibleMembers.length - index }} title={member.userName} > @@ -89,7 +90,7 @@ export default function UserTaskFilter({ members, onFilterClick }: Props) { onClick={() => onFilterClick(member.userId)} className={`flex cursor-pointer items-center gap-2 rounded-md px-2 py-1 hover:bg-white/20 dark:hover:bg-white/10 transition-colors ${ member.isActive ? "bg-white/30 dark:bg-white/20" : "" - }`} + } ${member.isLoading ? "pointer-events-none opacity-60" : ""}`} >
{member.imageUrl ? ( diff --git a/apps/codebility/app/home/kanban/[projectId]/[id]/_components/kanban_modals/KanbanAddMembersButton.tsx b/apps/codebility/app/home/kanban/[projectId]/[id]/_components/kanban_modals/KanbanAddMembersButton.tsx index 0fe03ed53..201ce0c90 100644 --- a/apps/codebility/app/home/kanban/[projectId]/[id]/_components/kanban_modals/KanbanAddMembersButton.tsx +++ b/apps/codebility/app/home/kanban/[projectId]/[id]/_components/kanban_modals/KanbanAddMembersButton.tsx @@ -1,7 +1,7 @@ "use client"; import { Button } from "@/components/ui/button"; -import { useModal } from "@/hooks/use-modal-projects"; +import { useModal } from "@/hooks/modals/use-modal-projects"; import { IconAdd } from "@/public/assets/svgs"; export default function KanbanAddMembersButton() { diff --git a/apps/codebility/app/home/kanban/[projectId]/[id]/_components/kanban_modals/KanbanAddMembersModal.tsx b/apps/codebility/app/home/kanban/[projectId]/[id]/_components/kanban_modals/KanbanAddMembersModal.tsx index 80445495f..345c200ff 100644 --- a/apps/codebility/app/home/kanban/[projectId]/[id]/_components/kanban_modals/KanbanAddMembersModal.tsx +++ b/apps/codebility/app/home/kanban/[projectId]/[id]/_components/kanban_modals/KanbanAddMembersModal.tsx @@ -10,7 +10,7 @@ import { getTeamLead, SimpleMemberData, updateProjectMembers, -} from "@/app/home/projects/actions"; +} from "@/actions/projects/actions"; import DefaultAvatar from "@/components/DefaultAvatar"; import { Button } from "@/components/ui/button"; import { @@ -22,7 +22,7 @@ import { } from "@/components/ui/dialog"; import { MemberSelection } from "@/components/ui/MemberSelection"; import { Skeleton } from "@/components/ui/skeleton/skeleton"; -import { useModal } from "@/hooks/use-modal-projects"; +import { useModal } from "@/hooks/modals/use-modal-projects"; import { Codev } from "@/types/home/codev"; import toast from "react-hot-toast"; @@ -139,7 +139,7 @@ export default function KanbanAddMembersModal() { try { const result = await updateProjectMembers( selectedProject, - updatedMembers, + updatedMembers as Codev[], teamLeaderId, ); diff --git a/apps/codebility/app/home/kanban/[projectId]/[id]/_components/kanban_modals/KanbanAddModalMembers.tsx b/apps/codebility/app/home/kanban/[projectId]/[id]/_components/kanban_modals/KanbanAddModalMembers.tsx index 8b2434be8..4f8f90dbc 100644 --- a/apps/codebility/app/home/kanban/[projectId]/[id]/_components/kanban_modals/KanbanAddModalMembers.tsx +++ b/apps/codebility/app/home/kanban/[projectId]/[id]/_components/kanban_modals/KanbanAddModalMembers.tsx @@ -18,10 +18,10 @@ import { SelectTrigger, SelectValue, } from "@/components/ui/select"; -import { useModal } from "@/hooks/use-modal"; +import { useKanbanModal } from "@/hooks/kanban/use-modal-kanban"; +import { useKanbanBoardSync } from "@/hooks/kanban/use-kanban-board-sync"; import { IconPlus } from "@/public/assets/svgs"; import { useUserStore } from "@/store/codev-store"; -import { useKanbanStore } from "@/store/kanban-store"; import { SkillCategory, Task } from "@/types/home/codev"; import { createClientClientComponent } from "@/utils/supabase/client"; import { Ellipsis, Loader2Icon } from "lucide-react"; @@ -42,7 +42,7 @@ import { completeTask, fetchAvailableMembers, updateTaskPRLink, -} from "../../actions"; +} from "@/actions/kanban"; import DifficultyPointsTooltip, { DIFFICULTY_LEVELS, } from "../DifficultyPointsTooltip"; @@ -291,9 +291,9 @@ const TaskViewModal = ({ // ============================================================================ // HOOKS AND STATE // ============================================================================ - const { isOpen, onOpen, onClose, type, data } = useModal(); + const { isOpen, onOpen, onClose, type, data } = useKanbanModal(); const user = useUserStore((state) => state.user); - const { fetchBoardData } = useKanbanStore(); + const { refreshBoard } = useKanbanBoardSync(); // Loading states const [isLoading, setIsLoading] = useState(false); @@ -501,7 +501,7 @@ const TaskViewModal = ({ } onClose(); - await fetchBoardData(); + refreshBoard(); } else { toast.error(result.error || "Failed to complete task"); } @@ -559,7 +559,7 @@ const TaskViewModal = ({ setForceRefreshKey(Date.now().toString()); // Refresh board data - await fetchBoardData(); + refreshBoard(); } else { toast.error("Failed to update assignee"); } @@ -592,13 +592,13 @@ const TaskViewModal = ({ onOpen("taskEditModal", task)} + onClick={() => task && onOpen("taskEditModal", task)} > Edit {user?.role_id !== 4 && ( onOpen("taskDeleteModal", task)} + onClick={() => task && onOpen("taskDeleteModal", task)} className="text-red-500 focus:text-red-500" > Delete diff --git a/apps/codebility/app/home/kanban/[projectId]/[id]/_components/kanban_modals/KanbanColumnAddButton.tsx b/apps/codebility/app/home/kanban/[projectId]/[id]/_components/kanban_modals/KanbanColumnAddButton.tsx index e9018b96d..e55b64d35 100644 --- a/apps/codebility/app/home/kanban/[projectId]/[id]/_components/kanban_modals/KanbanColumnAddButton.tsx +++ b/apps/codebility/app/home/kanban/[projectId]/[id]/_components/kanban_modals/KanbanColumnAddButton.tsx @@ -1,7 +1,7 @@ "use client"; import { Button } from "@/components/ui/button"; -import { useModal } from "@/hooks/use-modal"; +import { useKanbanModal } from "@/hooks/kanban/use-modal-kanban"; import { IconAdd } from "@/public/assets/svgs"; interface KanbanColumnAddButtonProps { @@ -11,7 +11,7 @@ interface KanbanColumnAddButtonProps { export default function KanbanColumnAddButton({ boardId, }: KanbanColumnAddButtonProps) { - const { onOpen } = useModal(); + const { onOpen } = useKanbanModal(); return (
); -// ✅ Team Leader Display +// ✅ Team Leader Display const TeamLeaderDisplay = ({ teamLead, onProfileClick }: { teamLead: SimpleMemberData | null; - onProfileClick?: (member: Codev) => void; + onProfileClick?: (member: { id: string }) => void; }) => (

Team Leader

@@ -139,36 +144,10 @@ const TeamLeaderDisplay = ({ style={{ width: 40, height: 40 }} onClick={(e) => { e.stopPropagation(); + // The handler resolves the full profile by id, so a fabricated Codev + // with two dozen undefined fields was never needed. if (onProfileClick && teamLead) { - const basicCodev: Codev = { - id: teamLead.id, - first_name: teamLead.first_name, - last_name: teamLead.last_name, - email_address: teamLead.email_address, - display_position: teamLead.display_position ?? undefined, - image_url: teamLead.image_url ?? undefined, - username: (teamLead as any).username ?? null, - username_updated_at: (teamLead as any).username_updated_at ?? null, - availability_status: undefined, - internal_status: undefined, - years_of_experience: undefined, - about: undefined, - education: [], - work_experience: [], - projects: [], - tech_stacks: [], - codev_points: [], - positions: [], - github: undefined, - linkedin: undefined, - facebook: undefined, - discord: undefined, - phone_number: undefined, - address: undefined, - role_id: undefined - }; - - onProfileClick(basicCodev); + onProfileClick({ id: teamLead.id }); } }} title={onProfileClick ? "Click to view profile" : undefined} @@ -201,17 +180,17 @@ const TeamLeaderDisplay = ({
); -// ✅ Team Members Grid — flex-based scroll, hover-reveal scrollbar, no cut-offs +// ✅ Team Members Grid — flex-based scroll, hover-reveal scrollbar, no cut-offs const TeamMembersGrid = ({ members, currentMemberIds, onProfileClick, onRemoveMember, }: { - members: Codev[]; + members: MemberListRow[]; currentMemberIds: string[]; - onProfileClick?: (member: Codev) => void; - onRemoveMember?: (member: Codev) => void; + onProfileClick?: (member: { id: string }) => void; + onRemoveMember?: (member: MemberListRow) => void; }) => { const getAvatarSize = () => { if (typeof window !== 'undefined') { @@ -231,8 +210,8 @@ const TeamMembersGrid = ({ {/* - flex-1 + min-h-0 lets the scroll container grow to fill whatever space - the parent left panel gives it — no hardcoded heights, no excess space - - overflowY:'scroll' always reserves the scrollbar gutter → zero layout shift on hover + the parent left panel gives it — no hardcoded heights, no excess space + - overflowY:'scroll' always reserves the scrollbar gutter → zero layout shift on hover - scrollbar track/thumb are transparent by default, visible only on hover via CSS - inner grid overflow:visible so X buttons and avatar rings never clip */} @@ -267,7 +246,7 @@ const TeamMembersGrid = ({ void; - onRemoveMember?: (member: Codev) => void; + onProfileClick?: (member: { id: string }) => void; + onRemoveMember?: (member: MemberListRow) => void; }) => (
@@ -325,10 +304,7 @@ const ProjectPreview = ({
- +
@@ -342,7 +318,7 @@ const ProjectPreview = ({
); -// ✅ Main Modal Component +// ✅ Main Modal Component const AddMembersModal = ({ isOpen, onClose, @@ -364,20 +340,18 @@ const AddMembersModal = ({ const { onOpen: openProfileModal } = useModal(); - const [selectedMembers, setSelectedMembers] = useState([]); + const [selectedMembers, setSelectedMembers] = useState([]); const [searchQuery, setSearchQuery] = useState(""); const [isUpdating, setIsUpdating] = useState(false); - const [availableMembers, setAvailableMembers] = useState([]); - const [recentMembers, setRecentMembers] = useState([]); - const [isLoadingMembers, setIsLoadingMembers] = useState(false); - const [loadError, setLoadError] = useState(null); + const [availableMembersDraft, setAvailableMembersDraft] = useState(null); + const [recentMembers, setRecentMembers] = useState([]); const [activeFilter, setActiveFilter] = useState<'smart' | 'all' | 'mentor' | 'graduated' | 'admin' | 'training'>('smart'); const [filterCounts, setFilterCounts] = useState({ mentor: 0, graduated: 0, admin: 0, training: 0, all: 0 }); const [isSearching, setIsSearching] = useState(false); const searchTimeoutRef = useRef(null); - // ✅ Fetch codevs based on filter with smart defaults - const fetchCodevsByFilter = useCallback(async (filter: 'smart' | 'all' | 'mentor' | 'graduated' | 'admin' | 'training'): Promise => { + // ✅ Fetch codevs based on filter with smart defaults + const fetchCodevsByFilter = useCallback(async (filter: 'smart' | 'all' | 'mentor' | 'graduated' | 'admin' | 'training'): Promise => { if (!supabase) { console.error('Supabase client not available'); return []; @@ -385,11 +359,11 @@ const AddMembersModal = ({ try { const PAGE_SIZE = 1000; - let allData: Codev[] = []; + let allData: MemberListRow[] = []; let from = 0; while (true) { - let query = supabase.from('codev').select('*'); + let query = supabase.from('codev').select(MEMBER_LIST_COLUMNS); // Apply filters based on type switch (filter) { @@ -441,8 +415,8 @@ const AddMembersModal = ({ } }, [supabase]); - // ✅ Fetch recently added members (last 7 days) - const fetchRecentMembers = useCallback(async (): Promise => { + // ✅ Fetch recently added members (last 7 days) + const fetchRecentMembers = useCallback(async (): Promise => { if (!supabase) return []; try { @@ -451,7 +425,7 @@ const AddMembersModal = ({ const { data, error } = await supabase .from('codev') - .select('*') + .select(MEMBER_LIST_COLUMNS) .or('role_id.eq.5,role_id.eq.1,internal_status.eq.GRADUATED,internal_status.eq.TRAINING') .gte('created_at', sevenDaysAgo.toISOString()) .order('created_at', { ascending: false }) @@ -465,7 +439,7 @@ const AddMembersModal = ({ } }, [supabase]); - // ✅ Fetch filter counts + // ✅ Fetch filter counts const fetchFilterCounts = useCallback(async () => { if (!supabase) return; @@ -490,8 +464,8 @@ const AddMembersModal = ({ } }, [supabase]); - // ✅ Server-side search with debouncing - const searchMembers = useCallback(async (query: string): Promise => { + // ✅ Server-side search with debouncing + const searchMembers = useCallback(async (query: string): Promise => { if (!supabase || !query.trim()) return []; try { @@ -501,7 +475,7 @@ const AddMembersModal = ({ // Search all users without status filtering - let user find anyone const { data, error } = await supabase .from('codev') - .select('*') + .select(MEMBER_LIST_COLUMNS) .or(`first_name.ilike.%${searchLower}%,last_name.ilike.%${searchLower}%,display_position.ilike.%${searchLower}%`) .order('first_name', { ascending: true }) .limit(50); @@ -516,7 +490,7 @@ const AddMembersModal = ({ } }, [supabase]); - // ✅ Debounced search handler + // ✅ Debounced search handler const handleSearchChange = useCallback((query: string) => { setSearchQuery(query); @@ -541,7 +515,7 @@ const AddMembersModal = ({ }, 300); }, [activeFilter, fetchCodevsByFilter, searchMembers]); - // ✅ Enhanced profile fetching with timeout protection + // ✅ Enhanced profile fetching with timeout protection const getCompleteCodevProfileSafe = useCallback(async (codevId: string): Promise => { try { if (!supabase) { @@ -601,104 +575,44 @@ const AddMembersModal = ({ } }, [supabase]); - // ✅ Profile click handler - const handleProfileClick = useCallback(async (member: Codev) => { + // ✅ Profile click handler + const handleProfileClick = useCallback(async (member: { id: string }) => { try { const completeProfile = await getCompleteCodevProfileSafe(member.id); - + if (completeProfile) { openProfileModal("profileModal", completeProfile); - } else { - const fallbackProfile: Codev = { - ...member, - years_of_experience: 0, - availability_status: member.availability_status ?? true, - internal_status: (member.internal_status as InternalStatus) ?? 'GRADUATED' - }; - openProfileModal("profileModal", fallbackProfile); } } catch (error) { console.error('Error in profile click handler:', error); - const safeFallback: Codev = { - ...member, - years_of_experience: 0, - availability_status: true, - internal_status: 'GRADUATED' as InternalStatus - }; - openProfileModal("profileModal", safeFallback); } }, [getCompleteCodevProfileSafe, openProfileModal]); - // ✅ Load available members based on active filter - useEffect(() => { - let isMounted = true; - let timeoutId: NodeJS.Timeout; - - const loadMembers = async () => { - if (!isOpen || !supabase) return; - - setIsLoadingMembers(true); - setLoadError(null); - - try { - timeoutId = setTimeout(() => { - if (isMounted) { - throw new Error('Loading timeout'); - } - }, 30000); - - // Load members based on active filter - const users = await fetchCodevsByFilter(activeFilter); - - // Load recent members separately - const recent = await fetchRecentMembers(); - - // Fetch filter counts (non-blocking) - fetchFilterCounts(); - - clearTimeout(timeoutId); - - if (isMounted) { - setAvailableMembers(users || []); - setRecentMembers(recent || []); - setLoadError(null); - } - } catch (error: any) { - console.error('Failed to fetch members:', error); - - if (isMounted) { - const errorMessage = error?.message || 'Failed to load members'; - setLoadError(errorMessage); - - if (errorMessage.includes('timeout')) { - toast.error('Loading members is taking longer than expected. Please try again.'); - } else { - toast.error('Failed to load members. Please try again.'); - } - - setAvailableMembers([]); - } - } finally { - if (isMounted) { - setIsLoadingMembers(false); - } - } - }; + // Loads the roster for the active filter. The query replaces the manual + // isMounted guard and 30s timeout the effect used to keep. + const { data: membersData, isPending: isLoadingMembers, error: membersError, refetch: refetchMembers } = useQuery({ + queryKey: ["myTeam", "addMembers", activeFilter], + enabled: Boolean(isOpen), + staleTime: 60_000, + queryFn: async () => { + const users = await fetchCodevsByFilter(activeFilter); + return users || []; + }, + }); - loadMembers(); + const availableMembers = availableMembersDraft ?? membersData ?? []; + const setAvailableMembers = (members: MemberListRow[]) => + setAvailableMembersDraft(members); + const loadError = membersError + ? membersError instanceof Error + ? membersError.message + : "Failed to load members" + : null; - return () => { - isMounted = false; - if (timeoutId) { - clearTimeout(timeoutId); - } - }; - }, [isOpen, supabase, activeFilter, fetchCodevsByFilter, fetchRecentMembers, fetchFilterCounts]); - - // ✅ Initialize selected members - only on modal open, not when availableMembers changes + // ✅ Initialize selected members - only on modal open, not when availableMembers changes const membersInitialized = useRef(false); - // ✅ Initialize selected members - ONLY when modal opens + // ✅ Initialize selected members - ONLY when modal opens useEffect(() => { if (!isOpen) { membersInitialized.current = false; @@ -712,21 +626,23 @@ const AddMembersModal = ({ // Initialize directly from currentMembers (already fetched from DB by server) // Don't filter from availableMembers because current members might not match the smart filter if (currentMembers && currentMembers.length > 0) { - // Convert SimpleMemberData to Codev format for selectedMembers - const membersAsCodev = currentMembers.map(m => ({ + // SimpleMemberData carries the same fields the picker lists. This used to + // fabricate a Codev via `as unknown as Codev`, inventing username/positions + // values that do not exist on the row. + const membersAsRows: MemberListRow[] = currentMembers.map((m) => ({ id: m.id, first_name: m.first_name, last_name: m.last_name, email_address: m.email_address, display_position: m.display_position, image_url: m.image_url, - username: null, - username_updated_at: null, - positions: [], - tech_stacks: [], - } as unknown as Codev)); + role_id: null, + internal_status: null, + tech_stacks: null, + level: null, + })); - setSelectedMembers(membersAsCodev); + setSelectedMembers(membersAsRows); membersInitialized.current = true; } // Mark as initialized even with no current members (new project) @@ -735,8 +651,8 @@ const AddMembersModal = ({ } }, [isOpen, currentMembers]); - // ✅ Member selection logic - const toggleMember = useCallback((member: Codev) => { + // ✅ Member selection logic + const toggleMember = useCallback((member: MemberListRow) => { setSelectedMembers(prev => { const isSelected = prev.some(m => m.id === member.id); return isSelected @@ -745,8 +661,8 @@ const AddMembersModal = ({ }); }, []); - // ✅ Remove member via X button on avatar - const handleRemoveMember = useCallback((member: Codev) => { + // ✅ Remove member via X button on avatar + const handleRemoveMember = useCallback((member: MemberListRow) => { toggleMember(member); }, [toggleMember]); @@ -760,7 +676,7 @@ const AddMembersModal = ({ [currentMembers] ); - // ✅ Filter available members (client-side filtering when not searching server-side) + // ✅ Filter available members (client-side filtering when not searching server-side) const filteredUsers = useMemo(() => { if (!availableMembers.length) return []; @@ -786,30 +702,17 @@ const AddMembersModal = ({ return baseFiltered; }, [availableMembers, teamLeadData, searchQuery, selectedMembers]); - // ✅ Reset state on close - useEffect(() => { - if (!isOpen) { - setSearchQuery(""); - setSelectedMembers([]); - setLoadError(null); - setIsSearching(false); - - // Clear any pending search timeouts - if (searchTimeoutRef.current) { - clearTimeout(searchTimeoutRef.current); - searchTimeoutRef.current = null; - } - } - }, [isOpen]); + // The parent mounts this modal only while it is open, so the reset-on-close + // effect that used to live here was unreachable: closing unmounts the state. - // ✅ Submit handler + // ✅ Submit handler const handleSubmit = async () => { if (!teamLeadData) { toast.error('Team leader not found'); return; } - console.log('🚀 [AddMembersModal] Starting member update'); + console.log('🚀 [AddMembersModal] Starting member update'); console.log(' Selected members count:', selectedMembers.length); console.log(' Selected member names:', selectedMembers.map(m => `${m.first_name} ${m.last_name}`)); console.log(' Team leader:', `${teamLeadData.first_name} ${teamLeadData.last_name}`); @@ -835,7 +738,7 @@ const AddMembersModal = ({ })), ]; - console.log('📤 [AddMembersModal] Sending to database'); + console.log('📤 [AddMembersModal] Sending to database'); console.log(' Total members (including team leader):', updatedMembers.length); console.log(' Project ID:', project.id); @@ -846,13 +749,13 @@ const AddMembersModal = ({ ); if (result.success) { - console.log('✅ [AddMembersModal] Update successful!'); + console.log('✅ [AddMembersModal] Update successful!'); console.log(' Members added:', selectedMembers.length); toast.success("Team members updated successfully!", { id: loadingToast }); await onUpdate(selectedMembers); onClose(); } else { - console.error('❌ [AddMembersModal] Update failed:', result.error); + console.error('❌ [AddMembersModal] Update failed:', result.error); toast.error(result.error || "Failed to update members", { id: loadingToast }); } } catch (error) { @@ -864,26 +767,20 @@ const AddMembersModal = ({ } }; - // ✅ Retry loading members + // Retry reloads the roster query and refreshes the recent list. const handleRetryLoad = async () => { - setLoadError(null); - setIsLoadingMembers(true); - try { - const users = await fetchCodevsByFilter(activeFilter); - const recent = await fetchRecentMembers(); + const [result, recent] = await Promise.all([ + refetchMembers(), + fetchRecentMembers(), + ]); fetchFilterCounts(); - - setAvailableMembers(users || []); + setAvailableMembers(result.data ?? []); setRecentMembers(recent || []); - setLoadError(null); } catch (error) { console.error('Retry failed:', error); - setLoadError('Failed to load members'); toast.error('Failed to load members. Please try again.'); setAvailableMembers([]); - } finally { - setIsLoadingMembers(false); } }; @@ -1217,4 +1114,4 @@ const AddMembersModal = ({ ); }; -export default AddMembersModal; \ No newline at end of file +export default AddMembersModal; diff --git a/apps/codebility/app/home/my-team/MyTeamPage.tsx b/apps/codebility/app/home/my-team/MyTeamPage.tsx index e5f8f7721..7b128843c 100644 --- a/apps/codebility/app/home/my-team/MyTeamPage.tsx +++ b/apps/codebility/app/home/my-team/MyTeamPage.tsx @@ -1,6 +1,7 @@ // my-team/MyTeamPage.tsx - Proper Client-Side Implementation "use client"; +import { MEMBER_LIST_COLUMNS, type MemberListRow } from "@/lib/shared/member-list"; import { useState, useEffect } from "react"; import Image from "next/image"; import { toast } from "react-hot-toast"; @@ -9,11 +10,11 @@ import { getProjectCodevs, getTeamLead, updateProjectMembers, - SimpleMemberData -} from "@/app/home/projects/actions"; + SimpleMemberData, +} from "@/actions/projects/actions"; import { Codev, InternalStatus } from "@/types/home/codev"; -import { useModal } from "@/hooks/use-modal-users"; -import { createClientClientComponent } from "@/utils/supabase/client"; +import { useModal } from "@/hooks/modals/use-modal-users"; +import { getClientSupabase } from "@/utils/supabase/client"; import AddMembersModal from "./AddMembersModal"; interface ProjectData { @@ -197,18 +198,24 @@ const MyTeamPage = ({ projectData }: MyTeamPageProps) => { const [showAddModal, setShowAddModal] = useState(false); const [isLoadingMembers, setIsLoadingMembers] = useState(false); - const [supabase, setSupabase] = useState(null); - - useEffect(() => { - const supabaseClient = createClientClientComponent(); - setSupabase(supabaseClient); - }, []); + // Created lazily inside the handler below rather than during render. The + // browser client is null on the server, and calling this in the component body + // threw during SSR ("Supabase client is not available"), which silently + // downgraded the whole page to client rendering. + const getSupabase = () => { + try { + return getClientSupabase(); + } catch { + return null; + } + }; // Modal hook for profile integration const { onOpen: openProfileModal } = useModal(); const getCompleteCodevProfileSafe = async (codevId: string): Promise => { try { + const supabase = getSupabase(); if (!supabase) { console.error('Supabase client not available'); return null; @@ -304,7 +311,7 @@ const MyTeamPage = ({ projectData }: MyTeamPageProps) => { setSelectedProject(null); }; - const handleUpdateMembers = async (selectedMembers: Codev[]) => { + const handleUpdateMembers = async (selectedMembers: MemberListRow[]) => { if (!selectedProject) return; try { @@ -463,4 +470,4 @@ const MyTeamPage = ({ projectData }: MyTeamPageProps) => { export default MyTeamPage; // Export types for reusability -export type { ProjectData }; \ No newline at end of file +export type { ProjectData }; diff --git a/apps/codebility/app/home/my-team/[projectId]/_components/AttendanceGrid.tsx b/apps/codebility/app/home/my-team/[projectId]/_components/AttendanceGrid.tsx index 9646e58e0..752d2453d 100644 --- a/apps/codebility/app/home/my-team/[projectId]/_components/AttendanceGrid.tsx +++ b/apps/codebility/app/home/my-team/[projectId]/_components/AttendanceGrid.tsx @@ -1,9 +1,10 @@ "use client"; import { useState, useEffect, useCallback, forwardRef, useImperativeHandle } from "react"; +import { useQuery } from "@tanstack/react-query"; import { ChevronLeft, ChevronRight, Circle, Save, Trophy, AlertTriangle, Calendar } from "lucide-react"; import { Button } from "@/components/ui/button"; -import { SimpleMemberData } from "@/app/home/projects/actions"; +import { SimpleMemberData } from "@/actions/projects/actions"; import { Select, SelectContent, @@ -12,11 +13,10 @@ import { SelectValue, } from "@codevs/ui/select"; import { toast } from "react-hot-toast"; -import { saveAttendance, getMonthlyAttendance, bulkSaveAttendance } from "../actions"; -import { checkAttendanceWarnings } from "../actions/attendance-warnings"; +import { saveAttendance, getMonthlyAttendance, bulkSaveAttendance } from "@/actions/my-team/project"; +import { checkAttendanceWarnings } from "@/actions/my-team/attendance-warnings"; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@codevs/ui/tooltip"; - -const ATTENDANCE_POINTS_PER_DAY = 2; +import { ATTENDANCE_POINTS_PER_DAY } from "@/constants/my-team/attendance"; type AttendanceStatus = "present" | "absent" | "holiday" | "weekend"; @@ -44,17 +44,89 @@ const AttendanceGrid = forwardRef(({ const currentDate = new Date(); const [selectedMonth, setSelectedMonth] = useState(currentDate.getMonth()); const [selectedYear, setSelectedYear] = useState(currentDate.getFullYear()); - const [attendanceData, setAttendanceData] = useState({}); // Safely combine team lead and members const safeTeamMembers = teamMembers || []; const allMembers = teamLead ? [teamLead, ...safeTeamMembers] : safeTeamMembers; - // Get days in selected month - const getDaysInMonth = (year: number, month: number) => { - return new Date(year, month + 1, 0).getDate(); + // Load attendance data from database + const buildAttendanceData = ( + rows: any[] | null | undefined, + members: SimpleMemberData[], + year: number, + month: number, + ): AttendanceData => { + const monthsDays = Array.from( + { length: new Date(year, month + 1, 0).getDate() }, + (_, i) => i + 1, + ); + const data: AttendanceData = {}; + + members.forEach((member) => { + monthsDays.forEach((day) => { + const dayOfWeek = new Date(year, month, day).getDay(); + const dateKey = `${member.id}-${year}-${String(month + 1).padStart(2, "0")}-${String(day).padStart(2, "0")}`; + if (dayOfWeek === 0 || dayOfWeek === 6) { + data[dateKey] = "weekend"; + } else { + data[dateKey] = "absent"; + } + }); + }); + + rows?.forEach((record: any) => { + data[`${record.codev_id}-${record.date}`] = record.status; + }); + + return data; + }; + + const { data: attendanceRows, isPending: isLoadingAttendance } = useQuery({ + queryKey: [ + "myTeam", + "attendance", + projectId, + selectedYear, + selectedMonth, + allMembers.map((m) => m.id).join(","), + ], + enabled: allMembers.length > 0, + staleTime: 60_000, + queryFn: async () => { + const result = await getMonthlyAttendance(projectId, selectedYear, selectedMonth); + return result.success ? (result.data ?? []) : []; + }, + }); + + // The fetched rows seed an editable draft. The month is stored with the draft + // so a month change re-derives it during render instead of in an effect, and + // edits made in the meantime are not thrown away. + const draftKey = `${selectedYear}-${selectedMonth}-${allMembers.length}`; + const [draft, setDraft] = useState<{ key: string; data: AttendanceData }>({ + key: "", + data: {}, + }); + + const attendanceData = + draft.key === draftKey + ? draft.data + : buildAttendanceData(attendanceRows, allMembers, selectedYear, selectedMonth); + + const setAttendanceData = ( + update: AttendanceData | ((prev: AttendanceData) => AttendanceData), + ) => { + const next = + typeof update === "function" ? update(attendanceData) : update; + setDraft({ key: draftKey, data: next }); }; + const isLoading = isLoadingAttendance && allMembers.length === 0; + const [isSaving, setIsSaving] = useState(false); + const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false); + + const getDaysInMonth = (year: number, month: number) => + new Date(year, month + 1, 0).getDate(); + const daysInMonth = getDaysInMonth(selectedYear, selectedMonth); const monthDays = Array.from({ length: daysInMonth }, (_, i) => i + 1); @@ -69,69 +141,6 @@ const AttendanceGrid = forwardRef(({ "July", "August", "September", "October", "November", "December" ]; - const [isLoading, setIsLoading] = useState(false); - const [isSaving, setIsSaving] = useState(false); - const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false); - - // Load attendance data from database - useEffect(() => { - const loadAttendance = async () => { - setIsLoading(true); - try { - const result = await getMonthlyAttendance(projectId, selectedYear, selectedMonth); - - if (result.success && result.data) { - const dbData: AttendanceData = {}; - - // First, set all weekends - allMembers.forEach(member => { - monthDays.forEach(day => { - const dayOfWeek = getDayOfWeek(selectedYear, selectedMonth, day); - const dateKey = `${member.id}-${selectedYear}-${String(selectedMonth + 1).padStart(2, '0')}-${String(day).padStart(2, '0')}`; - - if (dayOfWeek === 0 || dayOfWeek === 6) { - dbData[dateKey] = "weekend"; - } - }); - }); - - // Then apply saved attendance data - result.data.forEach((record: any) => { - const dateKey = `${record.codev_id}-${record.date}`; - dbData[dateKey] = record.status; - }); - - setAttendanceData(dbData); - } else { - // Generate default data if no records exist - const defaultData: AttendanceData = {}; - allMembers.forEach(member => { - monthDays.forEach(day => { - const dayOfWeek = getDayOfWeek(selectedYear, selectedMonth, day); - const dateKey = `${member.id}-${selectedYear}-${String(selectedMonth + 1).padStart(2, '0')}-${String(day).padStart(2, '0')}`; - - if (dayOfWeek === 0 || dayOfWeek === 6) { - defaultData[dateKey] = "weekend"; - } else { - defaultData[dateKey] = "absent"; - } - }); - }); - setAttendanceData(defaultData); - } - } catch (error) { - console.error("Error loading attendance:", error); - toast.error("Failed to load attendance data"); - } finally { - setIsLoading(false); - } - }; - - if (allMembers.length > 0) { - loadAttendance(); - } - }, [selectedMonth, selectedYear, allMembers.length, projectId]); - // Toggle attendance status const toggleAttendance = (memberId: string, day: number) => { if (readOnly) { diff --git a/apps/codebility/app/home/my-team/[projectId]/_components/AttendanceSync.tsx b/apps/codebility/app/home/my-team/[projectId]/_components/AttendanceSync.tsx index b8772bd9e..1215f5007 100644 --- a/apps/codebility/app/home/my-team/[projectId]/_components/AttendanceSync.tsx +++ b/apps/codebility/app/home/my-team/[projectId]/_components/AttendanceSync.tsx @@ -4,7 +4,7 @@ import { useState } from "react"; import { Button } from "@/components/ui/button"; import { RefreshCw } from "lucide-react"; import { toast } from "react-hot-toast"; -import { syncAttendancePoints } from "../actions/attendance-sync"; +import { syncAttendancePoints } from "@/actions/my-team/attendance-sync"; interface AttendanceSyncProps { codevId: string; diff --git a/apps/codebility/app/home/my-team/[projectId]/_components/AttendanceTracker.tsx b/apps/codebility/app/home/my-team/[projectId]/_components/AttendanceTracker.tsx index 61737e841..b7cd294cb 100644 --- a/apps/codebility/app/home/my-team/[projectId]/_components/AttendanceTracker.tsx +++ b/apps/codebility/app/home/my-team/[projectId]/_components/AttendanceTracker.tsx @@ -16,7 +16,7 @@ import { SelectTrigger, SelectValue, } from "@codevs/ui/select"; -import { SimpleMemberData } from "@/app/home/projects/actions"; +import { SimpleMemberData } from "@/actions/projects/actions"; import { toast } from "react-hot-toast"; type AttendanceStatus = "present" | "absent" | "late" | "break" | "off"; @@ -38,7 +38,9 @@ interface AttendanceTrackerProps { const AttendanceTracker = ({ teamMembers, teamLead, projectId }: AttendanceTrackerProps) => { const [isOpen, setIsOpen] = useState(false); - const [selectedDate, setSelectedDate] = useState(new Date().toISOString().split('T')[0]); + const [selectedDate, setSelectedDate] = useState( + () => new Date().toISOString().slice(0, 10), + ); const [attendanceRecords, setAttendanceRecords] = useState>({}); const [isLoading, setIsLoading] = useState(false); diff --git a/apps/codebility/app/home/my-team/[projectId]/_components/AttendanceWarningBanner.tsx b/apps/codebility/app/home/my-team/[projectId]/_components/AttendanceWarningBanner.tsx index 50d9b3851..d1891dd51 100644 --- a/apps/codebility/app/home/my-team/[projectId]/_components/AttendanceWarningBanner.tsx +++ b/apps/codebility/app/home/my-team/[projectId]/_components/AttendanceWarningBanner.tsx @@ -1,9 +1,10 @@ "use client"; -import { useState, useEffect } from "react"; +import { useState } from "react"; import { AlertTriangle, X } from "lucide-react"; import { Button } from "@codevs/ui/button"; -import { getAttendanceWarningStatus } from "../actions/attendance-warnings"; +import { useQuery } from "@tanstack/react-query"; +import { getAttendanceWarningStatus } from "@/actions/my-team/attendance-warnings"; interface AttendanceWarningBannerProps { projectId: string; @@ -11,38 +12,32 @@ interface AttendanceWarningBannerProps { } export default function AttendanceWarningBanner({ projectId, isTeamLead }: AttendanceWarningBannerProps) { - const [warningData, setWarningData] = useState(null); - const [isLoading, setIsLoading] = useState(true); const [isVisible, setIsVisible] = useState(true); - useEffect(() => { - const loadWarnings = async () => { - if (!isTeamLead) { - setIsLoading(false); - return; - } - - const currentDate = new Date(); - const result = await getAttendanceWarningStatus( - projectId, - currentDate.getFullYear(), - currentDate.getMonth() - ); + // The month is part of the key: it changes which absences are counted, so a + // key without it would serve last month's warnings. + const now = new Date(); + const year = now.getFullYear(); + const month = now.getMonth(); - if (result.success && result.summary?.membersWithWarnings > 0) { - setWarningData(result); + const { data: warningData } = useQuery({ + queryKey: ["myTeam", "attendanceWarnings", projectId, year, month], + enabled: isTeamLead, + staleTime: 60_000, + queryFn: async () => { + const result = await getAttendanceWarningStatus(projectId, year, month); + if (!result.success || (result.summary?.membersWithWarnings ?? 0) === 0) { + return null; } - setIsLoading(false); - }; - - loadWarnings(); - }, [projectId, isTeamLead]); + return result; + }, + }); - if (!isTeamLead || isLoading || !warningData || !isVisible) { + if (!isTeamLead || !warningData || !isVisible) { return null; } - const { summary, data } = warningData; + const { summary, data } = warningData as { summary: any; data: any[] }; const membersWithWarnings = data.filter((m: any) => m.hasWarning); return ( diff --git a/apps/codebility/app/home/my-team/[projectId]/_components/ChecklistStatusBanner.tsx b/apps/codebility/app/home/my-team/[projectId]/_components/ChecklistStatusBanner.tsx index 7076b15a4..d443f27f3 100644 --- a/apps/codebility/app/home/my-team/[projectId]/_components/ChecklistStatusBanner.tsx +++ b/apps/codebility/app/home/my-team/[projectId]/_components/ChecklistStatusBanner.tsx @@ -1,9 +1,10 @@ "use client"; -import { useState, useEffect } from "react"; +import { useState } from "react"; import { ChevronDown, ChevronUp, AlertCircle, CheckCircle, User } from "lucide-react"; -import { SimpleMemberData, getMembers, getTeamLead } from "@/app/home/projects/actions"; -import { createClientClientComponent } from "@/utils/supabase/client"; +import { SimpleMemberData } from "@/actions/projects/actions"; +import { getClientSupabase } from "@/utils/supabase/client"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; /** * ChecklistStatusBanner - COMPLETE FIX FOR MISSING MEMBERS @@ -35,124 +36,69 @@ interface MemberChecklistStatus { pendingItems: number; } -const ChecklistStatusBanner = ({ projectId }: ChecklistStatusBannerProps) => { - const [isExpanded, setIsExpanded] = useState(false); - const [isLoading, setIsLoading] = useState(true); - const [memberStatuses, setMemberStatuses] = useState([]); - - const [freshTeamMembers, setFreshTeamMembers] = useState([]); - const [freshTeamLead, setFreshTeamLead] = useState(null); - const [isFetchingMembers, setIsFetchingMembers] = useState(false); - - useEffect(() => { - if (projectId) { - fetchFreshMemberData(); - } - }, [projectId]); - - const fetchFreshMemberData = async () => { - - setIsFetchingMembers(true); - - try { - const teamLeadResult = await getTeamLead(projectId); - if (teamLeadResult.data) { - setFreshTeamLead(teamLeadResult.data); - } +const loadChecklistStatuses = async ( + projectId: string, + allMembers: { id: string; first_name: string; last_name: string }[], +): Promise => { + const supabase = getClientSupabase(); - const membersResult = await getMembers(projectId); - if (membersResult.data) { - setFreshTeamMembers(membersResult.data); - } - } catch (error) { - // Only log errors in development - if (process.env.NODE_ENV === 'development') { - console.error("❌ Banner - Error fetching fresh member data:", error); - } - } finally { - setIsFetchingMembers(false); - } - }; - - const allMembers = freshTeamLead - ? [freshTeamLead, ...freshTeamMembers] - : freshTeamMembers; + const { data: checklistData, error } = await supabase + .from("member_checklists") + .select("member_id, title, completed") + .eq("project_id", projectId); - useEffect(() => { - if (!isFetchingMembers && allMembers.length > 0) { - loadChecklistStatuses(); + if (error) { + if (process.env.NODE_ENV === "development") { + console.error("Banner - Error loading checklist data:", error); } - }, [projectId, allMembers.length, isFetchingMembers]); - - const loadChecklistStatuses = async () => { - setIsLoading(true); - const supabase = createClientClientComponent(); + return []; + } - if (!supabase) { - // Only log in development - if (process.env.NODE_ENV === 'development') { - console.error("Failed to initialize Supabase client"); - } - setIsLoading(false); - return; + const statusMap: { [key: string]: MemberChecklistStatus } = {}; + + allMembers.forEach((member) => { + const memberItems = + checklistData?.filter((item) => item.member_id === member.id) || []; + const completedCount = memberItems.filter((item) => item.completed).length; + + statusMap[member.id] = { + memberId: member.id, + memberName: `${member.first_name} ${member.last_name}`, + totalItems: memberItems.length, + completedItems: completedCount, + pendingItems: memberItems.length - completedCount, + }; + }); + + return Object.values(statusMap).sort((a, b) => { + if (a.pendingItems !== b.pendingItems) { + return b.pendingItems - a.pendingItems; } + return a.memberName.localeCompare(b.memberName); + }); +}; - try { - - // Get all checklist items for this project - const { data: checklistData, error } = await supabase - .from("member_checklists") - .select("member_id, title, completed") - .eq("project_id", projectId); - - if (error) { - // Only log errors in development - if (process.env.NODE_ENV === 'development') { - console.error("❌ Banner - Error loading checklist data:", error); - } - return; - } - - // 🔧 CRITICAL FIX: Create status for ALL members, not just those with items - const statusMap: { [key: string]: MemberChecklistStatus } = {}; - - allMembers.forEach(member => { - const memberItems = checklistData?.filter(item => item.member_id === member.id) || []; - const completedCount = memberItems.filter(item => item.completed).length; - - // Create status entry for EVERY member, even if they have 0 items - statusMap[member.id] = { - memberId: member.id, - memberName: `${member.first_name} ${member.last_name}`, - totalItems: memberItems.length, - completedItems: completedCount, - pendingItems: memberItems.length - completedCount - }; - - }); - - // 🔧 CRITICAL FIX: Don't filter out members with 0 items - // Show ALL members so the count is accurate (10/10 instead of 7/9) - const statusArray = Object.values(statusMap) - .sort((a, b) => { - // Sort by: pending items (desc), then by name - if (a.pendingItems !== b.pendingItems) { - return b.pendingItems - a.pendingItems; - } - return a.memberName.localeCompare(b.memberName); - }); - - setMemberStatuses(statusArray); - - } catch (error) { - // Only log errors in development - if (process.env.NODE_ENV === 'development') { - console.error("❌ Banner - Error loading checklist statuses:", error); - } - } finally { - setIsLoading(false); - } - }; +const ChecklistStatusBanner = ({ + projectId, + teamMembers = [], + teamLead = null, +}: ChecklistStatusBannerProps) => { + const [isExpanded, setIsExpanded] = useState(false); + const queryClient = useQueryClient(); + + const allMembers = teamLead ? [teamLead, ...teamMembers] : teamMembers; + + const { data: memberStatuses = [], isPending: isLoading } = useQuery({ + queryKey: [ + "myTeam", + "checklistStatuses", + projectId, + allMembers.map((m) => m.id).join(","), + ], + enabled: Boolean(projectId), + staleTime: 60_000, + queryFn: () => loadChecklistStatuses(projectId, allMembers), + }); // Calculate summary stats const membersWithPending = memberStatuses.filter(s => s.pendingItems > 0).length; @@ -164,7 +110,7 @@ const ChecklistStatusBanner = ({ projectId }: ChecklistStatusBannerProps) => { return s.pendingItems === 0; }).length; - if (isFetchingMembers) { + if (isLoading && memberStatuses.length === 0) { return (
@@ -295,8 +241,9 @@ const ChecklistStatusBanner = ({ projectId }: ChecklistStatusBannerProps) => { }`}>
) : items.length === 0 ? (
-
📝
+
📝

No checklist items yet

{isTeamLead && (

Add your first item above

diff --git a/apps/codebility/app/home/my-team/_components/MemberChecklist.tsx b/apps/codebility/app/home/my-team/_components/MemberChecklist.tsx index cbbcee702..442a7d839 100644 --- a/apps/codebility/app/home/my-team/_components/MemberChecklist.tsx +++ b/apps/codebility/app/home/my-team/_components/MemberChecklist.tsx @@ -1,8 +1,10 @@ "use client"; -import { useState, useEffect } from "react"; +import { useState } from "react"; import { CheckCircle2, Circle, Lock, Loader2 } from "lucide-react"; -import { createClientClientComponent } from "@/utils/supabase/client"; +import { getClientSupabase } from "@/utils/supabase/client"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; +import { useUserStore } from "@/store/codev-store"; import toast from "react-hot-toast"; /** @@ -53,219 +55,217 @@ interface MemberChecklistProps { const parseTargetRole = (description: string | null): string | null => { if (!description) return null; try { - const parsed = JSON.parse(description); + const parsed = JSON.parse(description) as { target_role?: string | null }; return parsed?.target_role ?? null; } catch { return null; } }; -const MemberChecklist = ({ + +const loadChecklistItems = async ({ + projectId, memberId, - projectId, - isTeamLead: _unusedProp, - viewMode = 'profile' -}: MemberChecklistProps) => { - const [memberStatuses, setMemberStatuses] = useState([]); - const [isLoading, setIsLoading] = useState(true); - const [supabase, setSupabase] = useState(null); - const [currentCodevId, setCurrentCodevId] = useState(null); - const [isCurrentUserTeamLead, setIsCurrentUserTeamLead] = useState(false); - - useEffect(() => { - const client = createClientClientComponent(); - setSupabase(client); + viewMode, + currentCodevId, + isCurrentUserTeamLead, +}: { + projectId: string; + memberId: string; + viewMode: "profile" | "team"; + currentCodevId: string; + isCurrentUserTeamLead: boolean; +}): Promise => { + const supabase = getClientSupabase(); + + try { + let membersToShow: string[] = []; - if (client) { - client.auth.getSession().then(async ({ data: { session } }) => { - if (session?.user?.email) { - const { data: codevData } = await client - .from("codev") - .select("id") - .eq("email_address", session.user.email) - .single(); - - if (codevData) { - setCurrentCodevId(codevData.id); - } - } - }); + if (viewMode === 'profile') { + membersToShow = [memberId]; + } else if (viewMode === 'team') { + if (isCurrentUserTeamLead) { + const { data: projectMembers, error: membersError } = await supabase + .from("project_members") + .select("codev_id") + .eq("project_id", projectId); + + if (membersError) throw membersError; + + membersToShow = projectMembers + ?.map((pm: any) => pm.codev_id) + .filter((id: string) => id != null && id !== undefined && id !== '') || []; + } else { + membersToShow = [currentCodevId]; + } } - }, []); - - useEffect(() => { - const checkTeamLeadStatus = async () => { - if (!supabase || !currentCodevId || !projectId) return; - const { data: projectMember } = await supabase - .from("project_members") - .select("role") - .eq("project_id", projectId) - .eq("codev_id", currentCodevId) - .single(); + if (membersToShow.length === 0) { + return []; + } - setIsCurrentUserTeamLead(projectMember?.role === "team_leader"); - }; + const { data: allChecklistItems, error: checklistError } = await supabase + .from("member_checklists") + .select("id, member_id, project_id, title, description, priority, completed, created_by, due_date, created_at, updated_at") + .eq("project_id", projectId) + .order("created_at", { ascending: true }); - checkTeamLeadStatus(); - }, [supabase, currentCodevId, projectId]); + if (checklistError) throw checklistError; - useEffect(() => { - if (supabase && currentCodevId && projectId) { - loadChecklistItems(); - } - }, [supabase, currentCodevId, projectId, isCurrentUserTeamLead, memberId, viewMode]); - - const loadChecklistItems = async () => { - if (!supabase || !projectId || !currentCodevId) return; - - setIsLoading(true); - try { - let membersToShow: string[] = []; + const uniqueTitlesMap = new Map(); + allChecklistItems?.forEach((item: ChecklistItem) => { + const existing = uniqueTitlesMap.get(item.title); - if (viewMode === 'profile') { - membersToShow = [memberId]; - } else if (viewMode === 'team') { - if (isCurrentUserTeamLead) { - const { data: projectMembers, error: membersError } = await supabase - .from("project_members") - .select("codev_id") - .eq("project_id", projectId); - - if (membersError) throw membersError; - - membersToShow = projectMembers - ?.map((pm: any) => pm.codev_id) - .filter((id: string) => id != null && id !== undefined && id !== '') || []; - } else { - membersToShow = [currentCodevId]; + if (!existing) { + uniqueTitlesMap.set(item.title, { + title: item.title, + description: item.description, + target_role: parseTargetRole(item.description), + priority: item.priority, + due_date: item.due_date, + created_by: item.created_by, + created_at: item.created_at + }); + } else { + const updatedItem = { ...existing }; + + if (item.description && !existing.description) { + updatedItem.description = item.description; + updatedItem.target_role = parseTargetRole(item.description); } + + if (new Date(item.created_at) < new Date(existing.created_at)) { + updatedItem.created_at = item.created_at; + updatedItem.created_by = item.created_by; + } + + uniqueTitlesMap.set(item.title, updatedItem); } + }); + + const uniqueTitles = Array.from(uniqueTitlesMap.values()); - if (membersToShow.length === 0) { - setMemberStatuses([]); - setIsLoading(false); - return; - } + const { data: memberDetails, error: memberDetailsError } = await supabase + .from("codev") + .select("id, first_name, last_name, email_address, image_url, display_position") + .in("id", membersToShow); - const { data: allChecklistItems, error: checklistError } = await supabase - .from("member_checklists") - .select("*") - .eq("project_id", projectId) - .order("created_at", { ascending: true }); + if (memberDetailsError) throw memberDetailsError; - if (checklistError) throw checklistError; + const statuses: MemberChecklistStatus[] = []; + + for (const member of (memberDetails || [])) { + const memberItems = allChecklistItems?.filter( + (item: ChecklistItem) => item.member_id === member.id + ) || []; - const uniqueTitlesMap = new Map(); - allChecklistItems?.forEach((item: ChecklistItem) => { - const existing = uniqueTitlesMap.get(item.title); - - if (!existing) { - uniqueTitlesMap.set(item.title, { - title: item.title, - description: item.description, - target_role: parseTargetRole(item.description), - priority: item.priority, - due_date: item.due_date, - created_by: item.created_by, - created_at: item.created_at - }); - } else { - const updatedItem = { ...existing }; - - if (item.description && !existing.description) { - updatedItem.description = item.description; - updatedItem.target_role = parseTargetRole(item.description); - } - - if (new Date(item.created_at) < new Date(existing.created_at)) { - updatedItem.created_at = item.created_at; - updatedItem.created_by = item.created_by; - } - - uniqueTitlesMap.set(item.title, updatedItem); - } - }); + const memberChecklistItems: ChecklistItem[] = []; - const uniqueTitles = Array.from(uniqueTitlesMap.values()); - - const { data: memberDetails, error: memberDetailsError } = await supabase - .from("codev") - .select("id, first_name, last_name, email_address, image_url, display_position") - .in("id", membersToShow); - - if (memberDetailsError) throw memberDetailsError; + for (const templateItem of uniqueTitles) { + // Skip items targeted to a different role than this member's + if (templateItem.target_role && member.display_position !== templateItem.target_role) { + continue; + } - const statuses: MemberChecklistStatus[] = []; - - for (const member of (memberDetails || [])) { - const memberItems = allChecklistItems?.filter( - (item: ChecklistItem) => item.member_id === member.id - ) || []; + const existingItem = memberItems.find( + (item: ChecklistItem) => item.title === templateItem.title + ); - const memberChecklistItems: ChecklistItem[] = []; - - for (const templateItem of uniqueTitles) { - // Skip items targeted to a different role than this member's - if (templateItem.target_role && member.display_position !== templateItem.target_role) { - continue; - } - - const existingItem = memberItems.find( - (item: ChecklistItem) => item.title === templateItem.title - ); - - if (existingItem) { - memberChecklistItems.push(existingItem); - } else { - memberChecklistItems.push({ - id: `placeholder-${member.id}-${templateItem.title}`, - member_id: member.id, - project_id: projectId, - title: templateItem.title, - description: templateItem.description, - priority: templateItem.priority, - completed: false, - created_by: templateItem.created_by, - due_date: templateItem.due_date, - created_at: templateItem.created_at, - updated_at: templateItem.created_at, - isPlaceholder: true - }); - } + if (existingItem) { + memberChecklistItems.push(existingItem); + } else { + memberChecklistItems.push({ + id: `placeholder-${member.id}-${templateItem.title}`, + member_id: member.id, + project_id: projectId, + title: templateItem.title, + description: templateItem.description, + priority: templateItem.priority, + completed: false, + created_by: templateItem.created_by, + due_date: templateItem.due_date, + created_at: templateItem.created_at, + updated_at: templateItem.created_at, + isPlaceholder: true + }); } + } - const completedCount = memberChecklistItems.filter(item => item.completed).length; - const memberTotalCount = memberChecklistItems.length; + const completedCount = memberChecklistItems.filter(item => item.completed).length; + const memberTotalCount = memberChecklistItems.length; - statuses.push({ - member, - completedCount, - totalCount: memberTotalCount, - allComplete: completedCount === memberTotalCount && memberTotalCount > 0, - checklistItems: memberChecklistItems - }); + statuses.push({ + member, + completedCount, + totalCount: memberTotalCount, + allComplete: completedCount === memberTotalCount && memberTotalCount > 0, + checklistItems: memberChecklistItems + }); + } + + statuses.sort((a, b) => { + if (a.allComplete !== b.allComplete) { + return a.allComplete ? 1 : -1; } + return `${a.member.first_name} ${a.member.last_name}`.localeCompare( + `${b.member.first_name} ${b.member.last_name}` + ); + }); - statuses.sort((a, b) => { - if (a.allComplete !== b.allComplete) { - return a.allComplete ? 1 : -1; - } - return `${a.member.first_name} ${a.member.last_name}`.localeCompare( - `${b.member.first_name} ${b.member.last_name}` - ); - }); + return statuses; - setMemberStatuses(statuses); + } catch (error) { + console.error("❌ Error loading checklist:", error); + toast.error("Failed to load checklist items"); + return []; + } +}; - } catch (error) { - console.error("❌ Error loading checklist:", error); - toast.error("Failed to load checklist items"); - setMemberStatuses([]); - } finally { - setIsLoading(false); - } - }; +const MemberChecklist = ({ + memberId, + projectId, + isTeamLead: _unusedProp, + viewMode = "profile", +}: MemberChecklistProps) => { + const currentCodevId = useUserStore((s) => s.user?.id ?? null); + const queryClient = useQueryClient(); + const supabase = getClientSupabase(); + + const { data: isCurrentUserTeamLead = false } = useQuery({ + queryKey: ["myTeam", "teamLead", projectId, currentCodevId], + enabled: Boolean(currentCodevId && projectId), + staleTime: 5 * 60_000, + queryFn: async () => { + const { data } = await getClientSupabase() + .from("project_members") + .select("role") + .eq("project_id", projectId) + .eq("codev_id", currentCodevId) + .single(); + return data?.role === "team_leader"; + }, + }); + + const { data: memberStatuses = [], isPending: isLoading } = useQuery({ + queryKey: [ + "myTeam", + "checklist", + projectId, + memberId, + viewMode, + currentCodevId, + isCurrentUserTeamLead, + ], + enabled: Boolean(currentCodevId && projectId), + queryFn: () => + loadChecklistItems({ + projectId, + memberId, + viewMode, + currentCodevId: currentCodevId as string, + isCurrentUserTeamLead, + }), + }); const toggleComplete = async ( memberStatus: MemberChecklistStatus, @@ -317,26 +317,7 @@ const MemberChecklist = ({ return; } - setMemberStatuses(prev => - prev.map(ms => { - if (ms.member.id === targetMemberId) { - const updatedItems = ms.checklistItems.map(i => - i.title === itemTitle - ? { ...newItem, isPlaceholder: false } - : i - ); - const completedCount = updatedItems.filter(i => i.completed).length; - return { - ...ms, - checklistItems: updatedItems, - completedCount, - allComplete: completedCount === ms.totalCount && ms.totalCount > 0 - }; - } - return ms; - }) - ); - + await queryClient.invalidateQueries({ queryKey: ["myTeam", "checklist"] }); toast.success(`${itemTitle} marked as ${newStatus ? 'completed' : 'incomplete'}`); return; } @@ -355,26 +336,8 @@ const MemberChecklist = ({ toast.error("Failed to update checklist"); return; } - - setMemberStatuses(prev => - prev.map(ms => { - if (ms.member.id === targetMemberId) { - const updatedItems = ms.checklistItems.map(item => - item.id === itemId - ? { ...item, completed: newStatus } - : item - ); - const completedCount = updatedItems.filter(i => i.completed).length; - return { - ...ms, - checklistItems: updatedItems, - completedCount, - allComplete: completedCount === ms.totalCount && ms.totalCount > 0 - }; - } - return ms; - }) - ); + + await queryClient.invalidateQueries({ queryKey: ["myTeam", "checklist"] }); toast.success(`${itemTitle} marked as ${newStatus ? 'completed' : 'incomplete'}`); @@ -605,4 +568,4 @@ const MemberChecklist = ({ ); }; -export default MemberChecklist; \ No newline at end of file +export default MemberChecklist; diff --git a/apps/codebility/app/home/my-team/_components/MemberDetailModal.tsx b/apps/codebility/app/home/my-team/_components/MemberDetailModal.tsx index af65462b2..79a6718e3 100644 --- a/apps/codebility/app/home/my-team/_components/MemberDetailModal.tsx +++ b/apps/codebility/app/home/my-team/_components/MemberDetailModal.tsx @@ -1,8 +1,8 @@ // apps/codebility/app/home/my-team/_components/MemberDetailModal.tsx "use client"; -import { useState, useEffect } from "react"; import Image from "next/image"; +import { useQuery } from "@tanstack/react-query"; import DefaultAvatar from "@/components/DefaultAvatar"; import { Dialog, @@ -10,7 +10,7 @@ import { DialogHeader, DialogTitle, } from "@/components/ui/dialog"; -import { SimpleMemberData } from "@/app/home/projects/actions"; +import { SimpleMemberData } from "@/actions/projects/actions"; import { Mail, Calendar, @@ -40,35 +40,23 @@ const MemberDetailModal = ({ projectId, onClose }: MemberDetailModalProps) => { - const [memberPoints, setMemberPoints] = useState(null); - const [isLoadingPoints, setIsLoadingPoints] = useState(false); - - // Load member points for the header display - useEffect(() => { - if (isOpen && member) { - loadMemberPoints(); - } - }, [isOpen, member]); - - const loadMemberPoints = async () => { - if (!member) return; - - setIsLoadingPoints(true); - try { - const response = await fetch(`/api/codev/${member.id}/points`); - if (response.ok) { - const data = await response.json() as { totalPoints?: number; attendancePoints?: number }; - setMemberPoints({ - totalPoints: data.totalPoints || 0, - attendancePoints: data.attendancePoints || 0 - }); - } - } catch (error) { - console.error("Error loading member points:", error); - } finally { - setIsLoadingPoints(false); - } - }; + const { data: memberPoints = null } = useQuery({ + queryKey: ["myTeam", "memberPoints", member?.id], + enabled: Boolean(isOpen && member), + staleTime: 60_000, + queryFn: async (): Promise => { + const response = await fetch(`/api/codev/${member!.id}/points`); + if (!response.ok) return null; + const data = (await response.json()) as { + totalPoints?: number; + attendancePoints?: number; + }; + return { + totalPoints: data.totalPoints || 0, + attendancePoints: data.attendancePoints || 0, + }; + }, + }); if (!isOpen || !member) return null; diff --git a/apps/codebility/app/home/my-team/_components/MemberRating.tsx b/apps/codebility/app/home/my-team/_components/MemberRating.tsx index 2996ffb63..1138bc09a 100644 --- a/apps/codebility/app/home/my-team/_components/MemberRating.tsx +++ b/apps/codebility/app/home/my-team/_components/MemberRating.tsx @@ -4,7 +4,9 @@ import { useState, useEffect } from "react"; import { Star, Loader2, Lock } from "lucide-react"; import { Button } from "@/components/ui/button"; -import { createClientClientComponent } from "@/utils/supabase/client"; +import { createClientClientComponent, getClientSupabase } from "@/utils/supabase/client"; +import { useQuery } from "@tanstack/react-query"; +import { useUserStore } from "@/store/codev-store"; import toast from "react-hot-toast"; // Define the 5 performance criteria with icons @@ -40,11 +42,11 @@ interface MemberRatingProps { } const MemberRating = ({ memberId, projectId }: MemberRatingProps) => { - // Supabase client and user state - const [supabase, setSupabase] = useState(null); - const [currentCodevId, setCurrentCodevId] = useState(null); // Changed from currentUserId - const [isTeamLead, setIsTeamLead] = useState(false); - const [isLoading, setIsLoading] = useState(true); + const currentCodevId = useUserStore((s) => s.user?.id ?? null); + // Resolved lazily inside the query and the handler, never during render. The + // browser client is null on the server, so resolving it in the component body + // threw inside SSR and silently downgraded the page to client rendering. + const getSupabase = () => createClientClientComponent(); const [isSaving, setIsSaving] = useState(false); // Rating state - default to 0 for all criteria @@ -59,88 +61,54 @@ const MemberRating = ({ memberId, projectId }: MemberRatingProps) => { const [feedback, setFeedback] = useState(""); const [existingRatingId, setExistingRatingId] = useState(null); - // Initialize Supabase client and get current user's codev_id - useEffect(() => { - const client = createClientClientComponent(); - setSupabase(client); - - if (client) { - // Get current user session and map to codev_id - client.auth.getSession().then(async ({ data: { session } }) => { - - if (session?.user?.email) { - // Map auth user email to codev_id - const { data: codevData, error } = await client - .from("codev") - .select("id") - .eq("email_address", session.user.email) - .single(); - - if (codevData) { - setCurrentCodevId(codevData.id); - } - } - }); - } - }, []); - - // Check if current user is team lead and load existing ratings - useEffect(() => { - if (!supabase || !currentCodevId || !projectId) { - return; - } - - const checkPermissionsAndLoadRatings = async () => { - setIsLoading(true); - - try { - - // Check if current user is team lead for this project - const { data: projectMember, error: roleError } = await supabase - .from("project_members") - .select("role") - .eq("project_id", projectId) - .eq("codev_id", currentCodevId) // Now using correct codev_id - .single(); + // One query replaces the session lookup, the permission check and the rating + // load; its key carries every input that changes the result. + const { data, isPending: isLoading } = useQuery({ + queryKey: ["myTeam", "rating", projectId, memberId, currentCodevId], + enabled: Boolean(currentCodevId && projectId), + staleTime: 60_000, + queryFn: async () => { + const supabase = getSupabase(); + if (!supabase) throw new Error("Supabase client is not available"); + + const { data: projectMember } = await supabase + .from("project_members") + .select("role") + .eq("project_id", projectId) + .eq("codev_id", currentCodevId) + .single(); + + const { data: existingRatings } = await supabase + .from("member_ratings") + .select("*") + .eq("member_id", memberId) + .eq("project_id", projectId) + .order("updated_at", { ascending: false }) + .limit(1); + + return { + isTeamLead: projectMember?.role === "team_leader", + rating: existingRatings?.[0] ?? null, + }; + }, + }); - const isLead = projectMember?.role === "team_leader"; - setIsTeamLead(isLead); - - const { data: existingRatings, error: ratingError } = await supabase - .from("member_ratings") - .select("*") - .eq("member_id", memberId) - .eq("project_id", projectId) - .order("updated_at", { ascending: false }) - .limit(1); - - if (existingRatings && existingRatings.length > 0) { - const rating = existingRatings[0]; - - // Load existing ratings into state - setRatings({ - punctuality: rating.punctuality || 0, - accountability: rating.accountability || 0, - responsiveness: rating.responsiveness || 0, - initiative: rating.initiative || 0, - reliability: rating.reliability || 0, - }); - setFeedback(rating.feedback || ""); - - // Only set existingRatingId if current user is the one who rated - if (rating.rated_by === currentCodevId) { - setExistingRatingId(rating.id); - } - } - } catch (error) { - // Error checking permissions - } finally { - setIsLoading(false); - } - }; + const isTeamLead = data?.isTeamLead ?? false; - checkPermissionsAndLoadRatings(); - }, [supabase, currentCodevId, projectId, memberId]); + // The fetched rating seeds the editable form; the form then owns it locally. + const rating = data?.rating ?? null; + useEffect(() => { + if (!rating) return; + setRatings({ + punctuality: rating.punctuality || 0, + accountability: rating.accountability || 0, + responsiveness: rating.responsiveness || 0, + initiative: rating.initiative || 0, + reliability: rating.reliability || 0, + }); + setFeedback(rating.feedback || ""); + setExistingRatingId(rating.rated_by === currentCodevId ? rating.id : null); + }, [rating, currentCodevId]); // Calculate overall performance (average of all criteria) const calculateOverall = (): number => { @@ -197,6 +165,7 @@ const MemberRating = ({ memberId, projectId }: MemberRatingProps) => { // Save or update rating in database const handleSaveRating = async () => { + const supabase = getSupabase(); if (!supabase || !currentCodevId || !isTeamLead) { toast.error("You don't have permission to rate this member"); return; diff --git a/apps/codebility/app/home/my-team/_components/MyTeamView.tsx b/apps/codebility/app/home/my-team/_components/MyTeamView.tsx index 54d73ffa6..18a493ddf 100644 --- a/apps/codebility/app/home/my-team/_components/MyTeamView.tsx +++ b/apps/codebility/app/home/my-team/_components/MyTeamView.tsx @@ -1,13 +1,14 @@ "use client"; +import { MEMBER_LIST_COLUMNS, type MemberListRow } from "@/lib/shared/member-list"; import { useState, useCallback } from "react"; import { toast } from "react-hot-toast"; import { getMembers, getTeamLead, updateProjectMembers, - SimpleMemberData -} from "@/app/home/projects/actions"; + SimpleMemberData, +} from "@/actions/projects/actions"; import { Codev } from "@/types/home/codev"; import { Button } from "@/components/ui/button"; import TeamProjectCard from "./TeamProjectCard"; @@ -49,7 +50,7 @@ const MyTeamView = ({ projectData }: MyTeamViewProps) => { setSelectedProject(null); }, []); - const handleUpdateMembers = async (selectedMembers: Codev[]) => { + const handleUpdateMembers = async (selectedMembers: MemberListRow[]) => { if (!selectedProject) { toast.error("No project selected"); return; @@ -211,4 +212,4 @@ const MyTeamView = ({ projectData }: MyTeamViewProps) => { ); }; -export default MyTeamView; \ No newline at end of file +export default MyTeamView; diff --git a/apps/codebility/app/home/my-team/_components/TeamProjectCard.tsx b/apps/codebility/app/home/my-team/_components/TeamProjectCard.tsx index 964f816bf..7662cb570 100644 --- a/apps/codebility/app/home/my-team/_components/TeamProjectCard.tsx +++ b/apps/codebility/app/home/my-team/_components/TeamProjectCard.tsx @@ -3,7 +3,7 @@ import Image from "next/image"; import Link from "next/link"; import { Button } from "@/components/ui/button"; -import { SimpleMemberData } from "@/app/home/projects/actions"; +import { SimpleMemberData } from "@/actions/projects/actions"; import { Users, UserPlus, ArrowRight, Crown, Calendar } from "lucide-react"; import DefaultAvatar from "@/components/DefaultAvatar"; import { memo } from "react"; diff --git a/apps/codebility/app/home/my-team/page.tsx b/apps/codebility/app/home/my-team/page.tsx index a9b6b7f50..f0d598b48 100644 --- a/apps/codebility/app/home/my-team/page.tsx +++ b/apps/codebility/app/home/my-team/page.tsx @@ -1,12 +1,14 @@ -import { Suspense } from "react"; +import { Suspense, cache } from "react"; import { redirect } from "next/navigation"; import AsyncErrorBoundary from "@/components/AsyncErrorBoundary"; -import { getMembers, getTeamLead, getUserProjects } from "../projects/actions"; +import { getMembers, getTeamLead, getUserProjects } from "@/actions/projects/actions"; +import type { Project } from "@/types/home/codev"; import MyTeamView from "./_components/MyTeamView"; import { Users } from "lucide-react"; -// Team data changes occasionally, use 5 minute revalidation -export const revalidate = 300; +// The redirect check and TeamData both need this, and it is not request +// deduped, so without cache() the page fetched it twice per render. +const getUserProjectsCached = cache(getUserProjects); // Loading component for team data function TeamDataSkeleton() { @@ -56,34 +58,11 @@ function TeamDataSkeleton() { } // Component that fetches and displays team data -async function TeamData() { - // Fetch user projects outside the try-catch to allow redirect to work properly - const userProjectsResponse = await getUserProjects(); - - if (userProjectsResponse.error || !userProjectsResponse.data || userProjectsResponse.data.length === 0) { - return ( -
-
-
-
- -
-
-
-

Empty Squad

-

- {userProjectsResponse.error?.message || "You haven't been assigned to any project teams yet. Connect with a Project Manager to get started!"} -

-
-
-

Pro-Tip: Check back once you're assigned to see your team's weekly leaderboard!

-
-
- ); - } - - const userProjects = userProjectsResponse.data; - +async function TeamData({ + userProjects, +}: { + userProjects: { project: Project; role: string }[]; +}) { try { // Fetch team data for each project in parallel const projectDataPromises = userProjects.map(async ({ project, role }) => { @@ -143,18 +122,46 @@ async function TeamData() { } export default async function MyTeamPage() { - // Pre-fetch logic for fast top-level redirection without hitting the error boundary - const userProjectsResponse = await getUserProjects(); - if (userProjectsResponse.data && userProjectsResponse.data.length > 0) { - const isTeamLeadInAny = userProjectsResponse.data.some((p) => p.role === "team_leader" || p.role === "admin"); + // Awaited once here so the redirect check and TeamData share one fetch. + const userProjectsResponse = await getUserProjectsCached(); + const userProjects = userProjectsResponse.data ?? []; + if (userProjects.length > 0) { + const isTeamLeadInAny = userProjects.some((p) => p.role === "team_leader" || p.role === "admin"); // Only auto-redirect if they are in exactly one project and are NOT a leader // If they are in multiple projects, we show the dashboard so they can pick which one to view - if (!isTeamLeadInAny && userProjectsResponse.data.length === 1 && userProjectsResponse.data[0]?.project?.id) { - redirect(`/home/my-team/${userProjectsResponse.data[0].project.id}/leaderboard`); + if (!isTeamLeadInAny && userProjects.length === 1 && userProjects[0]?.project?.id) { + redirect(`/home/my-team/${userProjects[0].project.id}/leaderboard`); } } + if (userProjectsResponse.error || userProjects.length === 0) { + return ( +
+
+
+
+
+ +
+
+
+

Empty Squad

+

+ {userProjectsResponse.error?.message || + "You haven't been assigned to any project teams yet. Connect with a Project Manager to get started!"} +

+
+
+

+ Pro-Tip: Check back once you're assigned to see your team's weekly leaderboard! +

+
+
+
+ ); + } + return (
@@ -192,7 +199,7 @@ export default async function MyTeamPage() { } > }> - +
diff --git a/apps/codebility/app/home/orgchart/_components/OrgChart.tsx b/apps/codebility/app/home/orgchart/_components/OrgChart.tsx index 7770c968b..9090e9760 100644 --- a/apps/codebility/app/home/orgchart/_components/OrgChart.tsx +++ b/apps/codebility/app/home/orgchart/_components/OrgChart.tsx @@ -6,13 +6,13 @@ import { motion, AnimatePresence } from "framer-motion"; import Image from "next/image"; import Link from "next/link"; import { useTheme } from "next-themes"; -import { useNavStore } from "@/hooks/use-sidebar"; +import { useNavStore } from "@/hooks/navigation/use-sidebar"; import { defaultAvatar } from "@/public/assets/images"; import { getValidImageUrl } from "@/utils/imageValidation"; -import { cn } from "@/lib/utils"; +import { cn } from "@/utils/utils"; import { OrgChartProps } from "../_types/org-chart"; -import { usePageAnimationSettings } from "@/hooks/usePageAnimationSettings"; +import { usePageAnimationSettings } from "@/hooks/ui/usePageAnimationSettings"; import { Crown, diff --git a/apps/codebility/app/home/orgchart/_components/OrgChartSkeleton.tsx b/apps/codebility/app/home/orgchart/_components/OrgChartSkeleton.tsx index 85a123f68..86b770979 100644 --- a/apps/codebility/app/home/orgchart/_components/OrgChartSkeleton.tsx +++ b/apps/codebility/app/home/orgchart/_components/OrgChartSkeleton.tsx @@ -1,7 +1,7 @@ "use client"; import { Skeleton } from "@codevs/ui/skeleton"; -import { cn } from "@/lib/utils"; +import { cn } from "@/utils/utils"; const MemberPillSkeleton = ({ size = "md" }: { size?: "md" | "lg" }) => { const isLg = size === "lg"; diff --git a/apps/codebility/app/home/orgchart/_components/OrgChartTeamMemberCard.tsx b/apps/codebility/app/home/orgchart/_components/OrgChartTeamMemberCard.tsx index a26475bf7..6b7482d63 100644 --- a/apps/codebility/app/home/orgchart/_components/OrgChartTeamMemberCard.tsx +++ b/apps/codebility/app/home/orgchart/_components/OrgChartTeamMemberCard.tsx @@ -1,4 +1,5 @@ import Image from "next/image"; +import Link from "next/link"; import { defaultAvatar } from "@/public/assets/images"; import { getValidImageUrl } from "@/utils/imageValidation"; @@ -18,10 +19,10 @@ export const TeamMemberCard = ({ // Line 15: Card wrapper now clickable with cursor pointer and enhanced hover return ( - window.location.href = `https://www.codebility.tech/profiles/${id}`} - className="group relative h-32 w-36 overflow-hidden border-2 border-gray-200 shadow-lg transition-all duration-300 cursor-pointer hover:-translate-y-1 hover:shadow-2xl hover:scale-[1.02] dark:border-gray-700" - > + +
@@ -45,5 +46,6 @@ export const TeamMemberCard = ({
+ ); }; \ No newline at end of file diff --git a/apps/codebility/app/home/orgchart/page.tsx b/apps/codebility/app/home/orgchart/page.tsx index a537f04d7..9a830138e 100644 --- a/apps/codebility/app/home/orgchart/page.tsx +++ b/apps/codebility/app/home/orgchart/page.tsx @@ -4,8 +4,6 @@ import { createClientServerComponent } from "@/utils/supabase/server"; import OrgCharts from "./_components/OrgChart"; import OrgChartSkeleton from "./_components/OrgChartSkeleton"; -export const dynamic = "force-dynamic"; -export const revalidate = 0; async function OrgChartData() { const supabase = await createClientServerComponent(); diff --git a/apps/codebility/app/home/overflow/_components/CommentSection.tsx b/apps/codebility/app/home/overflow/_components/CommentSection.tsx index 271ca1bb1..26bb2f63e 100644 --- a/apps/codebility/app/home/overflow/_components/CommentSection.tsx +++ b/apps/codebility/app/home/overflow/_components/CommentSection.tsx @@ -1,6 +1,7 @@ "use client"; import { useState, useEffect, memo, useCallback, useRef } from "react"; +import { useQuery } from "@tanstack/react-query"; import Image from "next/image"; import DefaultAvatar from "@/components/DefaultAvatar"; import { Button } from "@/components/ui/button"; @@ -30,20 +31,20 @@ import { fetchCommentLikes, markAsSolution, type Comment -} from "../actions"; -import { Question, fetchQuestions } from "../actions"; +} from "@/actions/overflow/actions"; +import { Question, fetchQuestions } from "@/actions/overflow/actions"; import { createClientClientComponent } from "@/utils/supabase/client"; interface CommentSectionProps { questionId: string; - questionAuthorId: string; // ← needed to show "Mark as Solution" button only to post author + questionAuthorId: string; // ← needed to show "Mark as Solution" button only to post author loggedIn: { id: string; name: string; image_url: string | null; }; setQuestions: React.Dispatch>; - onSolutionMarked?: () => void; // ← add this + onSolutionMarked?: () => void; // ← add this } interface MentionUser { @@ -54,7 +55,7 @@ interface MentionUser { username?: string; } -// Memoized MentionPopover component — renders absolutely above the textarea +// Memoized MentionPopover component — renders absolutely above the textarea const MentionPopover = memo(function MentionPopover({ isOpen, users, @@ -274,7 +275,7 @@ const CommentActions = memo(function CommentActions({ return (
- {/* Checkmark — only visible to post author */} + {/* Checkmark — only visible to post author */} {isPostAuthor && (
@@ -521,7 +524,7 @@ export default function PostQuestionModal({

- {editor?.storage.characterCount?.characters() || 0} characters • Press Ctrl+Enter to submit + {editor?.storage.characterCount?.characters() || 0} characters • Press Ctrl+Enter to submit

@@ -537,7 +540,7 @@ export default function PostQuestionModal({ className="border-gray-300 bg-white text-gray-900 placeholder-gray-500 focus:border-customBlue-500 focus:ring-2 focus:ring-customBlue-500/20 dark:border-gray-600 dark:bg-gray-800 dark:text-white dark:placeholder-gray-400 dark:focus:border-customBlue-400" />

- Separate tags with commas • Helps others find your question + Separate tags with commas • Helps others find your question

@@ -552,7 +555,7 @@ export default function PostQuestionModal({

- 💡 Tip: {isEditMode ? 'Update your question to make it clearer' : 'Clear questions get better answers faster'} + 💡 Tip: {isEditMode ? 'Update your question to make it clearer' : 'Clear questions get better answers faster'}

); -} \ No newline at end of file +} diff --git a/apps/codebility/app/home/page.tsx b/apps/codebility/app/home/page.tsx index cebd5e711..d4ab311ec 100644 --- a/apps/codebility/app/home/page.tsx +++ b/apps/codebility/app/home/page.tsx @@ -1,12 +1,10 @@ import { Suspense } from "react"; -import H1 from "@/components/shared/dashboard/H1"; -import PageContainer from "./_components/PageContainer"; -import DashboardClient from "./_components/DashboardClient"; -import NewsBanner from "./_components/NewsBanner"; -import { fetchActiveModal } from "./promote-modal/actions"; +import { fetchActiveModal } from "@/actions/promote-modal/actions"; + import FeaturePromoModal from "./promote-modal/_components/FeaturePromoModal"; +import DashboardContent from "./_components/DashboardContent"; +import NewsBanner from "./_components/NewsBanner"; -// Loading component for better UX function DashboardLoading() { return (
@@ -26,8 +24,6 @@ function DashboardLoading() { ); } -export const revalidate = 3600; // Revalidate every hour for fresh data - export default async function DashboardPage() { const modal = await fetchActiveModal(); @@ -35,58 +31,61 @@ export default async function DashboardPage() {
{modal && } -
+
{/* Background decorations - contained within content area */} -
-
-
+
+
+
- +
- {/* News Banner */} - - - {/* Enhanced Header */} -
-
-
- 🏠 -
-
-

- Welcome Home -

-

Track your progress and climb the leaderboards

-
-
- - {/* - TODO: Quick stats bar for future features - -
-
-
- 🔥 Streak: - 7 days + + + + + {/* Enhanced Header */} +
+
+
+ 🏠 +
+
+

+ Welcome Home +

+

+ Track your progress and climb the leaderboards +

- -
-
- 🎯 Goal: - 50 points this week + + {/* + TODO: Quick stats bar for future features + +
+
+
+ 🔥 Streak: + 7 days +
+
+ +
+
+ 🎯 Goal: + 50 points this week +
+ */}
- */} -
- }> - - + }> + +
); -} \ No newline at end of file +} diff --git a/apps/codebility/app/home/projects/_components/AddProjectButton.tsx b/apps/codebility/app/home/projects/_components/AddProjectButton.tsx index 91197d583..d84bade64 100644 --- a/apps/codebility/app/home/projects/_components/AddProjectButton.tsx +++ b/apps/codebility/app/home/projects/_components/AddProjectButton.tsx @@ -1,7 +1,7 @@ "use client"; import AddNewButton from "@/components/ui/AddNewButton"; -import { useModal } from "@/hooks/use-modal-projects"; +import { useModal } from "@/hooks/modals/use-modal-projects"; import { useUserStore } from "@/store/codev-store"; export default function AddProjectButton() { diff --git a/apps/codebility/app/home/projects/_components/BookmarkButton.tsx b/apps/codebility/app/home/projects/_components/BookmarkButton.tsx index 3987ecd88..c558381cf 100644 --- a/apps/codebility/app/home/projects/_components/BookmarkButton.tsx +++ b/apps/codebility/app/home/projects/_components/BookmarkButton.tsx @@ -1,7 +1,7 @@ import React, { useState } from "react"; import { Bookmark, BookmarkCheck } from "lucide-react"; -import { updatePublicDisplaySwitch } from "../actions"; +import { updatePublicDisplaySwitch } from "@/actions/projects/actions"; import { ProjectCardProps } from "./ProjectCard"; export default function BookmarkButton({ diff --git a/apps/codebility/app/home/projects/_components/ImageCrop.tsx b/apps/codebility/app/home/projects/_components/ImageCrop.tsx index 6da97b711..e2bb8fe2e 100644 --- a/apps/codebility/app/home/projects/_components/ImageCrop.tsx +++ b/apps/codebility/app/home/projects/_components/ImageCrop.tsx @@ -8,7 +8,7 @@ import { DialogTitle, } from "@/components/ui/dialog"; import { Slider } from "@/components/ui/sliders"; -import getCroppedImg from "@/hooks/useImageCrop"; +import getCroppedImg from "@/hooks/ui/useImageCrop"; import Cropper, { Area, Point } from "react-easy-crop"; export default function ImageCrop({ diff --git a/apps/codebility/app/home/projects/_components/ProjectAddModal.tsx b/apps/codebility/app/home/projects/_components/ProjectAddModal.tsx index db87f17b7..de26343af 100644 --- a/apps/codebility/app/home/projects/_components/ProjectAddModal.tsx +++ b/apps/codebility/app/home/projects/_components/ProjectAddModal.tsx @@ -7,7 +7,7 @@ import { getProjectCategories, getProjectClients, getProjectCodevs, -} from "@/app/home/projects/actions"; +} from "@/actions/projects/actions"; import ProjectAvatar from "@/components/ProjectAvatar"; import { CustomSelect } from "@/components/ui/CustomSelect"; import { @@ -19,9 +19,9 @@ import { DialogTitle, } from "@/components/ui/dialog"; import { MemberSelection } from "@/components/ui/MemberSelection"; -import { useModal as useGlobalModal } from "@/hooks/use-modal"; -import { useModal } from "@/hooks/use-modal-projects"; -import { useTechStackStore } from "@/hooks/use-techstack"; +import { useModal as useGlobalModal } from "@/hooks/modals/use-modal"; +import { useModal } from "@/hooks/modals/use-modal-projects"; +import { useTechStackStore } from "@/hooks/data/use-techstack"; import { Client, Codev, SkillCategory } from "@/types/home/codev"; import { uploadImage } from "@/utils/uploadImage"; import { useForm } from "react-hook-form"; diff --git a/apps/codebility/app/home/projects/_components/ProjectCard.tsx b/apps/codebility/app/home/projects/_components/ProjectCard.tsx index 52788b5f3..2a462010e 100644 --- a/apps/codebility/app/home/projects/_components/ProjectCard.tsx +++ b/apps/codebility/app/home/projects/_components/ProjectCard.tsx @@ -3,11 +3,11 @@ import React, { memo, useMemo, useState } from "react"; import Image from "next/image"; import Link from "next/link"; -import { SimpleMemberData } from "@/app/home/projects/actions"; +import { SimpleMemberData } from "@/actions/projects/actions"; import DefaultAvatar from "@/components/DefaultAvatar"; import { Skeleton } from "@/components/ui/skeleton/skeleton"; import SwitchStatusButton from "@/components/ui/SwitchStatusButton"; -import { ModalType } from "@/hooks/use-modal-projects"; +import { ModalType } from "@/hooks/modals/use-modal-projects"; import { defaultAvatar } from "@/public/assets/images"; import { IconGithub, IconLink } from "@/public/assets/svgs"; import { IconFigma } from "@/public/assets/svgs/techstack"; diff --git a/apps/codebility/app/home/projects/_components/ProjectCardContainer.tsx b/apps/codebility/app/home/projects/_components/ProjectCardContainer.tsx index cf7b944f9..6c5a96be4 100644 --- a/apps/codebility/app/home/projects/_components/ProjectCardContainer.tsx +++ b/apps/codebility/app/home/projects/_components/ProjectCardContainer.tsx @@ -1,83 +1,62 @@ "use client"; -import React, { useCallback, useEffect, useMemo, useState } from "react"; +import React, { useCallback, useState } from "react"; import DefaultPagination from "@/components/ui/pagination"; import { CATEGORIES, pageSize } from "@/constants"; -import { useModal } from "@/hooks/use-modal-projects"; -import usePagination from "@/hooks/use-pagination"; +import { useModal } from "@/hooks/modals/use-modal-projects"; +import { usePaginatedQuery } from "@/hooks/query/use-paginated-query"; +import { qk } from "@/lib/shared/query-keys"; +import type { Page } from "@/lib/server/paginate"; import { Project } from "@/types/home/codev"; import Container from "../../../(marketing)/_components/MarketingContainer"; import Section from "../../../(marketing)/_components/MarketingSection"; import ProjectCard from "./ProjectCard"; +import { getProjectsPageAction } from "@/actions/projects/actions"; interface ProjectCardContainerProps { - projects: Project[]; + initialData: Page; } // Define a special ID for the "All" category const ALL_CATEGORY_ID = 0; -const ProjectCardContainer = ({ projects }: ProjectCardContainerProps) => { +const ProjectCardContainer = ({ initialData }: ProjectCardContainerProps) => { // Initialize with "All" category const [currentCategory, setCurrentCategory] = useState(ALL_CATEGORY_ID); - // Filter projects by current category - const [filteredProjects, setFilteredProjects] = useState([]); - // Track current page for each tab - initialize with empty object - const [tabPages, setTabPages] = useState>({}); + const [page, setPage] = useState(1); const { onOpen } = useModal(); - // Memoize filtered projects to prevent unnecessary recalculation - const filteredProjectsMemo = useMemo(() => { - // If "All" category is selected, show all projects - // Otherwise, filter by the selected category (supports many-to-many) - return currentCategory === ALL_CATEGORY_ID - ? projects - : projects.filter( - (project) => project.categories?.some(cat => cat.id === currentCategory), - ); - }, [currentCategory, projects]); - - // First filter by category, then apply pagination - useEffect(() => { - setFilteredProjects(filteredProjectsMemo); - }, [filteredProjectsMemo]); - - const { - currentPage, - totalPages, - paginatedData: paginatedProjects, - handleNextPage, - handlePreviousPage, - setCurrentPage, - } = usePagination(filteredProjects, pageSize.projects); - - // Update current page when switching tabs - useEffect(() => { - const savedPage = tabPages[currentCategory] || 1; - // Ensure the saved page doesn't exceed total pages - const validPage = Math.min(savedPage, totalPages || 1); - setCurrentPage(validPage); - }, [currentCategory, tabPages, setCurrentPage, totalPages]); - - const handleTabClick = useCallback( - (categoryId: number) => { - // Save current page for the current tab before switching - if (currentPage > 0 && currentPage <= totalPages) { - setTabPages((prev) => ({ - ...prev, - [currentCategory]: currentPage, - })); - } - setCurrentCategory(categoryId); + const queryKey = qk.projects.list({ category: currentCategory, page }); + + const { data, showSkeleton } = usePaginatedQuery( + queryKey, + () => + getProjectsPageAction({ + page, + pageSize: pageSize.projects, + categoryId: currentCategory === ALL_CATEGORY_ID ? undefined : currentCategory, + }), + { + initialData, + // page.tsx renders exactly this key: all categories, page 1. + initialDataKey: qk.projects.list({ category: ALL_CATEGORY_ID, page: 1 }), }, - [currentCategory, currentPage, totalPages], ); + const totalPages = Math.max(Math.ceil((data?.total ?? 0) / (data?.pageSize ?? 1)), 1); + + const handleTabClick = useCallback((categoryId: number) => { + setCurrentCategory(categoryId); + setPage(1); + }, []); + + const rows = data?.rows ?? []; + return (
@@ -111,9 +90,15 @@ const ProjectCardContainer = ({ projects }: ProjectCardContainerProps) => { {/* Projects Grid */}
- {Array.isArray(paginatedProjects) && - paginatedProjects.length > 0 ? ( - paginatedProjects.map((project) => { + {showSkeleton ? ( + Array.from({ length: pageSize.projects }).map((_, i) => ( +
+ )) + ) : rows.length > 0 ? ( + rows.map((project) => { if (!project || !project.id) return null; return ( { )}
- {filteredProjects.length > pageSize.projects && totalPages > 1 && ( + {totalPages > 1 && ( { - const validPage = Math.max(1, Math.min(page, totalPages)); - setCurrentPage(validPage); - }} + currentPage={Math.max(1, Math.min(page, totalPages))} + handleNextPage={() => setPage((p) => Math.min(p + 1, totalPages))} + handlePreviousPage={() => setPage((p) => Math.max(p - 1, 1))} + setCurrentPage={(target: number) => + setPage(Math.max(1, Math.min(target, totalPages))) + } totalPages={totalPages} /> )} diff --git a/apps/codebility/app/home/projects/_components/ProjectDeleteModal.tsx b/apps/codebility/app/home/projects/_components/ProjectDeleteModal.tsx index 1717e1836..96d5aba2d 100644 --- a/apps/codebility/app/home/projects/_components/ProjectDeleteModal.tsx +++ b/apps/codebility/app/home/projects/_components/ProjectDeleteModal.tsx @@ -1,5 +1,5 @@ import { useState } from "react"; -import { deleteProject } from "@/app/home/projects/actions"; +import { deleteProject } from "@/actions/projects/actions"; import { Dialog, DialogContent, @@ -8,7 +8,7 @@ import { DialogHeader, DialogTitle, } from "@/components/ui/dialog"; -import { useModal } from "@/hooks/use-modal-projects"; +import { useModal } from "@/hooks/modals/use-modal-projects"; import toast from "react-hot-toast"; import { Button } from "@codevs/ui/button"; diff --git a/apps/codebility/app/home/projects/_components/ProjectEditModal.tsx b/apps/codebility/app/home/projects/_components/ProjectEditModal.tsx index d32840dc5..405d9dcb7 100644 --- a/apps/codebility/app/home/projects/_components/ProjectEditModal.tsx +++ b/apps/codebility/app/home/projects/_components/ProjectEditModal.tsx @@ -9,7 +9,7 @@ import { getProjectClients, getProjectCodevs, updateProject, -} from "@/app/home/projects/actions"; +} from "@/actions/projects/actions"; import ProjectAvatar from "@/components/ProjectAvatar"; import { CustomSelect } from "@/components/ui/CustomSelect"; import { @@ -22,9 +22,9 @@ import { import { MemberSelection } from "@/components/ui/MemberSelection"; import { SelectMemberModal } from "@/components/ui/SelectMemberModal"; import { Skeleton } from "@/components/ui/skeleton/skeleton"; -import { useModal as useGlobalModal } from "@/hooks/use-modal"; -import { useModal } from "@/hooks/use-modal-projects"; -import { useTechStackStore } from "@/hooks/use-techstack"; +import { useModal as useGlobalModal } from "@/hooks/modals/use-modal"; +import { useModal } from "@/hooks/modals/use-modal-projects"; +import { useTechStackStore } from "@/hooks/data/use-techstack"; import { Client, Codev, Project, SkillCategory } from "@/types/home/codev"; import { uploadImage } from "@/utils/uploadImage"; import { useQuery, useQueryClient } from "@tanstack/react-query"; diff --git a/apps/codebility/app/home/projects/_components/ProjectOptionsMenu.tsx b/apps/codebility/app/home/projects/_components/ProjectOptionsMenu.tsx index a3619bab2..35bee9dff 100644 --- a/apps/codebility/app/home/projects/_components/ProjectOptionsMenu.tsx +++ b/apps/codebility/app/home/projects/_components/ProjectOptionsMenu.tsx @@ -14,7 +14,7 @@ import { updateKanbanDisplaySwitch, updatePublicDisplaySwitch, updateStatus, -} from "../actions"; +} from "@/actions/projects/actions"; import { ProjectCardProps } from "./ProjectCard"; export default function ProjectOptionsMenu({ diff --git a/apps/codebility/app/home/projects/_components/ProjectViewModal.tsx b/apps/codebility/app/home/projects/_components/ProjectViewModal.tsx index 9c35e5a17..fa091d3f7 100644 --- a/apps/codebility/app/home/projects/_components/ProjectViewModal.tsx +++ b/apps/codebility/app/home/projects/_components/ProjectViewModal.tsx @@ -8,7 +8,7 @@ import { getSubLead, // ── CBP-116 getTeamLead, SimpleMemberData, -} from "@/app/home/projects/actions"; +} from "@/actions/projects/actions"; import DefaultAvatar from "@/components/DefaultAvatar"; import { Button } from "@/components/ui/button"; import { @@ -18,7 +18,7 @@ import { DialogTitle, } from "@/components/ui/dialog"; import { Skeleton } from "@/components/ui/skeleton/skeleton"; -import { useModal } from "@/hooks/use-modal-projects"; +import { useModal } from "@/hooks/modals/use-modal-projects"; import { IconGithub, IconLink } from "@/public/assets/svgs"; import { IconFigma } from "@/public/assets/svgs/techstack"; import { useQuery } from "@tanstack/react-query"; diff --git a/apps/codebility/app/home/projects/page.tsx b/apps/codebility/app/home/projects/page.tsx index c3fa42a62..4e944d76f 100644 --- a/apps/codebility/app/home/projects/page.tsx +++ b/apps/codebility/app/home/projects/page.tsx @@ -1,18 +1,14 @@ import H1 from "@/components/shared/dashboard/H1"; import AsyncErrorBoundary from "@/components/AsyncErrorBoundary"; -import getProjects from "@/lib/server/project.service"; -import { getOrSetCache } from "@/lib/server/redis-cache"; -import { cacheKeys } from "@/lib/server/redis-cache-keys"; +import { pageSize } from "@/constants"; +import { getProjectsPage } from "@/lib/server/project.service"; import { Project } from "@/types/home/codev"; -import { createClientServerComponent } from "@/utils/supabase/server"; import PageContainer from "../_components/PageContainer"; import AddProjectButton from "./_components/AddProjectButton"; import ProjectCardContainer from "./_components/ProjectCardContainer"; import ProjectFilterButton from "./_components/ProjectFilterButton"; -export const dynamic = "force-dynamic"; -export const revalidate = 0; type PageProps = { searchParams: Promise<{ @@ -22,32 +18,9 @@ type PageProps = { const Projects = async (props: PageProps) => { const searchParams = await props.searchParams; - const supabase = await createClientServerComponent(); const filter = searchParams.filter; - /* const allProjects = await getOrSetCache(cacheKeys.projects.all, () => - getProjects(), - ); */ - /* to be back on redis */ - const allProjects = await getProjects(); - - const Projects = - filter && filter !== "all" - ? allProjects?.filter( - (project) => project.status?.toLowerCase() === filter.toLowerCase(), - ) - : allProjects; - - if (!Projects) { - return ( - -

Projects

-
-

No projects found

-
-
- ); - } + const initialData = await getProjectsPage({ page: 1, pageSize: pageSize.projects }); return ( @@ -70,8 +43,8 @@ const Projects = async (props: PageProps) => {
- {Projects && Projects.length > 0 && ( - + {initialData.total > 0 && ( + } /> )}
diff --git a/apps/codebility/app/home/promote-modal/[id]/loading.tsx b/apps/codebility/app/home/promote-modal/[id]/loading.tsx new file mode 100644 index 000000000..44758374e --- /dev/null +++ b/apps/codebility/app/home/promote-modal/[id]/loading.tsx @@ -0,0 +1,7 @@ +import { FormSkeleton } from "@/app/home/_components/skeletons/RouteSkeletons"; + +const Loading = () => { + return ; +}; + +export default Loading; diff --git a/apps/codebility/app/home/promote-modal/[id]/page.tsx b/apps/codebility/app/home/promote-modal/[id]/page.tsx index 6f33ebf6e..f7ec6348f 100644 --- a/apps/codebility/app/home/promote-modal/[id]/page.tsx +++ b/apps/codebility/app/home/promote-modal/[id]/page.tsx @@ -1,4 +1,4 @@ -import { fetchModalById } from "../actions"; +import { fetchModalById } from "@/actions/promote-modal/actions"; import EditPromoModal from "../_components/EditPromoModal"; import PageContainer from "../../_components/PageContainer"; import { H1 } from "@/components/shared/dashboard"; diff --git a/apps/codebility/app/home/promote-modal/_components/EditPromoModal.tsx b/apps/codebility/app/home/promote-modal/_components/EditPromoModal.tsx index 8a7f85a90..69e25f568 100644 --- a/apps/codebility/app/home/promote-modal/_components/EditPromoModal.tsx +++ b/apps/codebility/app/home/promote-modal/_components/EditPromoModal.tsx @@ -3,10 +3,10 @@ import { useState } from "react"; import * as Icons from "lucide-react"; import { FeatureModal, ModalFeature } from "../type"; -import { upsertActiveModal } from "../actions"; +import { upsertActiveModal } from "@/actions/promote-modal/actions"; import IconPicker from "./IconPicker"; import { Upload, X } from "lucide-react"; -import { uploadModalImage } from "../actions"; +import { uploadModalImage } from "@/actions/promote-modal/actions"; interface EditPromoModalProps { data: FeatureModal | null; diff --git a/apps/codebility/app/home/promote-modal/_components/ModalList.tsx b/apps/codebility/app/home/promote-modal/_components/ModalList.tsx index 04bf809ad..00d82ddfa 100644 --- a/apps/codebility/app/home/promote-modal/_components/ModalList.tsx +++ b/apps/codebility/app/home/promote-modal/_components/ModalList.tsx @@ -4,7 +4,7 @@ import { useState, useTransition } from "react"; import { useRouter } from "next/navigation"; import * as Icons from "lucide-react"; import { FeatureModal } from "../type"; -import { createModal, deleteModal, toggleModalActive } from "../actions"; +import { createModal, deleteModal, toggleModalActive } from "@/actions/promote-modal/actions"; import { AlertDialog, AlertDialogAction, diff --git a/apps/codebility/app/home/promote-modal/loading.tsx b/apps/codebility/app/home/promote-modal/loading.tsx new file mode 100644 index 000000000..44758374e --- /dev/null +++ b/apps/codebility/app/home/promote-modal/loading.tsx @@ -0,0 +1,7 @@ +import { FormSkeleton } from "@/app/home/_components/skeletons/RouteSkeletons"; + +const Loading = () => { + return ; +}; + +export default Loading; diff --git a/apps/codebility/app/home/promote-modal/page.tsx b/apps/codebility/app/home/promote-modal/page.tsx index b47f4ea7c..15835bf3a 100644 --- a/apps/codebility/app/home/promote-modal/page.tsx +++ b/apps/codebility/app/home/promote-modal/page.tsx @@ -1,4 +1,4 @@ -import { fetchAllModals } from "./actions"; +import { fetchAllModals } from "@/actions/promote-modal/actions"; import PageContainer from "../_components/PageContainer"; import { H1 } from "@/components/shared/dashboard"; import ModalListClient from "./_components/ModalList"; diff --git a/apps/codebility/app/home/settings/account-settings/loading.tsx b/apps/codebility/app/home/settings/account-settings/loading.tsx new file mode 100644 index 000000000..44758374e --- /dev/null +++ b/apps/codebility/app/home/settings/account-settings/loading.tsx @@ -0,0 +1,7 @@ +import { FormSkeleton } from "@/app/home/_components/skeletons/RouteSkeletons"; + +const Loading = () => { + return ; +}; + +export default Loading; diff --git a/apps/codebility/app/home/settings/news-banners/_components/BannerImageUpload.tsx b/apps/codebility/app/home/settings/news-banners/_components/BannerImageUpload.tsx index ea69c70a5..cb7a60b52 100644 --- a/apps/codebility/app/home/settings/news-banners/_components/BannerImageUpload.tsx +++ b/apps/codebility/app/home/settings/news-banners/_components/BannerImageUpload.tsx @@ -55,7 +55,7 @@ export default function BannerImageUpload({ // Clean up old image if exists if (value) { - const oldImagePath = getImagePath(value); + const oldImagePath = await getImagePath(value); if (oldImagePath) { await deleteImage(oldImagePath, "codebility"); } @@ -86,7 +86,7 @@ export default function BannerImageUpload({ setIsUploading(true); try { // Delete from storage - const imagePath = getImagePath(value); + const imagePath = await getImagePath(value); if (imagePath) { await deleteImage(imagePath, "codebility"); } diff --git a/apps/codebility/app/home/settings/news-banners/_components/NewsBannerForm.tsx b/apps/codebility/app/home/settings/news-banners/_components/NewsBannerForm.tsx index 307784c9d..b91603fe3 100644 --- a/apps/codebility/app/home/settings/news-banners/_components/NewsBannerForm.tsx +++ b/apps/codebility/app/home/settings/news-banners/_components/NewsBannerForm.tsx @@ -10,7 +10,7 @@ import { Textarea } from "@codevs/ui/textarea"; import { Label } from "@codevs/ui/label"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@codevs/ui/select"; import { Switch } from "@codevs/ui/switch"; -import { createClientClientComponent } from "@/utils/supabase/client"; +import { getClientSupabase } from "@/utils/supabase/client"; import { toast } from "sonner"; import BannerImageUpload from "./BannerImageUpload"; @@ -72,7 +72,7 @@ export default function NewsBannerForm({ banner, onSuccess }: NewsBannerFormProp const onSubmit = async (data: BannerFormData) => { setLoading(true); - const supabase = createClientClientComponent(); + const supabase = getClientSupabase(); try { const bannerData = { diff --git a/apps/codebility/app/home/settings/news-banners/_components/NewsBannersClient.tsx b/apps/codebility/app/home/settings/news-banners/_components/NewsBannersClient.tsx new file mode 100644 index 000000000..c947e61ac --- /dev/null +++ b/apps/codebility/app/home/settings/news-banners/_components/NewsBannersClient.tsx @@ -0,0 +1,271 @@ +"use client"; + +import { useState } from "react"; +import { useRouter } from "next/navigation"; +import { Plus, Pencil, Trash2, Eye, EyeOff } from "lucide-react"; +import { Button } from "@codevs/ui/button"; +import { H1 } from "@/components/shared/dashboard"; +import PageContainer from "../../../_components/PageContainer"; +import NewsBannerForm from "./NewsBannerForm"; +import { getClientSupabase } from "@/utils/supabase/client"; +import { toast } from "sonner"; + +export interface NewsBanner { + id: string; + title: string; + message: string; + type: "info" | "warning" | "success" | "error" | "announcement"; + image_url?: string; + is_active: boolean; + priority: number; + start_date: string; + end_date?: string; + created_at: string; +} + +const typeColors = { + info: "bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-200", + warning: "bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-200", + success: "bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-200", + error: "bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-200", + announcement: "bg-purple-100 text-purple-800 dark:bg-purple-900/30 dark:text-purple-200", +}; + +interface NewsBannersClientProps { + banners: NewsBanner[]; +} + +export default function NewsBannersClient({ banners }: NewsBannersClientProps) { + const router = useRouter(); + const [showForm, setShowForm] = useState(false); + const [editingBanner, setEditingBanner] = useState(null); + + const toggleBannerStatus = async (banner: NewsBanner) => { + const supabase = getClientSupabase(); + + const { error } = await supabase + .from("news_banners") + .update({ is_active: !banner.is_active, updated_at: new Date().toISOString() }) + .eq("id", banner.id); + + if (error) { + toast.error("Failed to update banner status"); + } else { + toast.success(`Banner ${!banner.is_active ? "activated" : "deactivated"}`); + router.refresh(); + } + }; + + const deleteBanner = async (bannerId: string) => { + if (!confirm("Are you sure you want to delete this banner?")) return; + + const supabase = getClientSupabase(); + + const { error } = await supabase + .from("news_banners") + .delete() + .eq("id", bannerId); + + if (error) { + toast.error("Failed to delete banner"); + } else { + toast.success("Banner deleted successfully"); + router.refresh(); + } + }; + + const handleFormSuccess = () => { + setShowForm(false); + setEditingBanner(null); + router.refresh(); + }; + + const formatDate = (dateString: string) => { + return new Date(dateString).toLocaleDateString("en-US", { + year: "numeric", + month: "short", + day: "numeric", + hour: "2-digit", + minute: "2-digit", + }); + }; + + return ( + +
+
+
+ 📢 +
+
+

+ News Banners +

+

+ Manage announcements and notifications for the home page +

+
+
+ +
+ + {(showForm || editingBanner) && ( +
+
+
+
+ + {editingBanner ? "✏️" : "➕"} + +
+

+ {editingBanner ? "Edit Banner" : "Create New Banner"} +

+
+ +
+ +
+ )} + +
+ {banners.length === 0 ? ( +
+
+
+ 📢 +
+
+

+ No banners yet +

+

+ Create your first banner to start showing announcements on the home page +

+
+ +
+
+ ) : ( + banners.map((banner) => ( +
+
+
+ {/* Banner Image Preview */} + {banner.image_url && ( +
+ {banner.title} +
+ )} + +
+
+

{banner.title}

+ + {banner.type} + + + {banner.is_active ? "Active" : "Inactive"} + + + Priority: {banner.priority} + +
+

+ {banner.message} +

+
+
Created: {formatDate(banner.start_date)}
+ {banner.end_date && ( +
Expires: {formatDate(banner.end_date)}
+ )} +
+
+
+ +
+ + + +
+
+
+ )) + )} +
+
+ ); +} diff --git a/apps/codebility/app/home/settings/news-banners/loading.tsx b/apps/codebility/app/home/settings/news-banners/loading.tsx new file mode 100644 index 000000000..344408774 --- /dev/null +++ b/apps/codebility/app/home/settings/news-banners/loading.tsx @@ -0,0 +1,7 @@ +import { ListSkeleton } from "@/app/home/_components/skeletons/RouteSkeletons"; + +const Loading = () => { + return ; +}; + +export default Loading; diff --git a/apps/codebility/app/home/settings/news-banners/page.tsx b/apps/codebility/app/home/settings/news-banners/page.tsx index 9859724c5..cfe35e1c9 100644 --- a/apps/codebility/app/home/settings/news-banners/page.tsx +++ b/apps/codebility/app/home/settings/news-banners/page.tsx @@ -1,299 +1,39 @@ -"use client"; - -import { useState, useEffect } from "react"; -import { Plus, Pencil, Trash2, Eye, EyeOff } from "lucide-react"; -import { Button } from "@codevs/ui/button"; -import { H1 } from "@/components/shared/dashboard"; +import { Suspense } from "react"; +import { createClientServerComponent } from "@/utils/supabase/server"; import PageContainer from "../../_components/PageContainer"; -import NewsBannerForm from "./_components/NewsBannerForm"; -import { createClientClientComponent } from "@/utils/supabase/client"; -import { toast } from "sonner"; - -interface NewsBanner { - id: string; - title: string; - message: string; - type: "info" | "warning" | "success" | "error" | "announcement"; - image_url?: string; - is_active: boolean; - priority: number; - start_date: string; - end_date?: string; - created_at: string; -} - -const typeColors = { - info: "bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-200", - warning: "bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-200", - success: "bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-200", - error: "bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-200", - announcement: "bg-purple-100 text-purple-800 dark:bg-purple-900/30 dark:text-purple-200", -}; - -export default function NewsBannersPage() { - const [banners, setBanners] = useState([]); - const [loading, setLoading] = useState(true); - const [showForm, setShowForm] = useState(false); - const [editingBanner, setEditingBanner] = useState(null); - - useEffect(() => { - fetchBanners(); - }, []); - - const fetchBanners = async () => { - const supabase = createClientClientComponent(); - - const { data, error } = await supabase - .from("news_banners") - .select("*") - .order("priority", { ascending: false }) - .order("created_at", { ascending: false }); - - if (error) { - console.error("Error fetching banners:", error); - toast.error("Failed to fetch banners"); - } else { - setBanners(data || []); - } - setLoading(false); - }; - - const toggleBannerStatus = async (banner: NewsBanner) => { - const supabase = createClientClientComponent(); - - const { error } = await supabase - .from("news_banners") - .update({ is_active: !banner.is_active, updated_at: new Date().toISOString() }) - .eq("id", banner.id); - - if (error) { - toast.error("Failed to update banner status"); - } else { - toast.success(`Banner ${!banner.is_active ? "activated" : "deactivated"}`); - fetchBanners(); - } - }; - - const deleteBanner = async (bannerId: string) => { - if (!confirm("Are you sure you want to delete this banner?")) return; - - const supabase = createClientClientComponent(); - - const { error } = await supabase - .from("news_banners") - .delete() - .eq("id", bannerId); +import NewsBannersClient from "./_components/NewsBannersClient"; +import type { NewsBanner } from "./_components/NewsBannersClient"; - if (error) { - toast.error("Failed to delete banner"); - } else { - toast.success("Banner deleted successfully"); - fetchBanners(); - } - }; +async function NewsBannersData() { + const supabase = await createClientServerComponent(); - const handleFormSuccess = () => { - setShowForm(false); - setEditingBanner(null); - fetchBanners(); - }; + const { data, error } = await supabase + .from("news_banners") + .select("id, title, message, type, image_url, is_active, priority, start_date, end_date, created_at") + .order("priority", { ascending: false }) + .order("created_at", { ascending: false }); - const formatDate = (dateString: string) => { - return new Date(dateString).toLocaleDateString("en-US", { - year: "numeric", - month: "short", - day: "numeric", - hour: "2-digit", - minute: "2-digit", - }); - }; - - if (loading) { - return ( - -
-
-
-
- ); + if (error) { + console.error("Error fetching banners:", error); } - return ( - -
-
-
- 📢 -
-
-

- News Banners -

-

- Manage announcements and notifications for the home page -

-
-
- -
- - {(showForm || editingBanner) && ( -
-
-
-
- - {editingBanner ? "✏️" : "➕"} - -
-

- {editingBanner ? "Edit Banner" : "Create New Banner"} -

-
- -
- -
- )} + return ; +} -
- {banners.length === 0 ? ( -
-
-
- 📢 -
-
-

- No banners yet -

-

- Create your first banner to start showing announcements on the home page -

-
- -
-
- ) : ( - banners.map((banner) => ( -
-
-
- {/* Banner Image Preview */} - {banner.image_url && ( -
- {banner.title} -
- )} - -
-
-

{banner.title}

- - {banner.type} - - - {banner.is_active ? "Active" : "Inactive"} - - - Priority: {banner.priority} - -
-

- {banner.message} -

-
-
Created: {formatDate(banner.start_date)}
- {banner.end_date && ( -
Expires: {formatDate(banner.end_date)}
- )} -
-
-
- -
- - - -
-
-
- )) - )} +function NewsBannersLoading() { + return ( + +
+
); -} \ No newline at end of file +} + +export default function NewsBannersPage() { + return ( + }> + + + ); +} diff --git a/apps/codebility/app/home/settings/profile/_components/About.tsx b/apps/codebility/app/home/settings/profile/_components/About.tsx index 68b7e9a89..e39d99ba0 100644 --- a/apps/codebility/app/home/settings/profile/_components/About.tsx +++ b/apps/codebility/app/home/settings/profile/_components/About.tsx @@ -3,6 +3,10 @@ import { useState, useEffect } from "react"; import Box from "@/components/shared/dashboard/Box"; import { Button } from "@/components/ui/button"; +import { + useInvalidateProfilePoints, + useProfilePoints, +} from "@/hooks/query/use-profile-points"; import { IconEdit } from "@/public/assets/svgs"; import { Codev } from "@/types/home/codev"; import { useForm } from "react-hook-form"; @@ -11,7 +15,7 @@ import toast from "react-hot-toast"; import { Label } from "@codevs/ui/label"; import { Textarea } from "@codevs/ui/textarea"; -import { updateCodev } from "../action"; +import { updateCodev } from "@/actions/settings/profile"; type AboutProps = { data: Codev; @@ -21,12 +25,21 @@ type FormValues = { about: string; }; +type ProfilePointEntry = { category: string; points: number }; + const About = ({ data }: AboutProps) => { const [isEditMode, setIsEditMode] = useState(false); const [isLoading, setIsLoading] = useState(false); - const [hasAboutPoints, setHasAboutPoints] = useState(false); const [isExpanded, setIsExpanded] = useState(false); + const { data: profilePoints } = useProfilePoints(data.id); + const invalidateProfilePoints = useInvalidateProfilePoints(); + + // Has the user earned points for the 'about' field? + const aboutPoint = ( + (profilePoints?.points ?? []) as ProfilePointEntry[] + ).find((point) => point.category === "about"); + const hasAboutPoints = !!aboutPoint && aboutPoint.points > 0; const { register, @@ -47,28 +60,6 @@ const About = ({ data }: AboutProps) => { else setIsExpanded(false); }, [isEditMode]); - // Check if user has earned points for the 'about' field - useEffect(() => { - async function checkAboutPoints() { - if (!data.id) return; - - try { - const res = await fetch(`/api/profile-points/${data.id}`); - if (res.ok) { - const pointsData: { points?: { category: string; points: number }[] } = await res.json() as { points?: { category: string; points: number }[] }; - const aboutPoint = pointsData?.points?.find( - (point) => point.category === 'about' - ); - setHasAboutPoints(!!aboutPoint && aboutPoint.points > 0); - } - } catch (error) { - console.error("Failed to check about points:", error); - } - } - - checkAboutPoints(); - }, [data.id, data.about]); // Re-check when about field changes - const onSubmit = async (formData: FormValues) => { const toastId = toast.loading("Your info is being updated"); try { @@ -76,16 +67,9 @@ const About = ({ data }: AboutProps) => { await updateCodev(formData); toast.success("Your about was successfully updated!", { id: toastId }); setIsEditMode(false); - + // Re-check points after update - const res = await fetch(`/api/profile-points/${data.id}`); - if (res.ok) { - const pointsData: { points?: { category: string; points: number }[] } = await res.json() as { points?: { category: string; points: number }[] }; - const aboutPoint = pointsData?.points?.find( - (point) => point.category === 'about' - ); - setHasAboutPoints(!!aboutPoint && aboutPoint.points > 0); - } + await invalidateProfilePoints(data.id); } catch (error) { console.error(error); toast.error("Something went wrong, please try again later!"); diff --git a/apps/codebility/app/home/settings/profile/_components/ContactInfo.tsx b/apps/codebility/app/home/settings/profile/_components/ContactInfo.tsx index d3b309993..e8280eaff 100644 --- a/apps/codebility/app/home/settings/profile/_components/ContactInfo.tsx +++ b/apps/codebility/app/home/settings/profile/_components/ContactInfo.tsx @@ -4,13 +4,21 @@ import { useEffect, useState } from "react"; import Box from "@/components/shared/dashboard/Box"; import InputField from "@/components/shared/dashboard/InputPhone"; import { Button } from "@/components/ui/button"; +import { + useInvalidateProfilePoints, + useProfilePoints, +} from "@/hooks/query/use-profile-points"; import { IconEdit } from "@/public/assets/svgs"; import { useForm } from "react-hook-form"; import toast from "react-hot-toast"; import { Input } from "@codevs/ui/input"; -import { updateSocialLinks } from "../action"; +import { updateSocialLinks } from "@/actions/settings/profile"; +import { + fetchProfilePoints, + invalidateProfilePoints, +} from "@/lib/client/profile-points"; type ContactInfoProps = { data: { @@ -36,7 +44,6 @@ type FormValues = { const ContactInfo = ({ data }: ContactInfoProps) => { const [isEditMode, setIsEditMode] = useState(false); const [isLoading, setIsLoading] = useState(false); - const [hasContactPoints, setHasContactPoints] = useState(false); const { register, @@ -59,32 +66,16 @@ const ContactInfo = ({ data }: ContactInfoProps) => { // Watch all contact fields const watchedFields = watch(); - // Check if user has earned points for any contact field - useEffect(() => { - async function checkContactPoints() { - if (!data.id) return; - - try { - const res = await fetch(`/api/profile-points/${data.id}`); - if (res.ok) { - const pointsData: { points?: { category: string; points: number }[] } = - await res.json() as { points?: { category: string; points: number }[] }; - - // Check if any contact-related categories have points - const contactCategories = ['phone_number', 'github', 'facebook', 'linkedin', 'discord', 'portfolio_website']; - const hasAnyContactPoints = pointsData?.points?.some( - (point) => contactCategories.includes(point.category) && point.points > 0 - ); - - setHasContactPoints(!!hasAnyContactPoints); - } - } catch (error) { - console.error("Failed to check contact points:", error); - } - } - - checkContactPoints(); - }, [data.id, data.phone_number, data.github, data.facebook, data.linkedin, data.discord, data.portfolio_website]); + const { data: points } = useProfilePoints(data.id); + const invalidatePoints = useInvalidateProfilePoints(); + const hasContactPoints = [ + "phone_number", + "github", + "facebook", + "linkedin", + "discord", + "portfolio_website", + ].some((key) => points?.completionDetails?.[key]?.completed); const onSubmit = async (formData: FormValues) => { const toastId = toast.loading("Your contact info is being updated"); @@ -96,20 +87,10 @@ const ContactInfo = ({ data }: ContactInfoProps) => { }); setIsEditMode(false); - // Re-check points after update + // Re-check points after update; invalidation refetches the shared query. if (data.id) { - const res = await fetch(`/api/profile-points/${data.id}`); - if (res.ok) { - const pointsData: { points?: { category: string; points: number }[] } = - await res.json() as { points?: { category: string; points: number }[] }; - - const contactCategories = ['phone_number', 'github', 'facebook', 'linkedin', 'discord', 'portfolio_website']; - const hasAnyContactPoints = pointsData?.points?.some( - (point) => contactCategories.includes(point.category) && point.points > 0 - ); - - setHasContactPoints(!!hasAnyContactPoints); - } + invalidateProfilePoints(data.id); + await invalidatePoints(data.id); } } catch (error) { console.error(error); diff --git a/apps/codebility/app/home/settings/profile/_components/EducationalBackground.tsx b/apps/codebility/app/home/settings/profile/_components/EducationalBackground.tsx index 0d0dd2a95..1f8ef9569 100644 --- a/apps/codebility/app/home/settings/profile/_components/EducationalBackground.tsx +++ b/apps/codebility/app/home/settings/profile/_components/EducationalBackground.tsx @@ -14,7 +14,8 @@ import { createEducation, deleteEducation, updateEducation, -} from "../action"; +} from "@/actions/settings/profile"; +import { useProfilePoints } from "@/hooks/query/use-profile-points"; interface EducationProps { data: Education[]; @@ -43,33 +44,16 @@ interface EducationFormProps { const EducationalBackground = ({ data, codevId }: EducationProps) => { const [educationData, setEducationData] = useState(data); const [isLoadingMain, setIsLoadingMain] = useState(false); - const [hasEducationPoints, setHasEducationPoints] = useState(false); const editModePerItem = useRef({}); - // Check if user has earned points for education - useEffect(() => { - async function checkEducationPoints() { - if (!codevId) return; - - try { - const res = await fetch(`/api/profile-points/${codevId}`); - if (res.ok) { - const pointsData: { points?: { category: string; points: number }[] } = - await res.json() as { points?: { category: string; points: number }[] }; - - const educationPoint = pointsData?.points?.find( - (point) => point.category === 'education' - ); - - setHasEducationPoints(!!educationPoint && educationPoint.points > 0); - } - } catch (error) { - console.error("Failed to check education points:", error); - } - } + const { data: pointsData } = useProfilePoints(codevId); - checkEducationPoints(); - }, [codevId, educationData.length]); + const hasEducationPoints = Boolean( + pointsData?.points?.some((point) => { + const p = point as { category?: string; points?: number }; + return p.category === "education" && (p.points ?? 0) > 0; + }), + ); const handleUpdateEducation = ( itemNo: number, diff --git a/apps/codebility/app/home/settings/profile/_components/Experience.tsx b/apps/codebility/app/home/settings/profile/_components/Experience.tsx index ddb92824f..e27f71643 100644 --- a/apps/codebility/app/home/settings/profile/_components/Experience.tsx +++ b/apps/codebility/app/home/settings/profile/_components/Experience.tsx @@ -14,7 +14,8 @@ import { createWorkExperience, deleteWorkExperience, updateWorkExperience, -} from "../action"; +} from "@/actions/settings/profile"; +import { useProfilePoints } from "@/hooks/query/use-profile-points"; interface ExperienceProps { data: WorkExperience[]; @@ -43,33 +44,16 @@ interface ExperienceFormProps { const Experience = ({ data, codevId }: ExperienceProps) => { const [experienceData, setExperienceData] = useState(data); const [isLoadingMain, setIsLoadingMain] = useState(false); - const [hasWorkExperiencePoints, setHasWorkExperiencePoints] = useState(false); const editModePerItem = useRef({}); - // Check if user has earned points for work experience - useEffect(() => { - async function checkWorkExperiencePoints() { - if (!codevId) return; - - try { - const res = await fetch(`/api/profile-points/${codevId}`); - if (res.ok) { - const pointsData: { points?: { category: string; points: number }[] } = - await res.json() as { points?: { category: string; points: number }[] }; - - const workExpPoint = pointsData?.points?.find( - (point) => point.category === 'work_experience' - ); - - setHasWorkExperiencePoints(!!workExpPoint && workExpPoint.points > 0); - } - } catch (error) { - console.error("Failed to check work experience points:", error); - } - } + const { data: pointsData } = useProfilePoints(codevId); - checkWorkExperiencePoints(); - }, [codevId, experienceData.length]); + const hasWorkExperiencePoints = Boolean( + pointsData?.points?.some((point) => { + const p = point as { category?: string; points?: number }; + return p.category === "work_experience" && (p.points ?? 0) > 0; + }), + ); const handleUpdateExperience = ( itemNo: number, diff --git a/apps/codebility/app/home/settings/profile/_components/JobStatuses.tsx b/apps/codebility/app/home/settings/profile/_components/JobStatuses.tsx index c3b573fe3..76f03575b 100644 --- a/apps/codebility/app/home/settings/profile/_components/JobStatuses.tsx +++ b/apps/codebility/app/home/settings/profile/_components/JobStatuses.tsx @@ -19,7 +19,7 @@ import toast from "react-hot-toast"; import { Input } from "@codevs/ui/input"; -import { createJobStatus, deleteJobStatus, updateJobStatus } from "../action"; +import { createJobStatus, deleteJobStatus, updateJobStatus } from "@/actions/settings/profile"; interface JobStatusProps { data: JobStatus[]; diff --git a/apps/codebility/app/home/settings/profile/_components/PersonalInfo.tsx b/apps/codebility/app/home/settings/profile/_components/PersonalInfo.tsx index 09c74b771..e6e7c1bb6 100644 --- a/apps/codebility/app/home/settings/profile/_components/PersonalInfo.tsx +++ b/apps/codebility/app/home/settings/profile/_components/PersonalInfo.tsx @@ -1,6 +1,6 @@ "use client"; -import { useEffect, useState } from "react"; +import { useState } from "react"; import Box from "@/components/shared/dashboard/Box"; import { Button } from "@/components/ui/button"; import { @@ -13,14 +13,15 @@ import { SelectValue, } from "@/components/ui/select"; import { IconEdit } from "@/public/assets/svgs"; -import { Codev, Position } from "@/types/home/codev"; +import { Codev } from "@/types/home/codev"; +import type { PositionOption } from "@/lib/server/reference-data"; import { useForm } from "react-hook-form"; import toast from "react-hot-toast"; import { Input } from "@codevs/ui/input"; import { Label } from "@codevs/ui/label"; -import { getPositions, updateCodev } from "../action"; +import { updateCodev } from "@/actions/settings/profile"; type PersonalInfoProps = { data: Codev; @@ -35,8 +36,10 @@ type FormValues = { headline: string | undefined; }; -const PersonalInfo = ({ data }: PersonalInfoProps) => { - const [positions, setPositions] = useState([]); +const PersonalInfo = ({ + data, + positions, +}: PersonalInfoProps & { positions: PositionOption[] }) => { const [isEditMode, setIsEditMode] = useState(false); const [isLoading, setIsLoading] = useState(false); @@ -58,26 +61,6 @@ const PersonalInfo = ({ data }: PersonalInfoProps) => { }, }); - const fetchPositions = async () => { - try { - const { data, error } = await getPositions(); - if (error) { - console.error("Error fetching positions:", error); - toast.error("Failed to fetch positions"); - } else if (data) { - setPositions(data); - } - } catch (err) { - console.error("Error fetching positions:", err); - toast.error("Failed to fetch positions"); - } - }; - - // On mount, fetch positions - useEffect(() => { - fetchPositions(); - }, []); - const onSubmit = async (formData: FormValues) => { const toastId = toast.loading("Updating your information"); try { @@ -173,7 +156,7 @@ const PersonalInfo = ({ data }: PersonalInfoProps) => { Select Position {/* Map the positions from DB */} - {positions.map((position: Position) => ( + {positions.map((position: PositionOption) => ( { const [avatar, setAvatar] = useState(defaultAvatar); const [isUploading, setIsUploading] = useState(false); - const [hasImagePoints, setHasImagePoints] = useState(false); const { onOpen } = useModal(); const [croppedAvatar, setCroppedAvatar] = useState(null); @@ -37,30 +37,8 @@ const Photo = ({ data }: PhotoProps) => { } }, [data?.image_url]); - // Check if user has earned points for uploading a photo - useEffect(() => { - async function checkImagePoints() { - if (!data.id) return; - - try { - const res = await fetch(`/api/profile-points/${data.id}`); - if (res.ok) { - const pointsData: { points?: { category: string; points: number }[] } = - await res.json() as { points?: { category: string; points: number }[] }; - - const imagePoint = pointsData?.points?.find( - (point) => point.category === 'image_url' - ); - - setHasImagePoints(!!imagePoint && imagePoint.points > 0); - } - } catch (error) { - console.error("Failed to check image points:", error); - } - } - - checkImagePoints(); - }, [data.id, data.image_url]); + const { data: points } = useProfilePoints(data.id); + const hasImagePoints = points?.completionDetails?.image_url?.completed ?? false; const handleUploadAvatar = async ( event: React.ChangeEvent, diff --git a/apps/codebility/app/home/settings/profile/_components/ProfileCompletionGuide.tsx b/apps/codebility/app/home/settings/profile/_components/ProfileCompletionGuide.tsx index ba4ab84cd..e72c1d9aa 100644 --- a/apps/codebility/app/home/settings/profile/_components/ProfileCompletionGuide.tsx +++ b/apps/codebility/app/home/settings/profile/_components/ProfileCompletionGuide.tsx @@ -1,15 +1,17 @@ "use client"; -import { useState, useEffect } from "react"; +import { useState } from "react"; import { ChevronDown, ChevronUp, Minimize2, Maximize2, NotebookTabs, CheckCircle2, Circle, TrendingUp, Award } from "lucide-react"; import { Box } from "@/components/shared/dashboard"; -import { createClientClientComponent } from "@/utils/supabase/client"; +import { useUserStore } from "@/store/codev-store"; +import { + useProfilePoints, + type ProfilePointsResponse, +} from "@/hooks/query/use-profile-points"; -// Types for profile points data -interface ProfilePointsData { - totalPoints: number; - maxPossiblePoints: number; - completionPercentage: number; +// The endpoint returns more per-category detail than the shared response type +// models, so narrow it here rather than widening the type every caller shares. +interface ProfilePointsData extends ProfilePointsResponse { completionDetails: Record; - summary: { - profileSections: { - basicInfo: { points: number; maxPoints: number; completed: boolean }; - socialLinks: { points: number; maxPoints: number; completed: boolean }; - professionalInfo: { points: number; maxPoints: number; completed: boolean }; - }; - datacounts: { - workExperiences: number; - educationEntries: number; - techSkills: number; - positions: number; - }; - }; } const Badge = ({ @@ -128,10 +117,15 @@ const SectionProgress = ({ export default function ProfileCompletionGuide() { const [isExpanded, setIsExpanded] = useState(false); - const [loading, setLoading] = useState(true); - const [profileData, setProfileData] = useState(null); - const [error, setError] = useState(null); const [openDropdowns, setOpenDropdowns] = useState>({}); + const codevId = useUserStore((s) => s.user?.id ?? null); + + const { + data: profileData, + isLoading: loading, + error, + refetch: loadProfilePoints, + } = useProfilePoints(codevId ?? undefined); // Enhanced task definitions that map to actual profile points categories const getTasksFromProfileData = (data: ProfilePointsData) => [ @@ -217,39 +211,6 @@ export default function ProfileCompletionGuide() { }, ]; - useEffect(() => { - fetchProfilePoints(); - }, []); - - const fetchProfilePoints = async () => { - try { - setLoading(true); - setError(null); - - const supabase = createClientClientComponent(); - const { data: { user } } = await supabase.auth.getUser(); - - if (!user) { - setError("Please log in to view profile completion"); - return; - } - - const response = await fetch(`/api/profile-points/${user.id}`); - - if (!response.ok) { - throw new Error(`Failed to fetch profile points: ${response.statusText}`); - } - - const data = await response.json(); - setProfileData(data); - } catch (err) { - console.error("Error fetching profile points:", err); - setError(err instanceof Error ? err.message : "Failed to load profile completion data"); - } finally { - setLoading(false); - } - }; - const toggleDropdown = (taskId: string) => { setOpenDropdowns((prev) => ({ ...prev, @@ -277,9 +238,9 @@ export default function ProfileCompletionGuide() {

Failed to load profile completion data

-

{error}

+

{error.message}

+
+ + {!showPreview ? ( + /* Services List */ +
+ {services.length === 0 ? ( +
+
+
+ +
+
+

+ No services found +

+

+ No services available +

+
+
+
+ ) : ( + <> +
+
+
+

+ Total Services: {services.length} +

+

+ Categories: {[...new Set(services.map(s => s.category))].join(", ")} +

+
+
+
+ + {services.map((service) => ( +
+
+
+
+

{service.name}

+ + {service.category} + +
+

{service.description}

+
+ {service.price && ( + + {service.price} + + )} + {service.duration && ( + + Duration: {service.duration} + + )} +
+
+ {service.features.map((feature, index) => ( + + {feature} + + ))} +
+
+
+
+ ))} + + )} +
+ ) : ( + /* Preview Page View */ +
+ {/* Header with navigation */} +
+
+ + +
+ + Page {currentPage + 1} of {totalPages} + + +
+ + +
+ + +
+
+
+ + {/* Page Content with more spacing */} +
+ {getCurrentPageContent()} +
+ + {/* Hidden container with ALL pages for PDF generation */} +
+ {/* Cover Page */} +
+ +
+ + {/* All Service Pages - render full page with A4 boundary markers */} + {services.map((service, index) => ( +
+ + {/* A4 Page boundary markers - show where PDF will split */} +
+
+
+
+ ))} + + {/* Summary Page */} +
+ +
+
+
+ )} + + ); +} diff --git a/apps/codebility/app/home/settings/services/cms-diagnostic/loading.tsx b/apps/codebility/app/home/settings/services/cms-diagnostic/loading.tsx new file mode 100644 index 000000000..344408774 --- /dev/null +++ b/apps/codebility/app/home/settings/services/cms-diagnostic/loading.tsx @@ -0,0 +1,7 @@ +import { ListSkeleton } from "@/app/home/_components/skeletons/RouteSkeletons"; + +const Loading = () => { + return ; +}; + +export default Loading; diff --git a/apps/codebility/app/home/settings/services/diagnostic/loading.tsx b/apps/codebility/app/home/settings/services/diagnostic/loading.tsx new file mode 100644 index 000000000..344408774 --- /dev/null +++ b/apps/codebility/app/home/settings/services/diagnostic/loading.tsx @@ -0,0 +1,7 @@ +import { ListSkeleton } from "@/app/home/_components/skeletons/RouteSkeletons"; + +const Loading = () => { + return ; +}; + +export default Loading; diff --git a/apps/codebility/app/home/settings/services/loading.tsx b/apps/codebility/app/home/settings/services/loading.tsx new file mode 100644 index 000000000..49bfee30a --- /dev/null +++ b/apps/codebility/app/home/settings/services/loading.tsx @@ -0,0 +1,7 @@ +import { CardsSkeleton } from "@/app/home/_components/skeletons/RouteSkeletons"; + +const Loading = () => { + return ; +}; + +export default Loading; diff --git a/apps/codebility/app/home/settings/services/page.tsx b/apps/codebility/app/home/settings/services/page.tsx index 74bb30230..5908b227a 100644 --- a/apps/codebility/app/home/settings/services/page.tsx +++ b/apps/codebility/app/home/settings/services/page.tsx @@ -1,1645 +1,26 @@ -"use client"; - -import { useState, useEffect, useRef } from "react"; -import { DollarSign, Eye, X, ChevronLeft, ChevronRight } from "lucide-react"; -import { Button } from "@codevs/ui/button"; -import { H1 } from "@/components/shared/dashboard"; -import PageContainer from "../../_components/PageContainer"; -import { toast } from "sonner"; -import Image from "next/image"; -import { getRealProjects, RealProject, getCodevProfiles } from "./actions"; -import { techstacks } from '@/constants/techstack'; - -interface Service { - id: string; - name: string; - description: string; - category: string; - features: string[]; - price?: string; - duration?: string; -} - -export default function ServicesPage() { - const [realProjects, setRealProjects] = useState([]); - const [codevProfiles, setCodevProfiles] = useState([]); - const [isLoadingProjects, setIsLoadingProjects] = useState(true); - const [services] = useState([ - { - id: "1", - name: "Web Application Development", - description: "Full-stack web application development using modern technologies like React, Next.js, Node.js, and database integration.", - category: "Development", - features: [ - "Custom web application development", - "Responsive design for all devices", - "Database design and integration", - "API development and integration", - "Modern frameworks (React, Next.js)", - "SEO optimization", - "Performance optimization" - ], - price: "Starting from $5,000", - duration: "4-8 weeks" - }, - { - id: "2", - name: "Mobile Application Development", - description: "Cross-platform mobile application development for iOS and Android using React Native and modern mobile technologies.", - category: "Mobile", - features: [ - "iOS and Android development", - "Cross-platform compatibility", - "Native performance", - "App Store deployment", - "Push notifications", - "Offline functionality", - "Real-time features" - ], - price: "Starting from $8,000", - duration: "6-12 weeks" - }, - { - id: "3", - name: "Codev for Hire", - description: "Skilled developers available for hire to join your team or work on specific projects. Our codevs are experienced in various technologies.", - category: "Staffing", - features: [ - "Experienced developers", - "Full-time or part-time availability", - "Various technology stacks", - "Flexible engagement models", - "Quality assurance", - "Team integration", - "Ongoing support" - ], - price: "Starting from $8/hour", - duration: "Flexible" - }, - { - id: "4", - name: "Product Design", - description: "Complete product design services from user research to final designs, creating intuitive and engaging digital experiences.", - category: "Design", - features: [ - "User research and analysis", - "Wireframing and prototyping", - "Visual design and branding", - "Interactive prototypes", - "Design system creation", - "Usability testing", - "Design handoff to developers" - ], - price: "Starting from $1,000", - duration: "3-6 weeks" - }, - { - id: "5", - name: "CMS Service", - description: "Content Management System development and customization for easy content updates, multi-user access, and dynamic website management.", - category: "Development", - features: [ - "Custom CMS development", - "WordPress & Headless CMS", - "Content workflow management", - "Multi-user permissions", - "SEO-friendly structure", - "Media library management", - "API integration support" - ], - price: "Starting from $3,000", - duration: "6-10 weeks" - }, - { - id: "6", - name: "AI Development", - description: "Artificial Intelligence and Machine Learning solutions to automate processes, enhance user experiences, and unlock data-driven insights.", - category: "AI & ML", - features: [ - "Machine Learning model development", - "Natural Language Processing (NLP)", - "Computer Vision solutions", - "AI-powered chatbots", - "Predictive analytics", - "Data pipeline automation", - "Model deployment and monitoring" - ], - price: "Starting from $12,000", - duration: "8-16 weeks" - } +import { + getRealProjects, + getCodevProfiles, +} from "@/actions/settings/services"; + +import ServicesPageClient from "./_components/ServicesPageClient"; + +// Both actions read cookies through the Supabase server client, so this route +// cannot be statically prerendered. +export const dynamic = "force-dynamic"; + +export default async function ServicesPage() { + // Both datasets were fetched in a client mount effect; resolving them here + // means the catalog renders complete on first paint with no round trip. + const [projectsResult, codevsResult] = await Promise.all([ + getRealProjects(), + getCodevProfiles(), ]); - const [loading, setLoading] = useState(false); - const [showPreview, setShowPreview] = useState(false); - const [currentPage, setCurrentPage] = useState(0); - - // Fetch real projects and codev profiles on component mount - useEffect(() => { - const fetchData = async () => { - try { - const [projectsResult, codevsResult] = await Promise.all([ - getRealProjects(), - getCodevProfiles() - ]); - - if (projectsResult.data && !projectsResult.error) { - setRealProjects(projectsResult.data); - } - - if (codevsResult.data && !codevsResult.error) { - setCodevProfiles(codevsResult.data); - } - } finally { - setIsLoadingProjects(false); - } - }; - fetchData(); - }, []); - - // Calculate total pages (cover + services + summary) - const totalPages = 1 + services.length + 1; - - const getCurrentPageContent = () => { - if (currentPage === 0) { - return ; - } else if (currentPage <= services.length) { - const serviceIndex = currentPage - 1; - const service = services[serviceIndex]; - if (!service) return null; - return ; - } else { - return ; - } - }; - - const nextPage = () => { - if (currentPage < totalPages - 1) { - setCurrentPage(currentPage + 1); - } - }; - - const prevPage = () => { - if (currentPage > 0) { - setCurrentPage(currentPage - 1); - } - }; - - const generateServicesPDF = async () => { - if (services.length === 0) { - toast.error("No services available to export"); - return; - } - - if (!showPreview) { - toast.error("Please open the preview first, then click Download PDF from within the preview"); - return; - } - - if (isLoadingProjects) { - toast.error("Projects are still loading. Please wait a moment and try again."); - return; - } - - try { - toast("Generating PDF with all pages...", { duration: 5000 }); - - // Dynamically import libraries - const html2canvas = (await import('html2canvas')).default; - const { default: jsPDF } = await import('jspdf'); - - // Get all page elements (exclude split preview pages, only use original pages for capture) - const pages = document.querySelectorAll('#pdf-all-pages-hidden .pdf-page:not(.pdf-split-page)'); - - if (pages.length === 0) { - toast.error("No pages found to export"); - return; - } - - // Create new PDF - const pdf = new jsPDF({ - orientation: 'portrait', - unit: 'mm', - format: 'a4' - }); - - // Create an overlay to hide the rendering from user - const overlay = document.createElement('div'); - overlay.style.position = 'fixed'; - overlay.style.top = '0'; - overlay.style.left = '0'; - overlay.style.width = '100vw'; - overlay.style.height = '100vh'; - overlay.style.background = 'linear-gradient(135deg, #1e293b 0%, #312e81 50%, #1e1b4b 100%)'; - overlay.style.zIndex = '999999'; - overlay.style.display = 'flex'; - overlay.style.alignItems = 'center'; - overlay.style.justifyContent = 'center'; - overlay.innerHTML = '
Generating PDF...
Please wait while we capture all pages
'; - document.body.appendChild(overlay); - - // Capture each page - for (let i = 0; i < pages.length; i++) { - const page = pages[i] as HTMLElement; - - // Update overlay message - overlay.innerHTML = `
Generating PDF...
Capturing page ${i + 1} of ${pages.length}
`; - - // Temporarily show the page on-screen but behind the overlay - const originalPosition = page.style.position; - const originalLeft = page.style.left; - const originalTop = page.style.top; - const originalZIndex = page.style.zIndex; - - page.style.position = 'fixed'; - page.style.left = '0'; - page.style.top = '0'; - page.style.zIndex = '999998'; // Just below the overlay - - // Wait for render - await new Promise(resolve => setTimeout(resolve, 300)); - - // Get actual page dimensions and define A4 constants - const a4HeightPx = 1123; // A4 height in pixels at 96 DPI - const pageHeight = page.scrollHeight; - const pageWidth = page.offsetWidth; - - // Capture with html2canvas without forcing dimensions - const canvas = await html2canvas(page, { - scale: 2, - useCORS: true, - logging: false, - backgroundColor: '#0f172a' - }); - - // Hide the page again - page.style.position = originalPosition; - page.style.left = originalLeft; - page.style.top = originalTop; - page.style.zIndex = originalZIndex; - - // Calculate how many PDF pages this content needs - const a4HeightMm = 297; // A4 height in mm - const a4WidthMm = 210; // A4 width in mm - - const numPages = Math.ceil(pageHeight / a4HeightPx); - - // Split the canvas into multiple PDF pages if needed - for (let j = 0; j < numPages; j++) { - if (i > 0 || j > 0) { - pdf.addPage(); - } - - // Set PDF page background color - pdf.setFillColor(15, 23, 42); // #0f172a in RGB - pdf.rect(0, 0, a4WidthMm, a4HeightMm, 'F'); - - // Calculate the portion of the canvas to use - const sourceY = j * a4HeightPx * 2; // *2 because canvas is scaled at 2x - const sourceHeight = Math.min(a4HeightPx * 2, canvas.height - sourceY); - - // Skip if sourceHeight is invalid - if (sourceHeight <= 0) { - console.warn(`⚠️ Page ${i + 1}, section ${j + 1}: Invalid sourceHeight (${sourceHeight}), skipping`); - continue; - } - - // Create a temporary canvas for this portion - const tempCanvas = document.createElement('canvas'); - tempCanvas.width = canvas.width; - tempCanvas.height = sourceHeight; - - const ctx = tempCanvas.getContext('2d'); - if (ctx) { - // Draw the content portion (background already filled by PDF) - ctx.drawImage( - canvas, - 0, sourceY, // source x, y - canvas.width, sourceHeight, // source width, height - 0, 0, // dest x, y - canvas.width, sourceHeight // dest width, height - ); - - const imgData = tempCanvas.toDataURL('image/png', 1.0); // PNG with transparency - - // Validate the PNG data - if (!imgData || imgData === 'data:,') { - console.error(`❌ Page ${i + 1}, section ${j + 1}: Failed to generate valid PNG data`); - continue; - } - - // Calculate actual height in mm for this portion - const portionHeightMm = (sourceHeight / (a4HeightPx * 2)) * a4HeightMm; - - pdf.addImage(imgData, 'PNG', 0, 0, a4WidthMm, portionHeightMm); - - // Add footer to each split page (except if it's the last section and already has footer) - const isLastSection = j === numPages - 1; - if (!isLastSection && i > 0) { // Skip cover page (i=0) and last section of each service - // Draw footer background - pdf.setFillColor(30, 27, 75); // Dark indigo - const footerHeight = 15; // mm - const footerY = a4HeightMm - footerHeight; - pdf.rect(0, footerY, a4WidthMm, footerHeight, 'F'); - - // Draw footer text - pdf.setTextColor(255, 255, 255); - pdf.setFontSize(8); - pdf.text( - `© ${new Date().getFullYear()} Codebility • Professional Development Services`, - a4WidthMm / 2, - footerY + (footerHeight / 2) + 1, - { align: 'center' } - ); - } - - } - } - } - - // Remove overlay - document.body.removeChild(overlay); - - // Save the PDF - const filename = `codebility-services-catalog-${new Date().toISOString().split('T')[0]}.pdf`; - pdf.save(filename); - - toast.success("PDF downloaded successfully!"); - } catch (error) { - console.error("❌ Error generating PDF:", error); - toast.error("Failed to generate PDF. Please try again."); - - // Remove overlay if it exists - const overlay = document.querySelector('[style*="z-index: 999999"]'); - if (overlay && overlay.parentNode) { - overlay.parentNode.removeChild(overlay); - } - } - }; - - // Preview components with A4 aspect ratio and enhanced design - const CoverPagePreview = () => ( -
- {/* Animated Background Elements */} -
- {/* Code Matrix Effect */} -
-
-
- - {/* Dynamic Floating Elements */} -
-
-
- - {/* Geometric Shapes */} -
-
-
- - {/* Tech Icons Background */} -
⚛️
-
🚀
-
💻
-
- - {/* Main Content */} -
- {/* Logo section with enhanced design */} -
-
-
- Codebility Logo -
- {/* Logo Ring */} -
-
- -

CODEBILITY

-
-

Professional Development Services

-

Transforming Ideas into Digital Excellence

-
- - {/* Main Title with enhanced styling */} -
-
-

- SERVICES -

-

- CATALOG -

- {/* Subtitle */} -

Complete Portfolio of Digital Solutions

-
-
- - {/* Enhanced Stats Section */} -
-
-
{new Date().getFullYear()}
-
Current Year
-
-
-
{services.length}
-
Services
-
-
-
100+
-
Projects
-
-
- - {/* Enhanced Contact Card */} -
-
-
- 📧 -
-
Get In Touch
-
-
-
- Email: - admin@codebility.tech -
-
- Website: - codebility.tech -
-
-
-
-
- ); - - const ServicePagePreview = ({ service, index }: { service: Service, index: number }) => { - // Get real project images and data for service showcase - const getServiceProjects = (serviceName: string) => { - // First get all projects with valid images - const projectsWithImages = realProjects.filter(project => { - return project.main_image && project.main_image.trim() !== ''; - }); - - // Filter projects based on service type and category - const relevantProjects = projectsWithImages.filter(project => { - const hasCategory = (catId: number) => project.categories?.some((c: any) => c.id === catId); - - - switch (serviceName) { - case 'Web Application Development': - // Category ID 1 = "Web Application" - STRICT filtering for web only - return hasCategory(1); - case 'Mobile Application Development': - // Category ID 2 = "Mobile Application" - STRICT filtering for mobile only - return hasCategory(2); - case 'Product Design': - // Category ID 3 = "Product Design" - STRICT filtering for design only - return hasCategory(3); - case 'CMS Service': - // Category ID 5 = "CMS" - STRICT filtering for CMS only - return hasCategory(5); - case 'AI Development': - // Category ID 4 = "AI Development" - STRICT filtering for AI only - return hasCategory(4); - default: - return true; - } - }); - - // STRICT: Only use projects from the specific category, no fallbacks - const projectsToUse = relevantProjects; - - // Service-specific dummy images when no real projects exist - const getServiceDummyImages = (serviceName: string) => { - const dummyImageMap: { [key: string]: string[] } = { - 'CMS Service': [ - 'https://images.unsplash.com/photo-1547658719-da2b51169166?w=600&h=400&fit=crop', // CMS dashboard - 'https://images.unsplash.com/photo-1593720213428-28a5b9e94613?w=600&h=400&fit=crop', // Content editing - 'https://images.unsplash.com/photo-1460925895917-afdab827c52f?w=600&h=400&fit=crop', // Analytics dashboard - 'https://images.unsplash.com/photo-1504868584819-f8e8b4b6d7e3?w=600&h=400&fit=crop', // Website management - 'https://images.unsplash.com/photo-1499951360447-b19be8fe80f5?w=600&h=400&fit=crop' // Team collaboration - ], - 'AI Development': [ - 'https://images.unsplash.com/photo-1555421689-d68471e189f2?w=600&h=400&fit=crop', // AI/ML code - 'https://images.unsplash.com/photo-1677442136019-21780ecad995?w=600&h=400&fit=crop', // AI brain - 'https://images.unsplash.com/photo-1620712943543-bcc4688e7485?w=600&h=400&fit=crop', // Neural network - 'https://images.unsplash.com/photo-1531746790731-6c087fecd65a?w=600&h=400&fit=crop', // Data visualization - 'https://images.unsplash.com/photo-1527474305487-b87b222841cc?w=600&h=400&fit=crop' // Robot/AI - ], - 'Product Design': [ - 'https://images.unsplash.com/photo-1558655146-9f40138edfeb?w=600&h=400&fit=crop', // Design wireframes - 'https://images.unsplash.com/photo-1609902726285-00668009ebe1?w=600&h=400&fit=crop', // UI design - 'https://images.unsplash.com/photo-1586717791821-3f44a563fa4c?w=600&h=400&fit=crop', // Design process - 'https://images.unsplash.com/photo-1561070791-2526d30994b5?w=600&h=400&fit=crop', // UX design - 'https://images.unsplash.com/photo-1586717799252-bd134ad00e26?w=600&h=400&fit=crop' // Design tools - ] - }; - - return dummyImageMap[serviceName] || [ - 'https://images.unsplash.com/photo-1460925895917-afdab827c52f?w=600&h=400&fit=crop', - 'https://images.unsplash.com/photo-1498050108023-c5249f4df085?w=600&h=400&fit=crop', - 'https://images.unsplash.com/photo-1547658719-da2b51169166?w=600&h=400&fit=crop', - 'https://images.unsplash.com/photo-1593720213428-28a5b9e94613?w=600&h=400&fit=crop', - 'https://images.unsplash.com/photo-1627398242454-45a1465c2479?w=600&h=400&fit=crop' - ]; - }; - - // STRICT: If no projects in this category, use dummy images - if (projectsToUse.length === 0) { - return getServiceDummyImages(serviceName); - } - - // Return ONLY the real project images from this specific category - const imageUrls = projectsToUse.map(project => project.main_image!).slice(0, 12); - return imageUrls; - }; - - // Special handling for Codev for Hire service - const isCodevHireService = service.name === 'Codev for Hire'; - - const images = isCodevHireService - ? codevProfiles.map(() => "avatar-icon") // Use ALL codevs with avatar icons - : getServiceProjects(service.name); - - // Get actual project data for showcasing - const getServiceProjectData = (serviceName: string) => { - if (serviceName === 'Codev for Hire') { - return codevProfiles; // Return ALL codev profiles - } - - const projectsWithImages = realProjects.filter(project => project.main_image && project.main_image.trim() !== ''); - - const relevantProjects = projectsWithImages.filter(project => { - const hasCategory = (catId: number) => project.categories?.some((c: any) => c.id === catId); - - switch (serviceName) { - case 'Web Application Development': - // Category ID 1 = "Web Application" - STRICT filtering for web only - return hasCategory(1); - case 'Mobile Application Development': - // Category ID 2 = "Mobile Application" - STRICT filtering for mobile only - return hasCategory(2); - case 'Product Design': - // Category ID 3 = "Product Design" - STRICT filtering for design only - return hasCategory(3); - case 'CMS Service': - // Category ID 5 = "CMS" - STRICT filtering for CMS only - return hasCategory(5); - case 'AI Development': - // Category ID 4 = "AI Development" - STRICT filtering for AI only - return hasCategory(4); - default: - return true; - } - }); - - // STRICT: Return ONLY projects from the specific category, no fallbacks - return relevantProjects.slice(0, 12); - }; - - const projectData = getServiceProjectData(service.name); - - return ( -
- {/* Animated background with shapes */} -
- {/* Floating gradient orbs */} -
-
-
- - {/* Grid pattern */} -
-
-
-
- - {/* Header with vibrant gradient */} -
-
-
- Codebility Logo - CODEBILITY -
-
- {index + 1} / {services.length} -
-
-
- - {/* Hero Section with Large Project Showcase or Codev Profile */} -
-
- {isCodevHireService ? ( -
- {/* Decorative circles */} -
-
- Codev Placeholder -
- ) : ( - <> - {projectData[0]?.name { - const target = e.target as HTMLImageElement; - target.src = 'https://images.unsplash.com/photo-1460925895917-afdab827c52f?w=600&h=400&fit=crop'; - }} - /> -
- - )} -
- - {/* Service Title Overlay */} -
-
- - {service.category} - -

- {service.name} -

-
-
-
-
- - {/* Project Gallery Section / Codev Profiles Section */} -
-
-
- - {isCodevHireService ? 'Our Team' : 'Portfolio'} - -
-

- {isCodevHireService ? 'Available Codevs' : service.name === 'Mobile Application Development' ? 'Mobile App Showcase' : 'Recent Projects'} -

-

- {isCodevHireService - ? 'Meet our talented developers ready to join your team' - : service.name === 'Mobile Application Development' - ? 'Our mobile applications designed for iOS and Android' - : `Showcasing our expertise in ${service.category.toLowerCase()}` - } -

-
- - {/* Mobile Layout with Phone Mockups - Render ALL mobile projects */} - {service.name === 'Mobile Application Development' ? ( -
- {(() => { - // Get ALL mobile projects - const mobileProjects = realProjects.filter(project => { - // STRICT mobile filtering - only category 2 (Mobile Application) - return project.categories?.some((c: any) => c.id === 2); - }); - - const mobileProjectsWithImages = mobileProjects.filter(p => p.main_image && p.main_image.trim() !== ''); - - // Use ALL mobile projects or fallback to placeholder if none - const projectsToShow = mobileProjectsWithImages.length > 0 - ? mobileProjectsWithImages - : Array.from({ length: 6 }, (_, idx) => ({ - id: `mobile-${idx}`, - name: `Mobile App ${idx + 1}`, - main_image: `https://images.unsplash.com/photo-${1512941937669 + idx}-90a1b58e7e9c?w=300&h=600&fit=crop`, - tech_stack: ['React Native'] - })); - - return projectsToShow.slice(0, 12).map((project, idx) => { - const projectName = project?.name || `Mobile App ${idx + 1}`; - - return ( -
- {/* Phone Mockup */} -
-
-
- {/* Phone notch */} -
- {/* Phone Screen */} - {projectName} { - const target = e.target as HTMLImageElement; - target.src = `https://images.unsplash.com/photo-${1512941937669 + idx}-90a1b58e7e9c?w=300&h=600&fit=crop`; - }} - /> - {/* Phone status bar overlay */} -
- {/* Phone navigation overlay */} -
-
- {/* Phone home indicator */} -
-
- {/* Phone reflection */} -
- {/* Phone bezel highlight */} -
-
- {/* Project info */} -
-
{projectName}
- {project?.tech_stack && project.tech_stack.length > 0 && ( -
- {project.tech_stack.slice(0, 1).join(', ')} -
- )} -
-
- ); - }); - })()} -
- ) : isCodevHireService ? ( - /* Codev Grid - Render ALL codevs with images */ -
- {codevProfiles.map((codev, idx) => { - const codevName = codev ? `${codev.first_name} ${codev.last_name}` : `Codev ${idx + 1}`; - const codevSkills = codev?.tech_stacks ? codev.tech_stacks.slice(0, 2).join(', ') : 'Full Stack Developer'; - const codevImage = codev?.image_url || null; - - return ( -
-
- {codevImage ? ( - {codevName} { - const target = e.target as HTMLImageElement; - target.style.display = 'none'; - target.nextElementSibling?.classList.remove('hidden'); - }} - /> - ) : null} -
- Codev Avatar Placeholder -
-
-
-
{codevName}
-
{codevSkills}
-
-
- ); - })} -
- ) : ( - /* Regular Project Grid or Dummy Images */ -
- {(() => { - // STRICT: Use only category-specific projects, no mixing - const projectsToShow = projectData; - const imagesToShow = images; - - // For Web Application Development - show ONLY category 1 projects - if (service.name === 'Web Application Development') { - const webProjects = realProjects.filter(project => { - // STRICT: Only category 1 (Web Application) - return project.categories?.some((c: any) => c.id === 1); - }); - const webProjectsWithImages = webProjects.filter(p => p.main_image && p.main_image.trim() !== ''); - - if (webProjectsWithImages.length > 0) { - return webProjectsWithImages.slice(0, 12).map((project, idx) => { - return ( -
- {project.name} -
-
-
{project.name}
- {project.tech_stack && project.tech_stack.length > 0 && ( -
- {project.tech_stack.slice(0, 1).join(', ')} -
- )} -
-
-
- ); - }); - } - } - // For CMS Service - show ONLY category 5 projects - else if (service.name === 'CMS Service') { - const cmsProjects = realProjects.filter(project => { - // STRICT: Only category 5 (CMS) - return project.categories?.some((c: any) => c.id === 5); - }); - - const cmsProjectsWithImages = cmsProjects.filter(p => p.main_image && p.main_image.trim() !== ''); - - if (cmsProjectsWithImages.length > 0) { - return cmsProjectsWithImages.slice(0, 12).map((project, idx) => { - const projectName = project.name || `CMS Project ${idx + 1}`; - - return ( -
- {projectName} { - const target = e.target as HTMLImageElement; - target.src = `https://images.unsplash.com/photo-${1460925895917 + idx}-afdab827c52f?w=400&h=300&fit=crop`; - }} - /> -
-
-
{projectName}
- {project?.tech_stack && project.tech_stack.length > 0 && ( -
- {project.tech_stack.slice(0, 1).join(', ')} -
- )} -
-
-
- ); - }); - } else { - // Fallback to dummy CMS images if no real projects - const dummyImages = getServiceProjects(service.name); - return dummyImages.map((img, idx) => { - const serviceName = `CMS Project ${idx + 1}`; - - return ( -
- {serviceName} { - const target = e.target as HTMLImageElement; - target.src = `https://images.unsplash.com/photo-${1460925895917 + idx}-afdab827c52f?w=400&h=300&fit=crop`; - }} - /> -
-
-
{serviceName}
-
Content Management
-
-
-
- ); - }); - } - } - // For AI Development - show ONLY category 4 projects - else if (service.name === 'AI Development') { - const aiProjects = realProjects.filter(project => { - // STRICT: Only category 4 (AI Development) - return project.categories?.some((c: any) => c.id === 4); - }); - - const aiProjectsWithImages = aiProjects.filter(p => p.main_image && p.main_image.trim() !== ''); - - if (aiProjectsWithImages.length > 0) { - return aiProjectsWithImages.slice(0, 12).map((project, idx) => { - const projectName = project.name || `AI Project ${idx + 1}`; - - return ( -
- {projectName} { - const target = e.target as HTMLImageElement; - target.src = `https://images.unsplash.com/photo-${1555421689 + idx}-df2759f1ff84?w=400&h=300&fit=crop`; - }} - /> -
-
-
{projectName}
- {project?.tech_stack && project.tech_stack.length > 0 && ( -
- {project.tech_stack.slice(0, 1).join(', ')} -
- )} -
-
-
- ); - }); - } else { - // Fallback to dummy AI images if no real projects - const dummyImages = getServiceProjects(service.name); - return dummyImages.map((img, idx) => { - const serviceName = `AI Project ${idx + 1}`; - - return ( -
- {serviceName} { - const target = e.target as HTMLImageElement; - target.src = `https://images.unsplash.com/photo-${1555421689 + idx}-df2759f1ff84?w=400&h=300&fit=crop`; - }} - /> -
-
-
{serviceName}
-
AI & Machine Learning
-
-
-
- ); - }); - } - } - // For Product Design - show ONLY category 3 projects - else if (service.name === 'Product Design') { - const designProjects = realProjects.filter(project => { - // STRICT: Only category 3 (Product Design) - return project.categories?.some((c: any) => c.id === 3); - }); - - const designProjectsWithImages = designProjects.filter(p => p.main_image && p.main_image.trim() !== ''); - - if (designProjectsWithImages.length > 0) { - return designProjectsWithImages.slice(0, 12).map((project, idx) => { - const projectName = project.name || `Design Project ${idx + 1}`; - - return ( -
- {projectName} { - const target = e.target as HTMLImageElement; - target.src = `https://images.unsplash.com/photo-${1460925895917 + idx}-afdab827c52f?w=400&h=300&fit=crop`; - }} - /> -
-
-
{projectName}
- {project?.tech_stack && project.tech_stack.length > 0 && ( -
- {project.tech_stack.slice(0, 1).join(', ')} -
- )} -
-
-
- ); - }); - } else { - // Fallback to dummy design images if no real projects - const dummyImages = getServiceProjects(service.name); - return dummyImages.map((img, idx) => { - const serviceName = `Design Project ${idx + 1}`; - - return ( -
- {serviceName} { - const target = e.target as HTMLImageElement; - target.src = `https://images.unsplash.com/photo-${1460925895917 + idx}-afdab827c52f?w=400&h=300&fit=crop`; - }} - /> -
-
-
{serviceName}
-
UI/UX Design
-
-
-
- ); - }); - } - } - - // For other services without specific projects - show dummy images - if (!projectsToShow || projectsToShow.length === 0) { - const dummyImages = imagesToShow.length > 0 ? imagesToShow : []; - return dummyImages.slice(0, 6).map((img, idx) => { - return ( -
- {`${service.name} { - const target = e.target as HTMLImageElement; - target.src = `https://images.unsplash.com/photo-${1460925895917 + idx}-afdab827c52f?w=400&h=300&fit=crop`; - }} - /> -
-
-
{service.name}
-
-
-
- ); - }); - } - - // This should not be reached for category-specific services - return null; - })()} -
- )} -
- - {/* Content Section */} -
-
- {/* Price and Duration */} - {(service.price || service.duration) && ( -
- {/* Decorative elements */} -
-
- -
-
- 💎 -
-
-
{service.price || 'Contact for pricing'}
- {service.duration &&
{service.duration}
} -
Premium service package
-
-
-
- )} - - {/* Description */} -
- {/* Decorative glow */} -
-
-
-
- 📋 -
-
-

- About This Service -

-
-
-
-

{service.description}

-
-
- - {/* Technology Stack with Icons / Codev Skills */} -
- {/* Decorative glow */} -
-

-
- ⚡ -
- {isCodevHireService ? 'Skills & Expertise' : 'Technologies We Use'} -

-
- {(() => { - let techsToShow: string[] = []; - - if (isCodevHireService) { - // Get unique tech stacks from codev profiles - const allTechStacks = codevProfiles - .filter(codev => codev?.tech_stacks && codev.tech_stacks.length > 0) - .flatMap(codev => codev.tech_stacks) - .filter((tech, index, array) => array.indexOf(tech) === index); - - techsToShow = allTechStacks.length > 0 - ? allTechStacks.slice(0, 8) - : ['React', 'Node.js', 'TypeScript', 'Python', 'PostgreSQL', 'AWS']; - } else { - // Get unique tech stack from real projects - const allTechStacks = projectData - .filter(p => p?.tech_stack && p.tech_stack.length > 0) - .flatMap(p => p.tech_stack!) - .filter((tech, index, array) => array.indexOf(tech) === index); - - techsToShow = allTechStacks.length > 0 - ? allTechStacks.slice(0, 8) - : ['React', 'Node.js', 'TypeScript', 'AWS', 'Docker', 'PostgreSQL']; - } - - return techsToShow.map((tech, idx) => { - // Find matching tech stack icon - const techMatch = techstacks.find(t => - t.name.toLowerCase() === tech.toLowerCase() || - t.alias?.toLowerCase() === tech.toLowerCase() - ); - - if (techMatch) { - const IconComponent = techMatch.icon; - return ( -
- - {tech} -
- ); - } else { - // Fallback for technologies without icons - return ( -
-
- {tech.charAt(0)} -
- {tech} -
- ); - } - }); - })()} -
-
- - {/* Features Grid */} -
- {/* Decorative glow */} -
-
-
-
- ✨ -
-
-

- What's Included -

-
-
-
-
- {service.features.map((feature, idx) => ( -
-
- ✓ -
- {feature} -
- ))} -
-
-
- - {/* Process Timeline */} -
- {/* Decorative glow */} -
-

-
- 🚀 -
- Our Process -

-
- {['Discovery & Planning', 'Design & Development', 'Testing & Deployment', 'Maintenance & Support'].map((step, idx) => ( -
-
- {idx + 1} -
- {step} -
- ))} -
-
-
-
- - {/* Footer */} -
- {/* Decorative elements */} -
-
-
- Professional Development Services - codebility.tech -
-
-
- ); - }; - - const SummaryPagePreview = () => { - const totalServices = services.length; - const categories = [...new Set(services.map(s => s.category))]; - - return ( -
- {/* Animated background with shapes */} -
- {/* Floating gradient orbs */} -
-
-
- - {/* Grid pattern */} -
-
-
-
- - {/* Header */} -
-
-
- Codebility Logo -
-

SERVICE OVERVIEW

-

Your Complete Solution Partner

-
-
- - {/* Stats Grid */} -
-
-
-
- {totalServices} -
-
Total Services
-
- -
-
- {categories.length} -
-
Categories
-
- -
-
- 🌟 -
-
Premium Quality
-
-
- - - {/* Service Categories */} -
-

Our Expertise

-
- {categories.map((category, idx) => ( -
-
- ✓ -
- {category} -
- ))} -
-
- - - {/* Call to Action */} -
- {/* Decorative elements */} -
-
-
-

Ready to Transform Your Ideas?

-

Let's discuss your project and bring it to life

-
-
📧 admin@codebility.tech
-
🌐 www.codebility.tech
-
-
-
-
- - {/* Footer */} -
- {/* Decorative elements */} -
-
-
- © {new Date().getFullYear()} Codebility • Professional Development Services -
-
-
- ); - }; - - if (loading) { - return ( - -
-
-
-
- ); - } - return ( - -
-
-
- -
-
-

- Services -

-

- View all services and export to PDF -

-
-
- -
- - {!showPreview ? ( - /* Services List */ -
- {services.length === 0 ? ( -
-
-
- -
-
-

- No services found -

-

- No services available -

-
-
-
- ) : ( - <> -
-
-
-

- Total Services: {services.length} -

-

- Categories: {[...new Set(services.map(s => s.category))].join(", ")} -

-
-
-
- - {services.map((service) => ( -
-
-
-
-

{service.name}

- - {service.category} - -
-

{service.description}

-
- {service.price && ( - - {service.price} - - )} - {service.duration && ( - - Duration: {service.duration} - - )} -
-
- {service.features.map((feature, index) => ( - - {feature} - - ))} -
-
-
-
- ))} - - )} -
- ) : ( - /* Preview Page View */ -
- {/* Header with navigation */} -
-
- - -
- - Page {currentPage + 1} of {totalPages} - - -
- - -
- - -
-
-
- - {/* Page Content with more spacing */} -
- {getCurrentPageContent()} -
- - {/* Hidden container with ALL pages for PDF generation */} -
- {/* Cover Page */} -
- -
- - {/* All Service Pages - render full page with A4 boundary markers */} - {services.map((service, index) => ( -
- - {/* A4 Page boundary markers - show where PDF will split */} -
-
-
-
- ))} - - {/* Summary Page */} -
- -
-
-
- )} -
+ ); -} \ No newline at end of file +} diff --git a/apps/codebility/app/home/settings/surveys/[surveyId]/_components/SurveyBuilder.tsx b/apps/codebility/app/home/settings/surveys/[surveyId]/_components/SurveyBuilder.tsx new file mode 100644 index 000000000..bf70dfaf4 --- /dev/null +++ b/apps/codebility/app/home/settings/surveys/[surveyId]/_components/SurveyBuilder.tsx @@ -0,0 +1,439 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { useRouter } from "next/navigation"; +import { H1 } from "@/components/shared/dashboard"; +import { + ArrowLeft, + BarChart3, + GripVertical, + Pencil, + Plus, + Trash2, + Share2, +} from "lucide-react"; +import { toast } from "sonner"; + +import { Button } from "@codevs/ui/button"; + +import PageContainer from "../../../../_components/PageContainer"; +import { + createQuestion, + deleteQuestion, + reorderQuestions, + updateQuestion, +} from "@/actions/settings/survey-questions"; +import QuestionForm from "./QuestionForm"; +import ShareSurveyModal from "../../_components/ShareSurveyModal"; + +interface Question { + id: string; + question_text: string; + description?: string; + question_type: string; + options: string[]; + settings: { + required: boolean; + placeholder?: string; + min_rating?: number; + max_rating?: number; + }; + order_index: number; +} + +// Added image_url so we can show the thumbnail in the header +interface Survey { + id: string; + title: string; + description: string; + type: "general" | "feedback" | "satisfaction" | "research" | "onboarding"; + image_url?: string; +} + +interface SurveyBuilderProps { + surveyId: string; + survey: Survey; + initialQuestions: Question[]; +} + +const questionTypeLabels: Record = { + text: "Short Text", + textarea: "Long Text", + multiple_choice: "Multiple Choice", + checkbox: "Checkboxes", + rating: "Rating", + date: "Date", + email: "Email", + number: "Number", +}; + +// Badge colors matching the list page design +const typeColors: Record = { + general: "bg-blue-500/15 text-blue-400 border border-blue-500/30", + feedback: "bg-emerald-500/15 text-emerald-400 border border-emerald-500/30", + satisfaction: "bg-amber-500/15 text-amber-400 border border-amber-500/30", + research: "bg-violet-500/15 text-violet-400 border border-violet-500/30", + onboarding: "bg-pink-500/15 text-pink-400 border border-pink-500/30", +}; + +// Question type badge colors +const questionTypeBadgeColors: Record = { + text: "bg-blue-500/15 text-blue-400 border border-blue-500/30", + textarea: "bg-indigo-500/15 text-indigo-400 border border-indigo-500/30", + multiple_choice: "bg-violet-500/15 text-violet-400 border border-violet-500/30", + checkbox: "bg-purple-500/15 text-purple-400 border border-purple-500/30", + rating: "bg-amber-500/15 text-amber-400 border border-amber-500/30", + date: "bg-teal-500/15 text-teal-400 border border-teal-500/30", + email: "bg-pink-500/15 text-pink-400 border border-pink-500/30", + number: "bg-emerald-500/15 text-emerald-400 border border-emerald-500/30", +}; + +// Reusable thumbnail — handles broken/missing image_url with a colored fallback +function SurveyThumbnail({ + imageUrl, + title, + size = "md", +}: { + imageUrl?: string; + title: string; + size?: "sm" | "md"; +}) { + const [imgError, setImgError] = useState(false); + + // h-14 w-14 for header (md), h-10 w-10 for smaller contexts (sm) + const sizeClass = size === "sm" ? "h-10 w-10 rounded-xl" : "h-14 w-14 rounded-2xl"; + const logoSizeClass = size === "sm" ? "h-6 w-6" : "h-8 w-8"; + const showFallback = !imageUrl || imgError; + + return ( +
+ {title} { + if (!imgError) { + console.error("[SurveyThumbnail] Failed to load image_url:", imageUrl); + setImgError(true); + } + }} + /> +
+ ); +} + +export default function SurveyBuilder({ + surveyId, + survey, + initialQuestions, +}: SurveyBuilderProps) { + const router = useRouter(); + + // Questions stay in client state so reordering can be applied optimistically. + const [questions, setQuestions] = useState(initialQuestions); + const [showForm, setShowForm] = useState(false); + const [editingQuestion, setEditingQuestion] = useState(null); + const [submitting, setSubmitting] = useState(false); + const [shareModalOpen, setShareModalOpen] = useState(false); + + // Pick up the server's list again after router.refresh() + useEffect(() => { + setQuestions(initialQuestions); + }, [initialQuestions]); + + const handleSubmit = async (data: any) => { + setSubmitting(true); + const questionData = { + ...data, + order_index: editingQuestion?.order_index ?? questions.length, + }; + const result = editingQuestion + ? await updateQuestion(editingQuestion.id, questionData) + : await createQuestion(surveyId, questionData); + setSubmitting(false); + + if (result.error) { + toast.error(result.error); + } else { + toast.success(editingQuestion ? "Question updated" : "Question added"); + setShowForm(false); + setEditingQuestion(null); + router.refresh(); + } + }; + + const handleDelete = async (questionId: string) => { + if (!confirm("Are you sure you want to delete this question?")) return; + const result = await deleteQuestion(questionId); + if (result.error) { + toast.error(result.error); + } else { + toast.success("Question deleted"); + router.refresh(); + } + }; + + const handleEdit = (question: Question) => { + setEditingQuestion(question); + setShowForm(true); + }; + + const moveQuestion = async (index: number, direction: "up" | "down") => { + const newQuestions = [...questions]; + const targetIndex = direction === "up" ? index - 1 : index + 1; + if (targetIndex < 0 || targetIndex >= newQuestions.length) return; + [newQuestions[index], newQuestions[targetIndex]] = [ + newQuestions[targetIndex] as Question, + newQuestions[index] as Question, + ]; + const updates = newQuestions.map((q, i) => ({ id: q.id, order_index: i })); + setQuestions(newQuestions); + const result = await reorderQuestions(updates); + if (result.error) { + toast.error("Failed to reorder questions"); + router.refresh(); + } + }; + + return ( + + {/* ── Back nav ── */} +
+ +
+ + {/* ── Header: thumbnail + title + actions ── */} +
+
+ {/* Survey image thumbnail with broken-image fallback */} + +
+
+

+ {survey.title || "Survey Builder"} +

+ {/* Type badge */} + {survey.type && ( + + {survey.type} + + )} +
+

+ {survey.description || "Build your survey questions"} +

+
+
+ + {/* Action buttons */} +
+ + + +
+
+ + {/* ── Question Form ── */} + {showForm && editingQuestion !== undefined && ( +
+

+ {editingQuestion ? "Edit Question" : "Add New Question"} +

+ { setShowForm(false); setEditingQuestion(null); }} + loading={submitting} + /> +
+ )} + + {/* ── Questions progress bar ── */} + {questions.length > 0 && ( +
+ {questions.length} question{questions.length !== 1 ? "s" : ""} + + {questions.filter(q => q.settings.required).length} required + +
+ )} + + {/* ── Questions List ── */} +
+ {questions.length === 0 ? ( +
+
+
+ 📝 +
+
+

+ No questions yet +

+

+ Add your first question to start building the survey +

+
+ +
+
+ ) : ( + questions.map((question, index) => ( +
+
+ {/* ── Drag handle + number ── */} +
+ + + {index + 1} + +
+ + {/* ── Question Content ── */} +
+
+
+ {/* Question title + required asterisk */} +
+

+ {question.question_text} +

+ {question.settings.required && ( + * + )} +
+ + {/* Description */} + {question.description && ( +

+ {question.description} +

+ )} + + {/* Type + Required badges */} +
+ + {questionTypeLabels[question.question_type] || question.question_type} + + {question.settings.required && ( + + Required + + )} +
+ + {/* Multiple choice / checkbox options */} + {(question.question_type === "multiple_choice" || question.question_type === "checkbox") && + question.options.length > 0 && ( +
+ {question.options.map((option, i) => ( +
+ + {option} +
+ ))} +
+ )} + + {/* Rating scale info */} + {question.question_type === "rating" && ( +
+ Rating: {question.settings.min_rating} to {question.settings.max_rating} +
+ )} +
+ + {/* ── Action buttons ── */} +
+ +
+ +
+
+
+
+
+ )) + )} +
+ + {/* Share Modal */} + setShareModalOpen(false)} + surveyId={surveyId} + surveyTitle={survey.title} + /> + + ); +} diff --git a/apps/codebility/app/home/settings/surveys/[surveyId]/page.tsx b/apps/codebility/app/home/settings/surveys/[surveyId]/page.tsx index debb26031..f19856cf0 100644 --- a/apps/codebility/app/home/settings/surveys/[surveyId]/page.tsx +++ b/apps/codebility/app/home/settings/surveys/[surveyId]/page.tsx @@ -1,480 +1,59 @@ -"use client"; - -import { useEffect, useState } from "react"; -import { useParams, useRouter } from "next/navigation"; -import { H1 } from "@/components/shared/dashboard"; -import { createClientClientComponent } from "@/utils/supabase/client"; -import { - ArrowLeft, - BarChart3, - GripVertical, - Pencil, - Plus, - Trash2, - Share2, -} from "lucide-react"; -import { toast } from "sonner"; - -import { Button } from "@codevs/ui/button"; +import { Suspense } from "react"; +import { getSurveyQuestions } from "@/actions/settings/survey-questions"; +import { getSurveyById } from "@/actions/settings/surveys"; import PageContainer from "../../../_components/PageContainer"; -import { - createQuestion, - deleteQuestion, - getSurveyQuestions, - reorderQuestions, - updateQuestion, -} from "../questions/actions"; -import QuestionForm from "./_components/QuestionForm"; -import ShareSurveyModal from "../_components/ShareSurveyModal"; - -interface Question { - id: string; - question_text: string; - description?: string; - question_type: string; - options: string[]; - settings: { - required: boolean; - placeholder?: string; - min_rating?: number; - max_rating?: number; - }; - order_index: number; -} +import SurveyBuilder from "./_components/SurveyBuilder"; +import Loading from "./loading"; -// Added image_url so we can show the thumbnail in the header -interface Survey { - id: string; - title: string; - description: string; - type: "general" | "feedback" | "satisfaction" | "research" | "onboarding"; - image_url?: string; +interface PageProps { + params: Promise<{ surveyId: string }>; } -const questionTypeLabels: Record = { - text: "Short Text", - textarea: "Long Text", - multiple_choice: "Multiple Choice", - checkbox: "Checkboxes", - rating: "Rating", - date: "Date", - email: "Email", - number: "Number", -}; - -// Badge colors matching the list page design -const typeColors: Record = { - general: "bg-blue-500/15 text-blue-400 border border-blue-500/30", - feedback: "bg-emerald-500/15 text-emerald-400 border border-emerald-500/30", - satisfaction: "bg-amber-500/15 text-amber-400 border border-amber-500/30", - research: "bg-violet-500/15 text-violet-400 border border-violet-500/30", - onboarding: "bg-pink-500/15 text-pink-400 border border-pink-500/30", -}; - -// Question type badge colors -const questionTypeBadgeColors: Record = { - text: "bg-blue-500/15 text-blue-400 border border-blue-500/30", - textarea: "bg-indigo-500/15 text-indigo-400 border border-indigo-500/30", - multiple_choice: "bg-violet-500/15 text-violet-400 border border-violet-500/30", - checkbox: "bg-purple-500/15 text-purple-400 border border-purple-500/30", - rating: "bg-amber-500/15 text-amber-400 border border-amber-500/30", - date: "bg-teal-500/15 text-teal-400 border border-teal-500/30", - email: "bg-pink-500/15 text-pink-400 border border-pink-500/30", - number: "bg-emerald-500/15 text-emerald-400 border border-emerald-500/30", -}; - -// Reusable thumbnail — handles broken/missing image_url with a colored fallback -function SurveyThumbnail({ - imageUrl, - title, - size = "md", -}: { - imageUrl?: string; - title: string; - size?: "sm" | "md"; -}) { - const [imgError, setImgError] = useState(false); - - // h-14 w-14 for header (md), h-10 w-10 for smaller contexts (sm) - const sizeClass = size === "sm" ? "h-10 w-10 rounded-xl" : "h-14 w-14 rounded-2xl"; - const logoSizeClass = size === "sm" ? "h-6 w-6" : "h-8 w-8"; - const showFallback = !imageUrl || imgError; - - return ( -
- {title} { - if (!imgError) { - console.error("[SurveyThumbnail] Failed to load image_url:", imageUrl); - setImgError(true); - } - }} - /> -
- ); -} - -export default function SurveyBuilderPage() { - const params = useParams(); - const router = useRouter(); - const surveyId = params.surveyId as string; - - const [survey, setSurvey] = useState(null); - const [questions, setQuestions] = useState([]); - const [loading, setLoading] = useState(true); - const [showForm, setShowForm] = useState(false); - const [editingQuestion, setEditingQuestion] = useState(null); - const [submitting, setSubmitting] = useState(false); - const [shareModalOpen, setShareModalOpen] = useState(false); - - useEffect(() => { - fetchSurvey(); - fetchQuestions(); - }, [surveyId]); - - const fetchSurvey = async () => { - const supabase = createClientClientComponent(); - if (!supabase) { - toast.error("Failed to initialize database client"); - return; - } - const { data, error } = await supabase - .from("surveys") - // Added image_url and type so we can render the thumbnail + type badge - .select("id, title, description, image_url, type") - .eq("id", surveyId) - .single(); - - if (error) { - console.error("Error fetching survey:", error); - toast.error("Failed to fetch survey"); - return; - } - setSurvey(data); - }; - - const fetchQuestions = async () => { - setLoading(true); - const result = await getSurveyQuestions(surveyId); - if (result.error) { - toast.error(result.error); - } else { - setQuestions(result.data || []); - } - setLoading(false); - }; - - const handleSubmit = async (data: any) => { - setSubmitting(true); - const questionData = { - ...data, - order_index: editingQuestion?.order_index ?? questions.length, - }; - const result = editingQuestion - ? await updateQuestion(editingQuestion.id, questionData) - : await createQuestion(surveyId, questionData); - setSubmitting(false); - - if (result.error) { - toast.error(result.error); - } else { - toast.success(editingQuestion ? "Question updated" : "Question added"); - setShowForm(false); - setEditingQuestion(null); - fetchQuestions(); - } - }; - - const handleDelete = async (questionId: string) => { - if (!confirm("Are you sure you want to delete this question?")) return; - const result = await deleteQuestion(questionId); - if (result.error) { - toast.error(result.error); - } else { - toast.success("Question deleted"); - fetchQuestions(); - } - }; +async function SurveyBuilderData({ surveyId }: { surveyId: string }) { + const [surveyResult, questionsResult] = await Promise.all([ + getSurveyById(surveyId), + getSurveyQuestions(surveyId), + ]); - const handleEdit = (question: Question) => { - setEditingQuestion(question); - setShowForm(true); - }; + const surveyRow = surveyResult.data; - const moveQuestion = async (index: number, direction: "up" | "down") => { - const newQuestions = [...questions]; - const targetIndex = direction === "up" ? index - 1 : index + 1; - if (targetIndex < 0 || targetIndex >= newQuestions.length) return; - [newQuestions[index], newQuestions[targetIndex]] = [ - newQuestions[targetIndex] as Question, - newQuestions[index] as Question, - ]; - const updates = newQuestions.map((q, i) => ({ id: q.id, order_index: i })); - setQuestions(newQuestions); - const result = await reorderQuestions(updates); - if (result.error) { - toast.error("Failed to reorder questions"); - fetchQuestions(); - } - }; - - if (loading) { + if (surveyResult.error || !surveyRow) { + console.error("Error fetching survey:", surveyResult.error); return ( - -
-
+ +
+ Failed to fetch survey
); } - return ( - - {/* ── Back nav ── */} -
- -
- - {/* ── Header: thumbnail + title + actions ── */} -
-
- {/* Survey image thumbnail with broken-image fallback */} - {survey && ( - - )} -
-
-

- {survey?.title || "Survey Builder"} -

- {/* Type badge */} - {survey?.type && ( - - {survey.type} - - )} -
-

- {survey?.description || "Build your survey questions"} -

-
-
- - {/* Action buttons */} -
- - - -
-
- - {/* ── Question Form ── */} - {showForm && editingQuestion !== undefined && ( -
-

- {editingQuestion ? "Edit Question" : "Add New Question"} -

- { setShowForm(false); setEditingQuestion(null); }} - loading={submitting} - /> -
- )} - - {/* ── Questions progress bar ── */} - {questions.length > 0 && ( -
- {questions.length} question{questions.length !== 1 ? "s" : ""} - - {questions.filter(q => q.settings.required).length} required - -
- )} - - {/* ── Questions List ── */} -
- {questions.length === 0 ? ( -
-
-
- 📝 -
-
-

- No questions yet -

-

- Add your first question to start building the survey -

-
- -
-
- ) : ( - questions.map((question, index) => ( -
-
- {/* ── Drag handle + number ── */} -
- - - {index + 1} - -
- - {/* ── Question Content ── */} -
-
-
- {/* Question title + required asterisk */} -
-

- {question.question_text} -

- {question.settings.required && ( - * - )} -
- - {/* Description */} - {question.description && ( -

- {question.description} -

- )} - - {/* Type + Required badges */} -
- - {questionTypeLabels[question.question_type] || question.question_type} - - {question.settings.required && ( - - Required - - )} -
- - {/* Multiple choice / checkbox options */} - {(question.question_type === "multiple_choice" || question.question_type === "checkbox") && - question.options.length > 0 && ( -
- {question.options.map((option, i) => ( -
- - {option} -
- ))} -
- )} + if (questionsResult.error) { + console.error("Error fetching survey questions:", questionsResult.error); + } - {/* Rating scale info */} - {question.question_type === "rating" && ( -
- Rating: {question.settings.min_rating} to {question.settings.max_rating} -
- )} -
+ return ( + + ); +} - {/* ── Action buttons ── */} -
- -
- -
-
-
-
-
- )) - )} -
+export default async function SurveyBuilderPage({ params }: PageProps) { + const { surveyId } = await params; - {/* Share Modal */} - {survey && ( - setShareModalOpen(false)} - surveyId={surveyId} - surveyTitle={survey.title} - /> - )} - + return ( + }> + + ); -} \ No newline at end of file +} diff --git a/apps/codebility/app/home/settings/surveys/[surveyId]/results/_components/ExportResultsButton.tsx b/apps/codebility/app/home/settings/surveys/[surveyId]/results/_components/ExportResultsButton.tsx new file mode 100644 index 000000000..d953167be --- /dev/null +++ b/apps/codebility/app/home/settings/surveys/[surveyId]/results/_components/ExportResultsButton.tsx @@ -0,0 +1,70 @@ +"use client"; + +import { Download } from "lucide-react"; +import { toast } from "sonner"; + +import { Button } from "@codevs/ui/button"; + +import type { Question, SurveyResponse } from "./types"; + +interface ExportResultsButtonProps { + surveyId: string; + questions: Question[]; + responses: SurveyResponse[]; +} + +export default function ExportResultsButton({ + surveyId, + questions, + responses, +}: ExportResultsButtonProps) { + const exportToCSV = () => { + if (responses.length === 0) { + toast.error("No responses to export"); + return; + } + + const headers = [ + "Submitted At", + "Respondent", + ...questions.map((q) => q.question_text), + ]; + + const rows = responses.map((response) => { + const respondentName = response.respondent + ? `${response.respondent.first_name} ${response.respondent.last_name}` + : response.respondent_email || "Anonymous"; + + return [ + new Date(response.submitted_at).toLocaleString(), + respondentName, + ...questions.map((q) => { + const answer = response.answers[q.id]; + if (Array.isArray(answer)) return answer.join(", "); + return answer || ""; + }), + ]; + }); + + const csv = [headers, ...rows].map((row) => row.join(",")).join("\n"); + const blob = new Blob([csv], { type: "text/csv" }); + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = `survey-${surveyId}-results.csv`; + a.click(); + URL.revokeObjectURL(url); + toast.success("Results exported successfully"); + }; + + return ( + + ); +} diff --git a/apps/codebility/app/home/settings/surveys/[surveyId]/results/_components/types.ts b/apps/codebility/app/home/settings/surveys/[surveyId]/results/_components/types.ts new file mode 100644 index 000000000..d1f238e2a --- /dev/null +++ b/apps/codebility/app/home/settings/surveys/[surveyId]/results/_components/types.ts @@ -0,0 +1,25 @@ +export interface Question { + id: string; + question_text: string; + question_type: string; + options: string[]; +} + +export interface SurveyResponse { + id: string; + answers: Record; + submitted_at: string; + respondent: { + first_name: string; + last_name: string; + email_address: string; + } | null; + respondent_email: string | null; +} + +export interface Statistics { + total_responses: number; + completed_responses: number; + unique_respondents: number; + last_response_at: string; +} diff --git a/apps/codebility/app/home/settings/surveys/[surveyId]/results/loading.tsx b/apps/codebility/app/home/settings/surveys/[surveyId]/results/loading.tsx new file mode 100644 index 000000000..344408774 --- /dev/null +++ b/apps/codebility/app/home/settings/surveys/[surveyId]/results/loading.tsx @@ -0,0 +1,7 @@ +import { ListSkeleton } from "@/app/home/_components/skeletons/RouteSkeletons"; + +const Loading = () => { + return ; +}; + +export default Loading; diff --git a/apps/codebility/app/home/settings/surveys/[surveyId]/results/page.tsx b/apps/codebility/app/home/settings/surveys/[surveyId]/results/page.tsx index 279d759a5..1959cc8b5 100644 --- a/apps/codebility/app/home/settings/surveys/[surveyId]/results/page.tsx +++ b/apps/codebility/app/home/settings/surveys/[surveyId]/results/page.tsx @@ -1,201 +1,80 @@ -"use client"; - -import { useEffect, useState } from "react"; -import { useParams, useRouter } from "next/navigation"; +import { Suspense } from "react"; +import Link from "next/link"; import { H1 } from "@/components/shared/dashboard"; -import { createClientClientComponent } from "@/utils/supabase/client"; -import { ArrowLeft, CheckCircle, Download, Users } from "lucide-react"; -import { toast } from "sonner"; +import { ArrowLeft, CheckCircle, Users } from "lucide-react"; import { Button } from "@codevs/ui/button"; import PageContainer from "../../../../_components/PageContainer"; -import { getSurveyQuestions } from "../../questions/actions"; +import { getSurveyQuestions } from "@/actions/settings/survey-questions"; import { getSurveyResponses, getSurveyStatistics, -} from "../../responses/actions"; - -interface Question { - id: string; - question_text: string; - question_type: string; - options: string[]; -} - -interface Response { - id: string; - answers: Record; - submitted_at: string; - respondent: { - first_name: string; - last_name: string; - email_address: string; - } | null; - respondent_email: string | null; -} +} from "@/actions/settings/survey-responses"; +import { getSurveyById } from "@/actions/settings/surveys"; +import ExportResultsButton from "./_components/ExportResultsButton"; +import type { Question, Statistics, SurveyResponse } from "./_components/types"; -interface Statistics { - total_responses: number; - completed_responses: number; - unique_respondents: number; - last_response_at: string; +function ResultsSkeleton() { + return ( +
+
+
+ ); } -export default function SurveyResultsPage() { - const params = useParams(); - const router = useRouter(); - const surveyId = params.surveyId as string; - - const [survey, setSurvey] = useState(null); - const [questions, setQuestions] = useState([]); - const [responses, setResponses] = useState([]); - const [statistics, setStatistics] = useState(null); - const [loading, setLoading] = useState(true); - - useEffect(() => { - fetchData(); - }, [surveyId]); - - const fetchData = async () => { - setLoading(true); - - // Fetch survey - const supabase = createClientClientComponent(); - if (!supabase) { - toast.error("Failed to initialize database client"); - setLoading(false); - return; - } - - const { data: surveyData } = await supabase - .from("surveys") - .select("id, title, description") - .eq("id", surveyId) - .single(); - - setSurvey(surveyData); - - // Fetch questions - const questionsResult = await getSurveyQuestions(surveyId); - if (questionsResult.data) { - setQuestions(questionsResult.data); - } - - // Fetch responses - const responsesResult = await getSurveyResponses(surveyId); - if (responsesResult.data) { - setResponses(responsesResult.data); - } +function getAnswerSummary(question: Question, responses: SurveyResponse[]) { + const questionResponses = responses + .map((r) => r.answers[question.id]) + .filter(Boolean); - // Fetch statistics - const statsResult = await getSurveyStatistics(surveyId); - if (statsResult.data) { - setStatistics(statsResult.data as Statistics); - } - - setLoading(false); - }; - - const getAnswerSummary = (question: Question) => { - const questionResponses = responses - .map((r) => r.answers[question.id]) - .filter(Boolean); - - if (question.question_type === "multiple_choice") { - const counts: Record = {}; - questionResponses.forEach((answer) => { - counts[answer] = (counts[answer] || 0) + 1; - }); - return counts; - } - - if (question.question_type === "checkbox") { - const counts: Record = {}; - questionResponses.forEach((answer) => { - if (Array.isArray(answer)) { - answer.forEach((item) => { - counts[String(item)] = (counts[String(item)] || 0) + 1; - }); - } - }); - return counts; - } - - if (question.question_type === "rating") { - const ratings = questionResponses as number[]; - const average = ratings.length - ? (ratings.reduce((sum, r) => sum + r, 0) / ratings.length).toFixed(1) - : "N/A"; - return { average, count: ratings.length }; - } - - return questionResponses; - }; + if (question.question_type === "multiple_choice") { + const counts: Record = {}; + questionResponses.forEach((answer) => { + counts[answer] = (counts[answer] || 0) + 1; + }); + return counts; + } - const exportToCSV = () => { - if (responses.length === 0) { - toast.error("No responses to export"); - return; - } + if (question.question_type === "checkbox") { + const counts: Record = {}; + questionResponses.forEach((answer) => { + if (Array.isArray(answer)) { + answer.forEach((item) => { + counts[String(item)] = (counts[String(item)] || 0) + 1; + }); + } + }); + return counts; + } - const headers = [ - "Submitted At", - "Respondent", - ...questions.map((q) => q.question_text), - ]; + if (question.question_type === "rating") { + const ratings = questionResponses as number[]; + const average = ratings.length + ? (ratings.reduce((sum, r) => sum + r, 0) / ratings.length).toFixed(1) + : "N/A"; + return { average, count: ratings.length }; + } - const rows = responses.map((response) => { - const respondentName = response.respondent - ? `${response.respondent.first_name} ${response.respondent.last_name}` - : response.respondent_email || "Anonymous"; + return questionResponses; +} - return [ - new Date(response.submitted_at).toLocaleString(), - respondentName, - ...questions.map((q) => { - const answer = response.answers[q.id]; - if (Array.isArray(answer)) return answer.join(", "); - return answer || ""; - }), - ]; - }); +async function SurveyResultsData({ surveyId }: { surveyId: string }) { + const [surveyResult, questionsResult, responsesResult, statsResult] = + await Promise.all([ + getSurveyById(surveyId), + getSurveyQuestions(surveyId), + getSurveyResponses(surveyId), + getSurveyStatistics(surveyId), + ]); - const csv = [headers, ...rows].map((row) => row.join(",")).join("\n"); - const blob = new Blob([csv], { type: "text/csv" }); - const url = URL.createObjectURL(blob); - const a = document.createElement("a"); - a.href = url; - a.download = `survey-${surveyId}-results.csv`; - a.click(); - URL.revokeObjectURL(url); - toast.success("Results exported successfully"); - }; - - if (loading) { - return ( - -
-
-
-
- ); - } + const survey = surveyResult.data ?? null; + const questions: Question[] = questionsResult.data ?? []; + const responses: SurveyResponse[] = responsesResult.data ?? []; + const statistics = (statsResult.data ?? null) as Statistics | null; return ( - - {/* Header */} -
- -
- + <>

@@ -205,14 +84,11 @@ export default function SurveyResultsPage() { Survey responses and analytics

- +
{/* Statistics Cards */} @@ -276,7 +152,7 @@ export default function SurveyResultsPage() { {/* Question Results */}
{questions.map((question, index) => { - const summary = getAnswerSummary(question); + const summary = getAnswerSummary(question, responses); return (
+ + ); +} + +export default async function SurveyResultsPage({ + params, +}: { + params: Promise<{ surveyId: string }>; +}) { + const { surveyId } = await params; + + return ( + + {/* Header */} +
+ +
+ + }> + +
); } diff --git a/apps/codebility/app/home/settings/surveys/_components/SurveysClient.tsx b/apps/codebility/app/home/settings/surveys/_components/SurveysClient.tsx new file mode 100644 index 000000000..74e726266 --- /dev/null +++ b/apps/codebility/app/home/settings/surveys/_components/SurveysClient.tsx @@ -0,0 +1,371 @@ +"use client"; + +import { useState } from "react"; +import { useRouter } from "next/navigation"; +import { H1 } from "@/components/shared/dashboard"; +import { createClientClientComponent } from "@/utils/supabase/client"; +import { Eye, EyeOff, Pencil, Plus, Settings, Trash2, Share2, BarChart2 } from "lucide-react"; +import { toast } from "sonner"; + +import { Button } from "@codevs/ui/button"; + +import PageContainer from "../../../_components/PageContainer"; +import SurveyForm from "./SurveyForm"; +import ShareSurveyModal from "./ShareSurveyModal"; + +export interface Survey { + id: string; + title: string; + description: string; + type: "general" | "feedback" | "satisfaction" | "research" | "onboarding"; + image_url?: string; + target_audience: "all" | "codev" | "intern" | "hr" | "admin"; + is_active: boolean; + priority: number; + start_date: string; + end_date?: string; + created_at: string; +} + +// Badge color map using transparent fills (dark mode friendly) +const typeColors: Record = { + general: "bg-blue-500/15 text-blue-400 border border-blue-500/30", + feedback: "bg-emerald-500/15 text-emerald-400 border border-emerald-500/30", + satisfaction: "bg-amber-500/15 text-amber-400 border border-amber-500/30", + research: "bg-violet-500/15 text-violet-400 border border-violet-500/30", + onboarding: "bg-pink-500/15 text-pink-400 border border-pink-500/30", +}; + +// Left accent border per survey type - gives each card a colored identity stripe +const typeAccent: Record = { + general: "border-l-blue-500", + feedback: "border-l-emerald-500", + satisfaction: "border-l-amber-500", + research: "border-l-violet-500", + onboarding: "border-l-pink-500", +}; + +const audienceColors: Record = { + all: "bg-gray-500/15 text-gray-400 border border-gray-500/30", + codev: "bg-indigo-500/15 text-indigo-400 border border-indigo-500/30", + intern: "bg-teal-500/15 text-teal-400 border border-teal-500/30", + hr: "bg-orange-500/15 text-orange-400 border border-orange-500/30", + admin: "bg-red-500/15 text-red-400 border border-red-500/30", +}; + +// Handles its own imgError state — shows uploaded image when valid, +// falls back to Codebility logo when image_url is absent or broken +function SurveyThumbnail({ + imageUrl, + title, +}: { + imageUrl?: string; + title: string; +}) { + const [imgError, setImgError] = useState(false); + + const showFallback = !imageUrl || imgError; + + return ( + // h-16 w-16 both explicit — aspect-square conflicts with flex row stretching +
+ {title} { + // Only log + set error when trying to load the actual image, not the fallback + if (!imgError) { + console.error("[SurveyThumbnail] Failed to load image_url:", imageUrl); + setImgError(true); + } + }} + /> +
+ ); +} + +export default function SurveysClient({ surveys }: { surveys: Survey[] }) { + const router = useRouter(); + const [showForm, setShowForm] = useState(false); + const [editingSurvey, setEditingSurvey] = useState(null); + const [shareModalOpen, setShareModalOpen] = useState(false); + const [selectedSurvey, setSelectedSurvey] = useState(null); + + const toggleSurveyStatus = async (survey: Survey) => { + const supabase = createClientClientComponent(); + if (!supabase) { toast.error("Failed to initialize database connection"); return; } + const { error } = await supabase + .from("surveys") + .update({ is_active: !survey.is_active, updated_at: new Date().toISOString() }) + .eq("id", survey.id); + if (error) { + toast.error("Failed to update survey status"); + } else { + toast.success(`Survey ${!survey.is_active ? "activated" : "deactivated"}`); + router.refresh(); + } + }; + + const deleteSurvey = async (surveyId: string) => { + if (!confirm("Are you sure you want to delete this survey?")) return; + const supabase = createClientClientComponent(); + if (!supabase) { toast.error("Failed to initialize database connection"); return; } + const { error } = await supabase.from("surveys").delete().eq("id", surveyId); + if (error) { + toast.error("Failed to delete survey"); + } else { + toast.success("Survey deleted successfully"); + router.refresh(); + } + }; + + const handleFormSuccess = () => { + setShowForm(false); + setEditingSurvey(null); + router.refresh(); + }; + + const handleShare = (survey: Survey) => { + setSelectedSurvey(survey); + setShareModalOpen(true); + }; + + const formatDate = (dateString: string) => { + return new Date(dateString).toLocaleDateString("en-US", { + year: "numeric", + month: "short", + day: "numeric", + }); + }; + + return ( + + {/* ── Page Header ── */} +
+
+
+ +
+
+

+ Surveys +

+

+ Manage surveys and collect feedback from users +

+
+
+ +
+ + {/* ── Create / Edit Form ── */} + {(showForm || editingSurvey) && ( +
+
+
+
+ {editingSurvey ? "✏️" : "➕"} +
+

+ {editingSurvey ? "Edit Survey" : "Create New Survey"} +

+
+ +
+ +
+ )} + + {/* ── Quick Stats Row ── */} + {surveys.length > 0 && ( +
+
+ {surveys.length} total +
+
+ {surveys.filter((s) => s.is_active).length} active +
+
+ {surveys.filter((s) => !s.is_active).length} inactive +
+
+ )} + + {/* ── Survey Cards ── */} +
+ {surveys.length === 0 ? ( +
+
+
+ +
+
+

+ No surveys yet +

+

+ Create your first survey to start collecting feedback +

+
+ +
+
+ ) : ( + surveys.map((survey) => ( +
+
+ {/* ── Left: Thumbnail + Content ── */} +
+ + +
+ {/* Title + Badges */} +
+

+ {survey.title} +

+ + {survey.type} + + + {survey.target_audience} + + {/* Status badge with dot indicator */} + + + {survey.is_active ? "Active" : "Inactive"} + +
+ + {/* Description — clamp to 2 lines */} +

+ {survey.description} +

+ + {/* Meta: dates + priority. + Dates render in the viewer's timezone, which can differ from the + server's, so the server HTML and the client render may disagree. */} +
+ Created {formatDate(survey.start_date)} + {survey.end_date && ( + <> + + Expires {formatDate(survey.end_date)} + + )} + + Priority {survey.priority} +
+
+
+ + {/* ── Right: Action button group ── */} +
+ + + + + {/* Visual separator before destructive action */} +
+ +
+
+
+ )) + )} +
+ + {/* Share Modal */} + {selectedSurvey && ( + { + setShareModalOpen(false); + setSelectedSurvey(null); + }} + surveyId={selectedSurvey.id} + surveyTitle={selectedSurvey.title} + /> + )} + + ); +} diff --git a/apps/codebility/app/home/settings/surveys/page.tsx b/apps/codebility/app/home/settings/surveys/page.tsx index e0d70461f..7a53b0222 100644 --- a/apps/codebility/app/home/settings/surveys/page.tsx +++ b/apps/codebility/app/home/settings/surveys/page.tsx @@ -1,407 +1,31 @@ -"use client"; +import { Suspense } from "react"; +import { createClientServerComponent } from "@/utils/supabase/server"; -import { useEffect, useState } from "react"; -import { useRouter } from "next/navigation"; -import { H1 } from "@/components/shared/dashboard"; -import { createClientClientComponent } from "@/utils/supabase/client"; -import { Eye, EyeOff, Pencil, Plus, Settings, Trash2, Share2, BarChart2 } from "lucide-react"; -import { toast } from "sonner"; +import SurveysClient, { type Survey } from "./_components/SurveysClient"; +import SurveysLoading from "./loading"; -import { Button } from "@codevs/ui/button"; +async function SurveysData() { + const supabase = await createClientServerComponent(); -import PageContainer from "../../_components/PageContainer"; -import SurveyForm from "./_components/SurveyForm"; -import ShareSurveyModal from "./_components/ShareSurveyModal"; + // Same query the page used to run from the browser: access stays governed by + // RLS, so anyone with the `settings` permission still sees what they saw before. + const { data, error } = await supabase + .from("surveys") + .select("id, title, description, type, image_url, target_audience, is_active, priority, start_date, end_date, created_at") + .order("priority", { ascending: false }) + .order("created_at", { ascending: false }); -interface Survey { - id: string; - title: string; - description: string; - type: "general" | "feedback" | "satisfaction" | "research" | "onboarding"; - image_url?: string; - target_audience: "all" | "codev" | "intern" | "hr" | "admin"; - is_active: boolean; - priority: number; - start_date: string; - end_date?: string; - created_at: string; -} - -// Badge color map using transparent fills (dark mode friendly) -const typeColors: Record = { - general: "bg-blue-500/15 text-blue-400 border border-blue-500/30", - feedback: "bg-emerald-500/15 text-emerald-400 border border-emerald-500/30", - satisfaction: "bg-amber-500/15 text-amber-400 border border-amber-500/30", - research: "bg-violet-500/15 text-violet-400 border border-violet-500/30", - onboarding: "bg-pink-500/15 text-pink-400 border border-pink-500/30", -}; - -// Left accent border per survey type - gives each card a colored identity stripe -const typeAccent: Record = { - general: "border-l-blue-500", - feedback: "border-l-emerald-500", - satisfaction: "border-l-amber-500", - research: "border-l-violet-500", - onboarding: "border-l-pink-500", -}; - -const audienceColors: Record = { - all: "bg-gray-500/15 text-gray-400 border border-gray-500/30", - codev: "bg-indigo-500/15 text-indigo-400 border border-indigo-500/30", - intern: "bg-teal-500/15 text-teal-400 border border-teal-500/30", - hr: "bg-orange-500/15 text-orange-400 border border-orange-500/30", - admin: "bg-red-500/15 text-red-400 border border-red-500/30", -}; - -// Handles its own imgError state — shows uploaded image when valid, -// falls back to Codebility logo when image_url is absent or broken -function SurveyThumbnail({ - imageUrl, - title, -}: { - imageUrl?: string; - title: string; -}) { - const [imgError, setImgError] = useState(false); - - const showFallback = !imageUrl || imgError; + if (error) { + console.error("Error fetching surveys:", error); + } - return ( - // h-16 w-16 both explicit — aspect-square conflicts with flex row stretching -
- {title} { - // Only log + set error when trying to load the actual image, not the fallback - if (!imgError) { - console.error("[SurveyThumbnail] Failed to load image_url:", imageUrl); - setImgError(true); - } - }} - /> -
- ); + return ; } export default function SurveysPage() { - const router = useRouter(); - const [surveys, setSurveys] = useState([]); - const [loading, setLoading] = useState(true); - const [showForm, setShowForm] = useState(false); - const [editingSurvey, setEditingSurvey] = useState(null); - const [shareModalOpen, setShareModalOpen] = useState(false); - const [selectedSurvey, setSelectedSurvey] = useState(null); - - useEffect(() => { - fetchSurveys(); - }, []); - - const fetchSurveys = async () => { - const supabase = createClientClientComponent(); - if (!supabase) { - toast.error("Failed to initialize database connection"); - setLoading(false); - return; - } - const { data, error } = await supabase - .from("surveys") - .select("*") - .order("priority", { ascending: false }) - .order("created_at", { ascending: false }); - - if (error) { - console.error("Error fetching surveys:", error); - toast.error("Failed to fetch surveys"); - } else { - setSurveys(data || []); - } - setLoading(false); - }; - - const toggleSurveyStatus = async (survey: Survey) => { - const supabase = createClientClientComponent(); - if (!supabase) { toast.error("Failed to initialize database connection"); return; } - const { error } = await supabase - .from("surveys") - .update({ is_active: !survey.is_active, updated_at: new Date().toISOString() }) - .eq("id", survey.id); - if (error) { - toast.error("Failed to update survey status"); - } else { - toast.success(`Survey ${!survey.is_active ? "activated" : "deactivated"}`); - fetchSurveys(); - } - }; - - const deleteSurvey = async (surveyId: string) => { - if (!confirm("Are you sure you want to delete this survey?")) return; - const supabase = createClientClientComponent(); - if (!supabase) { toast.error("Failed to initialize database connection"); return; } - const { error } = await supabase.from("surveys").delete().eq("id", surveyId); - if (error) { - toast.error("Failed to delete survey"); - } else { - toast.success("Survey deleted successfully"); - fetchSurveys(); - } - }; - - const handleFormSuccess = () => { - setShowForm(false); - setEditingSurvey(null); - fetchSurveys(); - }; - - const handleShare = (survey: Survey) => { - setSelectedSurvey(survey); - setShareModalOpen(true); - }; - - const formatDate = (dateString: string) => { - return new Date(dateString).toLocaleDateString("en-US", { - year: "numeric", - month: "short", - day: "numeric", - }); - }; - - if (loading) { - return ( - -
-
-
- - ); - } - return ( - - {/* ── Page Header ── */} -
-
-
- -
-
-

- Surveys -

-

- Manage surveys and collect feedback from users -

-
-
- -
- - {/* ── Create / Edit Form ── */} - {(showForm || editingSurvey) && ( -
-
-
-
- {editingSurvey ? "✏️" : "➕"} -
-

- {editingSurvey ? "Edit Survey" : "Create New Survey"} -

-
- -
- -
- )} - - {/* ── Quick Stats Row ── */} - {surveys.length > 0 && ( -
-
- {surveys.length} total -
-
- {surveys.filter((s) => s.is_active).length} active -
-
- {surveys.filter((s) => !s.is_active).length} inactive -
-
- )} - - {/* ── Survey Cards ── */} -
- {surveys.length === 0 ? ( -
-
-
- -
-
-

- No surveys yet -

-

- Create your first survey to start collecting feedback -

-
- -
-
- ) : ( - surveys.map((survey) => ( -
-
- {/* ── Left: Thumbnail + Content ── */} -
- - -
- {/* Title + Badges */} -
-

- {survey.title} -

- - {survey.type} - - - {survey.target_audience} - - {/* Status badge with dot indicator */} - - - {survey.is_active ? "Active" : "Inactive"} - -
- - {/* Description — clamp to 2 lines */} -

- {survey.description} -

- - {/* Meta: dates + priority */} -
- Created {formatDate(survey.start_date)} - {survey.end_date && ( - <> - - Expires {formatDate(survey.end_date)} - - )} - - Priority {survey.priority} -
-
-
- - {/* ── Right: Action button group ── */} -
- - - - - {/* Visual separator before destructive action */} -
- -
-
-
- )) - )} -
- - {/* Share Modal */} - {selectedSurvey && ( - { - setShareModalOpen(false); - setSelectedSurvey(null); - }} - surveyId={selectedSurvey.id} - surveyTitle={selectedSurvey.title} - /> - )} - + }> + + ); -} \ No newline at end of file +} diff --git a/apps/codebility/app/home/surveys/[surveyId]/_components/SurveyResponseForm.tsx b/apps/codebility/app/home/surveys/[surveyId]/_components/SurveyResponseForm.tsx new file mode 100644 index 000000000..0c07d48fb --- /dev/null +++ b/apps/codebility/app/home/surveys/[surveyId]/_components/SurveyResponseForm.tsx @@ -0,0 +1,307 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { useRouter } from "next/navigation"; +import { Button } from "@codevs/ui/button"; +import { Input } from "@codevs/ui/input"; +import { Textarea } from "@codevs/ui/textarea"; +import { Label } from "@codevs/ui/label"; +import { RadioGroup, RadioGroupItem } from "@codevs/ui/radio-group"; +import { Checkbox } from "@codevs/ui/checkbox"; +import PageContainer from "../../../_components/PageContainer"; +import { toast } from "sonner"; +import { submitSurveyResponse } from "@/actions/settings/survey-responses"; + +export interface Question { + id: string; + question_text: string; + description?: string; + question_type: string; + options: string[]; + settings: { + required: boolean; + placeholder?: string; + min_rating?: number; + max_rating?: number; + }; +} + +export interface Survey { + id: string; + title: string; + description: string; + is_active: boolean; +} + +interface SurveyResponseFormProps { + surveyId: string; + survey: Survey; + questions: Question[]; + questionsError?: string; + alreadyResponded: boolean; +} + +export default function SurveyResponseForm({ + surveyId, + survey, + questions, + questionsError, + alreadyResponded, +}: SurveyResponseFormProps) { + const router = useRouter(); + + const [answers, setAnswers] = useState>({}); + const [submitting, setSubmitting] = useState(false); + + useEffect(() => { + if (questionsError) { + toast.error(questionsError); + } + }, [questionsError]); + + const handleAnswerChange = (questionId: string, value: any) => { + setAnswers((prev) => ({ + ...prev, + [questionId]: value, + })); + }; + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + + // Validate required questions + const missingRequired = questions.filter( + (q) => q.settings.required && !answers[q.id] + ); + + if (missingRequired.length > 0) { + toast.error(`Please answer all required questions`); + return; + } + + setSubmitting(true); + + const result = await submitSurveyResponse(surveyId, { + answers, + status: "completed", + }); + + setSubmitting(false); + + if (result.error) { + toast.error(result.error); + } else { + toast.success("Survey submitted successfully!"); + router.push("/home"); + } + }; + + const renderQuestion = (question: Question) => { + const value = answers[question.id]; + + switch (question.question_type) { + case "text": + case "email": + case "number": + return ( + handleAnswerChange(question.id, e.target.value)} + placeholder={question.settings.placeholder || ""} + className="rounded" + required={question.settings.required} + /> + ); + + case "textarea": + return ( +