diff --git a/src/contexts/ClientLibraryContext.jsx b/src/contexts/ClientLibraryContext.jsx index d5b6c67..fcc124b 100644 --- a/src/contexts/ClientLibraryContext.jsx +++ b/src/contexts/ClientLibraryContext.jsx @@ -194,6 +194,34 @@ export function ClientLibraryProvider({ children }) { } }, []); + // Self-report completion for the manual_complete completion requirement type — + // mirrors upsertLessonProgress's result shape (result.lesson/result.unit, not the + // result.cascade wrapper upsertWatchProgress gets from recordWatchProgress). + const markComplete = useCallback(async (lessonUuid, unitUuid) => { + try { + const { data } = await api.post(`/client/lessons/${lessonUuid}/mark-complete`, { + ...(unitUuid ? { unit_uuid: unitUuid } : {}), + }); + const result = data.data ?? null; + + setUnitDetail((prev) => { + if (!prev || !result?.lesson) return prev; + const nextLessons = prev.lessons.map((l) => + l.lesson_id === result.lesson.lesson_id + ? { ...l, status: result.lesson.status, completed_at: new Date().toISOString() } + : l + ); + const is_completed = result.unit ? result.unit.status === "completed" : prev.is_completed; + return { ...prev, lessons: nextLessons, is_completed }; + }); + + return result; + } catch (err) { + toast(err?.response?.data?.message ?? "Could not mark lesson complete."); + return null; + } + }, []); + // ─── Resets ───────────────────────────────────────────────────────────── const resetUnitDetail = useCallback(() => { @@ -222,6 +250,7 @@ export function ClientLibraryProvider({ children }) { saveUnitQuizDraft, upsertLessonProgress, upsertWatchProgress, + markComplete, resetUnitDetail, resetLesson, diff --git a/src/modules/auth/pages/Intro.jsx b/src/modules/auth/pages/Intro.jsx index 5f6629d..75d8fa1 100644 --- a/src/modules/auth/pages/Intro.jsx +++ b/src/modules/auth/pages/Intro.jsx @@ -10,9 +10,12 @@ ***********************************************************************************************************************************************************************/ import { useState } from 'react' import { Navigate, useNavigate, Link } from 'react-router-dom' +import { isValidPhoneNumber, parsePhoneNumber } from 'react-phone-number-input' import { useAuth } from '@/contexts/AuthContext' +import { useDetectedCountry } from '@/hooks/useDetectedCountry' import { Button } from '@/components/ui/button' import { Input } from '@/components/ui/input' +import { PhoneInput } from '@/components/ui/phone-input' import { Label } from '@/components/ui/label' import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar' import { Loader2 } from 'lucide-react' @@ -47,11 +50,13 @@ export default function IntroPage() { const [dateOfBirth, setDateOfBirth] = useState(pi.date_of_birth ?? '') const [occupation, setOccupation] = useState(pi.occupation ?? '') const [phone, setPhone] = useState( - pi.phone_number?.[0]?.full_number ?? '' + pi.phone_number?.[0]?.full_number ? `+${pi.phone_number[0].full_number}` : '' ) const [loading, setLoading] = useState(false) const [errors, setErrors] = useState({}) + const detectedCountry = useDetectedCountry() + // ── Derived ──────────────────────────────────────────────────────────────── const avatarUrl = pi.avatar?.url ?? '' @@ -71,7 +76,7 @@ export default function IntroPage() { } if (!phone.trim()) { e.phone = 'Phone number is required.' - } else if (!/^\+?[0-9\s\-() ]{7,20}$/.test(phone.trim())) { + } else if (!isValidPhoneNumber(phone)) { e.phone = 'Invalid phone number.' } return e @@ -104,9 +109,13 @@ export default function IntroPage() { occupation, phone_number: phone.trim() ? (() => { - const digits = phone.replace(/\D/g, '') - const number = digits.startsWith('63') ? digits.slice(2) : digits.replace(/^0/, '') - return [{ number, country_code: '+63', full_number: `+63${number}`, phone_type: 'mobile' }] + const parsed = parsePhoneNumber(phone) + return [{ + number: parsed.nationalNumber, + country_code: parsed.countryCallingCode, + full_number: `${parsed.countryCallingCode}${parsed.nationalNumber}`, + phone_type: 'mobile', + }] })() : (pi.phone_number ?? []), // Preserve existing avatar and addresses @@ -245,11 +254,11 @@ export default function IntroPage() { - setPhone(e.target.value)} - placeholder="+63 912 345 6789" + onChange={setPhone} + international + defaultCountry={detectedCountry} disabled={loading} /> {errors.phone &&

