Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion .env
Original file line number Diff line number Diff line change
Expand Up @@ -44,4 +44,7 @@ VITE_RECAPATCH=6LfLqG0sAAAAAIVBgjd_WSzhhcbIA8Z_boHtbtUv



VITE_VAPID_PUBLIC_KEY=BJmu1K4k02ru__di6cZa40gJD1b8wRy_K2oivy6LIzAf-EMBYl3u2fjTH1yUKp2bp2YZIPqhE0A0GsaczxqAJ9A
VITE_VAPID_PUBLIC_KEY=BJmu1K4k02ru__di6cZa40gJD1b8wRy_K2oivy6LIzAf-EMBYl3u2fjTH1yUKp2bp2YZIPqhE0A0GsaczxqAJ9A


VITE_CMS_ANALYTICS_ENDPOINT=/api/cms-analytics
37 changes: 37 additions & 0 deletions src/domain/entity/CMSAnalytics.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
/**
* Domain Entity for Analytics
* Represents the core business logic and structure.
*/
export class CMSAnalytics {
constructor({ users, courses, events, reports, contributors }) {
this.users = this._normalize(users);
this.courses = this._normalize(courses);
this.events = this._normalize(events);
this.reports = this._normalize(reports);
this.contributors = {
authors: this._normalize(contributors?.authors),
organizers: this._normalize(contributors?.organizers)
};
}

_normalize(data) {
return {
total: data?.total || 0,
timeline: (data?.timeline || []).map(t => ({
time: t.time,
count: Number(t.count || 0)
}))
};
}

getSummaryStats() {
return {
totalCourses: this.courses.total,
totalEvents: this.events.total,
totalUsers: this.users.total,
pendingReports: this.reports.total,
courseAuthors: this.contributors.authors.total,
eventOrganizers: this.contributors.organizers.total
};
}
}
13 changes: 13 additions & 0 deletions src/domain/interface/IAnalyticsAccess.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
/**
* Interface for Analytics Data Access
* Defines the contract for fetching CMS analytics.
*/
export class IAnalyticsAccess {
/**
* @param {CMSAnalyticsRequestDTO} requestDto
* @returns {Promise<Object>} raw analytics data
*/
async getFullAnalytics(requestDto) {

Check warning on line 10 in src/domain/interface/IAnalyticsAccess.js

View workflow job for this annotation

GitHub Actions / 🧪 CI — Lint & Build

'requestDto' is defined but never used
throw new Error("Method getFullAnalytics() must be implemented.");
}
}
21 changes: 21 additions & 0 deletions src/domain/mapper/AnalyticsMapper.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { CMSAnalytics } from "../entity/CMSAnalytics";
import { CMSAnalyticsDTO } from "../../infrastructure/DTO/CMSAnalyticsDTO";

/**
* Mapper for Analytics related data transformations
*/
export class AnalyticsMapper {
/**
* Maps Raw Data from API to Domain Entity
*/
static toEntity(rawData) {
return new CMSAnalytics(rawData);
}

/**
* Maps Domain Entity to Response DTO for the UI
*/
static toResponseDTO(entity) {
return new CMSAnalyticsDTO(entity);
}
}
64 changes: 30 additions & 34 deletions src/domain/useCase/useFetchCMSAnalytics.js
Original file line number Diff line number Diff line change
@@ -1,54 +1,50 @@
import { useState, useCallback } from 'react';
import { CourseRepository } from '@infrastructure/repository/CourseRepository';
import { EventRepository } from '@infrastructure/repository/EventRepository';
import { ReportRepository } from '@infrastructure/repository/ReportRepository';
import { UserRepository } from '@infrastructure/repository/UserRepository';
import { useState, useCallback, useMemo } from 'react';
import { AnalyticsRepository } from '../../infrastructure/repository/AnalyticsRepository';
import { AnalyticsMapper } from '../mapper/AnalyticsMapper';
import { CMSAnalyticsRequest } from '../../infrastructure/DTO/Request/CMSAnalyticsRequest';

