Files
starr-philproperties/src/modules/client/pages/Profile.jsx
T
kennethobsequio fbef7cb6e6 ready to test
Testing

Signed-off-by: Kenneth Obsequio <k80308392@gmail.com>
2026-06-22 10:07:20 +08:00

683 lines
32 KiB
React

import { useEffect, useState } from "react";
import { useNavigate } from "react-router-dom";
import {
Edit, BookOpen, Award, Trophy, Shield, Star, Zap, Target, BadgeCheck, Medal, Flame, LockIcon, Camera, ChevronRight, Download, RefreshCcw
} from "lucide-react";
import AvatarUploadDialog from "@/components/generic/AvatarUploadDialog";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Progress } from "@/components/ui/progress";
import { Separator } from "@/components/ui/separator";
import { Skeleton } from "@/components/ui/skeleton";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import ResponsiveModal from "@/components/generic/ResponsiveModal";
import { useProfile } from "@/contexts/ProfileProvider";
import { useAuth } from "@/contexts/AuthContext";
import { useClientTiers } from "@/contexts/ClientTiersProvider";
import api from "@/utils/api.util";
import { toast } from "sonner";
// ─── Tier badge config ────────────────────────────────────────────────────────
const TIER_BADGES = {
free: {
src: "/badges/free-access-badge-leaf-flaticon.svg",
label: "Free",
description: "Active member using the Free Plan.",
information: "Free users have access to the platform's core features without a subscription.",
},
premium: {
src: "/badges/premium-badge-sheriff-flaticon.svg",
label: "Premium",
description: "Subscriber with access to premium learning content.",
information: "Premium members can access all premium courses available on the platform.",
},
exclusive: {
src: "/badges/exclusive-award-flaticon.svg",
label: "Exclusive",
description: "Premium member with access to exclusive course content.",
information: "This badge represents a member with access to exclusive courses.",
},
};
const EARLY_ACCESS_BADGE = {
src: "/badges/early-access-badge-percent-flaticon.svg",
label: "Early Access",
description: "Registered during the Philproperties beta period.",
information: "Exclusive to members who registered before Dec 31, 2026.",
};
// ─── Achievement icon map (by key) ───────────────────────────────────────────
const ACHIEVEMENT_ICONS = {
early_access: Star,
premium_first_time: BadgeCheck,
exclusive_first_time: Medal,
first_course_completed: BookOpen,
courses_completed_5: Flame,
courses_completed_10: Zap,
perfect_quiz_score: Target,
profile_completed: Shield,
first_referral: Award,
};
const getFallbackIcon = (type) => type === "badge" ? BadgeCheck : Trophy;
// ─── Certificate landscape card (profile preview) ────────────────────────────
const CertBadgeIcon = ({ className }) => (
<svg className={className} viewBox="0 0 120 120" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="120" height="120" rx="26" fill="url(#cert-grad-p)" />
<rect x="30" y="32" width="60" height="16" rx="8" fill="white" fillOpacity="0.95" />
<rect x="22" y="54" width="76" height="16" rx="8" fill="white" fillOpacity="0.80" />
<rect x="17" y="76" width="86" height="14" rx="7" fill="white" fillOpacity="0.80" />
<defs>
<linearGradient id="cert-grad-p" x1="0" y1="0" x2="120" y2="120" gradientUnits="userSpaceOnUse">
<stop stopColor="#8B9FEE" />
<stop offset="1" stopColor="#4F6FD4" />
</linearGradient>
</defs>
</svg>
);
const LandscapeCertCard = ({ courseTitle, issuedAt, courseUuid }) => {
const [downloading, setDownloading] = useState(false);
const issuedLabel = issuedAt
? new Date(issuedAt).toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" })
: "—";
const handleDownload = async () => {
setDownloading(true);
try {
const res = await api.get(`/client/certificates/${courseUuid}`, { responseType: "blob" });
const disposition = res.headers["content-disposition"] ?? "";
const match = disposition.match(/filename="([^"]+)"/);
const filename = match ? match[1] : `certificate-${courseUuid}.pdf`;
const url = URL.createObjectURL(new Blob([res.data], { type: "application/pdf" }));
const a = document.createElement("a");
a.href = url;
a.download = filename;
a.click();
URL.revokeObjectURL(url);
} catch {
toast.error("Could not download certificate.");
} finally {
setDownloading(false);
}
};
return (
<div className="rounded-xl border bg-muted/40 p-3.5 flex items-center gap-3.5">
<CertBadgeIcon className="w-12 h-12 shrink-0" />
<div className="flex-1 min-w-0">
<p className="text-[10px] uppercase tracking-widest text-muted-foreground font-semibold">Certificate of Completion</p>
<p className="text-sm font-semibold truncate mt-0.5">{courseTitle}</p>
<div className="flex items-center gap-2 mt-1">
<span className="text-xs text-muted-foreground">Issued {issuedLabel}</span>
<Badge className="gap-1 text-[10px] py-0 bg-green-100 text-green-700 border-green-400 dark:bg-green-900/40 dark:text-green-400 dark:border-green-700">
<BadgeCheck className="size-2.5" /> Verified
</Badge>
</div>
</div>
<Button size="sm" variant="outline" className="shrink-0" onClick={handleDownload} disabled={downloading}>
<Download className="size-3.5" />
{downloading ? "…" : "PDF"}
</Button>
</div>
);
};
// ─── Component ───────────────────────────────────────────────────────────────
const ProfilePage = () => {
const navigate = useNavigate();
const { user } = useAuth();
const {
profile, profileLoading, getProfile,
achievements, achievementsLoading, getAchievements,
fullName, avatarUrl, occupation,
uploadAvatar, deleteAvatar, avatarLoading,
} = useProfile();
const [avatarDialogOpen, setAvatarDialogOpen] = useState(false);
const { myTier, tierLoading, getMyTier } = useClientTiers();
const [badgeOpen, setBadgeOpen] = useState(false);
const [selectedBadge, setSelectedBadge] = useState(null);
const [inProgressCourses, setInProgressCourses] = useState([]);
const [inProgressCoursesLoading, setInProgressCoursesLoading] = useState(false);
const [pendingModalOpen, setPendingModalOpen] = useState(false);
const [pendingModalCourse, setPendingModalCourse] = useState(null);
useEffect(() => {
getProfile();
getAchievements();
getMyTier();
(async () => {
setInProgressCoursesLoading(true);
try {
const { data } = await api.get("/client/courses/in-progress");
setInProgressCourses(data.data ?? []);
} catch {
// silent — empty state handles it
} finally {
setInProgressCoursesLoading(false);
}
})();
}, []);
// ── Derived ────────────────────────────────────────────────────────────────
const tier = myTier?.tier ?? user?.tier ?? "free";
const tierBadge = TIER_BADGES[tier] ?? TIER_BADGES.free;
const displayName = fullName || user?.personal_info?.name?.full_name || user?.email?.split("@")[0] || "—";
const initials = displayName.split(" ").map((w) => w[0]).join("").slice(0, 2).toUpperCase();
const tierRank = { free: 0, premium: 1, exclusive: 2 };
const userRank = tierRank[tier] ?? 0;
const certAchievements = achievements.filter((a) => a.key.startsWith("course_completed_"));
const hasEarlyAccess = achievements.some((a) => a.key === "early_access");
// Premium badge only shows when currently exclusive (went through premium to get here)
const hasPremiumBadge = achievements.some((a) => a.key === "premium_first_time") && userRank >= 2;
// Exclusive badge only shows when currently exclusive
const hasExclusiveBadge = achievements.some((a) => a.key === "exclusive_first_time") && userRank >= 2;
const badgesReady = !tierLoading && !achievementsLoading;
// Build badge object with earnedAt from achievements for modal
const getBadgeWithDate = (achievementKey, tierKey) => {
const achievement = achievements.find((a) => a.key === achievementKey);
const earnedAt = achievement?.granted_at
? new Date(achievement.granted_at).toLocaleDateString("en-US", {
month: "long", day: "numeric", year: "numeric",
})
: null;
return { ...TIER_BADGES[tierKey], earnedAt };
};
const getEarlyAccessBadge = () => {
const achievement = achievements.find((a) => a.key === "early_access");
const earnedAt = achievement?.granted_at
? new Date(achievement.granted_at).toLocaleDateString("en-US", {
month: "long", day: "numeric", year: "numeric",
})
: null;
return { ...EARLY_ACCESS_BADGE, earnedAt };
};
const getActiveTierBadgeWithDate = () => {
const achievementKey = tier === "premium"
? "premium_first_time"
: tier === "exclusive"
? "exclusive_first_time"
: null;
return achievementKey ? getBadgeWithDate(achievementKey, tier) : tierBadge;
};
return (
<div className="mt-17 bg-muted h-full">
<div className="p-6 lg:container lg:max-w-5xl lg:mx-auto space-y-4">
{/* ── Header card ──────────────────────────────────────────────────── */}
<Card>
<CardContent className="py-2 px-6">
<div className="flex items-center gap-5">
<div className="relative">
<Avatar className="h-20 w-20">
<AvatarImage src={avatarUrl || ""} />
<AvatarFallback className="text-lg">{initials}</AvatarFallback>
</Avatar>
<button
className="absolute -bottom-1 -right-1 p-1.5 rounded-full bg-primary text-primary-foreground shadow hover:opacity-80 transition-opacity"
onClick={() => setAvatarDialogOpen(true)}
>
<Camera size={11} />
</button>
</div>
<div className="flex-1">
<div className="flex items-center gap-2">
{profileLoading ? (
<Skeleton className="h-4 w-32" />
) : (
<p className="text-base font-medium">{displayName}</p>
)}
{/* ── Badges ─────────────────────────────────────────────── */}
{badgesReady ? (
<div className="flex items-center gap-1.5 select-none">
{/* Early access — always show if earned */}
{hasEarlyAccess && (
<Tooltip>
<TooltipTrigger asChild>
<img
className="size-4.5 cursor-pointer"
src={EARLY_ACCESS_BADGE.src}
alt={EARLY_ACCESS_BADGE.label}
onClick={() => { setSelectedBadge(getEarlyAccessBadge()); setBadgeOpen(true); }}
/>
</TooltipTrigger>
<TooltipContent><p>{EARLY_ACCESS_BADGE.label}</p></TooltipContent>
</Tooltip>
)}
{/* Premium badge — only when currently exclusive */}
{hasPremiumBadge && (
<Tooltip>
<TooltipTrigger asChild>
<img
className="size-4.5 cursor-pointer"
src={TIER_BADGES.premium.src}
alt="Premium"
onClick={() => { setSelectedBadge(getBadgeWithDate("premium_first_time", "premium")); setBadgeOpen(true); }}
/>
</TooltipTrigger>
<TooltipContent><p>Premium</p></TooltipContent>
</Tooltip>
)}
{/* Exclusive badge — only when currently exclusive */}
{hasExclusiveBadge && (
<Tooltip>
<TooltipTrigger asChild>
<img
className="size-4.5 cursor-pointer"
src={TIER_BADGES.exclusive.src}
alt="Exclusive"
onClick={() => { setSelectedBadge(getBadgeWithDate("exclusive_first_time", "exclusive")); setBadgeOpen(true); }}
/>
</TooltipTrigger>
<TooltipContent><p>Exclusive</p></TooltipContent>
</Tooltip>
)}
{/* Current active tier badge — always shown */}
{/* For free tier, show free badge. For premium/exclusive already shown above via hasPremiumBadge/hasExclusiveBadge */}
{userRank === 0 && (
<Tooltip>
<TooltipTrigger asChild>
<img
className="size-4.5 cursor-pointer"
src={tierBadge.src}
alt={tierBadge.label}
onClick={() => { setSelectedBadge(tierBadge); setBadgeOpen(true); }}
/>
</TooltipTrigger>
<TooltipContent><p>{tierBadge.label}</p></TooltipContent>
</Tooltip>
)}
{/* For premium tier without achievement yet (edge case) */}
{userRank === 1 && !hasPremiumBadge && (
<Tooltip>
<TooltipTrigger asChild>
<img
className="size-4.5 cursor-pointer"
src={tierBadge.src}
alt={tierBadge.label}
onClick={() => { setSelectedBadge(getActiveTierBadgeWithDate()); setBadgeOpen(true); }}
/>
</TooltipTrigger>
<TooltipContent><p>{tierBadge.label}</p></TooltipContent>
</Tooltip>
)}
</div>
) : (
<Skeleton className="h-4.5 w-16 rounded" />
)}
</div>
<p className="text-sm text-muted-foreground mb-2">{user?.email ?? "—"}</p>
<div className="flex items-center gap-2 flex-wrap">
{occupation && <Badge variant="outline">{occupation}</Badge>}
</div>
</div>
<Button variant="outline" size="sm" onClick={() => navigate("/profile/edit")}>
<Edit />
Edit profile
</Button>
</div>
</CardContent>
</Card>
{/* ── Badge modal ───────────────────────────────────────────────────── */}
<ResponsiveModal
open={badgeOpen}
onOpenChange={setBadgeOpen}
title="Badge"
description={selectedBadge?.description}
footer={
<Button variant="outline" onClick={() => setBadgeOpen(false)}>Close</Button>
}
>
<div className="flex flex-col items-center gap-4 py-2">
{selectedBadge?.src && (
<img src={selectedBadge.src} alt={selectedBadge.label} className="size-16" />
)}
<div className="text-center space-y-1">
<p className="text-sm font-medium">{selectedBadge?.label}</p>
{selectedBadge?.earnedAt && (
<p className="text-sm text-muted-foreground">{selectedBadge.earnedAt}</p>
)}
</div>
<Separator className="w-full" />
<p className="text-sm">{selectedBadge?.information}</p>
</div>
</ResponsiveModal>
{/* ── Two columns ──────────────────────────────────────────────────── */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{/* Left column */}
<div className="space-y-4">
{/* Learning progress */}
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium flex items-center gap-2">
<BookOpen className="size-4" />
Learning progress
<Badge><LockIcon className="size-3" /> Only you</Badge>
</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
{inProgressCoursesLoading ? (
<div className="space-y-3">
{[...Array(2)].map((_, i) => (
<div key={i} className="space-y-1.5">
<Skeleton className="h-3.5 w-40" />
<Skeleton className="h-1.5 w-full rounded-full" />
</div>
))}
</div>
) : inProgressCourses.length === 0 ? (
<p className="text-sm text-muted-foreground">No courses in progress.</p>
) : (
inProgressCourses.slice(0, 2).map((course, i) => {
const pct = course.lessons_total > 0
? Math.round((course.lessons_completed / course.lessons_total) * 100)
: 0;
const quizPending = course.reading_status === 'completed';
return (
<div key={course.course_id} className="space-y-2">
<div className="flex items-center justify-between mb-1.5">
<p className="text-sm font-medium leading-none truncate flex-1 mr-3">{course.title}</p>
<span className="text-sm text-muted-foreground shrink-0">{pct}%</span>
</div>
<Progress value={pct} className="h-1.5" />
{quizPending ? (
<Badge
className="select-none cursor-pointer"
variant="outline"
onClick={() => { setPendingModalCourse(course); setPendingModalOpen(true); }}
>
<RefreshCcw /> Quiz / Assessment Pending
</Badge>
) : (
<p className="text-xs text-muted-foreground mt-1">
{course.lessons_completed} / {course.lessons_total} lessons
</p>
)}
{i < Math.min(inProgressCourses.length, 2) - 1 && <Separator className="mt-3" />}
</div>
);
})
)}
</CardContent>
</Card>
{/* Quiz / Assessment Pending detail modal */}
<ResponsiveModal
open={pendingModalOpen}
onOpenChange={(v) => { setPendingModalOpen(v); if (!v) setPendingModalCourse(null); }}
title={pendingModalCourse?.title ?? 'Pending Items'}
description="You've finished reading all lessons. Complete the items below to earn your certificate."
hideDrawerClose={false}
footer={
<Button
className="w-full sm:w-auto"
onClick={() => {
setPendingModalOpen(false);
navigate(`/course/${pendingModalCourse?.course_id}/unit`);
}}
>
Go to Course
</Button>
}
>
{pendingModalCourse && (() => {
const { pending_quizzes = [], pending_assessment } = pendingModalCourse;
const hasQuizzes = pending_quizzes.length > 0;
const hasAssessment = !!pending_assessment;
if (!hasQuizzes && !hasAssessment) {
return (
<p className="text-sm text-muted-foreground py-2">No pending items found.</p>
);
}
return (
<div className="space-y-4 py-1">
{hasQuizzes && (
<div>
<p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground mb-2">Unit Quizzes</p>
<ul className="space-y-2">
{pending_quizzes.map((q) => (
<li key={q.quiz_id} className="flex items-start justify-between rounded-lg border px-3 py-2.5 gap-3">
<div className="min-w-0">
<p className="text-sm font-medium leading-none truncate">{q.title || 'Unit Quiz'}</p>
<p className="text-xs text-muted-foreground mt-0.5">{q.unit_title}</p>
</div>
<div className="shrink-0 text-right space-y-0.5">
<p className="text-xs text-muted-foreground">Pass: {q.passing_score}%</p>
<p className="text-xs text-muted-foreground">
{q.attempt_count === 0 ? 'Not attempted' : `${q.attempt_count} attempt${q.attempt_count === 1 ? '' : 's'}`}
</p>
{q.is_required && (
<span className="inline-block rounded-full bg-amber-100 dark:bg-amber-900/30 px-1.5 py-0.5 text-[10px] font-medium text-amber-700 dark:text-amber-400">
Required
</span>
)}
</div>
</li>
))}
</ul>
</div>
)}
{hasAssessment && (
<div>
<p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground mb-2">Final Assessment</p>
<div className="flex items-start justify-between rounded-lg border px-3 py-2.5 gap-3">
<div className="min-w-0">
<p className="text-sm font-medium leading-none truncate">{pending_assessment.title || 'Course Assessment'}</p>
</div>
<div className="shrink-0 text-right space-y-0.5">
<p className="text-xs text-muted-foreground">Pass: {pending_assessment.passing_score}%</p>
<p className="text-xs text-muted-foreground">
{pending_assessment.attempt_count === 0 ? 'Not attempted' : `${pending_assessment.attempt_count} attempt${pending_assessment.attempt_count === 1 ? '' : 's'}`}
</p>
{pending_assessment.is_required && (
<span className="inline-block rounded-full bg-amber-100 dark:bg-amber-900/30 px-1.5 py-0.5 text-[10px] font-medium text-amber-700 dark:text-amber-400">
Required
</span>
)}
</div>
</div>
</div>
)}
</div>
);
})()}
</ResponsiveModal>
{/* Account info */}
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium">Account info</CardTitle>
</CardHeader>
<CardContent className="space-y-3 text-sm">
<div className="flex justify-between">
<span className="text-muted-foreground">Member since</span>
<span className="font-medium">
{profile?.createdAt
? new Date(profile.createdAt).toLocaleDateString("en-US", { month: "long", year: "numeric" })
: "—"}
</span>
</div>
<Separator />
<div className="flex justify-between items-center">
<span className="text-muted-foreground">Plan</span>
{tierLoading ? (
<Skeleton className="h-5 w-20" />
) : (
<span className="font-medium capitalize">
{tier}{myTier?.expires_at && ` · until ${new Date(myTier.expires_at).toLocaleDateString("en-US", {
month: "short", day: "numeric", year: "numeric",
})}`}
</span>
)}
</div>
</CardContent>
</Card>
</div>
{/* Right column */}
<div className="space-y-4">
{/* Certificates — preview + full list at /certificates */}
<Card>
<CardContent
className="flex items-center justify-between cursor-pointer hover:bg-accent/40 transition-colors rounded-t-lg"
onClick={() => navigate("/certificates")}
>
<div className="flex items-center gap-2">
<Award className="h-4 w-4 text-muted-foreground" />
<span className="text-sm font-medium">My Certificates</span>
<Badge><LockIcon className="size-3" /> Only you</Badge>
</div>
<div className="flex items-center gap-2">
{achievementsLoading ? (
<Skeleton className="h-5 w-5 rounded-full" />
) : certAchievements.length > 0 ? (
<Badge variant="secondary" className="text-xs">{certAchievements.length}</Badge>
) : null}
<ChevronRight className="h-4 w-4 text-muted-foreground" />
</div>
</CardContent>
{!achievementsLoading && certAchievements.length > 0 && (
<CardContent className="pt-0">
<LandscapeCertCard
courseTitle={certAchievements[0].description}
issuedAt={certAchievements[0].granted_at}
courseUuid={certAchievements[0].metadata?.courseUuid ?? certAchievements[0].key.replace("course_completed_", "")}
/>
</CardContent>
)}
</Card>
{/* Achievements — latest 2, full list at /achievements */}
<Card>
<CardContent
className="flex items-center justify-between cursor-pointer hover:bg-accent/40 transition-colors rounded-t-lg"
onClick={() => navigate("/achievements")}
>
<div className="flex items-center gap-2">
<Trophy className="h-4 w-4 text-muted-foreground" />
<span className="text-sm font-medium">Achievements</span>
</div>
<div className="flex items-center gap-2">
{achievementsLoading ? (
<Skeleton className="h-5 w-5 rounded-full" />
) : achievements.length > 0 ? (
<Badge variant="secondary" className="text-xs">{achievements.length}</Badge>
) : null}
<ChevronRight className="h-4 w-4 text-muted-foreground" />
</div>
</CardContent>
<CardContent className="pt-0">
{achievementsLoading ? (
<div className="space-y-3">
{[...Array(2)].map((_, i) => (
<div key={i} className="flex items-center gap-3">
<Skeleton className="h-8 w-8 rounded-full" />
<div className="space-y-1 flex-1">
<Skeleton className="h-3 w-32" />
<Skeleton className="h-3 w-48" />
</div>
</div>
))}
</div>
) : achievements.length > 0 ? (() => {
const latest = [...achievements]
.sort((a, b) => new Date(b.granted_at) - new Date(a.granted_at))
.slice(0, 2);
return (
<div className="space-y-3">
{latest.map((item, i) => {
const Icon = ACHIEVEMENT_ICONS[item.key] ?? getFallbackIcon(item.type);
return (
<div key={item.achievement_id ?? i}>
<div className="flex items-center gap-3">
<div className="h-8 w-8 rounded-full bg-secondary flex items-center justify-center shrink-0">
<Icon className="size-4 text-secondary-foreground" />
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium">{item.label}</p>
<p className="text-xs text-muted-foreground">{item.description}</p>
{item.granted_at && (
<p className="text-xs mt-0.5">
{new Date(item.granted_at).toLocaleDateString("en-US", {
month: "long", day: "numeric", year: "numeric",
})}
</p>
)}
</div>
</div>
{i < latest.length - 1 && <Separator className="mt-3" />}
</div>
);
})}
</div>
);
})() : (
<div className="flex flex-col items-center justify-center py-8 gap-2 text-center">
<Shield className="h-8 w-8 text-muted-foreground/40" />
<p className="text-sm font-medium text-muted-foreground">Nothing to show yet</p>
<p className="text-xs text-muted-foreground/60">
Complete more courses to unlock achievements.
</p>
</div>
)}
</CardContent>
</Card>
</div>
</div>
</div>
<AvatarUploadDialog
open={avatarDialogOpen}
onClose={() => setAvatarDialogOpen(false)}
currentAvatarUrl={avatarUrl ?? ''}
initials={initials}
onUpload={uploadAvatar}
onDelete={deleteAvatar}
loading={avatarLoading}
/>
</div>
);
};
export default ProfilePage;