diff --git a/public/images/gamification/badges/attendance-1.png b/public/images/gamification/badges/attendance-1.png new file mode 100644 index 0000000..4ce3d03 Binary files /dev/null and b/public/images/gamification/badges/attendance-1.png differ diff --git a/public/images/gamification/badges/attendance-10.png b/public/images/gamification/badges/attendance-10.png new file mode 100644 index 0000000..a373f66 Binary files /dev/null and b/public/images/gamification/badges/attendance-10.png differ diff --git a/public/images/gamification/badges/attendance-25.png b/public/images/gamification/badges/attendance-25.png new file mode 100644 index 0000000..e6c6227 Binary files /dev/null and b/public/images/gamification/badges/attendance-25.png differ diff --git a/public/images/gamification/badges/attendance-5.png b/public/images/gamification/badges/attendance-5.png new file mode 100644 index 0000000..730bcea Binary files /dev/null and b/public/images/gamification/badges/attendance-5.png differ diff --git a/public/images/gamification/badges/comments-20.png b/public/images/gamification/badges/comments-20.png new file mode 100644 index 0000000..d1bcd99 Binary files /dev/null and b/public/images/gamification/badges/comments-20.png differ diff --git a/public/images/gamification/badges/comments-5.png b/public/images/gamification/badges/comments-5.png new file mode 100644 index 0000000..b235408 Binary files /dev/null and b/public/images/gamification/badges/comments-5.png differ diff --git a/public/images/gamification/badges/comments-50.png b/public/images/gamification/badges/comments-50.png new file mode 100644 index 0000000..fcdc58d Binary files /dev/null and b/public/images/gamification/badges/comments-50.png differ diff --git a/public/images/gamification/badges/level-ally.png b/public/images/gamification/badges/level-ally.png new file mode 100644 index 0000000..51027b5 Binary files /dev/null and b/public/images/gamification/badges/level-ally.png differ diff --git a/public/images/gamification/badges/level-companion.png b/public/images/gamification/badges/level-companion.png new file mode 100644 index 0000000..78dbbfb Binary files /dev/null and b/public/images/gamification/badges/level-companion.png differ diff --git a/public/images/gamification/badges/level-contributor.png b/public/images/gamification/badges/level-contributor.png new file mode 100644 index 0000000..922ab8d Binary files /dev/null and b/public/images/gamification/badges/level-contributor.png differ diff --git a/public/images/gamification/badges/level-influencer.png b/public/images/gamification/badges/level-influencer.png new file mode 100644 index 0000000..522e234 Binary files /dev/null and b/public/images/gamification/badges/level-influencer.png differ diff --git a/public/images/gamification/badges/level-pioneer.png b/public/images/gamification/badges/level-pioneer.png new file mode 100644 index 0000000..3264826 Binary files /dev/null and b/public/images/gamification/badges/level-pioneer.png differ diff --git a/public/images/gamification/badges/likes-10.png b/public/images/gamification/badges/likes-10.png new file mode 100644 index 0000000..bfd47d5 Binary files /dev/null and b/public/images/gamification/badges/likes-10.png differ diff --git a/public/images/gamification/badges/likes-100.png b/public/images/gamification/badges/likes-100.png new file mode 100644 index 0000000..fe79a25 Binary files /dev/null and b/public/images/gamification/badges/likes-100.png differ diff --git a/public/images/gamification/badges/likes-50.png b/public/images/gamification/badges/likes-50.png new file mode 100644 index 0000000..51f17e3 Binary files /dev/null and b/public/images/gamification/badges/likes-50.png differ diff --git a/public/images/gamification/badges/post-1.png b/public/images/gamification/badges/post-1.png new file mode 100644 index 0000000..a769679 Binary files /dev/null and b/public/images/gamification/badges/post-1.png differ diff --git a/public/images/gamification/badges/post-10.png b/public/images/gamification/badges/post-10.png new file mode 100644 index 0000000..95e5067 Binary files /dev/null and b/public/images/gamification/badges/post-10.png differ diff --git a/public/images/gamification/badges/post-5.png b/public/images/gamification/badges/post-5.png new file mode 100644 index 0000000..ffae4c5 Binary files /dev/null and b/public/images/gamification/badges/post-5.png differ diff --git a/src/app/rank-guide/page.tsx b/src/app/rank-guide/page.tsx new file mode 100644 index 0000000..de2133f --- /dev/null +++ b/src/app/rank-guide/page.tsx @@ -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 ; +} diff --git a/src/components/profile/GamificationBadges.tsx b/src/components/profile/GamificationBadges.tsx new file mode 100644 index 0000000..769e10f --- /dev/null +++ b/src/components/profile/GamificationBadges.tsx @@ -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 = { + 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

هنوز نشانی دریافت نشده است.

; + } + + return ( + + ); +} diff --git a/src/components/profile/GamificationCard.tsx b/src/components/profile/GamificationCard.tsx new file mode 100644 index 0000000..062b688 --- /dev/null +++ b/src/components/profile/GamificationCard.tsx @@ -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 ( +
+
+
+ {formatNumberPersian(points)} +
+ ); +} + +export function GamificationCard({ + summary, + isLoading, + isError, + onRetry, +}: { + summary?: GamificationSummarySchema; + isLoading: boolean; + isError: boolean; + onRetry: () => void; +}) { + if (isLoading) { + return ( + + +
+
+
+ + + ); + } + + if (isError || !summary) { + return ( + + +
+

رتبه و نشان‌ها بارگذاری نشد

+

برای دریافت دوباره اطلاعات تلاش کنید.

+
+ +
+
+ ); + } + + const currentRankBadgeId = getRankBadgeId(summary.current_level.id); + const currentRankImage = getGamificationBadgeImagePath(currentRankBadgeId); + + return ( + + +
+ رتبه و دستاوردهای من + سطح مادام‌العمر بر اساس مشارکت‌های معتبر در انجمن +
+ +
+ +
+
+ {currentRankImage ? ( + + ) : null} +
+

سطح فعلی

+

{summary.current_level.label}

+
+
+
+
+
+ +
+ {summary.next_level ? ( +
+ پیشرفت تا سطح {summary.next_level.label} + {formatNumberPersian(summary.points_to_next)} امتیاز تا سطح بعدی +
+ ) : ( +

بالاترین سطح را دریافت کرده‌اید.

+ )} + +
+ +
+ + +
+ +
+

+

+ +
+
+
+ ); +} diff --git a/src/components/profile/PublicProfileBadges.tsx b/src/components/profile/PublicProfileBadges.tsx new file mode 100644 index 0000000..99a0b71 --- /dev/null +++ b/src/components/profile/PublicProfileBadges.tsx @@ -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 ( +
+ {overallRank ? ( +
+
+ ) : null} + +
+

نشان‌ها

+ +
+
+ ); +} diff --git a/src/index.css b/src/index.css index 97df94e..dd86d5b 100644 --- a/src/index.css +++ b/src/index.css @@ -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; + } +} diff --git a/src/lib/api.ts b/src/lib/api.ts index 963b10d..02dfd24 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -271,6 +271,12 @@ class ApiClient { ); } + async getMyGamification() { + return this.request("/api/gamification/me", { + method: "GET", + }); + } + async updateProfile(data: Types.UserUpdateSchema) { return this.request('/api/auth/profile', { method: 'PUT', diff --git a/src/lib/gamification-badges.ts b/src/lib/gamification-badges.ts new file mode 100644 index 0000000..0b95288 --- /dev/null +++ b/src/lib/gamification-badges.ts @@ -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; + 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 = 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> { + const latest: Partial> = {}; + 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; +} diff --git a/src/lib/types.ts b/src/lib/types.ts index f3198ad..23873ba 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -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; diff --git a/src/views/Profile.tsx b/src/views/Profile.tsx index b9e8510..cb76488 100644 --- a/src/views/Profile.tsx +++ b/src/views/Profile.tsx @@ -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}`} > - +
@@ -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 }) {
- فعالیت‌های اخیر - مروری سریع بر حضور و مشارکت شما در انجمن + مشارکت‌های ثبت‌شده + فعالیت‌های معتبر برای امتیاز و نشان‌های شما
- - -
- رویدادهای اخیر - ثبت‌نام‌ها و حضورهای شما -
- -
- - {registrationsQuery.isLoading ?

در حال دریافت رویدادها...

: null} - {!registrationsQuery.isLoading && !confirmedEvents.length ? : null} - {confirmedEvents.slice(0, 3).map((registration) => )} -
-
+ { void gamificationQuery.refetch(); }} + />
); }; diff --git a/src/views/PublicProfile.tsx b/src/views/PublicProfile.tsx index a0fa02a..d2a64e9 100644 --- a/src/views/PublicProfile.tsx +++ b/src/views/PublicProfile.tsx @@ -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 ( - - {event.image_url ? : ( - + + {event.image_url ? : ( + )} {event.title} @@ -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() {
{profile.bio ?

{profile.bio}

: null} -
-

{formatNumberPersian(profile.events.length)}

رویداد

-
- - - - - {profile.details_visible ? "اطلاعات بیشتر" : "اطلاعات خصوصی"} - - {profile.details_visible ? maskedDetails.map(({ label, value }) => ( -
- {label} - {value ? toPersianDigits(String(value)) : "ثبت نشده"} -
- )) : ( -
-