diff --git a/.github/workflows/publish.yaml b/.github/workflows/publish.yaml index 8786eab..e398adc 100644 --- a/.github/workflows/publish.yaml +++ b/.github/workflows/publish.yaml @@ -19,7 +19,7 @@ jobs: uses: docker/login-action@v3 with: username: ${{ vars.DOCKERHUB_USERNAME }} - password: ${{ secrets.TOKEN }} + password: ${{ secrets.DOCKERHUB_TOKEN }} - name: Build Docker image uses: docker/build-push-action@v5 diff --git a/drizzle.config.ts b/drizzle.config.ts index 6ada99e..9715245 100644 --- a/drizzle.config.ts +++ b/drizzle.config.ts @@ -1,5 +1,5 @@ import { defineConfig } from "drizzle-kit"; -import { config } from "./src/lib/config"; +import { config } from "@/lib/config"; export default defineConfig({ dialect: "postgresql", @@ -8,4 +8,5 @@ export default defineConfig({ dbCredentials: { url: config.database, }, + casing: "snake_case", }); diff --git a/package.json b/package.json index 5312ba0..a403b58 100644 --- a/package.json +++ b/package.json @@ -12,7 +12,7 @@ "dev": "next dev --turbopack", "build": "next build", "start": "next start", - "lin": "biome check", + "lint": "biome check", "format": "biome format --write", "lint-staged": "lint-staged", "prepare": "husky", @@ -86,7 +86,6 @@ "drizzle-seed": "^0.3.1", "drizzle-zod": "^0.8.3", "embla-carousel-react": "^8.6.0", - "framer-motion": "12.23.0", "geist": "^1.4.2", "globals": "^16.0.0", "ical-generator": "^9.0.0", @@ -121,7 +120,6 @@ "zustand": "^5.0.3" }, "devDependencies": { - "@eslint/eslintrc": "^3", "@tailwindcss/postcss": "^4.0.17", "@types/node": "^22.15.3", "@types/nodemailer": "^6.4.14", @@ -129,13 +127,11 @@ "@types/react-datepicker": "^6.2.0", "@types/react-dom": "^19", "@types/uuid": "^10.0.0", - "@vitest/ui": "^3.2.4", "babel-plugin-react-compiler": "^1.0.0", "husky": "^9.1.7", "inngest-cli": "^1.15.3", "tailwindcss": "^4.1.18", - "typescript": "^5", - "vitest": "^3.2.4" + "typescript": "^5" }, "trustedDependencies": [ "inngest-cli", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5303398..5a93b83 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -161,9 +161,6 @@ importers: embla-carousel-react: specifier: ^8.6.0 version: 8.6.0(react@19.2.3) - framer-motion: - specifier: 12.23.0 - version: 12.23.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3) geist: specifier: ^1.4.2 version: 1.5.1(next@16.1.1(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)) @@ -261,9 +258,6 @@ importers: specifier: ^5.0.3 version: 5.0.9(@types/react@19.2.7)(react@19.2.3)(use-sync-external-store@1.6.0(react@19.2.3)) devDependencies: - '@eslint/eslintrc': - specifier: ^3 - version: 3.3.3 '@tailwindcss/postcss': specifier: ^4.0.17 version: 4.1.18 @@ -285,9 +279,6 @@ importers: '@types/uuid': specifier: ^10.0.0 version: 10.0.0 - '@vitest/ui': - specifier: ^3.2.4 - version: 3.2.4(vitest@3.2.4) babel-plugin-react-compiler: specifier: ^1.0.0 version: 1.0.0 @@ -303,9 +294,6 @@ importers: typescript: specifier: ^5 version: 5.9.3 - vitest: - specifier: ^3.2.4 - version: 3.2.4(@types/debug@4.1.12)(@types/node@22.19.3)(@vitest/ui@3.2.4)(jiti@2.6.1)(lightningcss@1.30.2) packages: @@ -4010,20 +3998,6 @@ packages: forwarded-parse@2.1.2: resolution: {integrity: sha512-alTFZZQDKMporBH77856pXgzhEzaUVmLCDk+egLgIgHst3Tpndzz8MnKe+GzRJRfvVdn69HhpW7cmXzvtLvJAw==} - framer-motion@12.23.0: - resolution: {integrity: sha512-xf6NxTGAyf7zR4r2KlnhFmsRfKIbjqeBupEDBAaEtVIBJX96sAon00kMlsKButSIRwPSHjbRrAPnYdJJ9kyhbA==} - peerDependencies: - '@emotion/is-prop-valid': '*' - react: ^18.0.0 || ^19.0.0 - react-dom: ^18.0.0 || ^19.0.0 - peerDependenciesMeta: - '@emotion/is-prop-valid': - optional: true - react: - optional: true - react-dom: - optional: true - framer-motion@12.23.26: resolution: {integrity: sha512-cPcIhgR42xBn1Uj+PzOyheMtZ73H927+uWPDVhUMqxy8UHt6Okavb6xIz9J/phFUHUj0OncR6UvMfJTXoc/LKA==} peerDependencies: @@ -6975,7 +6949,8 @@ snapshots: '@pinojs/redact@0.4.0': {} - '@polka/url@1.0.0-next.29': {} + '@polka/url@1.0.0-next.29': + optional: true '@protobuf-ts/runtime-rpc@2.11.1': dependencies: @@ -8398,6 +8373,7 @@ snapshots: dependencies: '@types/deep-eql': 4.0.2 assertion-error: 2.0.1 + optional: true '@types/connect@3.4.38': dependencies: @@ -8431,7 +8407,8 @@ snapshots: dependencies: '@types/ms': 2.1.0 - '@types/deep-eql@4.0.2': {} + '@types/deep-eql@4.0.2': + optional: true '@types/estree@1.0.8': {} @@ -8608,6 +8585,7 @@ snapshots: '@vitest/utils': 3.2.4 chai: 5.3.3 tinyrainbow: 2.0.0 + optional: true '@vitest/mocker@3.2.4(vite@7.3.0(@types/node@22.19.3)(jiti@2.6.1)(lightningcss@1.30.2))': dependencies: @@ -8616,26 +8594,31 @@ snapshots: magic-string: 0.30.21 optionalDependencies: vite: 7.3.0(@types/node@22.19.3)(jiti@2.6.1)(lightningcss@1.30.2) + optional: true '@vitest/pretty-format@3.2.4': dependencies: tinyrainbow: 2.0.0 + optional: true '@vitest/runner@3.2.4': dependencies: '@vitest/utils': 3.2.4 pathe: 2.0.3 strip-literal: 3.1.0 + optional: true '@vitest/snapshot@3.2.4': dependencies: '@vitest/pretty-format': 3.2.4 magic-string: 0.30.21 pathe: 2.0.3 + optional: true '@vitest/spy@3.2.4': dependencies: tinyspy: 4.0.4 + optional: true '@vitest/ui@3.2.4(vitest@3.2.4)': dependencies: @@ -8647,12 +8630,14 @@ snapshots: tinyglobby: 0.2.15 tinyrainbow: 2.0.0 vitest: 3.2.4(@types/debug@4.1.12)(@types/node@22.19.3)(@vitest/ui@3.2.4)(jiti@2.6.1)(lightningcss@1.30.2) + optional: true '@vitest/utils@3.2.4': dependencies: '@vitest/pretty-format': 3.2.4 loupe: 3.2.1 tinyrainbow: 2.0.0 + optional: true acorn-import-attributes@1.9.5(acorn@8.15.0): dependencies: @@ -8689,7 +8674,8 @@ snapshots: dependencies: tslib: 2.8.1 - assertion-error@2.0.1: {} + assertion-error@2.0.1: + optional: true asynckit@0.4.0: {} @@ -8760,7 +8746,8 @@ snapshots: buffer-from@1.1.2: {} - cac@6.7.14: {} + cac@6.7.14: + optional: true call-bind-apply-helpers@1.0.2: dependencies: @@ -8780,6 +8767,7 @@ snapshots: deep-eql: 5.0.2 loupe: 3.2.1 pathval: 2.0.1 + optional: true chalk@4.1.2: dependencies: @@ -8790,7 +8778,8 @@ snapshots: dependencies: '@kurkle/color': 0.3.4 - check-error@2.1.1: {} + check-error@2.1.1: + optional: true chownr@2.0.0: {} @@ -8913,7 +8902,8 @@ snapshots: decimal.js-light@2.5.1: {} - deep-eql@5.0.2: {} + deep-eql@5.0.2: + optional: true deep-is@0.1.4: {} @@ -9029,7 +9019,8 @@ snapshots: es-errors@1.3.0: {} - es-module-lexer@1.7.0: {} + es-module-lexer@1.7.0: + optional: true es-object-atoms@1.1.1: dependencies: @@ -9131,6 +9122,7 @@ snapshots: '@esbuild/win32-arm64': 0.27.2 '@esbuild/win32-ia32': 0.27.2 '@esbuild/win32-x64': 0.27.2 + optional: true escalade@3.2.0: {} @@ -9205,12 +9197,14 @@ snapshots: estree-walker@3.0.3: dependencies: '@types/estree': 1.0.8 + optional: true esutils@2.0.3: {} eventemitter3@4.0.7: {} - expect-type@1.3.0: {} + expect-type@1.3.0: + optional: true extend@3.0.2: {} @@ -9232,7 +9226,8 @@ snapshots: optionalDependencies: picomatch: 4.0.3 - fflate@0.8.2: {} + fflate@0.8.2: + optional: true file-entry-cache@8.0.0: dependencies: @@ -9262,15 +9257,6 @@ snapshots: forwarded-parse@2.1.2: {} - framer-motion@12.23.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3): - dependencies: - motion-dom: 12.23.23 - motion-utils: 12.23.6 - tslib: 2.8.1 - optionalDependencies: - react: 19.2.3 - react-dom: 19.2.3(react@19.2.3) - framer-motion@12.23.26(react-dom@19.2.3(react@19.2.3))(react@19.2.3): dependencies: motion-dom: 12.23.23 @@ -9501,7 +9487,8 @@ snapshots: js-tokens@4.0.0: {} - js-tokens@9.0.1: {} + js-tokens@9.0.1: + optional: true js-yaml@4.1.1: dependencies: @@ -9644,7 +9631,8 @@ snapshots: dependencies: js-tokens: 4.0.0 - loupe@3.2.1: {} + loupe@3.2.1: + optional: true lucide-react@0.561.0(react@19.2.3): dependencies: @@ -9705,7 +9693,8 @@ snapshots: react: 19.2.3 react-dom: 19.2.3(react@19.2.3) - mrmime@2.0.1: {} + mrmime@2.0.1: + optional: true ms@2.1.3: {} @@ -9790,9 +9779,11 @@ snapshots: path-key@3.1.1: {} - pathe@2.0.3: {} + pathe@2.0.3: + optional: true - pathval@2.0.1: {} + pathval@2.0.1: + optional: true peberminta@0.9.0: {} @@ -10111,6 +10102,7 @@ snapshots: '@rollup/rollup-win32-x64-gnu': 4.54.0 '@rollup/rollup-win32-x64-msvc': 4.54.0 fsevents: 2.3.3 + optional: true rou3@0.7.12: {} @@ -10191,13 +10183,15 @@ snapshots: shebang-regex@3.0.0: {} - siginfo@2.0.0: {} + siginfo@2.0.0: + optional: true sirv@3.0.2: dependencies: '@polka/url': 1.0.0-next.29 mrmime: 2.0.1 totalist: 3.0.1 + optional: true sonic-boom@4.2.0: dependencies: @@ -10219,9 +10213,11 @@ snapshots: split2@4.2.0: {} - stackback@0.0.2: {} + stackback@0.0.2: + optional: true - std-env@3.10.0: {} + std-env@3.10.0: + optional: true string-width@4.2.3: dependencies: @@ -10242,6 +10238,7 @@ snapshots: strip-literal@3.1.0: dependencies: js-tokens: 9.0.1 + optional: true strnum@2.1.2: {} @@ -10287,22 +10284,28 @@ snapshots: tiny-invariant@1.3.3: {} - tinybench@2.9.0: {} + tinybench@2.9.0: + optional: true - tinyexec@0.3.2: {} + tinyexec@0.3.2: + optional: true tinyglobby@0.2.15: dependencies: fdir: 6.5.0(picomatch@4.0.3) picomatch: 4.0.3 - tinypool@1.1.1: {} + tinypool@1.1.1: + optional: true - tinyrainbow@2.0.0: {} + tinyrainbow@2.0.0: + optional: true - tinyspy@4.0.4: {} + tinyspy@4.0.4: + optional: true - totalist@3.0.1: {} + totalist@3.0.1: + optional: true tr46@0.0.3: {} @@ -10412,6 +10415,7 @@ snapshots: - terser - tsx - yaml + optional: true vite@7.3.0(@types/node@22.19.3)(jiti@2.6.1)(lightningcss@1.30.2): dependencies: @@ -10426,6 +10430,7 @@ snapshots: fsevents: 2.3.3 jiti: 2.6.1 lightningcss: 1.30.2 + optional: true vitest@3.2.4(@types/debug@4.1.12)(@types/node@22.19.3)(@vitest/ui@3.2.4)(jiti@2.6.1)(lightningcss@1.30.2): dependencies: @@ -10469,6 +10474,7 @@ snapshots: - terser - tsx - yaml + optional: true wasm-feature-detect@1.8.0: {} @@ -10491,6 +10497,7 @@ snapshots: dependencies: siginfo: 2.0.0 stackback: 0.0.2 + optional: true word-wrap@1.2.5: {} diff --git a/src/action/meeting.action.ts b/src/action/meeting.action.ts index 541d806..32c2b87 100644 --- a/src/action/meeting.action.ts +++ b/src/action/meeting.action.ts @@ -1,6 +1,169 @@ "use server"; import { db } from "@/db/client"; -import { workspacesTable, workspaceMeetingTable } from "@/db/schema/schema"; +import { + workspacesTable, + workspaceMeetingTable, + meetingParticipantsTable, + membersTable, + SelectUserType, + usersTable, + SelectParticipantType, + SelectWorkspaceType, + SelectMemberType, +} from "@/db/schema/schema"; +import { APIResponse, Call, MeetingResponse } from "@/types"; +import { eq, and } from "drizzle-orm"; + +type Response = Promise>; + +export async function getCallsBySlug(slug: string) { + try { + const [workspace] = await db + .select() + .from(workspacesTable) + .where(eq(workspacesTable.slug, slug)) + .execute(); + + if (!workspace) { + return { + error: `Workspace with slug ${slug} not found`, + success: false, + }; + } + + const meeting = await db + .select() + .from(workspaceMeetingTable) + .where(eq(workspaceMeetingTable.workspaceId, workspace.id)) + .execute(); + + if (!meeting) + return { + error: `No meeting found for workspace with slug ${slug}`, + success: false, + }; + + return { + data: meeting, + success: true, + }; + } catch (error: unknown) { + return { success: false, error: `Failed to get call by slug: ${error}` }; + } +} + +type TParticipant = Omit< + SelectParticipantType, + "id" | "updatedAt" | "createdAt" +>; +type TMember = Omit; +type TUser = Pick; +// type TWorkspace = Omit; + +type WorkspaceAccess = TParticipant & TMember & TUser; + +/* +@params meeting: string +@params workspaceId: string +@returns { data: { access: boolean }, success: boolean } + +*/ +export async function checkWorkspaceMeetingAcces( + meeting: string, + workspaceId: string, + userId: string +): Response { + try { + // First check if the current user is a member of the workspace + const [memberData] = await db + .select() + .from(membersTable) + .where( + and( + eq(membersTable.workspaceId, workspaceId), + eq(membersTable.userId, userId) + ) + ) + .execute(); + + if (!memberData) { + return { + success: false, + error: "You are not a member of this workspace", + }; + } + + // Get user data + const [userData] = await db + .select() + .from(usersTable) + .where(eq(usersTable.id, userId)) + .execute(); + + if (!userData) { + return { + success: false, + error: `User with ID ${userId} not found`, + }; + } + + // Check if meeting exists in database (optional - meeting might be created on-the-fly) + const [meetingData] = await db + .select() + .from(workspaceMeetingTable) + .where( + and( + eq(workspaceMeetingTable.workspaceId, workspaceId), + eq(workspaceMeetingTable.meetingId, meeting) + ) + ) + .execute(); + + // If meeting exists in DB and is completed, don't allow joining + if (meetingData && meetingData.status === "completed") { + return { + success: false, + error: `Meeting with ID ${meeting} has ended`, + }; + } + + // Check if the user is already a participant + let [participantData] = await db + .select() + .from(meetingParticipantsTable) + .where( + and( + eq(meetingParticipantsTable.meetingId, meeting), + eq(meetingParticipantsTable.memberId, memberData.id) + ) + ) + .execute(); + + // If not already a participant, create a mock participant data + // (they are joining for the first time) + if (!participantData) { + participantData = { + id: "", + meetingId: meeting, + memberId: memberData.id, + joinedAt: new Date(), + leftAt: null, + status: "joined", + } as SelectParticipantType; + } + + // User is a workspace member - allow them to join the meeting + return { + data: { ...participantData, ...memberData, ...userData }, + success: true, + }; + } catch (error: unknown) { + return { + success: false, + error: `Failed to check workspace meeting access: ${error}`, + }; + } +} export async function getMeetingsData() { try { @@ -17,20 +180,125 @@ export async function getMeetingsData() { } } -// export async function addMemberToMeeting() { -// try { -// await db.insert(workspaceMeetingTable) -// .values( -// workspaceMeetingTable. -// ) -// } catch (error: unknown) { -// return { message: `Failed to join meeting \n ${error}`, status: 500 } -// } -// } +export async function addMemberToMeeting(meetingId: string, memberId: string) { + try { + const participant = await db + .insert(meetingParticipantsTable) + .values({ + meetingId, + memberId, + }) + .returning(); + + return { + success: true, + message: "Member added to meeting", + participant: participant[0], + }; + } catch (error: unknown) { + console.error(error); + return { + success: false, + message: `Failed to add member to meeting: ${error}`, + status: 500, + }; + } +} + +export async function removeMemberFromMeeting( + meetingId: string, + memberId: string +) { + try { + await db + .update(meetingParticipantsTable) + .set({ leftAt: new Date() }) + .where( + and( + eq(meetingParticipantsTable.meetingId, meetingId), + eq(meetingParticipantsTable.memberId, memberId) + ) + ); + + return { + success: true, + message: "Member removed from meeting", + }; + } catch (error: unknown) { + console.error(error); + return { + success: false, + message: `Failed to remove member from meeting: ${error}`, + status: 500, + }; + } +} + +export async function getMeetingParticipants(meetingId: string) { + try { + const participants = await db + .select({ + participant: meetingParticipantsTable, + member: membersTable, + }) + .from(meetingParticipantsTable) + .innerJoin( + membersTable, + eq(meetingParticipantsTable.memberId, membersTable.id) + ) + .where(eq(meetingParticipantsTable.meetingId, meetingId)); + + return { + success: true, + participants, + }; + } catch (error: unknown) { + console.error(error); + return { + success: false, + message: `Failed to fetch meeting participants: ${error}`, + status: 500, + }; + } +} + +export async function endMeeting( + meetingId: string, + owner: boolean +): Promise> { + try { + // + + // Update meeting status and end time + const [meeting] = await db + .update(workspaceMeetingTable) + .set({ status: "completed", endAt: new Date() }) + .where(eq(workspaceMeetingTable.meetingId, meetingId)) + .returning(); -// Hi, I'm a full stack web developer and a top contributor in open contributions at Tublian. I have worked with NextJS, MERN, Razorpay and zustand. + if (!meeting) { + return { + success: false, + error: `Meeting with ID ${meetingId} not found`, + }; + } -// I have completed two internships in web development. + await db + .update(meetingParticipantsTable) + .set({ + leftAt: new Date(), + }) + .where(eq(meetingParticipantsTable.meetingId, meetingId)); -// I see a job for a Frontend developer and am interested in the position. -// Could you please refer me. + return { + success: true, + data: meeting, + }; + } catch (error: unknown) { + console.error(error); + return { + success: false, + error: `Failed to end meeting: ${error}`, + }; + } +} diff --git a/src/action/participant.action.ts b/src/action/participant.action.ts new file mode 100644 index 0000000..d5d51ea --- /dev/null +++ b/src/action/participant.action.ts @@ -0,0 +1,145 @@ +"use server"; + +import { headers } from "next/headers"; +import { auth } from "@/lib/auth-config"; +import { db } from "@/db/client"; +import { membersTable, usersTable } from "@/db/schema/schema"; +import { eq, and } from "drizzle-orm"; +import type { APIResponse, TUserRole } from "@/types"; + +export type ParticipantRole = TUserRole; + +interface ParticipantRoleResponse { + role: ParticipantRole; + userId: string; + userName: string; + name: string; + email: string; +} + +interface RoleResponse extends APIResponse {} + +/** + * Get participant role in a workspace + * Used for displaying role badges in video calls + */ +export const getParticipantRole = async ( + userId: string, + workspaceId: string +): Promise => { + try { + // First try using Better Auth (faster) + const workspace = await auth.api.getFullOrganization({ + query: { + organizationId: workspaceId, + }, + headers: await headers(), + }); + + if (workspace) { + const member = workspace.members?.find((m) => m.userId === userId); + + if (member) { + return { + success: true, + data: { + role: member.role as ParticipantRole, + userId: member.userId, + userName: member.userId, // Better Auth might not have userName + name: member.userId, // Fallback, will be overridden by DB query if needed + email: "", // Not available in Better Auth member + }, + }; + } + } + + // Fallback: Query database directly + const result = await db + .select({ + role: membersTable.role, + userId: usersTable.id, + userName: usersTable.userName, + name: usersTable.name, + email: usersTable.email, + }) + .from(membersTable) + .innerJoin(usersTable, eq(membersTable.userId, usersTable.id)) + .where( + and( + eq(membersTable.userId, userId), + eq(membersTable.workspaceId, workspaceId) + ) + ) + .limit(1); + + if (result.length === 0) { + return { + success: false, + error: "Participant not found in workspace", + }; + } + + const data = result[0]; + return { + success: true, + data: { + role: data.role as ParticipantRole, + userId: data.userId, + userName: data.userName, + name: data.name, + email: data.email, + }, + }; + } catch (error) { + console.error("Error fetching participant role:", error); + return { + success: false, + error: "Failed to fetch participant role", + }; + } +}; + +/** + * Get multiple participant roles at once (batch operation) + */ +export const getParticipantRoles = async ( + userIds: string[], + workspaceId: string +): Promise> => { + try { + const results = await db + .select({ + role: membersTable.role, + userId: usersTable.id, + userName: usersTable.userName, + name: usersTable.name, + email: usersTable.email, + }) + .from(membersTable) + .innerJoin(usersTable, eq(membersTable.userId, usersTable.id)) + .where( + and( + eq(membersTable.workspaceId, workspaceId), + // Filter by userIds array + userIds.length > 0 ? eq(membersTable.userId, userIds[0]) : undefined + ) + ); + + return { + success: true, + data: results.map((r) => ({ + role: r.role as ParticipantRole, + userId: r.userId, + userName: r.userName, + name: r.name, + email: r.email, + })), + }; + } catch (error) { + console.error("Error fetching participant roles:", error); + return { + success: false, + error: "Failed to fetch participant roles", + }; + } +}; diff --git a/src/action/user.action.ts b/src/action/user.action.ts index 2ea7a26..eea3ad0 100644 --- a/src/action/user.action.ts +++ b/src/action/user.action.ts @@ -90,16 +90,20 @@ export async function loginAction(email: string, password: string) { headers: authHeaders, }); - if (data.length > 0) { - await auth.api.setActiveOrganization({ - body: { - organizationId: data[0].id, - }, - headers: authHeaders, - }); + if (data.length < 0) { + return { + data: { user: result.user, organizationExists: false }, + success: true, + }; } - return { data: result.user, success: true }; + return { + data: { + user: result.user, + organizationExists: true, + }, + success: true, + }; } catch (error: unknown) { let message = "An unknown error occurred"; diff --git a/src/action/workspace.action.ts b/src/action/workspace.action.ts index 0a722be..2d47b0c 100644 --- a/src/action/workspace.action.ts +++ b/src/action/workspace.action.ts @@ -1,9 +1,14 @@ "use server"; import { db } from "@/db/client"; -import { workspacesTable, membersTable, usersTable } from "@/db/schema/schema"; +import { + workspacesTable, + membersTable, + usersTable, + workspaceMeetingTable, +} from "@/db/schema/schema"; import { headers } from "next/headers"; import { auth } from "@/lib/auth-config"; -import { and, eq } from "drizzle-orm"; +import { and, eq, isNull } from "drizzle-orm"; import { redirect } from "next/navigation"; import { canDeleteWorkspace } from "@/lib/workspace-auth"; import { getCurrentUser } from "@/lib/session"; @@ -419,3 +424,41 @@ export async function deleteWorkspace( }; } } + +export async function getActiveMeetingForWorkspace(workspaceSlug: string) { + try { + const [workspace] = await db + .select() + .from(workspacesTable) + .where(eq(workspacesTable.slug, workspaceSlug)) + .execute(); + + if (!workspace) { + return { message: "Workspace not found", success: false }; + } + + const [activeMeeting] = await db + .select() + .from(workspaceMeetingTable) + .where( + and( + eq(workspaceMeetingTable.workspaceId, workspace.id), + isNull(workspaceMeetingTable.endAt) + ) + ) + .execute(); + + if (!activeMeeting) { + return { message: "No active meeting found", success: false }; + } + + return { meeting: activeMeeting, success: true }; + } catch (error: unknown) { + return { + error: `Failed to get active meeting for workspace: ${ + error instanceof Error ? error.message : "An unknown error occurred" + }`, + success: false, + }; + } +} diff --git a/src/app/(root)/(dashboard)/meeting/[id]/page.tsx b/src/app/(root)/(dashboard)/meeting/[id]/page.tsx index 049db35..caee6b8 100644 --- a/src/app/(root)/(dashboard)/meeting/[id]/page.tsx +++ b/src/app/(root)/(dashboard)/meeting/[id]/page.tsx @@ -8,8 +8,7 @@ import { Loader } from "lucide-react"; import { useGetCallById } from "@/hooks/useGetCallById"; import Alert from "@/components/Alert"; -import MeetingSetup from "@/components/MeetingSetup"; -import MeetingRoom from "@/components/MeetingRoom"; +import { MeetingRoom, MeetingSetup } from "@/components/workspace/meeting"; const MeetingPage = () => { const { id } = useParams(); diff --git a/src/app/(root)/(dashboard)/workspace/[slug]/(members)/personal-room/page.tsx b/src/app/(root)/(dashboard)/workspace/[slug]/(members)/personal-room/page.tsx index e042170..d4a851e 100644 --- a/src/app/(root)/(dashboard)/workspace/[slug]/(members)/personal-room/page.tsx +++ b/src/app/(root)/(dashboard)/workspace/[slug]/(members)/personal-room/page.tsx @@ -7,7 +7,6 @@ import { useRouter } from "next/navigation"; import { useGetCallById } from "@/hooks/useGetCallById"; import { Button } from "@/components/ui/button"; import { useToast } from "@/components/ui/use-toast"; -import Usercall from "../user/_components/usercall"; const Table = ({ title, @@ -85,7 +84,6 @@ const PersonalRoom = () => { Copy Invitation - ); }; diff --git a/src/app/(root)/(dashboard)/workspace/[slug]/(members)/user/_components/usercall.tsx b/src/app/(root)/(dashboard)/workspace/[slug]/(members)/user/_components/usercall.tsx deleted file mode 100644 index 414b544..0000000 --- a/src/app/(root)/(dashboard)/workspace/[slug]/(members)/user/_components/usercall.tsx +++ /dev/null @@ -1,73 +0,0 @@ -"use client"; -import { - Table, - TableBody, - TableCell, - TableHead, - TableHeader, - TableRow, -} from "@/components/ui/table"; -import { useGetCallsByTeam } from "@/hooks/useGetCallsbyTeam"; -import { useWorkspaceStore } from "@/store/workspace"; -import type { Call } from "@stream-io/video-react-sdk"; - -const Usercall = () => { - const { workspaceName } = useWorkspaceStore(); - const { calls: TeamCall, isCallsLoading } = useGetCallsByTeam( - workspaceName as string, - ); - - if (isCallsLoading || !workspaceName) { - return
Loading...
; - } - - const filteredCalls = TeamCall.filter((call: Call) => call.state.endedAt); - - return ( -
-

User Call

-
-

Total call: {TeamCall.length}

- - - - Description - Call ID - Updated At - Ended At - - - - {TeamCall && - TeamCall.length > 0 && - filteredCalls - .sort((a: Call, b: Call) => { - return ( - new Date(b.state.updatedAt!).getTime() - - new Date(a.state.updatedAt!).getTime() - ); - }) - .map((call: Call, index: number) => { - return ( - - - {call.state.custom?.description} - - {call.id} - - {call.state.updatedAt?.toLocaleDateString()} - - - {call.state.endedAt?.toLocaleDateString()} - - - ); - })} - -
-
-
- ); -}; - -export default Usercall; diff --git a/src/app/(root)/(dashboard)/workspace/[slug]/(members)/user/page.tsx b/src/app/(root)/(dashboard)/workspace/[slug]/(members)/user/page.tsx deleted file mode 100644 index 88c7374..0000000 --- a/src/app/(root)/(dashboard)/workspace/[slug]/(members)/user/page.tsx +++ /dev/null @@ -1,44 +0,0 @@ -import React from "react"; -import { headers } from "next/headers"; -import { auth } from "@/lib/auth-config"; -import Image from "next/image"; -import Usercall from "./_components/usercall"; -import { WeeklyMeetingsChart } from "@/components/charts/WeeklyMeetingsChart"; -import { DailyMeetingsChart } from "@/components/charts/DailyMeetingsChart"; - -const User = async () => { - const session = await auth.api.getSession({ - headers: await headers(), - }); - const user = session?.user; - - return ( -
-
- Profile Picture -
-

{user?.name}

-

@{user?.userName || user?.name}

-

{user?.email}

-
-
-
- - -
- -
-
-
-
- ); -}; - -export default User; diff --git a/src/app/(root)/(dashboard)/workspace/[slug]/(members)/workspace-details/page.tsx b/src/app/(root)/(dashboard)/workspace/[slug]/(members)/workspace-details/page.tsx index 79601ad..8ccf4ae 100644 --- a/src/app/(root)/(dashboard)/workspace/[slug]/(members)/workspace-details/page.tsx +++ b/src/app/(root)/(dashboard)/workspace/[slug]/(members)/workspace-details/page.tsx @@ -13,12 +13,14 @@ import { auth } from "@/lib/auth-config"; import { headers } from "next/headers"; import { getCurrentUser } from "@/lib/dal"; import { getMember } from "@/action/member.action"; -import { InviteMemberDialog } from "@/components/workspace/InviteMemberDialog"; +import { InviteMemberDialog } from "@/components/workspace/members/InviteMemberDialog"; +import { TOrganizationMember } from "@/types"; +import MembersTable from "@/components/workspace/meeting/charts/members-table"; export default async function OrgDetailsPage({ params, }: { - params: { slug: string }; + params: Promise<{ slug: string }>; }) { const { slug } = await params; @@ -40,10 +42,12 @@ export default async function OrgDetailsPage({ const orgMember = await getMember(slug, user.id); - if (!orgMember) { + if (!orgMember || !orgMember.success || !orgMember?.data) { throw new Error("Organization member not found"); } + const role = orgMember.data as TOrganizationMember; + const workspace = { ...activeOrg, member: orgMember }; return ( @@ -131,6 +135,8 @@ export default async function OrgDetailsPage({ + + ); } diff --git a/src/app/(root)/(dashboard)/workspace/[slug]/@navbar/default.tsx b/src/app/(root)/(dashboard)/workspace/[slug]/@navbar/default.tsx index 34be9e5..cb14c3b 100644 --- a/src/app/(root)/(dashboard)/workspace/[slug]/@navbar/default.tsx +++ b/src/app/(root)/(dashboard)/workspace/[slug]/@navbar/default.tsx @@ -3,7 +3,7 @@ import { useEffect, useMemo } from "react"; import Link from "next/link"; import { useParams, usePathname } from "next/navigation"; -import Logo from "@/components/Logo"; +import Logo from "@/components/navigation/Logo"; import { useListOrganizations, useActiveOrganization, @@ -23,7 +23,7 @@ import { } from "@/components/ui/breadcrumb"; import { sidebarLinks } from "@/constants/component"; import { IconSlash } from "@tabler/icons-react"; -import OrgSwitcher from "@/components/org-switcher"; +import OrgSwitcher from "@/components/workspace/org-switcher"; export default function Navbar() { const params = useParams<{ slug: string }>(); diff --git a/src/app/(root)/(dashboard)/workspace/[slug]/@navbar/page.tsx b/src/app/(root)/(dashboard)/workspace/[slug]/@navbar/page.tsx index 34be9e5..cb14c3b 100644 --- a/src/app/(root)/(dashboard)/workspace/[slug]/@navbar/page.tsx +++ b/src/app/(root)/(dashboard)/workspace/[slug]/@navbar/page.tsx @@ -3,7 +3,7 @@ import { useEffect, useMemo } from "react"; import Link from "next/link"; import { useParams, usePathname } from "next/navigation"; -import Logo from "@/components/Logo"; +import Logo from "@/components/navigation/Logo"; import { useListOrganizations, useActiveOrganization, @@ -23,7 +23,7 @@ import { } from "@/components/ui/breadcrumb"; import { sidebarLinks } from "@/constants/component"; import { IconSlash } from "@tabler/icons-react"; -import OrgSwitcher from "@/components/org-switcher"; +import OrgSwitcher from "@/components/workspace/org-switcher"; export default function Navbar() { const params = useParams<{ slug: string }>(); diff --git a/src/app/(root)/(dashboard)/workspace/[slug]/admin/dashboard/page.tsx b/src/app/(root)/(dashboard)/workspace/[slug]/admin/dashboard/page.tsx new file mode 100644 index 0000000..41a4689 --- /dev/null +++ b/src/app/(root)/(dashboard)/workspace/[slug]/admin/dashboard/page.tsx @@ -0,0 +1,35 @@ +import WeeklyMeetingsChart from "@/components/workspace/admin/charts/weekly-meeting-chart"; +import DailyMeetingsChart from "@/components/workspace/admin/charts/weekly-meeting-chart"; + +const AdminDashboardPage = async ({ + params, +}: { + params: Promise<{ slug: string }>; +}) => { + const { slug } = await params; + + return ( +
+
+

Admin Dashboard

+
+ +

+ This is the admin dashboard where workspace admins can manage settings, + view analytics, and oversee user activities. +

+ +
+
+ +
+ +
+ +
+
+
+ ); +}; + +export default AdminDashboardPage; diff --git a/src/app/(root)/(dashboard)/workspace/[slug]/(members)/recordings/page.tsx b/src/app/(root)/(dashboard)/workspace/[slug]/admin/recordings/page.tsx similarity index 100% rename from src/app/(root)/(dashboard)/workspace/[slug]/(members)/recordings/page.tsx rename to src/app/(root)/(dashboard)/workspace/[slug]/admin/recordings/page.tsx diff --git a/src/app/(root)/(dashboard)/workspace/[slug]/(members)/workspace-settings/_components/danger-zone.tsx b/src/app/(root)/(dashboard)/workspace/[slug]/admin/workspace-settings/_components/danger-zone.tsx similarity index 100% rename from src/app/(root)/(dashboard)/workspace/[slug]/(members)/workspace-settings/_components/danger-zone.tsx rename to src/app/(root)/(dashboard)/workspace/[slug]/admin/workspace-settings/_components/danger-zone.tsx diff --git a/src/app/(root)/(dashboard)/workspace/[slug]/(members)/workspace-settings/_components/org-settings-form.tsx b/src/app/(root)/(dashboard)/workspace/[slug]/admin/workspace-settings/_components/org-settings-form.tsx similarity index 100% rename from src/app/(root)/(dashboard)/workspace/[slug]/(members)/workspace-settings/_components/org-settings-form.tsx rename to src/app/(root)/(dashboard)/workspace/[slug]/admin/workspace-settings/_components/org-settings-form.tsx diff --git a/src/app/(root)/(dashboard)/workspace/[slug]/(members)/workspace-settings/page.tsx b/src/app/(root)/(dashboard)/workspace/[slug]/admin/workspace-settings/page.tsx similarity index 91% rename from src/app/(root)/(dashboard)/workspace/[slug]/(members)/workspace-settings/page.tsx rename to src/app/(root)/(dashboard)/workspace/[slug]/admin/workspace-settings/page.tsx index e3803c0..3171172 100644 --- a/src/app/(root)/(dashboard)/workspace/[slug]/(members)/workspace-settings/page.tsx +++ b/src/app/(root)/(dashboard)/workspace/[slug]/admin/workspace-settings/page.tsx @@ -12,11 +12,13 @@ import { headers } from "next/headers"; import { getCurrentUser } from "@/lib/dal"; import { getMember } from "@/action/member.action"; import { redirect } from "next/navigation"; +import { + canRoleDeleteOrganization, + canRoleUpdateOrganization, +} from "@/lib/auth-client"; import { OrgSettingsForm } from "./_components/org-settings-form"; import { DangerZone } from "./_components/danger-zone"; -type OrgMemberRole = "owner" | "admin" | "member"; - export default async function OrgSettingsPage({ params, }: { @@ -46,15 +48,16 @@ export default async function OrgSettingsPage({ redirect(`/workspace/${slug}`); } - const userRole = orgMember.data.role as OrgMemberRole; + const userRole = orgMember.data.role; // Only allow owner and admin to access this page if (userRole === "member") { redirect(`/workspace/${slug}/org-details`); } - const canDelete = userRole === "owner"; - const canUpdate = userRole === "owner" || userRole === "admin"; + // Check permissions using utility functions + const canDelete = canRoleDeleteOrganization(userRole); + const canUpdate = canRoleUpdateOrganization(userRole); return (
diff --git a/src/app/(root)/(dashboard)/workspace/[slug]/page.tsx b/src/app/(root)/(dashboard)/workspace/[slug]/page.tsx index 9c64e38..c369d9d 100644 --- a/src/app/(root)/(dashboard)/workspace/[slug]/page.tsx +++ b/src/app/(root)/(dashboard)/workspace/[slug]/page.tsx @@ -1,4 +1,4 @@ -import MeetingTypeList from "@/components/MeetingTypeList"; +import MeetingTypeList from "@/components/workspace/meeting/MeetingTypeList"; import ProfileCard from "@/components/ProfileCard"; import { checkWorkspaceAccess } from "@/lib/workspace-auth"; @@ -12,7 +12,7 @@ const Home = async ({ params }: { params: Promise<{ slug: string }> }) => { minute: "2-digit", }); const date = new Intl.DateTimeFormat("en-IN", { dateStyle: "full" }).format( - now, + now ); return ( @@ -35,7 +35,7 @@ const Home = async ({ params }: { params: Promise<{ slug: string }> }) => {
- + ); }; diff --git a/src/app/(root)/(dashboard)/workspace/page.tsx b/src/app/(root)/(dashboard)/workspace/page.tsx index f0e80ba..abff4d3 100644 --- a/src/app/(root)/(dashboard)/workspace/page.tsx +++ b/src/app/(root)/(dashboard)/workspace/page.tsx @@ -3,6 +3,14 @@ import { headers } from "next/headers"; import { auth } from "@/lib/auth-config"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import Link from "next/link"; +import { + Empty, + EmptyDescription, + EmptyHeader, + EmptyMedia, + EmptyTitle, +} from "@/components/ui/empty"; +import { IconBuildingSkyscraper } from "@tabler/icons-react"; const WorkspacePage = async () => { const session = await auth.api.getSession({ @@ -17,8 +25,11 @@ const WorkspacePage = async () => { headers: await headers(), }); - if (org.length === 0) { - return ( + return ( +
+ {/* */} +

Your Workspace

+ {/* {session?.session?.activeOrganizationId} */}

Your Workspace

{ Create Workspace
- ); - } - - return ( -
- {/* */} -

Your Workspace

- {/* {session?.session?.activeOrganizationId} */} -
- {org.length > 0 && + {org.length > 0 ? ( org.map((org) => ( - // {

{org.logo}

- - + +

{org.slug}

+
- // - ))} + )) + ) : ( + + + No Workspaces Found + + + + + + You have not created any workspaces yet. Get started by creating a + new workspace. + + + )}
); diff --git a/src/app/(root)/(home)/_components/Navbar.tsx b/src/app/(root)/(home)/_components/Navbar.tsx index 443d274..217410a 100644 --- a/src/app/(root)/(home)/_components/Navbar.tsx +++ b/src/app/(root)/(home)/_components/Navbar.tsx @@ -2,7 +2,7 @@ import { ThemeToggle } from "@/components/navigation/theme-toggle"; import { homeTabs } from "@/constants"; -import Logo from "@/components/Logo"; +import Logo from "@/components/navigation/Logo"; import Link from "next/link"; import { Button } from "@/components/ui/button"; import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; diff --git a/src/app/(root)/auth/login/page.tsx b/src/app/(root)/auth/login/page.tsx index 7d4aa00..826093a 100644 --- a/src/app/(root)/auth/login/page.tsx +++ b/src/app/(root)/auth/login/page.tsx @@ -64,6 +64,12 @@ export default function LoginPage() { toast.success("Welcome back!", { description: "You have been signed in successfully.", }); + + if (result.data?.organizationExists === false) { + router.push("/organization/new"); + return; + } + router.push("/workspace"); } catch (error) { toast.error("An unexpected error occurred. Please try again."); diff --git a/src/app/not-found.tsx b/src/app/(root)/not-found.tsx similarity index 100% rename from src/app/not-found.tsx rename to src/app/(root)/not-found.tsx diff --git a/src/app/api/heatlh/route.ts b/src/app/api/heatlh/route.ts new file mode 100644 index 0000000..1629640 --- /dev/null +++ b/src/app/api/heatlh/route.ts @@ -0,0 +1,16 @@ +import { APIResponse } from "@/types"; + +type HealthStatus = "OK" | "UNHEALTHY"; + +export const GET = async (): Promise> => { + try { + const status: HealthStatus = "OK"; + return { success: true, data: status }; + } catch (error: unknown) { + return { + success: false, + error: + error instanceof Error ? error.message : "An unknown error occurred", + }; + } +}; diff --git a/src/app/api/meeting/new/route.ts b/src/app/api/meeting/new/route.ts index 666ee50..0a6cbce 100644 --- a/src/app/api/meeting/new/route.ts +++ b/src/app/api/meeting/new/route.ts @@ -4,16 +4,17 @@ import { membersTable, usersTable, workspaceMeetingTable, + meetingParticipantsTable, } from "@/db/schema/schema"; import { headers } from "next/headers"; import { auth } from "@/lib/auth-config"; import { eq } from "drizzle-orm"; -import { NextResponse } from "next/server"; +import { NextResponse, type NextRequest } from "next/server"; import type { APIResponse } from "@/types"; type Response = Promise>>; -export async function POST(): Response { +export async function POST(request: NextRequest): Response { try { const session = await auth.api.getSession({ headers: await headers(), @@ -26,6 +27,17 @@ export async function POST(): Response { }); } + // Parse the request body to get the meetingId from Stream + const body = await request.json(); + const streamMeetingId = body?.data?.meetingId; + + if (!streamMeetingId) { + return NextResponse.json({ + success: false, + error: "Meeting ID is required", + }); + } + // Get user data from auth table const [dbUser] = await db .select() @@ -34,9 +46,19 @@ export async function POST(): Response { .execute(); if (!dbUser) { + console.error("User not found in database:", session.user.id); return NextResponse.json({ success: false, error: "User not found" }); } + // Ensure userName exists + if (!dbUser.userName) { + console.error("User has no userName:", session.user.id); + return NextResponse.json({ + success: false, + error: "User profile incomplete - missing username", + }); + } + // Get user's workspace membership const [membership] = await db .select() @@ -46,26 +68,79 @@ export async function POST(): Response { .execute(); if (!membership?.workspaceId) { + console.error("User not in any workspace:", session.user.id); return NextResponse.json({ success: false, error: "User not in a workspace", }); } - // Let the database generate the meetingId automatically + console.log("Creating meeting:", { + meetingId: streamMeetingId, + workspaceId: membership.workspaceId, + hostedBy: dbUser.userName, + }); + + // Use the Stream call ID as the meetingId in the database + // Use onConflictDoNothing to handle duplicate meeting IDs gracefully const [meeting] = await db .insert(workspaceMeetingTable) .values({ + meetingId: streamMeetingId, workspaceId: membership.workspaceId, - hostedBy: dbUser.userName || session.user.name, + hostedBy: dbUser.userName, }) + .onConflictDoNothing() .returning(); - return NextResponse.json({ success: true, user: dbUser, meeting }); + // If meeting wasn't created (already exists), try to fetch it + if (!meeting) { + console.log("Meeting already exists, fetching..."); + const [existingMeeting] = await db + .select() + .from(workspaceMeetingTable) + .where(eq(workspaceMeetingTable.meetingId, streamMeetingId)) + .execute(); + + if (existingMeeting) { + return NextResponse.json({ + success: true, + user: dbUser, + data: existingMeeting, + }); + } + + return NextResponse.json({ + success: false, + error: "Failed to create or find meeting", + }); + } + + console.log("Meeting created:", meeting); + + // Add the meeting host as a participant + await db + .insert(meetingParticipantsTable) + .values({ + meetingId: meeting.meetingId, + memberId: membership.id, + }) + .onConflictDoNothing(); // Ignore if already a participant + + return NextResponse.json({ success: true, user: dbUser, data: meeting }); } catch (error: unknown) { + console.error("Error creating meeting:", error); + + // Log more specific error info + if (error instanceof Error) { + console.error("Error name:", error.name); + console.error("Error message:", error.message); + console.error("Error stack:", error.stack); + } + return NextResponse.json( { success: false, error: (error as Error).message }, - { status: 500 }, + { status: 500 } ); } } diff --git a/src/app/api/meeting/route.ts b/src/app/api/meeting/route.ts new file mode 100644 index 0000000..1b3c730 --- /dev/null +++ b/src/app/api/meeting/route.ts @@ -0,0 +1,70 @@ +import { db } from "@/db/client"; +import { + meetingParticipantsTable, + workspaceMeetingTable, + workspacesTable, +} from "@/db/schema/schema"; +import { APIResponse, Call } from "@/types"; +import { and, eq } from "drizzle-orm"; +import { NextRequest } from "next/server"; + +export async function GET( + request: NextRequest, + { params }: { params: Promise<{ slug: string }> } +) { + try { + const { slug } = await params; + const searchParams = request.nextUrl.searchParams; + const page = searchParams.get("page"); + + if (!page) { + return new Response( + JSON.stringify({ success: false, error: "page is required" }), + { status: 400 } + ); + } + + if (!slug) { + return new Response( + JSON.stringify({ success: false, error: "slug is required" }), + { status: 400 } + ); + } + + const workspace = await db.query.workspacesTable.findFirst({ + where: (ws) => eq(ws.slug, slug), + }); + + if (!workspace) { + return new Response( + JSON.stringify({ + success: false, + error: `Workspace with slug ${slug} not found`, + }), + { status: 404 } + ); + } + + const meeting = await db + .select() + .from(workspaceMeetingTable) + .where(eq(workspaceMeetingTable.workspaceId, workspace.id)) + .innerJoin( + meetingParticipantsTable, + eq(workspaceMeetingTable.meetingId, meetingParticipantsTable.meetingId) + ) + .execute(); + + return new Response( + JSON.stringify({ + success: true, + data: meeting, + }) + ); + } catch (error: unknown) { + return new Response( + JSON.stringify({ success: false, error: String(error) }), + { status: 500 } + ); + } +} diff --git a/src/app/api/workspace/[slug]/members/route.ts b/src/app/api/workspace/[slug]/members/route.ts new file mode 100644 index 0000000..dc44b4e --- /dev/null +++ b/src/app/api/workspace/[slug]/members/route.ts @@ -0,0 +1,65 @@ +import { NextResponse } from "next/server"; +import { db } from "@/db/client"; +import { membersTable, usersTable } from "@/db/schema/schema"; +import { eq } from "drizzle-orm"; +import { auth } from "@/lib/auth-config"; +import { headers } from "next/headers"; +import { TWorkspaceMembersTableRow } from "@/types"; + +export const GET = async ({ + params, +}: { + params: Promise<{ slug: string }>; +}) => { + try { + const { slug } = await params; + + if (!slug) { + return NextResponse.json( + { success: false, error: "Missing slug" }, + { status: 400 } + ); + } + + const workspace = await auth.api.getFullOrganization({ + query: { + organizationSlug: slug, + }, + headers: await headers(), + }); + + if (!workspace) { + return NextResponse.json( + { success: false, error: "Workspace not found" }, + { status: 404 } + ); + } + + // Fetch members from DB with user details, as well as their accounts and sessions + const rows = await db + .select({ member: membersTable, user: usersTable }) + .from(membersTable) + .leftJoin(usersTable, eq(membersTable.userId, usersTable.id)) + .where(eq(membersTable.workspaceId, workspace.id)); + + const members: TWorkspaceMembersTableRow = rows.map(({ member, user }) => ({ + id: member.id, + userId: member.userId, + workspaceId: member.workspaceId, + role: member.role, + createdAt: member.createdAt, + updatedAt: member.updatedAt, + name: user?.name || "", + email: user?.email || "", + emailVerified: user?.emailVerified || false, + userName: user?.userName || "", + })); + + return NextResponse.json({ success: true, data: members }); + } catch (error) { + return NextResponse.json( + { success: false, error: "Server error" }, + { status: 500 } + ); + } +}; diff --git a/src/app/api/workspace/[workspaceId]/route.ts b/src/app/api/workspace/[workspaceId]/route.ts deleted file mode 100644 index 31f4f8b..0000000 --- a/src/app/api/workspace/[workspaceId]/route.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { NextResponse } from "next/server"; -import { getWorkspaceById } from "@/action/workspace.action"; - -export async function GET( - _: Request, - { params }: { params: Promise<{ workspaceId: string }> }, -) { - const { workspaceId } = await params; - const res = await getWorkspaceById(workspaceId); - if (res?.data) return NextResponse.json(res.data); - return NextResponse.json({ error: "Workspace not found" }, { status: 404 }); -} diff --git a/src/components/CalendarExport.tsx b/src/components/CalendarExport.tsx index 204a6ce..4881263 100644 --- a/src/components/CalendarExport.tsx +++ b/src/components/CalendarExport.tsx @@ -73,7 +73,7 @@ export function CalendarExport({ - +
); }; diff --git a/src/components/FeatureCard.tsx b/src/components/FeatureCard.tsx deleted file mode 100644 index a267d7e..0000000 --- a/src/components/FeatureCard.tsx +++ /dev/null @@ -1,28 +0,0 @@ -import { Card, CardContent } from "@/components/ui/card"; -import type React from "react"; - -interface FeatureCardProps { - icon: React.ReactNode; - title: string; - description: string; -} - -export default function FeatureCard({ - icon, - title, - description, -}: FeatureCardProps) { - return ( -
- - -
{icon}
-

{title}

-

{description}

-
-
-
- ); -} diff --git a/src/components/Logo.tsx b/src/components/Logo.tsx deleted file mode 100644 index 1986982..0000000 --- a/src/components/Logo.tsx +++ /dev/null @@ -1,21 +0,0 @@ -import React from "react"; -import Image from "next/image"; - -const Logo = () => { - return ( -
- {/* Logo for dark theme */} - collaro logo - -

Collaro

-
- ); -}; - -export default Logo; diff --git a/src/components/MeetingSetup.tsx b/src/components/MeetingSetup.tsx deleted file mode 100644 index 13d9b3b..0000000 --- a/src/components/MeetingSetup.tsx +++ /dev/null @@ -1,91 +0,0 @@ -"use client"; -import { useEffect, useState } from "react"; -import { - DeviceSettings, - VideoPreview, - useCall, - useCallStateHooks, -} from "@stream-io/video-react-sdk"; - -import Alert from "./Alert"; -import { Button } from "@/components/ui/button"; - -const MeetingSetup = ({ - setIsSetupComplete, -}: { - setIsSetupComplete: (value: boolean) => void; -}) => { - // https://getstream.io/video/docs/react/guides/call-and-participant-state/#call-state - const { useCallEndedAt, useCallStartsAt } = useCallStateHooks(); - const callStartsAt = useCallStartsAt(); - const callEndedAt = useCallEndedAt(); - const callTimeNotArrived = - callStartsAt && new Date(callStartsAt) > new Date(); - const callHasEnded = !!callEndedAt; - - const call = useCall(); - - if (!call) { - throw new Error( - "useStreamCall must be used within a StreamCall component.", - ); - } - - // https://getstream.io/video/docs/react/ui-cookbook/replacing-call-controls/ - const [isMicCamToggled, setIsMicCamToggled] = useState(false); - - useEffect(() => { - if (isMicCamToggled) { - call.camera.disable(); - call.microphone.disable(); - } else { - call.camera.enable(); - call.microphone.enable(); - } - }, [isMicCamToggled, call.camera, call.microphone]); - - if (callTimeNotArrived) - return ( - - ); - - if (callHasEnded) - return ( - - ); - - return ( -
-

Setup

- -
- - -
- -
- ); -}; - -export default MeetingSetup; diff --git a/src/components/MemberList.tsx b/src/components/MemberList.tsx deleted file mode 100644 index 4dc5639..0000000 --- a/src/components/MemberList.tsx +++ /dev/null @@ -1,74 +0,0 @@ -"use client"; - -import { useState, useEffect } from "react"; -import { useWorkspaceStore } from "@/store/workspace"; -import DirectCallButton from "./DirectCallButton"; -import { Avatar, AvatarFallback } from "./ui/avatar"; -import { Card, CardContent } from "./ui/card"; -import Loader from "./Loader"; - -interface Member { - id: string; - name: string; - imageUrl?: string; -} - -const MemberList = () => { - const [memberDetails, setMemberDetails] = useState([]); - const [isLoading, setIsLoading] = useState(true); - const { members, workspaceId } = useWorkspaceStore(); - - useEffect(() => { - const fetchMemberDetails = async () => { - if (!members || members.length === 0 || !workspaceId) { - setIsLoading(false); - return; - } - - try { - // Fetch detailed member information from your API - const response = await fetch(`/api/workspace/${workspaceId}/members`); - if (!response.ok) throw new Error("Failed to fetch member details"); - - const data = await response.json(); - console.log(`Response \n`, data); - setMemberDetails(data.members); - } catch (error) { - console.error("Error fetching member details:", error); - } finally { - setIsLoading(false); - } - }; - - fetchMemberDetails(); - }, [members, workspaceId]); - - if (isLoading) return ; - - return ( -
- {memberDetails.map((member) => ( - - -
-
- - - {member.name.substring(0, 2).toUpperCase()} - - -
-

{member.name}

-
-
- - -
-
-
- ))} -
- ); -}; - -export default MemberList; diff --git a/src/components/SignOutButton.tsx b/src/components/SignOutButton.tsx deleted file mode 100644 index 765ddcd..0000000 --- a/src/components/SignOutButton.tsx +++ /dev/null @@ -1,20 +0,0 @@ -"use client"; - -import { signOut } from "@/lib/auth-client"; -import { Button } from "@/components/ui/button"; -import { useRouter } from "next/navigation"; - -export const SignOutButton = () => { - const router = useRouter(); - - const handleSignOut = async () => { - // Sign out using better-auth - await signOut(); - - // Redirect to home page - router.push("/auth/sign-in"); - router.refresh(); - }; - - return ; -}; diff --git a/src/components/TeamCall.tsx b/src/components/TeamCall.tsx deleted file mode 100644 index 49e087c..0000000 --- a/src/components/TeamCall.tsx +++ /dev/null @@ -1,927 +0,0 @@ -"use client"; -import { useGetCallsByTeam } from "@/hooks/useGetCallsbyTeam"; -import { useWorkspaceStore } from "@/store/workspace"; -import { useMemo } from "react"; -import { - Card, - CardContent, - CardDescription, - CardFooter, - CardHeader, - CardTitle, -} from "@/components/ui/card"; -import { Badge } from "@/components/ui/badge"; -import { Skeleton } from "@/components/ui/skeleton"; -import { - CalendarIcon, - Users, - Clock, - User, - LucidePhoneOff, - LucidePhoneCall, - Video, - Calendar, - Clock3, - AlarmClock, - CheckCircle2, - BarChart3, - ClipboardList, - Hourglass, -} from "lucide-react"; -import { ScrollArea } from "@/components/ui/scroll-area"; -import { Button } from "@/components/ui/button"; -import Link from "next/link"; -import { useSession } from "@/lib/auth-client"; -import { - Tooltip, - TooltipContent, - TooltipProvider, - TooltipTrigger, -} from "./ui/tooltip"; -import { - BarChart, - Bar, - Cell, - XAxis, - YAxis, - CartesianGrid, - ResponsiveContainer, - PieChart as RPieChart, - Pie, - Legend, - Tooltip as RechartTooltip, - // AreaChart, Area -} from "recharts"; -import { Tabs, TabsContent, TabsList, TabsTrigger } from "./ui/tabs"; - -const TeamCall = () => { - const { data: session } = useSession(); - const { workspaceName } = useWorkspaceStore(); - const { calls, isCallsLoading } = useGetCallsByTeam(workspaceName as string); - - // Function to calculate and format call duration - const formatDuration = (startTime: string, endTime: string | null) => { - if (!endTime) return "In progress"; - - const start = new Date(startTime).getTime(); - const end = new Date(endTime).getTime(); - const durationMs = end - start; - - const hours = Math.floor(durationMs / (1000 * 60 * 60)); - const minutes = Math.floor((durationMs % (1000 * 60 * 60)) / (1000 * 60)); - const seconds = Math.floor((durationMs % (1000 * 60)) / 1000); - - return `${hours > 0 ? `${hours}h ` : ""}${minutes}m ${seconds}s`; - }; - - // Function to determine call status - const getCallStatus = (call: any) => { - if (call.state.endedAt) { - return { - label: "Ended", - color: "destructive", - icon: , - }; - } else if ( - call.state.startedAt && - new Date(call.state.startedAt) < new Date() - ) { - return { - label: "Active", - color: "success", - icon: , - }; - } else if (call.state.custom?.scheduled) { - return { - label: "Scheduled", - color: "warning", - icon: , - }; - } else { - return { - label: "Created", - color: "secondary", - icon: , - }; - } - }; - - // Process data for visualizations - const visualizationData = useMemo(() => { - if (!calls || calls.length === 0) return null; - - // Call status distribution - const statusCounts = { - Active: 0, - Ended: 0, - Scheduled: 0, - Created: 0, - }; - - // Call duration data - const durationData = []; - - // Member participation - const memberParticipation = new Map(); - - // Date distribution - const dateDistribution = new Map(); - - // Weekly distribution - const weeklyDistribution = new Map(); - - // Monthly distribution - const monthlyDistribution = new Map(); - - // Meeting type distribution - const meetingTypeCount = { - Scheduled: 0, - Instant: 0, - }; - - // Duration categories - const durationCategories = { - "< 15 mins": 0, - "15-30 mins": 0, - "30-60 mins": 0, - "> 60 mins": 0, - "In Progress": 0, - }; - - // Time of day distribution - const timeOfDayCount = { - "Morning (6-12)": 0, - "Afternoon (12-17)": 0, - "Evening (17-22)": 0, - "Night (22-6)": 0, - }; - - // Get current date for calculations - const currentDate = new Date(); - const currentDay = currentDate.getDate(); - console.log("Current Day:", currentDay); - // const currentMonth = currentDate.getMonth() + 1; // Months are 0-indexed - // const currentYear = currentDate.getFullYear(); - - // Month names - const monthNames = [ - "January", - "February", - "March", - "April", - "May", - "June", - "July", - "August", - "September", - "October", - "November", - "December", - ]; - - // Day names - const dayNames = [ - "Sunday", - "Monday", - "Tuesday", - "Wednesday", - "Thursday", - "Friday", - "Saturday", - ]; - - for (const call of calls) { - // Count statuses - const status = getCallStatus(call); - statusCounts[status.label as keyof typeof statusCounts]++; - - // Meeting type categorization - if (call.state.custom?.scheduled) { - meetingTypeCount.Scheduled++; - } else { - meetingTypeCount.Instant++; - } - - // Calculate duration for ended calls and categorize - if (call.state.startedAt) { - const startDate = new Date(call.state.startedAt); - - // Time of day categorization - - const hour = startDate.getHours(); - if (hour >= 6 && hour < 12) { - timeOfDayCount["Morning (6-12)"]++; - } else if (hour >= 12 && hour < 17) { - timeOfDayCount["Afternoon (12-17)"]++; - } else if (hour >= 17 && hour < 22) { - timeOfDayCount["Evening (17-22)"]++; - } else { - timeOfDayCount["Night (22-6)"]++; - } - - if (call.state.endedAt) { - const start = startDate.getTime(); - const end = new Date(call.state.endedAt).getTime(); - const durationMinutes = Math.round((end - start) / (1000 * 60)); - - // Duration categorization - if (durationMinutes < 15) { - durationCategories["< 15 mins"]++; - } else if (durationMinutes >= 15 && durationMinutes < 30) { - durationCategories["15-30 mins"]++; - } else if (durationMinutes >= 30 && durationMinutes < 60) { - durationCategories["30-60 mins"]++; - } else { - durationCategories["> 60 mins"]++; - } - - durationData.push({ - id: call.id.slice(0, 8), - duration: durationMinutes, - title: call.state?.custom?.description || "Untitled Call", - }); - } else { - // Call is in progress - durationCategories["In Progress"]++; - } - } - - // Count member participation - call.state.members.forEach((member) => { - const memberName = member.user.name || member.user.id.slice(0, 8); - memberParticipation.set( - memberName, - (memberParticipation.get(memberName) || 0) + 1, - ); - }); - - // Time-based aggregations - if (call.state.startedAt) { - const callDate = new Date(call.state.startedAt); - - // Daily distribution - const date = callDate.toLocaleDateString(); - dateDistribution.set(date, (dateDistribution.get(date) || 0) + 1); - - // Weekly distribution - group by day of week - const dayOfWeek = dayNames[callDate.getDay()]; - weeklyDistribution.set( - dayOfWeek, - (weeklyDistribution.get(dayOfWeek) || 0) + 1, - ); - - // Monthly distribution - group by month - const monthYear = `${monthNames[callDate.getMonth()]} ${callDate.getFullYear()}`; - monthlyDistribution.set( - monthYear, - (monthlyDistribution.get(monthYear) || 0) + 1, - ); - } - } - - // Format for charts - const statusData = Object.entries(statusCounts).map(([name, value]) => ({ - name, - value, - color: - name === "Active" - ? "#10b981" - : name === "Ended" - ? "#ef4444" - : name === "Scheduled" - ? "#f59e0b" - : "#9ca3af", - })); - - // Meeting type pie chart data - const meetingTypeData = Object.entries(meetingTypeCount).map( - ([name, value]) => ({ - name, - value, - color: name === "Scheduled" ? "#3b82f6" : "#8b5cf6", - }), - ); - - // Duration categories pie chart data - const durationCategoryData = Object.entries(durationCategories).map( - ([name, value]) => ({ - name, - value, - color: - name === "< 15 mins" - ? "#10b981" - : name === "15-30 mins" - ? "#3b82f6" - : name === "30-60 mins" - ? "#8b5cf6" - : name === "> 60 mins" - ? "#f59e0b" - : "#9ca3af", // In Progress - }), - ); - - // Time of day pie chart data - const timeOfDayData = Object.entries(timeOfDayCount).map( - ([name, value]) => ({ - name, - value, - color: - name === "Morning (6-12)" - ? "#f59e0b" // Orange for morning - : name === "Afternoon (12-17)" - ? "#3b82f6" // Blue for afternoon - : name === "Evening (17-22)" - ? "#8b5cf6" // Purple for evening - : "#1e293b", // Dark blue for night - }), - ); - - const memberData = Array.from(memberParticipation.entries()) - .map(([name, count]) => ({ name, calls: count })) - .sort((a, b) => b.calls - a.calls) - .slice(0, 10); - - const dateData = Array.from(dateDistribution.entries()) - .map(([date, count]) => ({ date, calls: count })) - .sort((a, b) => new Date(a.date).getTime() - new Date(b.date).getTime()) - .slice(-14); // Last 14 days - - // Process weekly data - ensure all days are represented and in correct order - const orderedDays = [ - "Monday", - "Tuesday", - "Wednesday", - "Thursday", - "Friday", - "Saturday", - "Sunday", - ]; - const weeklyData = orderedDays.map((day) => ({ - day, - calls: weeklyDistribution.get(day) || 0, - })); - - // Process monthly data - sort chronologically - const monthlyData = Array.from(monthlyDistribution.entries()) - .map(([month, count]) => ({ month, calls: count })) - .sort((a, b) => { - const [aMonth, aYear] = a.month.split(" "); - const [bMonth, bYear] = b.month.split(" "); - - if (aYear !== bYear) return parseInt(aYear) - parseInt(bYear); - return monthNames.indexOf(aMonth) - monthNames.indexOf(bMonth); - }) - .slice(-6); // Last 6 months - - return { - statusData, - durationData, - memberData, - dateData, - weeklyData, - monthlyData, - meetingTypeData, - durationCategoryData, - timeOfDayData, - }; - }, [calls]); - - // Custom colors for charts - const COLORS = [ - "#10b981", - "#ef4444", - "#f59e0b", - "#9ca3af", - "#3b82f6", - "#8b5cf6", - ]; - - if (isCallsLoading) { - return ( -
-

- Workspace Calls -

-
- {[...Array(3)].map((_, i) => ( - - - - - - -
- -
- - -
-
-
-
- ))} -
-
- ); - } - - return ( -
-

- Workspace Calls -

- - {calls.length === 0 ? ( - - -

- No calls available -

-
-
- ) : ( - - {/* Analytics Dashboard Section */} -
- - - - - Call Analytics Dashboard - - - Visual insights for your workspace calls - - - - - - Overview - - More Insights - - Call Duration - - Member Participation - - Call Trends - - Time Analysis - - - - -

- Call Status Distribution -

-
- {visualizationData && ( - - - - `${name}: ${(percent * 100).toFixed(0)}%` - } - innerRadius={60} - outerRadius={120} - fill="#8884d8" - dataKey="value" - > - {visualizationData.statusData.map( - (entry, index) => ( - - ), - )} - - - [ - `${value} calls`, - name, - ]} - /> - - - )} -
-
- Total Calls: {calls.length} -
-
- - - {/* Meeting Type Distribution */} -
-
-

- - Meeting Type Distribution -

-
- {visualizationData && - visualizationData.meetingTypeData && - visualizationData.meetingTypeData.some( - (d) => d.value > 0, - ) ? ( - - - - `${name}: ${(percent * 100).toFixed(0)}%` - } - outerRadius={80} - fill="#8884d8" - dataKey="value" - > - {visualizationData.meetingTypeData.map( - (entry, index) => ( - - ), - )} - - - [ - `${value} calls`, - name, - ]} - /> - - - ) : ( -
-

- No meeting type data available -

-
- )} -
-
- Distribution between scheduled and instant meetings -
-
-
-
- - - {/* Call Duration Categories */} -
-

- - Call Duration Categories -

-
- {visualizationData && - visualizationData.durationCategoryData && - visualizationData.durationCategoryData.some( - (d) => d.value > 0, - ) ? ( - <> - - - - `${name}: ${(percent * 100).toFixed(0)}%` - } - outerRadius={80} - fill="#8884d8" - dataKey="value" - > - {visualizationData.durationCategoryData.map( - (entry, index) => ( - - ), - )} - - - [ - `${value} calls`, - name, - ]} - /> - - - - ) : ( -
-

- No duration data available -

-

- No duration data available -

-
- )} -
-
- Breakdown of calls by duration categories -
-
- - {/* Monthly Distribution */} -
-

- Monthly Call Distribution -

-
- {visualizationData && - visualizationData.monthlyData.length > 0 ? ( - - - - - - [ - `${value} calls`, - "Call Count", - ]} - /> - - - - - - - - - - ) : ( -
-

- No monthly data available -

-
- )} -
-
- Call volume by month for the last 6 months -
-
-
-
-
-
-
- - {/* Call Cards Section */} -
- {calls.map((call) => { - const status = getCallStatus(call); - const isMember = call.state.members.some( - (member) => member.user.id === (session?.user?.id || ""), - ); - const hasRecording = call.state.custom?.hasRecording || false; - const meetingType = call.state.custom?.scheduled - ? "Scheduled Meeting" - : "Instant Meeting"; - - return ( - - -
- {call.state?.custom?.description ? ( - - {call.state.custom.description} - - ) : ( - - Untitled Call - - )} - - - {status.icon} {status.label} - -
- -
- - {call.state.startedAt && ( -
- - {new Date(call.state.startedAt).toLocaleString()} -
- )} -
- - - {call.state.custom?.scheduled ? ( -
- {meetingType} -
- ) : ( -
-
- )} -
-
-
- - - {/* Duration Info */} - {call.state.startedAt && ( -
- - - Duration:{" "} - {formatDuration( - call.state.startedAt.toLocaleDateString(), - call.state.endedAt?.toLocaleDateString() || "", - )} - -
- )} - - {/* Members Section */} - {call.state.members && ( -
-
- - - {call.state.members.length} members - -
- -
- - {call.state.members.slice(0, 3).map((member) => ( - - - - {member.user.name || member.user.id} - - - -

Role: {member.role}

-
-
- ))} - - {call.state.members.length > 3 && ( - - +{call.state.members.length - 3} more - - )} -
-
-
- )} - - {/* Creator Info */} - {call.state.createdBy && ( -
- - - Created by: {call.state.createdBy.name} - {call.isCreatedByMe && ( - - You - - )} - -
- )} - - {/* Created Time */} - {call.state.createdAt && ( -
- - - Created:{" "} - {new Date(call.state.createdAt).toLocaleString()} - -
- )} - - {/* Recording Badge */} - {hasRecording && ( -
- - -
- )} -
- - - {!call.state.endedAt || isMember ? ( - - - - ) : ( -
- - - Call Ended - -
- )} -
-
- ); - })} -
-
- )} -
- ); -}; - -export default TeamCall; diff --git a/src/components/WorkspaceInitializer.tsx b/src/components/WorkspaceInitializer.tsx deleted file mode 100644 index 56d9c2c..0000000 --- a/src/components/WorkspaceInitializer.tsx +++ /dev/null @@ -1,39 +0,0 @@ -"use client"; - -import { useEffect, useCallback } from "react"; -import { useWorkspaceStore } from "@/store/workspace"; -import type { WorkspaceInitializerProps } from "@/types"; - -export const WorkspaceInitializer = ({ - workspaceId, - workspaceName, - members, -}: WorkspaceInitializerProps) => { - const { setWorkspace, isInitialized, setInitialized } = useWorkspaceStore(); - - const initializeWorkspace = useCallback(() => { - if (!isInitialized && workspaceId && workspaceName) { - console.log( - "Initializing workspace in store:", - workspaceId, - workspaceName, - ); - - setWorkspace(workspaceId, workspaceName, members); - setInitialized(true); - } - }, [ - workspaceId, - workspaceName, - members, - setWorkspace, - isInitialized, - setInitialized, - ]); - - useEffect(() => { - initializeWorkspace(); - }, [initializeWorkspace]); - - return null; -}; diff --git a/src/components/app-sidebar.tsx b/src/components/app-sidebar.tsx index 41f008e..ee98820 100644 --- a/src/components/app-sidebar.tsx +++ b/src/components/app-sidebar.tsx @@ -14,7 +14,7 @@ import { } from "@/components/ui/sidebar"; import { useSession } from "@/lib/auth-client"; import Loader from "./Loader"; -import Logo from "./Logo"; +import Logo from "./navigation/Logo"; import Link from "next/link"; import { useEffect, useState } from "react"; import { getMember } from "@/action/member.action"; @@ -31,7 +31,7 @@ export function AppSidebar({ const fetchMemberRoel = async () => { const { data } = await getMember( pathname.split("/")[2], - session?.user?.id || "", + session?.user?.id || "" ); if (!data) return; setRole(data.role); diff --git a/src/components/AcceptInviteForm.tsx b/src/components/auth/AcceptInviteForm.tsx similarity index 99% rename from src/components/AcceptInviteForm.tsx rename to src/components/auth/AcceptInviteForm.tsx index a083926..a3c0d8b 100644 --- a/src/components/AcceptInviteForm.tsx +++ b/src/components/auth/AcceptInviteForm.tsx @@ -155,3 +155,5 @@ export function AcceptInviteForm({ invitationId }: AcceptInviteFormProps) { ); } + +export default AcceptInviteForm; diff --git a/src/components/auth/AuthStateManager.tsx b/src/components/auth/AuthStateManager.tsx deleted file mode 100644 index 831e4ec..0000000 --- a/src/components/auth/AuthStateManager.tsx +++ /dev/null @@ -1,112 +0,0 @@ -"use client"; - -import { useSession } from "@/lib/auth-client"; -import { useUserStore } from "@/store/user"; -import { useWorkspaceStore } from "@/store/workspace"; -import { useEffect, useCallback } from "react"; - -/** - * AuthStateManager - Handles post-signin data fetchings - * - * WHY: This component runs immediately after better-auth authentication - * WHEN: Triggered when session becomes available - * HOW: Fetches user data from database and updates stores - */ -export const AuthStateManager = () => { - const { data: session, isPending } = useSession(); - const { - setUserData, - clearUserData, - setDataLoaded, - isAuthenticated, - isDataLoaded, - } = useUserStore(); - const { setWorkspace, clearWorkspace } = useWorkspaceStore(); - - const fetchUserData = useCallback(async () => { - if (!session?.user?.id) return; - - try { - const response = await fetch("/api/user/me"); - const result = await response.json(); - - if (result.success && result.data) { - const userData = result.data; - - // Update user store with all data including workspaceName - setUserData({ - email: userData.email || session.user.email || "", - name: userData.name || session.user.name || "", - userId: userData.userId || session.user.id, - userName: userData.userName || session.user.userName, - currentWorkspaceId: userData.currentWorkspaceId, - currentWorkspaceName: userData.currentWorkspaceName, - role: userData.role, - }); - - // Also update workspace store if user has a workspace - if (userData.currentWorkspaceId && userData.currentWorkspaceName) { - setWorkspace( - userData.currentWorkspaceId, - userData.currentWorkspaceName, - ); - } - - setDataLoaded(true); - console.log("✅ User data loaded successfully:", { - userName: userData.userName, - workspaceName: userData.currentWorkspaceName, - }); - } else { - // Use session data directly if API failed - setUserData({ - email: session.user.email || "", - name: session.user.name || "", - userId: session.user.id, - userName: session.user.userName || "", - currentWorkspaceId: null, - currentWorkspaceName: null, - role: "member", - }); - setDataLoaded(true); - console.warn("⚠️ Using session data, API fetch failed:", result.error); - } - } catch (error) { - console.error("❌ Error fetching user data:", error); - } - }, [session?.user, setUserData, setWorkspace, setDataLoaded]); - - const handleSignOut = useCallback(() => { - clearUserData(); - clearWorkspace(); - console.log("🔄 User data cleared on sign out"); - }, [clearUserData, clearWorkspace]); - - // Effect: Handle authentication state changes - useEffect(() => { - if (isPending) return; - - if (session?.user) { - // User signed in - check if we need to fetch data - if (!isAuthenticated || !isDataLoaded) { - console.log("🔄 Fetching user data after sign in..."); - fetchUserData(); - } - } else { - // User signed out - if (isAuthenticated) { - handleSignOut(); - } - } - }, [ - isPending, - session, - isAuthenticated, - isDataLoaded, - fetchUserData, - handleSignOut, - ]); - - // This component doesn't render anything - return null; -}; diff --git a/src/components/auth/index.ts b/src/components/auth/index.ts new file mode 100644 index 0000000..632b44a --- /dev/null +++ b/src/components/auth/index.ts @@ -0,0 +1 @@ +export * from "./AcceptInviteForm"; diff --git a/src/components/charts/DailyMeetingsChart.tsx b/src/components/charts/DailyMeetingsChart.tsx deleted file mode 100644 index bd97923..0000000 --- a/src/components/charts/DailyMeetingsChart.tsx +++ /dev/null @@ -1,153 +0,0 @@ -"use client"; - -import { - BarChart, - Bar, - XAxis, - YAxis, - CartesianGrid, - Tooltip, - ResponsiveContainer, -} from "recharts"; -import { useCallback, useMemo, Suspense } from "react"; -import { useGetCallByTeamandId } from "@/hooks/useGetCallByTeamandId"; - -const ChartContent = () => { - const { calls: TeamCall, isCallsLoading } = useGetCallByTeamandId(); - - // Memoize date calculations - const getDaysArray = useCallback(() => { - const days = [...Array(7)] - .map((_, i) => { - const d = new Date(); - d.setHours(0, 0, 0, 0); - d.setDate(d.getDate() - i); - return d; - }) - .reverse(); - return days; - }, []); - - // Memoize chart data processing - const { dailyData, maxMeetings } = useMemo(() => { - const days = getDaysArray(); - const dateMap = new Map(); - - // Initialize map with dates - days.forEach((date) => { - dateMap.set(date.toISOString().split("T")[0], 0); - }); - - // Single pass count - TeamCall.forEach((call) => { - const callDate = new Date(call.state.createdAt || 0) - .toISOString() - .split("T")[0]; - if (dateMap.has(callDate)) { - dateMap.set(callDate, dateMap.get(callDate) + 1); - } - }); - - // Transform to chart data - const data = Array.from(dateMap).map(([date, count]) => ({ - date: new Date(date).toLocaleDateString("en-US", { weekday: "short" }), - meetings: count, - fullDate: date, // Keep full date for tooltip - })); - - return { - dailyData: data, - maxMeetings: Math.max(...data.map((d) => d.meetings)), - }; - }, [TeamCall, getDaysArray]); - - // Memoize chart config - const chartConfig = useMemo( - () => ({ - xAxis: { - tickFormatter: (value: string) => value, - style: { fontSize: "18px" }, - interval: 0, - dx: -10, - dy: 10, - }, - yAxis: { - domain: [0, maxMeetings + 1], - allowDecimals: false, - }, - }), - [maxMeetings], - ); - - if (isCallsLoading) { - return ( -
-
Loading meeting statistics...
-
- ); - } - - if (!TeamCall || TeamCall.length === 0) { - return ( -
-

No meeting data available

-
- ); - } - - return ( -
-

- Daily Meeting Count -

- - - - - - `Date: ${label}`} - formatter={(value) => [`${value} meetings`, "Count"]} - itemStyle={{ color: "var(--color-primary)" }} - contentStyle={{ backgroundColor: "var(--color-chart-1)" }} - wrapperStyle={{ borderRadius: "6px" }} - labelStyle={{ color: "var(--chart-1)" }} - /> - - - -
- ); -}; - -export const DailyMeetingsChart = () => { - return ( - -
Loading chart...
- - } - > - -
- ); -}; diff --git a/src/components/charts/WeeklyMeetingsChart.tsx b/src/components/charts/WeeklyMeetingsChart.tsx deleted file mode 100644 index 033bc3b..0000000 --- a/src/components/charts/WeeklyMeetingsChart.tsx +++ /dev/null @@ -1,99 +0,0 @@ -"use client"; - -import { - PieChart, - Pie, - Cell, - ResponsiveContainer, - Legend, - Tooltip, -} from "recharts"; -// import { useWorkspaceStore } from "@/store/workspace"; -// import { useGetCallsByTeam } from "@/hooks/useGetCallsbyTeam"; -import { useCallback, useMemo, useRef } from "react"; -import { useGetCallByTeamandId } from "@/hooks/useGetCallByTeamandId"; - -const COLORS = ["#0e0d85", "#00C49F", "#FFBB28", "#FF8042"]; - -export const WeeklyMeetingsChart = () => { - // const { workspaceName } = useWorkspaceStore();. - const { calls: TeamCall, isCallsLoading } = useGetCallByTeamandId(); - - // Keep stable reference to date objects - const dateRangeRef = useRef({ - startOfWeek: new Date(), - endOfWeek: new Date(), - }); - - // Memoize date range calculation - const getDateRange = useCallback(() => { - const now = new Date(); - const startOfWeek = new Date(now); - startOfWeek.setDate(now.getDate() - now.getDay()); - startOfWeek.setHours(0, 0, 0, 0); - - const endOfWeek = new Date(now); - endOfWeek.setDate(now.getDate() - now.getDay() + 6); - endOfWeek.setHours(23, 59, 59, 999); - - return { startOfWeek, endOfWeek }; - }, []); - - const { thisWeekCalls } = useMemo(() => { - const { startOfWeek, endOfWeek } = getDateRange(); - dateRangeRef.current = { startOfWeek, endOfWeek }; - - const filteredCalls = TeamCall.filter((call) => { - const callDate = new Date(call.state.createdAt || 0); - return callDate >= startOfWeek && callDate <= endOfWeek; - }); - - return { - thisWeekCalls: filteredCalls, - weekDates: { startOfWeek, endOfWeek }, - }; - }, [TeamCall, getDateRange]); - - // Stable data processing - const data = useMemo(() => { - const endedCalls = thisWeekCalls.filter((call) => call.state.endedAt); - const totalCalls = thisWeekCalls.length; - - return [ - { name: "Completed Calls", value: endedCalls.length }, - { name: "Active/Pending", value: totalCalls - endedCalls.length }, - ]; - }, [thisWeekCalls]); - - if (isCallsLoading) { - return
Loading...
; - } - - return ( -
- - - `${name}: ${value}`} - > - {data.map((_, index) => ( - - ))} - - - - - -
- ); -}; diff --git a/src/components/EmailTemplate.tsx b/src/components/email/EmailTemplate.tsx similarity index 100% rename from src/components/EmailTemplate.tsx rename to src/components/email/EmailTemplate.tsx diff --git a/src/components/email/index.ts b/src/components/email/index.ts new file mode 100644 index 0000000..ec69c5f --- /dev/null +++ b/src/components/email/index.ts @@ -0,0 +1 @@ +export * from "./EmailTemplate"; diff --git a/src/components/examples/UserDashboard.tsx b/src/components/examples/UserDashboard.tsx deleted file mode 100644 index 63e1bd5..0000000 --- a/src/components/examples/UserDashboard.tsx +++ /dev/null @@ -1,48 +0,0 @@ -"use client"; - -import { useAuthData } from "@/hooks/useAuthData"; -import { useEffect } from "react"; - -/** - * Example component showing how to use the auth data - */ -export const UserDashboard = () => { - const { userInfo, workspaceInfo, isReady } = useAuthData(); - - useEffect(() => { - if (isReady) { - console.log("🎉 User is ready with data:", { - userName: userInfo.userName, - workspaceName: workspaceInfo.currentWorkspaceName, - role: userInfo.role, - }); - } - }, [isReady, userInfo, workspaceInfo]); - - if (!isReady) { - return
Loading user data...
; - } - - return ( -
-

Welcome, {userInfo.name}!

-
-

- Username: {userInfo.userName} -

-

- Email: {userInfo.email} -

-

- Role: {userInfo.role} -

- {workspaceInfo.currentWorkspaceName && ( -

- Current Workspace:{" "} - {workspaceInfo.currentWorkspaceName} -

- )} -
-
- ); -}; diff --git a/src/components/home/index.ts b/src/components/home/index.ts new file mode 100644 index 0000000..b3a5fcf --- /dev/null +++ b/src/components/home/index.ts @@ -0,0 +1,3 @@ +export { default as FAQs } from "./FAQs"; +export * from "./Feature"; +export * from "./infinite-moving-cards"; diff --git a/src/components/icons/Home.tsx b/src/components/icons/Home.tsx deleted file mode 100644 index 6210041..0000000 --- a/src/components/icons/Home.tsx +++ /dev/null @@ -1,32 +0,0 @@ -import clsx from "clsx"; -import React from "react"; - -type Props = { - selected: boolean; -}; - -const Home = ({ selected }: Props) => { - return ( - - - - ); -}; - -export default Home; diff --git a/src/components/icons/Personal.tsx b/src/components/icons/Personal.tsx deleted file mode 100644 index 0a68be9..0000000 --- a/src/components/icons/Personal.tsx +++ /dev/null @@ -1,33 +0,0 @@ -import clsx from "clsx"; -import React from "react"; - -type Props = { - selected: boolean; -}; - -const Personal = ({ selected }: Props) => { - return ( -
- - - -
- ); -}; - -export default Personal; diff --git a/src/components/icons/Previous.tsx b/src/components/icons/Previous.tsx deleted file mode 100644 index 79a6ddb..0000000 --- a/src/components/icons/Previous.tsx +++ /dev/null @@ -1,39 +0,0 @@ -import clsx from "clsx"; -import React from "react"; - -type Props = { - selected: boolean; -}; - -const Previous = ({ selected }: Props) => { - return ( - - - - - - ); -}; - -export default Previous; diff --git a/src/components/icons/Reccording.tsx b/src/components/icons/Reccording.tsx deleted file mode 100644 index eb8c440..0000000 --- a/src/components/icons/Reccording.tsx +++ /dev/null @@ -1,45 +0,0 @@ -import clsx from "clsx"; -import React from "react"; - -type Props = { - selected: boolean; -}; - -const Reccording = ({ selected }: Props) => { - return ( - - - - - ); -}; - -export default Reccording; diff --git a/src/components/icons/Upcoming.tsx b/src/components/icons/Upcoming.tsx deleted file mode 100644 index fbbd30c..0000000 --- a/src/components/icons/Upcoming.tsx +++ /dev/null @@ -1,45 +0,0 @@ -import { clsx } from "clsx"; -import React from "react"; - -type Props = { - selected: boolean; -}; - -const Upcoming = ({ selected }: Props) => { - return ( -
- - - - - -
- ); -}; - -export default Upcoming; diff --git a/src/components/index.tsx b/src/components/index.tsx deleted file mode 100644 index 4ba57f2..0000000 --- a/src/components/index.tsx +++ /dev/null @@ -1,5 +0,0 @@ -export * from "./home/infinite-moving-cards"; - -export { default as FAQs } from "./home/FAQs"; - -export * from "./home/Feature"; diff --git a/src/components/nav-documents.tsx b/src/components/nav-documents.tsx deleted file mode 100644 index 0867383..0000000 --- a/src/components/nav-documents.tsx +++ /dev/null @@ -1,92 +0,0 @@ -"use client"; - -import { - IconDots, - IconFolder, - IconShare3, - IconTrash, - type Icon, -} from "@tabler/icons-react"; - -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuSeparator, - DropdownMenuTrigger, -} from "@/components/ui/dropdown-menu"; -import { - SidebarGroup, - SidebarGroupLabel, - SidebarMenu, - SidebarMenuAction, - SidebarMenuButton, - SidebarMenuItem, - useSidebar, -} from "@/components/ui/sidebar"; - -export function NavDocuments({ - items, -}: { - items: { - name: string; - url: string; - icon: Icon; - }[]; -}) { - const { isMobile } = useSidebar(); - - return ( - - Documents - - {items.map((item) => ( - - - - - {item.name} - - - - - - - More - - - - - - Open - - - - Share - - - - - Delete - - - - - ))} - - - - More - - - - - ); -} diff --git a/src/components/nav-main.tsx b/src/components/nav-main.tsx index 7994924..8d45171 100644 --- a/src/components/nav-main.tsx +++ b/src/components/nav-main.tsx @@ -68,49 +68,86 @@ export function NavMain({ ); } + + // Non-Admin Routes + // if (sidebarLinks.filter((items) => items.adminRoute == true).length === 0) { + // return ( + // + // + // + // {sidebarLinks + // .filter((item) => item.adminRoute !== true) + // .map((item) => { + // const route = `/workspace/${workspaceId}${item.route}`; + // const isActive = pathname === route; + // const isAdminRoute = item.adminRoute === true; + // const hasAdminAccess = role === "owner" || role === "admin"; + // const shouldRender = !isAdminRoute || hasAdminAccess; + + // if (!shouldRender) return null; + + // const Component = item.component; + + // return ( + // + // + // + // {Component && } + // {item.label} + // + // + // + // ); + // })} + // + // + // + // ); + // } + return ( {/* Non-Member Routes */} + {(role == "owner" || role == "admin") && ( +
+ Admin Routes + + + {sidebarLinks.filter((items) => items.adminRoute == true) && + sidebarLinks + .filter((item) => item.adminRoute === true) + .map((item) => { + const route = `/workspace/${workspaceId}${item.route}`; + const isActive = pathname === route; + const isAdminRoute = item.adminRoute === true; + const hasAdminAccess = role === "owner" || role === "admin"; + const shouldRender = !isAdminRoute || hasAdminAccess; - Admin Routes - - - {sidebarLinks - .filter((item) => item.adminRoute === true) - .map((item) => { - const route = `/workspace/${workspaceId}${item.route}`; - const isActive = pathname === route; - const isAdminRoute = item.adminRoute === true; - const hasAdminAccess = role === "owner" || role === "admin"; - const shouldRender = !isAdminRoute || hasAdminAccess; - - if (!shouldRender) return null; + if (!shouldRender) return null; - const Component = item.component; + const Component = item.component; - return ( - - - - {Component && ( - - )} - {item.label} - - - - ); - })} - - + return ( + + + + {Component && } + {item.label} + + + + ); + })} + + +
+ )} + {/* Member Routes */} Workspace @@ -135,12 +172,7 @@ export function NavMain({ isActive={isActive} > - {Component && ( - - )} + {Component && } {item.label} diff --git a/src/components/navigation/Logo.tsx b/src/components/navigation/Logo.tsx index 58fe859..1986982 100644 --- a/src/components/navigation/Logo.tsx +++ b/src/components/navigation/Logo.tsx @@ -3,28 +3,17 @@ import Image from "next/image"; const Logo = () => { return ( -
- {/* Logo for light theme */} - collaro logo - +
{/* Logo for dark theme */} collaro logo -

- Collaro -

+

Collaro

); }; diff --git a/src/components/signup-form.tsx b/src/components/signup-form.tsx deleted file mode 100644 index 5aba04d..0000000 --- a/src/components/signup-form.tsx +++ /dev/null @@ -1,70 +0,0 @@ -import { cn } from "@/lib/utils"; -import { Button } from "@/components/ui/button"; -import { - Field, - FieldDescription, - FieldGroup, - FieldLabel, - FieldSeparator, -} from "@/components/ui/field"; -import { Input } from "@/components/ui/input"; - -export function SignupForm({ - className, - ...props -}: React.ComponentProps<"form">) { - return ( -
- -
-

Create your account

-

- Fill in the form below to create your account -

-
- - Full Name - - - - Email - - - We'll use this to contact you. We will not share your email - with anyone else. - - - - Password - - - Must be at least 8 characters long. - - - - Confirm Password - - Please confirm your password. - - - - - Or continue with - - - - Already have an account? Sign in - - -
-
- ); -} diff --git a/src/components/site-header.tsx b/src/components/site-header.tsx deleted file mode 100644 index 823f2d5..0000000 --- a/src/components/site-header.tsx +++ /dev/null @@ -1,30 +0,0 @@ -import { Button } from "@/components/ui/button"; -import { Separator } from "@/components/ui/separator"; -import { SidebarTrigger } from "@/components/ui/sidebar"; - -export function SiteHeader() { - return ( -
-
- - -

Documents

-
- -
-
-
- ); -} diff --git a/src/components/RazorpayButton.tsx b/src/components/subscriptions/RazorpayButton.tsx similarity index 100% rename from src/components/RazorpayButton.tsx rename to src/components/subscriptions/RazorpayButton.tsx diff --git a/src/components/RazorpaySubscription.tsx b/src/components/subscriptions/RazorpaySubscription.tsx similarity index 100% rename from src/components/RazorpaySubscription.tsx rename to src/components/subscriptions/RazorpaySubscription.tsx diff --git a/src/components/SubscriptionCard.tsx b/src/components/subscriptions/SubscriptionCard.tsx similarity index 100% rename from src/components/SubscriptionCard.tsx rename to src/components/subscriptions/SubscriptionCard.tsx diff --git a/src/components/subscriptions/index.ts b/src/components/subscriptions/index.ts new file mode 100644 index 0000000..aa28252 --- /dev/null +++ b/src/components/subscriptions/index.ts @@ -0,0 +1,3 @@ +export * as RazorpayButton from "./RazorpayButton"; +export * as SubscriptionPlans from "./RazorpaySubscription"; +export * as SubscriptionCard from "./SubscriptionCard"; diff --git a/src/components/team-switcher.tsx b/src/components/team-switcher.tsx deleted file mode 100644 index b366d5f..0000000 --- a/src/components/team-switcher.tsx +++ /dev/null @@ -1,91 +0,0 @@ -"use client"; - -import * as React from "react"; -import { ChevronsUpDown, Plus } from "lucide-react"; - -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuLabel, - DropdownMenuSeparator, - DropdownMenuShortcut, - DropdownMenuTrigger, -} from "@/components/ui/dropdown-menu"; -import { - SidebarMenu, - SidebarMenuButton, - SidebarMenuItem, - useSidebar, -} from "@/components/ui/sidebar"; - -export function TeamSwitcher({ - teams, -}: { - teams: { - name: string; - logo: React.ElementType; - plan: string; - }[]; -}) { - const { isMobile } = useSidebar(); - const [activeTeam, setActiveTeam] = React.useState(teams[0]); - - if (!activeTeam) { - return null; - } - - return ( - - - - - -
- -
-
- {activeTeam.name} - {activeTeam.plan} -
- -
-
- - - Teams - - {teams.map((team, index) => ( - setActiveTeam(team)} - className="gap-2 p-2" - > -
- -
- {team.name} - ⌘{index + 1} -
- ))} - - -
- -
-
Add team
-
-
-
-
-
- ); -} diff --git a/src/components/workspace/admin/charts/month-meeting-chart.tsx b/src/components/workspace/admin/charts/month-meeting-chart.tsx new file mode 100644 index 0000000..bc8b407 --- /dev/null +++ b/src/components/workspace/admin/charts/month-meeting-chart.tsx @@ -0,0 +1,125 @@ +"use client"; + +import Loader from "@/components/Loader"; +import { useGetCallsBySlug } from "@/hooks/useGetCallsBySlug"; +import { useMemo } from "react"; +import { + BarChart, + Bar, + XAxis, + YAxis, + CartesianGrid, + Tooltip, + ResponsiveContainer, +} from "recharts"; + +type Props = { + slug: string; +}; + +const MonthlyMeetingChart = (props: Props) => { + const { data, isPending } = useGetCallsBySlug(props.slug); + + // Bucket meetings into weeks of the current month + const { chartData, totalMeetings, maxMeetings, monthLabel } = useMemo(() => { + const now = new Date(); + const month = now.getMonth(); + const year = now.getFullYear(); + + // 5 buckets: Week 1 (1-7), Week 2 (8-14), Week 3 (15-21), Week 4 (22-28), Week 5 (29-end) + const buckets = [0, 0, 0, 0, 0]; + + if (data && data.length > 0) { + data.forEach((meeting: any) => { + const date = new Date(meeting.createdAt || meeting.startTime); + if (date.getFullYear() === year && date.getMonth() === month) { + const weekIndex = Math.floor((date.getDate() - 1) / 7); // 0..4 + buckets[weekIndex] = (buckets[weekIndex] || 0) + 1; + } + }); + } + + const chartData = buckets.map((count, idx) => ({ + week: `Week ${idx + 1}`, + meetings: count, + })); + + const totalMeetings = buckets.reduce((s, v) => s + v, 0); + const maxMeetings = Math.max(...buckets, 1); + const monthLabel = now.toLocaleString("default", { + month: "long", + year: "numeric", + }); + + return { chartData, totalMeetings, maxMeetings, monthLabel }; + }, [data]); + + if (isPending) { + return ( +
+ +
+ ); + } + + if (!chartData || chartData.length === 0 || totalMeetings === 0) { + return ( +
+

No meetings this month

+

+ Meetings in {monthLabel} will appear once created +

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

Meetings — {monthLabel}

+

+ Total this month: {totalMeetings} meetings +

+
+ +
+ + + + + + [`${value} meetings`, "Count"]} + contentStyle={{ backgroundColor: "var(--color-chart-1)" }} + itemStyle={{ color: "var(--color-primary)" }} + wrapperStyle={{ borderRadius: 6 }} + /> + + + +
+
+ ); +}; + +export default MonthlyMeetingChart; diff --git a/src/components/workspace/admin/charts/weekly-meeting-chart.tsx b/src/components/workspace/admin/charts/weekly-meeting-chart.tsx new file mode 100644 index 0000000..49c9028 --- /dev/null +++ b/src/components/workspace/admin/charts/weekly-meeting-chart.tsx @@ -0,0 +1,129 @@ +"use client"; + +import { useGetCallsBySlug } from "@/hooks/useGetCallsBySlug"; +import { useMemo } from "react"; +import { + PieChart, + Pie, + Cell, + Legend, + Tooltip, + ResponsiveContainer, +} from "recharts"; +import Loader from "@/components/Loader"; +import { dayNames } from "@/constants"; + +const COLORS = [ + "#3b82f6", + "#10b981", + "#f59e0b", + "#ef4444", + "#8b5cf6", + "#ec4899", + "#14b8a6", +]; + +const WeeklyMeetingsChart = ({ slug }: { slug: string }) => { + const { data, isPending } = useGetCallsBySlug(slug); + + // Process data to show meetings by day of week + const chartData = useMemo(() => { + if (!data || data.length === 0) { + return []; + } + + const meetingsByDay: Record = { + Sunday: 0, + Monday: 0, + Tuesday: 0, + Wednesday: 0, + Thursday: 0, + Friday: 0, + Saturday: 0, + }; + + // Count meetings by day of week + data.forEach((meeting: any) => { + const date = new Date(meeting.createdAt || meeting.startTime); + const dayName = dayNames[date.getDay()]; + meetingsByDay[dayName]++; + }); + + // Convert to chart format and filter out empty days + return Object.entries(meetingsByDay) + .filter(([_, count]) => count > 0) + .map(([day, count]) => ({ + name: day, + value: count, + })); + }, [data]); + + if (isPending) { + return ( +
+ +
+ ); + } + + if (!chartData || chartData.length === 0) { + return ( +
+

No meetings yet

+

Meetings will appear here once created

+
+ ); + } + + const totalMeetings = chartData.reduce((sum, item) => sum + item.value, 0); + + return ( +
+
+

Meetings by Day

+

+ Total this week: {totalMeetings} meetings +

+
+ + + + `${name}: ${value} (${(percent * 100).toFixed(0)}%)` + } + outerRadius={80} + fill="#8884d8" + dataKey="value" + > + {chartData.map((_, index) => ( + + ))} + + [`${value} meetings`, "Count"]} + contentStyle={{ + backgroundColor: "rgba(0, 0, 0, 0.8)", + border: "1px solid rgba(255, 255, 255, 0.2)", + borderRadius: "8px", + color: "#fff", + }} + /> + + + +
+ ); +}; + +export default WeeklyMeetingsChart; diff --git a/src/components/workspace/calls/CallList.tsx b/src/components/workspace/calls/CallList.tsx new file mode 100644 index 0000000..f517ac2 --- /dev/null +++ b/src/components/workspace/calls/CallList.tsx @@ -0,0 +1,337 @@ +"use client"; + +import Loader from "@/components/Loader"; +import { useEffect, useState, useRef } from "react"; +import { useGetCalls } from "@/hooks/useGetCalls"; +import MeetingCard from "@/components/workspace/meeting/MeetingCard"; +import { useDebounce } from "@/hooks/useDebounce"; +import { useRouter } from "next/navigation"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import type { Call, CallRecording } from "@stream-io/video-react-sdk"; +import { FaSearch } from "react-icons/fa"; +import { getFormattedDate } from "@/hooks/getFormatDate"; + +const MAX_CARDS = 6; + +const CallList = ({ type }: { type: "ended" | "upcoming" | "recordings" }) => { + const router = useRouter(); + const { endedCalls, upcomingCalls, callRecordings, isLoading } = + useGetCalls(); + const [recordings, setRecordings] = useState([]); + const [searchTerm, setSearchTerm] = useState(""); + const debouncedSearchTerm = useDebounce(searchTerm, 3); + const [isSearching, setIsSearching] = useState(false); + const [currentPage, setCurrentPage] = useState(1); + const [selectedDate, setSelectedDate] = useState(""); + + const clearSearch = () => { + setSearchTerm(""); + }; + + const searchInputRef = useRef(null); + + useEffect(() => { + const handleShortcut = (event: KeyboardEvent) => { + if ((event.ctrlKey || event.metaKey) && event.key === "k") { + event.preventDefault(); + searchInputRef.current?.focus(); + } + }; + window.addEventListener("keydown", handleShortcut); + return () => { + window.removeEventListener("keydown", handleShortcut); + }; + }, []); + + // Reset pagination when search term changes + useEffect(() => { + setCurrentPage(1); + }, [debouncedSearchTerm]); + + // Show loading spinner when searching + useEffect(() => { + if (searchTerm) { + setIsSearching(true); + setTimeout(() => setIsSearching(false), 2500); + } else { + setIsSearching(false); + } + }, [debouncedSearchTerm, searchTerm]); + + // Fetch recordings when type is "recordings" + useEffect(() => { + const fetchRecordings = async (): Promise => { + const callData = await Promise.all( + callRecordings?.map((meeting) => meeting.queryRecordings()) ?? [] + ); + + const recordings = callData + .filter((call) => call.recordings.length > 0) + .flatMap((call) => call.recordings); + + setRecordings(recordings); + }; + + if (type === "recordings") { + fetchRecordings(); + } + }, [type, callRecordings]); + + if (isLoading) return ; + + // Filter calls based on type + const getCalls = (): (Call | CallRecording)[] => { + switch (type) { + case "ended": + return endedCalls || []; + case "recordings": + return recordings || []; + case "upcoming": + return upcomingCalls || []; + default: + return []; + } + }; + + // Get message when no calls are available + const getNoCallsMessage = (): string => { + switch (type) { + case "ended": + return "No Previous Calls"; + case "upcoming": + return "No Upcoming Calls"; + case "recordings": + return "No Recordings"; + default: + return ""; + } + }; + + // Get calls based on type + const calls = getCalls(); + const safeCallsArray = Array.isArray(calls) ? calls : []; + + // Filter calls based on search term + const filteredCalls = safeCallsArray.filter((meeting) => { + if (!meeting) return false; + + const title = + (meeting as Call)?.state?.custom?.description || + (meeting as CallRecording)?.filename || + ""; + + const searchLower = debouncedSearchTerm?.toLowerCase() || ""; + + // Fix date extraction + const meetingDate = + getFormattedDate((meeting as Call)?.state?.startsAt) || + getFormattedDate((meeting as CallRecording)?.start_time) || + ""; + + const matchDate = selectedDate ? meetingDate === selectedDate : true; + const matchTitle = title.toLowerCase().includes(searchLower); + return matchTitle && matchDate; + }); + + // Pagination logic + const totalPages = Math.ceil(filteredCalls.length / MAX_CARDS); + const startIndex = (currentPage - 1) * MAX_CARDS; + const endIndex = startIndex + MAX_CARDS; + const paginatedCalls = filteredCalls.slice(startIndex, endIndex); + + const goToNextPage = () => { + if (currentPage < totalPages) { + setCurrentPage(currentPage + 1); + } + }; + + const goToPreviousPage = () => { + if (currentPage > 1) { + setCurrentPage(currentPage - 1); + } + }; + + const goToPage = (pageNumber: number) => { + if (pageNumber >= 1 && pageNumber <= totalPages) { + setCurrentPage(pageNumber); + } + }; + + return ( + <> +

+ {type === "ended" + ? "Previous Meetings" + : type === "upcoming" + ? "Upcoming Meetings" + : "Recordings"} +

+ + {/* SearchBox */} +
+
+ setSearchTerm(e.target.value)} + className="w-full px-4 py-2 border rounded-md focus:outline-none focus:ring focus:ring-blue-300" + /> + {!isSearching && !searchTerm && ( +
+ +
+ )} + {searchTerm && !isSearching && ( + + )} + {isSearching && ( +
+ ⏳ +
+ )} +
+
+ setSelectedDate(e.target.value)} + className="px-4 py-2 border w-full rounded-md focus:outline-none focus:ring focus:ring-blue-300" + /> + {selectedDate && ( + + )} +
+
+ + {isSearching ? ( +
+ {Array.from({ length: 4 }).map((_, index) => ( +
+ ))} +
+ ) : ( + <> +
+ {filteredCalls.length > 0 ? ( + paginatedCalls.map((meeting: Call | CallRecording) => ( + router.push(`${(meeting as CallRecording).url}`) + : () => router.push(`/meeting/${(meeting as Call).id}`) + } + /> + )) + ) : ( +

+ {getNoCallsMessage()} +

+ )} +
+ + {!isSearching && totalPages > 1 && ( +
+ {/* Back Button */} + + + {/* Page Numbers */} +
+ {[...Array(totalPages)].map((_, index) => ( + + ))} +
+ + {/* Next Button */} + +
+ )} + + )} + + ); +}; + +export default CallList; diff --git a/src/components/DirectCallButton.tsx b/src/components/workspace/calls/DirectCallButton.tsx similarity index 98% rename from src/components/DirectCallButton.tsx rename to src/components/workspace/calls/DirectCallButton.tsx index eb00095..46f4c58 100644 --- a/src/components/DirectCallButton.tsx +++ b/src/components/workspace/calls/DirectCallButton.tsx @@ -14,7 +14,7 @@ import { DialogTitle, } from "@/components/ui/dialog"; import { useStreamVideoClient } from "@stream-io/video-react-sdk"; -import Loader from "./Loader"; +import Loader from "@/components/Loader"; interface DirectCallButtonProps { memberId: string; diff --git a/src/components/EndCallButton.tsx b/src/components/workspace/calls/EndCallButton.tsx similarity index 94% rename from src/components/EndCallButton.tsx rename to src/components/workspace/calls/EndCallButton.tsx index 3614290..88a2c31 100644 --- a/src/components/EndCallButton.tsx +++ b/src/components/workspace/calls/EndCallButton.tsx @@ -4,9 +4,9 @@ import { useCall, useCallStateHooks } from "@stream-io/video-react-sdk"; import { Button } from "@/components/ui/button"; import { useRouter } from "next/navigation"; import { useState } from "react"; -import { Phone } from "lucide-react"; import { toast } from "sonner"; import { useWorkspaceStore } from "@/store/workspace"; +import { IconPhone } from "@tabler/icons-react"; const EndCallButton = () => { const call = useCall(); @@ -16,7 +16,7 @@ const EndCallButton = () => { if (!call) throw new Error( - "useStreamCall must be used within a StreamCall component.", + "useStreamCall must be used within a StreamCall component." ); // https://getstream.io/video/docs/react/guides/call-and-participant-state/#participant-state-3 @@ -54,7 +54,7 @@ const EndCallButton = () => { router.push(`/workspace/${workspaceId}`); } catch (error: unknown) { toast.error( - `Failed to end call: ${error instanceof Error ? error.message : "Unknown error"}`, + `Failed to end call: ${error instanceof Error ? error.message : "Unknown error"}` ); } finally { setIsEnding(false); @@ -71,7 +71,7 @@ const EndCallButton = () => { `} variant={"destructive"} > - + {isEnding ? "Ending call..." : "End call for everyone"} ); diff --git a/src/components/HomeCard.tsx b/src/components/workspace/calls/HomeCard.tsx similarity index 100% rename from src/components/HomeCard.tsx rename to src/components/workspace/calls/HomeCard.tsx diff --git a/src/components/workspace/calls/IncomingCallBanner.tsx b/src/components/workspace/calls/IncomingCallBanner.tsx new file mode 100644 index 0000000..f4d4224 --- /dev/null +++ b/src/components/workspace/calls/IncomingCallBanner.tsx @@ -0,0 +1,173 @@ +"use client"; + +import React, { useState } from "react"; +import { useRouter } from "next/navigation"; +import { useToast } from "@/components/ui/use-toast"; +import { RingingCall } from "@stream-io/video-react-sdk"; +import { Button } from "@/components/ui/button"; +import MeetingModal from "@/components/workspace/meeting/MeetingModal"; +import { Textarea } from "@/components/ui/textarea"; + +type Props = { + meeting: any | null; + workspaceSlug: string; +}; + +export default function IncomingCallBanner({ meeting, workspaceSlug }: Props) { + const router = useRouter(); + const { toast } = useToast(); + const [isEnding, setIsEnding] = useState(false); + const [showConfirm, setShowConfirm] = useState(false); + const [declineReason, setDeclineReason] = useState(""); + + if (!meeting) return null; + + const joinMeeting = () => { + router.push(`/meeting/${meeting.meetingId}`); + }; + + const dismissMeeting = async () => { + setIsEnding(true); + try { + // Call the server endpoint that updates the participant response for the current user + const res = await fetch( + `/api/meeting/${meeting.meetingId}/participants/respond`, + { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + status: "declined", + reason: "Dismissed by invitee", + }), + } + ); + + if (!res.ok) { + const err = await res.json().catch(() => ({})); + toast({ title: err?.error || "Failed to dismiss meeting" }); + setIsEnding(false); + return; + } + + const data = await res.json(); + if (!data || !data.success) { + toast({ title: data?.error || "Failed to dismiss meeting" }); + setIsEnding(false); + return; + } + + toast({ title: "You declined the invite" }); + router.refresh(); + } catch (err) { + console.error(err); + toast({ title: "Failed to dismiss meeting" }); + } finally { + setIsEnding(false); + } + }; + + return ( +
+
+
+ {/* presentational ringing list if call context exists; safe to render even if it returns null */} +
+ +
+
+

Incoming Meeting

+

+ {meeting.description || "Instant Meeting"} +

+

+ Hosted by {meeting.hostedBy} +

+
+
+ +
+ + +
+
+ + setShowConfirm(false)} + title="Decline Meeting?" + buttonText={isEnding ? "Declining…" : "Confirm Decline"} + handleClick={async () => { + setIsEnding(true); + try { + const res = await fetch( + `/api/meeting/${meeting.meetingId}/participants/respond`, + { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + status: "declined", + reason: declineReason || "Dismissed by invitee", + }), + } + ); + + if (!res.ok) { + const err = await res.json().catch(() => ({})); + toast({ title: err?.error || "Failed to decline meeting" }); + setIsEnding(false); + return; + } + + const data = await res.json(); + if (!data || !data.success) { + toast({ title: data?.error || "Failed to decline meeting" }); + setIsEnding(false); + return; + } + + toast({ title: "You declined the invite" }); + setShowConfirm(false); + router.refresh(); + } catch (err) { + console.error(err); + toast({ title: "Failed to decline meeting" }); + } finally { + setIsEnding(false); + } + }} + > +

+ Are you sure you want to decline this meeting? +

+
+