mirror of
https://github.com/rgrgogu/new_starr.git
synced 2026-09-27 00:12:54 +08:00
add more
This commit is contained in:
@@ -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 ─────────────────────────────────────────────────────────────
|
// ─── Resets ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
const resetUnitDetail = useCallback(() => {
|
const resetUnitDetail = useCallback(() => {
|
||||||
@@ -222,6 +250,7 @@ export function ClientLibraryProvider({ children }) {
|
|||||||
saveUnitQuizDraft,
|
saveUnitQuizDraft,
|
||||||
upsertLessonProgress,
|
upsertLessonProgress,
|
||||||
upsertWatchProgress,
|
upsertWatchProgress,
|
||||||
|
markComplete,
|
||||||
|
|
||||||
resetUnitDetail,
|
resetUnitDetail,
|
||||||
resetLesson,
|
resetLesson,
|
||||||
|
|||||||
@@ -10,9 +10,12 @@
|
|||||||
***********************************************************************************************************************************************************************/
|
***********************************************************************************************************************************************************************/
|
||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { Navigate, useNavigate, Link } from 'react-router-dom'
|
import { Navigate, useNavigate, Link } from 'react-router-dom'
|
||||||
|
import { isValidPhoneNumber, parsePhoneNumber } from 'react-phone-number-input'
|
||||||
import { useAuth } from '@/contexts/AuthContext'
|
import { useAuth } from '@/contexts/AuthContext'
|
||||||
|
import { useDetectedCountry } from '@/hooks/useDetectedCountry'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Input } from '@/components/ui/input'
|
import { Input } from '@/components/ui/input'
|
||||||
|
import { PhoneInput } from '@/components/ui/phone-input'
|
||||||
import { Label } from '@/components/ui/label'
|
import { Label } from '@/components/ui/label'
|
||||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
|
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
|
||||||
import { Loader2 } from 'lucide-react'
|
import { Loader2 } from 'lucide-react'
|
||||||
@@ -47,11 +50,13 @@ export default function IntroPage() {
|
|||||||
const [dateOfBirth, setDateOfBirth] = useState(pi.date_of_birth ?? '')
|
const [dateOfBirth, setDateOfBirth] = useState(pi.date_of_birth ?? '')
|
||||||
const [occupation, setOccupation] = useState(pi.occupation ?? '')
|
const [occupation, setOccupation] = useState(pi.occupation ?? '')
|
||||||
const [phone, setPhone] = useState(
|
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 [loading, setLoading] = useState(false)
|
||||||
const [errors, setErrors] = useState({})
|
const [errors, setErrors] = useState({})
|
||||||
|
|
||||||
|
const detectedCountry = useDetectedCountry()
|
||||||
|
|
||||||
// ── Derived ────────────────────────────────────────────────────────────────
|
// ── Derived ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
const avatarUrl = pi.avatar?.url ?? ''
|
const avatarUrl = pi.avatar?.url ?? ''
|
||||||
@@ -71,7 +76,7 @@ export default function IntroPage() {
|
|||||||
}
|
}
|
||||||
if (!phone.trim()) {
|
if (!phone.trim()) {
|
||||||
e.phone = 'Phone number is required.'
|
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.'
|
e.phone = 'Invalid phone number.'
|
||||||
}
|
}
|
||||||
return e
|
return e
|
||||||
@@ -104,9 +109,13 @@ export default function IntroPage() {
|
|||||||
occupation,
|
occupation,
|
||||||
phone_number: phone.trim()
|
phone_number: phone.trim()
|
||||||
? (() => {
|
? (() => {
|
||||||
const digits = phone.replace(/\D/g, '')
|
const parsed = parsePhoneNumber(phone)
|
||||||
const number = digits.startsWith('63') ? digits.slice(2) : digits.replace(/^0/, '')
|
return [{
|
||||||
return [{ number, country_code: '+63', full_number: `+63${number}`, phone_type: 'mobile' }]
|
number: parsed.nationalNumber,
|
||||||
|
country_code: parsed.countryCallingCode,
|
||||||
|
full_number: `${parsed.countryCallingCode}${parsed.nationalNumber}`,
|
||||||
|
phone_type: 'mobile',
|
||||||
|
}]
|
||||||
})()
|
})()
|
||||||
: (pi.phone_number ?? []),
|
: (pi.phone_number ?? []),
|
||||||
// Preserve existing avatar and addresses
|
// Preserve existing avatar and addresses
|
||||||
@@ -245,11 +254,11 @@ export default function IntroPage() {
|
|||||||
<Label className="text-xs">
|
<Label className="text-xs">
|
||||||
Phone number <span className="text-destructive">*</span>
|
Phone number <span className="text-destructive">*</span>
|
||||||
</Label>
|
</Label>
|
||||||
<Input
|
<PhoneInput
|
||||||
type="tel"
|
|
||||||
value={phone}
|
value={phone}
|
||||||
onChange={e => setPhone(e.target.value)}
|
onChange={setPhone}
|
||||||
placeholder="+63 912 345 6789"
|
international
|
||||||
|
defaultCountry={detectedCountry}
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
/>
|
/>
|
||||||
{errors.phone && <p className="text-xs text-destructive">{errors.phone}</p>}
|
{errors.phone && <p className="text-xs text-destructive">{errors.phone}</p>}
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ import { useCourseReadingProgress } from "@/contexts/ClientCourseReadingProgress
|
|||||||
import { PageMeta } from "@/contexts/MetadataContext";
|
import { PageMeta } from "@/contexts/MetadataContext";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { resolveTierBadge } from "@/utils/tierBadge.util";
|
import { resolveTierBadge } from "@/utils/tierBadge.util";
|
||||||
import { getTierColor, getContrastText } from "@/utils/tierColors";
|
import { getTierColor } from "@/utils/tierColors";
|
||||||
import { useClientAdvertisements } from "@/contexts/ClientAdvertisementContext";
|
import { useClientAdvertisements } from "@/contexts/ClientAdvertisementContext";
|
||||||
import { Banner, BannerSkeleton } from "@/components/generic/Blocks/Client/Advertisements/Banner";
|
import { Banner, BannerSkeleton } from "@/components/generic/Blocks/Client/Advertisements/Banner";
|
||||||
import { Tags } from "lucide-react";
|
import { Tags } from "lucide-react";
|
||||||
@@ -781,15 +781,15 @@ const CourseDetails = () => {
|
|||||||
{course.prerequisites.map((p) => {
|
{course.prerequisites.map((p) => {
|
||||||
// Guarded = the prerequisite itself sits behind a paid tier —
|
// Guarded = the prerequisite itself sits behind a paid tier —
|
||||||
// paint the whole row with that tier's actual admin-configured
|
// paint the whole row with that tier's actual admin-configured
|
||||||
// color (Tier Categories → Color) instead of a plain gray row.
|
// color (Tier Categories → Color). Reuses the exact same
|
||||||
// Uses the swatch hex directly (inline style) rather than a
|
// gradient classes the tier Badge renders with (tierColors.js
|
||||||
// dynamic Tailwind class, since the color key is admin-defined
|
// #badge) instead of a flat swatch fill — those strings are
|
||||||
// at runtime and arbitrary bg-{key}-500 classes aren't
|
// hardcoded per color key in tierColors.js so they survive
|
||||||
// guaranteed to survive Tailwind's build-time purge.
|
// Tailwind's build-time purge, unlike a runtime-composed
|
||||||
|
// bg-{key}-500 class would.
|
||||||
const tierInfo = p.subscription ? tierMap[p.subscription] : null;
|
const tierInfo = p.subscription ? tierMap[p.subscription] : null;
|
||||||
const guarded = (tierInfo?.rank ?? 0) > 0 && !p.completed;
|
const guarded = (tierInfo?.rank ?? 0) > 0 && !p.completed;
|
||||||
const tierColor = guarded ? getTierColor(tierInfo.color) : null;
|
const tierColor = guarded ? getTierColor(tierInfo.color) : null;
|
||||||
const textColor = tierColor ? getContrastText(tierColor.swatch, tierInfo.color) : null;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -797,9 +797,9 @@ const CourseDetails = () => {
|
|||||||
className={cn(
|
className={cn(
|
||||||
"flex items-center justify-between gap-3 py-3 px-3 rounded-lg",
|
"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 && "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}
|
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-3 min-w-0">
|
<div className="flex items-center gap-3 min-w-0">
|
||||||
{p.completed ? (
|
{p.completed ? (
|
||||||
@@ -808,13 +808,15 @@ const CourseDetails = () => {
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div
|
<div
|
||||||
className="size-4 rounded-full border-2 shrink-0"
|
className={cn("size-4 rounded-full border-2 shrink-0", guarded && "border-white/70")}
|
||||||
style={{ borderColor: guarded ? textColor : undefined }}
|
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<span
|
<span
|
||||||
className={cn("font-medium truncate", !p.completed && !guarded && "text-muted-foreground")}
|
className={cn(
|
||||||
style={{ color: guarded ? textColor : undefined }}
|
"font-medium truncate",
|
||||||
|
!p.completed && !guarded && "text-muted-foreground",
|
||||||
|
guarded && "text-white"
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
{p.title ?? "—"}
|
{p.title ?? "—"}
|
||||||
</span>
|
</span>
|
||||||
@@ -825,9 +827,10 @@ const CourseDetails = () => {
|
|||||||
"shrink-0 border-0 gap-1",
|
"shrink-0 border-0 gap-1",
|
||||||
p.completed
|
p.completed
|
||||||
? "bg-emerald-100 text-emerald-700 dark:bg-emerald-900/40 dark:text-emerald-400"
|
? "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
|
{p.completed
|
||||||
? <><CheckCheck className="size-3.5" /> Completed</>
|
? <><CheckCheck className="size-3.5" /> Completed</>
|
||||||
|
|||||||
@@ -5,12 +5,13 @@ import {
|
|||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { Skeleton } from "@/components/ui/skeleton";
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
import { Button } from "@/components/ui/button";
|
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 { useLibrary } from "@/contexts/ClientLibraryContext";
|
||||||
import { useClientTiers } from "@/contexts/ClientTiersProvider";
|
import { useClientTiers } from "@/contexts/ClientTiersProvider";
|
||||||
import { PageMeta } from "@/contexts/MetadataContext";
|
import { PageMeta } from "@/contexts/MetadataContext";
|
||||||
import LockedContentPanel from "@/modules/client/components/LockedContentPanel";
|
import LockedContentPanel from "@/modules/client/components/LockedContentPanel";
|
||||||
import LessonBlock from "../components/LessonBlock.jsx";
|
import LessonBlock from "../components/LessonBlock.jsx";
|
||||||
|
import MarkCompleteButton from "@/modules/client/components/MarkCompleteButton.jsx";
|
||||||
import { TYPE_DEFS } from "@/modules/admin/components/courses/completionRequirementTypes";
|
import { TYPE_DEFS } from "@/modules/admin/components/courses/completionRequirementTypes";
|
||||||
import api from "@/utils/api.util";
|
import api from "@/utils/api.util";
|
||||||
|
|
||||||
@@ -46,9 +47,10 @@ const LessonDetails = () => {
|
|||||||
|
|
||||||
const {
|
const {
|
||||||
getLesson, lesson, lessonLoading, unitBlocked, unitBlockedInfo, resetLesson,
|
getLesson, lesson, lessonLoading, unitBlocked, unitBlockedInfo, resetLesson,
|
||||||
upsertWatchProgress,
|
upsertLessonProgress, upsertWatchProgress, markComplete,
|
||||||
} = useLibrary();
|
} = useLibrary();
|
||||||
const { tierMap, getTierCategories } = useClientTiers();
|
const { tierMap, getTierCategories } = useClientTiers();
|
||||||
|
const completedSessionRef = useRef(new Set());
|
||||||
|
|
||||||
const hasCompleted = lesson?.status === "completed";
|
const hasCompleted = lesson?.status === "completed";
|
||||||
const unit = lesson?.unit ?? null;
|
const unit = lesson?.unit ?? null;
|
||||||
@@ -66,6 +68,10 @@ const LessonDetails = () => {
|
|||||||
// Admin-configured completion requirement (null when nothing's set — default behavior).
|
// Admin-configured completion requirement (null when nothing's set — default behavior).
|
||||||
const requirementDef = lesson?.completion?.type ? TYPE_DEFS[lesson.completion.type] : null;
|
const requirementDef = lesson?.completion?.type ? TYPE_DEFS[lesson.completion.type] : null;
|
||||||
const RequirementIcon = requirementDef?.icon;
|
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(() => {
|
useEffect(() => {
|
||||||
getTierCategories();
|
getTierCategories();
|
||||||
@@ -74,6 +80,42 @@ const LessonDetails = () => {
|
|||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [uuid]);
|
}, [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
|
// 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.
|
// backend no-ops whichever type (if any) isn't configured on this lesson.
|
||||||
const handleWatchProgress = useCallback((percent, meta) => {
|
const handleWatchProgress = useCallback((percent, meta) => {
|
||||||
@@ -81,6 +123,12 @@ const LessonDetails = () => {
|
|||||||
upsertWatchProgress(lesson.uuid, unit?.uuid ?? null, percent, meta);
|
upsertWatchProgress(lesson.uuid, unit?.uuid ?? null, percent, meta);
|
||||||
}, [lesson, unit, upsertWatchProgress]);
|
}, [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 = [
|
const items = [
|
||||||
{ label: "Home", icon: <House className="size-4" />, to: `/dashboard` },
|
{ label: "Home", icon: <House className="size-4" />, to: `/dashboard` },
|
||||||
{ label: "Lessons", to: `/lessons` },
|
{ label: "Lessons", to: `/lessons` },
|
||||||
@@ -112,6 +160,23 @@ const LessonDetails = () => {
|
|||||||
return (
|
return (
|
||||||
<div className="flex-1 flex flex-col">
|
<div className="flex-1 flex flex-col">
|
||||||
<PageMeta title={`${lesson.title} - STARR`} description={lesson.description} />
|
<PageMeta title={`${lesson.title} - STARR`} description={lesson.description} />
|
||||||
|
|
||||||
|
{/* 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' && (
|
||||||
|
<div
|
||||||
|
className="fixed left-0 right-0 z-30 h-1.5 bg-border"
|
||||||
|
style={{ top: "var(--navbar-h)" }}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={`h-full transition-all duration-150 ease-out ${scrollProgress >= 100 ? 'bg-green-500' : 'bg-primary'}`}
|
||||||
|
style={{ width: `${scrollProgress}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div
|
<div
|
||||||
className="flex-1 flex flex-col"
|
className="flex-1 flex flex-col"
|
||||||
style={{ paddingTop: "var(--navbar-h)" }}
|
style={{ paddingTop: "var(--navbar-h)" }}
|
||||||
@@ -197,7 +262,19 @@ const LessonDetails = () => {
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="w-full">
|
<div className="w-full">
|
||||||
<LessonBlock lesson={lesson} loading={lessonLoading} showHeader={false} onWatchProgress={handleWatchProgress} />
|
<LessonBlock
|
||||||
|
lesson={lesson}
|
||||||
|
loading={lessonLoading}
|
||||||
|
showHeader={false}
|
||||||
|
onWatchProgress={['watch_percent', 'watch_video', 'listen_audio'].includes(lessonCompletionType) ? handleWatchProgress : undefined}
|
||||||
|
/>
|
||||||
|
{taskCtx?.has_task && !lessonLoading && lesson && lessonCompletionType === 'manual_complete' && (
|
||||||
|
<MarkCompleteButton
|
||||||
|
label={lesson?.completion?.button_label}
|
||||||
|
completed={hasCompleted}
|
||||||
|
onMarkComplete={handleMarkComplete}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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 }) => (
|
||||||
|
<div className="flex flex-col items-center justify-center py-20 text-center">
|
||||||
|
<Icon className="h-10 w-10 text-muted-foreground/40 mb-3" />
|
||||||
|
<p className="text-sm font-medium">Nothing completed yet</p>
|
||||||
|
<p className="text-xs text-muted-foreground">{label}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
const RowSkeleton = () => (
|
||||||
|
<div className="flex items-center gap-4 p-4">
|
||||||
|
<Skeleton className="h-9 w-9 rounded-full shrink-0" />
|
||||||
|
<div className="space-y-1.5 flex-1">
|
||||||
|
<Skeleton className="h-3.5 w-48" />
|
||||||
|
<Skeleton className="h-3 w-32" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
const CompletedRow = ({ icon: Icon, title, subtitle, completedAt, onClick }) => {
|
||||||
|
const { fmtDate } = useDateFormat();
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="flex items-center gap-4 p-4 cursor-pointer hover:bg-accent/40 transition-colors"
|
||||||
|
onClick={onClick}
|
||||||
|
>
|
||||||
|
<div className="h-9 w-9 rounded-full bg-green-100 dark:bg-green-900/30 flex items-center justify-center shrink-0">
|
||||||
|
<Icon className="size-4 text-green-700 dark:text-green-400" />
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="text-sm font-medium truncate">{title}</p>
|
||||||
|
{subtitle && <p className="text-xs text-muted-foreground truncate mt-0.5">{subtitle}</p>}
|
||||||
|
</div>
|
||||||
|
<div className="shrink-0 text-right">
|
||||||
|
<Badge variant="outline" className="gap-1 text-xs">
|
||||||
|
<CheckCircle2 className="size-3" /> {completedAt ? fmtDate(completedAt) : "—"}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<section className="mt-17 bg-muted min-h-full">
|
||||||
|
<div className="p-6 lg:container lg:max-w-3xl lg:mx-auto space-y-5">
|
||||||
|
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Button type="button" variant="ghost" size="icon" onClick={() => navigate("/profile")}>
|
||||||
|
<ArrowLeft className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<h1 className="text-xl font-semibold">Completed</h1>
|
||||||
|
<Badge variant="outline" className="gap-1 text-xs">
|
||||||
|
<LockIcon className="h-3 w-3" /> Only you
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Every lesson, unit, and course you've finished.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Tabs defaultValue="courses" className="w-full">
|
||||||
|
<TabsList>
|
||||||
|
<TabsTrigger value="courses">
|
||||||
|
Courses {!loading && <Badge variant="secondary" className="ml-1.5">{data.courses.length}</Badge>}
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="units">
|
||||||
|
Units {!loading && <Badge variant="secondary" className="ml-1.5">{data.units.length}</Badge>}
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="lessons">
|
||||||
|
Lessons {!loading && <Badge variant="secondary" className="ml-1.5">{data.lessons.length}</Badge>}
|
||||||
|
</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
|
||||||
|
<TabsContent value="courses">
|
||||||
|
<div className="rounded-xl border bg-card divide-y">
|
||||||
|
{loading ? (
|
||||||
|
[...Array(3)].map((_, i) => <RowSkeleton key={i} />)
|
||||||
|
) : data.courses.length === 0 ? (
|
||||||
|
<EmptyState icon={BookOpen} label="Finish a course to see it here." />
|
||||||
|
) : (
|
||||||
|
data.courses.map((c, i) => (
|
||||||
|
<div key={c.course_id}>
|
||||||
|
<CompletedRow
|
||||||
|
icon={BookOpen}
|
||||||
|
title={c.title}
|
||||||
|
subtitle={c.certificate ? `Certificate ${c.certificate.cert_no}` : null}
|
||||||
|
completedAt={c.completed_at}
|
||||||
|
onClick={() => navigate(`/course/${c.course_id}`)}
|
||||||
|
/>
|
||||||
|
{i < data.courses.length - 1 && <Separator />}
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="units">
|
||||||
|
<div className="rounded-xl border bg-card divide-y">
|
||||||
|
{loading ? (
|
||||||
|
[...Array(3)].map((_, i) => <RowSkeleton key={i} />)
|
||||||
|
) : data.units.length === 0 ? (
|
||||||
|
<EmptyState icon={Layers} label="Finish a unit to see it here." />
|
||||||
|
) : (
|
||||||
|
data.units.map((u, i) => (
|
||||||
|
<div key={u.uuid}>
|
||||||
|
<CompletedRow
|
||||||
|
icon={Layers}
|
||||||
|
title={u.title}
|
||||||
|
subtitle={u.course ? u.course.title : "Standalone"}
|
||||||
|
completedAt={u.completed_at}
|
||||||
|
onClick={() => navigate(`/units/${u.uuid}`)}
|
||||||
|
/>
|
||||||
|
{i < data.units.length - 1 && <Separator />}
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="lessons">
|
||||||
|
<div className="rounded-xl border bg-card divide-y">
|
||||||
|
{loading ? (
|
||||||
|
[...Array(3)].map((_, i) => <RowSkeleton key={i} />)
|
||||||
|
) : data.lessons.length === 0 ? (
|
||||||
|
<EmptyState icon={FileText} label="Finish a lesson to see it here." />
|
||||||
|
) : (
|
||||||
|
data.lessons.map((l, i) => (
|
||||||
|
<div key={l.uuid}>
|
||||||
|
<CompletedRow
|
||||||
|
icon={FileText}
|
||||||
|
title={l.title}
|
||||||
|
subtitle={l.course ? l.course.title : "Standalone"}
|
||||||
|
completedAt={l.completed_at}
|
||||||
|
onClick={() => navigate(`/lessons/${l.uuid}`)}
|
||||||
|
/>
|
||||||
|
{i < data.lessons.length - 1 && <Separator />}
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</TabsContent>
|
||||||
|
</Tabs>
|
||||||
|
|
||||||
|
{!loading && total === 0 && (
|
||||||
|
<p className="text-center text-xs text-muted-foreground">
|
||||||
|
Nothing completed yet — your finished lessons, units, and courses will appear here in real time.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -201,6 +201,9 @@ const ProfilePage = () => {
|
|||||||
const [pendingModalOpen, setPendingModalOpen] = useState(false);
|
const [pendingModalOpen, setPendingModalOpen] = useState(false);
|
||||||
const [pendingModalCourse, setPendingModalCourse] = useState(null);
|
const [pendingModalCourse, setPendingModalCourse] = useState(null);
|
||||||
|
|
||||||
|
const [completedTotal, setCompletedTotal] = useState(0);
|
||||||
|
const [completedLoading, setCompletedLoading] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
getProfile();
|
getProfile();
|
||||||
getAchievements();
|
getAchievements();
|
||||||
@@ -217,6 +220,18 @@ const ProfilePage = () => {
|
|||||||
setInProgressCoursesLoading(false);
|
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 ────────────────────────────────────────────────────────────────
|
// ── Derived ────────────────────────────────────────────────────────────────
|
||||||
@@ -727,6 +742,28 @@ const ProfilePage = () => {
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
{/* Completed — live count, full list at /completed */}
|
||||||
|
<Card>
|
||||||
|
<CardContent
|
||||||
|
className="flex items-center justify-between cursor-pointer hover:bg-accent/40 transition-colors rounded-lg"
|
||||||
|
onClick={() => navigate("/completed")}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<BookOpen className="h-4 w-4 text-muted-foreground" />
|
||||||
|
<span className="text-sm font-medium">Completed</span>
|
||||||
|
<Badge><LockIcon className="size-3" /> Only you</Badge>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{completedLoading ? (
|
||||||
|
<Skeleton className="h-5 w-5 rounded-full" />
|
||||||
|
) : completedTotal > 0 ? (
|
||||||
|
<Badge variant="secondary" className="text-xs">{completedTotal}</Badge>
|
||||||
|
) : null}
|
||||||
|
<ChevronRight className="h-4 w-4 text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { Sheet, SheetContent, SheetTrigger } from "@/components/ui/sheet";
|
|||||||
import ResponsiveModal from "@/components/generic/ResponsiveModal";
|
import ResponsiveModal from "@/components/generic/ResponsiveModal";
|
||||||
import LessonBlock from "../components/LessonBlock.jsx";
|
import LessonBlock from "../components/LessonBlock.jsx";
|
||||||
import QuizBlock from "../components/blocks/QuizBlock.jsx";
|
import QuizBlock from "../components/blocks/QuizBlock.jsx";
|
||||||
|
import MarkCompleteButton from "../components/MarkCompleteButton.jsx";
|
||||||
import { useLibrary } from "@/contexts/ClientLibraryContext";
|
import { useLibrary } from "@/contexts/ClientLibraryContext";
|
||||||
import { PageMeta } from "@/contexts/MetadataContext";
|
import { PageMeta } from "@/contexts/MetadataContext";
|
||||||
import { Skeleton } from "@/components/ui/skeleton";
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
@@ -82,7 +83,7 @@ const UnitReader = () => {
|
|||||||
unitDetail, unitDetailLoading, unitBlocked, getUnitDetail, resetUnitDetail,
|
unitDetail, unitDetailLoading, unitBlocked, getUnitDetail, resetUnitDetail,
|
||||||
lesson, lessonLoading, getLesson, resetLesson,
|
lesson, lessonLoading, getLesson, resetLesson,
|
||||||
quiz, quizLoading, getUnitQuiz, resetQuiz, submitUnitQuiz, saveUnitQuizDraft,
|
quiz, quizLoading, getUnitQuiz, resetQuiz, submitUnitQuiz, saveUnitQuizDraft,
|
||||||
upsertLessonProgress, upsertWatchProgress,
|
upsertLessonProgress, upsertWatchProgress, markComplete,
|
||||||
} = useLibrary();
|
} = useLibrary();
|
||||||
|
|
||||||
// Tracks which lessons have been marked completed this session to avoid duplicate calls
|
// Tracks which lessons have been marked completed this session to avoid duplicate calls
|
||||||
@@ -160,8 +161,15 @@ const UnitReader = () => {
|
|||||||
return () => window.removeEventListener("scroll", handleScroll);
|
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 ─────────────
|
// ── Mark lesson completed when user scrolls to the bottom ─────────────
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (lessonCompletionType !== 'read_all_content') return;
|
||||||
if (scrollProgress < 100 || !selectedLessonId || !lesson?.uuid) return;
|
if (scrollProgress < 100 || !selectedLessonId || !lesson?.uuid) return;
|
||||||
if (completedSessionRef.current.has(selectedLessonId)) return;
|
if (completedSessionRef.current.has(selectedLessonId)) return;
|
||||||
const stub = lessons.find((l) => l.lesson_id === selectedLessonId);
|
const stub = lessons.find((l) => l.lesson_id === selectedLessonId);
|
||||||
@@ -294,6 +302,12 @@ const UnitReader = () => {
|
|||||||
upsertWatchProgress(lesson.uuid, uuid, percent, meta);
|
upsertWatchProgress(lesson.uuid, uuid, percent, meta);
|
||||||
}, [lesson, uuid, upsertWatchProgress]);
|
}, [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 ──────────────────────────────────────────────────
|
// ── Next content item ──────────────────────────────────────────────────
|
||||||
const getNextContent = useCallback(() => {
|
const getNextContent = useCallback(() => {
|
||||||
const idx = allContent.findIndex((item) =>
|
const idx = allContent.findIndex((item) =>
|
||||||
@@ -518,7 +532,20 @@ const UnitReader = () => {
|
|||||||
nextLabel={nextLabel}
|
nextLabel={nextLabel}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<LessonBlock lesson={lesson} loading={lessonLoading} onWatchProgress={handleWatchProgress} />
|
<>
|
||||||
|
<LessonBlock
|
||||||
|
lesson={lesson}
|
||||||
|
loading={lessonLoading}
|
||||||
|
onWatchProgress={['watch_percent', 'watch_video', 'listen_audio'].includes(lessonCompletionType) ? handleWatchProgress : undefined}
|
||||||
|
/>
|
||||||
|
{!lessonLoading && lesson && lessonCompletionType === 'manual_complete' && (
|
||||||
|
<MarkCompleteButton
|
||||||
|
label={lesson?.completion?.button_label}
|
||||||
|
completed={lesson?.status === 'completed'}
|
||||||
|
onMarkComplete={handleMarkComplete}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import ViewTask from '../pages/ViewTask'
|
|||||||
import CourseCheckout from '../pages/CourseCheckout'
|
import CourseCheckout from '../pages/CourseCheckout'
|
||||||
import MyCertificates from '../pages/MyCertificates'
|
import MyCertificates from '../pages/MyCertificates'
|
||||||
import MyAchievements from '../pages/MyAchievements'
|
import MyAchievements from '../pages/MyAchievements'
|
||||||
|
import MyCompletedContent from '../pages/MyCompletedContent'
|
||||||
import AccountSettings from '../pages/AccountSettings'
|
import AccountSettings from '../pages/AccountSettings'
|
||||||
import Notifications from '../pages/Notifications'
|
import Notifications from '../pages/Notifications'
|
||||||
import AdvertisementLandingPage from '../pages/AdvertisementLandingPage'
|
import AdvertisementLandingPage from '../pages/AdvertisementLandingPage'
|
||||||
@@ -65,6 +66,7 @@ export const ClientRoutes = {
|
|||||||
},
|
},
|
||||||
{ path: 'certificates', element: <MyCertificates /> },
|
{ path: 'certificates', element: <MyCertificates /> },
|
||||||
{ path: 'achievements', element: <MyAchievements /> },
|
{ path: 'achievements', element: <MyAchievements /> },
|
||||||
|
{ path: 'completed', element: <MyCompletedContent /> },
|
||||||
{ path: 'settings', element: <AccountSettings /> },
|
{ path: 'settings', element: <AccountSettings /> },
|
||||||
{ path: 'notifications', element: <Notifications /> },
|
{ path: 'notifications', element: <Notifications /> },
|
||||||
{ path: 'ads/:uuid', element: <AdvertisementLandingPage /> },
|
{ path: 'ads/:uuid', element: <AdvertisementLandingPage /> },
|
||||||
|
|||||||
Reference in New Issue
Block a user