feat: add profile rank badges and guide

This commit is contained in:
2026-09-29 22:18:59 +03:30
parent bbf924c22d
commit 22cbc0b3fc
31 changed files with 785 additions and 68 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

View File

@@ -0,0 +1,11 @@
import type { Metadata } from "next";
import RankGuide from "@/views/RankGuide";
export const metadata: Metadata = {
title: "راهنمای رتبه و نشان‌ها",
description: "مسیرهای دریافت نشان و ارتقای سطح در انجمن علمی مهندسی کامپیوتر دانشگاه گیلان.",
};
export default function RankGuidePage() {
return <RankGuide />;
}

View File

@@ -0,0 +1,60 @@
import Image from "next/image";
import { Award } from "lucide-react";
import type { PublicGamificationBadgeSchema } from "@/lib/types";
import { getGamificationBadgeImagePath } from "@/lib/gamification-badges";
const categoryLabels: Record<PublicGamificationBadgeSchema["category"], string> = {
level: "سطح کلی",
attendance: "رویداد",
posts: "نویسندگی",
comments: "گفت‌وگو",
likes: "پسندها",
};
export function GamificationBadges({
badges,
className = "",
excludeIds = [],
compact = false,
}: {
badges: PublicGamificationBadgeSchema[];
className?: string;
excludeIds?: string[];
compact?: boolean;
}) {
const visibleBadges = badges.filter((badge) => !excludeIds.includes(badge.id));
if (!visibleBadges.length) {
return <p className="text-sm text-muted-foreground">هنوز نشانی دریافت نشده است.</p>;
}
return (
<ul className={`flex flex-wrap gap-3 ${className}`} aria-label="نشان‌های کسب‌شده">
{visibleBadges.map((badge) => {
const imagePath = getGamificationBadgeImagePath(badge.id);
return (
<li key={badge.id} className={`min-w-0 basis-[calc(50%-0.375rem)] ${compact ? "sm:basis-28" : "sm:basis-36"}`}>
<div className={`flex h-full flex-col items-center justify-center rounded-xl border border-border/70 bg-muted/15 px-2 text-center ${compact ? "min-h-24 gap-1 py-2" : "min-h-32 gap-1.5 py-3"}`}>
{imagePath ? (
<Image
src={imagePath}
alt=""
width={compact ? 56 : 72}
height={compact ? 56 : 72}
sizes={compact ? "56px" : "72px"}
className={compact ? "h-14 w-14 object-contain" : "h-[4.5rem] w-[4.5rem] object-contain"}
/>
) : (
<span className={`flex items-center justify-center text-primary ${compact ? "h-14 w-14" : "h-[4.5rem] w-[4.5rem]"}`}>
<Award className="h-9 w-9" aria-hidden="true" />
</span>
)}
<span className={`font-semibold leading-5 ${compact ? "text-xs" : "text-sm"}`}>{badge.label}</span>
<span className="text-xs text-muted-foreground">{categoryLabels[badge.category]}</span>
</div>
</li>
);
})}
</ul>
);
}

View File

