fix(ui): design-system consistency — typography, borders, admin IA, search removal
- typography: font-semibold/font-medium → font-bold throughout (card, dialog, empty-state, notification-bell, quiz-experience, questions-panel, profile-manage) - borders: border → border-2 for content containers (leaderboard error, notifications page, quiz pass screen, quiz option tiles, learning-panel error) - admin IA: remove Card-in-SectionPanel nesting from announce-panel, challenges-panel, physical-orders-panel; SectionPanel gains optional actions slot - announce-panel: flat composer + live preview; history as flat list; cancel-edit button - lockout: raw textarea → Textarea design-system component - global-search: delete component and all references (feature removed) - progress.tsx: dir="ltr" for correct RTL fill direction; remove rtl:me-auto
This commit is contained in:
@@ -8,6 +8,7 @@ import { api, http, toUserError } from "@/lib/api";
|
|||||||
import { MAX_HEARTS, clampHearts, hasInfiniteHearts } from "@/lib/hearts";
|
import { MAX_HEARTS, clampHearts, hasInfiniteHearts } from "@/lib/hearts";
|
||||||
import type { Mentor, User } from "@/lib/types";
|
import type { Mentor, User } from "@/lib/types";
|
||||||
import { Card } from "@/components/ui/card";
|
import { Card } from "@/components/ui/card";
|
||||||
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { toast } from "@/components/providers";
|
import { toast } from "@/components/providers";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
@@ -116,14 +117,14 @@ export default function LockoutPage() {
|
|||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<div className="mt-5 w-full space-y-2">
|
<div className="mt-5 w-full space-y-2">
|
||||||
<textarea
|
<Textarea
|
||||||
value={note}
|
value={note}
|
||||||
onChange={(e) => setNote(e.target.value)}
|
onChange={(e) => setNote(e.target.value)}
|
||||||
maxLength={500}
|
maxLength={500}
|
||||||
rows={3}
|
rows={3}
|
||||||
dir="rtl"
|
dir="rtl"
|
||||||
placeholder="اختیاری: چه آزمونی گیر کردید؟ چه کمکی لازم دارید؟"
|
placeholder="اختیاری: چه آزمونی گیر کردید؟ چه کمکی لازم دارید؟"
|
||||||
className="w-full rounded-xl border-2 border-border bg-background px-3 py-2 text-sm outline-none focus-visible:border-primary/50"
|
className="min-h-0"
|
||||||
/>
|
/>
|
||||||
<div className="flex flex-col gap-2 sm:flex-row">
|
<div className="flex flex-col gap-2 sm:flex-row">
|
||||||
<Button className="flex-1" disabled={busy || requested} onClick={() => void requestUnlock()}>
|
<Button className="flex-1" disabled={busy || requested} onClick={() => void requestUnlock()}>
|
||||||
|
|||||||
@@ -48,7 +48,7 @@ export default function NotificationsPage() {
|
|||||||
<Skeleton className="h-8 w-44" />
|
<Skeleton className="h-8 w-44" />
|
||||||
<Skeleton className="h-4 w-72" />
|
<Skeleton className="h-4 w-72" />
|
||||||
</div>
|
</div>
|
||||||
<div className="rounded-2xl border border-border/70 bg-card p-6 shadow-soft">
|
<div className="rounded-2xl border-2 border-border/70 bg-card p-6 shadow-soft">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<Skeleton className="h-10 w-10 rounded-xl" />
|
<Skeleton className="h-10 w-10 rounded-xl" />
|
||||||
<div className="flex-1 space-y-1.5">
|
<div className="flex-1 space-y-1.5">
|
||||||
|
|||||||
@@ -19,9 +19,9 @@ import {
|
|||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { http, toUserError } from "@/lib/api";
|
import { http, toUserError } from "@/lib/api";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
@@ -158,231 +158,217 @@ export function AnnouncePanel() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="flex flex-col gap-6">
|
{/* Composer + Live Preview */}
|
||||||
<div data-announce-compose className="grid items-start gap-4 lg:grid-cols-[minmax(0,1fr)_20rem]">
|
<div className="grid items-start gap-6 lg:grid-cols-[minmax(0,1fr)_18rem]">
|
||||||
<Card>
|
{/* Form */}
|
||||||
<CardHeader className="space-y-1">
|
<div className="space-y-5">
|
||||||
<CardTitle className="text-base">نگارش اعلان</CardTitle>
|
<div className="space-y-2">
|
||||||
<p className="text-xs text-muted-foreground">
|
<Label className="text-sm font-bold">عنوان</Label>
|
||||||
به همه هنرجویان فعال میرسد و در مرکز اعلانها دیده میشود.
|
<Input
|
||||||
</p>
|
value={title}
|
||||||
</CardHeader>
|
onChange={(e) => setTitle(e.target.value)}
|
||||||
<CardContent className="space-y-5">
|
placeholder="مثلاً جلسه زنده هفته ۴ · پنجشنبه ۲۰:۰۰"
|
||||||
<div className="space-y-2">
|
className={cn("h-11 rounded-2xl", !titleOk && title.length > 0 && "border-destructive")}
|
||||||
<Label className="text-sm font-bold">عنوان</Label>
|
autoFocus
|
||||||
<Input
|
/>
|
||||||
value={title}
|
</div>
|
||||||
onChange={(e) => setTitle(e.target.value)}
|
|
||||||
placeholder="مثلاً جلسه زنده هفته ۴ · پنجشنبه ۲۰:۰۰"
|
|
||||||
className={cn("h-11 rounded-2xl", !titleOk && title.length > 0 && "border-destructive")}
|
|
||||||
autoFocus
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<div className="flex h-5 items-center justify-between gap-3">
|
<div className="flex h-5 items-center justify-between gap-3">
|
||||||
<Label className="text-sm font-bold">پیام</Label>
|
<Label className="text-sm font-bold">پیام</Label>
|
||||||
<span className="text-[11px] tabular-nums text-muted-foreground">{body.length}/۴۰۰۰</span>
|
<span className="text-[11px] tabular-nums text-muted-foreground">{body.length}/۴۰۰۰</span>
|
||||||
</div>
|
|
||||||
<textarea
|
|
||||||
value={body}
|
|
||||||
onChange={(e) => setBody(e.target.value.slice(0, 4000))}
|
|
||||||
rows={5}
|
|
||||||
maxLength={4000}
|
|
||||||
className={cn(
|
|
||||||
"w-full resize-y rounded-xl border bg-background px-4 py-3 text-sm leading-relaxed focus:outline-none focus:ring-2 focus:ring-primary",
|
|
||||||
!bodyOk && body.length > 0 ? "border-destructive/50" : "border-input"
|
|
||||||
)}
|
|
||||||
placeholder="یک جملهٔ واضح و کوتاه — لینک یا زمان را حتماً بگویید…"
|
|
||||||
/>
|
|
||||||
{body.trim().length > 0 && body.trim().length < 8 && (
|
|
||||||
<p className="text-xs font-bold text-destructive">پیام حداقل چند کلمه باشد.</p>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
<Textarea
|
||||||
|
value={body}
|
||||||
|
onChange={(e) => setBody(e.target.value.slice(0, 4000))}
|
||||||
|
rows={5}
|
||||||
|
maxLength={4000}
|
||||||
|
className={cn("resize-y", !bodyOk && body.length > 0 ? "border-destructive/50" : "")}
|
||||||
|
placeholder="یک جملهٔ واضح و کوتاه — لینک یا زمان را حتماً بگویید…"
|
||||||
|
/>
|
||||||
|
{body.trim().length > 0 && body.trim().length < 8 && (
|
||||||
|
<p className="text-xs font-bold text-destructive">پیام حداقل چند کلمه باشد.</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label className="text-sm font-bold">دسته</Label>
|
<Label className="text-sm font-bold">دسته</Label>
|
||||||
<div className="grid gap-2 sm:grid-cols-2">
|
<div className="grid gap-2 sm:grid-cols-2">
|
||||||
{CATEGORIES.map((c) => {
|
{CATEGORIES.map((c) => {
|
||||||
const active = category === c.id;
|
const active = category === c.id;
|
||||||
const Icon = c.icon;
|
const Icon = c.icon;
|
||||||
return (
|
return (
|
||||||
<button
|
|
||||||
key={c.id}
|
|
||||||
type="button"
|
|
||||||
onClick={() => setCategory(c.id)}
|
|
||||||
className={cn(
|
|
||||||
"grid h-[4.25rem] grid-cols-[2.25rem_minmax(0,1fr)] items-center gap-3 rounded-xl border px-3 text-right transition-colors",
|
|
||||||
active ? "border-primary bg-primary/10" : "border-border hover:border-primary/40"
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
className={cn(
|
|
||||||
"grid h-9 w-9 place-items-center rounded-xl",
|
|
||||||
active ? "bg-primary text-primary-foreground" : "bg-muted text-muted-foreground"
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<Icon className="h-4 w-4" />
|
|
||||||
</span>
|
|
||||||
<span className="min-w-0">
|
|
||||||
<span className="block truncate text-sm font-extrabold">{c.label}</span>
|
|
||||||
<span className="block truncate text-[11px] text-muted-foreground">{c.hint}</span>
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label className="flex h-5 items-center gap-1.5 text-sm font-bold">
|
|
||||||
<Route className="h-3.5 w-3.5" /> با زدن اعلان این صفحه باز میشود
|
|
||||||
</Label>
|
|
||||||
<p className="text-[11px] text-muted-foreground">خالی بگذارید تا اعلان فقط خوانده شود و صفحهای باز نکند.</p>
|
|
||||||
<div className="flex flex-wrap gap-2">
|
|
||||||
{ROUTES.map((r) => (
|
|
||||||
<button
|
<button
|
||||||
key={r.path}
|
key={c.id}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setRoute(r.path)}
|
onClick={() => setCategory(c.id)}
|
||||||
className={cn(
|
className={cn(
|
||||||
"inline-flex h-8 items-center rounded-full border px-3 text-xs font-bold transition-colors",
|
"grid h-[4.25rem] grid-cols-[2.25rem_minmax(0,1fr)] items-center gap-3 rounded-xl border px-3 text-right transition-colors",
|
||||||
route === r.path
|
active ? "border-primary bg-primary/10" : "border-border hover:border-primary/40"
|
||||||
? "border-primary bg-primary text-primary-foreground"
|
|
||||||
: "border-border hover:border-primary/40"
|
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{r.label}
|
<span
|
||||||
|
className={cn(
|
||||||
|
"grid h-9 w-9 place-items-center rounded-xl",
|
||||||
|
active ? "bg-primary text-primary-foreground" : "bg-muted text-muted-foreground"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Icon className="h-4 w-4" />
|
||||||
|
</span>
|
||||||
|
<span className="min-w-0">
|
||||||
|
<span className="block truncate text-sm font-extrabold">{c.label}</span>
|
||||||
|
<span className="block truncate text-[11px] text-muted-foreground">{c.hint}</span>
|
||||||
|
</span>
|
||||||
</button>
|
</button>
|
||||||
))}
|
);
|
||||||
</div>
|
})}
|
||||||
<Input
|
|
||||||
value={route}
|
|
||||||
onChange={(e) => setRoute(e.target.value)}
|
|
||||||
dir="ltr"
|
|
||||||
className="h-10 rounded-2xl font-mono text-xs"
|
|
||||||
placeholder="خالی مجاز است"
|
|
||||||
/>
|
|
||||||
<label className="flex items-center gap-2 text-sm font-bold">
|
|
||||||
<input type="checkbox" checked={pinned} onChange={(e) => setPinned(e.target.checked)} />
|
|
||||||
سنجاق روی درخت مهارت
|
|
||||||
</label>
|
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-wrap items-center gap-3 border-t border-border pt-4">
|
<div className="space-y-2">
|
||||||
|
<Label className="flex h-5 items-center gap-1.5 text-sm font-bold">
|
||||||
|
<Route className="h-3.5 w-3.5" /> صفحهای که با کلیک باز میشود
|
||||||
|
</Label>
|
||||||
|
<p className="text-[11px] text-muted-foreground">خالی بگذارید تا اعلان فقط خوانده شود.</p>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{ROUTES.map((r) => (
|
||||||
|
<button
|
||||||
|
key={r.path}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setRoute(r.path)}
|
||||||
|
className={cn(
|
||||||
|
"inline-flex h-8 items-center rounded-full border px-3 text-xs font-bold transition-colors",
|
||||||
|
route === r.path
|
||||||
|
? "border-primary bg-primary text-primary-foreground"
|
||||||
|
: "border-border hover:border-primary/40"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{r.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<Input
|
||||||
|
value={route}
|
||||||
|
onChange={(e) => setRoute(e.target.value)}
|
||||||
|
dir="ltr"
|
||||||
|
className="h-10 rounded-2xl font-mono text-xs"
|
||||||
|
placeholder="خالی مجاز است"
|
||||||
|
/>
|
||||||
|
<label className="flex items-center gap-2 text-sm font-bold">
|
||||||
|
<input type="checkbox" checked={pinned} onChange={(e) => setPinned(e.target.checked)} />
|
||||||
|
سنجاق روی درخت مهارت
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap items-center gap-3 border-t border-border pt-4">
|
||||||
|
<Button
|
||||||
|
size="lg"
|
||||||
|
className="h-11 gap-2"
|
||||||
|
disabled={!valid}
|
||||||
|
onClick={() => setConfirmOpen(true)}
|
||||||
|
>
|
||||||
|
<Eye className="h-4 w-4" />
|
||||||
|
{editingId ? "پیشنمایش و ذخیره" : "پیشنمایش و ارسال"}
|
||||||
|
</Button>
|
||||||
|
{editingId && (
|
||||||
<Button
|
<Button
|
||||||
size="lg"
|
size="lg"
|
||||||
className="h-11 gap-2"
|
variant="outline"
|
||||||
disabled={!valid}
|
onClick={() => { setEditingId(null); setTitle(""); setBody(""); setPinned(false); }}
|
||||||
onClick={() => setConfirmOpen(true)}
|
|
||||||
>
|
>
|
||||||
<Eye className="h-4 w-4" />
|
انصراف از ویرایش
|
||||||
پیشنمایش و ارسال
|
|
||||||
</Button>
|
</Button>
|
||||||
{!valid && (
|
)}
|
||||||
<p className="text-xs text-muted-foreground">عنوان و پیام را کامل کنید تا ادامه فعال شود.</p>
|
{!valid && (
|
||||||
)}
|
<p className="text-xs text-muted-foreground">عنوان و پیام را کامل کنید تا ادامه فعال شود.</p>
|
||||||
</div>
|
)}
|
||||||
</CardContent>
|
</div>
|
||||||
</Card>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-4">
|
{/* Sidebar: live preview + last sent */}
|
||||||
<Card className="border bg-muted/20">
|
<div className="space-y-4 lg:sticky lg:top-24">
|
||||||
<CardHeader className="space-y-1 pb-3">
|
<div className="space-y-1.5">
|
||||||
<CardTitle className="flex h-6 items-center gap-2 text-sm">
|
<p className="flex items-center gap-1.5 text-xs font-bold text-muted-foreground">
|
||||||
<Eye className="h-3.5 w-3.5" /> پیشنمایش زنده
|
<Eye className="h-3.5 w-3.5" /> پیشنمایش زنده
|
||||||
</CardTitle>
|
</p>
|
||||||
<p className="text-[11px] text-muted-foreground">همزمان با تایپ بهروز میشود.</p>
|
<PreviewCard title={title} body={body} category={category} route={route} />
|
||||||
</CardHeader>
|
</div>
|
||||||
<CardContent>
|
|
||||||
<PreviewCard title={title} body={body} category={category} route={route} />
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{lastSent && (
|
{lastSent && (
|
||||||
<Card className="border border-success/40 bg-success/5">
|
<div className="rounded-xl border border-success/40 bg-success/5 px-4 py-3 text-sm">
|
||||||
<CardContent className="space-y-1 pt-5 text-sm">
|
<p className="font-bold text-success">آخرین ارسال موفق</p>
|
||||||
<p className="font-bold text-success">آخرین ارسال موفق</p>
|
<p className="mt-0.5 truncate font-extrabold">{lastSent.title}</p>
|
||||||
<p className="truncate font-extrabold">{lastSent.title}</p>
|
<p className="text-xs text-muted-foreground">{lastSent.recipients} گیرنده</p>
|
||||||
<p className="text-xs text-muted-foreground">{lastSent.recipients} گیرنده</p>
|
</div>
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Card data-announce-list className="rounded-2xl border border-border">
|
{/* History */}
|
||||||
<CardHeader>
|
<div className="mt-8 space-y-3 border-t border-border/70 pt-6">
|
||||||
<CardTitle className="text-base">اطلاعیههای ثبتشده</CardTitle>
|
<h3 className="text-sm font-black text-muted-foreground">اطلاعیههای ثبتشده</h3>
|
||||||
</CardHeader>
|
{(listQ.data?.announcements ?? []).length > 0 ? (
|
||||||
<CardContent>
|
<ul className="flex list-none flex-col gap-2.5 p-0">
|
||||||
{(listQ.data?.announcements ?? []).length > 0 ? (
|
{(listQ.data?.announcements ?? []).map((a) => {
|
||||||
<ul className="flex list-none flex-col gap-3 p-0">
|
const cat = CATEGORIES.find((c) => c.id === a.category);
|
||||||
{(listQ.data?.announcements ?? []).map((a) => {
|
const excerpt = announcementExcerpt(a.body);
|
||||||
const cat = CATEGORIES.find((c) => c.id === a.category);
|
return (
|
||||||
const excerpt = announcementExcerpt(a.body);
|
<li key={a.id} className="flex flex-col gap-3 rounded-2xl border border-border bg-background p-4 sm:flex-row sm:items-start sm:justify-between">
|
||||||
return (
|
<div className="min-w-0 flex-1 space-y-2">
|
||||||
<li key={a.id} className="rounded-2xl border border-border bg-background p-4">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
|
<p className="min-w-0 text-sm font-bold leading-snug">{a.title}</p>
|
||||||
<div className="min-w-0 flex-1 space-y-2">
|
<Badge variant="secondary" className="text-[10px]">
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
{cat?.label ?? a.category}
|
||||||
<p className="min-w-0 text-sm font-bold leading-snug">{a.title}</p>
|
</Badge>
|
||||||
<Badge variant="secondary" className="text-[10px]">
|
{a.pinned && (
|
||||||
{cat?.label ?? a.category}
|
<Badge variant="gold" className="text-[10px]">سنجاق</Badge>
|
||||||
</Badge>
|
)}
|
||||||
{a.pinned ? (
|
|
||||||
<Badge variant="gold" className="text-[10px]">
|
|
||||||
سنجاق
|
|
||||||
</Badge>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
{excerpt ? (
|
|
||||||
<p className="line-clamp-2 text-xs leading-relaxed text-muted-foreground">{excerpt}</p>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
<div className="flex shrink-0 items-center gap-2">
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
size="sm"
|
|
||||||
variant="outline"
|
|
||||||
onClick={() => {
|
|
||||||
setEditingId(a.id);
|
|
||||||
setTitle(a.title);
|
|
||||||
setBody(a.body);
|
|
||||||
setCategory(a.category);
|
|
||||||
setRoute(a.route);
|
|
||||||
setPinned(a.pinned);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
ویرایش
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
size="sm"
|
|
||||||
variant="outline"
|
|
||||||
aria-label="حذف"
|
|
||||||
onClick={() => {
|
|
||||||
void http
|
|
||||||
.del(`/api/admin/announcements/${a.id}`)
|
|
||||||
.then(() => qc.invalidateQueries({ queryKey: ["admin-announcements"] }))
|
|
||||||
.catch((e) => toast.error(toUserError(e, "حذف ممکن نشد")));
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Trash2 className="h-3.5 w-3.5" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</li>
|
{excerpt && (
|
||||||
);
|
<p className="line-clamp-2 text-xs leading-relaxed text-muted-foreground">{excerpt}</p>
|
||||||
})}
|
)}
|
||||||
</ul>
|
</div>
|
||||||
) : !listQ.isPending ? (
|
<div className="flex shrink-0 items-center gap-2">
|
||||||
<p className="rounded-2xl border border-dashed border-border px-4 py-6 text-sm text-muted-foreground">
|
<Button
|
||||||
هنوز اطلاعیهای ثبت نشده است.
|
type="button"
|
||||||
</p>
|
size="sm"
|
||||||
) : null}
|
variant="outline"
|
||||||
</CardContent>
|
onClick={() => {
|
||||||
</Card>
|
setEditingId(a.id);
|
||||||
|
setTitle(a.title);
|
||||||
|
setBody(a.body);
|
||||||
|
setCategory(a.category);
|
||||||
|
setRoute(a.route);
|
||||||
|
setPinned(a.pinned);
|
||||||
|
window.scrollTo({ top: 0, behavior: "smooth" });
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
ویرایش
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
aria-label="حذف"
|
||||||
|
onClick={() => {
|
||||||
|
void http
|
||||||
|
.del(`/api/admin/announcements/${a.id}`)
|
||||||
|
.then(() => qc.invalidateQueries({ queryKey: ["admin-announcements"] }))
|
||||||
|
.catch((e) => toast.error(toUserError(e, "حذف ممکن نشد")));
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Trash2 className="h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
) : !listQ.isPending ? (
|
||||||
|
<p className="rounded-2xl border border-dashed border-border px-4 py-6 text-sm text-muted-foreground">
|
||||||
|
هنوز اطلاعیهای ثبت نشده است.
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Dialog open={confirmOpen} onOpenChange={setConfirmOpen}>
|
<Dialog open={confirmOpen} onOpenChange={setConfirmOpen}>
|
||||||
|
|||||||
@@ -5,9 +5,9 @@ import { useQuery, useQueryClient } from "@tanstack/react-query";
|
|||||||
import { Plus, Trash2, Trophy } from "lucide-react";
|
import { Plus, Trash2, Trophy } from "lucide-react";
|
||||||
import { http, toUserError } from "@/lib/api";
|
import { http, toUserError } from "@/lib/api";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import { Switch } from "@/components/ui/switch";
|
import { Switch } from "@/components/ui/switch";
|
||||||
import { Skeleton } from "@/components/ui/skeleton";
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
@@ -86,81 +86,77 @@ export function ChallengesPanel() {
|
|||||||
minute: "2-digit",
|
minute: "2-digit",
|
||||||
}).format(new Date(iso));
|
}).format(new Date(iso));
|
||||||
|
|
||||||
|
const newChallenge = () =>
|
||||||
|
setModal({
|
||||||
|
id: 0,
|
||||||
|
title: "",
|
||||||
|
description: "",
|
||||||
|
targetXp: 500,
|
||||||
|
startsAt: "",
|
||||||
|
endsAt: "",
|
||||||
|
isActive: true,
|
||||||
|
createdAt: "",
|
||||||
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{dialog}
|
{dialog}
|
||||||
<Card>
|
<div className="space-y-4">
|
||||||
<CardHeader className="flex-row items-center justify-between gap-4">
|
<div className="flex items-center justify-between gap-4">
|
||||||
<div>
|
<p className="text-xs text-muted-foreground">
|
||||||
<CardTitle className="text-base">چالشهای امتیاز</CardTitle>
|
پیشرفت هنرجو با تکمیل آزمونها بهروز میشود.
|
||||||
<p className="mt-1 text-xs text-muted-foreground">
|
</p>
|
||||||
چالش ماهانه با هدف امتیاز؛ پیشرفت هنرجو با تکمیل آزمونها بهروز میشود.
|
<Button className="shrink-0 gap-1.5" onClick={newChallenge}>
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<Button
|
|
||||||
className="gap-1.5"
|
|
||||||
onClick={() =>
|
|
||||||
setModal({
|
|
||||||
id: 0,
|
|
||||||
title: "",
|
|
||||||
description: "",
|
|
||||||
targetXp: 500,
|
|
||||||
startsAt: "",
|
|
||||||
endsAt: "",
|
|
||||||
isActive: true,
|
|
||||||
createdAt: "",
|
|
||||||
})
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Plus className="h-4 w-4" /> چالش جدید
|
<Plus className="h-4 w-4" /> چالش جدید
|
||||||
</Button>
|
</Button>
|
||||||
</CardHeader>
|
</div>
|
||||||
<CardContent>
|
|
||||||
{isLoading && (
|
{isLoading && (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{[0, 1].map((i) => (
|
{[0, 1].map((i) => (
|
||||||
<Skeleton key={i} className="h-20 rounded-xl" />
|
<Skeleton key={i} className="h-20 rounded-xl" />
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{isError && (
|
|
||||||
<div className="rounded-md border border-destructive/30 bg-destructive/5 p-4 text-sm">
|
|
||||||
<p className="font-medium text-destructive">{toUserError(error)}</p>
|
|
||||||
<Button size="sm" variant="outline" className="mt-2" onClick={() => refetch()}>
|
|
||||||
تلاش دوباره
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div className="grid gap-3 sm:grid-cols-2">
|
|
||||||
{(data?.challenges ?? []).map((c) => (
|
|
||||||
<div key={c.id} className="rounded-md border border-border p-4">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Trophy className="h-4 w-4 text-gold" />
|
|
||||||
<Badge variant={c.isActive ? "success" : "secondary"}>{c.isActive ? "فعال" : "خاموش"}</Badge>
|
|
||||||
<Badge variant="accent">هدف {formatFaNumber(c.targetXp)} امتیاز</Badge>
|
|
||||||
<div className="ms-auto flex gap-1">
|
|
||||||
<Button size="sm" variant="outline" className="h-7 px-2" onClick={() => setModal({ ...c })}>
|
|
||||||
ویرایش
|
|
||||||
</Button>
|
|
||||||
<Button size="sm" variant="ghost" className="h-7 px-2 text-destructive" onClick={() => del(c)}>
|
|
||||||
<Trash2 className="h-3.5 w-3.5" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<p className="mt-2 font-medium">{c.title}</p>
|
|
||||||
<p className="line-clamp-2 text-xs text-muted-foreground">{c.description || "—"}</p>
|
|
||||||
<p className="mt-1.5 text-xs text-muted-foreground">
|
|
||||||
{fmt(c.startsAt)} تا {fmt(c.endsAt)}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
{!isLoading && !isError && (data?.challenges ?? []).length === 0 && (
|
)}
|
||||||
<p className="py-8 text-center text-sm text-muted-foreground">هنوز چالشی نیست.</p>
|
{isError && (
|
||||||
)}
|
<div className="rounded-md border-2 border-destructive/30 bg-destructive/5 p-4 text-sm">
|
||||||
</CardContent>
|
<p className="font-bold text-destructive">{toUserError(error)}</p>
|
||||||
<ChallengeDialog challenge={modal} onClose={() => setModal(null)} onSave={save} />
|
<Button size="sm" variant="outline" className="mt-2" onClick={() => refetch()}>
|
||||||
</Card>
|
تلاش دوباره
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="grid gap-3 sm:grid-cols-2">
|
||||||
|
{(data?.challenges ?? []).map((c) => (
|
||||||
|
<div key={c.id} className="rounded-xl border border-border p-4">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Trophy className="h-4 w-4 text-gold" />
|
||||||
|
<Badge variant={c.isActive ? "success" : "secondary"}>{c.isActive ? "فعال" : "خاموش"}</Badge>
|
||||||
|
<Badge variant="accent">هدف {formatFaNumber(c.targetXp)} امتیاز</Badge>
|
||||||
|
<div className="ms-auto flex gap-1">
|
||||||
|
<Button size="sm" variant="outline" className="h-7 px-2" onClick={() => setModal({ ...c })}>
|
||||||
|
ویرایش
|
||||||
|
</Button>
|
||||||
|
<Button size="sm" variant="ghost" className="h-7 px-2 text-destructive" onClick={() => del(c)}>
|
||||||
|
<Trash2 className="h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p className="mt-2 font-bold">{c.title}</p>
|
||||||
|
<p className="line-clamp-2 text-xs text-muted-foreground">{c.description || "—"}</p>
|
||||||
|
<p className="mt-1.5 text-xs text-muted-foreground">
|
||||||
|
{fmt(c.startsAt)} تا {fmt(c.endsAt)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{!isLoading && !isError && (data?.challenges ?? []).length === 0 && (
|
||||||
|
<p className="rounded-2xl border border-dashed border-border px-4 py-6 text-center text-sm text-muted-foreground">
|
||||||
|
هنوز چالشی نیست.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<ChallengeDialog challenge={modal} onClose={() => setModal(null)} onSave={save} />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -199,11 +195,11 @@ function ChallengeDialog({
|
|||||||
</div>
|
</div>
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<Label>توضیحات</Label>
|
<Label>توضیحات</Label>
|
||||||
<textarea
|
<Textarea
|
||||||
value={draft.description}
|
value={draft.description}
|
||||||
onChange={(e) => set({ description: e.target.value })}
|
onChange={(e) => set({ description: e.target.value })}
|
||||||
rows={3}
|
rows={3}
|
||||||
className="w-full resize-y rounded-md border border-input bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-primary"
|
className="resize-y"
|
||||||
placeholder="هدف و انگیزهٔ چالش…"
|
placeholder="هدف و انگیزهٔ چالش…"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import { Loader2 } from "lucide-react";
|
|||||||
import { http, toUserError } from "@/lib/api";
|
import { http, toUserError } from "@/lib/api";
|
||||||
import type { CertificatePhysicalOrder, PhysicalCertSettings, PhysicalOrderStatus } from "@/lib/types";
|
import type { CertificatePhysicalOrder, PhysicalCertSettings, PhysicalOrderStatus } from "@/lib/types";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { toast } from "@/components/providers";
|
import { toast } from "@/components/providers";
|
||||||
@@ -75,16 +74,16 @@ export function PhysicalOrdersPanel() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card>
|
<div className="space-y-4">
|
||||||
<CardHeader className="flex flex-row flex-wrap items-center justify-between gap-3 space-y-0">
|
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||||
<CardTitle className="text-base">فهرست سفارشها</CardTitle>
|
<p className="text-xs text-muted-foreground">پیگیری درخواست و ارسال گواهینامه فیزیکی.</p>
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
<select
|
<select
|
||||||
className="h-10 rounded-lg border border-input bg-card px-3 text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
className="h-10 rounded-lg border border-input bg-card px-3 text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||||
value={statusFilter}
|
value={statusFilter}
|
||||||
onChange={(e) => setStatusFilter(e.target.value)}
|
onChange={(e) => setStatusFilter(e.target.value)}
|
||||||
>
|
>
|
||||||
<option value="">همه</option>
|
<option value="">همه وضعیتها</option>
|
||||||
<option value="requested">درخواستشده</option>
|
<option value="requested">درخواستشده</option>
|
||||||
<option value="paid">پرداختشده</option>
|
<option value="paid">پرداختشده</option>
|
||||||
<option value="shipped">ارسالشده</option>
|
<option value="shipped">ارسالشده</option>
|
||||||
@@ -97,15 +96,15 @@ export function PhysicalOrdersPanel() {
|
|||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</div>
|
||||||
<CardContent className="space-y-2">
|
<div className="space-y-2">
|
||||||
{isLoading && (
|
{isLoading && (
|
||||||
<div className="flex justify-center py-8">
|
<div className="flex justify-center py-8">
|
||||||
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
|
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{(data?.orders ?? []).map((o) => (
|
{(data?.orders ?? []).map((o) => (
|
||||||
<div key={o.id} className="space-y-2 rounded-md border border-border px-3 py-3">
|
<div key={o.id} className="space-y-2 rounded-xl border border-border px-3 py-3">
|
||||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm font-medium">{o.userName ?? `کاربر ${o.userId}`}</p>
|
<p className="text-sm font-medium">{o.userName ?? `کاربر ${o.userId}`}</p>
|
||||||
@@ -167,7 +166,9 @@ export function PhysicalOrdersPanel() {
|
|||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
{!isLoading && (data?.orders ?? []).length === 0 && (
|
{!isLoading && (data?.orders ?? []).length === 0 && (
|
||||||
<p className="py-8 text-center text-sm text-muted-foreground">سفارشی نیست.</p>
|
<p className="rounded-2xl border border-dashed border-border px-4 py-6 text-center text-sm text-muted-foreground">
|
||||||
|
سفارشی نیست.
|
||||||
|
</p>
|
||||||
)}
|
)}
|
||||||
<ListPagination
|
<ListPagination
|
||||||
page={page}
|
page={page}
|
||||||
@@ -175,7 +176,7 @@ export function PhysicalOrdersPanel() {
|
|||||||
total={data?.total ?? 0}
|
total={data?.total ?? 0}
|
||||||
onPageChange={setPage}
|
onPageChange={setPage}
|
||||||
/>
|
/>
|
||||||
</CardContent>
|
</div>
|
||||||
</Card>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -238,7 +238,7 @@ function AccountFact({
|
|||||||
{icon}
|
{icon}
|
||||||
{label}
|
{label}
|
||||||
</dt>
|
</dt>
|
||||||
<dd className={cn("mt-1.5 text-sm font-semibold leading-relaxed [overflow-wrap:anywhere]", ltr && "font-latin")} dir={ltr ? "ltr" : undefined}>
|
<dd className={cn("mt-1.5 text-sm font-bold leading-relaxed [overflow-wrap:anywhere]", ltr && "font-latin")} dir={ltr ? "ltr" : undefined}>
|
||||||
{value}
|
{value}
|
||||||
</dd>
|
</dd>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { Button } from "@/components/ui/button";
|
|||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
@@ -338,15 +339,15 @@ function QuestionDialog({
|
|||||||
<div className="space-y-5">
|
<div className="space-y-5">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label className="text-sm font-bold">متن سوال</Label>
|
<Label className="text-sm font-bold">متن سوال</Label>
|
||||||
<textarea
|
<Textarea
|
||||||
value={draft.question}
|
value={draft.question}
|
||||||
onChange={(e) => set({ question: e.target.value })}
|
onChange={(e) => set({ question: e.target.value })}
|
||||||
rows={3}
|
rows={3}
|
||||||
autoFocus
|
autoFocus
|
||||||
placeholder="مثلاً نتیجهٔ اجرای git status چیست؟"
|
placeholder="مثلاً نتیجهٔ اجرای git status چیست؟"
|
||||||
className={cn(
|
className={cn(
|
||||||
"w-full resize-y rounded-xl border bg-background px-4 py-3 text-base font-medium leading-relaxed focus:outline-none focus:ring-2 focus:ring-primary",
|
"resize-y",
|
||||||
questionOk ? "border-input" : "border-destructive/40"
|
questionOk ? "" : "border-destructive/40"
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
{!questionOk && (
|
{!questionOk && (
|
||||||
@@ -485,7 +486,7 @@ function QuestionDialog({
|
|||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex h-7 min-w-7 shrink-0 items-center justify-center rounded-md border px-1.5 text-[11px] font-semibold",
|
"flex h-7 min-w-7 shrink-0 items-center justify-center rounded-md border px-1.5 text-[11px] font-bold",
|
||||||
isAns
|
isAns
|
||||||
? "border-primary bg-primary text-primary-foreground"
|
? "border-primary bg-primary text-primary-foreground"
|
||||||
: "border-border text-muted-foreground"
|
: "border-border text-muted-foreground"
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ export function EmptyState({
|
|||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
<div className="space-y-1 px-6">
|
<div className="space-y-1 px-6">
|
||||||
<p className="text-sm font-semibold">{title}</p>
|
<p className="text-sm font-bold">{title}</p>
|
||||||
{description && <p className="text-xs leading-relaxed text-muted-foreground sm:text-sm">{description}</p>}
|
{description && <p className="text-xs leading-relaxed text-muted-foreground sm:text-sm">{description}</p>}
|
||||||
</div>
|
</div>
|
||||||
{action && <div className="mt-1">{action}</div>}
|
{action && <div className="mt-1">{action}</div>}
|
||||||
|
|||||||
@@ -1,65 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { useEffect, useState } from "react";
|
|
||||||
import { useRouter } from "next/navigation";
|
|
||||||
import { Search } from "lucide-react";
|
|
||||||
import { http } from "@/lib/api";
|
|
||||||
|
|
||||||
type Hit = { kind: string; title: string; href: string };
|
|
||||||
|
|
||||||
export function GlobalSearch() {
|
|
||||||
const router = useRouter();
|
|
||||||
const [q, setQ] = useState("");
|
|
||||||
const [hits, setHits] = useState<Hit[]>([]);
|
|
||||||
const [open, setOpen] = useState(false);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const query = q.trim();
|
|
||||||
if (query.length < 2) {
|
|
||||||
setHits([]);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const t = setTimeout(() => {
|
|
||||||
void http
|
|
||||||
.get<{ results: Hit[] }>(`/api/search?q=${encodeURIComponent(query)}`)
|
|
||||||
.then((res) => setHits(res.results ?? []))
|
|
||||||
.catch(() => setHits([]));
|
|
||||||
}, 250);
|
|
||||||
return () => clearTimeout(t);
|
|
||||||
}, [q]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="relative min-w-0 w-28 sm:w-56">
|
|
||||||
<Search className="pointer-events-none absolute start-2.5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground" />
|
|
||||||
<input
|
|
||||||
value={q}
|
|
||||||
onChange={(e) => {
|
|
||||||
setQ(e.target.value);
|
|
||||||
setOpen(true);
|
|
||||||
}}
|
|
||||||
onFocus={() => setOpen(true)}
|
|
||||||
placeholder="جستجو"
|
|
||||||
className="h-9 w-full rounded-full border border-border bg-background/70 ps-8 pe-3 text-sm outline-none focus:border-primary"
|
|
||||||
/>
|
|
||||||
{open && hits.length > 0 && (
|
|
||||||
<div className="absolute z-50 mt-1 w-full overflow-hidden rounded-xl border bg-popover shadow-lg">
|
|
||||||
{hits.map((h) => (
|
|
||||||
<button
|
|
||||||
key={h.kind + h.href + h.title}
|
|
||||||
type="button"
|
|
||||||
className="block w-full px-3 py-2 text-start text-sm hover:bg-muted"
|
|
||||||
onClick={() => {
|
|
||||||
setOpen(false);
|
|
||||||
setQ("");
|
|
||||||
router.push(h.href);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<span className="text-[10px] text-muted-foreground">{h.kind}</span>
|
|
||||||
<span className="block truncate font-bold">{h.title}</span>
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -64,7 +64,7 @@ export function NotificationBell() {
|
|||||||
<div dir="rtl" className="text-start">
|
<div dir="rtl" className="text-start">
|
||||||
<div className="flex items-center justify-between gap-3 border-b border-border px-3 py-2.5">
|
<div className="flex items-center justify-between gap-3 border-b border-border px-3 py-2.5">
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<p className="text-sm font-semibold">اعلانها</p>
|
<p className="text-sm font-bold">اعلانها</p>
|
||||||
<p className="text-[11px] text-muted-foreground">
|
<p className="text-[11px] text-muted-foreground">
|
||||||
{unread > 0 ? `${unread} خواندهنشده` : "همه خوانده شده"}
|
{unread > 0 ? `${unread} خواندهنشده` : "همه خوانده شده"}
|
||||||
</p>
|
</p>
|
||||||
@@ -133,7 +133,7 @@ export function NotificationBell() {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => router.push("/notifications")}
|
onClick={() => router.push("/notifications")}
|
||||||
className="w-full border-t border-border bg-muted/40 py-2.5 text-xs font-semibold text-primary hover:bg-muted"
|
className="w-full border-t border-border bg-muted/40 py-2.5 text-xs font-bold text-primary hover:bg-muted"
|
||||||
>
|
>
|
||||||
مشاهده همه اعلانها
|
مشاهده همه اعلانها
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -119,7 +119,7 @@ export function QuizExperience({ lessonId }: { lessonId: number }) {
|
|||||||
return (
|
return (
|
||||||
<Card className="mx-auto max-w-md p-8 text-center">
|
<Card className="mx-auto max-w-md p-8 text-center">
|
||||||
<Lock className="mx-auto mb-3 h-10 w-10 text-destructive" />
|
<Lock className="mx-auto mb-3 h-10 w-10 text-destructive" />
|
||||||
<h2 className="mb-2 text-lg font-semibold">آزمون در دسترس نیست</h2>
|
<h2 className="mb-2 text-lg font-bold">آزمون در دسترس نیست</h2>
|
||||||
<p className="mb-4 text-sm text-muted-foreground">{msg}</p>
|
<p className="mb-4 text-sm text-muted-foreground">{msg}</p>
|
||||||
<Button onClick={() => router.push(`/player/${lessonId}`)}>ابتدا درس را تماشا کنید</Button>
|
<Button onClick={() => router.push(`/player/${lessonId}`)}>ابتدا درس را تماشا کنید</Button>
|
||||||
</Card>
|
</Card>
|
||||||
@@ -188,7 +188,7 @@ export function QuizExperience({ lessonId }: { lessonId: number }) {
|
|||||||
<Badge variant="secondary" className="h-6 w-6 justify-center rounded-md p-0">
|
<Badge variant="secondary" className="h-6 w-6 justify-center rounded-md p-0">
|
||||||
{qi + 1}
|
{qi + 1}
|
||||||
</Badge>
|
</Badge>
|
||||||
<p className="font-medium leading-snug">{q.question}</p>
|
<p className="font-bold leading-snug">{q.question}</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid gap-2">
|
<div className="grid gap-2">
|
||||||
{q.options.map((opt, oi) => {
|
{q.options.map((opt, oi) => {
|
||||||
@@ -198,7 +198,7 @@ export function QuizExperience({ lessonId }: { lessonId: number }) {
|
|||||||
key={oi}
|
key={oi}
|
||||||
onClick={() => select(q.id, oi)}
|
onClick={() => select(q.id, oi)}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex items-center gap-3 rounded-md border px-4 py-3 text-right text-sm transition-colors",
|
"flex items-center gap-3 rounded-md border-2 px-4 py-3 text-right text-sm transition-colors",
|
||||||
active
|
active
|
||||||
? "border-primary bg-primary/10 ring-1 ring-primary"
|
? "border-primary bg-primary/10 ring-1 ring-primary"
|
||||||
: "border-border hover:bg-muted/50"
|
: "border-border hover:bg-muted/50"
|
||||||
@@ -206,7 +206,7 @@ export function QuizExperience({ lessonId }: { lessonId: number }) {
|
|||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex h-7 min-w-7 shrink-0 items-center justify-center rounded-md border px-1.5 text-[11px] font-semibold",
|
"flex h-7 min-w-7 shrink-0 items-center justify-center rounded-md border px-1.5 text-[11px] font-bold",
|
||||||
active ? "border-primary bg-primary text-primary-foreground" : "border-border text-muted-foreground"
|
active ? "border-primary bg-primary text-primary-foreground" : "border-border text-muted-foreground"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
@@ -305,7 +305,7 @@ function HeartsMeterInline({ hearts, infinite }: { hearts: number; infinite?: bo
|
|||||||
|
|
||||||
function PassScreen({ result, onNext, onLeagues }: { result: QuizResult; onNext: () => void; onLeagues: () => void }) {
|
function PassScreen({ result, onNext, onLeagues }: { result: QuizResult; onNext: () => void; onLeagues: () => void }) {
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto max-w-md rounded-lg border border-border bg-card p-10 text-center">
|
<div className="mx-auto max-w-md rounded-2xl border-2 border-border bg-card p-10 text-center">
|
||||||
<div className="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-lg bg-primary">
|
<div className="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-lg bg-primary">
|
||||||
<PartyPopper className="h-9 w-9 text-primary-foreground" />
|
<PartyPopper className="h-9 w-9 text-primary-foreground" />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -103,21 +103,26 @@ export function SectionPanel({
|
|||||||
titleId,
|
titleId,
|
||||||
title,
|
title,
|
||||||
description,
|
description,
|
||||||
|
actions,
|
||||||
children,
|
children,
|
||||||
}: {
|
}: {
|
||||||
id?: string;
|
id?: string;
|
||||||
titleId: string;
|
titleId: string;
|
||||||
title: string;
|
title: string;
|
||||||
description: string;
|
description: string;
|
||||||
|
actions?: ReactNode;
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<section id={id} aria-labelledby={titleId} className="min-w-0 rounded-2xl border-2 border-border bg-card">
|
<section id={id} aria-labelledby={titleId} className="min-w-0 rounded-2xl border-2 border-border bg-card">
|
||||||
<header className="border-b border-border/70 px-5 py-4 sm:px-6">
|
<header className="border-b border-border/70 px-5 py-4 sm:px-6">
|
||||||
<h2 id={titleId} className="text-lg font-black">
|
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||||
{title}
|
<div className="min-w-0">
|
||||||
</h2>
|
<h2 id={titleId} className="text-lg font-black">{title}</h2>
|
||||||
<p className="mt-1 text-sm text-muted-foreground">{description}</p>
|
{description && <p className="mt-1 text-sm text-muted-foreground">{description}</p>}
|
||||||
|
</div>
|
||||||
|
{actions && <div className="shrink-0">{actions}</div>}
|
||||||
|
</div>
|
||||||
</header>
|
</header>
|
||||||
<div className="p-5 sm:p-6">{children}</div>
|
<div className="p-5 sm:p-6">{children}</div>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ CardHeader.displayName = "CardHeader";
|
|||||||
|
|
||||||
const CardTitle = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
const CardTitle = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||||
({ className, ...props }, ref) => (
|
({ className, ...props }, ref) => (
|
||||||
<div ref={ref} className={cn("text-xl font-semibold leading-none", className)} {...props} />
|
<div ref={ref} className={cn("text-xl font-bold leading-none", className)} {...props} />
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
CardTitle.displayName = "CardTitle";
|
CardTitle.displayName = "CardTitle";
|
||||||
|
|||||||
@@ -78,7 +78,7 @@ const DialogTitle = React.forwardRef<
|
|||||||
>(({ className, ...props }, ref) => (
|
>(({ className, ...props }, ref) => (
|
||||||
<DialogPrimitive.Title
|
<DialogPrimitive.Title
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn("text-lg font-semibold leading-none tracking-tight", className)}
|
className={cn("text-lg font-bold leading-none tracking-tight", className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
));
|
));
|
||||||
|
|||||||
@@ -16,12 +16,13 @@ const Progress = React.forwardRef<HTMLDivElement, ProgressProps>(
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
ref={ref}
|
ref={ref}
|
||||||
|
dir="ltr"
|
||||||
className={cn("relative h-2.5 w-full overflow-hidden rounded-full bg-muted", className)}
|
className={cn("relative h-2.5 w-full overflow-hidden rounded-full bg-muted", className)}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"h-full rounded-full will-change-[width] rtl:me-auto",
|
"h-full rounded-full will-change-[width]",
|
||||||
gradient ? "bg-primary" : "bg-primary",
|
gradient ? "bg-primary" : "bg-primary",
|
||||||
smooth
|
smooth
|
||||||
? "transition-[width] duration-1000 ease-[cubic-bezier(0.22,1,0.36,1)]"
|
? "transition-[width] duration-1000 ease-[cubic-bezier(0.22,1,0.36,1)]"
|
||||||
|
|||||||
Reference in New Issue
Block a user