add: more commits

Signed-off-by: Kenneth Obsequio <k80308392@gmail.com>
This commit is contained in:
2026-07-03 16:21:27 +08:00
parent 17326b2c2e
commit 7e964f2432
112 changed files with 9160 additions and 3461 deletions
+4 -1
View File
@@ -1,4 +1,4 @@
import { Users, GitFork, FolderOpen, BookText, ListCheck, ShieldCheck, Megaphone } from "lucide-react";
import { Users, GitFork, FolderOpen, BookText, ListCheck, ShieldCheck, Megaphone, Bell, Trophy, Mail } from "lucide-react";
export const ADMIN_SECTIONS = [
{
@@ -39,6 +39,9 @@ export const ADMIN_SECTIONS = [
description: "Manage public-facing content",
tiles: [
{ key: "advertisements", label: "Advertisements", icon: Megaphone, link: "/admin/advertisements" },
{ key: "notifications", label: "Notifications", icon: Bell, link: "/admin/notifications" },
{ key: "achievements", label: "Achievements", icon: Trophy, link: "/admin/achievements" },
{ key: "email-templates", label: "Email Templates", icon: Mail, link: "/admin/email-templates" },
],
},
];
+24
View File
@@ -0,0 +1,24 @@
// data/cronPresets.data.js
// Mirrors backend data/cronPresets.data.js — the only schedule options the UI offers.
export const CRON_PRESET_OPTIONS = [
{ value: "every_minute", label: "Every minute" },
{ value: "every_5_min", label: "Every 5 minutes" },
{ value: "every_15_min", label: "Every 15 minutes" },
{ value: "hourly", label: "Hourly" },
{ value: "every_6_hours", label: "Every 6 hours" },
{ value: "daily", label: "Daily at midnight" },
];
export const CRON_PRESET_MAP = Object.fromEntries(
CRON_PRESET_OPTIONS.map((p) => [p.value, p])
);
// job_name -> friendly copy (label/description come from the API too, but this
// is used as a fallback and for the settings icon).
export const JOB_LABELS = {
taskOverdue: { label: "Task Overdue Alerts (Admin)", description: "Notifies admins when tasks flip to overdue." },
userNotifications: { label: "Task Overdue Alerts (Users)", description: "Notifies affected users when their tasks are marked overdue." },
issueCertificates: { label: "Certificate Issued", description: "Notifies users when a course certificate is ready." },
expireUserTiers: { label: "Tier Expired", description: "Notifies users when their subscription tier expires." },
};
+10
View File
@@ -0,0 +1,10 @@
export const EMAIL_BROADCAST_STATUSES = [
{ value: "queued", label: "Queued", badgeClass: "bg-muted text-muted-foreground border-border" },
{ value: "sending", label: "Sending", badgeClass: "bg-blue-100 text-blue-700 border-blue-400 dark:bg-blue-900/40 dark:text-blue-400 dark:border-blue-700" },
{ value: "completed", label: "Completed", badgeClass: "bg-emerald-100 text-emerald-700 border-emerald-400 dark:bg-emerald-900/40 dark:text-emerald-400 dark:border-emerald-700" },
{ value: "canceled", label: "Canceled", badgeClass: "bg-muted text-muted-foreground border-border" },
];
export const EMAIL_BROADCAST_STATUS_MAP = Object.fromEntries(
EMAIL_BROADCAST_STATUSES.map((s) => [s.value, s])
);
+46
View File
@@ -0,0 +1,46 @@
import { Megaphone, BadgePercent, Shield, Tag } from "lucide-react";
// Purely organizational — lets admins tell at a glance what an email template
// is for. Independent of `is_system` (which is about whether the type/row can
// be renamed or deleted, not what it's used for).
export const EMAIL_TEMPLATE_CATEGORIES = [
{
value: "announcement",
label: "Announcement",
description: "Platform news, updates, or broadcast-style messages to users.",
icon: Megaphone,
badgeClass: "bg-blue-100 text-blue-700 border-blue-400 dark:bg-blue-900/40 dark:text-blue-400 dark:border-blue-700",
},
{
value: "advertisement",
label: "Advertisement",
description: "Promotional or marketing content (offers, plans, campaigns).",
icon: BadgePercent,
badgeClass: "bg-amber-100 text-amber-700 border-amber-400 dark:bg-amber-900/40 dark:text-amber-400 dark:border-amber-700",
},
{
value: "system",
label: "System",
description: "Account and transactional emails triggered by platform events.",
icon: Shield,
badgeClass: "bg-slate-100 text-slate-700 border-slate-400 dark:bg-slate-800/60 dark:text-slate-300 dark:border-slate-600",
},
{
value: "other",
label: "Other",
description: "Anything that doesn't fit the categories above.",
icon: Tag,
badgeClass: "bg-purple-100 text-purple-700 border-purple-400 dark:bg-purple-900/40 dark:text-purple-400 dark:border-purple-700",
},
];
export const getEmailTemplateCategory = (value) =>
EMAIL_TEMPLATE_CATEGORIES.find((c) => c.value === value) ?? EMAIL_TEMPLATE_CATEGORIES[3];
// Mirrors BROADCASTABLE_CATEGORIES in controllers/admin/email_broadcasts.controller.js —
// only these categories make sense to blast to real recipients. System/transactional
// templates are triggered per-user by app events, never mass-sent.
export const BROADCASTABLE_CATEGORIES = ["announcement", "advertisement"];
export const isBroadcastable = (template) =>
BROADCASTABLE_CATEGORIES.includes(template?.category) && template?.status === "sent";
@@ -0,0 +1,14 @@
// Reference-only registry of {{placeholder}} tokens available per system email
// type. Purely informational for the admin editor — the backend derives the
// real substitution data from wherever sendEmail({ type, data }) is called in
// code, this just tells the admin what's actually available to reference.
export const EMAIL_TEMPLATE_PLACEHOLDERS = {
OTP: ["otp", "expiryMinutes"],
WELCOME: ["name"],
PASSWORD_CHANGED: [],
ADDED_TO_GROUP: ["groupName"],
TASK_ASSIGNED: ["taskTitle", "dueDate"],
BAN_LIFTED: ["name", "email", "date"],
BANNED: ["name", "email", "date", "reason", "duration_word", "duration_label", "suspension_note"],
ADD_STAFF: ["name", "email", "password", "expiryHours"],
};
+11
View File
@@ -0,0 +1,11 @@
// A template is "sent" once it has live subject/html_body — that's the only
// content services/email.service.js's sendEmail() ever reads on the backend.
// Editing a sent template writes to draft_subject/draft_html_body instead, so
// "pending changes" means there's a draft sitting on top of the live version.
export const hasPendingChanges = (t) =>
t?.status === "sent" && (t?.draft_subject != null || t?.draft_html_body != null);
export const STATUS_META = {
draft: { label: "Draft", badgeClass: "bg-muted text-muted-foreground border-border" },
sent: { label: "Sent", badgeClass: "bg-emerald-100 text-emerald-700 border-emerald-400 dark:bg-emerald-900/40 dark:text-emerald-400 dark:border-emerald-700" },
};
+32
View File
@@ -0,0 +1,32 @@
// data/notificationBroadcast.data.js
import { Shield, Users, Megaphone, ListCheck, BookText, ShieldCheck } from "lucide-react";
// ─── Target types ─────────────────────────────────────────────────────────────
// Drives: target select in the Add form, target badge on each card.
// needsTarget: true means the form must also show a picker for target_id.
export const TARGET_TYPE_OPTIONS = [
{ value: "admin", label: "Admins", icon: Shield, description: "Visible only in the admin notification bell", needsTarget: false },
{ value: "user", label: "Users", icon: Users, description: "Sent to every active user", needsTarget: false },
{ value: "both", label: "Admins & Users", icon: Megaphone, description: "Sent to admins and every active user", needsTarget: false },
{ value: "task_list", label: "Task List", icon: ListCheck, description: "Sent to everyone assigned to a specific task list", needsTarget: true },
{ value: "course", label: "Course", icon: BookText, description: "Sent to everyone with access to a specific course", needsTarget: true },
{ value: "tier_plan", label: "Tier Plan", icon: ShieldCheck, description: "Sent to everyone currently on a specific tier plan", needsTarget: true },
];
export const TARGET_TYPE_MAP = Object.fromEntries(
TARGET_TYPE_OPTIONS.map((a) => [a.value, a])
);
// ─── Statuses ───────────────────────────────────────────────────────────────
// Drives: filter dropdown options, status badge color/label on each card.
export const BROADCAST_STATUSES = [
{ value: "draft", label: "Draft", badgeClass: "bg-muted text-muted-foreground" },
{ value: "sent", label: "Sent", badgeClass: "bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-400" },
{ value: "archived", label: "Archived", badgeClass: "bg-muted text-muted-foreground" },
];
export const BROADCAST_STATUS_MAP = Object.fromEntries(
BROADCAST_STATUSES.map((s) => [s.value, s])
);
+27
View File
@@ -0,0 +1,27 @@
// data/placement.data.js
//
// Mirrors the backend placement registry (models/advertisements/advertisements.placements.js
// in new_starr). Each entry is a page + position slot; the format (hero/banner/popup/sidebar)
// is derived from the placement, never chosen independently. Keep this list in sync with the
// backend registry when adding a new placement — same pattern as ADVERTISEMENT_TYPES already
// mirroring the backend type ENUM.
export const PLACEMENTS = [
{ key: "dashboard.hero", format: "hero", page: "dashboard", pageLabel: "Dashboard", slotLabel: "Hero (top of page)" },
{ key: "dashboard.popup", format: "popup", page: "dashboard", pageLabel: "Dashboard", slotLabel: "Popup (on load)" },
{ key: "course_list.banner", format: "banner", page: "course_list", pageLabel: "Courses", slotLabel: "Banner (above course grid)" },
{ key: "course_details.banner", format: "banner", page: "course_details", pageLabel: "Course Details", slotLabel: "Banner (below hero)" },
{ key: "course_details.sidebar", format: "sidebar", page: "course_details", pageLabel: "Course Details", slotLabel: "Sidebar (beside course content)" },
{ key: "plans.banner", format: "banner", page: "plans", pageLabel: "Plans", slotLabel: "Banner (above plan cards)" },
];
export const PLACEMENT_MAP = Object.fromEntries(PLACEMENTS.map((p) => [p.key, p]));
// Grouped by page — powers the cascading Page → Position picker in Add/EditAdvertisement.
export const AD_PAGES = Object.values(
PLACEMENTS.reduce((acc, p) => {
if (!acc[p.page]) acc[p.page] = { page: p.page, pageLabel: p.pageLabel, placements: [] };
acc[p.page].placements.push(p);
return acc;
}, {})
);
+75
View File
@@ -0,0 +1,75 @@
// data/placementLayouts.data.js
//
// Declarative "wireframe recipe" per placement — powers <PlacementSkeleton />
// in the advertisement creation wizard so admins can see roughly where their
// ad will land before saving. Keys mirror PLACEMENTS in placement.data.js.
// Adding a new placement: add one entry here, nothing else needs to change.
//
// Block kinds:
// nav — thin top bar stand-in
// bar — a rounded block; `size` controls height (sm/md/lg/xl);
// `highlight: true` marks it as the ad slot, `label` names it
// filters — a row of small pill shapes
// grid — a row of small equal boxes (card grid stand-in)
// list — a couple of thin stacked rows (table/list stand-in)
// row — a flex row of `columns`, each rendered like a `bar`
//
// `overlay` (optional, top-level) renders the base `blocks` dimmed with a
// centered floating highlighted box on top — used for popups.
export const PLACEMENT_LAYOUTS = {
"dashboard.hero": {
blocks: [
{ kind: "nav" },
{ kind: "bar", size: "xl", highlight: true, label: "Hero" },
{ kind: "list", label: "My Groups" },
{ kind: "grid", label: "Courses" },
],
},
"dashboard.popup": {
blocks: [
{ kind: "nav" },
{ kind: "bar", size: "xl" },
{ kind: "list", label: "My Groups" },
{ kind: "grid", label: "Courses" },
],
overlay: { label: "Popup" },
},
"course_list.banner": {
blocks: [
{ kind: "nav" },
{ kind: "filters" },
{ kind: "bar", size: "md", highlight: true, label: "Banner" },
{ kind: "grid", label: "Course cards" },
],
},
"course_details.banner": {
blocks: [
{ kind: "nav" },
{ kind: "bar", size: "lg", label: "Course hero" },
{ kind: "bar", size: "sm", highlight: true, label: "Banner" },
{ kind: "row", columns: [
{ label: "Course content", width: "flex-1" },
{ label: "Sidebar", width: "w-1/4" },
] },
],
},
"course_details.sidebar": {
blocks: [
{ kind: "nav" },
{ kind: "bar", size: "lg", label: "Course hero" },
{ kind: "bar", size: "sm", label: "Banner" },
{ kind: "row", columns: [
{ label: "Course content", width: "flex-1" },
{ label: "Sidebar", width: "w-1/4", highlight: true },
] },
],
},
"plans.banner": {
blocks: [
{ kind: "nav" },
{ kind: "bar", size: "md", highlight: true, label: "Banner" },
{ kind: "grid", label: "Plan cards" },
],
},
};