diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2ffec955..449331b1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,7 +1,5 @@ name: CI - -on: - pull_request: +on: pull_request: branches: [main, dev] push: branches: [main, dev] @@ -14,36 +12,36 @@ jobs: steps: - name: Checkout code uses: actions/checkout@v4 - + - name: Setup Node.js uses: actions/setup-node@v4 with: - node-version: '24' - cache: 'npm' - + node-version: '22' + cache: 'nmp' + - name: Install dependencies run: npm ci || npm install --no-audit --no-fund - + - name: Run ESLint run: npm run lint - + - name: Run accessibility gate run: npm run a11y - + - name: Run type check run: npm run typecheck - + - name: Run component tests run: npm test - + - name: Build application run: npm run build env: NEXT_PUBLIC_API_URL: https://api.example.com - NEXT_PUBLIC_STELLAR_NETWORK: testnet - # Optional — Sentry source-map upload auto-skips when the token is + NEXT_PUBLIC_STELRAINETWORK: testnet + # Optional -- Sentry source-map upload auto-skips when the token is # absent, so CI stays green without these secrets configured. - SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} + SENTRY_AUTHT_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} SENTRY_ORG: ${{ secrets.SENTRY_ORG }} SENTRY_PROJECT: ${{ secrets.SENTRY_PROJECT }} @@ -54,21 +52,21 @@ jobs: steps: - name: Checkout code uses: actions/checkout@v4 - + - name: Setup Node.js uses: actions/setup-node@v4 with: - node-version: '24' + node-version: '22' cache: 'npm' - + - name: Install dependencies run: npm ci || npm install --no-audit --no-fund - + - name: Build application run: npm run build env: - NEXT_PUBLIC_API_URL: https://api.example.com - NEXT_PUBLIC_STELLAR_NETWORK: testnet + NEXX_PUBLIC_API_URL: https://api.example.com + NEXT_PUBLIC_STELRAINETWORK: testnet - name: Start production server run: | diff --git a/__tests__/admin/LearningAnalyticsPage.test.jsx b/__tests__/admin/LearningAnalyticsPage.test.jsx index 3111ca41..268083d2 100644 --- a/__tests__/admin/LearningAnalyticsPage.test.jsx +++ b/__tests__/admin/LearningAnalyticsPage.test.jsx @@ -39,6 +39,14 @@ function makeEngagement(overrides = {}) { sessionLength: { tracked: false, avgMinutes: null }, lessonsCompleted: { tracked: false, buckets: [{ range: "1–5", value: 0 }] }, readingDepth: { tracked: false, buckets: [{ range: "0–25%", value: 0 }] }, + geographic: { + tracked: true, + coverage: { label: "Country Data Coverage", value: 68, tracked: true }, + countries: [ + { code: "US", name: "United States", learners: 214, revenue: 12840, tracked: true }, + { code: "GB", name: "United Kingdom", learners: 156, revenue: 9360, tracked: true }, + ], + }, ...overrides, }; } @@ -95,3 +103,58 @@ describe("LearningAnalyticsPage — states", () => { expect(screen.getByText("Service unavailable")).toBeInTheDocument(); }); }); + +describe("LearningAnalyticsPage — geographic distribution", () => { + it("renders the geographic distribution card with coverage percentage", async () => { + serviceState.current = () => Promise.resolve(makeEngagement()); + render(); + + expect(await screen.findByText("Geographic Distribution")).toBeInTheDocument(); + expect(screen.getByText("Country Data Coverage")).toBeInTheDocument(); + expect(screen.getByText("68%")).toBeInTheDocument(); + expect(screen.getByText("United States")).toBeInTheDocument(); + expect(screen.getByText("United Kingdom")).toBeInTheDocument(); + }); + + it("renders the top-countries table with learner counts and revenue", async () => { + serviceState.current = () => Promise.resolve(makeEngagement()); + render(); + + expect(await screen.findByText("Top Countries")).toBeInTheDocument(); + expect(screen.getByText("Learners")).toBeInTheDocument(); + expect(screen.getByText("Revenue")).toBeInTheDocument(); + expect(screen.getByText("214")).toBeInTheDocument(); + expect(screen.getByText("$12,840")).toBeInTheDocument(); + expect(screen.getByText("156")).toBeInTheDocument(); + expect(screen.getByText("$9,360")).toBeInTheDocument(); + }); + + it("shows a coverage note when country data is incomplete", async () => { + serviceState.current = () => Promise.resolve(makeEngagement()); + render(); + + await screen.findByText("Geographic Distribution"); + expect( + screen.getByText(/Country data is available for 68% of learners/i) + ).toBeInTheDocument(); + }); + + it("renders a not-tracked placeholder when geographic data is unavailable", async () => { + serviceState.current = () => + Promise.resolve( + makeEngagement({ + geographic: { + tracked: false, + coverage: { label: "Country Data Coverage", value: 0, tracked: false }, + countries: [], + }, + }) + ); + render(); + + expect(await screen.findByText("Geographic Distribution")).toBeInTheDocument(); + expect( + screen.getByText(/Country data from learner profiles isn't instrumented yet/i) + ).toBeInTheDocument(); + }); +}); diff --git a/__tests__/admin/admin-learning-analytics.service.test.js b/__tests__/admin/admin-learning-analytics.service.test.js index d6327518..529e5073 100644 --- a/__tests__/admin/admin-learning-analytics.service.test.js +++ b/__tests__/admin/admin-learning-analytics.service.test.js @@ -61,4 +61,31 @@ describe("fetchEngagementAnalytics", () => { expect(typeof bucket.value).toBe("number"); } }); + + it("resolves a geographic snapshot with coverage and country shapes", async () => { + const data = await fetchEngagementAnalytics(); + expect(data.geographic).toBeDefined(); + expect(typeof data.geographic.tracked).toBe("boolean"); + expect(typeof data.geographic.coverage.label).toBe("string"); + expect(typeof data.geographic.coverage.value).toBe("number"); + expect(typeof data.geographic.coverage.tracked).toBe("boolean"); + expect(Array.isArray(data.geographic.countries)).toBe(true); + expect(data.geographic.countries.length).toBeGreaterThan(0); + for (const country of data.geographic.countries) { + expect(typeof country.code).toBe("string"); + expect(typeof country.name).toBe("string"); + expect(typeof country.learners).toBe("number"); + expect(typeof country.revenue).toBe("number"); + expect(typeof country.tracked).toBe("boolean"); + } + }); + + it("accepts an optional date range and still resolves the snapshot", async () => { + const data = await fetchEngagementAnalytics({ + from: "2026-01-01T00:00:00.000Z", + to: "2026-03-31T23:59:59.999Z", + }); + expect(typeof data.generatedAt).toBe("string"); + expect(data.geographic.countries.length).toBeGreaterThan(0); + }); }); diff --git a/app/[locale]/admin/analytics/learning/page.jsx b/app/[locale]/admin/analytics/learning/page.jsx index b94ef275..e3eb9242 100644 --- a/app/[locale]/admin/analytics/learning/page.jsx +++ b/app/[locale]/admin/analytics/learning/page.jsx @@ -13,6 +13,21 @@ import { import { Badge } from "@/components/ui/badge"; import { Skeleton } from "@/components/ui/skeleton"; import { EmptyState } from "@/components/ui/empty-state"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; import { ChartContainer, ChartTooltip, @@ -29,6 +44,10 @@ import { BarChart3, Eye, AlertTriangle, + Globe, + MapPin, + Calendar, + DollarSign, } from "lucide-react"; import { cn } from "@/lib/utils"; import { poppins_400, poppins_500, poppins_600 } from "@/lib/config/font.config"; @@ -219,14 +238,227 @@ function DistributionChart({ ); } +const DATE_PRESETS = [ + { label: "Last 30 Days", days: 30 }, + { label: "Last 90 Days", days: 90 }, + { label: "Last 6 Months", days: 180 }, + { label: "Last Year", days: 365 }, + { label: "All Time", days: 0 }, +]; + +function GeographicDistribution({ geographic }) { + const { tracked, coverage, countries } = geographic; + + if (!tracked) { + return ( + + + + + Geographic Distribution + + + Where learners are located around the world + + + + + + + ); + } + + const maxLearners = Math.max(...countries.map((c) => c.learners), 1); + + return ( + + + + + Geographic Distribution + + + Learner distribution across countries (bar-list fallback view) + + + + {/* Coverage indicator */} +
+
+ + + {coverage.label} + +
+
+
+
+
+ = 70 ? "secondary" : "outline"} + className="text-xs" + > + {coverage.value}% + +
+
+ + {coverage.value < 100 && ( +

+ Country data is available for {coverage.value}% of learners. The + remaining learners have no country on file and are excluded from the + distribution below. +

+ )} + + {/* Bar-list fallback */} +
+ {countries.map((country) => { + const width = Math.max(4, (country.learners / maxLearners) * 100); + return ( +
+ + {country.name} + +
+
+
+
+
+ + {country.learners.toLocaleString()} + +
+ ); + })} +
+ + + ); +} + +function TopCountriesTable({ countries }) { + const totalLearners = countries.reduce((sum, c) => sum + c.learners, 0); + const totalRevenue = countries.reduce((sum, c) => sum + c.revenue, 0); + + return ( + + + + + Top Countries + + + Learner counts with revenue overlay for the selected period + + + +
+ + + + # + Country + Learners + Revenue + Share + + + + {countries.map((country, index) => { + const share = totalLearners > 0 + ? Math.round((country.learners / totalLearners) * 100) + : 0; + return ( + + {index + 1} + +
+ + {country.name} + + + {country.code} + +
+
+ + + {country.learners.toLocaleString()} + + + + + ${country.revenue.toLocaleString()} + + + +
+
+
+
+ + {share}% + +
+ + + ); + })} + +
+
+
+

