feat(frontend): add async admin form foundations
This commit is contained in:
32
package-lock.json
generated
32
package-lock.json
generated
@@ -56,10 +56,12 @@
|
|||||||
"next": "^15.4.6",
|
"next": "^15.4.6",
|
||||||
"next-themes": "^0.3.0",
|
"next-themes": "^0.3.0",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
|
"react-date-object": "^2.1.9",
|
||||||
"react-day-picker": "^8.10.1",
|
"react-day-picker": "^8.10.1",
|
||||||
"react-dom": "^18.3.1",
|
"react-dom": "^18.3.1",
|
||||||
"react-hook-form": "^7.61.1",
|
"react-hook-form": "^7.61.1",
|
||||||
"react-markdown": "^9.0.3",
|
"react-markdown": "^9.0.3",
|
||||||
|
"react-multi-date-picker": "^4.5.2",
|
||||||
"react-qr-code": "^2.0.11",
|
"react-qr-code": "^2.0.11",
|
||||||
"react-resizable-panels": "^2.1.9",
|
"react-resizable-panels": "^2.1.9",
|
||||||
"react-syntax-highlighter": "^16.1.1",
|
"react-syntax-highlighter": "^16.1.1",
|
||||||
@@ -6653,6 +6655,12 @@
|
|||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/react-date-object": {
|
||||||
|
"version": "2.1.9",
|
||||||
|
"resolved": "https://package-mirror.liara.ir/repository/npm/react-date-object/-/react-date-object-2.1.9.tgz",
|
||||||
|
"integrity": "sha512-BHxD/quWOTo9fLKV/cfL/M31ePoj4a1JaJ/CnOf8Ndg3mrkh4x9wEMMkCfTrzduxDOgU8ZgR8uarhqI5G71sTg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/react-day-picker": {
|
"node_modules/react-day-picker": {
|
||||||
"version": "8.10.1",
|
"version": "8.10.1",
|
||||||
"resolved": "https://registry.npmjs.org/react-day-picker/-/react-day-picker-8.10.1.tgz",
|
"resolved": "https://registry.npmjs.org/react-day-picker/-/react-day-picker-8.10.1.tgz",
|
||||||
@@ -6680,6 +6688,16 @@
|
|||||||
"react": "^18.3.1"
|
"react": "^18.3.1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/react-element-popper": {
|
||||||
|
"version": "2.1.7",
|
||||||
|
"resolved": "https://package-mirror.liara.ir/repository/npm/react-element-popper/-/react-element-popper-2.1.7.tgz",
|
||||||
|
"integrity": "sha512-tuM2OxKlW32h+6uFSK6EENHPeZ2OGgOipHfOAl+VLWEv9/j3QkSGbD+ADX3A9uJlmq24i37n28RjJmAbGTfpEg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": ">=16.8.0",
|
||||||
|
"react-dom": ">=16.8.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/react-hook-form": {
|
"node_modules/react-hook-form": {
|
||||||
"version": "7.61.1",
|
"version": "7.61.1",
|
||||||
"resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.61.1.tgz",
|
"resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.61.1.tgz",
|
||||||
@@ -6728,6 +6746,20 @@
|
|||||||
"react": ">=18"
|
"react": ">=18"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/react-multi-date-picker": {
|
||||||
|
"version": "4.5.2",
|
||||||
|
"resolved": "https://package-mirror.liara.ir/repository/npm/react-multi-date-picker/-/react-multi-date-picker-4.5.2.tgz",
|
||||||
|
"integrity": "sha512-FgWjZB3Z6IA6XpcWiLPk85PwcRUhOiYhKK42o5k672gD/n2I6rzPfQ8bUrldOIiF/Z7FfOCdH7a6FeubzqteLg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"react-date-object": "^2.1.8",
|
||||||
|
"react-element-popper": "^2.1.6"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": ">=16.8.0",
|
||||||
|
"react-dom": ">=16.8.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/react-qr-code": {
|
"node_modules/react-qr-code": {
|
||||||
"version": "2.0.18",
|
"version": "2.0.18",
|
||||||
"resolved": "https://registry.npmjs.org/react-qr-code/-/react-qr-code-2.0.18.tgz",
|
"resolved": "https://registry.npmjs.org/react-qr-code/-/react-qr-code-2.0.18.tgz",
|
||||||
|
|||||||
@@ -58,10 +58,12 @@
|
|||||||
"next": "^15.4.6",
|
"next": "^15.4.6",
|
||||||
"next-themes": "^0.3.0",
|
"next-themes": "^0.3.0",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
|
"react-date-object": "^2.1.9",
|
||||||
"react-day-picker": "^8.10.1",
|
"react-day-picker": "^8.10.1",
|
||||||
"react-dom": "^18.3.1",
|
"react-dom": "^18.3.1",
|
||||||
"react-hook-form": "^7.61.1",
|
"react-hook-form": "^7.61.1",
|
||||||
"react-markdown": "^9.0.3",
|
"react-markdown": "^9.0.3",
|
||||||
|
"react-multi-date-picker": "^4.5.2",
|
||||||
"react-qr-code": "^2.0.11",
|
"react-qr-code": "^2.0.11",
|
||||||
"react-resizable-panels": "^2.1.9",
|
"react-resizable-panels": "^2.1.9",
|
||||||
"react-syntax-highlighter": "^16.1.1",
|
"react-syntax-highlighter": "^16.1.1",
|
||||||
|
|||||||
96
src/components/AdminDateTimeField.tsx
Normal file
96
src/components/AdminDateTimeField.tsx
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import * as React from "react";
|
||||||
|
import DateObject from "react-date-object";
|
||||||
|
import persian from "react-date-object/calendars/persian";
|
||||||
|
import persian_fa from "react-date-object/locales/persian_fa";
|
||||||
|
import DatePicker from "react-multi-date-picker";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
|
||||||
|
type AdminDateTimeFieldProps = {
|
||||||
|
label: string;
|
||||||
|
value?: string | null;
|
||||||
|
onChange: (value: string | null) => void;
|
||||||
|
required?: boolean;
|
||||||
|
disabled?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
function splitDateTime(value?: string | null) {
|
||||||
|
if (!value) {
|
||||||
|
return { date: null as DateObject | null, time: "" };
|
||||||
|
}
|
||||||
|
const date = new Date(value);
|
||||||
|
if (Number.isNaN(date.getTime())) {
|
||||||
|
return { date: null, time: "" };
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
date: new DateObject({ date, calendar: persian, locale: persian_fa }),
|
||||||
|
time: `${String(date.getHours()).padStart(2, "0")}:${String(date.getMinutes()).padStart(2, "0")}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function combineDateTime(date: DateObject | null, time: string) {
|
||||||
|
if (!date || !time || !/^\d{2}:\d{2}$/.test(time)) return null;
|
||||||
|
const gregorian = date.toDate();
|
||||||
|
const [hours, minutes] = time.split(":").map(Number);
|
||||||
|
gregorian.setHours(hours, minutes, 0, 0);
|
||||||
|
return gregorian.toISOString();
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AdminDateTimeField({
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
required,
|
||||||
|
disabled,
|
||||||
|
}: AdminDateTimeFieldProps) {
|
||||||
|
const initial = React.useMemo(() => splitDateTime(value), [value]);
|
||||||
|
const [date, setDate] = React.useState<DateObject | null>(initial.date);
|
||||||
|
const [time, setTime] = React.useState(initial.time);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
setDate(initial.date);
|
||||||
|
setTime(initial.time);
|
||||||
|
}, [initial.date, initial.time]);
|
||||||
|
|
||||||
|
const emitChange = (nextDate: DateObject | null, nextTime: string) => {
|
||||||
|
onChange(combineDateTime(nextDate, nextTime));
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>
|
||||||
|
{label}
|
||||||
|
{required ? <span className="text-destructive"> *</span> : null}
|
||||||
|
</Label>
|
||||||
|
<div className="grid gap-2 sm:grid-cols-[1fr_120px]">
|
||||||
|
<DatePicker
|
||||||
|
value={date}
|
||||||
|
onChange={(next) => {
|
||||||
|
const nextDate = next instanceof DateObject ? next : null;
|
||||||
|
setDate(nextDate);
|
||||||
|
emitChange(nextDate, time);
|
||||||
|
}}
|
||||||
|
calendar={persian}
|
||||||
|
locale={persian_fa}
|
||||||
|
calendarPosition="bottom-right"
|
||||||
|
disabled={disabled}
|
||||||
|
inputClass="h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm text-right ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
|
placeholder="تاریخ"
|
||||||
|
containerClassName="w-full"
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
dir="ltr"
|
||||||
|
type="time"
|
||||||
|
value={time}
|
||||||
|
disabled={disabled}
|
||||||
|
onChange={(event) => {
|
||||||
|
setTime(event.target.value);
|
||||||
|
emitChange(date, event.target.value);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
178
src/components/AsyncSearchableCombobox.tsx
Normal file
178
src/components/AsyncSearchableCombobox.tsx
Normal file
@@ -0,0 +1,178 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import * as React from "react";
|
||||||
|
import { Check, ChevronsUpDown, Loader2 } from "lucide-react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
Command,
|
||||||
|
CommandEmpty,
|
||||||
|
CommandGroup,
|
||||||
|
CommandInput,
|
||||||
|
CommandItem,
|
||||||
|
CommandList,
|
||||||
|
} from "@/components/ui/command";
|
||||||
|
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
export type AsyncComboboxOption = {
|
||||||
|
value: string;
|
||||||
|
label: string;
|
||||||
|
description?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type AsyncSearchableComboboxProps = {
|
||||||
|
value?: string | null;
|
||||||
|
onChange: (value: string | null) => void;
|
||||||
|
loadOptions: (params: { search: string; limit: number; offset: number }) => Promise<{
|
||||||
|
count: number;
|
||||||
|
results: AsyncComboboxOption[];
|
||||||
|
}>;
|
||||||
|
placeholder?: string;
|
||||||
|
searchPlaceholder?: string;
|
||||||
|
emptyText?: string;
|
||||||
|
disabled?: boolean;
|
||||||
|
allowClear?: boolean;
|
||||||
|
pageSize?: number;
|
||||||
|
className?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function AsyncSearchableCombobox({
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
loadOptions,
|
||||||
|
placeholder = "انتخاب کنید",
|
||||||
|
searchPlaceholder = "جستجو...",
|
||||||
|
emptyText = "موردی پیدا نشد.",
|
||||||
|
disabled = false,
|
||||||
|
allowClear = true,
|
||||||
|
pageSize = 20,
|
||||||
|
className,
|
||||||
|
}: AsyncSearchableComboboxProps) {
|
||||||
|
const [open, setOpen] = React.useState(false);
|
||||||
|
const [search, setSearch] = React.useState("");
|
||||||
|
const [debouncedSearch, setDebouncedSearch] = React.useState("");
|
||||||
|
const [options, setOptions] = React.useState<AsyncComboboxOption[]>([]);
|
||||||
|
const [count, setCount] = React.useState(0);
|
||||||
|
const [loading, setLoading] = React.useState(false);
|
||||||
|
const [loadingMore, setLoadingMore] = React.useState(false);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
const timer = window.setTimeout(() => setDebouncedSearch(search.trim()), 300);
|
||||||
|
return () => window.clearTimeout(timer);
|
||||||
|
}, [search]);
|
||||||
|
|
||||||
|
const selected = React.useMemo(
|
||||||
|
() => options.find((option) => option.value === value),
|
||||||
|
[options, value],
|
||||||
|
);
|
||||||
|
|
||||||
|
const fetchPage = React.useCallback(
|
||||||
|
async (offset: number, append = false) => {
|
||||||
|
if (append) setLoadingMore(true);
|
||||||
|
else setLoading(true);
|
||||||
|
try {
|
||||||
|
const data = await loadOptions({ search: debouncedSearch, limit: pageSize, offset });
|
||||||
|
setCount(data.count);
|
||||||
|
setOptions((current) => {
|
||||||
|
const next = append ? [...current, ...data.results] : data.results;
|
||||||
|
const byValue = new Map(next.map((option) => [option.value, option]));
|
||||||
|
return Array.from(byValue.values());
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
setLoadingMore(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[debouncedSearch, loadOptions, pageSize],
|
||||||
|
);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
void fetchPage(0);
|
||||||
|
}, [fetchPage, open]);
|
||||||
|
|
||||||
|
const hasMore = options.length < count;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Popover open={open} onOpenChange={setOpen}>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
role="combobox"
|
||||||
|
aria-expanded={open}
|
||||||
|
disabled={disabled}
|
||||||
|
className={cn("w-full justify-between gap-2", className)}
|
||||||
|
>
|
||||||
|
<span className="truncate text-right">{selected?.label || value || placeholder}</span>
|
||||||
|
<ChevronsUpDown className="h-4 w-4 shrink-0 opacity-50" />
|
||||||
|
</Button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent className="w-[var(--radix-popover-trigger-width)] p-0" align="start" dir="rtl">
|
||||||
|
<Command shouldFilter={false}>
|
||||||
|
<CommandInput value={search} onValueChange={setSearch} placeholder={searchPlaceholder} />
|
||||||
|
<CommandList>
|
||||||
|
{loading ? (
|
||||||
|
<div className="flex items-center justify-center gap-2 py-6 text-sm text-muted-foreground">
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
|
در حال جستجو...
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<CommandEmpty>{emptyText}</CommandEmpty>
|
||||||
|
<CommandGroup>
|
||||||
|
{allowClear ? (
|
||||||
|
<CommandItem
|
||||||
|
value="__clear"
|
||||||
|
onSelect={() => {
|
||||||
|
onChange(null);
|
||||||
|
setOpen(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Check className={cn("ml-2 h-4 w-4", !value ? "opacity-100" : "opacity-0")} />
|
||||||
|
همه موارد
|
||||||
|
</CommandItem>
|
||||||
|
) : null}
|
||||||
|
{options.map((option) => (
|
||||||
|
<CommandItem
|
||||||
|
key={option.value}
|
||||||
|
value={option.value}
|
||||||
|
onSelect={() => {
|
||||||
|
onChange(option.value === value ? null : option.value);
|
||||||
|
setOpen(false);
|
||||||
|
}}
|
||||||
|
className="flex items-center justify-between gap-2"
|
||||||
|
>
|
||||||
|
<span className="min-w-0 flex-1 text-right">
|
||||||
|
<span className="block truncate">{option.label}</span>
|
||||||
|
{option.description ? (
|
||||||
|
<span className="block truncate text-xs text-muted-foreground">{option.description}</span>
|
||||||
|
) : null}
|
||||||
|
</span>
|
||||||
|
<Check className={cn("h-4 w-4", value === option.value ? "opacity-100" : "opacity-0")} />
|
||||||
|
</CommandItem>
|
||||||
|
))}
|
||||||
|
</CommandGroup>
|
||||||
|
{hasMore ? (
|
||||||
|
<div className="p-2">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="w-full"
|
||||||
|
disabled={loadingMore}
|
||||||
|
onClick={() => void fetchPage(options.length, true)}
|
||||||
|
>
|
||||||
|
{loadingMore ? <Loader2 className="ml-2 h-4 w-4 animate-spin" /> : null}
|
||||||
|
بارگذاری بیشتر
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</CommandList>
|
||||||
|
</Command>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
);
|
||||||
|
}
|
||||||
186
src/lib/api.ts
186
src/lib/api.ts
@@ -397,6 +397,10 @@ class ApiClient {
|
|||||||
return this.request<Types.UserListSchema[]>(`/api/auth/users${query.toString() ? `?${query.toString()}` : ''}`);
|
return this.request<Types.UserListSchema[]>(`/api/auth/users${query.toString() ? `?${query.toString()}` : ''}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async getUserDetail(userId: number) {
|
||||||
|
return this.request<Types.UserProfileSchema>(`/api/auth/users/${userId}`);
|
||||||
|
}
|
||||||
|
|
||||||
async listAuthorizationRoles() {
|
async listAuthorizationRoles() {
|
||||||
return this.request<Types.AuthorizationRoleSchema[]>('/api/auth/roles');
|
return this.request<Types.AuthorizationRoleSchema[]>('/api/auth/roles');
|
||||||
}
|
}
|
||||||
@@ -856,6 +860,68 @@ class ApiClient {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async createEvent(data: Types.EventCreateSchema) {
|
||||||
|
return this.request<Types.EventDetailSchema>('/api/events/', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async uploadEventFeaturedImage(eventId: number, file: File) {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('file', file);
|
||||||
|
const token = this.getStorageValue('access_token');
|
||||||
|
const response = await fetch(`${this.baseUrl}/api/events/${eventId}/featured-image`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||||
|
},
|
||||||
|
body: formData,
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
const body = (await response.json().catch(() => ({}))) as ApiErrorBody;
|
||||||
|
throw new Error(body.error || body.detail || 'Event image upload failed');
|
||||||
|
}
|
||||||
|
return response.json() as Promise<Types.EventDetailSchema>;
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteEventFeaturedImage(eventId: number) {
|
||||||
|
return this.request<Types.EventDetailSchema>(`/api/events/${eventId}/featured-image`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async listEventGallery(eventId: number) {
|
||||||
|
return this.request<Types.EventGalleryItem[]>(`/api/events/${eventId}/gallery`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async uploadEventGalleryImage(eventId: number, file: File, data: { title?: string; alt_text?: string } = {}) {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('file', file);
|
||||||
|
if (data.title) formData.append('title', data.title);
|
||||||
|
if (data.alt_text) formData.append('alt_text', data.alt_text);
|
||||||
|
const token = this.getStorageValue('access_token');
|
||||||
|
const response = await fetch(`${this.baseUrl}/api/events/${eventId}/gallery`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||||
|
},
|
||||||
|
body: formData,
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
const body = (await response.json().catch(() => ({}))) as ApiErrorBody;
|
||||||
|
throw new Error(body.error || body.detail || 'Event gallery upload failed');
|
||||||
|
}
|
||||||
|
return response.json() as Promise<Types.EventGalleryItem>;
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteEventGalleryImage(eventId: number, imageId: number) {
|
||||||
|
return this.request<Types.MessageSchema>(`/api/events/${eventId}/gallery/${imageId}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
async deleteEvent(eventId: number) {
|
async deleteEvent(eventId: number) {
|
||||||
return this.request<Types.MessageSchema>(`/api/events/${eventId}`, {
|
return this.request<Types.MessageSchema>(`/api/events/${eventId}`, {
|
||||||
method: 'DELETE',
|
method: 'DELETE',
|
||||||
@@ -971,6 +1037,44 @@ class ApiClient {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async listDiscountCodes(params?: {
|
||||||
|
search?: string;
|
||||||
|
is_active?: boolean;
|
||||||
|
type?: 'percent' | 'fixed';
|
||||||
|
limit?: number;
|
||||||
|
offset?: number;
|
||||||
|
}) {
|
||||||
|
const query = new URLSearchParams();
|
||||||
|
if (params?.search) query.set('search', params.search);
|
||||||
|
if (params?.is_active != null) query.set('is_active', String(params.is_active));
|
||||||
|
if (params?.type) query.set('type', params.type);
|
||||||
|
if (params?.limit != null) query.set('limit', String(params.limit));
|
||||||
|
if (params?.offset != null) query.set('offset', String(params.offset));
|
||||||
|
return this.request<Types.PagedDiscountCodeSchema>(
|
||||||
|
`/api/payments/admin/discount-codes${query.toString() ? `?${query.toString()}` : ''}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async createDiscountCode(data: Types.DiscountCodeWriteSchema) {
|
||||||
|
return this.request<Types.DiscountCodeSchema>('/api/payments/admin/discount-codes', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateDiscountCode(codeId: number, data: Types.DiscountCodeWriteSchema) {
|
||||||
|
return this.request<Types.DiscountCodeSchema>(`/api/payments/admin/discount-codes/${codeId}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteDiscountCode(codeId: number) {
|
||||||
|
return this.request<Types.MessageSchema>(`/api/payments/admin/discount-codes/${codeId}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// ============= Gallery Endpoints =============
|
// ============= Gallery Endpoints =============
|
||||||
|
|
||||||
async getGalleryImages(params?: {
|
async getGalleryImages(params?: {
|
||||||
@@ -1019,12 +1123,86 @@ class ApiClient {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async getMajors(): Promise<Types.MajorOption[]> {
|
async getMajors(params?: { search?: string; limit?: number; offset?: number }): Promise<Types.MajorOption[]> {
|
||||||
return this.request('/api/meta/majors', { method: 'GET' });
|
const data = await this.getMajorsPaged({ limit: 100, offset: 0, ...params });
|
||||||
|
return data.results.map((item) => ({ code: item.code, label: item.label, id: item.id, name: item.name, user_count: item.user_count }));
|
||||||
}
|
}
|
||||||
|
|
||||||
async getUniversities(): Promise<Types.MajorOption[]> {
|
async getUniversities(params?: { search?: string; limit?: number; offset?: number }): Promise<Types.MajorOption[]> {
|
||||||
return this.request('/api/meta/universities', { method: 'GET' });
|
const data = await this.getUniversitiesPaged({ limit: 100, offset: 0, ...params });
|
||||||
|
return data.results.map((item) => ({ code: item.code, label: item.label, id: item.id, name: item.name, user_count: item.user_count }));
|
||||||
|
}
|
||||||
|
|
||||||
|
async getMajorsPaged(params?: { search?: string; limit?: number; offset?: number }) {
|
||||||
|
const query = new URLSearchParams();
|
||||||
|
if (params?.search) query.set('search', params.search);
|
||||||
|
if (params?.limit != null) query.set('limit', String(params.limit));
|
||||||
|
if (params?.offset != null) query.set('offset', String(params.offset));
|
||||||
|
return this.request<Types.PagedMetaOptionSchema>(`/api/meta/majors${query.toString() ? `?${query.toString()}` : ''}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getUniversitiesPaged(params?: { search?: string; limit?: number; offset?: number }) {
|
||||||
|
const query = new URLSearchParams();
|
||||||
|
if (params?.search) query.set('search', params.search);
|
||||||
|
if (params?.limit != null) query.set('limit', String(params.limit));
|
||||||
|
if (params?.offset != null) query.set('offset', String(params.offset));
|
||||||
|
return this.request<Types.PagedMetaOptionSchema>(`/api/meta/universities${query.toString() ? `?${query.toString()}` : ''}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async listAdminMajors(params?: { search?: string; limit?: number; offset?: number }) {
|
||||||
|
const query = new URLSearchParams();
|
||||||
|
if (params?.search) query.set('search', params.search);
|
||||||
|
if (params?.limit != null) query.set('limit', String(params.limit));
|
||||||
|
if (params?.offset != null) query.set('offset', String(params.offset));
|
||||||
|
return this.request<Types.PagedMetaOptionSchema>(`/api/meta/admin/majors${query.toString() ? `?${query.toString()}` : ''}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async createMajor(data: Types.MetaOptionWriteSchema) {
|
||||||
|
return this.request<Types.MetaOptionSchema>('/api/meta/admin/majors', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateMajor(id: number, data: Types.MetaOptionWriteSchema) {
|
||||||
|
return this.request<Types.MetaOptionSchema>(`/api/meta/admin/majors/${id}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteMajor(id: number) {
|
||||||
|
return this.request<Types.MessageSchema>(`/api/meta/admin/majors/${id}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async listAdminUniversities(params?: { search?: string; limit?: number; offset?: number }) {
|
||||||
|
const query = new URLSearchParams();
|
||||||
|
if (params?.search) query.set('search', params.search);
|
||||||
|
if (params?.limit != null) query.set('limit', String(params.limit));
|
||||||
|
if (params?.offset != null) query.set('offset', String(params.offset));
|
||||||
|
return this.request<Types.PagedMetaOptionSchema>(`/api/meta/admin/universities${query.toString() ? `?${query.toString()}` : ''}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async createUniversity(data: Types.MetaOptionWriteSchema) {
|
||||||
|
return this.request<Types.MetaOptionSchema>('/api/meta/admin/universities', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateUniversity(id: number, data: Types.MetaOptionWriteSchema) {
|
||||||
|
return this.request<Types.MetaOptionSchema>(`/api/meta/admin/universities/${id}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteUniversity(id: number) {
|
||||||
|
return this.request<Types.MessageSchema>(`/api/meta/admin/universities/${id}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async subscribeNewsletter(email: string) {
|
async subscribeNewsletter(email: string) {
|
||||||
|
|||||||
110
src/lib/types.ts
110
src/lib/types.ts
@@ -18,6 +18,27 @@ export interface TokenSchema {
|
|||||||
export interface MajorOption {
|
export interface MajorOption {
|
||||||
code: string;
|
code: string;
|
||||||
label: string;
|
label: string;
|
||||||
|
id?: number;
|
||||||
|
name?: string;
|
||||||
|
user_count?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MetaOptionSchema {
|
||||||
|
id: number;
|
||||||
|
code: string;
|
||||||
|
name: string;
|
||||||
|
label: string;
|
||||||
|
user_count?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PagedMetaOptionSchema {
|
||||||
|
count: number;
|
||||||
|
results: MetaOptionSchema[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MetaOptionWriteSchema {
|
||||||
|
code: string;
|
||||||
|
name: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface UserProfileSchema {
|
export interface UserProfileSchema {
|
||||||
@@ -62,6 +83,19 @@ export interface UserListSchema {
|
|||||||
full_name?: string | null;
|
full_name?: string | null;
|
||||||
university?: string | null;
|
university?: string | null;
|
||||||
major?: string | null;
|
major?: string | null;
|
||||||
|
profile_picture?: string | null;
|
||||||
|
profile_picture_thumbnail_url?: string | null;
|
||||||
|
profile_picture_preview_url?: string | null;
|
||||||
|
student_id?: string | null;
|
||||||
|
year_of_study?: number | null;
|
||||||
|
bio?: string | null;
|
||||||
|
is_email_verified?: boolean;
|
||||||
|
is_mobile_verified?: boolean;
|
||||||
|
is_deleted?: boolean;
|
||||||
|
deleted_at?: string | null;
|
||||||
|
can_access_blog_admin?: boolean;
|
||||||
|
can_write_blog_posts?: boolean;
|
||||||
|
can_review_blog_posts?: boolean;
|
||||||
is_active: boolean;
|
is_active: boolean;
|
||||||
is_staff: boolean;
|
is_staff: boolean;
|
||||||
is_superuser: boolean;
|
is_superuser: boolean;
|
||||||
@@ -524,6 +558,12 @@ export interface EventGalleryItem {
|
|||||||
absolute_image_blur_url?: string | null;
|
absolute_image_blur_url?: string | null;
|
||||||
width?: number;
|
width?: number;
|
||||||
height?: number;
|
height?: number;
|
||||||
|
file_size_mb?: number;
|
||||||
|
markdown_url?: string;
|
||||||
|
image?: string;
|
||||||
|
alt_text?: string | null;
|
||||||
|
is_public?: boolean;
|
||||||
|
created_at?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface EventDetailSchema extends EventListItemSchema {
|
export interface EventDetailSchema extends EventListItemSchema {
|
||||||
@@ -536,19 +576,28 @@ export interface EventDetailSchema extends EventListItemSchema {
|
|||||||
export interface EventCreateSchema {
|
export interface EventCreateSchema {
|
||||||
title: string;
|
title: string;
|
||||||
description: string;
|
description: string;
|
||||||
start_date: string;
|
slug?: string | null;
|
||||||
end_date?: string;
|
event_type: 'online' | 'on_site' | 'hybrid';
|
||||||
location: string;
|
address?: string | null;
|
||||||
capacity?: number;
|
location?: string | null;
|
||||||
event_image?: string;
|
online_link?: string | null;
|
||||||
requirements?: string;
|
start_time: string;
|
||||||
is_registration_open?: boolean;
|
end_time: string;
|
||||||
|
registration_start_date?: string | null;
|
||||||
|
registration_end_date?: string | null;
|
||||||
|
capacity?: number | null;
|
||||||
|
price?: number | null;
|
||||||
|
status?: 'draft' | 'published' | 'cancelled' | 'completed';
|
||||||
|
registration_success_markdown?: string | null;
|
||||||
|
gallery_image_ids?: number[] | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PaymentAdminSchema {
|
export interface PaymentAdminSchema {
|
||||||
id: number;
|
id: number;
|
||||||
authority?: string | null;
|
authority?: string | null;
|
||||||
ref_id?: string | null;
|
ref_id?: string | null;
|
||||||
|
card_pan?: string | null;
|
||||||
|
card_hash?: string | null;
|
||||||
status: number;
|
status: number;
|
||||||
status_label: string;
|
status_label: string;
|
||||||
base_amount: number;
|
base_amount: number;
|
||||||
@@ -573,6 +622,14 @@ export interface RegistrationAdminSchema {
|
|||||||
first_name: string;
|
first_name: string;
|
||||||
last_name: string;
|
last_name: string;
|
||||||
email: string;
|
email: string;
|
||||||
|
mobile?: string | null;
|
||||||
|
profile_picture?: string | null;
|
||||||
|
profile_picture_thumbnail_url?: string | null;
|
||||||
|
profile_picture_preview_url?: string | null;
|
||||||
|
university?: string | null;
|
||||||
|
major?: string | null;
|
||||||
|
student_id?: string | null;
|
||||||
|
year_of_study?: number | null;
|
||||||
};
|
};
|
||||||
payments: PaymentAdminSchema[];
|
payments: PaymentAdminSchema[];
|
||||||
}
|
}
|
||||||
@@ -582,6 +639,7 @@ export interface EventAdminDetailSchema extends EventDetailSchema {
|
|||||||
}
|
}
|
||||||
export interface EventUpdateSchema {
|
export interface EventUpdateSchema {
|
||||||
title?: string;
|
title?: string;
|
||||||
|
slug?: string | null;
|
||||||
description?: string;
|
description?: string;
|
||||||
event_type?: 'online' | 'on_site' | 'hybrid';
|
event_type?: 'online' | 'on_site' | 'hybrid';
|
||||||
address?: string | null;
|
address?: string | null;
|
||||||
@@ -594,9 +652,47 @@ export interface EventUpdateSchema {
|
|||||||
capacity?: number | null;
|
capacity?: number | null;
|
||||||
price?: number | null;
|
price?: number | null;
|
||||||
status?: 'draft' | 'published' | 'cancelled' | 'completed';
|
status?: 'draft' | 'published' | 'cancelled' | 'completed';
|
||||||
|
registration_success_markdown?: string | null;
|
||||||
gallery_image_ids?: number[] | null;
|
gallery_image_ids?: number[] | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface DiscountCodeSchema {
|
||||||
|
id: number;
|
||||||
|
code: string;
|
||||||
|
type: 'percent' | 'fixed';
|
||||||
|
value: number;
|
||||||
|
max_discount?: number | null;
|
||||||
|
is_active: boolean;
|
||||||
|
starts_at?: string | null;
|
||||||
|
ends_at?: string | null;
|
||||||
|
usage_limit_total?: number | null;
|
||||||
|
usage_limit_per_user?: number | null;
|
||||||
|
min_amount?: number | null;
|
||||||
|
applicable_event_ids: number[];
|
||||||
|
usage_count: number;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PagedDiscountCodeSchema {
|
||||||
|
count: number;
|
||||||
|
results: DiscountCodeSchema[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DiscountCodeWriteSchema {
|
||||||
|
code: string;
|
||||||
|
type: 'percent' | 'fixed';
|
||||||
|
value: number;
|
||||||
|
max_discount?: number | null;
|
||||||
|
is_active?: boolean;
|
||||||
|
starts_at?: string | null;
|
||||||
|
ends_at?: string | null;
|
||||||
|
usage_limit_total?: number | null;
|
||||||
|
usage_limit_per_user?: number | null;
|
||||||
|
min_amount?: number | null;
|
||||||
|
applicable_event_ids?: number[];
|
||||||
|
}
|
||||||
|
|
||||||
export interface EventRegistrationSchema {
|
export interface EventRegistrationSchema {
|
||||||
id: number;
|
id: number;
|
||||||
status: 'pending' | 'confirmed' | 'cancelled' | 'attended';
|
status: 'pending' | 'confirmed' | 'cancelled' | 'attended';
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useMemo, useState } from "react";
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
|
||||||
import {
|
import {
|
||||||
AlertTriangle,
|
AlertTriangle,
|
||||||
ArrowRight,
|
ArrowRight,
|
||||||
@@ -10,7 +9,7 @@ import {
|
|||||||
MessageSquareMore,
|
MessageSquareMore,
|
||||||
Smartphone,
|
Smartphone,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import SearchableCombobox from "@/components/SearchableCombobox";
|
import AsyncSearchableCombobox from "@/components/AsyncSearchableCombobox";
|
||||||
import OtpCodeField from "@/components/OtpCodeField";
|
import OtpCodeField from "@/components/OtpCodeField";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
@@ -96,25 +95,23 @@ export default function Auth() {
|
|||||||
return () => window.clearInterval(timer);
|
return () => window.clearInterval(timer);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const { data: majors = [], isLoading: majorsLoading } = useQuery({
|
const loadMajors = useCallback(async (params: { search: string; limit: number; offset: number }) => {
|
||||||
queryKey: ["majors"],
|
const data = await api.getMajorsPaged(params);
|
||||||
queryFn: () => api.getMajors(),
|
return {
|
||||||
staleTime: 7 * 24 * 60 * 60 * 1000,
|
count: data.count,
|
||||||
});
|
results: data.results.map((major) => ({ value: String(major.code), label: major.label })),
|
||||||
const { data: universities = [], isLoading: universitiesLoading } = useQuery({
|
};
|
||||||
queryKey: ["universities"],
|
}, []);
|
||||||
queryFn: () => api.getUniversities(),
|
|
||||||
staleTime: 7 * 24 * 60 * 60 * 1000,
|
|
||||||
});
|
|
||||||
|
|
||||||
const majorItems = useMemo(
|
const loadUniversities = useCallback(async (params: { search: string; limit: number; offset: number }) => {
|
||||||
() => majors.map((major) => ({ value: String(major.code), label: major.label })),
|
const data = await api.getUniversitiesPaged(params);
|
||||||
[majors],
|
return {
|
||||||
);
|
count: data.count,
|
||||||
const universityItems = useMemo(
|
results: data.results.map((university) => ({ value: String(university.code), label: university.label })),
|
||||||
() => universities.map((university) => ({ value: String(university.code), label: university.label })),
|
};
|
||||||
[universities],
|
}, []);
|
||||||
);
|
const majorsLoading = false;
|
||||||
|
const universitiesLoading = false;
|
||||||
|
|
||||||
const stepMeta = useMemo(() => {
|
const stepMeta = useMemo(() => {
|
||||||
switch (step) {
|
switch (step) {
|
||||||
@@ -666,14 +663,14 @@ export default function Auth() {
|
|||||||
{universitiesLoading ? (
|
{universitiesLoading ? (
|
||||||
<div className="h-12 animate-pulse rounded-2xl bg-muted" />
|
<div className="h-12 animate-pulse rounded-2xl bg-muted" />
|
||||||
) : (
|
) : (
|
||||||
<SearchableCombobox
|
<AsyncSearchableCombobox
|
||||||
items={universityItems}
|
loadOptions={loadUniversities}
|
||||||
value={registerForm.university}
|
value={registerForm.university}
|
||||||
onChange={(value) => updateRegisterForm("university", value)}
|
onChange={(value) => updateRegisterForm("university", value)}
|
||||||
placeholder="انتخاب دانشگاه"
|
placeholder="انتخاب دانشگاه"
|
||||||
searchPlaceholder="نام دانشگاه را بنویسید..."
|
searchPlaceholder="نام دانشگاه را بنویسید..."
|
||||||
emptyText="دانشگاهی پیدا نشد"
|
emptyText="دانشگاهی پیدا نشد"
|
||||||
dir="rtl"
|
className="h-12 rounded-2xl"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -684,14 +681,14 @@ export default function Auth() {
|
|||||||
{majorsLoading ? (
|
{majorsLoading ? (
|
||||||
<div className="h-12 animate-pulse rounded-2xl bg-muted" />
|
<div className="h-12 animate-pulse rounded-2xl bg-muted" />
|
||||||
) : (
|
) : (
|
||||||
<SearchableCombobox
|
<AsyncSearchableCombobox
|
||||||
items={majorItems}
|
loadOptions={loadMajors}
|
||||||
value={registerForm.major}
|
value={registerForm.major}
|
||||||
onChange={(value) => updateRegisterForm("major", value)}
|
onChange={(value) => updateRegisterForm("major", value)}
|
||||||
placeholder="انتخاب رشته"
|
placeholder="انتخاب رشته"
|
||||||
searchPlaceholder="نام رشته را بنویسید..."
|
searchPlaceholder="نام رشته را بنویسید..."
|
||||||
emptyText="رشتهای پیدا نشد"
|
emptyText="رشتهای پیدا نشد"
|
||||||
dir="rtl"
|
className="h-12 rounded-2xl"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,9 +1,8 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
import { useCallback, useEffect, useState } from "react";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
|
||||||
import { AlertTriangle, ArrowLeft, CheckCircle2, Loader2 } from "lucide-react";
|
import { AlertTriangle, ArrowLeft, CheckCircle2, Loader2 } from "lucide-react";
|
||||||
import SearchableCombobox from "@/components/SearchableCombobox";
|
import AsyncSearchableCombobox from "@/components/AsyncSearchableCombobox";
|
||||||
import OtpCodeField from "@/components/OtpCodeField";
|
import OtpCodeField from "@/components/OtpCodeField";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
@@ -47,27 +46,21 @@ export default function GoogleAuthCallback() {
|
|||||||
});
|
});
|
||||||
const [claimCode, setClaimCode] = useState("");
|
const [claimCode, setClaimCode] = useState("");
|
||||||
|
|
||||||
const { data: majors = [] } = useQuery({
|
const loadMajors = useCallback(async (params: { search: string; limit: number; offset: number }) => {
|
||||||
queryKey: ["majors"],
|
const data = await api.getMajorsPaged(params);
|
||||||
queryFn: () => api.getMajors(),
|
return {
|
||||||
staleTime: 7 * 24 * 60 * 60 * 1000,
|
count: data.count,
|
||||||
enabled: step === "collect_profile",
|
results: data.results.map((major) => ({ value: String(major.code), label: major.label })),
|
||||||
});
|
};
|
||||||
const { data: universities = [] } = useQuery({
|
}, []);
|
||||||
queryKey: ["universities"],
|
|
||||||
queryFn: () => api.getUniversities(),
|
|
||||||
staleTime: 7 * 24 * 60 * 60 * 1000,
|
|
||||||
enabled: step === "collect_profile",
|
|
||||||
});
|
|
||||||
|
|
||||||
const majorItems = useMemo(
|
const loadUniversities = useCallback(async (params: { search: string; limit: number; offset: number }) => {
|
||||||
() => majors.map((major) => ({ value: String(major.code), label: major.label })),
|
const data = await api.getUniversitiesPaged(params);
|
||||||
[majors],
|
return {
|
||||||
);
|
count: data.count,
|
||||||
const universityItems = useMemo(
|
results: data.results.map((university) => ({ value: String(university.code), label: university.label })),
|
||||||
() => universities.map((university) => ({ value: String(university.code), label: university.label })),
|
};
|
||||||
[universities],
|
}, []);
|
||||||
);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (otpCooldown <= 0) {
|
if (otpCooldown <= 0) {
|
||||||
@@ -323,26 +316,26 @@ export default function GoogleAuthCallback() {
|
|||||||
<div className="grid gap-4 md:grid-cols-2">
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
<div>
|
<div>
|
||||||
<Label className="mb-2 block text-right">دانشگاه</Label>
|
<Label className="mb-2 block text-right">دانشگاه</Label>
|
||||||
<SearchableCombobox
|
<AsyncSearchableCombobox
|
||||||
items={universityItems}
|
loadOptions={loadUniversities}
|
||||||
value={profileForm.university}
|
value={profileForm.university}
|
||||||
onChange={(value) => setProfileForm((current) => ({ ...current, university: value }))}
|
onChange={(value) => setProfileForm((current) => ({ ...current, university: value }))}
|
||||||
placeholder="انتخاب دانشگاه"
|
placeholder="انتخاب دانشگاه"
|
||||||
searchPlaceholder="نام دانشگاه را بنویسید..."
|
searchPlaceholder="نام دانشگاه را بنویسید..."
|
||||||
emptyText="دانشگاهی پیدا نشد"
|
emptyText="دانشگاهی پیدا نشد"
|
||||||
dir="rtl"
|
className="h-12 rounded-2xl"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<Label className="mb-2 block text-right">رشته تحصیلی</Label>
|
<Label className="mb-2 block text-right">رشته تحصیلی</Label>
|
||||||
<SearchableCombobox
|
<AsyncSearchableCombobox
|
||||||
items={majorItems}
|
loadOptions={loadMajors}
|
||||||
value={profileForm.major}
|
value={profileForm.major}
|
||||||
onChange={(value) => setProfileForm((current) => ({ ...current, major: value }))}
|
onChange={(value) => setProfileForm((current) => ({ ...current, major: value }))}
|
||||||
placeholder="انتخاب رشته"
|
placeholder="انتخاب رشته"
|
||||||
searchPlaceholder="نام رشته را بنویسید..."
|
searchPlaceholder="نام رشته را بنویسید..."
|
||||||
emptyText="رشتهای پیدا نشد"
|
emptyText="رشتهای پیدا نشد"
|
||||||
dir="rtl"
|
className="h-12 rounded-2xl"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import {
|
|||||||
UserRound,
|
UserRound,
|
||||||
XCircle,
|
XCircle,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
import AsyncSearchableCombobox from "@/components/AsyncSearchableCombobox";
|
||||||
import BlogThumbnail from "@/components/BlogThumbnail";
|
import BlogThumbnail from "@/components/BlogThumbnail";
|
||||||
import Markdown from "@/components/Markdown";
|
import Markdown from "@/components/Markdown";
|
||||||
import { Helmet } from "@/lib/helmet";
|
import { Helmet } from "@/lib/helmet";
|
||||||
@@ -40,7 +41,6 @@ import { Button } from "@/components/ui/button";
|
|||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
|
||||||
import { Textarea } from "@/components/ui/textarea";
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
|
|
||||||
type EventTab = "confirmed" | "pending" | "cancelled";
|
type EventTab = "confirmed" | "pending" | "cancelled";
|
||||||
@@ -147,6 +147,22 @@ export default function Profile() {
|
|||||||
staleTime: 7 * 24 * 60 * 60 * 1000,
|
staleTime: 7 * 24 * 60 * 60 * 1000,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const loadMajors = useCallback(async (params: { search: string; limit: number; offset: number }) => {
|
||||||
|
const data = await api.getMajorsPaged(params);
|
||||||
|
return {
|
||||||
|
count: data.count,
|
||||||
|
results: data.results.map((major) => ({ value: String(major.code), label: major.label })),
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const loadUniversities = useCallback(async (params: { search: string; limit: number; offset: number }) => {
|
||||||
|
const data = await api.getUniversitiesPaged(params);
|
||||||
|
return {
|
||||||
|
count: data.count,
|
||||||
|
results: data.results.map((university) => ({ value: String(university.code), label: university.label })),
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
data: blogActivity,
|
data: blogActivity,
|
||||||
isLoading: blogActivityLoading,
|
isLoading: blogActivityLoading,
|
||||||
@@ -516,21 +532,21 @@ export default function Profile() {
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<Label htmlFor="university" className="mb-2 block text-right">دانشگاه</Label>
|
<Label htmlFor="university" className="mb-2 block text-right">دانشگاه</Label>
|
||||||
<Select value={formData.university || ""} onValueChange={(value) => setFormData((prev) => ({ ...prev, university: value }))}>
|
<AsyncSearchableCombobox
|
||||||
<SelectTrigger id="university"><SelectValue placeholder="انتخاب دانشگاه" /></SelectTrigger>
|
value={formData.university || null}
|
||||||
<SelectContent>
|
onChange={(value) => setFormData((prev) => ({ ...prev, university: value }))}
|
||||||
{universities?.map((item) => <SelectItem key={item.code} value={item.code}>{item.label}</SelectItem>)}
|
loadOptions={loadUniversities}
|
||||||
</SelectContent>
|
placeholder="انتخاب دانشگاه"
|
||||||
</Select>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<Label htmlFor="major" className="mb-2 block text-right">رشته</Label>
|
<Label htmlFor="major" className="mb-2 block text-right">رشته</Label>
|
||||||
<Select value={formData.major || ""} onValueChange={(value) => setFormData((prev) => ({ ...prev, major: value }))}>
|
<AsyncSearchableCombobox
|
||||||
<SelectTrigger id="major"><SelectValue placeholder="انتخاب رشته" /></SelectTrigger>
|
value={formData.major || null}
|
||||||
<SelectContent>
|
onChange={(value) => setFormData((prev) => ({ ...prev, major: value }))}
|
||||||
{majors?.map((item) => <SelectItem key={item.code} value={item.code}>{item.label}</SelectItem>)}
|
loadOptions={loadMajors}
|
||||||
</SelectContent>
|
placeholder="انتخاب رشته"
|
||||||
</Select>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<Label htmlFor="student_id" className="mb-2 block text-right">شماره دانشجویی</Label>
|
<Label htmlFor="student_id" className="mb-2 block text-right">شماره دانشجویی</Label>
|
||||||
|
|||||||
Reference in New Issue
Block a user