feat(frontend): add async admin form foundations

This commit is contained in:
2026-06-14 00:04:22 +03:30
parent 25bd46ea2a
commit 9080b0caea
9 changed files with 668 additions and 80 deletions

View File

@@ -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 ? (
<div className="h-12 animate-pulse rounded-2xl bg-muted" />
) : (
<SearchableCombobox
items={universityItems}
<AsyncSearchableCombobox
loadOptions={loadUniversities}
value={registerForm.university}
onChange={(value) => updateRegisterForm("university", value)}
placeholder="انتخاب دانشگاه"
searchPlaceholder="نام دانشگاه را بنویسید..."
emptyText="دانشگاهی پیدا نشد"
dir="rtl"
className="h-12 rounded-2xl"
/>
)}
</div>
@@ -684,14 +681,14 @@ export default function Auth() {
{majorsLoading ? (
<div className="h-12 animate-pulse rounded-2xl bg-muted" />
) : (
<SearchableCombobox
items={majorItems}
<AsyncSearchableCombobox
loadOptions={loadMajors}
value={registerForm.major}
onChange={(value) => updateRegisterForm("major", value)}
placeholder="انتخاب رشته"
searchPlaceholder="نام رشته را بنویسید..."
emptyText="رشته‌ای پیدا نشد"
dir="rtl"
className="h-12 rounded-2xl"
/>
)}
</div>

View File

@@ -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() {
<div className="grid gap-4 md:grid-cols-2">
<div>
<Label className="mb-2 block text-right">دانشگاه</Label>
<SearchableCombobox
items={universityItems}
<AsyncSearchableCombobox
loadOptions={loadUniversities}
value={profileForm.university}
onChange={(value) => setProfileForm((current) => ({ ...current, university: value }))}
placeholder="انتخاب دانشگاه"
searchPlaceholder="نام دانشگاه را بنویسید..."
emptyText="دانشگاهی پیدا نشد"
dir="rtl"
className="h-12 rounded-2xl"
/>
</div>
<div>
<Label className="mb-2 block text-right">رشته تحصیلی</Label>
<SearchableCombobox
items={majorItems}
<AsyncSearchableCombobox
loadOptions={loadMajors}
value={profileForm.major}
onChange={(value) => setProfileForm((current) => ({ ...current, major: value }))}
placeholder="انتخاب رشته"
searchPlaceholder="نام رشته را بنویسید..."
emptyText="رشته‌ای پیدا نشد"
dir="rtl"
className="h-12 rounded-2xl"
/>
</div>
</div>

View File

@@ -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() {
</div>
<div>
<Label htmlFor="university" className="mb-2 block text-right">دانشگاه</Label>
<Select value={formData.university || ""} onValueChange={(value) => setFormData((prev) => ({ ...prev, university: value }))}>
<SelectTrigger id="university"><SelectValue placeholder="انتخاب دانشگاه" /></SelectTrigger>
<SelectContent>
{universities?.map((item) => <SelectItem key={item.code} value={item.code}>{item.label}</SelectItem>)}
</SelectContent>
</Select>
<AsyncSearchableCombobox
value={formData.university || null}
onChange={(value) => setFormData((prev) => ({ ...prev, university: value }))}
loadOptions={loadUniversities}
placeholder="انتخاب دانشگاه"
/>
</div>
<div>
<Label htmlFor="major" className="mb-2 block text-right">رشته</Label>
<Select value={formData.major || ""} onValueChange={(value) => setFormData((prev) => ({ ...prev, major: value }))}>
<SelectTrigger id="major"><SelectValue placeholder="انتخاب رشته" /></SelectTrigger>
<SelectContent>
{majors?.map((item) => <SelectItem key={item.code} value={item.code}>{item.label}</SelectItem>)}
</SelectContent>
</Select>
<AsyncSearchableCombobox
value={formData.major || null}
onChange={(value) => setFormData((prev) => ({ ...prev, major: value }))}
loadOptions={loadMajors}
placeholder="انتخاب رشته"
/>
</div>
<div>
<Label htmlFor="student_id" className="mb-2 block text-right">شماره دانشجویی</Label>