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;
|
border-color: hsl(var(--ring)) !important;
|
||||||
color: hsl(var(--secondary-foreground)) !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) {
|
async updateProfile(data: Types.UserUpdateSchema) {
|
||||||
return this.request<Types.UserProfileSchema>('/api/auth/profile', {
|
return this.request<Types.UserProfileSchema>('/api/auth/profile', {
|
||||||
method: 'PUT',
|
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;
|
bio: string;
|
||||||
details_visible: boolean;
|
details_visible: boolean;
|
||||||
mobile?: string | null;
|
mobile?: string | null;
|
||||||
|
email?: string | null;
|
||||||
student_id?: string | null;
|
student_id?: string | null;
|
||||||
year_of_study?: number | null;
|
year_of_study?: number | null;
|
||||||
university?: string | null;
|
university?: string | null;
|
||||||
major?: string | null;
|
major?: string | null;
|
||||||
|
badges: PublicGamificationBadgeSchema[];
|
||||||
events: PublicProfileEventSchema[];
|
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 {
|
export interface UserListSchema {
|
||||||
id: number;
|
id: number;
|
||||||
username: string;
|
username: string;
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import Image from "next/image";
|
|||||||
import {
|
import {
|
||||||
Activity,
|
Activity,
|
||||||
ArrowDownWideNarrow,
|
ArrowDownWideNarrow,
|
||||||
ArrowUpLeft,
|
|
||||||
ChevronLeft,
|
ChevronLeft,
|
||||||
ChevronRight,
|
ChevronRight,
|
||||||
Bookmark,
|
Bookmark,
|
||||||
@@ -26,6 +25,7 @@ import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
|||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { GamificationCard } from "@/components/profile/GamificationCard";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||||
import { Helmet } from "@/lib/helmet";
|
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"
|
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}`}
|
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>
|
</Link>
|
||||||
<div className="flex min-w-0 flex-1 flex-col">
|
<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">
|
<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,
|
enabled: isAuthenticated,
|
||||||
initialData: user ?? undefined,
|
initialData: user ?? undefined,
|
||||||
});
|
});
|
||||||
const registrationsQuery = useQuery({
|
const gamificationQuery = useQuery({
|
||||||
queryKey: ["my-registrations"],
|
queryKey: ["my-gamification"],
|
||||||
queryFn: () => api.getMyRegistrations(),
|
queryFn: () => api.getMyGamification(),
|
||||||
enabled: isAuthenticated && section === "overview",
|
|
||||||
});
|
|
||||||
const blogQuery = useQuery({
|
|
||||||
queryKey: ["my-blog-activity"],
|
|
||||||
queryFn: () => api.getMyBlogActivity(),
|
|
||||||
enabled: isAuthenticated && section === "overview",
|
enabled: isAuthenticated && section === "overview",
|
||||||
});
|
});
|
||||||
const eventSortOption = eventSortItems.find(({ id }) => id === eventSort) ?? eventSortItems[0];
|
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].filter(Boolean).join(" ").trim() || profile?.username || "کاربر",
|
||||||
[profile?.first_name, profile?.last_name, profile?.username],
|
[profile?.first_name, profile?.last_name, profile?.username],
|
||||||
);
|
);
|
||||||
const eventRegistrations = registrationsQuery.data ?? [];
|
const gamification = gamificationQuery.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 activeBlogPageData = blogSection === "comments" ? blogCommentsPageQuery.data : blogPostsPageQuery.data;
|
const activeBlogPageData = blogSection === "comments" ? blogCommentsPageQuery.data : blogPostsPageQuery.data;
|
||||||
const activeBlogIsLoading = blogSection === "comments" ? blogCommentsPageQuery.isLoading : blogPostsPageQuery.isLoading;
|
const activeBlogIsLoading = blogSection === "comments" ? blogCommentsPageQuery.isLoading : blogPostsPageQuery.isLoading;
|
||||||
const activeBlogIsError = blogSection === "comments" ? blogCommentsPageQuery.isError : blogPostsPageQuery.isError;
|
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 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 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 publicPath = `/users/profiles/${encodeURIComponent(profile.username)}`;
|
||||||
|
|
||||||
const renderSection = () => {
|
const renderSection = () => {
|
||||||
@@ -507,41 +496,33 @@ export default function Profile({ initialTab }: { initialTab?: string }) {
|
|||||||
<div className="space-y-5">
|
<div className="space-y-5">
|
||||||
<Card className="border-border/70 shadow-sm">
|
<Card className="border-border/70 shadow-sm">
|
||||||
<CardHeader className="text-right">
|
<CardHeader className="text-right">
|
||||||
<CardTitle>فعالیتهای اخیر</CardTitle>
|
<CardTitle>مشارکتهای ثبتشده</CardTitle>
|
||||||
<CardDescription>مروری سریع بر حضور و مشارکت شما در انجمن</CardDescription>
|
<CardDescription>فعالیتهای معتبر برای امتیاز و نشانهای شما</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="grid gap-3 sm:grid-cols-3">
|
<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">
|
<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" />
|
<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-3 text-2xl font-bold">{formatNumberPersian(gamification?.activity_counts.attendance ?? 0)}</p>
|
||||||
<p className="mt-1 text-sm text-muted-foreground">رویداد تأییدشده یا حضور یافته</p>
|
<p className="mt-1 text-sm text-muted-foreground">حضور تأییدشده در رویداد</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="rounded-2xl border border-border/70 bg-muted/45 p-4 text-right shadow-sm dark:bg-background/70">
|
<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" />
|
<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-3 text-2xl font-bold">{formatNumberPersian(gamification?.activity_counts.likes ?? 0)}</p>
|
||||||
<p className="mt-1 text-sm text-muted-foreground">نوشته پسندیدهشده</p>
|
<p className="mt-1 text-sm text-muted-foreground">پسند دادهشده یا دریافتشده</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="rounded-2xl border border-border/70 bg-muted/45 p-4 text-right shadow-sm dark:bg-background/70">
|
<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" />
|
<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-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>
|
<p className="mt-1 text-sm text-muted-foreground">نوشتهها و دیدگاههای تأییدشده</p>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
<Card className="border-border/70 shadow-sm">
|
<GamificationCard
|
||||||
<CardHeader className="flex flex-row-reverse items-center justify-between gap-3 text-right">
|
summary={gamification}
|
||||||
<div>
|
isLoading={gamificationQuery.isLoading}
|
||||||
<CardTitle>رویدادهای اخیر</CardTitle>
|
isError={gamificationQuery.isError}
|
||||||
<CardDescription>ثبتنامها و حضورهای شما</CardDescription>
|
onRetry={() => { void gamificationQuery.refetch(); }}
|
||||||
</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>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -5,17 +5,18 @@ import Image from "next/image";
|
|||||||
import { CalendarDays, EyeOff, UserRound } from "lucide-react";
|
import { CalendarDays, EyeOff, UserRound } from "lucide-react";
|
||||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { PublicProfileBadges } from "@/components/profile/PublicProfileBadges";
|
||||||
import { Helmet } from "@/lib/helmet";
|
import { Helmet } from "@/lib/helmet";
|
||||||
import { Link, useParams } from "@/lib/router";
|
import { Link, useParams } from "@/lib/router";
|
||||||
import { api } from "@/lib/api";
|
import { api } from "@/lib/api";
|
||||||
import { formatJalali, formatNumberPersian, toPersianDigits } from "@/lib/utils";
|
import { formatJalali, toPersianDigits } from "@/lib/utils";
|
||||||
import type { PublicProfileEventSchema } from "@/lib/types";
|
import type { PublicProfileEventSchema } from "@/lib/types";
|
||||||
|
|
||||||
function EventCard({ event }: { event: PublicProfileEventSchema }) {
|
function EventCard({ event }: { event: PublicProfileEventSchema }) {
|
||||||
return (
|
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">
|
<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={160} height={120} unoptimized className="aspect-[4/3] w-24 shrink-0 rounded-xl object-cover sm:w-32" /> : (
|
{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-[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>
|
<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="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>
|
<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 fallback = `${profile.first_name?.[0] ?? ""}${profile.last_name?.[0] ?? ""}` || profile.username[0];
|
||||||
const maskedDetails = [
|
const maskedDetails = [
|
||||||
{ label: "شماره موبایل", value: profile.mobile },
|
{ label: "شماره موبایل", value: profile.mobile, isEmail: false },
|
||||||
{ label: "شماره دانشجویی", value: profile.student_id },
|
...(profile.email ? [{ label: "ایمیل", value: profile.email, isEmail: true }] : []),
|
||||||
{ label: "دانشگاه", value: profile.university },
|
{ label: "دانشگاه", value: profile.university, isEmail: false },
|
||||||
{ label: "رشته", value: profile.major },
|
{ label: "رشته", value: profile.major, isEmail: false },
|
||||||
{ label: "سال ورود", value: profile.year_of_study || null },
|
{ label: "شماره دانشجویی", value: profile.student_id, isEmail: false },
|
||||||
|
{ label: "سال ورود", value: profile.year_of_study || null, isEmail: false },
|
||||||
];
|
];
|
||||||
const pageDescription = profile.bio || `پروفایل عمومی ${profile.full_name} و رویدادهای او در انجمن علمی مهندسی کامپیوتر.`;
|
const pageDescription = profile.bio || `پروفایل عمومی ${profile.full_name} و رویدادهای او در انجمن علمی مهندسی کامپیوتر.`;
|
||||||
|
|
||||||
@@ -85,25 +87,11 @@ export default function PublicProfile() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{profile.bio ? <p className="mt-5 whitespace-pre-wrap text-sm leading-7 text-muted-foreground">{profile.bio}</p> : null}
|
{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">
|
{!profile.details_visible ? (
|
||||||
<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 className="relative isolate mt-5 overflow-hidden rounded-xl border border-border/70 bg-muted/20 p-4 sm:p-5">
|
||||||
</div>
|
<div aria-hidden="true" className="pointer-events-none select-none space-y-2.5 opacity-55 blur-[4px]">
|
||||||
</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]">
|
|
||||||
{maskedDetails.map(({ label }) => (
|
{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="shrink-0 text-muted-foreground">{label}</span>
|
||||||
<span className="font-medium">••••••••</span>
|
<span className="font-medium">••••••••</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -111,17 +99,34 @@ export default function PublicProfile() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="absolute inset-0 flex items-center justify-center bg-background/55 p-4 text-center">
|
<div className="absolute inset-0 flex items-center justify-center bg-background/55 p-4 text-center">
|
||||||
<div className="max-w-xs">
|
<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">
|
<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-6 w-6" aria-hidden="true" />
|
<EyeOff className="h-5 w-5" aria-hidden="true" />
|
||||||
</span>
|
</span>
|
||||||
<p className="font-semibold">جزئیات این پروفایل خصوصی است</p>
|
<p className="font-semibold">جزئیات این پروفایل خصوصی است</p>
|
||||||
<p className="mt-1 text-sm leading-6 text-muted-foreground">نمایش اطلاعات تکمیلی به درخواست صاحب حساب غیرفعال شده است.</p>
|
<p className="mt-1 text-sm leading-6 text-muted-foreground">نمایش اطلاعات تکمیلی به درخواست صاحب حساب غیرفعال شده است.</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</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>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
<Card className="border-border/70 shadow-sm">
|
||||||
|
<CardContent className="pt-6">
|
||||||
|
<PublicProfileBadges badges={profile.badges} />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
<div className="space-y-5 lg:col-span-8">
|
<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.
|
||||||