From 9080b0caea4942f6892aaa56d93ed869cf3978fe Mon Sep 17 00:00:00 2001 From: Amirhossein Khalili Date: Sun, 14 Jun 2026 00:04:22 +0330 Subject: [PATCH] feat(frontend): add async admin form foundations --- package-lock.json | 32 ++++ package.json | 2 + src/components/AdminDateTimeField.tsx | 96 +++++++++++ src/components/AsyncSearchableCombobox.tsx | 178 ++++++++++++++++++++ src/lib/api.ts | 186 ++++++++++++++++++++- src/lib/types.ts | 110 +++++++++++- src/views/Auth.tsx | 51 +++--- src/views/GoogleAuthCallback.tsx | 51 +++--- src/views/Profile.tsx | 42 +++-- 9 files changed, 668 insertions(+), 80 deletions(-) create mode 100644 src/components/AdminDateTimeField.tsx create mode 100644 src/components/AsyncSearchableCombobox.tsx diff --git a/package-lock.json b/package-lock.json index 9df4029..39c7cff 100644 --- a/package-lock.json +++ b/package-lock.json @@ -56,10 +56,12 @@ "next": "^15.4.6", "next-themes": "^0.3.0", "react": "^18.3.1", + "react-date-object": "^2.1.9", "react-day-picker": "^8.10.1", "react-dom": "^18.3.1", "react-hook-form": "^7.61.1", "react-markdown": "^9.0.3", + "react-multi-date-picker": "^4.5.2", "react-qr-code": "^2.0.11", "react-resizable-panels": "^2.1.9", "react-syntax-highlighter": "^16.1.1", @@ -6653,6 +6655,12 @@ "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": { "version": "8.10.1", "resolved": "https://registry.npmjs.org/react-day-picker/-/react-day-picker-8.10.1.tgz", @@ -6680,6 +6688,16 @@ "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": { "version": "7.61.1", "resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.61.1.tgz", @@ -6728,6 +6746,20 @@ "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": { "version": "2.0.18", "resolved": "https://registry.npmjs.org/react-qr-code/-/react-qr-code-2.0.18.tgz", diff --git a/package.json b/package.json index f659067..831089e 100644 --- a/package.json +++ b/package.json @@ -58,10 +58,12 @@ "next": "^15.4.6", "next-themes": "^0.3.0", "react": "^18.3.1", + "react-date-object": "^2.1.9", "react-day-picker": "^8.10.1", "react-dom": "^18.3.1", "react-hook-form": "^7.61.1", "react-markdown": "^9.0.3", + "react-multi-date-picker": "^4.5.2", "react-qr-code": "^2.0.11", "react-resizable-panels": "^2.1.9", "react-syntax-highlighter": "^16.1.1", diff --git a/src/components/AdminDateTimeField.tsx b/src/components/AdminDateTimeField.tsx new file mode 100644 index 0000000..9de5bda --- /dev/null +++ b/src/components/AdminDateTimeField.tsx @@ -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(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 ( +
+ +
+ { + 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" + /> + { + setTime(event.target.value); + emitChange(date, event.target.value); + }} + /> +
+
+ ); +} diff --git a/src/components/AsyncSearchableCombobox.tsx b/src/components/AsyncSearchableCombobox.tsx new file mode 100644 index 0000000..c723af2 --- /dev/null +++ b/src/components/AsyncSearchableCombobox.tsx @@ -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([]); + 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 ( + + + + + + + + + {loading ? ( +
+ + در حال جستجو... +
+ ) : ( + <> + {emptyText} + + {allowClear ? ( + { + onChange(null); + setOpen(false); + }} + > + + همه موارد + + ) : null} + {options.map((option) => ( + { + onChange(option.value === value ? null : option.value); + setOpen(false); + }} + className="flex items-center justify-between gap-2" + > + + {option.label} + {option.description ? ( + {option.description} + ) : null} + + + + ))} + + {hasMore ? ( +
+ +
+ ) : null} + + )} +
+
+
+
+ ); +} diff --git a/src/lib/api.ts b/src/lib/api.ts index b585cae..e2ffc30 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -397,6 +397,10 @@ class ApiClient { return this.request(`/api/auth/users${query.toString() ? `?${query.toString()}` : ''}`); } + async getUserDetail(userId: number) { + return this.request(`/api/auth/users/${userId}`); + } + async listAuthorizationRoles() { return this.request('/api/auth/roles'); } @@ -856,6 +860,68 @@ class ApiClient { }); } + async createEvent(data: Types.EventCreateSchema) { + return this.request('/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; + } + + async deleteEventFeaturedImage(eventId: number) { + return this.request(`/api/events/${eventId}/featured-image`, { + method: 'DELETE', + }); + } + + async listEventGallery(eventId: number) { + return this.request(`/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; + } + + async deleteEventGalleryImage(eventId: number, imageId: number) { + return this.request(`/api/events/${eventId}/gallery/${imageId}`, { + method: 'DELETE', + }); + } + async deleteEvent(eventId: number) { return this.request(`/api/events/${eventId}`, { 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( + `/api/payments/admin/discount-codes${query.toString() ? `?${query.toString()}` : ''}`, + ); + } + + async createDiscountCode(data: Types.DiscountCodeWriteSchema) { + return this.request('/api/payments/admin/discount-codes', { + method: 'POST', + body: JSON.stringify(data), + }); + } + + async updateDiscountCode(codeId: number, data: Types.DiscountCodeWriteSchema) { + return this.request(`/api/payments/admin/discount-codes/${codeId}`, { + method: 'PUT', + body: JSON.stringify(data), + }); + } + + async deleteDiscountCode(codeId: number) { + return this.request(`/api/payments/admin/discount-codes/${codeId}`, { + method: 'DELETE', + }); + } + // ============= Gallery Endpoints ============= async getGalleryImages(params?: { @@ -1019,12 +1123,86 @@ class ApiClient { }); } - async getMajors(): Promise { - return this.request('/api/meta/majors', { method: 'GET' }); + async getMajors(params?: { search?: string; limit?: number; offset?: number }): Promise { + 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 { - return this.request('/api/meta/universities', { method: 'GET' }); + async getUniversities(params?: { search?: string; limit?: number; offset?: number }): Promise { + 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(`/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(`/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(`/api/meta/admin/majors${query.toString() ? `?${query.toString()}` : ''}`); + } + + async createMajor(data: Types.MetaOptionWriteSchema) { + return this.request('/api/meta/admin/majors', { + method: 'POST', + body: JSON.stringify(data), + }); + } + + async updateMajor(id: number, data: Types.MetaOptionWriteSchema) { + return this.request(`/api/meta/admin/majors/${id}`, { + method: 'PUT', + body: JSON.stringify(data), + }); + } + + async deleteMajor(id: number) { + return this.request(`/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(`/api/meta/admin/universities${query.toString() ? `?${query.toString()}` : ''}`); + } + + async createUniversity(data: Types.MetaOptionWriteSchema) { + return this.request('/api/meta/admin/universities', { + method: 'POST', + body: JSON.stringify(data), + }); + } + + async updateUniversity(id: number, data: Types.MetaOptionWriteSchema) { + return this.request(`/api/meta/admin/universities/${id}`, { + method: 'PUT', + body: JSON.stringify(data), + }); + } + + async deleteUniversity(id: number) { + return this.request(`/api/meta/admin/universities/${id}`, { + method: 'DELETE', + }); } async subscribeNewsletter(email: string) { diff --git a/src/lib/types.ts b/src/lib/types.ts index 8025217..54e7ccb 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -18,6 +18,27 @@ export interface TokenSchema { export interface MajorOption { code: 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 { @@ -62,6 +83,19 @@ export interface UserListSchema { full_name?: string | null; university?: 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_staff: boolean; is_superuser: boolean; @@ -524,6 +558,12 @@ export interface EventGalleryItem { absolute_image_blur_url?: string | null; width?: 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 { @@ -536,19 +576,28 @@ export interface EventDetailSchema extends EventListItemSchema { export interface EventCreateSchema { title: string; description: string; - start_date: string; - end_date?: string; - location: string; - capacity?: number; - event_image?: string; - requirements?: string; - is_registration_open?: boolean; + slug?: string | null; + event_type: 'online' | 'on_site' | 'hybrid'; + address?: string | null; + location?: string | null; + online_link?: string | null; + start_time: string; + 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 { id: number; authority?: string | null; ref_id?: string | null; + card_pan?: string | null; + card_hash?: string | null; status: number; status_label: string; base_amount: number; @@ -573,6 +622,14 @@ export interface RegistrationAdminSchema { first_name: string; last_name: 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[]; } @@ -582,6 +639,7 @@ export interface EventAdminDetailSchema extends EventDetailSchema { } export interface EventUpdateSchema { title?: string; + slug?: string | null; description?: string; event_type?: 'online' | 'on_site' | 'hybrid'; address?: string | null; @@ -594,9 +652,47 @@ export interface EventUpdateSchema { capacity?: number | null; price?: number | null; status?: 'draft' | 'published' | 'cancelled' | 'completed'; + registration_success_markdown?: string | 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 { id: number; status: 'pending' | 'confirmed' | 'cancelled' | 'attended'; diff --git a/src/views/Auth.tsx b/src/views/Auth.tsx index d63b5f4..f76a25e 100644 --- a/src/views/Auth.tsx +++ b/src/views/Auth.tsx @@ -1,7 +1,6 @@ "use client"; -import { useEffect, useMemo, useState } from "react"; -import { useQuery } from "@tanstack/react-query"; +import { useCallback, useEffect, useMemo, useState } from "react"; import { AlertTriangle, ArrowRight, @@ -10,7 +9,7 @@ import { MessageSquareMore, Smartphone, } from "lucide-react"; -import SearchableCombobox from "@/components/SearchableCombobox"; +import AsyncSearchableCombobox from "@/components/AsyncSearchableCombobox"; import OtpCodeField from "@/components/OtpCodeField"; import { Button } from "@/components/ui/button"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; @@ -96,25 +95,23 @@ export default function Auth() { return () => window.clearInterval(timer); }, []); - const { data: majors = [], isLoading: majorsLoading } = useQuery({ - queryKey: ["majors"], - queryFn: () => api.getMajors(), - staleTime: 7 * 24 * 60 * 60 * 1000, - }); - const { data: universities = [], isLoading: universitiesLoading } = useQuery({ - queryKey: ["universities"], - queryFn: () => api.getUniversities(), - 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 majorItems = useMemo( - () => majors.map((major) => ({ value: String(major.code), label: major.label })), - [majors], - ); - const universityItems = useMemo( - () => universities.map((university) => ({ value: String(university.code), label: university.label })), - [universities], - ); + 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 majorsLoading = false; + const universitiesLoading = false; const stepMeta = useMemo(() => { switch (step) { @@ -666,14 +663,14 @@ export default function Auth() { {universitiesLoading ? (
) : ( - updateRegisterForm("university", value)} placeholder="انتخاب دانشگاه" searchPlaceholder="نام دانشگاه را بنویسید..." emptyText="دانشگاهی پیدا نشد" - dir="rtl" + className="h-12 rounded-2xl" /> )}
@@ -684,14 +681,14 @@ export default function Auth() { {majorsLoading ? (
) : ( - updateRegisterForm("major", value)} placeholder="انتخاب رشته" searchPlaceholder="نام رشته را بنویسید..." emptyText="رشته‌ای پیدا نشد" - dir="rtl" + className="h-12 rounded-2xl" /> )}
diff --git a/src/views/GoogleAuthCallback.tsx b/src/views/GoogleAuthCallback.tsx index 30d238b..444a7a8 100644 --- a/src/views/GoogleAuthCallback.tsx +++ b/src/views/GoogleAuthCallback.tsx @@ -1,9 +1,8 @@ "use client"; -import { useCallback, useEffect, useMemo, useState } from "react"; -import { useQuery } from "@tanstack/react-query"; +import { useCallback, useEffect, useState } from "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 { Button } from "@/components/ui/button"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; @@ -47,27 +46,21 @@ export default function GoogleAuthCallback() { }); const [claimCode, setClaimCode] = useState(""); - const { data: majors = [] } = useQuery({ - queryKey: ["majors"], - queryFn: () => api.getMajors(), - staleTime: 7 * 24 * 60 * 60 * 1000, - enabled: step === "collect_profile", - }); - const { data: universities = [] } = useQuery({ - queryKey: ["universities"], - queryFn: () => api.getUniversities(), - staleTime: 7 * 24 * 60 * 60 * 1000, - enabled: step === "collect_profile", - }); + 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 majorItems = useMemo( - () => majors.map((major) => ({ value: String(major.code), label: major.label })), - [majors], - ); - const universityItems = useMemo( - () => universities.map((university) => ({ value: String(university.code), label: university.label })), - [universities], - ); + 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 })), + }; + }, []); useEffect(() => { if (otpCooldown <= 0) { @@ -323,26 +316,26 @@ export default function GoogleAuthCallback() {
- setProfileForm((current) => ({ ...current, university: value }))} placeholder="انتخاب دانشگاه" searchPlaceholder="نام دانشگاه را بنویسید..." emptyText="دانشگاهی پیدا نشد" - dir="rtl" + className="h-12 rounded-2xl" />
- setProfileForm((current) => ({ ...current, major: value }))} placeholder="انتخاب رشته" searchPlaceholder="نام رشته را بنویسید..." emptyText="رشته‌ای پیدا نشد" - dir="rtl" + className="h-12 rounded-2xl" />
diff --git a/src/views/Profile.tsx b/src/views/Profile.tsx index f2842b7..1652c25 100644 --- a/src/views/Profile.tsx +++ b/src/views/Profile.tsx @@ -17,6 +17,7 @@ import { UserRound, XCircle, } from "lucide-react"; +import AsyncSearchableCombobox from "@/components/AsyncSearchableCombobox"; import BlogThumbnail from "@/components/BlogThumbnail"; import Markdown from "@/components/Markdown"; 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 { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; -import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { Textarea } from "@/components/ui/textarea"; type EventTab = "confirmed" | "pending" | "cancelled"; @@ -147,6 +147,22 @@ export default function Profile() { 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 { data: blogActivity, isLoading: blogActivityLoading, @@ -516,21 +532,21 @@ export default function Profile() {
- + setFormData((prev) => ({ ...prev, university: value }))} + loadOptions={loadUniversities} + placeholder="انتخاب دانشگاه" + />
- + setFormData((prev) => ({ ...prev, major: value }))} + loadOptions={loadMajors} + placeholder="انتخاب رشته" + />