feat: improve profile navigation and responsive privacy UX
This commit is contained in:
@@ -49,7 +49,7 @@ export default async function RootLayout({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<html lang="fa" dir="rtl" suppressHydrationWarning>
|
<html lang="fa" dir="rtl" suppressHydrationWarning>
|
||||||
<body className={`${vazirmatn.variable} font-sans antialiased`}>
|
<body className={`${vazirmatn.variable} flex min-h-svh flex-col font-sans antialiased`}>
|
||||||
<Providers>
|
<Providers>
|
||||||
<a
|
<a
|
||||||
href="#main-content"
|
href="#main-content"
|
||||||
@@ -61,12 +61,12 @@ export default async function RootLayout({
|
|||||||
<RouteProgress />
|
<RouteProgress />
|
||||||
</Suspense>
|
</Suspense>
|
||||||
<Navbar siteContent={siteContent} />
|
<Navbar siteContent={siteContent} />
|
||||||
<div className="min-h-screen pb-28 md:pb-0">
|
<div className="flex min-h-[calc(100svh-4.5rem)] flex-1 flex-col pb-28 md:pb-0">
|
||||||
<div id="main-content" tabIndex={-1} className="outline-none">
|
<div id="main-content" tabIndex={-1} className="flex-1 outline-none">
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
<Footer siteContent={siteContent} />
|
|
||||||
</div>
|
</div>
|
||||||
|
<Footer siteContent={siteContent} />
|
||||||
<MobileBottomNav siteContent={siteContent} />
|
<MobileBottomNav siteContent={siteContent} />
|
||||||
</Providers>
|
</Providers>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
10
src/app/members/[username]/page.tsx
Normal file
10
src/app/members/[username]/page.tsx
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import { redirect } from "next/navigation";
|
||||||
|
|
||||||
|
export default async function LegacyPublicMemberProfilePage({
|
||||||
|
params,
|
||||||
|
}: {
|
||||||
|
params: Promise<{ username: string }>;
|
||||||
|
}) {
|
||||||
|
const { username } = await params;
|
||||||
|
redirect(`/users/profiles/${encodeURIComponent(username)}`);
|
||||||
|
}
|
||||||
11
src/app/profile/edit/page.tsx
Normal file
11
src/app/profile/edit/page.tsx
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
import ProfileEdit from "@/views/ProfileEdit";
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: "ویرایش پروفایل",
|
||||||
|
robots: { index: false, follow: false },
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function ProfileEditPage() {
|
||||||
|
return <ProfileEdit />;
|
||||||
|
}
|
||||||
@@ -6,6 +6,11 @@ export const metadata: Metadata = {
|
|||||||
robots: { index: false, follow: false },
|
robots: { index: false, follow: false },
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function ProfilePage() {
|
type SearchParams = Promise<Record<string, string | string[] | undefined>>;
|
||||||
return <Profile />;
|
|
||||||
|
export default async function ProfilePage({ searchParams }: { searchParams: SearchParams }) {
|
||||||
|
const params = await searchParams;
|
||||||
|
const tab = Array.isArray(params.tab) ? params.tab[0] : params.tab;
|
||||||
|
|
||||||
|
return <Profile initialTab={tab} />;
|
||||||
}
|
}
|
||||||
|
|||||||
11
src/app/users/profiles/[username]/page.tsx
Normal file
11
src/app/users/profiles/[username]/page.tsx
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
import PublicProfile from "@/views/PublicProfile";
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: "پروفایل عمومی عضو",
|
||||||
|
description: "پروفایل عمومی اعضای انجمن علمی مهندسی کامپیوتر دانشگاه گیلان.",
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function PublicMemberProfilePage() {
|
||||||
|
return <PublicProfile />;
|
||||||
|
}
|
||||||
@@ -21,7 +21,9 @@ export type AsyncComboboxOption = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
type AsyncSearchableComboboxProps = {
|
type AsyncSearchableComboboxProps = {
|
||||||
|
id?: string;
|
||||||
value?: string | null;
|
value?: string | null;
|
||||||
|
selectedLabel?: string | null;
|
||||||
onChange: (value: string | null) => void;
|
onChange: (value: string | null) => void;
|
||||||
loadOptions: (params: { search: string; limit: number; offset: number }) => Promise<{
|
loadOptions: (params: { search: string; limit: number; offset: number }) => Promise<{
|
||||||
count: number;
|
count: number;
|
||||||
@@ -37,7 +39,9 @@ type AsyncSearchableComboboxProps = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export default function AsyncSearchableCombobox({
|
export default function AsyncSearchableCombobox({
|
||||||
|
id,
|
||||||
value,
|
value,
|
||||||
|
selectedLabel,
|
||||||
onChange,
|
onChange,
|
||||||
loadOptions,
|
loadOptions,
|
||||||
placeholder = "انتخاب کنید",
|
placeholder = "انتخاب کنید",
|
||||||
@@ -97,19 +101,27 @@ export default function AsyncSearchableCombobox({
|
|||||||
<Popover open={open} onOpenChange={setOpen}>
|
<Popover open={open} onOpenChange={setOpen}>
|
||||||
<PopoverTrigger asChild>
|
<PopoverTrigger asChild>
|
||||||
<Button
|
<Button
|
||||||
|
id={id}
|
||||||
type="button"
|
type="button"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
role="combobox"
|
role="combobox"
|
||||||
aria-expanded={open}
|
aria-expanded={open}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
className={cn("w-full justify-between gap-2", className)}
|
className={cn("w-full min-w-0 max-w-full justify-between gap-2", className)}
|
||||||
>
|
>
|
||||||
<span className="truncate text-right">{selected?.label || value || placeholder}</span>
|
<span className="truncate text-right">{selected?.label || (value ? selectedLabel || value : placeholder)}</span>
|
||||||
<ChevronsUpDown className="h-4 w-4 shrink-0 opacity-50" />
|
<ChevronsUpDown className="h-4 w-4 shrink-0 opacity-50" />
|
||||||
</Button>
|
</Button>
|
||||||
</PopoverTrigger>
|
</PopoverTrigger>
|
||||||
<PopoverContent className="w-[var(--radix-popover-trigger-width)] p-0" align="start" dir="rtl">
|
<PopoverContent
|
||||||
<Command shouldFilter={false}>
|
side="bottom"
|
||||||
|
sideOffset={6}
|
||||||
|
align="end"
|
||||||
|
collisionPadding={12}
|
||||||
|
className="w-[var(--radix-popover-trigger-width)] max-w-[calc(100vw-1.5rem)] p-0"
|
||||||
|
dir="rtl"
|
||||||
|
>
|
||||||
|
<Command shouldFilter={false} dir="rtl">
|
||||||
<CommandInput value={search} onValueChange={setSearch} placeholder={searchPlaceholder} />
|
<CommandInput value={search} onValueChange={setSearch} placeholder={searchPlaceholder} />
|
||||||
<CommandList>
|
<CommandList>
|
||||||
{loading ? (
|
{loading ? (
|
||||||
|
|||||||
@@ -9,13 +9,10 @@ import OtpCodeField from "@/components/OtpCodeField";
|
|||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
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 { resolveErrorMessage } from "@/lib/utils";
|
import { toEnglishDigits } from "@/lib/digits";
|
||||||
|
import { resolveErrorMessage, toPersianDigits } from "@/lib/utils";
|
||||||
|
|
||||||
const toEnglishDigits = (value: string) =>
|
const normalizeNumericInput = (value: string) => toEnglishDigits(value).replace(/[^\d]/g, "");
|
||||||
value
|
|
||||||
.replace(/[\u06F0-\u06F9]/g, (digit) => String(digit.charCodeAt(0) - 0x06f0))
|
|
||||||
.replace(/[\u0660-\u0669]/g, (digit) => String(digit.charCodeAt(0) - 0x0660))
|
|
||||||
.replace(/[^\d]/g, "");
|
|
||||||
|
|
||||||
export default function MobileVerificationGate() {
|
export default function MobileVerificationGate() {
|
||||||
const { user, isAuthenticated, loading, refreshProfile, setUser, logout } = useAuth();
|
const { user, isAuthenticated, loading, refreshProfile, setUser, logout } = useAuth();
|
||||||
@@ -29,7 +26,7 @@ export default function MobileVerificationGate() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (user?.mobile) {
|
if (user?.mobile) {
|
||||||
setMobile(user.mobile);
|
setMobile(normalizeNumericInput(user.mobile));
|
||||||
setStep("verify");
|
setStep("verify");
|
||||||
}
|
}
|
||||||
}, [user?.mobile]);
|
}, [user?.mobile]);
|
||||||
@@ -45,7 +42,7 @@ export default function MobileVerificationGate() {
|
|||||||
const handleSendOtp = async () => {
|
const handleSendOtp = async () => {
|
||||||
try {
|
try {
|
||||||
setSending(true);
|
setSending(true);
|
||||||
const normalizedMobile = toEnglishDigits(mobile);
|
const normalizedMobile = normalizeNumericInput(mobile);
|
||||||
const response = await api.sendMobileVerificationOtp({ mobile: normalizedMobile });
|
const response = await api.sendMobileVerificationOtp({ mobile: normalizedMobile });
|
||||||
setMobile(normalizedMobile);
|
setMobile(normalizedMobile);
|
||||||
setStep("verify");
|
setStep("verify");
|
||||||
@@ -70,8 +67,8 @@ export default function MobileVerificationGate() {
|
|||||||
try {
|
try {
|
||||||
setVerifying(true);
|
setVerifying(true);
|
||||||
const profile = await api.verifyMobile({
|
const profile = await api.verifyMobile({
|
||||||
mobile: toEnglishDigits(mobile),
|
mobile: normalizeNumericInput(mobile),
|
||||||
code: toEnglishDigits(code),
|
code: normalizeNumericInput(code),
|
||||||
});
|
});
|
||||||
setUser(profile);
|
setUser(profile);
|
||||||
await refreshProfile();
|
await refreshProfile();
|
||||||
@@ -143,8 +140,8 @@ export default function MobileVerificationGate() {
|
|||||||
type="tel"
|
type="tel"
|
||||||
dir="ltr"
|
dir="ltr"
|
||||||
inputMode="numeric"
|
inputMode="numeric"
|
||||||
value={mobile}
|
value={toPersianDigits(mobile)}
|
||||||
onChange={(event) => setMobile(toEnglishDigits(event.target.value))}
|
onChange={(event) => setMobile(normalizeNumericInput(event.target.value))}
|
||||||
placeholder="09xxxxxxxxx"
|
placeholder="09xxxxxxxxx"
|
||||||
className="h-12 rounded-2xl"
|
className="h-12 rounded-2xl"
|
||||||
disabled={sending || verifying}
|
disabled={sending || verifying}
|
||||||
@@ -157,7 +154,7 @@ export default function MobileVerificationGate() {
|
|||||||
<Label className="mb-3 block text-right">کد تأیید پیامکی</Label>
|
<Label className="mb-3 block text-right">کد تأیید پیامکی</Label>
|
||||||
<OtpCodeField
|
<OtpCodeField
|
||||||
value={code}
|
value={code}
|
||||||
onChange={(value) => setCode(toEnglishDigits(value))}
|
onChange={(value) => setCode(normalizeNumericInput(value))}
|
||||||
disabled={verifying}
|
disabled={verifying}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -103,7 +103,7 @@ function ProfileAvatarMenu() {
|
|||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
) : null}
|
) : null}
|
||||||
<DropdownMenuSeparator />
|
<DropdownMenuSeparator />
|
||||||
<DropdownMenuItem asChild className="flex-row-reverse justify-end gap-2 rounded-xl text-destructive transition-colors hover:bg-secondary hover:text-destructive focus:bg-secondary focus:text-destructive">
|
<DropdownMenuItem asChild className="flex-row-reverse justify-end gap-2 rounded-xl text-destructive transition-colors hover:bg-secondary hover:text-destructive focus:bg-secondary focus:text-destructive dark:text-red-300 dark:hover:text-red-200 dark:focus:text-red-200">
|
||||||
<Link to="/logout">
|
<Link to="/logout">
|
||||||
خروج از حساب
|
خروج از حساب
|
||||||
<LogOut className="h-4 w-4" />
|
<LogOut className="h-4 w-4" />
|
||||||
|
|||||||
@@ -111,7 +111,7 @@ export default function RouteProgress() {
|
|||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className="h-full origin-right bg-[linear-gradient(90deg,hsl(var(--primary))_0%,hsl(var(--route-progress))_100%)] shadow-[0_0_16px_hsl(var(--route-progress)/0.55)] transition-[transform] duration-200 ease-out"
|
className="h-full origin-right bg-[hsl(var(--accent))] shadow-[0_0_16px_hsl(var(--accent)/0.55)] transition-[transform] duration-200 ease-out"
|
||||||
style={{ transform: `scaleX(${progress / 100})` }}
|
style={{ transform: `scaleX(${progress / 100})` }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -40,11 +40,11 @@ const CommandInput = React.forwardRef<
|
|||||||
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Input>
|
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Input>
|
||||||
>(({ className, ...props }, ref) => (
|
>(({ className, ...props }, ref) => (
|
||||||
<div className="flex items-center border-b px-3" cmdk-input-wrapper="">
|
<div className="flex items-center border-b px-3" cmdk-input-wrapper="">
|
||||||
<Search className="mr-2 h-4 w-4 shrink-0 opacity-50" />
|
<Search className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||||
<CommandPrimitive.Input
|
<CommandPrimitive.Input
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex h-11 w-full rounded-md bg-transparent py-3 text-sm outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50",
|
"flex h-11 w-full min-w-0 rounded-md bg-transparent py-3 text-right text-sm outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50",
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
@@ -105,7 +105,7 @@ const CommandItem = React.forwardRef<
|
|||||||
<CommandPrimitive.Item
|
<CommandPrimitive.Item
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
className={cn(
|
||||||
"relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none data-[disabled=true]:pointer-events-none data-[selected='true']:bg-accent data-[selected=true]:text-accent-foreground data-[disabled=true]:opacity-50",
|
"relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-right text-sm outline-none data-[disabled=true]:pointer-events-none data-[selected='true']:bg-secondary data-[selected=true]:text-foreground data-[disabled=true]:opacity-50",
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ const ContextMenuSubTrigger = React.forwardRef<
|
|||||||
<ContextMenuPrimitive.SubTrigger
|
<ContextMenuPrimitive.SubTrigger
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none data-[state=open]:bg-accent data-[state=open]:text-accent-foreground focus:bg-accent focus:text-accent-foreground",
|
"flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none data-[state=open]:bg-secondary data-[state=open]:text-foreground focus:bg-secondary focus:text-foreground",
|
||||||
inset && "pl-8",
|
inset && "pl-8",
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
@@ -78,7 +78,7 @@ const ContextMenuItem = React.forwardRef<
|
|||||||
<ContextMenuPrimitive.Item
|
<ContextMenuPrimitive.Item
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
className={cn(
|
||||||
"relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 focus:bg-accent focus:text-accent-foreground",
|
"relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 focus:bg-secondary focus:text-foreground",
|
||||||
inset && "pl-8",
|
inset && "pl-8",
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
@@ -94,7 +94,7 @@ const ContextMenuCheckboxItem = React.forwardRef<
|
|||||||
<ContextMenuPrimitive.CheckboxItem
|
<ContextMenuPrimitive.CheckboxItem
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
className={cn(
|
||||||
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 focus:bg-accent focus:text-accent-foreground",
|
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 focus:bg-secondary focus:text-foreground",
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
checked={checked}
|
checked={checked}
|
||||||
@@ -117,7 +117,7 @@ const ContextMenuRadioItem = React.forwardRef<
|
|||||||
<ContextMenuPrimitive.RadioItem
|
<ContextMenuPrimitive.RadioItem
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
className={cn(
|
||||||
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 focus:bg-accent focus:text-accent-foreground",
|
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 focus:bg-secondary focus:text-foreground",
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ const DialogContent = React.forwardRef<
|
|||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity data-[state=open]:bg-accent data-[state=open]:text-muted-foreground hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none">
|
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-colors data-[state=open]:bg-secondary data-[state=open]:text-foreground hover:bg-secondary hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none">
|
||||||
<X className="h-4 w-4" />
|
<X className="h-4 w-4" />
|
||||||
<span className="sr-only">Close</span>
|
<span className="sr-only">Close</span>
|
||||||
</DialogPrimitive.Close>
|
</DialogPrimitive.Close>
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ const DropdownMenuSubTrigger = React.forwardRef<
|
|||||||
<DropdownMenuPrimitive.SubTrigger
|
<DropdownMenuPrimitive.SubTrigger
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none data-[state=open]:bg-accent focus:bg-accent",
|
"flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none data-[state=open]:bg-secondary data-[state=open]:text-foreground focus:bg-secondary focus:text-foreground",
|
||||||
inset && "pl-8",
|
inset && "pl-8",
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
@@ -79,7 +79,7 @@ const DropdownMenuItem = React.forwardRef<
|
|||||||
<DropdownMenuPrimitive.Item
|
<DropdownMenuPrimitive.Item
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
className={cn(
|
||||||
"relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none transition-colors data-[disabled]:pointer-events-none data-[disabled]:opacity-50 focus:bg-accent focus:text-accent-foreground",
|
"relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none transition-colors data-[disabled]:pointer-events-none data-[disabled]:opacity-50 focus:bg-secondary focus:text-foreground",
|
||||||
inset && "pl-8",
|
inset && "pl-8",
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
@@ -95,7 +95,7 @@ const DropdownMenuCheckboxItem = React.forwardRef<
|
|||||||
<DropdownMenuPrimitive.CheckboxItem
|
<DropdownMenuPrimitive.CheckboxItem
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
className={cn(
|
||||||
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors data-[disabled]:pointer-events-none data-[disabled]:opacity-50 focus:bg-accent focus:text-accent-foreground",
|
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors data-[disabled]:pointer-events-none data-[disabled]:opacity-50 focus:bg-secondary focus:text-foreground",
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
checked={checked}
|
checked={checked}
|
||||||
@@ -118,7 +118,7 @@ const DropdownMenuRadioItem = React.forwardRef<
|
|||||||
<DropdownMenuPrimitive.RadioItem
|
<DropdownMenuPrimitive.RadioItem
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
className={cn(
|
||||||
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors data-[disabled]:pointer-events-none data-[disabled]:opacity-50 focus:bg-accent focus:text-accent-foreground",
|
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors data-[disabled]:pointer-events-none data-[disabled]:opacity-50 focus:bg-secondary focus:text-foreground",
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ const MenubarTrigger = React.forwardRef<
|
|||||||
<MenubarPrimitive.Trigger
|
<MenubarPrimitive.Trigger
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex cursor-default select-none items-center rounded-sm px-3 py-1.5 text-sm font-medium outline-none data-[state=open]:bg-accent data-[state=open]:text-accent-foreground focus:bg-accent focus:text-accent-foreground",
|
"flex cursor-default select-none items-center rounded-sm px-3 py-1.5 text-sm font-medium outline-none data-[state=open]:bg-secondary data-[state=open]:text-foreground focus:bg-secondary focus:text-foreground",
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
@@ -50,7 +50,7 @@ const MenubarSubTrigger = React.forwardRef<
|
|||||||
<MenubarPrimitive.SubTrigger
|
<MenubarPrimitive.SubTrigger
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none data-[state=open]:bg-accent data-[state=open]:text-accent-foreground focus:bg-accent focus:text-accent-foreground",
|
"flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none data-[state=open]:bg-secondary data-[state=open]:text-foreground focus:bg-secondary focus:text-foreground",
|
||||||
inset && "pl-8",
|
inset && "pl-8",
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
@@ -106,7 +106,7 @@ const MenubarItem = React.forwardRef<
|
|||||||
<MenubarPrimitive.Item
|
<MenubarPrimitive.Item
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
className={cn(
|
||||||
"relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 focus:bg-accent focus:text-accent-foreground",
|
"relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 focus:bg-secondary focus:text-foreground",
|
||||||
inset && "pl-8",
|
inset && "pl-8",
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
@@ -122,7 +122,7 @@ const MenubarCheckboxItem = React.forwardRef<
|
|||||||
<MenubarPrimitive.CheckboxItem
|
<MenubarPrimitive.CheckboxItem
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
className={cn(
|
||||||
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 focus:bg-accent focus:text-accent-foreground",
|
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 focus:bg-secondary focus:text-foreground",
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
checked={checked}
|
checked={checked}
|
||||||
@@ -145,7 +145,7 @@ const MenubarRadioItem = React.forwardRef<
|
|||||||
<MenubarPrimitive.RadioItem
|
<MenubarPrimitive.RadioItem
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
className={cn(
|
||||||
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 focus:bg-accent focus:text-accent-foreground",
|
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 focus:bg-secondary focus:text-foreground",
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
|
|||||||
@@ -61,7 +61,7 @@ SelectScrollDownButton.displayName = SelectPrimitive.ScrollDownButton.displayNam
|
|||||||
const SelectContent = React.forwardRef<
|
const SelectContent = React.forwardRef<
|
||||||
React.ElementRef<typeof SelectPrimitive.Content>,
|
React.ElementRef<typeof SelectPrimitive.Content>,
|
||||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
|
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
|
||||||
>(({ className, children, position = "popper", ...props }, ref) => (
|
>(({ className, children, position = "popper", dir = "rtl", ...props }, ref) => (
|
||||||
<SelectPrimitive.Portal>
|
<SelectPrimitive.Portal>
|
||||||
<SelectPrimitive.Content
|
<SelectPrimitive.Content
|
||||||
ref={ref}
|
ref={ref}
|
||||||
@@ -72,6 +72,7 @@ const SelectContent = React.forwardRef<
|
|||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
position={position}
|
position={position}
|
||||||
|
dir={dir}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
<SelectScrollUpButton />
|
<SelectScrollUpButton />
|
||||||
@@ -94,7 +95,7 @@ const SelectLabel = React.forwardRef<
|
|||||||
React.ElementRef<typeof SelectPrimitive.Label>,
|
React.ElementRef<typeof SelectPrimitive.Label>,
|
||||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Label>
|
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Label>
|
||||||
>(({ className, ...props }, ref) => (
|
>(({ className, ...props }, ref) => (
|
||||||
<SelectPrimitive.Label ref={ref} className={cn("py-1.5 pl-8 pr-2 text-sm font-semibold", className)} {...props} />
|
<SelectPrimitive.Label ref={ref} className={cn("py-1.5 pl-2 pr-8 text-right text-sm font-semibold", className)} {...props} />
|
||||||
));
|
));
|
||||||
SelectLabel.displayName = SelectPrimitive.Label.displayName;
|
SelectLabel.displayName = SelectPrimitive.Label.displayName;
|
||||||
|
|
||||||
@@ -105,12 +106,12 @@ const SelectItem = React.forwardRef<
|
|||||||
<SelectPrimitive.Item
|
<SelectPrimitive.Item
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
className={cn(
|
||||||
"relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 focus:bg-accent focus:text-accent-foreground",
|
"relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-2 pr-8 text-right text-sm outline-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 focus:bg-secondary focus:text-foreground",
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
<span className="absolute right-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||||
<SelectPrimitive.ItemIndicator>
|
<SelectPrimitive.ItemIndicator>
|
||||||
<Check className="h-4 w-4" />
|
<Check className="h-4 w-4" />
|
||||||
</SelectPrimitive.ItemIndicator>
|
</SelectPrimitive.ItemIndicator>
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ const Switch = React.forwardRef<
|
|||||||
React.ComponentPropsWithoutRef<typeof SwitchPrimitives.Root>
|
React.ComponentPropsWithoutRef<typeof SwitchPrimitives.Root>
|
||||||
>(({ className, ...props }, ref) => (
|
>(({ className, ...props }, ref) => (
|
||||||
<SwitchPrimitives.Root
|
<SwitchPrimitives.Root
|
||||||
|
dir="ltr"
|
||||||
className={cn(
|
className={cn(
|
||||||
"peer inline-flex h-6 w-11 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent transition-colors data-[state=checked]:bg-primary data-[state=unchecked]:bg-input focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50",
|
"peer inline-flex h-6 w-11 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent transition-colors data-[state=checked]:bg-primary data-[state=unchecked]:bg-input focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50",
|
||||||
className,
|
className,
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { cva, type VariantProps } from "class-variance-authority";
|
|||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
const toggleVariants = cva(
|
const toggleVariants = cva(
|
||||||
"inline-flex items-center justify-center rounded-md text-sm font-medium ring-offset-background transition-colors hover:bg-muted hover:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=on]:bg-accent data-[state=on]:text-accent-foreground",
|
"inline-flex items-center justify-center rounded-md text-sm font-medium ring-offset-background transition-colors hover:bg-muted hover:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=on]:bg-secondary data-[state=on]:text-foreground",
|
||||||
{
|
{
|
||||||
variants: {
|
variants: {
|
||||||
variant: {
|
variant: {
|
||||||
|
|||||||
@@ -62,7 +62,6 @@ All colors MUST be HSL.
|
|||||||
--sidebar-border: 220 13% 91%;
|
--sidebar-border: 220 13% 91%;
|
||||||
|
|
||||||
--sidebar-ring: 217.2 91.2% 59.8%;
|
--sidebar-ring: 217.2 91.2% 59.8%;
|
||||||
--route-progress: 199 89% 48%;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.dark {
|
.dark {
|
||||||
@@ -111,7 +110,6 @@ All colors MUST be HSL.
|
|||||||
--sidebar-accent-foreground: 240 4.8% 95.9%;
|
--sidebar-accent-foreground: 240 4.8% 95.9%;
|
||||||
--sidebar-border: 240 3.7% 15.9%;
|
--sidebar-border: 240 3.7% 15.9%;
|
||||||
--sidebar-ring: 217.2 91.2% 59.8%;
|
--sidebar-ring: 217.2 91.2% 59.8%;
|
||||||
--route-progress: 198 93% 60%;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -265,6 +265,12 @@ class ApiClient {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async getPublicProfile(username: string) {
|
||||||
|
return this.request<Types.PublicMemberProfileSchema>(
|
||||||
|
`/api/auth/public-profile/${encodeURIComponent(username)}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
async updateProfile(data: Types.UserUpdateSchema) {
|
async updateProfile(data: Types.UserUpdateSchema) {
|
||||||
return this.request<Types.UserProfileSchema>('/api/auth/profile', {
|
return this.request<Types.UserProfileSchema>('/api/auth/profile', {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
@@ -711,6 +717,18 @@ class ApiClient {
|
|||||||
return this.request<Types.BlogProfileActivitySchema>('/api/blog/me/activity');
|
return this.request<Types.BlogProfileActivitySchema>('/api/blog/me/activity');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async getMyBlogPostsPage(section: "liked" | "saved", page: number) {
|
||||||
|
return this.request<Types.PaginatedResponseSchema<Types.PostListSchema>>(
|
||||||
|
`/api/blog/me/activity/${section}?page=${page}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getMyBlogCommentsPage(page: number) {
|
||||||
|
return this.request<Types.PaginatedResponseSchema<Types.CommentSchema>>(
|
||||||
|
`/api/blog/me/activity/comments?page=${page}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// Categories
|
// Categories
|
||||||
async getCategories() {
|
async getCategories() {
|
||||||
return this.request<Types.CategorySchema[]>('/api/blog/categories');
|
return this.request<Types.CategorySchema[]>('/api/blog/categories');
|
||||||
@@ -982,6 +1000,35 @@ class ApiClient {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async getMyRegistrationsPage(
|
||||||
|
status: Types.MyEventRegistrationSchema["status"] | "all",
|
||||||
|
sortBy: "start_time" | "title",
|
||||||
|
sortOrder: "asc" | "desc",
|
||||||
|
page: number,
|
||||||
|
) {
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
status,
|
||||||
|
sort_by: sortBy,
|
||||||
|
sort_order: sortOrder,
|
||||||
|
page: String(page),
|
||||||
|
});
|
||||||
|
return this.request<Types.PaginatedResponseSchema<Types.MyEventRegistrationSchema>>(
|
||||||
|
`/api/events/my-registrations/paginated?${params.toString()}`,
|
||||||
|
{ method: "GET" },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getMyPayments() {
|
||||||
|
return this.request<Types.UserPaymentSchema[]>("/api/payments/me", { method: "GET" });
|
||||||
|
}
|
||||||
|
|
||||||
|
async getMyPaymentsPage(page: number) {
|
||||||
|
return this.request<Types.PaginatedResponseSchema<Types.UserPaymentSchema>>(
|
||||||
|
`/api/payments/me/paginated?page=${page}`,
|
||||||
|
{ method: "GET" },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
async getRegistrationStatus(eventId: number) {
|
async getRegistrationStatus(eventId: number) {
|
||||||
return this.request<Types.RegistrationStatusSchema>(
|
return this.request<Types.RegistrationStatusSchema>(
|
||||||
`/api/events/${eventId}/is-registered`,
|
`/api/events/${eventId}/is-registered`,
|
||||||
|
|||||||
5
src/lib/digits.ts
Normal file
5
src/lib/digits.ts
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
export function toEnglishDigits(value: string): string {
|
||||||
|
return value
|
||||||
|
.replace(/[\u06F0-\u06F9]/g, (digit) => String(digit.charCodeAt(0) - 0x06f0))
|
||||||
|
.replace(/[\u0660-\u0669]/g, (digit) => String(digit.charCodeAt(0) - 0x0660));
|
||||||
|
}
|
||||||
@@ -55,7 +55,9 @@ export interface UserProfileSchema {
|
|||||||
student_id?: string | null;
|
student_id?: string | null;
|
||||||
year_of_study?: number;
|
year_of_study?: number;
|
||||||
university?: string;
|
university?: string;
|
||||||
|
university_code?: string | null;
|
||||||
major?: string;
|
major?: string;
|
||||||
|
major_code?: string | null;
|
||||||
date_joined: string;
|
date_joined: string;
|
||||||
|
|
||||||
is_email_verified?: boolean;
|
is_email_verified?: boolean;
|
||||||
@@ -71,6 +73,31 @@ export interface UserProfileSchema {
|
|||||||
can_access_blog_admin?: boolean;
|
can_access_blog_admin?: boolean;
|
||||||
can_write_blog_posts?: boolean;
|
can_write_blog_posts?: boolean;
|
||||||
can_review_blog_posts?: boolean;
|
can_review_blog_posts?: boolean;
|
||||||
|
show_public_profile_details?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PublicProfileEventSchema {
|
||||||
|
title: string;
|
||||||
|
slug: string;
|
||||||
|
start_time: string;
|
||||||
|
image_url?: string | null;
|
||||||
|
participation: "confirmed" | "attended";
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PublicMemberProfileSchema {
|
||||||
|
username: string;
|
||||||
|
first_name: string;
|
||||||
|
last_name: string;
|
||||||
|
full_name: string;
|
||||||
|
profile_picture?: string | null;
|
||||||
|
bio: string;
|
||||||
|
details_visible: boolean;
|
||||||
|
mobile?: string | null;
|
||||||
|
student_id?: string | null;
|
||||||
|
year_of_study?: number | null;
|
||||||
|
university?: string | null;
|
||||||
|
major?: string | null;
|
||||||
|
events: PublicProfileEventSchema[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface UserListSchema {
|
export interface UserListSchema {
|
||||||
@@ -144,6 +171,7 @@ export interface UserRegistrationSchema {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export type UserUpdateSchema = {
|
export type UserUpdateSchema = {
|
||||||
|
username?: string;
|
||||||
email?: string | null;
|
email?: string | null;
|
||||||
first_name?: string | null;
|
first_name?: string | null;
|
||||||
last_name?: string | null;
|
last_name?: string | null;
|
||||||
@@ -152,8 +180,31 @@ export type UserUpdateSchema = {
|
|||||||
major?: string | null;
|
major?: string | null;
|
||||||
university?: string | null;
|
university?: string | null;
|
||||||
student_id?: string | null;
|
student_id?: string | null;
|
||||||
|
show_public_profile_details?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export interface UserPaymentSchema {
|
||||||
|
event: {
|
||||||
|
title: string;
|
||||||
|
slug: string;
|
||||||
|
image_url?: string | null;
|
||||||
|
};
|
||||||
|
base_amount: number;
|
||||||
|
discount_amount: number;
|
||||||
|
amount: number;
|
||||||
|
status: "initiated" | "pending" | "paid" | "failed" | "canceled";
|
||||||
|
created_at: string;
|
||||||
|
paid_at?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PaginatedResponseSchema<T> {
|
||||||
|
count: number;
|
||||||
|
page: number;
|
||||||
|
page_size: number;
|
||||||
|
total_pages: number;
|
||||||
|
results: T[];
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
export interface UserLoginSchema {
|
export interface UserLoginSchema {
|
||||||
identifier: string;
|
identifier: string;
|
||||||
@@ -715,7 +766,16 @@ export interface MyEventRegistrationSchema {
|
|||||||
id: number;
|
id: number;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
status: 'pending' | 'confirmed' | 'cancelled' | 'attended';
|
status: 'pending' | 'confirmed' | 'cancelled' | 'attended';
|
||||||
event: EventListItemSchema;
|
event: {
|
||||||
|
id: number;
|
||||||
|
title: string;
|
||||||
|
slug: string;
|
||||||
|
start_date: string;
|
||||||
|
end_date?: string | null;
|
||||||
|
location?: string | null;
|
||||||
|
price: number;
|
||||||
|
absolute_image_url?: string | null;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Gallery Types
|
// Gallery Types
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
431
src/views/ProfileEdit.tsx
Normal file
431
src/views/ProfileEdit.tsx
Normal file
@@ -0,0 +1,431 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
|
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { Camera, Check, CircleAlert, Eye, Loader2, LockKeyhole, Trash2, UserRound } from "lucide-react";
|
||||||
|
import AsyncSearchableCombobox from "@/components/AsyncSearchableCombobox";
|
||||||
|
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Switch } from "@/components/ui/switch";
|
||||||
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
|
import { useAuth } from "@/contexts/AuthContext";
|
||||||
|
import { useToast } from "@/hooks/use-toast";
|
||||||
|
import { Helmet } from "@/lib/helmet";
|
||||||
|
import { Link, Navigate, useNavigate } from "@/lib/router";
|
||||||
|
import { buildAuthPath } from "@/lib/auth-return";
|
||||||
|
import { api } from "@/lib/api";
|
||||||
|
import { toEnglishDigits } from "@/lib/digits";
|
||||||
|
import type * as Types from "@/lib/types";
|
||||||
|
import { resolveErrorMessage, toPersianDigits } from "@/lib/utils";
|
||||||
|
|
||||||
|
type EditForm = Omit<Types.UserUpdateSchema, "email">;
|
||||||
|
const BIO_MAX_LENGTH = 500;
|
||||||
|
const USERNAME_MIN_LENGTH = 5;
|
||||||
|
const USERNAME_MAX_LENGTH = 150;
|
||||||
|
const USERNAME_PATTERN = /^[A-Za-z0-9_]+$/;
|
||||||
|
const persianNumberFormatter = new Intl.NumberFormat("fa-IR", { useGrouping: false });
|
||||||
|
type UsernameAvailability = "checking" | "available" | "taken" | "error";
|
||||||
|
|
||||||
|
const emptyForm: EditForm = {
|
||||||
|
username: "",
|
||||||
|
first_name: "",
|
||||||
|
last_name: "",
|
||||||
|
bio: "",
|
||||||
|
year_of_study: null,
|
||||||
|
major: null,
|
||||||
|
university: null,
|
||||||
|
student_id: "",
|
||||||
|
show_public_profile_details: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
const editorSections = [
|
||||||
|
{ id: "identity", label: "اطلاعات فردی", icon: UserRound },
|
||||||
|
{ id: "academic", label: "اطلاعات دانشگاهی", icon: LockKeyhole },
|
||||||
|
{ id: "about", label: "درباره من", icon: Camera },
|
||||||
|
{ id: "visibility", label: "پروفایل عمومی", icon: Eye },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export default function ProfileEdit() {
|
||||||
|
const { isAuthenticated, loading, setUser } = useAuth();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const { toast } = useToast();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [form, setForm] = useState<EditForm>(emptyForm);
|
||||||
|
const [pictureFile, setPictureFile] = useState<File | null>(null);
|
||||||
|
const [picturePreview, setPicturePreview] = useState<string | null>(null);
|
||||||
|
const [removePicture, setRemovePicture] = useState(false);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [dirty, setDirty] = useState(false);
|
||||||
|
const [usernameCheck, setUsernameCheck] = useState<{ username: string; status: UsernameAvailability } | null>(null);
|
||||||
|
|
||||||
|
const profileQuery = useQuery({
|
||||||
|
queryKey: ["profile"],
|
||||||
|
queryFn: () => api.getProfile(),
|
||||||
|
enabled: isAuthenticated,
|
||||||
|
});
|
||||||
|
const profile = profileQuery.data;
|
||||||
|
const currentUsername = profile?.username;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!profile) return;
|
||||||
|
setForm({
|
||||||
|
username: profile.username,
|
||||||
|
first_name: profile.first_name ?? "",
|
||||||
|
last_name: profile.last_name ?? "",
|
||||||
|
bio: profile.bio ?? "",
|
||||||
|
year_of_study: typeof profile.year_of_study === "number" ? profile.year_of_study : null,
|
||||||
|
major: profile.major_code ?? null,
|
||||||
|
university: profile.university_code ?? null,
|
||||||
|
student_id: toEnglishDigits(profile.student_id ?? ""),
|
||||||
|
show_public_profile_details: profile.show_public_profile_details ?? false,
|
||||||
|
});
|
||||||
|
}, [profile]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!currentUsername) return;
|
||||||
|
const username = (form.username ?? "").trim();
|
||||||
|
if (
|
||||||
|
username === currentUsername
|
||||||
|
|| username.length < USERNAME_MIN_LENGTH
|
||||||
|
|| username.length > USERNAME_MAX_LENGTH
|
||||||
|
|| !USERNAME_PATTERN.test(username)
|
||||||
|
) {
|
||||||
|
setUsernameCheck(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let active = true;
|
||||||
|
setUsernameCheck({ username, status: "checking" });
|
||||||
|
const timeout = window.setTimeout(() => {
|
||||||
|
void api.checkUsername(username).then((result) => {
|
||||||
|
if (active) setUsernameCheck({ username, status: result.exists ? "taken" : "available" });
|
||||||
|
}).catch(() => {
|
||||||
|
if (active) setUsernameCheck({ username, status: "error" });
|
||||||
|
});
|
||||||
|
}, 350);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
active = false;
|
||||||
|
window.clearTimeout(timeout);
|
||||||
|
};
|
||||||
|
}, [form.username, currentUsername]);
|
||||||
|
|
||||||
|
useEffect(() => () => {
|
||||||
|
if (picturePreview) URL.revokeObjectURL(picturePreview);
|
||||||
|
}, [picturePreview]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!dirty) return;
|
||||||
|
const warnBeforeLeaving = (event: BeforeUnloadEvent) => {
|
||||||
|
event.preventDefault();
|
||||||
|
event.returnValue = "";
|
||||||
|
};
|
||||||
|
window.addEventListener("beforeunload", warnBeforeLeaving);
|
||||||
|
return () => window.removeEventListener("beforeunload", warnBeforeLeaving);
|
||||||
|
}, [dirty]);
|
||||||
|
|
||||||
|
const loadMajors = useCallback(async (params: { search: string; limit: number; offset: number }) => {
|
||||||
|
const result = await api.getMajorsPaged(params);
|
||||||
|
return { count: result.count, results: result.results.map((item) => ({ value: String(item.code), label: item.label })) };
|
||||||
|
}, []);
|
||||||
|
const loadUniversities = useCallback(async (params: { search: string; limit: number; offset: number }) => {
|
||||||
|
const result = await api.getUniversitiesPaged(params);
|
||||||
|
return { count: result.count, results: result.results.map((item) => ({ value: String(item.code), label: item.label })) };
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const fullName = useMemo(
|
||||||
|
() => [form.first_name, form.last_name].filter(Boolean).join(" ").trim() || profile?.username || "کاربر",
|
||||||
|
[form.first_name, form.last_name, profile?.username],
|
||||||
|
);
|
||||||
|
const avatarFallback = `${form.first_name?.[0] ?? ""}${form.last_name?.[0] ?? ""}` || profile?.username?.[0] || "؟";
|
||||||
|
const currentPicture = picturePreview || (removePicture ? undefined : profile?.profile_picture_preview_url || profile?.profile_picture);
|
||||||
|
const bioLength = (form.bio ?? "").length;
|
||||||
|
const bioHasError = bioLength > BIO_MAX_LENGTH;
|
||||||
|
const yearOfStudyHasError = form.year_of_study != null
|
||||||
|
&& (!Number.isInteger(form.year_of_study) || form.year_of_study < 1300 || form.year_of_study > 1600);
|
||||||
|
const bioLengthLabel = persianNumberFormatter.format(bioLength);
|
||||||
|
const bioMaxLengthLabel = persianNumberFormatter.format(BIO_MAX_LENGTH);
|
||||||
|
const usernameValue = (form.username ?? "").trim();
|
||||||
|
const usernameChanged = Boolean(profile && usernameValue !== profile.username);
|
||||||
|
const usernameCheckStatus = !usernameChanged
|
||||||
|
? "unchanged"
|
||||||
|
: usernameValue.length < USERNAME_MIN_LENGTH
|
||||||
|
? "too-short"
|
||||||
|
: usernameValue.length > USERNAME_MAX_LENGTH
|
||||||
|
? "too-long"
|
||||||
|
: !USERNAME_PATTERN.test(usernameValue)
|
||||||
|
? "invalid-format"
|
||||||
|
: usernameCheck?.username !== usernameValue
|
||||||
|
? "checking"
|
||||||
|
: usernameCheck.status;
|
||||||
|
const usernameCanSave = usernameCheckStatus === "unchanged" || usernameCheckStatus === "available";
|
||||||
|
const usernameFeedback = {
|
||||||
|
"too-short": "نام کاربری باید حداقل ۵ نویسه داشته باشد.",
|
||||||
|
"too-long": "نام کاربری بیش از حد مجاز طولانی است.",
|
||||||
|
"invalid-format": "فقط حروف انگلیسی، عدد و زیرخط (_) مجاز است.",
|
||||||
|
checking: "در حال بررسی نام کاربری...",
|
||||||
|
available: "این نام کاربری در دسترس است.",
|
||||||
|
taken: "این نام کاربری قبلاً انتخاب شده است.",
|
||||||
|
error: "بررسی نام کاربری انجام نشد؛ دوباره تلاش کنید.",
|
||||||
|
}[usernameCheckStatus];
|
||||||
|
const usernameHasError = usernameChanged && ["too-short", "too-long", "invalid-format", "taken", "error"].includes(usernameCheckStatus);
|
||||||
|
|
||||||
|
if (!loading && !isAuthenticated) return <Navigate to={buildAuthPath("/profile/edit")} replace />;
|
||||||
|
if ((loading || profileQuery.isLoading) && !profile) {
|
||||||
|
return <main className="flex min-h-[70vh] items-center justify-center text-muted-foreground" dir="rtl">در حال دریافت اطلاعات...</main>;
|
||||||
|
}
|
||||||
|
if (!profile) {
|
||||||
|
return <main className="container mx-auto max-w-4xl px-4 py-16 text-center" dir="rtl"><p role="alert" className="text-destructive">اطلاعات پروفایل دریافت نشد. صفحه را دوباره بارگذاری کنید.</p></main>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateForm = <K extends keyof EditForm>(key: K, value: EditForm[K]) => {
|
||||||
|
setDirty(true);
|
||||||
|
setForm((current) => ({ ...current, [key]: value }));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePictureSelection = (file?: File) => {
|
||||||
|
if (!file) return;
|
||||||
|
if (picturePreview) URL.revokeObjectURL(picturePreview);
|
||||||
|
setPicturePreview(URL.createObjectURL(file));
|
||||||
|
setPictureFile(file);
|
||||||
|
setRemovePicture(false);
|
||||||
|
setDirty(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSave = async (event: React.FormEvent<HTMLFormElement>) => {
|
||||||
|
event.preventDefault();
|
||||||
|
const username = (form.username ?? "").trim();
|
||||||
|
const usernameChanged = username !== profile.username;
|
||||||
|
const bio = form.bio ?? "";
|
||||||
|
const bioChanged = bio !== (profile.bio ?? "");
|
||||||
|
|
||||||
|
if (!usernameCanSave) {
|
||||||
|
toast({ title: "نام کاربری را بررسی کنید", description: usernameFeedback, variant: "destructive" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (bioHasError) {
|
||||||
|
toast({ title: "معرفی بیش از حد مجاز است", description: `معرفی پروفایل باید حداکثر ${BIO_MAX_LENGTH} نویسه باشد.`, variant: "destructive" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (yearOfStudyHasError) {
|
||||||
|
toast({ title: "سال ورود نامعتبر است", description: "سال ورود باید بین ۱۳۰۰ تا ۱۶۰۰ باشد.", variant: "destructive" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
const updates: Types.UserUpdateSchema = {};
|
||||||
|
if (usernameChanged) updates.username = username;
|
||||||
|
if ((form.first_name ?? "") !== profile.first_name) updates.first_name = form.first_name ?? "";
|
||||||
|
if ((form.last_name ?? "") !== profile.last_name) updates.last_name = form.last_name ?? "";
|
||||||
|
if (bioChanged) updates.bio = bio;
|
||||||
|
if ((form.year_of_study ?? null) !== (profile.year_of_study ?? null)) updates.year_of_study = form.year_of_study == null ? null : Number(form.year_of_study);
|
||||||
|
if ((form.major ?? null) !== (profile.major_code ?? null)) updates.major = form.major || null;
|
||||||
|
if ((form.university ?? null) !== (profile.university_code ?? null)) updates.university = form.university || null;
|
||||||
|
const studentId = toEnglishDigits(form.student_id ?? "");
|
||||||
|
const currentStudentId = toEnglishDigits(profile.student_id ?? "");
|
||||||
|
if (studentId !== currentStudentId) updates.student_id = studentId || null;
|
||||||
|
if (Boolean(form.show_public_profile_details) !== Boolean(profile.show_public_profile_details)) {
|
||||||
|
updates.show_public_profile_details = Boolean(form.show_public_profile_details);
|
||||||
|
}
|
||||||
|
if (Object.keys(updates).length) await api.updateProfile(updates);
|
||||||
|
if (pictureFile) await api.uploadProfilePicture(pictureFile);
|
||||||
|
else if (removePicture && profile.profile_picture) await api.deleteProfilePicture();
|
||||||
|
const updated = await api.getProfile();
|
||||||
|
queryClient.setQueryData(["profile"], updated);
|
||||||
|
setUser(updated);
|
||||||
|
setDirty(false);
|
||||||
|
toast({ title: "تغییرات پروفایل ذخیره شد", variant: "success" });
|
||||||
|
navigate("/profile");
|
||||||
|
} catch (error) {
|
||||||
|
toast({ title: "ذخیره پروفایل انجام نشد", description: resolveErrorMessage(error, "دوباره تلاش کنید"), variant: "destructive" });
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCancel = () => {
|
||||||
|
if (dirty && !window.confirm("تغییرات ذخیرهنشده کنار گذاشته شوند؟")) return;
|
||||||
|
navigate("/profile");
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Helmet>
|
||||||
|
<title>ویرایش پروفایل | انجمن علمی مهندسی کامپیوتر</title>
|
||||||
|
<meta name="robots" content="noindex, nofollow" />
|
||||||
|
</Helmet>
|
||||||
|
<main className="min-h-[70vh] w-full min-w-0 overflow-x-clip bg-muted/15 px-4 py-6 md:py-12" dir="rtl">
|
||||||
|
<div className="container mx-auto w-full min-w-0 max-w-5xl">
|
||||||
|
<div className="mb-6 min-w-0 text-right">
|
||||||
|
<p className="text-sm text-muted-foreground">حساب کاربری / پروفایل</p>
|
||||||
|
<h1 className="mt-1 text-2xl font-bold md:text-3xl">ویرایش پروفایل</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form onSubmit={handleSave} className="min-w-0 space-y-5">
|
||||||
|
<Card className="min-w-0 border-border/70 shadow-sm">
|
||||||
|
<CardContent className="flex min-w-0 flex-col items-center gap-5 p-4 text-center sm:flex-row sm:items-center sm:p-6 sm:text-right">
|
||||||
|
<Avatar className="h-24 w-24 border-2 border-background shadow-sm">
|
||||||
|
<AvatarImage src={currentPicture || undefined} alt={fullName} />
|
||||||
|
<AvatarFallback className="text-2xl">{avatarFallback}</AvatarFallback>
|
||||||
|
</Avatar>
|
||||||
|
<div className="min-w-0 w-full flex-1 text-right">
|
||||||
|
<p className="font-semibold">تصویر پروفایل</p>
|
||||||
|
<p className="mt-1 text-sm text-muted-foreground">تصویر دلخواه خود را بارگذاری کنید. حداکثر حجم فایل ۵ مگابایت است.</p>
|
||||||
|
<div className="mt-3 grid w-full grid-cols-1 gap-2 sm:grid-cols-2">
|
||||||
|
<Label htmlFor="profile-picture" className="inline-flex min-h-10 w-full cursor-pointer items-center justify-center rounded-xl border border-border px-4 text-sm font-medium transition-colors hover:bg-muted focus-within:ring-2 focus-within:ring-ring">
|
||||||
|
<Camera className="ml-2 h-4 w-4" />انتخاب تصویر
|
||||||
|
</Label>
|
||||||
|
<Input id="profile-picture" type="file" accept="image/*" className="sr-only" onChange={(event) => handlePictureSelection(event.currentTarget.files?.[0])} />
|
||||||
|
{profile.profile_picture || pictureFile ? (
|
||||||
|
<Button type="button" variant="ghost" size="sm" className="w-full" onClick={() => { setPicturePreview(null); setPictureFile(null); setRemovePicture(true); setDirty(true); }}>
|
||||||
|
<Trash2 className="ml-2 h-4 w-4" />حذف تصویر
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
{removePicture ? <Button type="button" variant="outline" size="sm" className="w-full" onClick={() => { setRemovePicture(false); setDirty(true); }}>بازگردانی تصویر فعلی</Button> : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<div className="grid min-w-0 gap-5 lg:grid-cols-[minmax(0,220px)_minmax(0,1fr)]">
|
||||||
|
<nav className="hidden min-w-0 self-start lg:sticky lg:top-24 lg:flex lg:flex-col lg:gap-2 lg:rounded-2xl lg:border lg:border-border/70 lg:bg-card lg:p-2" aria-label="بخشهای ویرایش پروفایل">
|
||||||
|
{editorSections.map(({ id, label, icon: Icon }) => (
|
||||||
|
<a key={id} href={`#${id}`} className="inline-flex min-h-11 min-w-0 items-center justify-center gap-2 rounded-xl px-2 text-xs text-muted-foreground transition-colors hover:bg-muted hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring sm:justify-start sm:px-3 sm:text-sm">
|
||||||
|
<Icon className="h-4 w-4" aria-hidden="true" />{label}
|
||||||
|
</a>
|
||||||
|
))}
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div className="min-w-0 space-y-5">
|
||||||
|
<Card id="identity" className="scroll-mt-24 min-w-0 border-border/70 shadow-sm">
|
||||||
|
<CardHeader className="text-right">
|
||||||
|
<CardTitle>اطلاعات فردی</CardTitle>
|
||||||
|
<CardDescription>نامی که در سایت و پروفایل عمومی شما نمایش داده میشود</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="grid min-w-0 gap-4 sm:grid-cols-2">
|
||||||
|
<div className="min-w-0"><Label htmlFor="first_name">نام</Label><Input id="first_name" autoComplete="given-name" value={form.first_name ?? ""} onChange={(event) => updateForm("first_name", event.target.value)} className="mt-2 w-full min-w-0 text-right" /></div>
|
||||||
|
<div className="min-w-0"><Label htmlFor="last_name">نام خانوادگی</Label><Input id="last_name" autoComplete="family-name" value={form.last_name ?? ""} onChange={(event) => updateForm("last_name", event.target.value)} className="mt-2 w-full min-w-0 text-right" /></div>
|
||||||
|
<div className="min-w-0 sm:col-span-2">
|
||||||
|
<Label htmlFor="username">نام کاربری</Label>
|
||||||
|
<Input
|
||||||
|
id="username"
|
||||||
|
autoComplete="username"
|
||||||
|
autoCapitalize="none"
|
||||||
|
spellCheck={false}
|
||||||
|
maxLength={USERNAME_MAX_LENGTH}
|
||||||
|
required
|
||||||
|
aria-invalid={usernameHasError}
|
||||||
|
aria-describedby={usernameChanged ? "username-help username-warning username-feedback" : "username-help"}
|
||||||
|
value={form.username ?? ""}
|
||||||
|
onChange={(event) => updateForm("username", event.target.value)}
|
||||||
|
className={`mt-2 text-left ${usernameHasError ? "border-destructive focus-visible:ring-destructive" : ""}`}
|
||||||
|
dir="ltr"
|
||||||
|
/>
|
||||||
|
{usernameChanged ? (
|
||||||
|
<div id="username-warning" role="note" className="mt-2 flex items-start gap-2 rounded-lg border border-amber-300/70 bg-amber-50/80 px-3 py-2 text-xs leading-5 text-amber-950 dark:border-amber-900/70 dark:bg-amber-950/20 dark:text-amber-100">
|
||||||
|
<CircleAlert className="mt-0.5 h-4 w-4 shrink-0" aria-hidden="true" />
|
||||||
|
<span>با تغییر نام کاربری، نشانی پروفایل عمومی شما عوض میشود و پیوندهای قبلی از دسترس خارج میشوند.</span>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
<p id="username-help" className="mt-1 text-xs leading-5 text-muted-foreground">نام کاربری در نشانی پروفایل عمومی شما استفاده میشود.</p>
|
||||||
|
{usernameChanged ? (
|
||||||
|
<p id="username-feedback" aria-live="polite" className={`mt-1 text-xs ${usernameCheckStatus === "available" ? "text-emerald-700 dark:text-emerald-400" : usernameCheckStatus === "checking" ? "text-muted-foreground" : "text-destructive"}`}>
|
||||||
|
{usernameCheckStatus === "checking" ? <Loader2 className="ml-1 inline h-3 w-3 animate-spin" aria-hidden="true" /> : null}
|
||||||
|
{usernameFeedback}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card id="academic" className="scroll-mt-24 min-w-0 border-border/70 shadow-sm">
|
||||||
|
<CardHeader className="text-right">
|
||||||
|
<CardTitle>اطلاعات دانشگاهی</CardTitle>
|
||||||
|
<CardDescription>با فعالکردن نمایش جزئیات پروفایل عمومی، این اطلاعات برای همه قابل مشاهده میشوند.</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="grid min-w-0 gap-4 sm:grid-cols-2">
|
||||||
|
<div className="min-w-0"><Label htmlFor="university">دانشگاه</Label><div className="mt-2 min-w-0"><AsyncSearchableCombobox id="university" value={form.university || null} selectedLabel={profile.university} onChange={(value) => updateForm("university", value)} loadOptions={loadUniversities} placeholder="انتخاب دانشگاه" /></div></div>
|
||||||
|
<div className="min-w-0"><Label htmlFor="major">رشته</Label><div className="mt-2 min-w-0"><AsyncSearchableCombobox id="major" value={form.major || null} selectedLabel={profile.major} onChange={(value) => updateForm("major", value)} loadOptions={loadMajors} placeholder="انتخاب رشته" /></div></div>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<Label htmlFor="year_of_study">سال ورود</Label>
|
||||||
|
<Input
|
||||||
|
id="year_of_study"
|
||||||
|
type="text"
|
||||||
|
inputMode="numeric"
|
||||||
|
maxLength={4}
|
||||||
|
value={form.year_of_study == null ? "" : toPersianDigits(form.year_of_study)}
|
||||||
|
onChange={(event) => {
|
||||||
|
const digits = toEnglishDigits(event.target.value).replace(/\D/g, "").slice(0, 4);
|
||||||
|
updateForm("year_of_study", digits ? Number(digits) : null);
|
||||||
|
}}
|
||||||
|
aria-invalid={yearOfStudyHasError || undefined}
|
||||||
|
aria-describedby={yearOfStudyHasError ? "year_of_study-error" : undefined}
|
||||||
|
className={`mt-2 w-full min-w-0 text-right ${yearOfStudyHasError ? "border-destructive focus-visible:ring-destructive" : ""}`}
|
||||||
|
/>
|
||||||
|
{yearOfStudyHasError ? <p id="year_of_study-error" className="mt-1 text-xs text-destructive">سال ورود باید بین ۱۳۰۰ تا ۱۶۰۰ باشد.</p> : null}
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<Label htmlFor="student_id">شماره دانشجویی</Label>
|
||||||
|
<Input id="student_id" autoComplete="off" value={toPersianDigits(form.student_id ?? "")} onChange={(event) => updateForm("student_id", toEnglishDigits(event.target.value))} className="mt-2 w-full min-w-0 text-right" />
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card id="about" className="scroll-mt-24 min-w-0 border-border/70 shadow-sm">
|
||||||
|
<CardHeader className="text-right"><CardTitle>درباره من</CardTitle><CardDescription>یک معرفی کوتاه و ساده بنویسید. متن در پروفایل عمومی بدون قالببندی نمایش داده میشود.</CardDescription></CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<Label htmlFor="bio">معرفی کوتاه</Label>
|
||||||
|
<Textarea
|
||||||
|
id="bio"
|
||||||
|
value={form.bio ?? ""}
|
||||||
|
onChange={(event) => updateForm("bio", event.target.value)}
|
||||||
|
className={`mt-2 min-h-28 resize-y text-right leading-7 ${bioHasError ? "border-destructive focus-visible:ring-destructive" : ""}`}
|
||||||
|
maxLength={BIO_MAX_LENGTH}
|
||||||
|
aria-invalid={bioHasError}
|
||||||
|
aria-describedby="bio-count"
|
||||||
|
/>
|
||||||
|
<p id="bio-count" aria-live="polite" className={`mt-2 text-xs ${bioHasError ? "text-destructive" : "text-muted-foreground"}`}>
|
||||||
|
{bioLengthLabel} از {bioMaxLengthLabel} نویسه{bioHasError ? " — برای ذخیره، متن را کوتاه کنید." : ""}
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card id="visibility" className="scroll-mt-24 min-w-0 border-border/70 shadow-sm">
|
||||||
|
<CardHeader className="text-right"><CardTitle>پروفایل عمومی</CardTitle><CardDescription>پروفایل عمومی رویدادهایی را که در آنها شرکت کردهاید نشان میدهد. این گزینه، جزئیات پروفایل را هم در اختیار بازدیدکنندگان قرار میدهد.</CardDescription></CardHeader>
|
||||||
|
<CardContent className="space-y-5">
|
||||||
|
<div className="flex min-w-0 items-start justify-between gap-4 rounded-2xl border border-border/70 p-4">
|
||||||
|
<div className="min-w-0 flex-1 text-right">
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<Label htmlFor="show-public-details" className="cursor-pointer text-sm font-semibold">نمایش جزئیات پروفایل برای همه</Label>
|
||||||
|
<span aria-live="polite" className={`rounded-full px-2 py-0.5 text-xs font-semibold ${form.show_public_profile_details ? "bg-primary/15 text-primary" : "bg-muted text-muted-foreground"}`}>{form.show_public_profile_details ? "فعال" : "غیرفعال"}</span>
|
||||||
|
</div>
|
||||||
|
<p id="public-details-description" className="mt-1 max-w-xl text-sm leading-6 text-muted-foreground">با فعالکردن این گزینه، شماره موبایل، شماره دانشجویی، دانشگاه، رشته و سال ورود در پروفایل عمومی دیده میشوند.</p>
|
||||||
|
</div>
|
||||||
|
<Switch id="show-public-details" checked={Boolean(form.show_public_profile_details)} onCheckedChange={(checked) => updateForm("show_public_profile_details", checked)} aria-label="نمایش جزئیات پروفایل برای همه" aria-describedby="public-details-description" className="shrink-0 data-[state=checked]:bg-primary data-[state=unchecked]:bg-muted-foreground/40" />
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-4 sm:grid-cols-2">
|
||||||
|
<div><Label htmlFor="mobile">شماره موبایل</Label><Input id="mobile" value={profile.mobile ? toPersianDigits(profile.mobile) : "ثبت نشده"} readOnly aria-readonly="true" className="mt-2 text-right read-only:cursor-not-allowed read-only:bg-muted/40" /><p className="mt-1 text-xs text-muted-foreground">تغییر شماره موبایل از این صفحه امکانپذیر نیست. برای بهروزرسانی آن، روند تأیید شماره را دنبال کنید.</p></div>
|
||||||
|
<div><Label htmlFor="email">ایمیل</Label><Input id="email" value={profile.email ?? "ثبت نشده"} readOnly aria-readonly="true" className="mt-2 text-right read-only:cursor-not-allowed read-only:bg-muted/40" /><p className="mt-1 text-xs text-muted-foreground">تغییر ایمیل از این صفحه امکانپذیر نیست.</p></div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<div className="flex flex-col-reverse gap-3 sm:flex-row sm:justify-end">
|
||||||
|
<Button type="button" variant="outline" onClick={handleCancel} disabled={saving}>انصراف</Button>
|
||||||
|
<Button type="submit" disabled={saving || bioHasError || !usernameCanSave} className="min-w-36">
|
||||||
|
{saving ? <Loader2 className="ml-2 h-4 w-4 animate-spin" /> : <Check className="ml-2 h-4 w-4" />}
|
||||||
|
{saving ? "در حال ذخیره..." : "ذخیره تغییرات"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
143
src/views/PublicProfile.tsx
Normal file
143
src/views/PublicProfile.tsx
Normal file
@@ -0,0 +1,143 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
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 { Helmet } from "@/lib/helmet";
|
||||||
|
import { Link, useParams } from "@/lib/router";
|
||||||
|
import { api } from "@/lib/api";
|
||||||
|
import { formatJalali, formatNumberPersian, 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>
|
||||||
|
)}
|
||||||
|
<span className="flex min-w-0 flex-1 flex-col gap-2 py-1">
|
||||||
|
<span className="font-semibold text-foreground group-hover:text-primary">{event.title}</span>
|
||||||
|
<span className="text-xs text-muted-foreground">{formatJalali(event.start_time, false)}</span>
|
||||||
|
</span>
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function PublicProfile() {
|
||||||
|
const { username: rawUsername } = useParams<{ username: string }>();
|
||||||
|
const username = Array.isArray(rawUsername) ? rawUsername[0] : rawUsername;
|
||||||
|
const profileQuery = useQuery({
|
||||||
|
queryKey: ["public-profile", username],
|
||||||
|
queryFn: () => api.getPublicProfile(username),
|
||||||
|
enabled: Boolean(username),
|
||||||
|
retry: false,
|
||||||
|
});
|
||||||
|
const profile = profileQuery.data;
|
||||||
|
|
||||||
|
if (profileQuery.isLoading) {
|
||||||
|
return <main className="flex min-h-[70vh] items-center justify-center text-muted-foreground" dir="rtl">در حال بارگذاری پروفایل عمومی...</main>;
|
||||||
|
}
|
||||||
|
if (profileQuery.isError || !profile) {
|
||||||
|
return (
|
||||||
|
<main className="container mx-auto max-w-4xl px-4 py-16" dir="rtl">
|
||||||
|
<Card className="border-border/70 text-center shadow-sm"><CardContent className="p-10">
|
||||||
|
<UserRound className="mx-auto h-8 w-8 text-muted-foreground" aria-hidden="true" />
|
||||||
|
<h1 className="mt-4 text-xl font-bold">این پروفایل در دسترس نیست</h1>
|
||||||
|
<p className="mt-2 text-sm text-muted-foreground">نام کاربری را بررسی کنید یا بعداً دوباره تلاش کنید.</p>
|
||||||
|
</CardContent></Card>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 },
|
||||||
|
];
|
||||||
|
const pageDescription = profile.bio || `پروفایل عمومی ${profile.full_name} و رویدادهای او در انجمن علمی مهندسی کامپیوتر.`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Helmet>
|
||||||
|
<title>{`${profile.full_name} | پروفایل عمومی`}</title>
|
||||||
|
<meta name="description" content={pageDescription} />
|
||||||
|
<meta name="robots" content="index, follow" />
|
||||||
|
</Helmet>
|
||||||
|
<main className="min-h-screen bg-muted/15 px-4 py-8 md:py-12" dir="rtl">
|
||||||
|
<div className="container mx-auto max-w-6xl space-y-5">
|
||||||
|
<div className="grid gap-5 lg:grid-cols-12 lg:items-start">
|
||||||
|
<aside className="space-y-5 lg:col-span-4">
|
||||||
|
<Card className="border-border/70 shadow-sm">
|
||||||
|
<CardContent className="p-6 text-right">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<Avatar className="h-20 w-20 border-2 border-background shadow-sm">
|
||||||
|
<AvatarImage src={profile.profile_picture || undefined} alt={profile.full_name} />
|
||||||
|
<AvatarFallback className="text-xl">{fallback}</AvatarFallback>
|
||||||
|
</Avatar>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<h1 className="truncate text-xl font-bold">{profile.full_name}</h1>
|
||||||
|
<p className="mt-1 text-sm text-muted-foreground">@{profile.username}</p>
|
||||||
|
</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]">
|
||||||
|
{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">
|
||||||
|
<span className="shrink-0 text-muted-foreground">{label}</span>
|
||||||
|
<span className="font-medium">••••••••</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</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>
|
||||||
|
<p className="font-semibold">جزئیات این پروفایل خصوصی است</p>
|
||||||
|
<p className="mt-1 text-sm leading-6 text-muted-foreground">نمایش اطلاعات تکمیلی به درخواست صاحب حساب غیرفعال شده است.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<div className="space-y-5 lg:col-span-8">
|
||||||
|
<Card className="border-border/70 shadow-sm">
|
||||||
|
<CardHeader className="text-right">
|
||||||
|
<CardTitle className="flex items-center gap-2"><CalendarDays className="h-5 w-5 text-primary" aria-hidden="true" />رویدادهای انجمن</CardTitle>
|
||||||
|
<CardDescription>رویدادهایی که {profile.first_name || profile.username} در آنها شرکت کرده</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="h-[70vh] space-y-3 overflow-y-auto overscroll-contain">
|
||||||
|
{profile.events.length ? profile.events.map((event) => <EventCard key={`${event.slug}-${event.start_time}`} event={event} />) : <p className="rounded-xl bg-muted/30 p-5 text-center text-sm text-muted-foreground">هنوز رویدادی برای نمایش ثبت نشده است.</p>}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
11
tests/digits.test.mjs
Normal file
11
tests/digits.test.mjs
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import test from "node:test";
|
||||||
|
import { toEnglishDigits } from "../src/lib/digits.ts";
|
||||||
|
|
||||||
|
test("normalizes Persian and Arabic-Indic digits to English digits", () => {
|
||||||
|
assert.equal(toEnglishDigits("۱۴۰۱ ١٢٣ 456"), "1401 123 456");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("keeps non-digit characters unchanged", () => {
|
||||||
|
assert.equal(toEnglishDigits("۰۹۳۸-abc"), "0938-abc");
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user