feat: add dedicated notifications page
Replace the bell popover with an RTL notification inbox, inline Markdown details, read filters, and explicit destination and delete actions. Remove retired email-link screens and client calls.
This commit is contained in:
@@ -31,7 +31,7 @@ The production runtime serves on port `3000` inside Docker. Dockerfiles live onl
|
|||||||
|
|
||||||
## Routes
|
## Routes
|
||||||
- Public SEO pages: `/`, `/about`, `/blog`, `/blog/[slug]`, `/events`, `/events/[slug]`
|
- Public SEO pages: `/`, `/about`, `/blog`, `/blog/[slug]`, `/events`, `/events/[slug]`
|
||||||
- Client-heavy flows: `/auth`, `/profile`, `/logout`, `/payments/result`, `/reset-password/*`, `/verify-email/*`
|
- Client-heavy flows: `/auth`, `/profile`, `/notifications`, `/logout`, `/payments/result`, `/reset-password`
|
||||||
- Admin: `/admin/*`
|
- Admin: `/admin/*`
|
||||||
|
|
||||||
## Validation
|
## Validation
|
||||||
|
|||||||
11
src/app/notifications/page.tsx
Normal file
11
src/app/notifications/page.tsx
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
import NotificationsPageContent from "@/components/NotificationsPageContent";
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: "اعلانهای من",
|
||||||
|
robots: { index: false, follow: false },
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function NotificationsPage() {
|
||||||
|
return <NotificationsPageContent />;
|
||||||
|
}
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
import type { Metadata } from "next";
|
|
||||||
import ResetPasswordConfirm from "@/views/ResetPasswordConfirm";
|
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
|
||||||
title: "تعیین رمز جدید",
|
|
||||||
robots: { index: false, follow: false },
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function ResetPasswordConfirmPage() {
|
|
||||||
return <ResetPasswordConfirm />;
|
|
||||||
}
|
|
||||||
@@ -14,7 +14,6 @@ export default function robots(): MetadataRoute.Robots {
|
|||||||
"/profile",
|
"/profile",
|
||||||
"/payments/",
|
"/payments/",
|
||||||
"/reset-password",
|
"/reset-password",
|
||||||
"/verify-email/",
|
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -1,11 +0,0 @@
|
|||||||
import type { Metadata } from "next";
|
|
||||||
import VerifyEmail from "@/views/VerifyEmail";
|
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
|
||||||
title: "تأیید ایمیل",
|
|
||||||
robots: { index: false, follow: false },
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function VerifyEmailPage() {
|
|
||||||
return <VerifyEmail />;
|
|
||||||
}
|
|
||||||
@@ -20,8 +20,7 @@ const isProfileOrAuthPath = (pathname: string) =>
|
|||||||
pathname.startsWith("/profile/") ||
|
pathname.startsWith("/profile/") ||
|
||||||
pathname === "/auth" ||
|
pathname === "/auth" ||
|
||||||
pathname.startsWith("/auth/") ||
|
pathname.startsWith("/auth/") ||
|
||||||
pathname.startsWith("/reset-password") ||
|
pathname.startsWith("/reset-password");
|
||||||
pathname.startsWith("/verify-email");
|
|
||||||
|
|
||||||
export default function MobileBottomNav() {
|
export default function MobileBottomNav() {
|
||||||
const pathname = usePathname() || "/";
|
const pathname = usePathname() || "/";
|
||||||
|
|||||||
@@ -1,184 +1,24 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { Bell, CheckCheck, Loader2, Trash2 } from "lucide-react";
|
import { Bell } from "lucide-react";
|
||||||
import ConfirmAction from "@/components/ConfirmAction";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { Badge } from "@/components/ui/badge";
|
|
||||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
|
||||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
|
||||||
import { useNotifications } from "@/contexts/NotificationsContext";
|
import { useNotifications } from "@/contexts/NotificationsContext";
|
||||||
import type { NotificationSchema } from "@/lib/types";
|
import { Link } from "@/lib/router";
|
||||||
import { cn, formatJalali } from "@/lib/utils";
|
|
||||||
|
|
||||||
const connectionLabels = {
|
|
||||||
idle: "خاموش",
|
|
||||||
connecting: "در حال اتصال",
|
|
||||||
connected: "متصل",
|
|
||||||
disconnected: "قطع شده",
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
function NotificationItem({
|
|
||||||
notification,
|
|
||||||
onOpen,
|
|
||||||
onDelete,
|
|
||||||
}: {
|
|
||||||
notification: NotificationSchema;
|
|
||||||
onOpen: (notification: NotificationSchema) => Promise<unknown>;
|
|
||||||
onDelete: (notification: NotificationSchema) => Promise<unknown>;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
"rounded-2xl border border-border/70 bg-background/75 p-3 text-right transition hover:border-primary/30 hover:bg-muted/35",
|
|
||||||
!notification.is_seen && "border-primary/30 bg-primary/5",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<div className="flex items-start justify-between gap-3">
|
|
||||||
<ConfirmAction
|
|
||||||
title="حذف اعلان"
|
|
||||||
description="آیا از حذف این اعلان مطمئن هستید؟"
|
|
||||||
onConfirm={() => onDelete(notification)}
|
|
||||||
trigger={
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
size="icon"
|
|
||||||
variant="ghost"
|
|
||||||
className="h-8 w-8 shrink-0 rounded-full text-muted-foreground hover:text-destructive"
|
|
||||||
>
|
|
||||||
<Trash2 className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => void onOpen(notification)}
|
|
||||||
className="min-w-0 flex-1 space-y-1 text-right"
|
|
||||||
>
|
|
||||||
<div className="flex items-center justify-end gap-2">
|
|
||||||
{!notification.is_seen ? (
|
|
||||||
<span className="h-2.5 w-2.5 rounded-full bg-primary" />
|
|
||||||
) : null}
|
|
||||||
<p className="truncate font-semibold">{notification.title}</p>
|
|
||||||
</div>
|
|
||||||
<p className="line-clamp-2 text-sm text-muted-foreground">
|
|
||||||
{notification.message}
|
|
||||||
</p>
|
|
||||||
<p className="text-xs text-muted-foreground/80">
|
|
||||||
{formatJalali(notification.created_at, false)}
|
|
||||||
</p>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function NotificationsBell() {
|
export default function NotificationsBell() {
|
||||||
const {
|
const { unreadCount } = useNotifications();
|
||||||
notifications,
|
|
||||||
unreadCount,
|
|
||||||
totalCount,
|
|
||||||
hasMore,
|
|
||||||
isLoading,
|
|
||||||
isLoadingMore,
|
|
||||||
connectionStatus,
|
|
||||||
loadMore,
|
|
||||||
markAllAsSeen,
|
|
||||||
deleteNotification,
|
|
||||||
openNotification,
|
|
||||||
} = useNotifications();
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Popover>
|
<Link
|
||||||
<PopoverTrigger asChild>
|
to="/notifications"
|
||||||
<Button
|
aria-label={unreadCount ? `اعلانها، ${unreadCount} خواندهنشده` : "اعلانها"}
|
||||||
type="button"
|
className="relative inline-flex h-10 w-10 items-center justify-center rounded-full text-foreground transition hover:bg-background/45"
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
className="relative h-10 w-10 rounded-full border-0 bg-transparent shadow-none transition hover:bg-background/45 hover:shadow-sm"
|
|
||||||
aria-label="اعلانها"
|
|
||||||
>
|
>
|
||||||
<Bell className="h-4 w-4" />
|
<Bell className="h-5 w-5" aria-hidden="true" />
|
||||||
{unreadCount > 0 ? (
|
{unreadCount > 0 ? (
|
||||||
<span className="absolute -left-1 -top-1 flex min-w-5 items-center justify-center rounded-full bg-primary px-1.5 py-0.5 text-[10px] font-semibold text-primary-foreground">
|
<span className="absolute -left-1 -top-1 flex min-w-5 items-center justify-center rounded-full bg-primary px-1.5 py-0.5 text-[10px] font-semibold text-primary-foreground">
|
||||||
{unreadCount > 9 ? "9+" : unreadCount}
|
{unreadCount > 9 ? "9+" : unreadCount}
|
||||||
</span>
|
</span>
|
||||||
) : null}
|
) : null}
|
||||||
</Button>
|
</Link>
|
||||||
</PopoverTrigger>
|
|
||||||
<PopoverContent className="w-[min(92vw,26rem)] rounded-[1.5rem] border border-border/70 bg-background/95 p-0 shadow-xl backdrop-blur-xl" align="end" sideOffset={12}>
|
|
||||||
<div className="border-b border-border/70 px-4 py-4 text-right">
|
|
||||||
<div className="flex items-center justify-between gap-3">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Badge variant={connectionStatus === "connected" ? "default" : "secondary"}>
|
|
||||||
{connectionLabels[connectionStatus]}
|
|
||||||
</Badge>
|
|
||||||
{unreadCount > 0 ? (
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
className="rounded-full"
|
|
||||||
onClick={() => void markAllAsSeen()}
|
|
||||||
>
|
|
||||||
<CheckCheck className="ml-2 h-4 w-4" />
|
|
||||||
خواندن همه
|
|
||||||
</Button>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p className="font-semibold">اعلانها</p>
|
|
||||||
<p className="mt-1 text-xs text-muted-foreground">
|
|
||||||
{totalCount > 0 ? `${totalCount} مورد ثبت شده` : "هنوز اعلانی ندارید."}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<ScrollArea className="max-h-[24rem] px-4 py-4">
|
|
||||||
<div className="space-y-3">
|
|
||||||
{isLoading ? (
|
|
||||||
<div className="flex items-center justify-center gap-2 py-10 text-sm text-muted-foreground">
|
|
||||||
<Loader2 className="h-4 w-4 animate-spin" />
|
|
||||||
در حال بارگذاری اعلانها...
|
|
||||||
</div>
|
|
||||||
) : notifications.length ? (
|
|
||||||
notifications.map((notification) => (
|
|
||||||
<NotificationItem
|
|
||||||
key={notification.id}
|
|
||||||
notification={notification}
|
|
||||||
onOpen={openNotification}
|
|
||||||
onDelete={deleteNotification}
|
|
||||||
/>
|
|
||||||
))
|
|
||||||
) : (
|
|
||||||
<div className="rounded-2xl border border-dashed border-border/70 bg-muted/20 px-4 py-10 text-center text-sm text-muted-foreground">
|
|
||||||
اعلان تازهای برای شما ثبت نشده است.
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</ScrollArea>
|
|
||||||
|
|
||||||
{hasMore ? (
|
|
||||||
<div className="border-t border-border/70 px-4 py-3">
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="secondary"
|
|
||||||
className="w-full rounded-2xl"
|
|
||||||
onClick={() => void loadMore()}
|
|
||||||
disabled={isLoadingMore}
|
|
||||||
>
|
|
||||||
{isLoadingMore ? (
|
|
||||||
<>
|
|
||||||
<Loader2 className="ml-2 h-4 w-4 animate-spin" />
|
|
||||||
در حال بارگذاری...
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
"نمایش موارد بیشتر"
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</PopoverContent>
|
|
||||||
</Popover>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
255
src/components/NotificationsPageContent.tsx
Normal file
255
src/components/NotificationsPageContent.tsx
Normal file
@@ -0,0 +1,255 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
|
import { ArrowLeft, ChevronDown, Loader2, Trash2 } from "lucide-react";
|
||||||
|
import ConfirmAction from "@/components/ConfirmAction";
|
||||||
|
import Markdown from "@/components/Markdown";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { useAuth } from "@/contexts/AuthContext";
|
||||||
|
import { useNotifications } from "@/contexts/NotificationsContext";
|
||||||
|
import { toast } from "@/hooks/use-toast";
|
||||||
|
import { api } from "@/lib/api";
|
||||||
|
import { Link } from "@/lib/router";
|
||||||
|
import type { NotificationDetailSchema, NotificationSchema } from "@/lib/types";
|
||||||
|
import { cn, formatJalaliNumericDate } from "@/lib/utils";
|
||||||
|
|
||||||
|
type ReadFilter = "all" | "unread" | "read";
|
||||||
|
const PAGE_SIZE = 12;
|
||||||
|
|
||||||
|
function safeDestination(url?: string | null) {
|
||||||
|
if (!url) return null;
|
||||||
|
if (url.startsWith("/") && !url.startsWith("//")) return url;
|
||||||
|
if (/^https?:\/\//i.test(url)) return url;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function NotificationsPageContent() {
|
||||||
|
const { isAuthenticated, loading: authLoading } = useAuth();
|
||||||
|
const { notifications: liveNotifications, markAsSeen, deleteNotification } = useNotifications();
|
||||||
|
const knownLiveIds = useRef<Set<string> | null>(null);
|
||||||
|
const requestVersion = useRef(0);
|
||||||
|
const [filter, setFilter] = useState<ReadFilter>("all");
|
||||||
|
const [items, setItems] = useState<NotificationSchema[]>([]);
|
||||||
|
const [count, setCount] = useState(0);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [loadingMore, setLoadingMore] = useState(false);
|
||||||
|
const [expandedId, setExpandedId] = useState<string | null>(null);
|
||||||
|
const [detail, setDetail] = useState<NotificationDetailSchema | null>(null);
|
||||||
|
const [openingId, setOpeningId] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const loadFirst = useCallback(async (status: ReadFilter) => {
|
||||||
|
const version = ++requestVersion.current;
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const result = await api.getNotifications({ limit: PAGE_SIZE, status });
|
||||||
|
if (version === requestVersion.current) {
|
||||||
|
setItems(result.notifications);
|
||||||
|
setCount(result.count);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
if (version === requestVersion.current) toast({ title: "بارگذاری اعلانها انجام نشد.", variant: "destructive" });
|
||||||
|
} finally {
|
||||||
|
if (version === requestVersion.current) setLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (authLoading) return;
|
||||||
|
if (!isAuthenticated) {
|
||||||
|
setItems([]);
|
||||||
|
setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setExpandedId(null);
|
||||||
|
setDetail(null);
|
||||||
|
void loadFirst(filter);
|
||||||
|
}, [authLoading, filter, isAuthenticated, loadFirst]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const nextIds = new Set(liveNotifications.map((item) => item.id));
|
||||||
|
const previousIds = knownLiveIds.current;
|
||||||
|
knownLiveIds.current = nextIds;
|
||||||
|
if (previousIds && liveNotifications.some((item) => !previousIds.has(item.id)) && isAuthenticated && !expandedId) {
|
||||||
|
void loadFirst(filter);
|
||||||
|
}
|
||||||
|
}, [expandedId, filter, isAuthenticated, liveNotifications, loadFirst]);
|
||||||
|
|
||||||
|
const loadMore = async () => {
|
||||||
|
if (loadingMore) return;
|
||||||
|
const version = requestVersion.current;
|
||||||
|
setLoadingMore(true);
|
||||||
|
try {
|
||||||
|
const offset = filter === "unread" ? items.filter((item) => !item.is_seen).length : items.length;
|
||||||
|
const result = await api.getNotifications({ limit: PAGE_SIZE, offset, status: filter });
|
||||||
|
if (version !== requestVersion.current) return;
|
||||||
|
setItems((current) => {
|
||||||
|
const existing = new Set(current.map((item) => item.id));
|
||||||
|
return [...current, ...result.notifications.filter((item) => !existing.has(item.id))];
|
||||||
|
});
|
||||||
|
setCount(result.count);
|
||||||
|
} catch {
|
||||||
|
if (version === requestVersion.current) toast({ title: "بارگذاری اعلانهای بیشتر انجام نشد.", variant: "destructive" });
|
||||||
|
} finally {
|
||||||
|
setLoadingMore(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const close = () => {
|
||||||
|
setExpandedId(null);
|
||||||
|
setDetail(null);
|
||||||
|
void loadFirst(filter);
|
||||||
|
};
|
||||||
|
|
||||||
|
const open = async (item: NotificationSchema) => {
|
||||||
|
if (expandedId === item.id) {
|
||||||
|
close();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const version = requestVersion.current;
|
||||||
|
setOpeningId(item.id);
|
||||||
|
try {
|
||||||
|
const result = await api.getNotification(item.id);
|
||||||
|
if (version !== requestVersion.current) return;
|
||||||
|
if (expandedId && filter === "unread") {
|
||||||
|
setItems((current) => current.filter((entry) => entry.id !== expandedId));
|
||||||
|
}
|
||||||
|
setDetail(result);
|
||||||
|
setExpandedId(item.id);
|
||||||
|
if (!item.is_seen) {
|
||||||
|
const seen = await markAsSeen(item);
|
||||||
|
if (seen?.deleted) {
|
||||||
|
setItems((current) => current.filter((entry) => entry.id !== item.id));
|
||||||
|
setCount((current) => Math.max(0, current - 1));
|
||||||
|
setExpandedId(null);
|
||||||
|
setDetail(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setItems((current) => current.map((entry) => entry.id === item.id ? { ...entry, is_seen: true } : entry));
|
||||||
|
if (filter === "unread") setCount((current) => Math.max(0, current - 1));
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
toast({ title: "باز کردن اعلان انجام نشد.", variant: "destructive" });
|
||||||
|
} finally {
|
||||||
|
setOpeningId(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const remove = async (item: NotificationSchema) => {
|
||||||
|
try {
|
||||||
|
await deleteNotification(item);
|
||||||
|
setItems((current) => current.filter((entry) => entry.id !== item.id));
|
||||||
|
if (filter !== "unread" || !item.is_seen) setCount((current) => Math.max(0, current - 1));
|
||||||
|
if (expandedId === item.id) {
|
||||||
|
setExpandedId(null);
|
||||||
|
setDetail(null);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
toast({ title: "حذف اعلان انجام نشد.", variant: "destructive" });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (authLoading) {
|
||||||
|
return <div className="mx-auto max-w-6xl px-4 py-16 text-center text-muted-foreground">در حال بارگذاری...</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isAuthenticated) {
|
||||||
|
return (
|
||||||
|
<main className="mx-auto max-w-6xl px-4 py-16 text-center" dir="rtl">
|
||||||
|
<h1 className="text-2xl font-bold">اعلانهای من</h1>
|
||||||
|
<p className="mt-4 text-muted-foreground">برای دیدن اعلانهای خود وارد حساب شوید.</p>
|
||||||
|
<Button asChild className="mt-6"><Link to="/auth">ورود به حساب</Link></Button>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="min-h-[70vh] bg-[#f7f8fa] px-3 py-5 text-foreground dark:bg-background sm:px-5 sm:py-8" dir="rtl">
|
||||||
|
<div className="mx-auto max-w-6xl space-y-5">
|
||||||
|
<header className="flex flex-wrap items-center justify-between gap-4 rounded-2xl border border-border/70 bg-background px-5 py-5 shadow-sm sm:px-7">
|
||||||
|
<h1 className="text-xl font-bold sm:text-2xl">اعلانهای من</h1>
|
||||||
|
<label className="flex items-center gap-3 text-sm">
|
||||||
|
<span className="text-muted-foreground">نمایش:</span>
|
||||||
|
<span className="relative">
|
||||||
|
<select
|
||||||
|
value={filter}
|
||||||
|
onChange={(event) => setFilter(event.target.value as ReadFilter)}
|
||||||
|
className="min-w-28 appearance-none rounded-lg border border-transparent bg-transparent py-2 pe-8 ps-2 font-semibold outline-none focus-visible:border-primary"
|
||||||
|
aria-label="فیلتر اعلانها"
|
||||||
|
>
|
||||||
|
<option value="all">همه</option>
|
||||||
|
<option value="unread">خواندهنشده</option>
|
||||||
|
<option value="read">خواندهشده</option>
|
||||||
|
</select>
|
||||||
|
<ChevronDown className="pointer-events-none absolute end-2 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<div className="flex items-center justify-center gap-2 py-16 text-muted-foreground"><Loader2 className="h-5 w-5 animate-spin" />در حال بارگذاری اعلانها...</div>
|
||||||
|
) : items.length === 0 ? (
|
||||||
|
<div className="rounded-2xl bg-background px-6 py-16 text-center text-muted-foreground">اعلانی برای نمایش وجود ندارد.</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{items.map((item) => {
|
||||||
|
const expanded = expandedId === item.id;
|
||||||
|
const destination = safeDestination(item.action_url);
|
||||||
|
return (
|
||||||
|
<article key={item.id} className={cn("overflow-hidden rounded-2xl bg-background shadow-sm", !item.is_seen && "ring-1 ring-primary/25")}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={Boolean(openingId)}
|
||||||
|
onClick={() => void open(item)}
|
||||||
|
aria-expanded={expanded}
|
||||||
|
aria-controls={`notification-body-${item.id}`}
|
||||||
|
className="flex min-h-20 w-full flex-col items-start justify-center gap-2 px-5 py-4 text-right transition hover:bg-muted/30 sm:flex-row sm:items-center sm:justify-between sm:gap-6 sm:px-7"
|
||||||
|
>
|
||||||
|
<span className="flex min-w-0 items-center gap-2 font-medium leading-7">
|
||||||
|
{!item.is_seen && <span className="h-2 w-2 shrink-0 rounded-full bg-primary" aria-label="خواندهنشده" />}
|
||||||
|
<span>{item.title}</span>
|
||||||
|
{openingId === item.id && <Loader2 className="h-4 w-4 animate-spin" />}
|
||||||
|
</span>
|
||||||
|
<span className="shrink-0 text-xs text-muted-foreground">ارسال شده {formatJalaliNumericDate(item.created_at)}</span>
|
||||||
|
</button>
|
||||||
|
{expanded && detail && (
|
||||||
|
<div id={`notification-body-${item.id}`}>
|
||||||
|
<div className="border-t border-border/70 px-5 py-5 text-sm text-muted-foreground sm:px-7">
|
||||||
|
{detail.body_markdown ? (
|
||||||
|
<>
|
||||||
|
<p className="mb-3">{detail.message}</p>
|
||||||
|
<Markdown content={detail.body_markdown} allowHtml={false} size="sm" />
|
||||||
|
</>
|
||||||
|
) : <p className="leading-8">{detail.message}</p>}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between border-t border-border/70 px-5 py-3 sm:px-7">
|
||||||
|
<ConfirmAction
|
||||||
|
title="حذف اعلان"
|
||||||
|
description="آیا از حذف این اعلان مطمئن هستید؟"
|
||||||
|
onConfirm={() => remove(item)}
|
||||||
|
trigger={<Button type="button" variant="ghost" size="sm" className="gap-2 text-muted-foreground"><Trash2 className="h-4 w-4" />حذف کردن</Button>}
|
||||||
|
/>
|
||||||
|
{destination ? (
|
||||||
|
<a href={destination} className="inline-flex items-center gap-2 text-sm font-medium text-primary hover:underline" rel={destination.startsWith("http") ? "noopener noreferrer" : undefined}>
|
||||||
|
مشاهده <ArrowLeft className="h-4 w-4" />
|
||||||
|
</a>
|
||||||
|
) : <span />}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</article>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!loading && (filter === "unread" ? items.filter((item) => !item.is_seen).length : items.length) < count && (
|
||||||
|
<div className="text-center">
|
||||||
|
<Button type="button" variant="outline" onClick={() => void loadMore()} disabled={loadingMore}>
|
||||||
|
{loadingMore ? "در حال بارگذاری..." : "نمایش موارد بیشتر"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -35,7 +35,6 @@ type NotificationsContextValue = {
|
|||||||
markAsSeen: (notification: NotificationSchema) => Promise<NotificationSeenResponseSchema | null>;
|
markAsSeen: (notification: NotificationSchema) => Promise<NotificationSeenResponseSchema | null>;
|
||||||
deleteNotification: (notification: NotificationSchema) => Promise<NotificationDeleteResponseSchema | null>;
|
deleteNotification: (notification: NotificationSchema) => Promise<NotificationDeleteResponseSchema | null>;
|
||||||
markAllAsSeen: () => Promise<void>;
|
markAllAsSeen: () => Promise<void>;
|
||||||
openNotification: (notification: NotificationSchema) => Promise<void>;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const NotificationsContext = createContext<NotificationsContextValue | undefined>(undefined);
|
const NotificationsContext = createContext<NotificationsContextValue | undefined>(undefined);
|
||||||
@@ -58,19 +57,6 @@ const mergeNotifications = (
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const openNotificationTarget = (notification: NotificationSchema) => {
|
|
||||||
if (typeof window === "undefined" || !notification.action_url) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const targetUrl = notification.action_url;
|
|
||||||
if (/^https?:\/\//i.test(targetUrl)) {
|
|
||||||
window.open(targetUrl, "_blank", "noopener,noreferrer");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
window.location.assign(targetUrl);
|
|
||||||
};
|
|
||||||
|
|
||||||
export function NotificationsProvider({ children }: { children: ReactNode }) {
|
export function NotificationsProvider({ children }: { children: ReactNode }) {
|
||||||
const { isAuthenticated } = useAuth();
|
const { isAuthenticated } = useAuth();
|
||||||
const [notifications, setNotifications] = useState<NotificationSchema[]>([]);
|
const [notifications, setNotifications] = useState<NotificationSchema[]>([]);
|
||||||
@@ -185,11 +171,6 @@ export function NotificationsProvider({ children }: { children: ReactNode }) {
|
|||||||
);
|
);
|
||||||
}, [isAuthenticated]);
|
}, [isAuthenticated]);
|
||||||
|
|
||||||
const openNotification = useCallback(async (notification: NotificationSchema) => {
|
|
||||||
await markAsSeen(notification);
|
|
||||||
openNotificationTarget(notification);
|
|
||||||
}, [markAsSeen]);
|
|
||||||
|
|
||||||
const announceIncomingNotification = useCallback((notification: NotificationSchema) => {
|
const announceIncomingNotification = useCallback((notification: NotificationSchema) => {
|
||||||
if (notification.is_seen || shownToastIdsRef.current.has(notification.id)) {
|
if (notification.is_seen || shownToastIdsRef.current.has(notification.id)) {
|
||||||
return;
|
return;
|
||||||
@@ -355,7 +336,6 @@ export function NotificationsProvider({ children }: { children: ReactNode }) {
|
|||||||
markAsSeen,
|
markAsSeen,
|
||||||
deleteNotification,
|
deleteNotification,
|
||||||
markAllAsSeen,
|
markAllAsSeen,
|
||||||
openNotification,
|
|
||||||
}), [
|
}), [
|
||||||
connectionStatus,
|
connectionStatus,
|
||||||
deleteNotification,
|
deleteNotification,
|
||||||
@@ -366,7 +346,6 @@ export function NotificationsProvider({ children }: { children: ReactNode }) {
|
|||||||
markAllAsSeen,
|
markAllAsSeen,
|
||||||
markAsSeen,
|
markAsSeen,
|
||||||
notifications,
|
notifications,
|
||||||
openNotification,
|
|
||||||
refreshNotifications,
|
refreshNotifications,
|
||||||
totalCount,
|
totalCount,
|
||||||
unreadCount,
|
unreadCount,
|
||||||
|
|||||||
@@ -261,28 +261,6 @@ class ApiClient {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async verifyEmail(token: string): Promise<Types.MessageSchema> {
|
|
||||||
const url = `${this.baseUrl}/api/auth/verify-email/${encodeURIComponent(token)}`;
|
|
||||||
const response = await fetch(url, { method: 'GET' });
|
|
||||||
|
|
||||||
if (response.ok) {
|
|
||||||
return response.json() as Promise<Types.MessageSchema>;
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = (await response.json().catch(() => ({}))) as ApiErrorBody;
|
|
||||||
const errMsg: string =
|
|
||||||
(data && (data.error || data.detail)) || 'خطای ناشناخته رخ داد';
|
|
||||||
throw new Error(errMsg);
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
async resendVerification(email: string) {
|
|
||||||
return this.request<Types.MessageSchema>(
|
|
||||||
`/api/auth/resend-verification?email=${encodeURIComponent(email)}`,
|
|
||||||
{ method: 'POST' }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
async getProfile() {
|
async getProfile() {
|
||||||
return this.request<Types.UserProfileSchema>('/api/auth/profile'
|
return this.request<Types.UserProfileSchema>('/api/auth/profile'
|
||||||
);
|
);
|
||||||
@@ -324,34 +302,6 @@ class ApiClient {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async requestPasswordReset(email: string) {
|
|
||||||
return this.request<Types.MessageSchema>('/api/auth/request-password-reset', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ email }),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async resetPasswordConfirm(token: string, new_password: string) {
|
|
||||||
return this.request<Types.MessageSchema>('/api/auth/reset-password-confirm', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ token, new_password }),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async getLegacyVerifyEmailMessage(token: string) {
|
|
||||||
return this.request<Types.MessageSchema>(`/api/auth/verify-email/${encodeURIComponent(token)}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
async getLegacyResetTokenMessage(token: string) {
|
|
||||||
return this.request<Types.MessageSchema>('/api/auth/reset-password-confirm', {
|
|
||||||
method: 'POST',
|
|
||||||
body: JSON.stringify({ token }),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
async checkUsername(username: string) {
|
async checkUsername(username: string) {
|
||||||
return this.request<Types.UsernameCheckSchema>(
|
return this.request<Types.UsernameCheckSchema>(
|
||||||
`/api/auth/check-username?username=${encodeURIComponent(username)}`
|
`/api/auth/check-username?username=${encodeURIComponent(username)}`
|
||||||
@@ -1259,27 +1209,22 @@ class ApiClient {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async subscribeNewsletter(email: string) {
|
async getNotifications(params?: { limit?: number; offset?: number; type?: string; status?: 'all' | 'unread' | 'read' }) {
|
||||||
return this.request<{ message: string, success: boolean }>(
|
|
||||||
`/api/communications/newsletter/subscribe/`,
|
|
||||||
{
|
|
||||||
method: "POST",
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify({ email: email }),
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
async getNotifications(params?: { limit?: number; offset?: number; type?: string }) {
|
|
||||||
const query = new URLSearchParams();
|
const query = new URLSearchParams();
|
||||||
if (params?.limit != null) query.set('limit', String(params.limit));
|
if (params?.limit != null) query.set('limit', String(params.limit));
|
||||||
if (params?.offset != null) query.set('offset', String(params.offset));
|
if (params?.offset != null) query.set('offset', String(params.offset));
|
||||||
if (params?.type) query.set('type', params.type);
|
if (params?.type) query.set('type', params.type);
|
||||||
|
if (params?.status) query.set('status', params.status);
|
||||||
return this.request<Types.NotificationListSchema>(
|
return this.request<Types.NotificationListSchema>(
|
||||||
`/api/notifications/${query.toString() ? `?${query.toString()}` : ''}`
|
`/api/notifications/${query.toString() ? `?${query.toString()}` : ''}`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async getNotification(id: string) {
|
||||||
|
return this.request<Types.NotificationDetailSchema>(`/api/notifications/${encodeURIComponent(id)}`);
|
||||||
|
}
|
||||||
|
|
||||||
async markNotificationSeen(id: string) {
|
async markNotificationSeen(id: string) {
|
||||||
return this.request<Types.NotificationSeenResponseSchema>('/api/notifications/mark-seen', {
|
return this.request<Types.NotificationSeenResponseSchema>('/api/notifications/mark-seen', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -240,16 +240,20 @@ export interface NotificationSchema {
|
|||||||
type: string;
|
type: string;
|
||||||
title: string;
|
title: string;
|
||||||
message: string;
|
message: string;
|
||||||
|
has_body: boolean;
|
||||||
level: "info" | "success" | "warning" | "error";
|
level: "info" | "success" | "warning" | "error";
|
||||||
created_at: string;
|
created_at: string;
|
||||||
is_seen: boolean;
|
is_seen: boolean;
|
||||||
delete_on_seen: boolean;
|
|
||||||
action_url?: string | null;
|
action_url?: string | null;
|
||||||
entity_type?: string | null;
|
entity_type?: string | null;
|
||||||
entity_id?: string | number | null;
|
entity_id?: string | number | null;
|
||||||
meta?: Record<string, unknown>;
|
meta?: Record<string, unknown>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface NotificationDetailSchema extends NotificationSchema {
|
||||||
|
body_markdown?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
export interface NotificationListSchema {
|
export interface NotificationListSchema {
|
||||||
count: number;
|
count: number;
|
||||||
unread_count: number;
|
unread_count: number;
|
||||||
|
|||||||
@@ -53,6 +53,19 @@ export function formatJalaliDate(iso?: string): string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function formatJalaliNumericDate(iso?: string): string {
|
||||||
|
if (!iso) return '—';
|
||||||
|
try {
|
||||||
|
const parts = new Intl.DateTimeFormat('fa-IR-u-ca-persian', {
|
||||||
|
year: 'numeric', month: '2-digit', day: '2-digit',
|
||||||
|
}).formatToParts(new Date(iso));
|
||||||
|
const part = (name: string) => parts.find((item) => item.type === name)?.value || '';
|
||||||
|
return `${part('year')}/${part('month')}/${part('day')}`;
|
||||||
|
} catch {
|
||||||
|
return '—';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const DEFAULT_THUMB = "/placeholder.svg";
|
const DEFAULT_THUMB = "/placeholder.svg";
|
||||||
|
|
||||||
const pickFirstUrl = (...values: Array<string | null | undefined>) =>
|
const pickFirstUrl = (...values: Array<string | null | undefined>) =>
|
||||||
|
|||||||
@@ -1,77 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { useQuery } from "@tanstack/react-query";
|
|
||||||
import { AlertTriangle, ArrowLeft, Loader2, ShieldAlert } from "lucide-react";
|
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { api } from "@/lib/api";
|
|
||||||
import { Link, useParams } from "@/lib/router";
|
|
||||||
import { resolveErrorMessage } from "@/lib/utils";
|
|
||||||
|
|
||||||
export default function ResetPasswordConfirm() {
|
|
||||||
const { token } = useParams<{ token: string }>();
|
|
||||||
const { data, isLoading, isError, error } = useQuery({
|
|
||||||
queryKey: ["legacy-reset-guidance", token],
|
|
||||||
queryFn: () => api.getLegacyResetTokenMessage(token || ""),
|
|
||||||
retry: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
const message = isError
|
|
||||||
? resolveErrorMessage(error, "این مسیر دیگر برای بازیابی رمز عبور فعال نیست.")
|
|
||||||
: data?.message ||
|
|
||||||
"لینک بازیابی ایمیلی غیرفعال شده است. برای ادامه از بازیابی با موبایل یا ورود با گوگل استفاده کنید.";
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="min-h-screen bg-background px-4 py-10" dir="rtl">
|
|
||||||
<div className="mx-auto max-w-2xl">
|
|
||||||
<Card className="border-border/70 bg-background/90 shadow-xl">
|
|
||||||
<CardHeader className="text-right">
|
|
||||||
<div className="mb-4 inline-flex rounded-2xl border border-amber-400/30 bg-amber-500/10 p-3 text-amber-700 dark:text-amber-100">
|
|
||||||
<ShieldAlert className="h-5 w-5" />
|
|
||||||
</div>
|
|
||||||
<CardTitle>لینک بازیابی قدیمی غیرفعال شده است</CardTitle>
|
|
||||||
<CardDescription className="leading-7">
|
|
||||||
مسیرهای مبتنی بر ایمیل دیگر برای بازیابی حساب استفاده نمیشوند.
|
|
||||||
</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="space-y-5 text-right">
|
|
||||||
<div className="rounded-[1.5rem] border border-border/70 bg-muted/25 p-4 text-sm leading-7 text-muted-foreground">
|
|
||||||
{isLoading ? (
|
|
||||||
<span className="flex items-center justify-end gap-2">
|
|
||||||
<Loader2 className="h-4 w-4 animate-spin" />
|
|
||||||
در حال دریافت راهنمای بازیابی...
|
|
||||||
</span>
|
|
||||||
) : (
|
|
||||||
message
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="rounded-[1.5rem] border border-amber-400/30 bg-amber-500/10 p-4 text-sm leading-7 text-amber-900 dark:text-amber-100">
|
|
||||||
<div className="flex items-start justify-between gap-3">
|
|
||||||
<AlertTriangle className="mt-0.5 h-5 w-5 shrink-0" />
|
|
||||||
<div>
|
|
||||||
<p className="font-semibold">راه جایگزین</p>
|
|
||||||
<p className="mt-2">
|
|
||||||
از صفحه بازیابی با موبایل استفاده کنید. اگر موبایل ثبتشده را هم در دسترس ندارید، ورود با گوگل و همان ایمیل قبلی بهترین مسیر بازیابی است.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid gap-3 sm:grid-cols-2">
|
|
||||||
<Button asChild className="h-12 rounded-2xl">
|
|
||||||
<Link to="/reset-password">بازیابی با موبایل</Link>
|
|
||||||
</Button>
|
|
||||||
<Button asChild variant="outline" className="h-12 rounded-2xl">
|
|
||||||
<Link to="/auth">
|
|
||||||
<ArrowLeft className="ml-2 h-4 w-4" />
|
|
||||||
بازگشت به ورود
|
|
||||||
</Link>
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,77 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { useQuery } from "@tanstack/react-query";
|
|
||||||
import { AlertTriangle, ArrowLeft, Loader2, MailWarning } from "lucide-react";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
|
||||||
import { api } from "@/lib/api";
|
|
||||||
import { Link, useParams } from "@/lib/router";
|
|
||||||
import { resolveErrorMessage } from "@/lib/utils";
|
|
||||||
|
|
||||||
export default function VerifyEmail() {
|
|
||||||
const { token } = useParams<{ token: string }>();
|
|
||||||
const { data, isLoading, isError, error } = useQuery({
|
|
||||||
queryKey: ["legacy-email-guidance", token],
|
|
||||||
queryFn: () => api.getLegacyVerifyEmailMessage(token || ""),
|
|
||||||
retry: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
const message = isError
|
|
||||||
? resolveErrorMessage(error, "تأیید ایمیل دیگر برای ورود و بازیابی حساب استفاده نمیشود.")
|
|
||||||
: data?.message ||
|
|
||||||
"تأیید ایمیل غیرفعال شده است. برای ادامه باید موبایل خود را تأیید کنید یا با همان حساب گوگل مرتبط وارد شوید.";
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="min-h-screen bg-background px-4 py-10" dir="rtl">
|
|
||||||
<div className="mx-auto max-w-2xl">
|
|
||||||
<Card className="border-border/70 bg-background/90 shadow-xl">
|
|
||||||
<CardHeader className="text-right">
|
|
||||||
<div className="mb-4 inline-flex rounded-2xl border border-amber-400/30 bg-amber-500/10 p-3 text-amber-700 dark:text-amber-100">
|
|
||||||
<MailWarning className="h-5 w-5" />
|
|
||||||
</div>
|
|
||||||
<CardTitle>تأیید ایمیل کنار گذاشته شده است</CardTitle>
|
|
||||||
<CardDescription className="leading-7">
|
|
||||||
دسترسی کاربران به ایمیل محدود شده و مسیر تأیید حساب به موبایل و گوگل منتقل شده است.
|
|
||||||
</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="space-y-5 text-right">
|
|
||||||
<div className="rounded-[1.5rem] border border-border/70 bg-muted/25 p-4 text-sm leading-7 text-muted-foreground">
|
|
||||||
{isLoading ? (
|
|
||||||
<span className="flex items-center justify-end gap-2">
|
|
||||||
<Loader2 className="h-4 w-4 animate-spin" />
|
|
||||||
در حال دریافت پیام راهنما...
|
|
||||||
</span>
|
|
||||||
) : (
|
|
||||||
message
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="rounded-[1.5rem] border border-amber-400/30 bg-amber-500/10 p-4 text-sm leading-7 text-amber-900 dark:text-amber-100">
|
|
||||||
<div className="flex items-start justify-between gap-3">
|
|
||||||
<AlertTriangle className="mt-0.5 h-5 w-5 shrink-0" />
|
|
||||||
<div>
|
|
||||||
<p className="font-semibold">برای کاربران قدیمی</p>
|
|
||||||
<p className="mt-2">
|
|
||||||
اگر حساب شما قبلاً با ایمیل ساخته شده است، از صفحه ورود گزینه گوگل را بزنید تا در صورت تطابق ایمیل، حسابتان به موبایل متصل شود.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid gap-3 sm:grid-cols-2">
|
|
||||||
<Button asChild className="h-12 rounded-2xl">
|
|
||||||
<Link to="/auth">ورود و اتصال حساب</Link>
|
|
||||||
</Button>
|
|
||||||
<Button asChild variant="outline" className="h-12 rounded-2xl">
|
|
||||||
<Link to="/reset-password">
|
|
||||||
<ArrowLeft className="ml-2 h-4 w-4" />
|
|
||||||
بازیابی با موبایل
|
|
||||||
</Link>
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user