Full site redesign, help system, and dashboard stats fixes
Multi-phase visual facelift (design tokens, dashboards, sidebar/navbar shell, per-page help guides, and a layout/content pass across every remaining page) plus backend fixes to the dashboard KPI stats: - Admin/Supervisor dashboard KPIs (revenue, donations, registrations, tickets sold) now use a rolling trailing-month window (today back one calendar month, e.g. 9 May - 8 June if today is 8 June) instead of calendar month-to-date, which under-counted for most of the month. The comparison window shifts the same way, so like is still compared with like. - Reports deep-links from those stat tiles now match the same window (range=trailing_month, replacing range=this_month). - Design tokens (brand-* Tailwind scale + shadcn CSS variables), a site-wide contextual help button, fixed dashboard sidebar/navbar, Admin/Supervisor/Staff/User dashboard rebuilds backed by a new GET /api/stats/overview endpoint, a dedicated Contact page, Site Settings restyle with WhatsApp config folded in, and an Account activity feed backed by a new SecurityEvent model. - Every remaining page (home, events, registration flow, auth, legal, payment results, and every Admin/Supervisor/Staff/User tool page) restyled onto the same design tokens, several with real layout upgrades (home hero, events list/detail, donate page, auth pages). - 20+ new dedicated help guides so the whole site has page-specific help content instead of falling back to a generic guide. - Assorted fixes surfaced along the way: donation-leg double-counting in payment stats, donations not counting toward revenue, refund netting in per-method report breakdowns, and donation over-allocation after a refund. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,20 @@
|
||||
-- CreateEnum
|
||||
CREATE TYPE "SecurityEventType" AS ENUM ('login', 'password_changed', 'password_reset');
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "SecurityEvent" (
|
||||
"id" TEXT NOT NULL,
|
||||
"userId" TEXT,
|
||||
"type" "SecurityEventType" NOT NULL,
|
||||
"ip" TEXT,
|
||||
"device" TEXT,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "SecurityEvent_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "SecurityEvent_userId_createdAt_idx" ON "SecurityEvent"("userId", "createdAt");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "SecurityEvent" ADD CONSTRAINT "SecurityEvent_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
@@ -47,6 +47,12 @@ enum EventCostType {
|
||||
per_item
|
||||
}
|
||||
|
||||
enum SecurityEventType {
|
||||
login
|
||||
password_changed
|
||||
password_reset
|
||||
}
|
||||
|
||||
model User {
|
||||
id String @id @default(uuid())
|
||||
name String
|
||||
@@ -78,6 +84,8 @@ model User {
|
||||
|
||||
personCashCountsFor EventCashupPersonCount[] @relation("EventCashupPersonCountFor")
|
||||
personCashCountsEntered EventCashupPersonCount[] @relation("EventCashupPersonCountEnteredBy")
|
||||
|
||||
securityEvents SecurityEvent[]
|
||||
}
|
||||
|
||||
model Event {
|
||||
@@ -291,6 +299,21 @@ model PasswordReset {
|
||||
@@index([userId])
|
||||
}
|
||||
|
||||
// Append-only "Account activity" log for the Profile & Security page (logins, password
|
||||
// changes). Nullable FK with SetNull (not Cascade) so entries survive account
|
||||
// close/anonymization, the same pattern EventCashup uses for its performedBy audit trail.
|
||||
model SecurityEvent {
|
||||
id String @id @default(uuid())
|
||||
userId String?
|
||||
user User? @relation(fields: [userId], references: [id], onDelete: SetNull)
|
||||
type SecurityEventType
|
||||
ip String?
|
||||
device String?
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@index([userId, createdAt])
|
||||
}
|
||||
|
||||
model EventAttachment {
|
||||
id String @id @default(uuid())
|
||||
eventId String
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
const prisma = require('../config/db');
|
||||
const { safeErrorMessage } = require('../utils/errorUtils');
|
||||
const { isDonationLeg } = require('../utils/cashupUtils');
|
||||
|
||||
// Shared building blocks for the per-dashboard stats endpoints below. Each dashboard
|
||||
// (staff/supervisor/admin) gets exactly one endpoint that returns only what it renders,
|
||||
@@ -56,19 +57,26 @@ function getActiveEventsCount() {
|
||||
return prisma.event.count({ where: { isActive: true, endDate: { gte: new Date() } } });
|
||||
}
|
||||
|
||||
// A donation-application leg (isDonation:false, originalPaymentId set, amount>0) isn't new
|
||||
// money — see isDonationLeg in cashupUtils.js. Matches the same exclusion already applied in
|
||||
// paymentController.getPaymentStats and every report; without it these totals overcount.
|
||||
const EXCLUDE_DONATION_LEGS = {
|
||||
NOT: { AND: [{ isDonation: false }, { originalPaymentId: { not: null } }, { amount: { gt: 0 } }] },
|
||||
};
|
||||
|
||||
async function computePaymentStats({ includeWeekMonth }) {
|
||||
const startOfDay = new Date(new Date().setHours(0, 0, 0, 0));
|
||||
|
||||
const queries = [
|
||||
prisma.payment.aggregate({ _sum: { amount: true }, where: { createdAt: { gte: startOfDay } } }),
|
||||
prisma.payment.aggregate({ _sum: { amount: true }, where: { createdAt: { gte: startOfDay }, ...EXCLUDE_DONATION_LEGS } }),
|
||||
prisma.payment.count({ where: { isDonation: true, createdAt: { gte: startOfDay } } }),
|
||||
];
|
||||
if (includeWeekMonth) {
|
||||
const lastWeek = new Date(startOfDay.getTime() - 7 * 24 * 60 * 60 * 1000);
|
||||
const lastMonth = new Date(startOfDay.getTime() - 30 * 24 * 60 * 60 * 1000);
|
||||
queries.push(
|
||||
prisma.payment.aggregate({ _sum: { amount: true }, where: { createdAt: { gte: lastWeek } } }),
|
||||
prisma.payment.aggregate({ _sum: { amount: true }, where: { createdAt: { gte: lastMonth } } }),
|
||||
prisma.payment.aggregate({ _sum: { amount: true }, where: { createdAt: { gte: lastWeek }, ...EXCLUDE_DONATION_LEGS } }),
|
||||
prisma.payment.aggregate({ _sum: { amount: true }, where: { createdAt: { gte: lastMonth }, ...EXCLUDE_DONATION_LEGS } }),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -134,8 +142,148 @@ const getAdminDashboardStats = async (req, res) => {
|
||||
}
|
||||
};
|
||||
|
||||
// Subtracts `months` calendar months from `date`, clamping to the last day of the target
|
||||
// month if the original day doesn't exist there (e.g. 31 Mar - 1 month -> 28/29 Feb, not
|
||||
// 3 Mar which is what naive setMonth() arithmetic would silently produce).
|
||||
function subtractMonths(date, months) {
|
||||
const d = new Date(date.getTime());
|
||||
const originalDate = d.getDate();
|
||||
d.setMonth(d.getMonth() - months, 1); // move to the 1st of the target month first, so setDate below can't spill into the following month
|
||||
const daysInTargetMonth = new Date(d.getFullYear(), d.getMonth() + 1, 0).getDate();
|
||||
d.setDate(Math.min(originalDate, daysInTargetMonth));
|
||||
return d;
|
||||
}
|
||||
|
||||
// Returns a rolling one-month window ending at the end of today (so today is fully
|
||||
// included), and the equal-length window immediately before it — e.g. if today is 8 June,
|
||||
// thisStart/thisEnd covers 9 May through the end of 8 June, and lastStart/lastEnd covers
|
||||
// 9 April through 8 May. This replaced a calendar-month-to-date window (1st of the month
|
||||
// through now), which under-counted for most of the month — e.g. on the 3rd it only
|
||||
// covered 3 days' worth of data instead of a full trailing month.
|
||||
function trailingMonthRanges(now = new Date()) {
|
||||
const startOfToday = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 0, 0, 0, 0);
|
||||
const thisEnd = new Date(startOfToday.getTime() + 24 * 60 * 60 * 1000); // exclusive upper bound, covers all of today
|
||||
const thisStart = subtractMonths(thisEnd, 1);
|
||||
const lastEnd = thisStart;
|
||||
const lastStart = subtractMonths(thisStart, 1);
|
||||
return { thisStart, thisEnd, lastStart, lastEnd };
|
||||
}
|
||||
|
||||
function pctChange(current, previous) {
|
||||
if (previous === 0) return current === 0 ? 0 : null;
|
||||
return ((current - previous) / previous) * 100;
|
||||
}
|
||||
|
||||
function fetchPaymentsWithOriginal(where) {
|
||||
return prisma.payment.findMany({
|
||||
where,
|
||||
select: {
|
||||
id: true, amount: true, isDonation: true, originalPaymentId: true, eventId: true, createdAt: true,
|
||||
originalPayment: { select: { isDonation: true } },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Classifies each payment the same way computeEventFinancials does (cashupUtils.js):
|
||||
// donation-application legs are excluded entirely (not new money — see isDonationLeg).
|
||||
// Everything else — registration payments, donations themselves, and refunds of either —
|
||||
// is real money in/out and counts toward "revenue". "donations" is a breakdown *within*
|
||||
// that revenue (donations plus any refund of a donation), not a separate bucket.
|
||||
function classifyAndSum(payments) {
|
||||
let revenue = 0;
|
||||
let donations = 0;
|
||||
for (const p of payments) {
|
||||
if (isDonationLeg(p)) continue;
|
||||
revenue += p.amount;
|
||||
if (p.isDonation) {
|
||||
donations += p.amount;
|
||||
} else if (p.originalPaymentId && p.amount < 0 && p.originalPayment?.isDonation) {
|
||||
donations += p.amount;
|
||||
}
|
||||
}
|
||||
return { revenue, donations };
|
||||
}
|
||||
|
||||
// @desc Registrations/tickets/revenue/donations for the trailing month (today back one
|
||||
// month) vs the month before that, a daily revenue trend for the trailing month,
|
||||
// and the top performing events overall.
|
||||
// @route GET /api/stats/overview
|
||||
// @access Private/Supervisor+
|
||||
const getOverviewStats = async (req, res) => {
|
||||
try {
|
||||
const now = new Date();
|
||||
const { thisStart, thisEnd, lastStart, lastEnd } = trailingMonthRanges(now);
|
||||
|
||||
const [thisPayments, lastPayments, thisRegistrations, lastRegistrations, thisTickets, lastTickets, activeEvents] = await Promise.all([
|
||||
fetchPaymentsWithOriginal({ createdAt: { gte: thisStart, lt: thisEnd } }),
|
||||
fetchPaymentsWithOriginal({ createdAt: { gte: lastStart, lt: lastEnd } }),
|
||||
prisma.registration.count({ where: { createdAt: { gte: thisStart, lt: thisEnd }, status: { not: 'cancelled' } } }),
|
||||
prisma.registration.count({ where: { createdAt: { gte: lastStart, lt: lastEnd }, status: { not: 'cancelled' } } }),
|
||||
prisma.ticket.aggregate({ _sum: { quantity: true }, where: { createdAt: { gte: thisStart, lt: thisEnd } } }),
|
||||
prisma.ticket.aggregate({ _sum: { quantity: true }, where: { createdAt: { gte: lastStart, lt: lastEnd } } }),
|
||||
getActiveEventsCount(),
|
||||
]);
|
||||
|
||||
const thisTotals = classifyAndSum(thisPayments);
|
||||
const lastTotals = classifyAndSum(lastPayments);
|
||||
const thisTicketsSold = thisTickets._sum.quantity || 0;
|
||||
const lastTicketsSold = lastTickets._sum.quantity || 0;
|
||||
|
||||
// Daily revenue trend for the trailing month, from the payment rows already fetched above.
|
||||
// Same definition as classifyAndSum: everything except donation-application legs.
|
||||
const trendMap = new Map();
|
||||
for (const p of thisPayments) {
|
||||
if (isDonationLeg(p)) continue;
|
||||
const day = p.createdAt.toISOString().slice(0, 10);
|
||||
trendMap.set(day, (trendMap.get(day) || 0) + p.amount);
|
||||
}
|
||||
const trend = Array.from(trendMap.entries())
|
||||
.sort(([a], [b]) => a.localeCompare(b))
|
||||
.map(([date, revenue]) => ({ date, revenue }));
|
||||
|
||||
// Top performing events overall (not scoped to this month), ranked by revenue — same
|
||||
// "real revenue" definition as the KPI above: everything except application legs.
|
||||
const revenueByEvent = await prisma.payment.groupBy({
|
||||
by: ['eventId'],
|
||||
where: { ...EXCLUDE_DONATION_LEGS, eventId: { not: null } },
|
||||
_sum: { amount: true },
|
||||
orderBy: { _sum: { amount: 'desc' } },
|
||||
take: 5,
|
||||
});
|
||||
const topEventIds = revenueByEvent.map(r => r.eventId).filter(Boolean);
|
||||
const [events, regCounts, ticketSums] = await Promise.all([
|
||||
prisma.event.findMany({ where: { id: { in: topEventIds } }, select: { id: true, title: true } }),
|
||||
prisma.registration.groupBy({ by: ['eventId'], where: { eventId: { in: topEventIds }, status: { not: 'cancelled' } }, _count: { _all: true } }),
|
||||
prisma.ticket.groupBy({ by: ['eventId'], where: { eventId: { in: topEventIds } }, _sum: { quantity: true } }),
|
||||
]);
|
||||
const eventTitleById = Object.fromEntries(events.map(e => [e.id, e.title]));
|
||||
const regCountById = Object.fromEntries(regCounts.map(r => [r.eventId, r._count._all]));
|
||||
const ticketSumById = Object.fromEntries(ticketSums.map(t => [t.eventId, t._sum.quantity || 0]));
|
||||
const topEvents = revenueByEvent.map(r => ({
|
||||
eventId: r.eventId,
|
||||
title: eventTitleById[r.eventId] || 'Untitled event',
|
||||
revenue: r._sum.amount || 0,
|
||||
registrations: regCountById[r.eventId] || 0,
|
||||
ticketsSold: ticketSumById[r.eventId] || 0,
|
||||
}));
|
||||
|
||||
res.json({
|
||||
activeEvents,
|
||||
registrations: { thisMonth: thisRegistrations, lastMonth: lastRegistrations, pctChange: pctChange(thisRegistrations, lastRegistrations) },
|
||||
ticketsSold: { thisMonth: thisTicketsSold, lastMonth: lastTicketsSold, pctChange: pctChange(thisTicketsSold, lastTicketsSold) },
|
||||
revenue: { thisMonth: thisTotals.revenue, lastMonth: lastTotals.revenue, pctChange: pctChange(thisTotals.revenue, lastTotals.revenue) },
|
||||
donations: { thisMonth: thisTotals.donations, lastMonth: lastTotals.donations, pctChange: pctChange(thisTotals.donations, lastTotals.donations) },
|
||||
trend,
|
||||
topEvents,
|
||||
});
|
||||
} catch (error) {
|
||||
res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
getStaffDashboardStats,
|
||||
getSupervisorDashboardStats,
|
||||
getAdminDashboardStats,
|
||||
getOverviewStats,
|
||||
};
|
||||
@@ -2,6 +2,7 @@ const prisma = require('../config/db');
|
||||
const { generateToken, hashPassword, comparePassword } = require('../config/auth');
|
||||
const { v4: uuidv4 } = require('uuid');
|
||||
const { safeErrorMessage } = require('../utils/errorUtils');
|
||||
const { logSecurityEvent, getRecentSecurityEvents } = require('../utils/securityEvents');
|
||||
const axios = require('axios');
|
||||
|
||||
// ─── Helpers ────────────────────────────────────────────────────────────────
|
||||
@@ -273,6 +274,7 @@ const loginUser = async (req, res) => {
|
||||
|
||||
// Send login notification in the background
|
||||
sendLoginNotification(updated, req).catch(() => {});
|
||||
logSecurityEvent({ userId: updated.id, type: 'login', ip: getClientIp(req), userAgent: req.headers['user-agent'] }).catch(() => {});
|
||||
|
||||
res.json({
|
||||
id: updated.id,
|
||||
@@ -399,6 +401,7 @@ const updateUserProfile = async (req, res) => {
|
||||
.catch(e => console.warn('[email] Failed to send password changed alert:', e?.message || e));
|
||||
const { waText } = require('../utils/notify');
|
||||
waText(updatedUser, content.text).catch(() => {});
|
||||
logSecurityEvent({ userId: updatedUser.id, type: 'password_changed', ip: getClientIp(req), userAgent: req.headers['user-agent'] }).catch(() => {});
|
||||
}
|
||||
|
||||
res.json({
|
||||
@@ -744,6 +747,8 @@ const resetPassword = async (req, res) => {
|
||||
prisma.passwordReset.update({ where: { token }, data: { used: true } })
|
||||
]);
|
||||
|
||||
logSecurityEvent({ userId: user.id, type: 'password_reset', ip: getClientIp(req), userAgent: req.headers['user-agent'] }).catch(() => {});
|
||||
|
||||
res.json({ message: 'Password has been reset successfully' });
|
||||
} catch (error) {
|
||||
res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) });
|
||||
@@ -929,6 +934,18 @@ const closeAccount = async (req, res) => {
|
||||
}
|
||||
};
|
||||
|
||||
// @desc Recent account activity (logins, password changes) for the current user
|
||||
// @route GET /api/users/activity
|
||||
// @access Private
|
||||
const getMyActivity = async (req, res) => {
|
||||
try {
|
||||
const events = await getRecentSecurityEvents(req.user.id, 10);
|
||||
res.json(events);
|
||||
} catch (error) {
|
||||
res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
registerUser,
|
||||
loginUser,
|
||||
@@ -946,4 +963,5 @@ module.exports = {
|
||||
revokeMySession,
|
||||
adminRevokeUserSessions,
|
||||
closeAccount,
|
||||
getMyActivity,
|
||||
};
|
||||
@@ -195,7 +195,7 @@ const handleWebhook = async (req, res) => {
|
||||
const { getSettingSync } = require('../utils/settingsCache');
|
||||
const adminEmail = getSettingSync('smtp_from', process.env.EMAIL_FROM || process.env.EMAIL_USER || '')
|
||||
|| getSettingSync('org_email', process.env.EMAIL_FROM || process.env.EMAIL_USER || '');
|
||||
const dashboardUrl = `${(process.env.FRONTEND_URL || 'http://localhost:3000').replace(/\/$/, '')}/dashboard/admin/whatsapp`;
|
||||
const dashboardUrl = `${(process.env.FRONTEND_URL || 'http://localhost:3000').replace(/\/$/, '')}/dashboard/admin/settings?tab=whatsapp`;
|
||||
const when = new Date().toLocaleString('en-ZA', { timeZone: 'Africa/Johannesburg' });
|
||||
await sendMail({
|
||||
to: adminEmail,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const { getStaffDashboardStats, getSupervisorDashboardStats, getAdminDashboardStats } = require('../controllers/statsController');
|
||||
const { getStaffDashboardStats, getSupervisorDashboardStats, getAdminDashboardStats, getOverviewStats } = require('../controllers/statsController');
|
||||
const { protect, staff, supervisor, admin } = require('../middleware/authMiddleware');
|
||||
|
||||
// One endpoint per dashboard — each returns exactly what that dashboard renders in a
|
||||
@@ -10,4 +10,8 @@ router.get('/staff', protect, staff, getStaffDashboardStats);
|
||||
router.get('/supervisor', protect, supervisor, getSupervisorDashboardStats);
|
||||
router.get('/admin', protect, admin, getAdminDashboardStats);
|
||||
|
||||
// Month-over-month KPIs + trend + top events, shared by the Admin and Supervisor dashboards.
|
||||
// Staff never sees financial data, so this has no staff-accessible route.
|
||||
router.get('/overview', protect, supervisor, getOverviewStats);
|
||||
|
||||
module.exports = router;
|
||||
@@ -17,6 +17,7 @@ const {
|
||||
revokeMySession,
|
||||
adminRevokeUserSessions,
|
||||
closeAccount,
|
||||
getMyActivity,
|
||||
} = require('../controllers/userController');
|
||||
const { protect, admin, supervisor, loginLimiter} = require('../middleware/authMiddleware');
|
||||
|
||||
@@ -34,6 +35,7 @@ router.route('/profile')
|
||||
|
||||
router.post('/revoke-sessions', protect, revokeMySession);
|
||||
router.post('/close-account', protect, closeAccount);
|
||||
router.get('/activity', protect, getMyActivity);
|
||||
|
||||
// Admin routes
|
||||
router.route('/')
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
const prisma = require('../config/db');
|
||||
const { describeUserAgent } = require('./userAgent');
|
||||
|
||||
// Fire-and-forget by design — a logging failure must never break login/password-change,
|
||||
// so this swallows its own errors rather than propagating them to the caller (same
|
||||
// posture as the existing email/WhatsApp notification sends elsewhere in this codebase).
|
||||
async function logSecurityEvent({ userId, type, ip, userAgent }) {
|
||||
try {
|
||||
await prisma.securityEvent.create({
|
||||
data: {
|
||||
userId: userId || null,
|
||||
type,
|
||||
ip: ip || null,
|
||||
device: describeUserAgent(userAgent),
|
||||
},
|
||||
});
|
||||
} catch (e) {
|
||||
console.error('Failed to log security event:', e?.message);
|
||||
}
|
||||
}
|
||||
|
||||
// Recent activity for a user's Profile & Security page — no raw IP in the response,
|
||||
// just what the mockup shows (what happened, on what device, when).
|
||||
async function getRecentSecurityEvents(userId, limit = 10) {
|
||||
const rows = await prisma.securityEvent.findMany({
|
||||
where: { userId },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take: limit,
|
||||
select: { id: true, type: true, device: true, createdAt: true },
|
||||
});
|
||||
return rows;
|
||||
}
|
||||
|
||||
module.exports = { logSecurityEvent, getRecentSecurityEvents };
|
||||
@@ -0,0 +1,36 @@
|
||||
// Lightweight User-Agent -> "Browser on OS" summary for account-activity logging.
|
||||
// No UA-parsing library is installed in this project; this covers the common cases
|
||||
// (desktop/mobile browsers, major OSes) without pulling one in for a single display string.
|
||||
|
||||
function detectBrowser(ua) {
|
||||
if (/Edg\//.test(ua)) return 'Edge';
|
||||
if (/OPR\//.test(ua) || /Opera/.test(ua)) return 'Opera';
|
||||
if (/SamsungBrowser/.test(ua)) return 'Samsung Internet';
|
||||
if (/CriOS/.test(ua)) return 'Chrome';
|
||||
if (/FxiOS/.test(ua)) return 'Firefox';
|
||||
if (/Firefox\//.test(ua)) return 'Firefox';
|
||||
if (/Chrome\//.test(ua)) return 'Chrome';
|
||||
if (/Safari\//.test(ua) && /Version\//.test(ua)) return 'Safari';
|
||||
return null;
|
||||
}
|
||||
|
||||
function detectOS(ua) {
|
||||
if (/Windows/.test(ua)) return 'Windows';
|
||||
if (/iPhone|iPad|iPod/.test(ua)) return 'iOS';
|
||||
if (/Mac OS X/.test(ua)) return 'macOS';
|
||||
if (/Android/.test(ua)) return 'Android';
|
||||
if (/Linux/.test(ua)) return 'Linux';
|
||||
return null;
|
||||
}
|
||||
|
||||
function describeUserAgent(uaString) {
|
||||
if (!uaString) return 'Unknown device';
|
||||
const browser = detectBrowser(uaString);
|
||||
const os = detectOS(uaString);
|
||||
if (browser && os) return `${browser} on ${os}`;
|
||||
if (browser) return browser;
|
||||
if (os) return os;
|
||||
return 'Unknown device';
|
||||
}
|
||||
|
||||
module.exports = { describeUserAgent };
|
||||
Reference in New Issue
Block a user