mirror of
https://github.com/rgrgogu/new_starr.git
synced 2026-09-27 00:12:54 +08:00
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -179,6 +179,7 @@ const LessonsList = () => {
|
||||
<LessonCard
|
||||
key={lesson.lesson_id}
|
||||
lesson={lesson}
|
||||
tierMap={tierMap}
|
||||
onViewDetails={handleViewDetails}
|
||||
/>
|
||||
))}
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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: [
|
||||
|
||||
Reference in New Issue
Block a user