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

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>
);
}