feat: add profile rank badges and guide
BIN
public/images/gamification/badges/attendance-1.png
Normal file
|
After Width: | Height: | Size: 1.2 MiB |
BIN
public/images/gamification/badges/attendance-10.png
Normal file
|
After Width: | Height: | Size: 1.5 MiB |
BIN
public/images/gamification/badges/attendance-25.png
Normal file
|
After Width: | Height: | Size: 1.7 MiB |
BIN
public/images/gamification/badges/attendance-5.png
Normal file
|
After Width: | Height: | Size: 1.6 MiB |
BIN
public/images/gamification/badges/comments-20.png
Normal file
|
After Width: | Height: | Size: 1.4 MiB |
BIN
public/images/gamification/badges/comments-5.png
Normal file
|
After Width: | Height: | Size: 1.4 MiB |
BIN
public/images/gamification/badges/comments-50.png
Normal file
|
After Width: | Height: | Size: 1.5 MiB |
BIN
public/images/gamification/badges/level-ally.png
Normal file
|
After Width: | Height: | Size: 1.9 MiB |
BIN
public/images/gamification/badges/level-companion.png
Normal file
|
After Width: | Height: | Size: 1.5 MiB |
BIN
public/images/gamification/badges/level-contributor.png
Normal file
|
After Width: | Height: | Size: 1.6 MiB |
BIN
public/images/gamification/badges/level-influencer.png
Normal file
|
After Width: | Height: | Size: 1.8 MiB |
BIN
public/images/gamification/badges/level-pioneer.png
Normal file
|
After Width: | Height: | Size: 1.8 MiB |
BIN
public/images/gamification/badges/likes-10.png
Normal file
|
After Width: | Height: | Size: 1.4 MiB |
BIN
public/images/gamification/badges/likes-100.png
Normal file
|
After Width: | Height: | Size: 1.5 MiB |
BIN
public/images/gamification/badges/likes-50.png
Normal file
|
After Width: | Height: | Size: 1.5 MiB |
BIN
public/images/gamification/badges/post-1.png
Normal file
|
After Width: | Height: | Size: 1.5 MiB |
BIN
public/images/gamification/badges/post-10.png
Normal file
|
After Width: | Height: | Size: 1.7 MiB |
BIN
public/images/gamification/badges/post-5.png
Normal file
|
After Width: | Height: | Size: 1.5 MiB |
11
src/app/rank-guide/page.tsx
Normal 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 />;
|
||||
}
|
||||
60
src/components/profile/GamificationBadges.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
145
src/components/profile/GamificationCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
51
src/components/profile/PublicProfileBadges.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
135
src/lib/gamification-badges.ts
Normal 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;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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.
|
||||