/**
* UseCase: Fetches high-level metrics for the CMS Dashboard.
* Utilizes Promise.all to fetch metadata without heavily loading payload arrays.
* UseCase: Fetch CMS Analytics
* Orchestrates the flow using DTOs, Entities, and Mappers.
*/
export const useFetchCMSAnalytics = () => {
const repository = useMemo(() => new AnalyticsRepository(), []);

const [data, setData] = useState(null); // Will hold a ResponseDTO
const [stats, setStats] = useState({
totalCourses: 0,
totalEvents: 0,
pendingReports: 0,
totalUsers: 0
totalCourses: 0, totalEvents: 0, totalUsers: 0,
pendingReports: 0, courseAuthors: 0, eventOrganizers: 0
});
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState(null);

const fetchAnalytics = useCallback(async () => {
const fetchAnalytics = useCallback(async (days = 60) => {
setIsLoading(true);
setError(null);
try {
const courseRepo = new CourseRepository();
const eventRepo = new EventRepository();
const reportRepo = new ReportRepository();
const userRepo = new UserRepository();

const [coursesRes, eventsRes, reportsRes, usersRes] = await Promise.all([
courseRepo.getAll(null, 1, 1), // passing size 1 just to get meta.total
eventRepo.getAll(1, 1),
reportRepo.getAll(1, 1, '', 'PENDING'),
userRepo.getAllUsers()
]);

setStats({
totalCourses: coursesRes?.meta?.pagination?.total || 0,
totalEvents: eventsRes?.meta?.pagination?.total || 0,
pendingReports: reportsRes?.meta?.pagination?.total || 0,
totalUsers: Array.isArray(usersRes) ? usersRes.length : 0
});
// 1. Create Request DTO
const requestDto = new CMSAnalyticsRequest({ days });

// 2. Fetch via Repo
const rawData = await repository.getFullAnalytics(requestDto);

// 3. Map to Entity via Mapper
const entity = AnalyticsMapper.toEntity(rawData);

// 4. Map to Response DTO via Mapper
const responseDto = AnalyticsMapper.toResponseDTO(entity);

setData(responseDto);
setStats(entity.getSummaryStats());
setError(null);
} catch (err) {
console.error('[CMS Analytics] Fetch failed:', err);
setError(err.message || 'Failed to load analytics');
setError(err.message || "Failed to fetch scholarly analytics");
} finally {
setIsLoading(false);
}
}, []);
}, [repository]);

return {
data,
stats,
isLoading,
error,
Expand Down
13 changes: 13 additions & 0 deletions src/infrastructure/DTO/CMSAnalyticsDTO.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
/**
* Response DTO for Analytics (Low -> High)
* Standards for data delivery from repository to presentation
*/
export class CMSAnalyticsDTO {
constructor(entity) {
this.courses = entity.courses;
this.events = entity.events;
this.users = entity.users;
this.reports = entity.reports;
this.contributors = entity.contributors;
}
}
15 changes: 15 additions & 0 deletions src/infrastructure/DTO/Request/CMSAnalyticsRequest.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { BaseRequest } from './BaseRequest';

/**
* Request DTO for Analytics (High -> Low)
*/
export class CMSAnalyticsRequest extends BaseRequest {
constructor(data = {}) {
super(data);
this.days = Number(data.days) || 60;
}

toQueryString() {
return `?days=${this.days}`;
}
}
29 changes: 29 additions & 0 deletions src/infrastructure/repository/AnalyticsRepository.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import { IAnalyticsAccess } from '../../domain/interface/IAnalyticsAccess';
import { repositoryRegistry } from './RepositoryRegistry';

/**
* AnalyticsRepository implementing IAnalyticsAccess.
* Responsible for fetching aggregated system metrics.
*/
export class AnalyticsRepository extends IAnalyticsAccess {
constructor(apiClient = repositoryRegistry.apiClient) {
super();
this.apiClient = apiClient;
this.endpoint = import.meta.env.VITE_CMS_ANALYTICS_ENDPOINT || "/api/cms-analytics";
}

/**
* Concrete implementation of the IAnalyticsAccess contract
* @param {CMSAnalyticsRequestDTO} requestDto
*/
async getFullAnalytics(requestDto) {
try {
const query = requestDto.toQueryString();
const response = await this.apiClient.get(`${this.endpoint}${query}`);
return response?.data || {};
} catch (error) {
console.error('[AnalyticsRepository] Fetch error:', error);
throw error;
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import React from 'react';
import { BookOpen, Calendar, Award } from 'lucide-react';

const ContributorStatsViewer = ({ stats = {} }) => {
return (
<div className="animation-slide-up">
<div className="flex justify-between items-center mb-10">
<div>
<h2 className="text-sm font-serif font-bold text-near-black uppercase tracking-widest flex items-center gap-2">
<Award size={16} className="text-accent-primary" />
Scholarly Contributors
</h2>
<p className="text-[10px] text-text-muted mt-1 uppercase tracking-tighter">Verified authors and organizers across the academy</p>
</div>
</div>

<div className="grid grid-cols-1 md:grid-cols-2 gap-8">
{/* Course Contributors */}
<div className="relative group">
<div className="p-8 bg-surface border border-border-subtle rounded-3xl group-hover:border-accent-primary transition-all duration-500 overflow-hidden">
<div className="absolute -right-4 -bottom-4 opacity-5 group-hover:scale-110 transition-transform duration-700">
<BookOpen size={120} />
</div>
<h4 className="text-xs font-bold text-text-muted uppercase tracking-widest mb-2 font-serif">Curriculum Authors</h4>
<div className="flex items-baseline gap-2">
<span className="text-5xl font-bold text-near-black font-sans">{stats.courseAuthors || 0}</span>
<span className="text-xs text-text-muted font-medium italic">Unique Educators</span>
</div>
<div className="mt-6 pt-6 border-t border-border-subtle">
<div className="flex items-center justify-between text-[10px] uppercase tracking-widest font-bold">
<span className="text-text-muted">Status</span>
<span className="text-success">Active Pipeline</span>
</div>
</div>
</div>
</div>

{/* Event Organizers */}
<div className="relative group">
<div className="p-8 bg-surface border border-border-subtle rounded-3xl group-hover:border-tertiary transition-all duration-500 overflow-hidden">
<div className="absolute -right-4 -bottom-4 opacity-5 group-hover:scale-110 transition-transform duration-700">
<Calendar size={120} />
</div>
<h4 className="text-xs font-bold text-text-muted uppercase tracking-widest mb-2 font-serif">Assembly Organizers</h4>
<div className="flex items-baseline gap-2">
<span className="text-5xl font-bold text-near-black font-sans">{stats.eventOrganizers || 0}</span>
<span className="text-xs text-text-muted font-medium italic">Event Leads</span>
</div>
<div className="mt-6 pt-6 border-t border-border-subtle">
<div className="flex items-center justify-between text-[10px] uppercase tracking-widest font-bold">
<span className="text-text-muted">Status</span>
<span className="text-tertiary">Live Network</span>
</div>
</div>
</div>
</div>
</div>
</div>
);
};

export default ContributorStatsViewer;
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import React from 'react';

const DefaultDashboardViewer = () => {
// Current "Activity Trend" bar chart logic
const heights = [40, 60, 30, 80, 50, 70, 90, 45, 65, 85, 55, 75, 40, 60, 100];

return (
<div className="animation-fade-in">
<div className="flex justify-between items-center mb-6">
<h2 className="text-sm font-serif font-bold text-near-black uppercase tracking-widest">Platform Activity Trend</h2>
<span className="text-[10px] text-text-muted uppercase tracking-widest border border-border-subtle px-2 py-1 rounded-full bg-surface-sunken">System Aggregate</span>
</div>

<div className="h-64 flex items-end justify-between gap-[2px] sm:gap-2 px-1 mt-4 border-b border-border-subtle pb-2">
{heights.map((height, i) => (
<div key={i} className="w-full bg-border-subtle/40 rounded-t-sm relative group hover:bg-near-black transition-colors duration-300" style={{ height: `${height}%` }}>
<div className="absolute -top-8 left-1/2 transform -translate-x-1/2 bg-near-black text-ivory text-[10px] py-1 px-2 rounded opacity-0 group-hover:opacity-100 transition-opacity whitespace-nowrap z-10 pointer-events-none font-mono tracking-tighter">
{height} Load
</div>
</div>
))}
</div>
<p className="text-[10px] text-text-muted mt-4 italic">Note: This represents cumulative system interactions across all published modules in the current session.</p>
</div>
);
};

export default DefaultDashboardViewer;
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import React from 'react';
import { TrendingUp, Users } from 'lucide-react';

const COLOR_MAPS = {
"accent-primary": { bar: "bg-accent-primary/40", hover: "hover:bg-accent-primary", text: "text-accent-primary", border: "border-accent-primary/20", bg: "bg-accent-primary/5" },
"accent-blue": { bar: "bg-accent-blue/40", hover: "hover:bg-accent-blue", text: "text-accent-blue", border: "border-accent-blue/20", bg: "bg-accent-blue/5" },
"accent-violet": { bar: "bg-accent-violet/40", hover: "hover:bg-accent-violet", text: "text-accent-violet", border: "border-accent-violet/20", bg: "bg-accent-violet/5" },
"accent-rose": { bar: "bg-accent-rose/40", hover: "hover:bg-accent-rose", text: "text-accent-rose", border: "border-accent-rose/20", bg: "bg-accent-rose/5" },
};

const UnifiedTimelineViewer = ({ data = {}, title, subtitle, colorClass = "accent-blue" }) => {
const timeline = data.timeline || [];
const maxVal = Math.max(...timeline.map(d => d.count), 1);
const colors = COLOR_MAPS[colorClass] || COLOR_MAPS["accent-blue"];

return (
<div className="animation-fade-in">
<div className="flex justify-between items-center mb-6">
<div>
<h2 className="text-sm font-serif font-bold text-near-black uppercase tracking-widest flex items-center gap-2">
<TrendingUp size={16} className={colors.text} />
{title}
</h2>
<p className="text-[10px] text-text-muted mt-1 uppercase tracking-tighter">{subtitle}</p>
</div>
<div className={`flex items-center gap-2 text-[10px] ${colors.bg} px-2 py-1 rounded-full border ${colors.border}`}>
<span className={`${colors.text} font-bold font-mono uppercase`}>Dynamic_Pulse</span>
</div>
</div>

<div className="h-64 flex items-end justify-between gap-1 px-1 mt-8 border-b border-border-subtle pb-4 relative">
<div className="absolute inset-0 flex flex-col justify-between pointer-events-none opacity-5 py-4">
{[0, 1, 2, 4].map(i => <div key={i} className="border-t border-near-black w-full" />)}
</div>

{timeline.length > 0 ? timeline.map((item, i) => {
const height = (item.count / maxVal) * 100;
return (
<div
key={item.time}
className={`flex-1 ${colors.bar} rounded-t-[1px] relative group ${colors.hover} transition-all duration-200`}
style={{ height: `${Math.max(height, 2)}%`, minWidth: '2px' }}
>
<div className="absolute -top-12 left-1/2 transform -translate-x-1/2 bg-near-black text-ivory text-[9px] py-1.5 px-2 rounded opacity-0 group-hover:opacity-100 transition-opacity shadow-lg z-20 pointer-events-none border border-border-subtle whitespace-nowrap">
<p className="font-bold">{item.count} Actions Logged</p>
<p className="opacity-60 text-[8px] font-mono">{item.time}</p>
</div>
</div>
);
}) : (
<div className="w-full h-full flex items-center justify-center text-text-muted text-xs italic opacity-50">
Historical Ledger Empty
</div>
)}
</div>

<div className="mt-8 grid grid-cols-2 gap-6">
<div className="p-5 bg-surface-sunken rounded-2xl border border-border-subtle shadow-inner">
<span className="text-[10px] text-text-muted uppercase font-bold tracking-[0.2em] block mb-2">Peak Activity</span>
<span className="text-3xl font-serif font-bold text-near-black">{maxVal === 1 && timeline.every(t => t.count === 0) ? 0 : maxVal}</span>
</div>
<div className="p-5 bg-surface-sunken rounded-2xl border border-border-subtle shadow-inner">
<span className="text-[10px] text-text-muted uppercase font-bold tracking-[0.2em] block mb-2">Cumulative total</span>
<span className="text-3xl font-serif font-bold text-near-black">{data.total || 0}</span>
</div>
</div>
</div>
);
};

export default UnifiedTimelineViewer;
Loading
Loading