mirror of
https://github.com/rgrgogu/new_starr.git
synced 2026-09-27 00:12:54 +08:00
683 lines
32 KiB
React
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; |