feat: add profile rank badges and guide

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

View File

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

View File

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

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

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