diff --git a/__tests__/admin/PrintRecordViews.test.jsx b/__tests__/admin/PrintRecordViews.test.jsx index 6919693d..d639a577 100644 --- a/__tests__/admin/PrintRecordViews.test.jsx +++ b/__tests__/admin/PrintRecordViews.test.jsx @@ -22,27 +22,68 @@ vi.mock("@/lib/config/env", () => ({ }, })); -vi.mock("@/lib/actions/users/getUserById", () => ({ - getUserById: vi.fn().mockResolvedValue({ - user: { - _id: "usr_999", - name: "Zaynab Idris", - email: "zaynab@deenbridge.org", - role: "educator", - status: "active", - createdAt: "2025-03-01T00:00:00Z", - walletAddress: "GC3BDSVU7WAKCCGLTDTBQLP3Y4S7G45P6W6Y5Z2XJ3K4L5M6N7P8Q111", - bio: "Quran & Tajweed Educator", - purchases: [{ id: "p1", title: "Fiqh of Worship", amount: "42.00", date: "2026-01-01" }], - }, - }), +const getUserByIdMock = vi.hoisted(() => vi.fn()); +const fetchVerificationHistoryMock = vi.hoisted(() => vi.fn()); + +vi.mock("@/lib/actions/users/getUserById", () => ({ getUserById: getUserByIdMock })); +vi.mock("@/lib/actions/admin-verification-history", () => ({ + fetchEducatorVerificationHistory: fetchVerificationHistoryMock, })); +const educatorUser = { + _id: "usr_999", + name: "Zaynab Idris", + email: "zaynab@deenbridge.org", + role: "educator", + status: "active", + createdAt: "2025-03-01T00:00:00Z", + walletAddress: "GC3BDSVU7WAKCCGLTDTBQLP3Y4S7G45P6W6Y5Z2XJ3K4L5M6N7P8Q111", + bio: "Quran & Tajweed Educator", + verification: { + submittedAt: "2026-01-01T09:00:00.000Z", + approvedAt: "2026-01-02T10:00:00.000Z", + reviewedBy: "Amina Admin", + }, + purchases: [{ id: "p1", title: "Fiqh of Worship", amount: "42.00", date: "2026-01-01" }], +}; + +const defaultHistory = { + source: "composed", + events: [ + { + id: "approved-1", + type: "approved", + label: "Approved", + actor: "Amina Admin", + timestamp: "2026-01-02T10:00:00.000Z", + note: null, + }, + { + id: "submitted-1", + type: "submitted", + label: "Submitted", + actor: "Zaynab Idris", + timestamp: "2026-01-01T09:00:00.000Z", + note: null, + }, + ], +}; + +function deferred() { + let resolve; + const promise = new Promise((resolvePromise) => { + resolve = resolvePromise; + }); + return { promise, resolve }; +} + describe("Print-Friendly Views for Records (#338)", () => { let originalPrint; beforeEach(() => { vi.clearAllMocks(); + getUserByIdMock.mockResolvedValue({ user: educatorUser }); + fetchVerificationHistoryMock.mockResolvedValue(defaultHistory); originalPrint = window.print; window.print = vi.fn(); }); @@ -91,7 +132,11 @@ describe("Print-Friendly Views for Records (#338)", () => { render(); }); - expect(await screen.findByText("Zaynab Idris")).toBeInTheDocument(); + expect(await screen.findByRole("heading", { name: "Zaynab Idris" })).toBeInTheDocument(); + expect(await screen.findByText("Verification History")).toBeInTheDocument(); + expect(screen.getByText("Approved")).toBeInTheDocument(); + expect(screen.getAllByText(/Actor:/)).toHaveLength(2); + expect(screen.getByText(/Backend history endpoint is not available yet/i)).toBeInTheDocument(); const printRoot = document.body.querySelector(".print-root"); expect(printRoot).toBeInTheDocument(); @@ -104,6 +149,47 @@ describe("Print-Friendly Views for Records (#338)", () => { expect(window.print).toHaveBeenCalledTimes(1); }); + it("keeps the latest educator history when an earlier request resolves last", async () => { + const userAHistory = deferred(); + const userBHistory = deferred(); + getUserByIdMock.mockImplementation(async (userId) => ({ + user: { ...educatorUser, _id: userId, name: userId === "usr_a" ? "Educator A" : "Educator B" }, + })); + fetchVerificationHistoryMock.mockImplementation((userId) => + userId === "usr_a" ? userAHistory.promise : userBHistory.promise + ); + + let rerender; + await act(async () => { + ({ rerender } = render( + + )); + }); + await waitFor(() => expect(fetchVerificationHistoryMock).toHaveBeenCalledWith("usr_a", expect.any(Object))); + + await act(async () => { + rerender(); + }); + await waitFor(() => expect(fetchVerificationHistoryMock).toHaveBeenCalledWith("usr_b", expect.any(Object))); + + await act(async () => { + userBHistory.resolve({ + source: "backend", + events: [{ ...defaultHistory.events[0], id: "user-b", actor: "Reviewer B" }], + }); + }); + expect(await screen.findByText("Reviewer B")).toBeInTheDocument(); + + await act(async () => { + userAHistory.resolve({ + source: "backend", + events: [{ ...defaultHistory.events[0], id: "user-a", actor: "Reviewer A" }], + }); + }); + await waitFor(() => expect(screen.queryByText("Reviewer A")).not.toBeInTheDocument()); + expect(screen.getByText("Reviewer B")).toBeInTheDocument(); + }); + it("DisputesPage detail modal renders print-root container and print record button", async () => { const { container } = render(); diff --git a/__tests__/admin/admin-verification-history.service.test.js b/__tests__/admin/admin-verification-history.service.test.js new file mode 100644 index 00000000..6c1ba5ab --- /dev/null +++ b/__tests__/admin/admin-verification-history.service.test.js @@ -0,0 +1,96 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +const mockAxios = vi.hoisted(() => ({ + get: vi.fn(), +})); + +vi.mock("@/lib/config/axios.config", () => ({ + default: mockAxios, +})); + +import { + composeVerificationHistoryFromUser, + fetchEducatorVerificationHistory, +} from "@/lib/actions/admin-verification-history"; + +describe("admin verification history service", () => { + beforeEach(() => { + mockAxios.get.mockReset(); + }); + + it("uses backend verification events when the endpoint is available", async () => { + mockAxios.get.mockResolvedValue({ + data: { + events: [ + { + id: "evt_1", + type: "approved", + actor: { name: "Admin reviewer" }, + timestamp: "2026-01-03T12:00:00.000Z", + }, + ], + }, + }); + + const result = await fetchEducatorVerificationHistory("usr_1", {}); + + expect(mockAxios.get).toHaveBeenCalledWith( + "/api/admin/educators/usr_1/verification-history" + ); + expect(result.source).toBe("backend"); + expect(result.events[0]).toMatchObject({ + type: "approved", + actor: "Admin reviewer", + label: "Approved", + }); + }); + + it("composes newest-first fallback events from educator record fields", async () => { + mockAxios.get.mockRejectedValue(new Error("Not found")); + + const result = await fetchEducatorVerificationHistory("usr_2", { + email: "educator@example.com", + verification: { + submittedAt: "2026-01-01T09:00:00.000Z", + infoRequestedAt: "2026-01-02T09:00:00.000Z", + approvedAt: "2026-01-03T09:00:00.000Z", + reviewedBy: "Amina Admin", + infoRequestReason: "Certificate scan was unclear.", + }, + }); + + expect(result.source).toBe("composed"); + expect(result.events.map((event) => event.type)).toEqual([ + "approved", + "info_requested", + "submitted", + ]); + expect(result.events[1].note).toBe("Certificate scan was unclear."); + }); + + it("normalizes explicit history arrays from the user record", () => { + const events = composeVerificationHistoryFromUser({ + verificationHistory: [ + { type: "submitted", timestamp: "2026-01-01T00:00:00Z", actor: "Educator" }, + { type: "re_verified", timestamp: "2026-01-04T00:00:00Z", actor: "Admin" }, + ], + }); + + expect(events).toHaveLength(2); + expect(events[0].type).toBe("re_verified"); + expect(events[0].label).toBe("Re-verified"); + }); + + it("does not treat a generic review timestamp as rejection for an approved record", () => { + const events = composeVerificationHistoryFromUser({ + verification: { + status: "approved", + submittedAt: "2026-01-01T00:00:00Z", + reviewedAt: "2026-01-02T00:00:00Z", + approvedAt: "2026-01-02T00:00:00Z", + }, + }); + + expect(events.map((event) => event.type)).toEqual(["approved", "submitted"]); + }); +}); diff --git a/__tests__/admin/useMediaBlur.test.js b/__tests__/admin/useMediaBlur.test.js index ea509f6e..bc8505b6 100644 --- a/__tests__/admin/useMediaBlur.test.js +++ b/__tests__/admin/useMediaBlur.test.js @@ -56,10 +56,15 @@ afterEach(() => { // ── Tests ────────────────────────────────────────────────────────────── describe("useMediaBlur", () => { - it("starts with blur disabled by default", () => { + it("starts with blur disabled by default", async () => { const { result } = renderHook(() => useMediaBlur()); - expect(result.current.loaded).toBe(true); + + await act(async () => { + await new Promise((r) => setTimeout(r, 0)); + }); + expect(result.current.blurEnabled).toBe(false); + expect(result.current.loaded).toBe(true); }); it("uses overrideDefault when no stored value exists", async () => { diff --git a/__tests__/verification/VerificationPage.test.jsx b/__tests__/verification/VerificationPage.test.jsx index 02398bcf..a20add64 100644 --- a/__tests__/verification/VerificationPage.test.jsx +++ b/__tests__/verification/VerificationPage.test.jsx @@ -2,7 +2,7 @@ * VerificationPage - status center component tests */ -import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { describe, it, expect, vi, beforeAll, beforeEach, afterEach } from "vitest"; import { render, screen, fireEvent, waitFor } from "@testing-library/react"; import { VERIFICATION_STATUS } from "@/lib/actions/educators/fetchVerificationStatus"; @@ -66,14 +66,15 @@ function clickBtn(testId) { if (inner) fireEvent.click(inner); } let _VerificationPage; -beforeEach(async () => { +beforeAll(async () => { + const mod = await import("@/app/[locale]/account/verification/page"); + _VerificationPage = mod.default; +}, 60000); + +beforeEach(() => { _hookState.current = makeHook(); mockPush.mockClear(); mockFetchSignedUrl.mockClear(); - if (!_VerificationPage) { - const mod = await import("@/app/[locale]/account/verification/page"); - _VerificationPage = mod.default; - } }); afterEach(() => vi.clearAllMocks()); diff --git a/app/[locale]/admin/courses/[courseId]/page.jsx b/app/[locale]/admin/courses/[courseId]/page.jsx index 2d2fa2da..6119d2d1 100644 --- a/app/[locale]/admin/courses/[courseId]/page.jsx +++ b/app/[locale]/admin/courses/[courseId]/page.jsx @@ -2,11 +2,11 @@ import { useState, useEffect } from "react"; import { useParams } from "next/navigation"; -import { - Accordion, - AccordionContent, - AccordionItem, - AccordionTrigger +import { + Accordion, + AccordionContent, + AccordionItem, + AccordionTrigger } from "@/components/ui/accordion"; import { Play, BookOpen, FileText, Settings, ShieldAlert, Calendar, BarChart3, Tag } from "lucide-react"; import { PageShell } from "@/components/ui/page-shell"; diff --git a/app/[locale]/admin/users/[userId]/page.jsx b/app/[locale]/admin/users/[userId]/page.jsx index 876318ed..0d94ce6b 100644 --- a/app/[locale]/admin/users/[userId]/page.jsx +++ b/app/[locale]/admin/users/[userId]/page.jsx @@ -17,42 +17,74 @@ import { Avatar, AvatarFallback } from "@/components/ui/avatar"; import { Printer, User, - Mail, - Shield, - Calendar, Wallet, ExternalLink, - BookOpen, - GraduationCap, - Activity, - CheckCircle, + Clock, + FileCheck, Film, PauseCircle, PlayCircle, } from "lucide-react"; import { getUserById } from "@/lib/actions/users/getUserById"; +import { fetchEducatorVerificationHistory } from "@/lib/actions/admin-verification-history"; import { getExplorerUrl } from "@/lib/utils/stellarExplorer"; import { config } from "@/lib/config/env"; import { cn } from "@/lib/utils"; import { CreatorReelsControlDialog } from "@/components/admin/CreatorReelsControlDialog"; +const VERIFICATION_BADGE_STYLES = { + submitted: "bg-blue-100 text-blue-800 border-blue-200", + info_requested: "bg-amber-100 text-amber-800 border-amber-200", + approved: "bg-green-100 text-green-800 border-green-200", + rejected: "bg-red-100 text-red-800 border-red-200", + re_verified: "bg-emerald-100 text-emerald-800 border-emerald-200", +}; + +function isMentorRecord(user) { + const role = String(user?.role || "").toLowerCase(); + return role === "educator" || role === "mentor"; +} + +function formatVerificationTimestamp(timestamp) { + if (!timestamp) return "Timestamp unavailable"; + const date = new Date(timestamp); + if (Number.isNaN(date.getTime())) return "Timestamp unavailable"; + return date.toLocaleString("en-US", { + year: "numeric", + month: "short", + day: "numeric", + hour: "2-digit", + minute: "2-digit", + }); +} + export default function AdminUserDetailPage({ params }) { const { userId } = use(params); const [reelsDialogOpen, setReelsDialogOpen] = useState(false); const [creatorReelsPaused, setCreatorReelsPaused] = useState(false); const [user, setUser] = useState(null); + const [verificationHistory, setVerificationHistory] = useState({ + source: null, + events: [], + loading: true, + error: null, + }); const [loading, setLoading] = useState(true); useEffect(() => { + let isActive = true; + async function loadUser() { setLoading(true); try { const res = await getUserById(userId); + if (!isActive) return; + let resolvedUser; if (res?.user) { - setUser(res.user); + resolvedUser = res.user; } else { // Mock fallback user record if backend endpoint is not connected - setUser({ + resolvedUser = { _id: userId, name: "Amina Yusuf", email: "amina@deenbridge.org", @@ -63,14 +95,45 @@ export default function AdminUserDetailPage({ params }) { bio: "Senior Arabic & Quranic Studies Educator at DeenBridge.", coursesCount: 8, booksCount: 3, + verification: { + submittedAt: "2025-01-16T09:00:00Z", + reviewedAt: "2025-01-17T14:30:00Z", + approvedAt: "2025-01-17T14:30:00Z", + reviewedBy: "Admin review team", + }, purchases: [ { id: "p1", title: "Tafsir of Surah Al-Fatihah", amount: "$24.50", date: "2026-01-10" }, { id: "p2", title: "The Sealed Nectar", amount: "$9.99", date: "2026-02-01" }, ], - }); + }; + } + setUser(resolvedUser); + + if (isMentorRecord(resolvedUser)) { + try { + const history = await fetchEducatorVerificationHistory(userId, resolvedUser); + if (!isActive) return; + setVerificationHistory({ + source: history.source, + events: Array.isArray(history.events) ? history.events : [], + loading: false, + error: null, + }); + } catch (err) { + if (!isActive) return; + setVerificationHistory({ + source: null, + events: [], + loading: false, + error: err?.message || "Failed to load verification history", + }); + } + } else { + setVerificationHistory({ source: null, events: [], loading: false, error: null }); } } catch { - setUser({ + if (!isActive) return; + const fallbackUser = { _id: userId, name: "Amina Yusuf", email: "amina@deenbridge.org", @@ -81,16 +144,35 @@ export default function AdminUserDetailPage({ params }) { bio: "Senior Arabic & Quranic Studies Educator at DeenBridge.", coursesCount: 8, booksCount: 3, + verification: { + submittedAt: "2025-01-16T09:00:00Z", + reviewedAt: "2025-01-17T14:30:00Z", + approvedAt: "2025-01-17T14:30:00Z", + reviewedBy: "Admin review team", + }, purchases: [ { id: "p1", title: "Tafsir of Surah Al-Fatihah", amount: "$24.50", date: "2026-01-10" }, { id: "p2", title: "The Sealed Nectar", amount: "$9.99", date: "2026-02-01" }, ], + }; + setUser(fallbackUser); + const history = await fetchEducatorVerificationHistory(userId, fallbackUser); + if (!isActive) return; + setVerificationHistory({ + source: history.source, + events: Array.isArray(history.events) ? history.events : [], + loading: false, + error: null, }); } finally { - setLoading(false); + if (isActive) setLoading(false); } } loadUser(); + + return () => { + isActive = false; + }; }, [userId]); const handlePrint = () => { @@ -211,6 +293,69 @@ export default function AdminUserDetailPage({ params }) { + {isMentorRecord(user) && ( + + + + + Verification History + + + Full audit trail of educator verification events, newest first + + + + {verificationHistory.loading ? ( +

Loading verification history...

+ ) : verificationHistory.error ? ( +

{verificationHistory.error}

+ ) : verificationHistory.events.length > 0 ? ( +
+ {verificationHistory.source === "composed" && ( +

+ Backend history endpoint is not available yet; this timeline is composed from verification record fields. +

+ )} +
    + {verificationHistory.events.map((event) => ( +
  1. + +
    +
    +
    + + {event.label} + + + Actor: {event.actor} + +
    + {event.note && ( +

    {event.note}

    + )} +
    + +
    +
  2. + ))} +
+
+ ) : ( +

No verification events recorded yet.

+ )} +
+
+ )} + {/* Wallet & Stellar Chain Information */} diff --git a/app/[locale]/dashboard/ai/context.jsx b/app/[locale]/dashboard/ai/context.jsx new file mode 100644 index 00000000..fe7c3d3c --- /dev/null +++ b/app/[locale]/dashboard/ai/context.jsx @@ -0,0 +1,17 @@ +"use client"; + +import { createContext, useContext } from "react"; + +const AiLayoutContext = createContext({ + chatData: { messages: [], chatId: null }, + onChatUpdate: null, + onOpenSidebar: null, +}); + +export function AiLayoutProvider({ value, children }) { + return {children}; +} + +export function useAiLayout() { + return useContext(AiLayoutContext); +} diff --git a/app/[locale]/dashboard/ai/layout.js b/app/[locale]/dashboard/ai/layout.js index fe854e25..8d428504 100644 --- a/app/[locale]/dashboard/ai/layout.js +++ b/app/[locale]/dashboard/ai/layout.js @@ -1,7 +1,8 @@ "use client"; -import React, { useState } from "react"; +import { useState } from "react"; import { X } from "lucide-react"; import { AiSidebar } from "@/components/organisms/dashboard/ai/Ai-Sidebar"; +import { AiLayoutProvider } from "./context"; export default function Layout({ children }) { const [currentChatId, setCurrentChatId] = useState(null); @@ -59,13 +60,15 @@ export default function Layout({ children }) { {/* Chat panel */}
- {children && - typeof children === "object" && - React.cloneElement(children, { + setSidebarOpen(true), - })} + }} + > + {children} +
); diff --git a/app/[locale]/dashboard/ai/page.jsx b/app/[locale]/dashboard/ai/page.jsx index 3e18d1af..093a89b1 100644 --- a/app/[locale]/dashboard/ai/page.jsx +++ b/app/[locale]/dashboard/ai/page.jsx @@ -24,6 +24,7 @@ import { poppins_500, poppins_600, } from "@/lib/config/font.config"; +import { useAiLayout } from "./context"; const markdownComponents = { p: ({ children }) => ( @@ -95,7 +96,7 @@ const AiAvatar = ({ size = 32 }) => ( ); -export default function Dashboard({ chatData, onChatUpdate, onOpenSidebar }) { +function Dashboard({ chatData, onChatUpdate, onOpenSidebar }) { const { user } = useAuth(); const [messages, setMessages] = useState([]); const [inputMessage, setInputMessage] = useState(""); @@ -416,3 +417,15 @@ export default function Dashboard({ chatData, onChatUpdate, onOpenSidebar }) { ); } + +export default function DashboardPage() { + const { chatData, onChatUpdate, onOpenSidebar } = useAiLayout(); + + return ( + + ); +} diff --git a/app/[locale]/dashboard/courses/[courseId]/CourseDetailPageClient.jsx b/app/[locale]/dashboard/courses/[courseId]/CourseDetailPageClient.jsx index 5263f599..df4d9fef 100644 --- a/app/[locale]/dashboard/courses/[courseId]/CourseDetailPageClient.jsx +++ b/app/[locale]/dashboard/courses/[courseId]/CourseDetailPageClient.jsx @@ -13,9 +13,9 @@ import { useHasCourse, usePurchaseCourse } from "@/hooks/usePurchase"; import { useCourseProgress, formatTime } from "@/hooks/useCourseProgress"; import { Progress } from "@/components/ui/progress"; import { toast } from "sonner"; -import { - Wallet, RotateCcw, Play, Star, BookOpen, Tag, Users, MessageSquare, Lock, - CheckCircle2, GraduationCap, Clock, BarChart3, AlertTriangle +import { + Wallet, RotateCcw, Play, Star, BookOpen, Tag, Users, MessageSquare, Lock, + CheckCircle2, GraduationCap, Clock, BarChart3, AlertTriangle } from "lucide-react"; import PaymentModal from "@/components/stellar/PaymentModal"; import { useStellar } from "@/components/stellar/StellarProvider"; @@ -72,7 +72,7 @@ export default function CourseDetailClient({ course }) {
{flagCount > 0 && ( - @@ -89,10 +89,10 @@ export default function CourseDetailClient({ course }) {
{/* ... remainder of content remains unchanged ... */} - setShowPaymentModal(false)} - course={course} + setShowPaymentModal(false)} + course={course} />
diff --git a/app/api/documents/signed-url/route.js b/app/api/documents/signed-url/route.js index 86df1797..4acfa486 100644 --- a/app/api/documents/signed-url/route.js +++ b/app/api/documents/signed-url/route.js @@ -1,11 +1,15 @@ import { NextResponse } from 'next/server'; -import cloudinary from 'cloudinary'; +import crypto from 'node:crypto'; -cloudinary.v2.config({ - cloud_name: process.env.CLOUDINARY_CLOUD_NAME, - api_key: process.env.CLOUDINARY_API_KEY, - api_secret: process.env.CLOUDINARY_API_SECRET, -}); +function signCloudinaryRequest(params, apiSecret) { + const payload = Object.entries(params) + .filter(([, value]) => value !== undefined && value !== null && value !== '') + .sort(([a], [b]) => a.localeCompare(b)) + .map(([key, value]) => `${key}=${value}`) + .join('&'); + + return crypto.createHash('sha1').update(`${payload}${apiSecret}`).digest('hex'); +} export async function GET(request) { const { searchParams } = new URL(request.url); @@ -22,18 +26,33 @@ export async function GET(request) { } try { + const cloudName = process.env.CLOUDINARY_CLOUD_NAME; + const apiKey = process.env.CLOUDINARY_API_KEY; + const apiSecret = process.env.CLOUDINARY_API_SECRET; + + if (!cloudName || !apiKey || !apiSecret) { + return NextResponse.json({ error: 'Cloudinary credentials are not configured' }, { status: 500 }); + } + const expiresInSeconds = parseInt(process.env.SIGNED_URL_EXPIRATION_SECONDS, '10') || 60; + const timestamp = Math.floor(Date.now() / 1000); const expiresAt = Math.floor(Date.now() / 1000) + expiresInSeconds; // Generate a signed URL with a short expiration. // Note: This route must be protected by authentication and authorization in production. - const signedUrl = cloudinary.v2.utils.private_download_url(id, '', { - resource_type: resourceType, + const signatureParams = { + timestamp, + public_id: id, type: 'authenticated', - sign_url: true, expires_at: expiresAt, - secure: true, + }; + const signature = signCloudinaryRequest(signatureParams, apiSecret); + const signedParams = new URLSearchParams({ + ...Object.fromEntries(Object.entries(signatureParams).map(([key, value]) => [key, String(value)])), + signature, + api_key: apiKey, }); + const signedUrl = `https://api.cloudinary.com/v1_1/${cloudName}/${resourceType}/download?${signedParams.toString()}`; // Do not log or cache the signed URL. return NextResponse.json({ url: signedUrl, expiresInSeconds }); @@ -41,4 +60,4 @@ export async function GET(request) { // Avoid logging error details as the error may contain sensitive information. return NextResponse.json({ error: 'Unable to generate signed URL' }, { status: 500 }); } -} \ No newline at end of file +} diff --git a/components/admin/BulkCoursePublishDialog.jsx b/components/admin/BulkCoursePublishDialog.jsx index 0e39ac14..09befa93 100644 --- a/components/admin/BulkCoursePublishDialog.jsx +++ b/components/admin/BulkCoursePublishDialog.jsx @@ -31,7 +31,7 @@ export default function BulkCoursePublishDialog({ open, onOpenChange, courses, o Are you sure you want to set these courses to live? This action will make them visible to all students. - +
    {courses.map(c =>
  • • {c.title}
  • )} @@ -47,8 +47,8 @@ export default function BulkCoursePublishDialog({ open, onOpenChange, courses, o -