Signed-off-by: Kenneth Obsequio <k80308392@gmail.com>
This commit is contained in:
2026-07-12 12:39:47 +08:00
parent 71f758fe0b
commit fa92d924f4
50 changed files with 2202 additions and 2623 deletions
+10 -18
View File
@@ -3,10 +3,11 @@
// no lesson_count/quiz_id of its own — shows unit_count instead (how many Units
// it's attached to).
import { Timer, LockIcon, Layers } from "lucide-react";
import { Timer, LockIcon, Layers, Tag } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { Skeleton } from "@/components/ui/skeleton";
import { cn } from "@/lib/utils";
import { resolveTierBadge, cheapestTierSlug } from "@/utils/tierBadge.util";
function formatDuration(seconds = 0) {
if (!seconds) return null;
@@ -17,11 +18,13 @@ function formatDuration(seconds = 0) {
return `${m}m`;
}
export const LessonCard = ({ lesson, onViewDetails }) => {
export const LessonCard = ({ lesson, tierMap = {}, onViewDetails }) => {
const locked = lesson.is_locked;
const duration = formatDuration(lesson.duration_seconds);
const unitCount = Number(lesson.unit_count ?? 0);
const courses = lesson.courses ?? [];
const slug = cheapestTierSlug(courses.map((c) => c.subscription), tierMap);
const { label, cls } = resolveTierBadge(slug, tierMap);
return (
<div
@@ -35,23 +38,12 @@ export const LessonCard = ({ lesson, onViewDetails }) => {
onClick={() => onViewDetails(lesson)}
>
<div className="flex flex-wrap gap-1.5">
<Badge className={cls}>
{locked ? <LockIcon className="size-3" /> : <Tag className="size-3" />}
{label}
</Badge>
{locked && (
<Badge variant="secondary">
<LockIcon className="size-3" /> Locked
</Badge>
)}
{unitCount > 0 ? (
<Badge variant="outline">
<Layers className="size-3" /> In {unitCount} unit{unitCount === 1 ? "" : "s"}
{courses[0] && (
<>
{" "}· <span className="truncate max-w-[120px] inline-block align-bottom">{courses[0].title}</span>
{courses.length > 1 ? ` +${courses.length - 1}` : ""}
</>
)}
</Badge>
) : (
!locked && <Badge variant="outline" className="text-muted-foreground">Standalone</Badge>
<Badge variant="outline" className="text-muted-foreground">Locked</Badge>
)}
</div>
+66 -77
View File
@@ -1,96 +1,85 @@
// UnitCard — grid card for a standalone Unit. Shared by UnitsList.jsx and
// Dashboard.jsx's "Featured Units" section.
import { Timer, LockIcon, Layers, BookOpen, ClipboardList } from "lucide-react";
import { Timer, LockIcon, Layers, Tag } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { Skeleton } from "@/components/ui/skeleton";
import { cn } from "@/lib/utils";
import { resolveTierBadge, cheapestTierSlug } from "@/utils/tierBadge.util";
function formatDuration(seconds = 0) {
if (!seconds) return null;
const h = Math.floor(seconds / 3600);
const m = Math.floor((seconds % 3600) / 60);
if (h && m) return `${h}h ${m}m`;
if (h) return `${h}h`;
return `${m}m`;
if (!seconds) return null;
const h = Math.floor(seconds / 3600);
const m = Math.floor((seconds % 3600) / 60);
if (h && m) return `${h}h ${m}m`;
if (h) return `${h}h`;
return `${m}m`;
}
export const UnitCard = ({ unit, onViewDetails }) => {
const locked = unit.is_locked;
const duration = formatDuration(unit.duration_seconds);
const lessonCount = Number(unit.lesson_count ?? 0);
const courseCount = Number(unit.course_count ?? 0);
const courses = unit.courses ?? [];
export const UnitCard = ({ unit, tierMap = {}, onViewDetails }) => {
const locked = unit.is_locked;
const duration = formatDuration(unit.duration_seconds);
const lessonCount = Number(unit.lesson_count ?? 0);
const courses = unit.courses ?? [];
const slug = cheapestTierSlug([unit.subscription, ...courses.map((c) => c.subscription)], tierMap);
const { label, cls } = resolveTierBadge(slug, tierMap);
return (
<div
className={cn(
"bg-card rounded-2xl border p-4 flex flex-col gap-2.5 transition-all cursor-pointer group",
"hover:shadow-sm",
locked
? "opacity-80 hover:opacity-100 hover:border-muted-foreground/40"
: "hover:bg-muted/60 dark:hover:border-blue-500"
)}
onClick={() => onViewDetails(unit)}
>
<div className="flex flex-wrap gap-1.5">
{locked && (
<Badge variant="secondary">
<LockIcon className="size-3" /> Locked
</Badge>
)}
{courseCount > 0 ? (
<Badge variant="outline">
<BookOpen className="size-3" />
<span className="truncate max-w-[140px] inline-block align-bottom">{courses[0]?.title ?? "Course"}</span>
{courseCount > 1 ? ` +${courseCount - 1}` : ""}
</Badge>
) : (
!locked && <Badge variant="outline" className="text-muted-foreground">Standalone</Badge>
)}
{unit.quiz_id && (
<Badge variant="outline"><ClipboardList className="size-3" /> Quiz</Badge>
)}
</div>
return (
<div
className={cn(
"bg-card rounded-2xl border p-4 flex flex-col gap-2.5 transition-all cursor-pointer group",
"hover:shadow-sm",
locked
? "opacity-80 hover:opacity-100 hover:border-muted-foreground/40"
: "hover:bg-muted/60 dark:hover:border-blue-500"
)}
onClick={() => onViewDetails(unit)}
>
<div className="flex flex-wrap gap-1.5">
<Badge className={cls}>
{locked ? <LockIcon className="size-3" /> : <Tag className="size-3" />}
{label}
</Badge>
</div>
<div className="flex flex-col gap-1">
<h1 className="text-lg font-medium leading-snug line-clamp-3 transition-colors group-hover:text-blue-700 dark:group-hover:text-blue-400">
{unit.title}
</h1>
{unit.description && (
<p className="text-sm leading-relaxed line-clamp-2 group-hover:text-blue-700 dark:group-hover:text-blue-400">
{unit.description}
</p>
)}
</div>
<div className="flex flex-col gap-1">
<h1 className="text-lg font-medium leading-snug line-clamp-3 transition-colors group-hover:text-blue-700 dark:group-hover:text-blue-400">
{unit.title}
</h1>
{unit.description && (
<p className="text-sm leading-relaxed line-clamp-2 group-hover:text-blue-700 dark:group-hover:text-blue-400">
{unit.description}
</p>
)}
</div>
<div className="flex items-center justify-between pt-2 mt-auto border-t">
<div className={`flex items-center gap-3 text-sm [&_svg]:size-4 ${locked ? "text-muted-foreground" : "text-card-foreground group-hover:text-blue-700 dark:group-hover:text-blue-400"}`}>
<div className="flex items-center gap-1">
<Layers /> {lessonCount} {lessonCount === 1 ? "Lesson" : "Lessons"}
</div>
<div className="flex items-center gap-1">
<Timer /> {duration ?? "—"}
</div>
</div>
{locked && (
<span className="text-xs text-muted-foreground">Upgrade to unlock</span>
)}
</div>
<div className="flex items-center justify-between pt-2 mt-auto border-t">
<div className={`flex items-center gap-3 text-sm [&_svg]:size-4 ${locked ? "text-muted-foreground" : "text-card-foreground group-hover:text-blue-700 dark:group-hover:text-blue-400"}`}>
<div className="flex items-center gap-1">
<Layers /> {lessonCount} {lessonCount === 1 ? "Lesson" : "Lessons"}
</div>
<div className="flex items-center gap-1">
<Timer /> {duration ?? "—"}
</div>
</div>
);
{locked && (
<span className="text-xs text-muted-foreground">Upgrade to unlock</span>
)}
</div>
</div>
);
};
export const UnitCardSkeleton = () => (
<div className="bg-card rounded-2xl border p-4 flex flex-col gap-2.5">
<div className="flex gap-1.5">
<Skeleton className="h-5 w-20 rounded-full" />
</div>
<Skeleton className="h-6 w-3/4" />
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-2/3" />
<div className="pt-2 mt-auto border-t">
<Skeleton className="h-4 w-24" />
</div>
<div className="bg-card rounded-2xl border p-4 flex flex-col gap-2.5">
<div className="flex gap-1.5">
<Skeleton className="h-5 w-20 rounded-full" />
</div>
<Skeleton className="h-6 w-3/4" />
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-2/3" />
<div className="pt-2 mt-auto border-t">
<Skeleton className="h-4 w-24" />
</div>
</div>
);
@@ -0,0 +1,104 @@
// modules/client/pages/AdvertisementLandingPage.jsx
//
// Destination for an advertisement's own click-through when no redirect_link
// was set — the internal page authored in the "Page Builder" wizard step
// (Step 3 of Add Advertisement). Resolved by uuid via
// GET /api/client/advertisements/uuid/:uuid.
import { useEffect, useState } from "react";
import { useParams, useNavigate } from "react-router-dom";
import { Megaphone, ArrowLeft } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Skeleton } from "@/components/ui/skeleton";
import { PageMeta } from "@/contexts/MetadataContext";
import { resolveAssetSrc } from "@/utils/media.util";
import api from "@/utils/api.util";
export default function AdvertisementLandingPage() {
const { uuid } = useParams();
const navigate = useNavigate();
const [ad, setAd] = useState(null);
const [loading, setLoading] = useState(true);
const [notFound, setNotFound] = useState(false);
useEffect(() => {
setLoading(true);
setNotFound(false);
api.get(`/client/advertisements/uuid/${uuid}`)
.then(({ data }) => setAd(data?.data?.data ?? null))
.catch(() => setNotFound(true))
.finally(() => setLoading(false));
}, [uuid]);
if (loading) {
return (
<div className="my-20 lg:container lg:mx-auto max-w-3xl px-4 space-y-4">
<Skeleton className="h-8 w-2/3" />
<Skeleton className="h-56 w-full rounded-lg" />
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-5/6" />
</div>
);
}
if (notFound || !ad) {
return (
<div className="my-20 lg:container lg:mx-auto max-w-3xl px-4 flex flex-col items-center text-center gap-3 py-16">
<Megaphone className="size-8 text-muted-foreground" />
<p className="font-medium">This advertisement is no longer available.</p>
<Button variant="outline" onClick={() => navigate(-1)}>
<ArrowLeft className="size-4" /> Go back
</Button>
</div>
);
}
const page = ad.landing_page ?? {};
const imageSrc = resolveAssetSrc(ad.image) || ad.image_url || null;
const links = Array.isArray(page.links) ? page.links : [];
return (
<div className="my-20 lg:container lg:mx-auto max-w-3xl px-4 space-y-6 pb-16">
<PageMeta title={page.title ? `${page.title} - STARR` : undefined} description={page.description} />
<Button variant="ghost" size="sm" className="w-fit -ml-2" onClick={() => navigate(-1)}>
<ArrowLeft className="size-4" /> Back
</Button>
{imageSrc && (
<div className="rounded-lg overflow-hidden border h-56 sm:h-72">
<img src={imageSrc} alt={page.title || ad.headline || "Advertisement"} className="w-full h-full object-cover" />
</div>
)}
<div className="space-y-2">
<h1 className="text-2xl sm:text-3xl font-bold tracking-tight">{page.title || ad.headline || "Advertisement"}</h1>
{page.description && <p className="text-muted-foreground text-lg">{page.description}</p>}
</div>
{page.body && (
<div className="prose prose-sm sm:prose max-w-none dark:prose-invert whitespace-pre-wrap">
{page.body}
</div>
)}
{links.length > 0 && (
<div className="flex flex-wrap gap-2 pt-2">
{links.map((l, i) => (
<Button
key={i}
variant={i === 0 ? "default" : "outline"}
onClick={() => {
if (!l.link) return;
if (/^https?:\/\//.test(l.link)) window.open(l.link, "_blank", "noopener,noreferrer");
else navigate(l.link);
}}
>
{l.label || l.link}
</Button>
))}
</div>
)}
</div>
);
}
+45 -58
View File
@@ -29,7 +29,6 @@ import { toast } from "sonner";
import { resolveTierBadge } from "@/utils/tierBadge.util";
import { useClientAdvertisements } from "@/contexts/ClientAdvertisementContext";
import { Banner, BannerSkeleton } from "@/components/generic/Blocks/Client/Advertisements/Banner";
import { Sidebar, SidebarSkeleton } from "@/components/generic/Blocks/Client/Advertisements/Sidebar";
import { Tags } from "lucide-react";
// ─── Helpers ──────────────────────────────────────────────────────────────────
@@ -542,13 +541,12 @@ const CourseDetails = () => {
getMyTier();
getCourse(courseId);
fetchCourseProgress(courseId);
getActiveAdvertisements(["course_details.banner", "course_details.sidebar"]);
getActiveAdvertisements(["course_details.banner"]);
return () => { resetCourse(); resetProgress(); setBadgeImageUrl(null); };
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [courseId]);
const bannerAd = advertisements["course_details.banner"] ?? null;
const sidebarAd = advertisements["course_details.sidebar"] ?? null;
// Resolve badge image once course loads — issue a client stream token for
// private S3 assets so the badge preview works on this page.
@@ -714,65 +712,54 @@ const CourseDetails = () => {
{/* Body */}
<div className="lg:container lg:mx-auto flex flex-col gap-6 xs:px-3 xs:-mt-5 lg:-mt-0">
<div className="flex flex-col lg:flex-row gap-8">
<div className="flex flex-col xs:gap-12 lg:gap-12 flex-1 min-w-0">
<div className="space-y-4">
<div className="font-bold text-2xl">About this course</div>
<div className="max-w-3xl space-y-4 text-muted-foreground lg:text-lg">
<p>{course?.description ?? ""}</p>
</div>
<div className="flex flex-col xs:gap-12 lg:gap-12 flex-1 min-w-0">
<div className="space-y-4">
<div className="font-bold text-2xl">About this course</div>
<div className="max-w-3xl space-y-4 text-muted-foreground lg:text-lg">
<p>{course?.description ?? ""}</p>
</div>
{/* Objectives */}
<div className="space-y-4">
{course?.objectives?.length > 0 && (
<div className="space-y-4">
<div className="font-bold text-2xl">What you will learn</div>
<ul className="max-w-3xl list-disc list-inside space-y-1 lg:text-lg text-muted-foreground">
{course.objectives.map((obj) => (
<li key={obj.objective_id}>{obj.text}</li>
))}
</ul>
</div>
)}
</div>
{/* Units — while content isn't ready, only Rewards is shown */}
<div className="space-y-4">
{course?.units?.length > 0 && (
<>
<div className="font-bold text-2xl">
{contentNotReady ? "Rewards" : "Course content"}
</div>
<CourseUnits
units={course.units}
courseId={courseId}
courseTitle={course.title}
courseLevel={course.level}
badgeColor={course.badge_color ?? "purple"}
badgeImageUrl={badgeImageUrl}
isCompleted={isCompleted}
pendingCert={course.pending_certificate ?? null}
certificate={course.certificate ?? null}
assessment={course.assessment ?? null}
contentNotReady={contentNotReady}
/>
</>
)}
</div>
</div>
{/* Advertisement Sidebar */}
<aside className="hidden lg:block w-72 shrink-0 sticky top-36 h-fit">
{adLoading["course_details.sidebar"] ? (
<SidebarSkeleton />
) : (
<Sidebar ad={sidebarAd} onCtaClick={handleAdCtaClick} />
{/* Objectives */}
<div className="space-y-4">
{course?.objectives?.length > 0 && (
<div className="space-y-4">
<div className="font-bold text-2xl">What you will learn</div>
<ul className="max-w-3xl list-disc list-inside space-y-1 lg:text-lg text-muted-foreground">
{course.objectives.map((obj) => (
<li key={obj.objective_id}>{obj.text}</li>
))}
</ul>
</div>
)}
</aside>
</div>
{/* Units — while content isn't ready, only Rewards is shown */}
<div className="space-y-4">
{course?.units?.length > 0 && (
<>
<div className="font-bold text-2xl">
{contentNotReady ? "Rewards" : "Course content"}
</div>
<CourseUnits
units={course.units}
courseId={courseId}
courseTitle={course.title}
courseLevel={course.level}
badgeColor={course.badge_color ?? "purple"}
badgeImageUrl={badgeImageUrl}
isCompleted={isCompleted}
pendingCert={course.pending_certificate ?? null}
certificate={course.certificate ?? null}
assessment={course.assessment ?? null}
contentNotReady={contentNotReady}
/>
</>
)}
</div>
</div>
</div>
</div>
-13
View File
@@ -18,8 +18,6 @@ import { useDateFormat } from "@/hooks/useDateFormat";
import api from "@/utils/api.util";
import { resolveTierBadge } from "@/utils/tierBadge.util";
import { Building2 } from "lucide-react";
import { useClientAdvertisements } from "@/contexts/ClientAdvertisementContext";
import { Banner, BannerSkeleton } from "@/components/generic/Blocks/Client/Advertisements/Banner";
import { GitBranch } from "lucide-react";
// ─── Helpers ──────────────────────────────────────────────────────────────────
@@ -166,7 +164,6 @@ const CoursesList = () => {
const navigate = useNavigate();
const { courses, coursesLoading, getCourses } = useClientCourses();
const { fmtCurrency } = useDateFormat();
const { advertisements, loading: adLoading, getActiveAdvertisement, handleAdCtaClick } = useClientAdvertisements();
const [tierCategories, setTierCategories] = useState([]);
const [currentPage, setCurrentPage] = useState(1);
@@ -187,12 +184,9 @@ const CoursesList = () => {
api.get("/client/courses/categories")
.then(({ data }) => setAllCategories(data.data ?? []))
.catch(() => { });
getActiveAdvertisement("course_list.banner");
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const bannerAd = advertisements["course_list.banner"] ?? null;
// slug → category info map
const tierMap = useMemo(() => {
const m = {};
@@ -306,13 +300,6 @@ const CoursesList = () => {
</div>
</div>
{/* Advertisement Banner */}
{adLoading["course_list.banner"] ? (
<BannerSkeleton />
) : (
<Banner ad={bannerAd} onCtaClick={handleAdCtaClick} />
)}
{categoryFilter !== "All" && (
<div className="flex items-center flex-wrap gap-2">
<span className="text-sm text-muted-foreground">Tags:</span>
+4 -19
View File
@@ -26,7 +26,6 @@ import { cn } from "@/lib/utils";
import { useGroup } from "@/contexts/ClientGroupContext";
import { useClientAdvertisements } from "@/contexts/ClientAdvertisementContext";
import { Hero, HeroSkeleton } from "@/components/generic/Blocks/Client/Advertisements/Hero";
import { Popup } from "@/components/generic/Blocks/Client/Advertisements/Popup";
// ─── Helpers ──────────────────────────────────────────────────────────────────
@@ -214,9 +213,8 @@ const Client = () => {
const { myTier, getMyTier, tierMap } = useClientTiers();
const { groups, fetchGroups, loading: groupLoading } = useGroup();
const {
advertisements, getActiveAdvertisements,
adLists, listLoading, getActiveAdvertisementList,
handleAdCtaClick, dismissPopupForever,
handleAdCtaClick,
} = useClientAdvertisements();
const [modalOpen, setModalOpen] = useState(false);
@@ -228,12 +226,9 @@ const Client = () => {
const [lessonModalOpen, setLessonModalOpen] = useState(false);
const [selectedLesson, setSelectedLesson] = useState(null);
const [popupOpen, setPopupOpen] = useState(false);
const userTier = myTier?.tier ?? "free";
const heroAds = adLists["dashboard.hero"] ?? [];
const popupAd = advertisements["dashboard.popup"] ?? null;
// Show welcome toast on first registration
useEffect(() => {
@@ -256,11 +251,8 @@ const Client = () => {
fetchGroups();
}, [])
// ── Resolve active popup ad + hero ad carousel once on mount ─────────────
// ── Resolve hero ad carousel once on mount ────────────────────────────────
useEffect(() => {
getActiveAdvertisements(["dashboard.popup"]).then((result) => {
if (result["dashboard.popup"]) setPopupOpen(true);
});
getActiveAdvertisementList("dashboard.hero");
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
@@ -387,6 +379,7 @@ const Client = () => {
<UnitCard
key={unit.unit_id}
unit={unit}
tierMap={tierMap}
onViewDetails={handleViewUnitDetails}
/>
))}
@@ -415,6 +408,7 @@ const Client = () => {
<LessonCard
key={lesson.lesson_id}
lesson={lesson}
tierMap={tierMap}
onViewDetails={handleViewLessonDetails}
/>
))}
@@ -425,15 +419,6 @@ const Client = () => {
</div>
</div>
{/* ── Popup Advertisement ── */}
<Popup
ad={popupAd}
open={popupOpen}
onOpenChange={setPopupOpen}
onCtaClick={handleAdCtaClick}
onDismissForever={dismissPopupForever}
/>
{/* ── Upsell Modal — only for locked courses ── */}
<ResponsiveModal
open={modalOpen}
+1
View File
@@ -179,6 +179,7 @@ const LessonsList = () => {
<LessonCard
key={lesson.lesson_id}
lesson={lesson}
tierMap={tierMap}
onViewDetails={handleViewDetails}
/>
))}
+3 -3
View File
@@ -356,11 +356,11 @@ export default function PlanList() {
getPlans();
getMyTier();
getTierCategories();
getActiveAdvertisement("plans.banner");
getActiveAdvertisement("tier_plans.banner");
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [getPlans, getMyTier]);
const bannerAd = advertisements["plans.banner"] ?? null;
const bannerAd = advertisements["tier_plans.banner"] ?? null;
useEffect(() => {
clearInterval(refundTimerRef.current);
@@ -406,7 +406,7 @@ export default function PlanList() {
<div className="lg:container lg:mx-auto space-y-8 p-6">
{/* Advertisement Banner */}
{adLoading["plans.banner"] ? (
{adLoading["tier_plans.banner"] ? (
<BannerSkeleton />
) : (
<Banner ad={bannerAd} onCtaClick={handleAdCtaClick} />
+1
View File
@@ -179,6 +179,7 @@ const UnitsList = () => {
<UnitCard
key={unit.unit_id}
unit={unit}
tierMap={tierMap}
onViewDetails={handleViewDetails}
/>
))}
@@ -25,6 +25,7 @@ import MyCertificates from '../pages/MyCertificates'
import MyAchievements from '../pages/MyAchievements'
import AccountSettings from '../pages/AccountSettings'
import Notifications from '../pages/Notifications'
import AdvertisementLandingPage from '../pages/AdvertisementLandingPage'
import IntroPage from '@/modules/auth/pages/Intro'
import { useAuth } from '@/contexts/AuthContext'
@@ -66,6 +67,7 @@ export const ClientRoutes = {
{ path: 'achievements', element: <MyAchievements /> },
{ path: 'settings', element: <AccountSettings /> },
{ path: 'notifications', element: <Notifications /> },
{ path: 'ads/:uuid', element: <AdvertisementLandingPage /> },
{
path: 'plans', element: <Outlet />,
children: [