Files
starr-philproperties/src/modules/client/pages/Profile.jsx
T

784 lines
36 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 * as LucideIcons from "lucide-react";
import { getTierColor } from "@/utils/tierColors";
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 { useDateFormat } from "@/hooks/useDateFormat";
import api from "@/utils/api.util";
import { toast } from "sonner";
import CourseBadge from "@/modules/admin/components/courses/CourseBadge";
// ─── Tier badge fallbacks (used when no policy is configured in DB) ───────────
const TIER_BADGE_FALLBACKS = {
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_FALLBACK = {
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.",
};
// Renders either a Lucide icon badge or an image badge.
function TierBadgeDisplay({ badge, className }) {
if (badge?.src) return <img src={badge.src} alt={badge.label} className={className} />;
if (badge?.icon) {
const Icon = LucideIcons[badge.icon];
if (!Icon) return null;
const swatch = getTierColor(badge.colorKey ?? "green").swatch;
return <Icon className={className} style={{ color: swatch }} />;
}
return null;
}
function resolveTierBadge(myTier) {
const tier = myTier?.tier ?? "free";
const category = myTier?.plan?.category ?? myTier?.category ?? null;
if (category) {
const hasBadge = category.badgeAsset || category.badge_icon || category.badge_label;
if (hasBadge) {
return {
src: category.badgeAsset?.file_url ?? null,
icon: !category.badgeAsset ? (category.badge_icon ?? null) : null,
colorKey: category.color ?? "green",
label: category.badge_label ?? category.name ?? tier,
description: "",
information: category.description ?? "",
};
}
}
return TIER_BADGE_FALLBACKS[tier] ?? TIER_BADGE_FALLBACKS.free;
}
function resolveEarlyAccessBadge(systemBadges) {
const found = systemBadges?.find((b) => b.key === "early_access");
if (!found) return EARLY_ACCESS_FALLBACK;
return {
src: found.asset?.file_url ?? EARLY_ACCESS_FALLBACK.src,
label: found.label ?? EARLY_ACCESS_FALLBACK.label,
description: found.description ?? EARLY_ACCESS_FALLBACK.description,
information: found.information ?? EARLY_ACCESS_FALLBACK.information,
};
}
// ─── 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 LandscapeCertCard = ({ courseTitle, issuedAt, courseUuid, badgeColor, badgeImageUrl }) => {
const [downloading, setDownloading] = useState(false);
const { fmtDate } = useDateFormat();
const issuedLabel = issuedAt ? fmtDate(issuedAt) : "—";
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("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">
<CourseBadge mini color={badgeColor ?? "purple"} imageUrl={badgeImageUrl ?? null} />
<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 { fmtDate } = useDateFormat();
const {
profile, profileLoading, getProfile,
achievements, achievementsLoading, getAchievements,
fullName, avatarUrl, occupation,
uploadAvatar, deleteAvatar, avatarLoading,
} = useProfile();
const [avatarDialogOpen, setAvatarDialogOpen] = useState(false);
const { myTier, tierLoading, getMyTier, systemBadges, getSystemBadges } = useClientTiers();
const [badgeOpen, setBadgeOpen] = useState(false);
const [selectedBadge, setSelectedBadge] = useState(null);
const [badgeSrc, setBadgeSrc] = useState(null);
const [firstCertBadge, setFirstCertBadge] = useState({ color: "purple", imageUrl: null });
useEffect(() => {
const asset = myTier?.plan?.category?.badgeAsset ?? myTier?.category?.badgeAsset
if (!asset) { setBadgeSrc(null); return }
if (asset.storage_provider !== 's3') { setBadgeSrc(asset.file_url ?? null); return }
api.post('/client/media/token', { asset_id: asset.asset_id })
.then(({ data }) => {
const base = (import.meta.env.VITE_API_URL ?? '').replace(/\/$/, '')
setBadgeSrc(`${base}/client/media/stream/${data?.data?.token}`)
})
.catch(() => setBadgeSrc(null))
}, [myTier])
const [inProgressCourses, setInProgressCourses] = useState([]);
const [inProgressCoursesLoading, setInProgressCoursesLoading] = useState(false);
const [pendingModalOpen, setPendingModalOpen] = useState(false);
const [pendingModalCourse, setPendingModalCourse] = useState(null);
const [completedTotal, setCompletedTotal] = useState(0);
const [completedLoading, setCompletedLoading] = useState(false);
useEffect(() => {
getProfile();
getAchievements();
getMyTier();
getSystemBadges();
(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);
}
})();
(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 ────────────────────────────────────────────────────────────────
const tier = myTier?.tier ?? user?.tier ?? "free";
const tierBadge = { ...resolveTierBadge(myTier), ...(badgeSrc ? { src: badgeSrc } : {}) };
const earlyAccessBadge = resolveEarlyAccessBadge(systemBadges);
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_"));
// Fetch badge color/image for the first certificate shown in the profile preview
useEffect(() => {
if (!certAchievements.length) return;
const first = certAchievements[0];
const uuid = first.metadata?.courseUuid ?? first.key.replace("course_completed_", "");
if (!uuid) return;
const STREAM_BASE = `${import.meta.env.VITE_API_URL}/client/media/stream`;
api.get(`/client/courses/uuid/${uuid}`)
.then(({ data }) => {
const c = data.data ?? data;
if (!c) return;
if (c.badge_asset_id) {
api.post("/client/media/token", { asset_id: c.badge_asset_id })
.then(({ data: td }) => {
const token = td.data?.token;
setFirstCertBadge({
color: c.badge_color ?? "purple",
imageUrl: token ? `${STREAM_BASE}/${token}` : null,
});
})
.catch(() => setFirstCertBadge({ color: c.badge_color ?? "purple", imageUrl: null }));
} else {
setFirstCertBadge({ color: c.badge_color ?? "purple", imageUrl: c.badge_image_url ?? null });
}
})
.catch(() => {});
}, [certAchievements.length]);
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 ? fmtDate(achievement.granted_at) : null;
return { ...TIER_BADGE_FALLBACKS[tierKey], earnedAt };
};
const getEarlyAccessBadge = () => {
const achievement = achievements.find((a) => a.key === "early_access");
const earnedAt = achievement?.granted_at ? fmtDate(achievement.granted_at) : null;
return { ...earlyAccessBadge, 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 min-h-screen">
<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={earlyAccessBadge.src}
alt={earlyAccessBadge.label}
onClick={() => { setSelectedBadge(getEarlyAccessBadge()); setBadgeOpen(true); }}
/>
</TooltipTrigger>
<TooltipContent><p>{earlyAccessBadge.label}</p></TooltipContent>
</Tooltip>
)}
{/* Premium badge — only when currently exclusive */}
{hasPremiumBadge && (
<Tooltip>
<TooltipTrigger asChild>
<img
className="size-4.5 cursor-pointer"
src={TIER_BADGE_FALLBACKS.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_BADGE_FALLBACKS.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>
<span className="cursor-pointer" onClick={() => { setSelectedBadge(tierBadge); setBadgeOpen(true); }}>
<TierBadgeDisplay badge={tierBadge} className="size-4.5" />
</span>
</TooltipTrigger>
<TooltipContent><p>{tierBadge.label}</p></TooltipContent>
</Tooltip>
)}
{/* For premium tier without achievement yet (edge case) */}
{userRank === 1 && !hasPremiumBadge && (
<Tooltip>
<TooltipTrigger asChild>
<span className="cursor-pointer" onClick={() => { setSelectedBadge(getActiveTierBadgeWithDate()); setBadgeOpen(true); }}>
<TierBadgeDisplay badge={tierBadge} className="size-4.5" />
</span>
</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 || selectedBadge?.icon) && (
<TierBadgeDisplay badge={selectedBadge} 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, assessment_configured = true } = pendingModalCourse;
const hasQuizzes = pending_quizzes.length > 0;
const hasAssessment = !!pending_assessment;
if (!hasQuizzes && !hasAssessment && assessment_configured) {
return (
<p className="text-sm text-muted-foreground py-2">No pending items found.</p>
);
}
return (
<div className="space-y-4 py-1">
{!assessment_configured && (
<p className="text-sm text-muted-foreground">
You've finished all lessons, but this course's final assessment hasn't been set up yet.
Check back once it's available — the course can't be marked complete until then.
</p>
)}
{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 ? fmtDate(profile.createdAt) : "—"}
</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 ${fmtDate(myTier.expires_at)}`}
</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_", "")}
badgeColor={firstCertBadge.color}
badgeImageUrl={firstCertBadge.imageUrl}
/>
</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">
{fmtDate(item.granted_at)}
</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>
{/* 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>
<AvatarUploadDialog
open={avatarDialogOpen}
onClose={() => setAvatarDialogOpen(false)}
currentAvatarUrl={avatarUrl ?? ''}
initials={initials}
onUpload={uploadAvatar}
onDelete={deleteAvatar}
loading={avatarLoading}
/>
</div>
);
};
export default ProfilePage;