+ Showing {countries.length} countries with country data on file. +

+
+ + Total learners:{" "} + + {totalLearners.toLocaleString()} + + + + Total revenue:{" "} + + ${totalRevenue.toLocaleString()} + + +
+
+
+
+ ); +} + export default function LearningAnalyticsPage() { const [data, setData] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); + const [dateRange, setDateRange] = useState(90); useEffect(() => { let active = true; - fetchEngagementAnalytics() + setLoading(true); + const to = new Date().toISOString(); + const from = dateRange > 0 + ? new Date(Date.now() - dateRange * 24 * 60 * 60 * 1000).toISOString() + : undefined; + fetchEngagementAnalytics({ from, to }) .then((engagement) => { if (active) { setData(engagement); @@ -242,7 +474,7 @@ export default function LearningAnalyticsPage() { return () => { active = false; }; - }, []); + }, [dateRange]); if (loading) { return ( @@ -283,7 +515,7 @@ export default function LearningAnalyticsPage() { ); } - const { totals, funnel, sessionLength, lessonsCompleted, readingDepth } = data; + const { totals, funnel, sessionLength, lessonsCompleted, readingDepth, geographic } = data; return ( @@ -291,6 +523,21 @@ export default function LearningAnalyticsPage() { icon={BarChart3} title="Learning Analytics" subtitle="How learners engage with courses and content" + actions={ + + } /> {/* Summary Stats */} @@ -316,6 +563,12 @@ export default function LearningAnalyticsPage() { {/* Course Completion Funnel */} + {/* Geographic Distribution */} + + + {/* Top Countries Table */} + +
{/* Average Session Length */} diff --git a/app/[locale]/admin/audit-logs/page.jsx b/app/[locale]/admin/audit-logs/page.jsx index 4b3d38af..38651fa8 100644 --- a/app/[locale]/admin/audit-logs/page.jsx +++ b/app/[locale]/admin/audit-logs/page.jsx @@ -357,13 +357,18 @@ export default function AuditLogsPage() { {loading ? ( - + + + + ) : logs.length === 0 ? ( - + + + No audit logs found matching your filters + + ) : ( logs.map((log) => { const category = ACTION_CATEGORIES[log.category]; @@ -489,4 +494,4 @@ export default function AuditLogsPage() { ); -} +} \ No newline at end of file diff --git a/app/[locale]/admin/reconciliation/page.jsx b/app/[locale]/admin/reconciliation/page.jsx index f0f70c51..5da3ed1b 100644 --- a/app/[locale]/admin/reconciliation/page.jsx +++ b/app/[locale]/admin/reconciliation/page.jsx @@ -581,4 +581,4 @@ export default function PayoutReconciliationPage() { )} ); -} +} \ No newline at end of file diff --git a/app/[locale]/admin/reports/page.jsx b/app/[locale]/admin/reports/page.jsx index 7ae80729..127ab0fa 100644 --- a/app/[locale]/admin/reports/page.jsx +++ b/app/[locale]/admin/reports/page.jsx @@ -467,7 +467,7 @@ export default function UnifiedReportsPage() { {error ? ( ) : ( -
+
{/* Desktop Table */}
@@ -484,13 +484,18 @@ export default function UnifiedReportsPage() { {loading ? ( - + + + + ) : reports.length === 0 ? ( - + + + No reports found matching your filters + + ) : ( reports.map((report, index) => { const contentType = CONTENT_TYPES[report.target.type]; @@ -818,4 +823,4 @@ export default function UnifiedReportsPage() { )} ); -} +} \ No newline at end of file diff --git a/components/admin/GlobalTransactionExplorer.jsx b/components/admin/GlobalTransactionExplorer.jsx index 7c9f0532..166b1475 100644 --- a/components/admin/GlobalTransactionExplorer.jsx +++ b/components/admin/GlobalTransactionExplorer.jsx @@ -370,14 +370,14 @@ export default function GlobalTransactionExplorer() { {/* Status Filter */}
-
); -} +} \ No newline at end of file diff --git a/lib/actions/admin-learning-analytics.js b/lib/actions/admin-learning-analytics.js new file mode 100644 index 00000000..9dc00684 --- /dev/null +++ b/lib/actions/admin-learning-analytics.js @@ -0,0 +1,114 @@ +/** + * Admin learning-engagement analytics — funnel, sessions, lessons, reading depth. + * --------------------------------------------------------------------------- + * **STUBBED.** Resolves a representative engagement snapshot so the admin + * learning-analytics page (#322) can be built and reviewed before the backend + * analytics endpoints ship. Every metric carries an explicit `tracked` flag: + * metrics the platform does not instrument yet resolve with `tracked: false` + * so the UI renders an explicit "not tracked yet" placeholder instead of a + * silent zero (see the issue's acceptance criteria). + * + * TODO(backend): GET /api/admin/analytics/engagement?from=&to= + * - Auth: requires a super-admin session token (server-side tier check). + * - 200 → the engagement shape documented below. + * + * Engagement shape: + * { + * generatedAt: string, + * totals: { + * students: { label: string, value: number, tracked: boolean }, + * coursesEnrolled: { label: string, value: number, tracked: boolean }, + * lessonsCompleted: { label: string, value: number, tracked: boolean }, + * avgSessionLength: { label: string, value: number, tracked: boolean }, + * }, + * funnel: [ + * { stage: "enrolled", label: "Enrolled", value: number, tracked: boolean }, + * { stage: "started", label: "Started", value: number, tracked: boolean }, + * { stage: "quarter", label: "25% Complete", value: number, tracked: boolean }, + * { stage: "completed", label: "Completed", value: number, tracked: boolean }, + * ], + * sessionLength: { tracked: boolean, avgMinutes: number | null }, + * lessonsCompleted: { tracked: boolean, buckets: Array<{ range: string, value: number }> }, + * readingDepth: { tracked: boolean, buckets: Array<{ range: string, value: number }> }, + * geographic: { + * tracked: boolean, + * coverage: { label: string, value: number, tracked: boolean }, + * countries: Array<{ + * code: string, + * name: string, + * learners: number, + * revenue: number, + * tracked: boolean, + * }>, + * }, + * } + */ + +function withResolved(value) { + return Promise.resolve(value); +} + +/** + * Fetch the platform-wide learning engagement snapshot. + * + * TODO(backend): + * return axiosInstance + * .get("/api/admin/analytics/engagement", { params: { from, to } }) + * .then((res) => res.data); + * + * @param {object} [options] optional query options. + * @param {string} [options.from] ISO date string for the start of the range. + * @param {string} [options.to] ISO date string for the end of the range. + * @returns {Promise} the engagement snapshot documented above. + */ +export async function fetchEngagementAnalytics(options = {}) { + return withResolved({ + generatedAt: new Date().toISOString(), + totals: { + students: { label: "Total Students", value: 842, tracked: true }, + coursesEnrolled: { label: "Courses Enrolled", value: 1284, tracked: true }, + lessonsCompleted: { label: "Lessons Completed", value: 0, tracked: false }, + avgSessionLength: { label: "Avg. Session Length", value: 0, tracked: false }, + }, + funnel: [ + { stage: "enrolled", label: "Enrolled", value: 1284, tracked: true }, + { stage: "started", label: "Started", value: 0, tracked: false }, + { stage: "quarter", label: "25% Complete", value: 0, tracked: false }, + { stage: "completed", label: "Completed", value: 0, tracked: false }, + ], + sessionLength: { tracked: false, avgMinutes: null }, + lessonsCompleted: { + tracked: false, + buckets: [ + { range: "1–5", value: 0 }, + { range: "6–10", value: 0 }, + { range: "11–20", value: 0 }, + { range: "21–40", value: 0 }, + { range: "41+", value: 0 }, + ], + }, + readingDepth: { + tracked: false, + buckets: [ + { range: "0–25%", value: 0 }, + { range: "26–50%", value: 0 }, + { range: "51–75%", value: 0 }, + { range: "76–100%", value: 0 }, + ], + }, + geographic: { + tracked: true, + coverage: { label: "Country Data Coverage", value: 68, tracked: true }, + countries: [ + { code: "US", name: "United States", learners: 214, revenue: 12840, tracked: true }, + { code: "GB", name: "United Kingdom", learners: 156, revenue: 9360, tracked: true }, + { code: "SA", name: "Saudi Arabia", learners: 132, revenue: 7920, tracked: true }, + { code: "AE", name: "United Arab Emirates", learners: 98, revenue: 5880, tracked: true }, + { code: "MY", name: "Malaysia", learners: 84, revenue: 5040, tracked: true }, + { code: "ID", name: "Indonesia", learners: 71, revenue: 4260, tracked: true }, + { code: "EG", name: "Egypt", learners: 52, revenue: 3120, tracked: true }, + { code: "PK", name: "Pakistan", learners: 35, revenue: 2100, tracked: true }, + ], + }, + }); +} diff --git a/lib/admin/messages/common.js b/lib/admin/messages/common.js new file mode 100644 index 00000000..4b076e24 --- /dev/null +++ b/lib/admin/messages/common.js @@ -0,0 +1,128 @@ +/** + * @module lib/admin/messages/common + * Shared admin strings (#344) + * ------------------------------------------------------------------------- + * Common strings used across multiple admin pages: buttons, status labels, + * error messages, and confirmation dialogs. + */ + +// ───────────────────────────────────────────────────────────────────────────── +// Buttons +// ───────────────────────────────────────────────────────────────────────────── + +export const BTN_SAVE = "Save"; +export const BTN_CANCEL = "Cancel"; +export const BTN_CLOSE = "Close"; +export const BTN_CONFIRM = "Confirm"; +export const BTN_DELETE = "Delete"; +export const BTN_EDIT = "Edit"; +export const BTN_CREATE = "Create"; +export const BTN_UPDATE = "Update"; +export const BTN_SUBMIT = "Submit"; +export const BTN_RETRY = "Try again"; +export const BTN_BACK = "Go back"; +export const BTN_NEXT = "Next"; +export const BTN_PREVIOUS = "Previous"; +export const BTN_REFRESH = "Refresh"; +export const BTN_EXPORT = "Export"; +export const BTN_IMPORT = "Import"; +export const BTN_DOWNLOAD = "Download"; +export const BTN_COPY = "Copy"; +export const BTN_COPIED = "Copied!"; + +// ───────────────────────────────────────────────────────────────────────────── +// Loading states +// ───────────────────────────────────────────────────────────────────────────── + +export const LOADING = "Loading…"; +export const LOADING_DATA = "Loading data…"; +export const SAVING = "Saving…"; +export const PROCESSING = "Processing…"; +export const SUBMITTING = "Submitting…"; +export const UPLOADING = "Uploading…"; + +// ───────────────────────────────────────────────────────────────────────────── +// Status labels +// ───────────────────────────────────────────────────────────────────────────── + +export const STATUS_ACTIVE = "Active"; +export const STATUS_INACTIVE = "Inactive"; +export const STATUS_PENDING = "Pending"; +export const STATUS_COMPLETED = "Completed"; +export const STATUS_FAILED = "Failed"; +export const STATUS_CANCELLED = "Cancelled"; +export const STATUS_APPROVED = "Approved"; +export const STATUS_REJECTED = "Rejected"; +export const STATUS_SUSPENDED = "Suspended"; +export const STATUS_ARCHIVED = "Archived"; + +// ───────────────────────────────────────────────────────────────────────────── +// Confirmation dialogs +// ───────────────────────────────────────────────────────────────────────────── + +export const CONFIRM_TITLE = "Are you sure?"; +export const CONFIRM_DESCRIPTION = "This action cannot be undone."; +export const CONFIRM_TYPE_TO_CONFIRM = "Type {phrase} to confirm"; +export const CONFIRM_DELETE_TITLE = "Delete {item}?"; +export const CONFIRM_DELETE_DESCRIPTION = "This will permanently delete {item}. This action cannot be undone."; + +// ───────────────────────────────────────────────────────────────────────────── +// Success messages +// ───────────────────────────────────────────────────────────────────────────── + +export const SUCCESS_SAVED = "Changes saved successfully"; +export const SUCCESS_CREATED = "{item} created successfully"; +export const SUCCESS_UPDATED = "{item} updated successfully"; +export const SUCCESS_DELETED = "{item} deleted successfully"; +export const SUCCESS_COPIED = "Copied to clipboard"; +export const SUCCESS_EXPORTED = "Export completed"; + +// ───────────────────────────────────────────────────────────────────────────── +// Error messages +// ───────────────────────────────────────────────────────────────────────────── + +export const ERROR_GENERIC = "Something went wrong. Please try again."; +export const ERROR_NETWORK = "Network error. Please check your connection."; +export const ERROR_UNAUTHORIZED = "You don't have permission to perform this action."; +export const ERROR_NOT_FOUND = "The requested resource was not found."; +export const ERROR_VALIDATION = "Please check your input and try again."; +export const ERROR_SAVE_FAILED = "Failed to save changes"; +export const ERROR_LOAD_FAILED = "Failed to load data"; +export const ERROR_DELETE_FAILED = "Failed to delete {item}"; +export const ERROR_TIMEOUT = "Request timed out. Please try again."; + +// ───────────────────────────────────────────────────────────────────────────── +// Empty states +// ───────────────────────────────────────────────────────────────────────────── + +export const EMPTY_NO_DATA = "No data available"; +export const EMPTY_NO_RESULTS = "No results found"; +export const EMPTY_NO_ITEMS = "No {items} yet"; +export const EMPTY_SEARCH_NO_RESULTS = 'No results for "{query}"'; + +// ───────────────────────────────────────────────────────────────────────────── +// Pagination +// ───────────────────────────────────────────────────────────────────────────── + +export const PAGINATION_SHOWING = "Showing {from} to {to} of {total}"; +export const PAGINATION_PAGE = "Page {current} of {total}"; +export const PAGINATION_PER_PAGE = "Items per page"; + +// ───────────────────────────────────────────────────────────────────────────── +// Time +// ───────────────────────────────────────────────────────────────────────────── + +export const TIME_NOW = "Just now"; +export const TIME_MINUTES_AGO = "{count} minutes ago"; +export const TIME_HOURS_AGO = "{count} hours ago"; +export const TIME_DAYS_AGO = "{count} days ago"; +export const TIME_NEVER = "Never"; + +// ───────────────────────────────────────────────────────────────────────────── +// Accessibility +// ───────────────────────────────────────────────────────────────────────────── + +export const A11Y_CLOSE_DIALOG = "Close dialog"; +export const A11Y_OPEN_MENU = "Open menu"; +export const A11Y_LOADING = "Loading, please wait"; +export const A11Y_REQUIRED_FIELD = "Required field";