{errors.phone}

} diff --git a/src/modules/client/pages/CourseDetails.jsx b/src/modules/client/pages/CourseDetails.jsx index 0ddd887..dfecf73 100644 --- a/src/modules/client/pages/CourseDetails.jsx +++ b/src/modules/client/pages/CourseDetails.jsx @@ -28,7 +28,7 @@ import { useCourseReadingProgress } from "@/contexts/ClientCourseReadingProgress import { PageMeta } from "@/contexts/MetadataContext"; import { toast } from "sonner"; import { resolveTierBadge } from "@/utils/tierBadge.util"; -import { getTierColor, getContrastText } from "@/utils/tierColors"; +import { getTierColor } from "@/utils/tierColors"; import { useClientAdvertisements } from "@/contexts/ClientAdvertisementContext"; import { Banner, BannerSkeleton } from "@/components/generic/Blocks/Client/Advertisements/Banner"; import { Tags } from "lucide-react"; @@ -781,15 +781,15 @@ const CourseDetails = () => { {course.prerequisites.map((p) => { // Guarded = the prerequisite itself sits behind a paid tier — // paint the whole row with that tier's actual admin-configured - // color (Tier Categories → Color) instead of a plain gray row. - // Uses the swatch hex directly (inline style) rather than a - // dynamic Tailwind class, since the color key is admin-defined - // at runtime and arbitrary bg-{key}-500 classes aren't - // guaranteed to survive Tailwind's build-time purge. + // color (Tier Categories → Color). Reuses the exact same + // gradient classes the tier Badge renders with (tierColors.js + // #badge) instead of a flat swatch fill — those strings are + // hardcoded per color key in tierColors.js so they survive + // Tailwind's build-time purge, unlike a runtime-composed + // bg-{key}-500 class would. const tierInfo = p.subscription ? tierMap[p.subscription] : null; const guarded = (tierInfo?.rank ?? 0) > 0 && !p.completed; const tierColor = guarded ? getTierColor(tierInfo.color) : null; - const textColor = tierColor ? getContrastText(tierColor.swatch, tierInfo.color) : null; return (
{ className={cn( "flex items-center justify-between gap-3 py-3 px-3 rounded-lg", p.completed && "bg-emerald-50 dark:bg-emerald-950/20", - !p.completed && !guarded && "bg-muted/40" + !p.completed && !guarded && "bg-muted/40", + guarded && tierColor.badge )} - style={guarded ? { backgroundColor: tierColor.swatch } : undefined} >
{p.completed ? ( @@ -808,13 +808,15 @@ const CourseDetails = () => {
) : (
)} {p.title ?? "—"} @@ -825,9 +827,10 @@ const CourseDetails = () => { "shrink-0 border-0 gap-1", p.completed ? "bg-emerald-100 text-emerald-700 dark:bg-emerald-900/40 dark:text-emerald-400" - : !guarded && "text-muted-foreground" + : guarded + ? "bg-white/15 text-white" + : "text-muted-foreground" )} - style={guarded ? { backgroundColor: `${textColor}1a`, color: textColor } : undefined} > {p.completed ? <> Completed diff --git a/src/modules/client/pages/LessonDetails.jsx b/src/modules/client/pages/LessonDetails.jsx index b315e4c..d454203 100644 --- a/src/modules/client/pages/LessonDetails.jsx +++ b/src/modules/client/pages/LessonDetails.jsx @@ -5,12 +5,13 @@ import { } from "lucide-react"; import { Skeleton } from "@/components/ui/skeleton"; import { Button } from "@/components/ui/button"; -import { useCallback, useEffect, useState } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; import { useLibrary } from "@/contexts/ClientLibraryContext"; import { useClientTiers } from "@/contexts/ClientTiersProvider"; import { PageMeta } from "@/contexts/MetadataContext"; import LockedContentPanel from "@/modules/client/components/LockedContentPanel"; import LessonBlock from "../components/LessonBlock.jsx"; +import MarkCompleteButton from "@/modules/client/components/MarkCompleteButton.jsx"; import { TYPE_DEFS } from "@/modules/admin/components/courses/completionRequirementTypes"; import api from "@/utils/api.util"; @@ -46,9 +47,10 @@ const LessonDetails = () => { const { getLesson, lesson, lessonLoading, unitBlocked, unitBlockedInfo, resetLesson, - upsertWatchProgress, + upsertLessonProgress, upsertWatchProgress, markComplete, } = useLibrary(); const { tierMap, getTierCategories } = useClientTiers(); + const completedSessionRef = useRef(new Set()); const hasCompleted = lesson?.status === "completed"; const unit = lesson?.unit ?? null; @@ -66,6 +68,10 @@ const LessonDetails = () => { // Admin-configured completion requirement (null when nothing's set — default behavior). const requirementDef = lesson?.completion?.type ? TYPE_DEFS[lesson.completion.type] : null; const RequirementIcon = requirementDef?.icon; + // read_all_content (or unconfigured/default) → scroll-to-bottom tracking, below. + // watch_percent / manual_complete → their own dedicated triggers — same dispatch + // pattern as UnitList.jsx / UnitReader.jsx, brought here for standalone lessons. + const lessonCompletionType = lesson?.completion?.type ?? 'read_all_content'; useEffect(() => { getTierCategories(); @@ -74,6 +80,42 @@ const LessonDetails = () => { // eslint-disable-next-line react-hooks/exhaustive-deps }, [uuid]); + // ── Scroll progress (read_all_content) — mirrors UnitList.jsx/UnitReader.jsx's + // scrollY-based indicator, only meaningful for content-only standalone lessons. + const [scrollProgress, setScrollProgress] = useState(0); + + useEffect(() => { + setScrollProgress(0); + window.scrollTo(0, 0); + }, [uuid]); + + useEffect(() => { + const handleScroll = () => { + const scrollTop = window.scrollY; + const scrollHeight = document.documentElement.scrollHeight - window.innerHeight; + if (scrollHeight <= 0) { setScrollProgress(100); return; } + setScrollProgress(Math.round((scrollTop / scrollHeight) * 100)); + }; + window.addEventListener("scroll", handleScroll, { passive: true }); + return () => window.removeEventListener("scroll", handleScroll); + }, []); + + // ── Mark lesson completed when user scrolls to the bottom ───────────── + // Only fires in Task mode — outside of it, this standalone lesson page is a + // passive preview/reader with no automatic progress persistence (matches the + // "Task mode — progress is being tracked automatically" banner's promise: no + // banner, no tracking). + useEffect(() => { + if (!taskCtx?.has_task) return; + if (lessonCompletionType !== 'read_all_content') return; + if (scrollProgress < 100 || !lesson?.uuid || hasCourse) return; + if (completedSessionRef.current.has(lesson.uuid)) return; + if (hasCompleted) return; + completedSessionRef.current.add(lesson.uuid); + upsertLessonProgress(lesson.uuid, 'completed', unit?.uuid ?? null); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [scrollProgress]); + // watch_percent / watch_video / listen_audio — safe to always pass through, the // backend no-ops whichever type (if any) isn't configured on this lesson. const handleWatchProgress = useCallback((percent, meta) => { @@ -81,6 +123,12 @@ const LessonDetails = () => { upsertWatchProgress(lesson.uuid, unit?.uuid ?? null, percent, meta); }, [lesson, unit, upsertWatchProgress]); + // ── manual_complete trigger: learner clicks the Mark Complete button ──── + const handleMarkComplete = useCallback(async () => { + if (!lesson?.uuid) return; + await markComplete(lesson.uuid, unit?.uuid ?? null); + }, [lesson, unit, markComplete]); + const items = [ { label: "Home", icon: , to: `/dashboard` }, { label: "Lessons", to: `/lessons` }, @@ -112,6 +160,23 @@ const LessonDetails = () => { return (
+ + {/* Scroll progress indicator — Task mode only (matches the "progress is being + tracked automatically" banner's promise), read_all_content lessons only, + fixed edge-to-edge under the navbar, same style as the Course > Unit > + Lesson reader's top bar. */} + {taskCtx?.has_task && !hasCourse && !contentNotReady && lessonCompletionType === 'read_all_content' && ( +
+
= 100 ? 'bg-green-500' : 'bg-primary'}`} + style={{ width: `${scrollProgress}%` }} + /> +
+ )} +
{
) : (
- + + {taskCtx?.has_task && !lessonLoading && lesson && lessonCompletionType === 'manual_complete' && ( + + )}
)}
diff --git a/src/modules/client/pages/MyCompletedContent.jsx b/src/modules/client/pages/MyCompletedContent.jsx new file mode 100644 index 0000000..a1bdaab --- /dev/null +++ b/src/modules/client/pages/MyCompletedContent.jsx @@ -0,0 +1,206 @@ +import { useEffect, useState } from "react"; +import { useNavigate } from "react-router-dom"; +import { ArrowLeft, BookOpen, Layers, FileText, LockIcon, CheckCircle2 } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { Badge } from "@/components/ui/badge"; +import { Skeleton } from "@/components/ui/skeleton"; +import { Separator } from "@/components/ui/separator"; +import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs"; +import { useDateFormat } from "@/hooks/useDateFormat"; +import api from "@/utils/api.util"; + +// Polls in the background so items completed elsewhere (another tab, another +// device) show up here without a manual refresh — same convention as +// ClientNotificationContext's badge polling, kept local since only this page +// consumes it. +const POLL_INTERVAL = 30_000; + +const EMPTY = { courses: [], units: [], lessons: [] }; + +const EmptyState = ({ icon: Icon, label }) => ( +
+ +

Nothing completed yet

+

{label}

+
+); + +const RowSkeleton = () => ( +
+ +
+ + +
+
+); + +const CompletedRow = ({ icon: Icon, title, subtitle, completedAt, onClick }) => { + const { fmtDate } = useDateFormat(); + return ( +
+
+ +
+
+

{title}

+ {subtitle &&

{subtitle}

} +
+
+ + {completedAt ? fmtDate(completedAt) : "—"} + +
+
+ ); +}; + +export default function MyCompletedContent() { + const navigate = useNavigate(); + const [data, setData] = useState(EMPTY); + const [loading, setLoading] = useState(true); + + useEffect(() => { + let cancelled = false; + + const load = async (silent = false) => { + if (!silent) setLoading(true); + try { + const { data: res } = await api.get("/client/courses/completed"); + if (cancelled) return; + setData({ + courses: res.data?.courses ?? [], + units: res.data?.units ?? [], + lessons: res.data?.lessons ?? [], + }); + } catch { + // silent — keep last known state on a failed poll + } finally { + if (!silent) setLoading(false); + } + }; + + load(); + const interval = setInterval(() => load(true), POLL_INTERVAL); + return () => { cancelled = true; clearInterval(interval); }; + }, []); + + const total = data.courses.length + data.units.length + data.lessons.length; + + return ( +
+
+ +
+ +
+
+

Completed

+ + Only you + +
+

+ Every lesson, unit, and course you've finished. +

+
+
+ + + + + Courses {!loading && {data.courses.length}} + + + Units {!loading && {data.units.length}} + + + Lessons {!loading && {data.lessons.length}} + + + + +
+ {loading ? ( + [...Array(3)].map((_, i) => ) + ) : data.courses.length === 0 ? ( + + ) : ( + data.courses.map((c, i) => ( +
+ navigate(`/course/${c.course_id}`)} + /> + {i < data.courses.length - 1 && } +
+ )) + )} +
+
+ + +
+ {loading ? ( + [...Array(3)].map((_, i) => ) + ) : data.units.length === 0 ? ( + + ) : ( + data.units.map((u, i) => ( +
+ navigate(`/units/${u.uuid}`)} + /> + {i < data.units.length - 1 && } +
+ )) + )} +
+
+ + +
+ {loading ? ( + [...Array(3)].map((_, i) => ) + ) : data.lessons.length === 0 ? ( + + ) : ( + data.lessons.map((l, i) => ( +
+ navigate(`/lessons/${l.uuid}`)} + /> + {i < data.lessons.length - 1 && } +
+ )) + )} +
+
+
+ + {!loading && total === 0 && ( +

+ Nothing completed yet — your finished lessons, units, and courses will appear here in real time. +

+ )} + +
+
+ ); +} diff --git a/src/modules/client/pages/Profile.jsx b/src/modules/client/pages/Profile.jsx index 956a09c..fe3af3b 100644 --- a/src/modules/client/pages/Profile.jsx +++ b/src/modules/client/pages/Profile.jsx @@ -201,6 +201,9 @@ const ProfilePage = () => { const [pendingModalOpen, setPendingModalOpen] = useState(false); const [pendingModalCourse, setPendingModalCourse] = useState(null); + const [completedTotal, setCompletedTotal] = useState(0); + const [completedLoading, setCompletedLoading] = useState(false); + useEffect(() => { getProfile(); getAchievements(); @@ -217,6 +220,18 @@ const ProfilePage = () => { setInProgressCoursesLoading(false); } })(); + (async () => { + setCompletedLoading(true); + try { + const { data } = await api.get("/client/courses/completed"); + const counts = data.data?.counts ?? {}; + setCompletedTotal((counts.courses ?? 0) + (counts.units ?? 0) + (counts.lessons ?? 0)); + } catch { + // silent — empty state handles it + } finally { + setCompletedLoading(false); + } + })(); }, []); // ── Derived ──────────────────────────────────────────────────────────────── @@ -727,6 +742,28 @@ const ProfilePage = () => { + {/* Completed — live count, full list at /completed */} + + navigate("/completed")} + > +
+ + Completed + Only you +
+
+ {completedLoading ? ( + + ) : completedTotal > 0 ? ( + {completedTotal} + ) : null} + +
+
+
+
diff --git a/src/modules/client/pages/UnitReader.jsx b/src/modules/client/pages/UnitReader.jsx index 853f270..9dedd98 100644 --- a/src/modules/client/pages/UnitReader.jsx +++ b/src/modules/client/pages/UnitReader.jsx @@ -8,6 +8,7 @@ import { Sheet, SheetContent, SheetTrigger } from "@/components/ui/sheet"; import ResponsiveModal from "@/components/generic/ResponsiveModal"; import LessonBlock from "../components/LessonBlock.jsx"; import QuizBlock from "../components/blocks/QuizBlock.jsx"; +import MarkCompleteButton from "../components/MarkCompleteButton.jsx"; import { useLibrary } from "@/contexts/ClientLibraryContext"; import { PageMeta } from "@/contexts/MetadataContext"; import { Skeleton } from "@/components/ui/skeleton"; @@ -82,7 +83,7 @@ const UnitReader = () => { unitDetail, unitDetailLoading, unitBlocked, getUnitDetail, resetUnitDetail, lesson, lessonLoading, getLesson, resetLesson, quiz, quizLoading, getUnitQuiz, resetQuiz, submitUnitQuiz, saveUnitQuizDraft, - upsertLessonProgress, upsertWatchProgress, + upsertLessonProgress, upsertWatchProgress, markComplete, } = useLibrary(); // Tracks which lessons have been marked completed this session to avoid duplicate calls @@ -160,8 +161,15 @@ const UnitReader = () => { return () => window.removeEventListener("scroll", handleScroll); }, []); + // ── Lesson completion-trigger dispatch (mirrors UnitList.jsx) ────────── + // read_all_content (or unconfigured/default) → scroll-to-bottom, below. + // watch_percent / manual_complete → their own dedicated triggers — the scroll + // trigger must NOT also fire completion for those, so it's gated here. + const lessonCompletionType = lesson?.completion?.type ?? 'read_all_content'; + // ── Mark lesson completed when user scrolls to the bottom ───────────── useEffect(() => { + if (lessonCompletionType !== 'read_all_content') return; if (scrollProgress < 100 || !selectedLessonId || !lesson?.uuid) return; if (completedSessionRef.current.has(selectedLessonId)) return; const stub = lessons.find((l) => l.lesson_id === selectedLessonId); @@ -294,6 +302,12 @@ const UnitReader = () => { upsertWatchProgress(lesson.uuid, uuid, percent, meta); }, [lesson, uuid, upsertWatchProgress]); + // ── manual_complete trigger: learner clicks the Mark Complete button ──── + const handleMarkComplete = useCallback(async () => { + if (!lesson?.uuid) return; + await markComplete(lesson.uuid, uuid); + }, [lesson, uuid, markComplete]); + // ── Next content item ────────────────────────────────────────────────── const getNextContent = useCallback(() => { const idx = allContent.findIndex((item) => @@ -518,7 +532,20 @@ const UnitReader = () => { nextLabel={nextLabel} /> ) : ( - + <> + + {!lessonLoading && lesson && lessonCompletionType === 'manual_complete' && ( + + )} + )} diff --git a/src/modules/client/routes/ClientRoutes.jsx b/src/modules/client/routes/ClientRoutes.jsx index 5ad0238..9aed2c9 100644 --- a/src/modules/client/routes/ClientRoutes.jsx +++ b/src/modules/client/routes/ClientRoutes.jsx @@ -23,6 +23,7 @@ import ViewTask from '../pages/ViewTask' import CourseCheckout from '../pages/CourseCheckout' import MyCertificates from '../pages/MyCertificates' import MyAchievements from '../pages/MyAchievements' +import MyCompletedContent from '../pages/MyCompletedContent' import AccountSettings from '../pages/AccountSettings' import Notifications from '../pages/Notifications' import AdvertisementLandingPage from '../pages/AdvertisementLandingPage' @@ -65,6 +66,7 @@ export const ClientRoutes = { }, { path: 'certificates', element: }, { path: 'achievements', element: }, + { path: 'completed', element: }, { path: 'settings', element: }, { path: 'notifications', element: }, { path: 'ads/:uuid', element: },