@@ -0,0 +1,145 @@
import {
CalendarCheck2,
CircleHelp,
Heart,
MessageCircle,
Trophy,
} from "lucide-react";
import Image from "next/image";
import { GamificationBadges } from "@/components/profile/GamificationBadges";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Progress } from "@/components/ui/progress";
import { getGamificationBadgeImagePath, getRankBadgeId } from "@/lib/gamification-badges";
import { Link } from "@/lib/router";
import type { GamificationSummarySchema } from "@/lib/types";
import { formatNumberPersian } from "@/lib/utils";
function ScoreBreakdown({
icon: Icon,
label,
points,
}: {
icon: typeof CalendarCheck2;
label: string;
points: number;
}) {
return (
<div className="flex min-w-0 items-center justify-between gap-3 rounded-xl border border-border/70 bg-muted/20 px-4 py-3">
<div className="flex min-w-0 items-center gap-2 text-sm text-muted-foreground">
<Icon className="h-4 w-4 shrink-0 text-primary" aria-hidden="true" />
<span>{label}</span>
</div>
<span className="shrink-0 font-semibold text-foreground">{formatNumberPersian(points)}</span>
</div>
);
}
export function GamificationCard({
summary,
isLoading,
isError,
onRetry,
}: {
summary?: GamificationSummarySchema;
isLoading: boolean;
isError: boolean;
onRetry: () => void;
}) {
if (isLoading) {
return (
<Card className="border-border/70 shadow-sm">
<CardContent className="space-y-4 p-6" aria-busy="true" aria-label="در حال دریافت رتبه و نشان‌ها">
<div className="h-6 w-40 animate-pulse rounded bg-muted" />
<div className="h-10 w-full animate-pulse rounded bg-muted" />
<div className="grid gap-3 sm:grid-cols-2"><div className="h-12 animate-pulse rounded bg-muted" /><div className="h-12 animate-pulse rounded bg-muted" /></div>
</CardContent>
</Card>
);
}
if (isError || !summary) {
return (
<Card className="border-border/70 shadow-sm">
<CardContent className="flex flex-col items-start gap-3 p-6 text-right sm:flex-row sm:items-center sm:justify-between">
<div>
<p className="font-semibold">رتبه و نشان‌ها بارگذاری نشد</p>
<p className="mt-1 text-sm text-muted-foreground">برای دریافت دوباره اطلاعات تلاش کنید.</p>
</div>
<Button type="button" variant="outline" onClick={onRetry}>تلاش دوباره</Button>
</CardContent>
</Card>
);
}
const currentRankBadgeId = getRankBadgeId(summary.current_level.id);
const currentRankImage = getGamificationBadgeImagePath(currentRankBadgeId);
return (
<Card className="border-border/70 shadow-sm">
<CardHeader className="flex flex-row items-start justify-between gap-3 text-right">
<div className="space-y-1">
<CardTitle className="text-xl">رتبه و دستاوردهای من</CardTitle>
<CardDescription>سطح مادام‌العمر بر اساس مشارکت‌های معتبر در انجمن</CardDescription>
</div>
<Button asChild type="button" variant="outline" size="icon" className="h-10 w-10 shrink-0" aria-label="راهنمای رتبه‌بندی و نشان‌ها">
<Link to="/rank-guide" target="_blank" rel="noopener noreferrer" title="بازکردن راهنما در زبانه جدید">
<CircleHelp className="h-5 w-5" aria-hidden="true" />
</Link>
</Button>
</CardHeader>
<CardContent className="space-y-5">
<div className="flex flex-wrap items-center justify-between gap-4">
<div className="flex min-w-0 items-center gap-3">
{currentRankImage ? (
<Image src={currentRankImage} alt="" width={112} height={112} sizes="112px" className="h-24 w-24 shrink-0 object-contain sm:h-28 sm:w-28" />
) : null}
<div>
<p className="text-sm text-muted-foreground">سطح فعلی</p>
<p className="mt-1 text-2xl font-bold">{summary.current_level.label}</p>
</div>
</div>
<div className="inline-flex items-center gap-2 rounded-full bg-primary/10 px-3 py-2 text-sm font-semibold text-primary">
<Trophy className="h-4 w-4" aria-hidden="true" />
{formatNumberPersian(summary.points_total)} امتیاز
</div>
</div>
<div className="space-y-2">
{summary.next_level ? (
<div className="flex flex-wrap items-center justify-between gap-2 text-sm">
<span className="text-muted-foreground">پیشرفت تا سطح {summary.next_level.label}</span>
<span className="font-medium">{formatNumberPersian(summary.points_to_next)} امتیاز تا سطح بعدی</span>
</div>
) : (
<p className="text-sm text-muted-foreground">بالاترین سطح را دریافت کرده‌اید.</p>
)}
<Progress
value={summary.progress_percent}
aria-label="پیشرفت تا سطح بعدی"
aria-valuetext={`${formatNumberPersian(summary.progress_percent)} درصد`}
className="h-2.5 bg-muted"
/>
</div>
<div className="grid gap-3 sm:grid-cols-2">
<ScoreBreakdown icon={CalendarCheck2} label="رویدادها" points={summary.points_event} />
<ScoreBreakdown icon={MessageCircle} label="بلاگ" points={summary.points_blog} />
</div>
<section className="space-y-3 border-t border-border/70 pt-4">
<h3 className="flex items-center gap-2 text-sm font-semibold">
<Heart className="h-4 w-4 text-primary" aria-hidden="true" />نشان‌های کسب‌شده
</h3>
<GamificationBadges badges={summary.badges} excludeIds={[currentRankBadgeId]} />
</section>
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,51 @@
import Image from "next/image";
import { Trophy } from "lucide-react";
import { GamificationBadges } from "@/components/profile/GamificationBadges";
import {
getGamificationBadgeImagePath,
getLatestBadgePerCategory,
} from "@/lib/gamification-badges";
import type { PublicGamificationBadgeSchema } from "@/lib/types";
const activityCategories = ["attendance", "posts", "comments", "likes"] as const;
export function PublicProfileBadges({ badges }: { badges: PublicGamificationBadgeSchema[] }) {
const latestBadges = getLatestBadgePerCategory(badges);
const overallRank = latestBadges.level;
const activityBadges = activityCategories.flatMap((category) => {
const badge = latestBadges[category];
return badge ? [badge] : [];
});
const rankImage = overallRank ? getGamificationBadgeImagePath(overallRank.id) : undefined;
return (
<div className="space-y-5">
{overallRank ? (
<section
aria-label="رتبه کلی کسب‌شده"
className="relative isolate overflow-hidden rounded-2xl border border-amber-500/40 bg-gradient-to-bl from-amber-500/15 via-primary/10 to-cyan-500/10 p-4 shadow-sm"
>
<div aria-hidden="true" className="pointer-events-none absolute -left-8 -top-10 h-36 w-36 rounded-full bg-amber-300/15 blur-2xl" />
<div className="relative flex flex-col items-center gap-3 text-center min-[480px]:flex-row min-[480px]:text-right">
{rankImage ? (
<Image src={rankImage} alt="" width={128} height={128} sizes="128px" loading="eager" className="h-24 w-24 shrink-0 object-contain lg:h-28 lg:w-28" />
) : null}
<div className="min-w-0 min-[480px]:flex-1">
<p className="inline-flex max-w-full shrink-0 items-center gap-1.5 whitespace-nowrap rounded-full border border-amber-500/30 bg-amber-500/10 px-2.5 py-1 text-xs font-semibold text-amber-700 dark:text-amber-200">
<Trophy className="h-3.5 w-3.5" aria-hidden="true" />
رتبه کلی
</p>
<h3 className="mt-2 text-xl font-extrabold leading-7 sm:text-2xl">{overallRank.label}</h3>
<p className="mt-1 text-xs leading-5 text-muted-foreground">بالاترین سطح کلی کسب‌شده</p>
</div>
</div>
</section>
) : null}
<section aria-label="نشان‌ها" className="space-y-3">
<h3 className="text-sm font-semibold">نشان‌ها</h3>
<GamificationBadges badges={activityBadges} compact />
</section>
</div>
);
}

View File

@@ -155,3 +155,23 @@ html.dark a.home-hero-secondary:focus-visible {
border-color: hsl(var(--ring)) !important;
color: hsl(var(--secondary-foreground)) !important;
}
@media (max-width: 639px) {
main.px-4 {
padding-inline: 1rem;
}
.container.px-4 {
padding-inline: 1rem;
}
/* Let the page own the mobile gutter when it already has horizontal padding. */
main.px-4 > .container {
padding-inline: 0;
}
main.py-8,
.container.py-8 {
padding-block: 1.5rem;
}
}

View File

@@ -271,6 +271,12 @@ class ApiClient {
);
}
async getMyGamification() {
return this.request<Types.GamificationSummarySchema>("/api/gamification/me", {
method: "GET",
});
}
async updateProfile(data: Types.UserUpdateSchema) {
return this.request<Types.UserProfileSchema>('/api/auth/profile', {
method: 'PUT',

View File

@@ -0,0 +1,135 @@
import type { PublicGamificationBadgeSchema } from "@/lib/types";
export type BadgeCategory = PublicGamificationBadgeSchema["category"];
export interface RankLevelDefinition {
id: string;
label: string;
minimumPoints: number;
badgeId: string;
}
export interface ActivityBadgeDefinition {
id: string;
label: string;
threshold: number;
description: string;
}
export interface ActivityLadderDefinition {
category: Exclude<BadgeCategory, "level">;
title: string;
description: string;
unitLabel: string;
pointsPerAction: number;
badges: ActivityBadgeDefinition[];
}
export const rankLevels: RankLevelDefinition[] = [
{ id: "companion", label: "همراه", minimumPoints: 0, badgeId: "level-companion" },
{ id: "contributor", label: "مشارکت‌گر", minimumPoints: 30, badgeId: "level-contributor" },
{ id: "ally", label: "همیار", minimumPoints: 150, badgeId: "level-ally" },
{ id: "influencer", label: "اثرگذار", minimumPoints: 750, badgeId: "level-influencer" },
{ id: "pioneer", label: "پیشرو", minimumPoints: 1500, badgeId: "level-pioneer" },
];
export const activityLadders: ActivityLadderDefinition[] = [
{
category: "attendance",
title: "حضور در رویدادها",
description: "پس از آن‌که برگزارکننده حضور شما را تأیید کند، امتیاز و نشان این مسیر ثبت می‌شود.",
unitLabel: "حضور تأییدشده",
pointsPerAction: 30,
badges: [
{ id: "attendance-1", label: "حضور اول", threshold: 1, description: "حضور تأییدشده در یک رویداد" },
{ id: "attendance-5", label: "همراه رویدادها", threshold: 5, description: "حضور تأییدشده در پنج رویداد" },
{ id: "attendance-10", label: "همیار رویدادها", threshold: 10, description: "حضور تأییدشده در ده رویداد" },
{ id: "attendance-25", label: "چهره آشنای رویدادها", threshold: 25, description: "حضور تأییدشده در بیست‌وپنج رویداد" },
],
},
{
category: "posts",
title: "نوشته‌های منتشرشده",
description: "نوشته‌های منتشرشده و هم‌نویسندگی‌هایی که در انجمن ثبت شده‌اند حساب می‌شوند.",
unitLabel: "نوشته منتشرشده یا هم‌نویسندگی",
pointsPerAction: 20,
badges: [
{ id: "post-1", label: "نویسنده انجمن", threshold: 1, description: "انتشار یا هم‌نویسندگی یک نوشته" },
{ id: "post-5", label: "نویسنده فعال", threshold: 5, description: "انتشار یا هم‌نویسندگی پنج نوشته" },
{ id: "post-10", label: "نویسنده برجسته", threshold: 10, description: "انتشار یا هم‌نویسندگی ده نوشته" },
],
},
{
category: "comments",
title: "گفت‌وگوهای سازنده",
description: "دیدگاه‌ها و پاسخ‌های تأییدشده در گفت‌وگوهای بلاگ به این مسیر کمک می‌کنند.",
unitLabel: "دیدگاه یا پاسخ تأییدشده",
pointsPerAction: 5,
badges: [
{ id: "comment-5", label: "اهل گفت‌وگو", threshold: 5, description: "پنج دیدگاه یا پاسخ تأییدشده" },
{ id: "comment-20", label: "همراه گفت‌وگو", threshold: 20, description: "بیست دیدگاه یا پاسخ تأییدشده" },
{ id: "comment-50", label: "صدای اثرگذار", threshold: 50, description: "پنجاه دیدگاه یا پاسخ تأییدشده" },
],
},
{
category: "likes",
title: "پسندهای انجمن",
description: "پسندهایی که داده‌اید و پسندهایی که نوشته‌های شما گرفته‌اند با هم شمرده می‌شوند.",
unitLabel: "پسند داده‌شده یا دریافت‌شده",
pointsPerAction: 1,
badges: [
{ id: "likes-10", label: "دست‌گرم", threshold: 10, description: "ده پسند داده‌شده یا دریافت‌شده" },
{ id: "likes-50", label: "محبوب انجمن", threshold: 50, description: "پنجاه پسند داده‌شده یا دریافت‌شده" },
{ id: "likes-100", label: "الهام‌بخش", threshold: 100, description: "صد پسند داده‌شده یا دریافت‌شده" },
],
},
];
export const activityPoints = {
save: 1,
saveLabel: "ذخیره نوشته امتیاز دارد، اما نشان جداگانه ندارد.",
selfLikeLabel: "پسند نوشته خودتان هم برای شما حساب می‌شود.",
reversalLabel: "اگر فعالیتی حذف یا نامعتبر شود، امتیاز و نشان وابسته به آن هم بازبینی می‌شود.",
};
const badgeImagePaths: Record<string, string> = Object.fromEntries(
[
...rankLevels.map(({ badgeId }) => badgeId),
...activityLadders.flatMap(({ badges }) => badges.map(({ id }) => id)),
].map((id) => {
// The generated art uses a plural filename for the singular API comment IDs.
const filenameId = id.startsWith("comment-") ? id.replace("comment-", "comments-") : id;
return [id, `/images/gamification/badges/${filenameId}.png`];
}),
);
export function getGamificationBadgeImagePath(id: string): string | undefined {
return badgeImagePaths[id];
}
export function getRankBadgeId(levelId: string): string {
return `level-${levelId}`;
}
export function getLatestBadgePerCategory(
badges: PublicGamificationBadgeSchema[],
): Partial<Record<BadgeCategory, PublicGamificationBadgeSchema>> {
const latest: Partial<Record<BadgeCategory, PublicGamificationBadgeSchema>> = {};
const progressionFor = (badge: PublicGamificationBadgeSchema): number => {
if (badge.category === "level") {
return rankLevels.findIndex((level) => level.badgeId === badge.id);
}
const ladder = activityLadders.find(({ category }) => category === badge.category);
return ladder?.badges.find(({ id }) => id === badge.id)?.threshold ?? -1;
};
for (const badge of badges) {
const current = latest[badge.category];
if (!current || progressionFor(badge) > progressionFor(current)) {
latest[badge.category] = badge;
}
}
return latest;
}

View File

@@ -93,13 +93,63 @@ export interface PublicMemberProfileSchema {
bio: string;
details_visible: boolean;
mobile?: string | null;
email?: string | null;
student_id?: string | null;
year_of_study?: number | null;
university?: string | null;
major?: string | null;
badges: PublicGamificationBadgeSchema[];
events: PublicProfileEventSchema[];
}
export interface PublicGamificationBadgeSchema {
id: string;
category: "level" | "attendance" | "posts" | "comments" | "likes";
label: string;
}
export interface GamificationBadgeSchema extends PublicGamificationBadgeSchema {
description?: string | null;
threshold?: number | null;
}
export interface GamificationLevelSchema {
id: string;
label: string;
minimum_points: number;
}
export interface GamificationSummarySchema {
points_total: number;
points_event: number;
points_blog: number;
activity_counts: {
attendance: number;
posts: number;
comments: number;
likes: number;
};
levels: GamificationLevelSchema[];
points_per_activity: {
attendance: number;
post: number;
comment: number;
like: number;
save: number;
};
badge_thresholds: {
attendance: number[];
posts: number[];
comments: number[];
likes: number[];
};
current_level: GamificationLevelSchema;
next_level: GamificationLevelSchema | null;
points_to_next: number;
progress_percent: number;
badges: GamificationBadgeSchema[];
}
export interface UserListSchema {
id: number;
username: string;

View File

@@ -6,7 +6,6 @@ import Image from "next/image";
import {
Activity,
ArrowDownWideNarrow,
ArrowUpLeft,
ChevronLeft,
ChevronRight,
Bookmark,
@@ -26,6 +25,7 @@ import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { GamificationCard } from "@/components/profile/GamificationCard";
import { Label } from "@/components/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Helmet } from "@/lib/helmet";
@@ -162,7 +162,7 @@ function ProfileEventCard({ registration }: { registration: Types.MyEventRegistr
className="w-full shrink-0 overflow-hidden rounded-xl focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring sm:w-32"
aria-label={`مشاهده رویداد ${event.title}`}
>
<Image src={imageUrl || "/placeholder.svg"} alt="" width={160} height={120} unoptimized className="aspect-video h-auto w-full object-cover sm:aspect-[4/3] sm:h-full" />
<Image src={imageUrl} alt="" width={160} height={120} unoptimized className="aspect-video h-auto w-full object-cover sm:aspect-[4/3] sm:h-full" />
</Link>
<div className="flex min-w-0 flex-1 flex-col">
<Link to={`/events/${event.slug}`} className="break-words font-semibold text-foreground hover:text-primary focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring">
@@ -248,14 +248,9 @@ export default function Profile({ initialTab }: { initialTab?: string }) {
enabled: isAuthenticated,
initialData: user ?? undefined,
});
const registrationsQuery = useQuery({
queryKey: ["my-registrations"],
queryFn: () => api.getMyRegistrations(),
enabled: isAuthenticated && section === "overview",
});
const blogQuery = useQuery({
queryKey: ["my-blog-activity"],
queryFn: () => api.getMyBlogActivity(),
const gamificationQuery = useQuery({
queryKey: ["my-gamification"],
queryFn: () => api.getMyGamification(),
enabled: isAuthenticated && section === "overview",
});
const eventSortOption = eventSortItems.find(({ id }) => id === eventSort) ?? eventSortItems[0];
@@ -286,12 +281,7 @@ export default function Profile({ initialTab }: { initialTab?: string }) {
() => [profile?.first_name, profile?.last_name].filter(Boolean).join(" ").trim() || profile?.username || "کاربر",
[profile?.first_name, profile?.last_name, profile?.username],
);
const eventRegistrations = registrationsQuery.data ?? [];
const confirmedEvents = eventRegistrations.filter((item) => item.status === "confirmed" || item.status === "attended");
const blogActivity = blogQuery.data;
const comments = [...(blogActivity?.comments ?? []), ...(blogActivity?.replies ?? [])].sort(
(a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime(),
);
const gamification = gamificationQuery.data;
const activeBlogPageData = blogSection === "comments" ? blogCommentsPageQuery.data : blogPostsPageQuery.data;
const activeBlogIsLoading = blogSection === "comments" ? blogCommentsPageQuery.isLoading : blogPostsPageQuery.isLoading;
const activeBlogIsError = blogSection === "comments" ? blogCommentsPageQuery.isError : blogPostsPageQuery.isError;
@@ -319,7 +309,6 @@ export default function Profile({ initialTab }: { initialTab?: string }) {
const profilePicture = profile.profile_picture_preview_url || profile.profile_picture_thumbnail_url || profile.profile_picture;
const initials = `${profile.first_name?.[0] ?? ""}${profile.last_name?.[0] ?? ""}` || profile.username.slice(0, 1);
const blogCount = (blogActivity?.liked_posts.length ?? 0) + (blogActivity?.saved_posts.length ?? 0) + comments.length;
const publicPath = `/users/profiles/${encodeURIComponent(profile.username)}`;
const renderSection = () => {
@@ -507,41 +496,33 @@ export default function Profile({ initialTab }: { initialTab?: string }) {
<div className="space-y-5">
<Card className="border-border/70 shadow-sm">
<CardHeader className="text-right">
<CardTitle>فعالیت‌های اخیر</CardTitle>
<CardDescription>مروری سریع بر حضور و مشارکت شما در انجمن</CardDescription>
<CardTitle>مشارکت‌های ثبت‌شده</CardTitle>
<CardDescription>فعالیت‌های معتبر برای امتیاز و نشان‌های شما</CardDescription>
</CardHeader>
<CardContent className="grid gap-3 sm:grid-cols-3">
<div className="rounded-2xl border border-border/70 bg-muted/45 p-4 text-right shadow-sm dark:bg-background/70">
<CalendarDays className="h-5 w-5 text-primary" aria-hidden="true" />
<p className="mt-3 text-2xl font-bold">{formatNumberPersian(confirmedEvents.length)}</p>
<p className="mt-1 text-sm text-muted-foreground">رویداد تأییدشده یا حضور یافته</p>
<p className="mt-3 text-2xl font-bold">{formatNumberPersian(gamification?.activity_counts.attendance ?? 0)}</p>
<p className="mt-1 text-sm text-muted-foreground">حضور تأییدشده در رویداد</p>
</div>
<div className="rounded-2xl border border-border/70 bg-muted/45 p-4 text-right shadow-sm dark:bg-background/70">
<Heart className="h-5 w-5 text-primary" aria-hidden="true" />
<p className="mt-3 text-2xl font-bold">{formatNumberPersian(blogActivity?.liked_posts.length ?? 0)}</p>
<p className="mt-1 text-sm text-muted-foreground">نوشته پسندیده‌شده</p>
<p className="mt-3 text-2xl font-bold">{formatNumberPersian(gamification?.activity_counts.likes ?? 0)}</p>
<p className="mt-1 text-sm text-muted-foreground">پسند داده‌شده یا دریافت‌شده</p>
</div>
<div className="rounded-2xl border border-border/70 bg-muted/45 p-4 text-right shadow-sm dark:bg-background/70">
<Activity className="h-5 w-5 text-primary" aria-hidden="true" />
<p className="mt-3 text-2xl font-bold">{formatNumberPersian(blogCount)}</p>
<p className="mt-1 text-sm text-muted-foreground">تعامل بلاگ</p>
<p className="mt-3 text-2xl font-bold">{formatNumberPersian((gamification?.activity_counts.posts ?? 0) + (gamification?.activity_counts.comments ?? 0))}</p>
<p className="mt-1 text-sm text-muted-foreground">نوشته‌ها و دیدگاه‌های تأییدشده</p>
</div>
</CardContent>
</Card>
<Card className="border-border/70 shadow-sm">
<CardHeader className="flex flex-row-reverse items-center justify-between gap-3 text-right">
<div>
<CardTitle>رویدادهای اخیر</CardTitle>
<CardDescription>ثبت‌نام‌ها و حضورهای شما</CardDescription>
</div>
<Button variant="ghost" size="sm" onClick={() => navigateToSection("events")}>همه رویدادها <ArrowUpLeft className="mr-1 h-4 w-4" /></Button>
</CardHeader>
<CardContent className="space-y-3">
{registrationsQuery.isLoading ? <p className="text-sm text-muted-foreground">در حال دریافت رویدادها...</p> : null}
{!registrationsQuery.isLoading && !confirmedEvents.length ? <EmptyState title="هنوز رویداد تأییدشده‌ای ندارید" description="ثبت‌نام‌های تأییدشده اینجا دیده می‌شوند." /> : null}
{confirmedEvents.slice(0, 3).map((registration) => <ProfileEventCard key={registration.id} registration={registration} />)}
</CardContent>
</Card>
<GamificationCard
summary={gamification}
isLoading={gamificationQuery.isLoading}
isError={gamificationQuery.isError}
onRetry={() => { void gamificationQuery.refetch(); }}
/>
</div>
);
};

View File

@@ -5,17 +5,18 @@ import Image from "next/image";
import { CalendarDays, EyeOff, UserRound } from "lucide-react";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { PublicProfileBadges } from "@/components/profile/PublicProfileBadges";
import { Helmet } from "@/lib/helmet";
import { Link, useParams } from "@/lib/router";
import { api } from "@/lib/api";
import { formatJalali, formatNumberPersian, toPersianDigits } from "@/lib/utils";
import { formatJalali, toPersianDigits } from "@/lib/utils";
import type { PublicProfileEventSchema } from "@/lib/types";
function EventCard({ event }: { event: PublicProfileEventSchema }) {
return (
<Link to={`/events/${event.slug}`} className="group flex min-h-28 gap-4 rounded-2xl border border-border/70 bg-background p-3 text-right transition-colors hover:bg-muted/45 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring">
{event.image_url ? <Image src={event.image_url} alt="" width={160} height={120} unoptimized className="aspect-[4/3] w-24 shrink-0 rounded-xl object-cover sm:w-32" /> : (
<span className="flex aspect-[4/3] w-24 shrink-0 items-center justify-center rounded-xl bg-primary/10 text-primary sm:w-32"><CalendarDays className="h-6 w-6" aria-hidden="true" /></span>
<Link to={`/events/${event.slug}`} className="group flex min-h-28 flex-col gap-3 rounded-2xl border border-border/70 bg-background p-3 text-right transition-colors hover:bg-muted/45 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring sm:flex-row sm:gap-4">
{event.image_url ? <Image src={event.image_url} alt="" width={640} height={360} unoptimized className="aspect-video w-full shrink-0 rounded-xl object-cover sm:w-32" /> : (
<span className="flex aspect-video w-full shrink-0 items-center justify-center rounded-xl bg-primary/10 text-primary sm:w-32"><CalendarDays className="h-6 w-6" aria-hidden="true" /></span>
)}
<span className="flex min-w-0 flex-1 flex-col gap-2 py-1">
<span className="font-semibold text-foreground group-hover:text-primary">{event.title}</span>
@@ -53,11 +54,12 @@ export default function PublicProfile() {
const fallback = `${profile.first_name?.[0] ?? ""}${profile.last_name?.[0] ?? ""}` || profile.username[0];
const maskedDetails = [
{ label: "شماره موبایل", value: profile.mobile },
{ label: "شماره دانشجویی", value: profile.student_id },
{ label: "دانشگاه", value: profile.university },
{ label: "رشته", value: profile.major },
{ label: "سال ورود", value: profile.year_of_study || null },
{ label: "شماره موبایل", value: profile.mobile, isEmail: false },
...(profile.email ? [{ label: "ایمیل", value: profile.email, isEmail: true }] : []),
{ label: "دانشگاه", value: profile.university, isEmail: false },
{ label: "رشته", value: profile.major, isEmail: false },
{ label: "شماره دانشجویی", value: profile.student_id, isEmail: false },
{ label: "سال ورود", value: profile.year_of_study || null, isEmail: false },
];
const pageDescription = profile.bio || `پروفایل عمومی ${profile.full_name} و رویدادهای او در انجمن علمی مهندسی کامپیوتر.`;
@@ -85,25 +87,11 @@ export default function PublicProfile() {
</div>
</div>
{profile.bio ? <p className="mt-5 whitespace-pre-wrap text-sm leading-7 text-muted-foreground">{profile.bio}</p> : null}
<div className="mt-5 grid grid-cols-1 gap-3">
<div className="rounded-2xl border border-border/70 bg-muted/45 p-4 text-center shadow-sm dark:bg-background/70"><p className="text-xl font-bold">{formatNumberPersian(profile.events.length)}</p><p className="mt-1 text-xs text-muted-foreground">رویداد</p></div>
</div>
</CardContent>
</Card>
<Card className="border-border/70 shadow-sm">
<CardHeader className="pb-2 text-right"><CardTitle className="text-base">{profile.details_visible ? "اطلاعات بیشتر" : "اطلاعات خصوصی"}</CardTitle></CardHeader>
<CardContent className="space-y-1">
{profile.details_visible ? maskedDetails.map(({ label, value }) => (
<div key={label} className="flex items-center justify-between gap-4 border-b border-border/60 py-3 last:border-0 text-sm">
<span className="shrink-0 text-muted-foreground">{label}</span>
<span className="text-left font-medium">{value ? toPersianDigits(String(value)) : "ثبت نشده"}</span>
</div>
)) : (
<div className="relative isolate overflow-hidden rounded-xl border border-border/70 bg-muted/20 p-5 sm:p-6">
<div aria-hidden="true" className="pointer-events-none select-none space-y-3 opacity-55 blur-[4px]">
{!profile.details_visible ? (
<div className="relative isolate mt-5 overflow-hidden rounded-xl border border-border/70 bg-muted/20 p-4 sm:p-5">
<div aria-hidden="true" className="pointer-events-none select-none space-y-2.5 opacity-55 blur-[4px]">
{maskedDetails.map(({ label }) => (
<div key={label} className="flex items-center justify-between gap-4 border-b border-border/60 py-3 last:border-0 text-sm">
<div key={label} className="flex items-center justify-between gap-4 border-b border-border/60 py-2.5 last:border-0 text-sm">
<span className="shrink-0 text-muted-foreground">{label}</span>
<span className="font-medium">••••••••</span>
</div>
@@ -111,17 +99,34 @@ export default function PublicProfile() {
</div>
<div className="absolute inset-0 flex items-center justify-center bg-background/55 p-4 text-center">
<div className="max-w-xs">
<span className="mx-auto mb-3 flex h-12 w-12 items-center justify-center rounded-full bg-primary/10 text-primary">
<EyeOff className="h-6 w-6" aria-hidden="true" />
<span className="mx-auto mb-2 flex h-11 w-11 items-center justify-center rounded-full bg-primary/10 text-primary">
<EyeOff className="h-5 w-5" aria-hidden="true" />
</span>
<p className="font-semibold">جزئیات این پروفایل خصوصی است</p>
<p className="mt-1 text-sm leading-6 text-muted-foreground">نمایش اطلاعات تکمیلی به درخواست صاحب حساب غیرفعال شده است.</p>
</div>
</div>
</div>
) : (
<div className="mt-5 space-y-1">
{maskedDetails.map(({ label, value, isEmail }) => (
<div key={label} className="flex items-center justify-between gap-4 border-b border-border/60 py-3 last:border-0 text-sm">
<span className="shrink-0 text-muted-foreground">{label}</span>
<span dir={isEmail ? "ltr" : undefined} className={`min-w-0 text-left font-medium ${isEmail ? "break-all" : ""}`}>
{value ? (isEmail ? String(value) : toPersianDigits(String(value))) : "ثبت نشده"}
</span>
</div>
))}
</div>
)}
</CardContent>
</Card>
<Card className="border-border/70 shadow-sm">
<CardContent className="pt-6">
<PublicProfileBadges badges={profile.badges} />
</CardContent>
</Card>
</aside>
<div className="space-y-5 lg:col-span-8">

222
src/views/RankGuide.tsx Normal file
View File

@@ -0,0 +1,222 @@
import Image from "next/image";
import Link from "next/link";
import {
ArrowLeft,
Award,
CalendarCheck2,
Heart,
MessageCircle,
PenLine,
Sparkles,
} from "lucide-react";
import { Card, CardContent } from "@/components/ui/card";
import {
activityLadders,
activityPoints,
getGamificationBadgeImagePath,
rankLevels,
} from "@/lib/gamification-badges";
import { formatNumberPersian } from "@/lib/utils";
const activityIcons = {
attendance: CalendarCheck2,
posts: PenLine,
comments: MessageCircle,
likes: Heart,
} as const;
function ProgressionBadge({
id,
label,
requirement,
description,
size,
eyebrow,
className = "",
}: {
id: string;
label: string;
requirement: string;
description: string;
size: "level" | "activity";
eyebrow?: string;
className?: string;
}) {
const imagePath = getGamificationBadgeImagePath(id);
return (
<li className={`flex min-w-0 flex-col items-center text-center ${size === "level" ? "gap-2" : "gap-1.5"} ${className}`}>
{eyebrow ? <span className="mb-1 text-[0.65rem] font-semibold text-muted-foreground">{eyebrow}</span> : null}
{imagePath ? (
<Image
src={imagePath}
alt=""
width={size === "level" ? 144 : 96}
height={size === "level" ? 144 : 96}
sizes={size === "level" ? "(max-width: 640px) 112px, 144px" : "96px"}
className={size === "level" ? "h-28 w-28 object-contain sm:h-36 sm:w-36" : "h-20 w-20 object-contain sm:h-24 sm:w-24"}
/>
) : null}
<span className={`font-semibold leading-5 ${size === "level" ? "text-sm sm:text-base" : "text-xs sm:text-sm"}`}>{label}</span>
<span className="text-xs leading-5 text-muted-foreground">{requirement}</span>
<span className="sr-only">{description}</span>
</li>
);
}
export default function RankGuide() {
return (
<main className="min-h-screen bg-muted/20 px-4 py-6 sm:py-10" dir="rtl">
<div className="container mx-auto max-w-6xl space-y-8">
<Link
href="/profile"
className="inline-flex min-h-10 items-center gap-2 rounded-lg px-2 text-sm font-medium text-muted-foreground transition-colors hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
<ArrowLeft className="h-4 w-4" aria-hidden="true" />
بازگشت به پروفایل
</Link>
<header className="relative isolate overflow-hidden rounded-3xl bg-primary text-primary-foreground">
<div aria-hidden="true" className="pointer-events-none absolute -left-16 -top-24 h-72 w-72 rounded-full bg-cyan-300/20 blur-3xl" />
<div aria-hidden="true" className="pointer-events-none absolute -bottom-40 right-1/3 h-72 w-72 rounded-full bg-amber-300/15 blur-3xl" />
<div className="relative flex flex-col gap-5 p-6 sm:flex-row sm:items-end sm:justify-between sm:p-9">
<div className="max-w-2xl space-y-3">
<div className="inline-flex items-center gap-2 rounded-full border border-white/20 bg-white/10 px-3 py-1 text-xs font-medium">
<Sparkles className="h-3.5 w-3.5" aria-hidden="true" />
راهنمای دستاوردهای انجمن
</div>
<h1 className="text-3xl font-bold leading-tight sm:text-4xl">مشارکت شما، نشان‌دار می‌شود</h1>
<p className="max-w-xl text-sm leading-7 text-primary-foreground/80 sm:text-base">
با حضور در رویدادها و مشارکت در گفت‌وگوهای انجمن امتیاز بگیرید، سطح کلی‌تان را بالا ببرید و نشان‌های هر مسیر را جمع کنید.
</p>
</div>
<div className="flex shrink-0 items-center gap-2 text-sm font-semibold">
<Award className="h-5 w-5 text-amber-200" aria-hidden="true" />
۵ سطح کلی · ۴ مسیر نشان
</div>
</div>
</header>
<section aria-labelledby="overall-levels-title" className="space-y-4">
<div className="flex flex-wrap items-end justify-between gap-3">
<div>
<p className="text-xs font-semibold tracking-wide text-primary">پیشرفت ترکیبی</p>
<h2 id="overall-levels-title" className="mt-1 text-2xl font-bold">سطح کلی شما</h2>
</div>
<p className="max-w-lg text-sm leading-6 text-muted-foreground">امتیاز رویدادها و فعالیت‌های بلاگ با هم جمع می‌شوند. سطح‌ها مادام‌العمرند و برای همه دست‌یافتنی هستند.</p>
</div>
<div className="relative overflow-hidden rounded-2xl border border-border/70 bg-card p-4 shadow-sm sm:p-6">
<div aria-hidden="true" className="pointer-events-none absolute inset-x-[9%] top-[5.7rem] hidden h-px bg-gradient-to-l from-primary/10 via-primary/50 to-primary/10 lg:block" />
<ol className="relative grid grid-cols-2 gap-x-3 gap-y-7 sm:grid-cols-3 lg:grid-cols-5 lg:gap-2">
{rankLevels.map((level, index) => (
<ProgressionBadge
key={level.id}
id={level.badgeId}
label={level.label}
requirement={level.minimumPoints === 0 ? "شروع مسیر" : `از ${formatNumberPersian(level.minimumPoints)} امتیاز`}
description={`نشان سطح ${level.label}`}
size="level"
eyebrow={`سطح ${formatNumberPersian(index + 1)}`}
className="relative"
/>
))}
</ol>
</div>
</section>
<section aria-labelledby="activity-ladders-title" className="space-y-4">
<div className="max-w-2xl">
<p className="text-xs font-semibold tracking-wide text-primary">دستاوردهای مستقل</p>
<h2 id="activity-ladders-title" className="mt-1 text-2xl font-bold">چهار مسیر برای نشان گرفتن</h2>
<p className="mt-2 text-sm leading-6 text-muted-foreground">هر مسیر جداگانه پیش می‌رود. با رسیدن به هر حدنصاب، نشان همان مرحله به مجموعه دستاوردهای شما اضافه می‌شود.</p>
</div>
<div className="grid gap-4 lg:grid-cols-2">
{activityLadders.map((ladder) => {
const Icon = activityIcons[ladder.category];
return (
<Card key={ladder.category} className="overflow-hidden border-border/70 shadow-sm">
<CardContent className="p-0">
<div className="flex items-start justify-between gap-3 border-b border-border/70 bg-muted/20 px-5 py-4">
<div className="flex min-w-0 items-start gap-3">
<span className="mt-0.5 flex h-10 w-10 shrink-0 items-center justify-center rounded-xl bg-primary/10 text-primary">
<Icon className="h-5 w-5" aria-hidden="true" />
</span>
<div>
<h3 className="font-bold">{ladder.title}</h3>
<p className="mt-1 text-xs leading-5 text-muted-foreground">{ladder.description}</p>
</div>
</div>
<span className="shrink-0 rounded-full border border-primary/20 bg-primary/5 px-2.5 py-1 text-xs font-semibold text-primary">
+{formatNumberPersian(ladder.pointsPerAction)} امتیاز
</span>
</div>
<div className="p-4 sm:p-5">
<p className="mb-3 text-xs font-medium text-muted-foreground">نشان‌ها با این تعداد {ladder.unitLabel} باز می‌شوند</p>
<ol className={`grid gap-3 ${ladder.badges.length === 4 ? "grid-cols-2 sm:grid-cols-4" : "grid-cols-2 sm:grid-cols-3"}`}>
{ladder.badges.map((badge) => (
<ProgressionBadge
key={badge.id}
id={badge.id}
label={badge.label}
requirement={`${formatNumberPersian(badge.threshold)} ${ladder.unitLabel}`}
description={badge.description}
size="activity"
/>
))}
</ol>
</div>
</CardContent>
</Card>
);
})}
</div>
</section>
<section aria-labelledby="scoring-title" className="grid gap-4 lg:grid-cols-[0.8fr_1.2fr]">
<div className="rounded-2xl border border-border/70 bg-card p-5 sm:p-6">
<p className="text-xs font-semibold tracking-wide text-primary">امتیازها چطور حساب می‌شوند؟</p>
<h2 id="scoring-title" className="mt-1 text-xl font-bold">مشارکت معتبر، امتیاز می‌گیرد</h2>
<p className="mt-2 text-sm leading-6 text-muted-foreground">این امتیازها سطح کلی را می‌سازند؛ حدنصاب نشان‌های هر مسیر بر اساس تعداد فعالیت همان مسیر است.</p>
</div>
<div className="grid gap-3 sm:grid-cols-2">
{activityLadders.map((ladder) => {
const Icon = activityIcons[ladder.category];
return (
<div key={ladder.category} className="flex min-w-0 items-center gap-3 rounded-xl border border-border/70 bg-card px-4 py-3">
<span className="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg bg-primary/10 text-primary">
<Icon className="h-4 w-4" aria-hidden="true" />
</span>
<div className="min-w-0">
<p className="text-sm font-semibold">{ladder.title}</p>
<p className="mt-0.5 text-xs text-muted-foreground">هر مورد معتبر: {formatNumberPersian(ladder.pointsPerAction)} امتیاز</p>
</div>
</div>
);
})}
<div className="flex min-w-0 items-center gap-3 rounded-xl border border-border/70 bg-card px-4 py-3 sm:col-span-2">
<span className="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg bg-primary/10 text-primary">
<Heart className="h-4 w-4" aria-hidden="true" />
</span>
<div className="min-w-0">
<p className="text-sm font-semibold">تعامل با نوشته‌ها</p>
<p className="mt-0.5 text-xs leading-5 text-muted-foreground">
{activityPoints.saveLabel} {activityPoints.selfLikeLabel}
</p>
</div>
</div>
</div>
</section>
<footer className="flex flex-col gap-3 rounded-2xl border border-border/70 bg-muted/20 p-5 text-sm leading-6 text-muted-foreground sm:flex-row sm:items-center sm:justify-between">
<p>{activityPoints.reversalLabel} پرداخت‌ها و مبالغ تراکنش در رتبه‌بندی نقشی ندارند.</p>
<Link href="/profile" className="inline-flex shrink-0 items-center gap-2 font-semibold text-primary hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring">
مشاهده دستاوردهای من
<ArrowLeft className="h-4 w-4" aria-hidden="true" />
</Link>
</footer>
</div>
</main>
);
}

24
tasks/plan.md Normal file
View File

@@ -0,0 +1,24 @@
# Rank guide and badge art
## Outcome
Replace the profile rank help dialog with a standalone, responsive visual guide at `/rank-guide`. The profile help control opens the guide in a new tab. Show every current overall level and activity badge milestone with its transparent PNG, label, and earning requirement. Profile badge collections continue to show earned badges only.
## Design direction
- Keep the association's teal surface and border tokens, with cobalt, cyan, and gold drawn from the approved glossy medal artwork.
- Lead with a visual five-level progression strip, then show four activity ladders as image-led rows.
- Keep scoring and reversal rules in short supporting blocks; no text is embedded in badge images.
- Make larger overall-level art distinct from activity badges, and keep the layout RTL and responsive.
## Implementation tasks
1. Add an explicit catalog mapping API badge IDs to the 18 generated PNG assets, labels, milestones, and concise explanations.
2. Replace the profile help dialog with an accessible new-tab link to `/rank-guide` and show the user's current overall badge larger in the rank card.
3. Render earned badge artwork in signed-in and public profiles without exposing unearned badges.
4. Build `/rank-guide` with all five overall levels, all activity milestones, scoring rules, and short guidance on reversals.
5. Verify assets have transparency, lint affected components, and run a production build.
## Backend contract
The backend already returns current levels, points per activity, activity badge thresholds, and earned badge IDs. This page uses the existing contract and current rules; it does not require a migration or a new scoring API. Keep frontend asset IDs aligned with the backend's current IDs (`post-*`, singular `comment-*`, `attendance-*`, `likes-*`, and `level-*`).

7
tasks/todo.md Normal file
View File

@@ -0,0 +1,7 @@
# Task checklist
- [x] Create the badge catalog for all 18 generated assets.
- [x] Replace the rank dialog with a new-tab guide link.
- [x] Show generated artwork for earned badges and the current overall level.
- [x] Implement the visual `/rank-guide` page with all current progression rules.
- [x] Verify transparency, lint, and production build.