vulneribilty fixed

This commit is contained in:
nabijonovdavronbek619@gmail.com
2026-04-09 12:00:06 +05:00
parent dfb8d3bdbc
commit 73158a1972
26 changed files with 553 additions and 175 deletions

View File

@@ -5,7 +5,7 @@ const LOCALES = ['uz', 'ru', 'en'] as const;
const STATIC_ROUTES = [ const STATIC_ROUTES = [
{ path: '', changeFreq: 'daily' as const, priority: 1.0 }, { path: '', changeFreq: 'daily' as const, priority: 1.0 },
{ path: '/plagat', changeFreq: 'weekly' as const, priority: 0.8 }, { path: '/plagiat', changeFreq: 'weekly' as const, priority: 0.8 },
{ path: '/cabinet', changeFreq: 'weekly' as const, priority: 0.7 }, { path: '/cabinet', changeFreq: 'weekly' as const, priority: 0.7 },
]; ];

View File

@@ -52,13 +52,11 @@ export function useLoginForm() {
console.log('Login successful:', data); console.log('Login successful:', data);
toggleLoginModal(); toggleLoginModal();
toast.success('Kirish muvaffaqiyatli!'); toast.success('Kirish muvaffaqiyatli!');
route.push('/plagat'); route.push('/plagiat');
}, },
onError: (err) => { onError: (err) => {
console.log('Login failed:', err); console.log('Login failed:', err);
setError(err instanceof Error ? err.message : 'Unknown error'); setError(err instanceof Error ? err.message : 'Unknown error');
// toggleLoginModal();
toast.error(err instanceof Error ? err.message : 'Unknown error');
}, },
}); });

View File

@@ -53,12 +53,10 @@ export function useRegisterForm() {
toggleRegisterModal(); toggleRegisterModal();
setSuccess(true); setSuccess(true);
toast.success("Ro'yxatdan o'tish muvaffaqiyatli!"); toast.success("Ro'yxatdan o'tish muvaffaqiyatli!");
route.push('/plagat'); route.push('/plagiat');
}, },
onError: (err) => { onError: (err) => {
// toggleLoginModal();
console.log('Register failed:', err); console.log('Register failed:', err);
toast.error(err instanceof Error ? err.message : 'Unknown error');
}, },
}); });

View File

@@ -4,7 +4,7 @@
"about": "Go to the about page" "about": "Go to the about page"
}, },
"Navbar": { "Navbar": {
"logo": "Plagat", "logo": "Plagiat",
"aboutSite": "About Site", "aboutSite": "About Site",
"contact": "Contact", "contact": "Contact",
"login": "Login", "login": "Login",
@@ -260,7 +260,61 @@
"close": "Close", "close": "Close",
"personalCabinet": "Personal Cabinet", "personalCabinet": "Personal Cabinet",
"plagiatChecks": "Plagiarism Checks", "plagiatChecks": "Plagiarism Checks",
"dashboard": "Dashboard" "dashboard": "Dashboard",
"welcome": "Welcome, {userName} 👋",
"welcomeDesc": "Welcome to your personal cabinet",
"quickActions": "Quick Actions",
"totalChecks": "Total Checks",
"thisMonth": "This Month",
"paidAmount": "Amount Paid",
"noData": "No data found",
"checkModules": "Check Modules",
"checkModulesDesc": "All sources used for plagiarism detection",
"modulesCount": "{count} modules",
"totalModules": "Total Modules",
"freeInternetSources": "Free Internet Sources",
"aiAnalysisModules": "AI Analysis Modules",
"categories": "Categories",
"paymentsCount": "{count} payments",
"loading": "Loading...",
"noPayments": "No payment history",
"tableNum": "#",
"service": "Service",
"amount": "Amount",
"discount": "Discount",
"date": "Date",
"status": "Status",
"unknown": "Unknown",
"noSiChecks": "No AI checks yet",
"loadError": "Failed to load data",
"paid": "Paid",
"unpaid": "Unpaid",
"checksCount": "{count} checks",
"tableTitle": "Title",
"tableFile": "File",
"words": "Words",
"action": "Action",
"pay": "Pay",
"view": "View",
"profileDesc": "Manage your information",
"personalInfo": "Personal Information",
"changePassword": "Change Password",
"firstName": "First Name",
"lastName": "Last Name",
"phone": "Phone",
"email": "Email",
"newPassword": "New Password",
"saved": "Saved",
"saving": "Saving…",
"save": "Save",
"firstNameRequired": "First name is required",
"lastNameRequired": "Last name is required",
"phoneInvalid": "Phone number must be 9 digits",
"passwordTooShort": "Password must be at least 8 characters",
"discountThisMonth": "Discount this month",
"discountRemaining": "Discount expires after {remaining} documents",
"discountAllUsed": "All discounts for this month have been used",
"discountUsed": "{count} used"
}, },
"SiDetail": { "SiDetail": {
"siCheck": "AI Check", "siCheck": "AI Check",

View File

@@ -4,7 +4,7 @@
"about": "Перейти на страницу о нас" "about": "Перейти на страницу о нас"
}, },
"Navbar": { "Navbar": {
"logo": "Plagat", "logo": "Plagiat",
"aboutSite": "О сайте", "aboutSite": "О сайте",
"contact": "Контакты", "contact": "Контакты",
"login": "Войти", "login": "Войти",
@@ -259,7 +259,61 @@
"close": "Закрыть", "close": "Закрыть",
"personalCabinet": "Личный кабинет", "personalCabinet": "Личный кабинет",
"plagiatChecks": "Проверки на плагиат", "plagiatChecks": "Проверки на плагиат",
"dashboard": "Dashboard" "dashboard": "Dashboard",
"welcome": "Добро пожаловать, {userName} 👋",
"welcomeDesc": "Добро пожаловать в ваш личный кабинет",
"quickActions": "Быстрые действия",
"totalChecks": "Всего проверок",
"thisMonth": "Этот месяц",
"paidAmount": "Оплаченная сумма",
"noData": "Данные не найдены",
"checkModules": "Модули проверки",
"checkModulesDesc": "Все источники, используемые для обнаружения плагиата",
"modulesCount": "{count} модулей",
"totalModules": "Всего модулей",
"freeInternetSources": "Бесплатные интернет-источники",
"aiAnalysisModules": "Модули AI анализа",
"categories": "Категория",
"paymentsCount": "{count} платежей",
"loading": "Загрузка...",
"noPayments": "История платежей отсутствует",
"tableNum": "#",
"service": "Услуга",
"amount": "Сумма",
"discount": "Скидка",
"date": "Дата",
"status": "Статус",
"unknown": "Неизвестно",
"noSiChecks": "Проверок ИИ пока нет",
"loadError": "Ошибка загрузки данных",
"paid": "Оплачено",
"unpaid": "Не оплачено",
"checksCount": "{count} проверок",
"tableTitle": "Заголовок",
"tableFile": "Файл",
"words": "Слов",
"action": "Действие",
"pay": "Оплатить",
"view": "Просмотр",
"profileDesc": "Управляйте своими данными",
"personalInfo": "Личные данные",
"changePassword": "Изменить пароль",
"firstName": "Имя",
"lastName": "Фамилия",
"phone": "Телефон",
"email": "Email",
"newPassword": "Новый пароль",
"saved": "Сохранено",
"saving": "Сохранение…",
"save": "Сохранить",
"firstNameRequired": "Имя обязательно",
"lastNameRequired": "Фамилия обязательна",
"phoneInvalid": "Номер телефона должен содержать 9 цифр",
"passwordTooShort": "Пароль должен содержать не менее 8 символов",
"discountThisMonth": "Скидка в этом месяце",
"discountRemaining": "Скидка истекает через {remaining} документов",
"discountAllUsed": "Все скидки этого месяца использованы",
"discountUsed": "{count} использовано"
}, },
"SiDetail": { "SiDetail": {
"siCheck": "Проверка ИИ", "siCheck": "Проверка ИИ",

View File

@@ -7,7 +7,7 @@ declare const messages: {
about: "Biz haqimizda sahifasiga o'ting"; about: "Biz haqimizda sahifasiga o'ting";
}; };
Navbar: { Navbar: {
logo: 'Plagat'; logo: 'Plagiat';
aboutSite: 'Sayt haqida'; aboutSite: 'Sayt haqida';
contact: 'Aloqa'; contact: 'Aloqa';
login: 'Kirish'; login: 'Kirish';
@@ -264,6 +264,60 @@ declare const messages: {
personalCabinet: 'Shaxsiy kabinet'; personalCabinet: 'Shaxsiy kabinet';
plagiatChecks: 'Plagiat tekshiruvlar'; plagiatChecks: 'Plagiat tekshiruvlar';
dashboard: 'Dashboard'; dashboard: 'Dashboard';
welcome: 'Xush kelibsiz, {userName} 👋';
welcomeDesc: 'Shaxsiy kabinetingizga xush kelibsiz';
quickActions: 'Tezkor harakatlar';
totalChecks: 'Jami tekshiruvlar';
thisMonth: 'Bu oy';
paidAmount: "To'langan summa";
noData: "Ma'lumot topilmadi";
checkModules: 'Tekshiruv modullari';
checkModulesDesc: 'Plagiat aniqlashda foydalaniladigan barcha manbalar';
modulesCount: '{count} ta modul';
totalModules: 'Jami modullar';
freeInternetSources: 'Bepul internet manbalari';
aiAnalysisModules: 'AI tahlil modullari';
categories: 'Kategoriya';
paymentsCount: "{count} ta to'lov";
loading: 'Yuklanmoqda...';
noPayments: "To'lovlar tarixi mavjud emas";
tableNum: '#';
service: 'Xizmat';
amount: 'Summa';
discount: 'Chegirma';
date: 'Sana';
status: 'Holat';
unknown: "Noma'lum";
noSiChecks: "Hozircha SI tekshiruvlar yo'q";
loadError: "Ma'lumotlarni yuklashda xatolik yuz berdi";
paid: "To'langan";
unpaid: "To'lanmagan";
checksCount: '{count} ta tekshiruv';
tableTitle: 'Sarlavha';
tableFile: 'Fayl';
words: "So'z";
action: 'Amal';
pay: "To'lash";
view: "Ko'rish";
profileDesc: "Ma'lumotlaringizni boshqaring";
personalInfo: "Shaxsiy ma'lumotlar";
changePassword: "Parol o'zgartirish";
firstName: 'Ism';
lastName: 'Familiya';
phone: 'Telefon';
email: 'Email';
newPassword: 'Yangi parol';
saved: 'Saqlandi';
saving: 'Saqlanmoqda…';
save: 'Saqlash';
firstNameRequired: 'Ism kiritilishi shart';
lastNameRequired: 'Familiya kiritilishi shart';
phoneInvalid: "Telefon raqami 9 ta raqamdan iborat bo'lishi kerak";
passwordTooShort: "Parol kamida 8 ta belgidan iborat bo'lishi kerak";
discountThisMonth: 'Bu oyda chegirma';
discountRemaining: '{remaining} ta hujjatdan keyin chegirma tugaydi';
discountAllUsed: 'Bu oyda barcha chegirmalar ishlatildi';
discountUsed: '{count} ta ishlatildi';
}; };
SiDetail: { SiDetail: {
siCheck: 'SI tekshiruv'; siCheck: 'SI tekshiruv';

View File

@@ -4,7 +4,7 @@
"about": "Biz haqimizda sahifasiga o'ting" "about": "Biz haqimizda sahifasiga o'ting"
}, },
"Navbar": { "Navbar": {
"logo": "Plagat", "logo": "Plagiat",
"aboutSite": "Sayt haqida", "aboutSite": "Sayt haqida",
"contact": "Aloqa", "contact": "Aloqa",
"login": "Kirish", "login": "Kirish",
@@ -260,7 +260,61 @@
"close": "Yopish", "close": "Yopish",
"personalCabinet": "Shaxsiy kabinet", "personalCabinet": "Shaxsiy kabinet",
"plagiatChecks": "Plagiat tekshiruvlar", "plagiatChecks": "Plagiat tekshiruvlar",
"dashboard": "Dashboard" "dashboard": "Dashboard",
"welcome": "Xush kelibsiz, {userName} 👋",
"welcomeDesc": "Shaxsiy kabinetingizga xush kelibsiz",
"quickActions": "Tezkor harakatlar",
"totalChecks": "Jami tekshiruvlar",
"thisMonth": "Bu oy",
"paidAmount": "To'langan summa",
"noData": "Ma'lumot topilmadi",
"checkModules": "Tekshiruv modullari",
"checkModulesDesc": "Plagiat aniqlashda foydalaniladigan barcha manbalar",
"modulesCount": "{count} ta modul",
"totalModules": "Jami modullar",
"freeInternetSources": "Bepul internet manbalari",
"aiAnalysisModules": "AI tahlil modullari",
"categories": "Kategoriya",
"paymentsCount": "{count} ta to'lov",
"loading": "Yuklanmoqda...",
"noPayments": "To'lovlar tarixi mavjud emas",
"tableNum": "#",
"service": "Xizmat",
"amount": "Summa",
"discount": "Chegirma",
"date": "Sana",
"status": "Holat",
"unknown": "Noma'lum",
"noSiChecks": "Hozircha SI tekshiruvlar yo'q",
"loadError": "Ma'lumotlarni yuklashda xatolik yuz berdi",
"paid": "To'langan",
"unpaid": "To'lanmagan",
"checksCount": "{count} ta tekshiruv",
"tableTitle": "Sarlavha",
"tableFile": "Fayl",
"words": "So'z",
"action": "Amal",
"pay": "To'lash",
"view": "Ko'rish",
"profileDesc": "Ma'lumotlaringizni boshqaring",
"personalInfo": "Shaxsiy ma'lumotlar",
"changePassword": "Parol o'zgartirish",
"firstName": "Ism",
"lastName": "Familiya",
"phone": "Telefon",
"email": "Email",
"newPassword": "Yangi parol",
"saved": "Saqlandi",
"saving": "Saqlanmoqda…",
"save": "Saqlash",
"firstNameRequired": "Ism kiritilishi shart",
"lastNameRequired": "Familiya kiritilishi shart",
"phoneInvalid": "Telefon raqami 9 ta raqamdan iborat bo'lishi kerak",
"passwordTooShort": "Parol kamida 8 ta belgidan iborat bo'lishi kerak",
"discountThisMonth": "Bu oyda chegirma",
"discountRemaining": "{remaining} ta hujjatdan keyin chegirma tugaydi",
"discountAllUsed": "Bu oyda barcha chegirmalar ishlatildi",
"discountUsed": "{count} ta ishlatildi"
}, },
"SiDetail": { "SiDetail": {
"siCheck": "SI tekshiruv", "siCheck": "SI tekshiruv",

View File

@@ -4,8 +4,44 @@ import axios, {
AxiosError, AxiosError,
InternalAxiosRequestConfig, InternalAxiosRequestConfig,
} from 'axios'; } from 'axios';
import { toast } from 'react-toastify';
import { getRouteLang } from './getLanguage'; import { getRouteLang } from './getLanguage';
// ─── Error message extractor ───────────────────────────────────────────────────
function extractErrorMessage(error: AxiosError): string {
const data = error.response?.data as Record<string, unknown> | undefined;
if (!data) {
if (error.code === 'ECONNABORTED')
return 'Request timed out. Please try again.';
if (!navigator.onLine) return 'No internet connection.';
return error.message || 'An unexpected error occurred.';
}
// Simple string fields: { message, detail, error }
if (typeof data.message === 'string' && data.message) return data.message;
if (typeof data.detail === 'string' && data.detail) return data.detail;
if (typeof data.error === 'string' && data.error) return data.error;
// Wrapped: { errors: { field: ["msg"] } }
if (data.errors && typeof data.errors === 'object') {
const first = Object.values(data.errors as Record<string, unknown>)[0];
if (Array.isArray(first) && first.length > 0) return String(first[0]);
if (typeof first === 'string') return first;
}
// DRF field-level errors at top level: { phone: ["msg"], name: ["msg"] }
for (const val of Object.values(data)) {
if (Array.isArray(val) && val.length > 0 && typeof val[0] === 'string') {
return val[0];
}
if (typeof val === 'string' && val) return val;
}
return 'An unexpected error occurred.';
}
// ─── Constants ───────────────────────────────────────────────────────────────── // ─── Constants ─────────────────────────────────────────────────────────────────
// const baseUrl = process.env.NEXT_PUBLIC_API_BASE_URL; // const baseUrl = process.env.NEXT_PUBLIC_API_BASE_URL;
@@ -107,9 +143,14 @@ api.interceptors.response.use(
}; };
const status = error.response?.status; const status = error.response?.status;
const requestUrl = originalRequest.url ?? '';
const isAuthEndpoint =
requestUrl.includes('/users/login/') ||
requestUrl.includes('/users/register/');
// Only attempt refresh on 401 and only once per request // For auth endpoints, 401 means wrong credentials — show error, don't refresh
if (status !== 401 || originalRequest._retry) { if (isAuthEndpoint || status !== 401 || originalRequest._retry) {
toast.error(extractErrorMessage(error));
return Promise.reject(error); return Promise.reject(error);
} }

View File

@@ -1,7 +1,7 @@
export const links = { export const links = {
login: '/users/login/', login: '/users/login/',
register: '/users/register/', register: '/users/register/',
plagiarismCheck: '/shared/documents/', plagiarismCheck: '/shared/document/',
history: '/shared/documents/list/', history: '/shared/documents/list/',
detail: (id: number) => `/shared/documents/${id}/`, detail: (id: number) => `/shared/documents/${id}/`,
payment: (order_id: number) => `/users/payme/link/${order_id}/`, payment: (order_id: number) => `/users/payme/link/${order_id}/`,

View File

@@ -1,6 +1,7 @@
'use client'; 'use client';
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useTranslations } from 'next-intl';
import { apiRequest } from '@/shared/request/apiRequest'; import { apiRequest } from '@/shared/request/apiRequest';
import { links } from '@/shared/request/links'; import { links } from '@/shared/request/links';
import type { UserProfile } from '../types'; import type { UserProfile } from '../types';
@@ -8,13 +9,27 @@ import type { UserProfile } from '../types';
interface ProfileFormState { interface ProfileFormState {
first_name: string; first_name: string;
last_name: string; last_name: string;
phone: string; phone: string; // 9 digits only, without 998 prefix
password: string; password: string;
} }
export interface ProfileFormErrors {
first_name?: string;
last_name?: string;
phone?: string;
password?: string;
}
function stripPrefix(phone: string): string {
if (phone.startsWith('998')) return phone.slice(3);
return phone;
}
export const useProfile = () => { export const useProfile = () => {
const t = useTranslations('Cabinet');
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const [saved, setSaved] = useState(false); const [saved, setSaved] = useState(false);
const [errors, setErrors] = useState<ProfileFormErrors>({});
const [form, setForm] = useState<ProfileFormState>({ const [form, setForm] = useState<ProfileFormState>({
first_name: '', first_name: '',
last_name: '', last_name: '',
@@ -33,7 +48,7 @@ export const useProfile = () => {
setForm({ setForm({
first_name: profile.first_name, first_name: profile.first_name,
last_name: profile.last_name, last_name: profile.last_name,
phone: profile.phone, phone: stripPrefix(profile.phone ?? ''),
password: '', password: '',
}); });
} }
@@ -51,15 +66,28 @@ export const useProfile = () => {
const handleChange = (field: keyof ProfileFormState, value: string) => { const handleChange = (field: keyof ProfileFormState, value: string) => {
setForm((prev) => ({ ...prev, [field]: value })); setForm((prev) => ({ ...prev, [field]: value }));
setErrors((prev) => ({ ...prev, [field]: undefined }));
setSaved(false); setSaved(false);
}; };
const validate = (): boolean => {
const next: ProfileFormErrors = {};
if (!form.first_name.trim()) next.first_name = t('firstNameRequired');
if (!form.last_name.trim()) next.last_name = t('lastNameRequired');
if (form.phone && form.phone.length !== 9) next.phone = t('phoneInvalid');
if (form.password && form.password.length < 8)
next.password = t('passwordTooShort');
setErrors(next);
return Object.keys(next).length === 0;
};
const handleSave = () => { const handleSave = () => {
if (!validate()) return;
const payload: Record<string, string> = { const payload: Record<string, string> = {
first_name: form.first_name, first_name: form.first_name,
last_name: form.last_name, last_name: form.last_name,
}; };
if (form.phone) payload.phone = form.phone; if (form.phone) payload.phone = `998${form.phone}`;
if (form.password) payload.password = form.password; if (form.password) payload.password = form.password;
mutate(payload); mutate(payload);
}; };
@@ -70,6 +98,7 @@ export const useProfile = () => {
isLoading, isLoading,
isSaving, isSaving,
saved, saved,
errors,
handleChange, handleChange,
handleSave, handleSave,
}; };

View File

@@ -33,9 +33,10 @@ export const Sidebar: React.FC<SidebarProps> = ({
userName, userName,
}) => { }) => {
const t = useTranslations('Cabinet'); const t = useTranslations('Cabinet');
console.log(userName);
const NAV_ITEMS = [ const NAV_ITEMS = [
{ id: 'home' as const, label: t('home'), icon: Home, href: '/' }, { id: 'home' as const, label: t('home'), icon: Home, href: '/plagiat' },
{ {
id: 'dashboard' as CabinetSection, id: 'dashboard' as CabinetSection,
label: t('dashboard'), label: t('dashboard'),
@@ -82,7 +83,7 @@ export const Sidebar: React.FC<SidebarProps> = ({
</div> </div>
{/* User pill */} {/* User pill */}
<div className="px-3 pt-4 pb-2"> {/* <div className="px-3 pt-4 pb-2">
<div className="flex items-center gap-3 px-3 py-2.5 rounded-xl bg-slate-50 border border-slate-100"> <div className="flex items-center gap-3 px-3 py-2.5 rounded-xl bg-slate-50 border border-slate-100">
<div className="w-8 h-8 rounded-full bg-blue-100 flex items-center justify-center shrink-0"> <div className="w-8 h-8 rounded-full bg-blue-100 flex items-center justify-center shrink-0">
<span className="text-blue-600 text-xs font-semibold"> <span className="text-blue-600 text-xs font-semibold">
@@ -98,7 +99,7 @@ export const Sidebar: React.FC<SidebarProps> = ({
</p> </p>
</div> </div>
</div> </div>
</div> </div> */}
{/* Navigation */} {/* Navigation */}
<nav className="flex-1 px-3 py-2 space-y-0.5 overflow-y-auto"> <nav className="flex-1 px-3 py-2 space-y-0.5 overflow-y-auto">

View File

@@ -12,7 +12,7 @@ export const CtaCards = () => {
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4"> <div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
{/* Plagiat */} {/* Plagiat */}
<Link <Link
href={'/plagat'} href={'/plagiat'}
className="group relative overflow-hidden rounded-2xl bg-linear-to-br from-blue-500 to-blue-600 p-6 text-left shadow-md hover:shadow-xl transition-all duration-200 hover:-translate-y-0.5 active:translate-y-0 active:shadow-md" className="group relative overflow-hidden rounded-2xl bg-linear-to-br from-blue-500 to-blue-600 p-6 text-left shadow-md hover:shadow-xl transition-all duration-200 hover:-translate-y-0.5 active:translate-y-0 active:shadow-md"
> >
<div className="absolute right-3 top-3 opacity-10 pointer-events-none"> <div className="absolute right-3 top-3 opacity-10 pointer-events-none">

View File

@@ -1,4 +1,6 @@
'use client';
import React from 'react'; import React from 'react';
import { useTranslations } from 'next-intl';
import { import {
MODULE_CATEGORIES, MODULE_CATEGORIES,
MODULE_STATS, MODULE_STATS,
@@ -8,13 +10,15 @@ import {
// ─── Module stats mini-cards ─────────────────────────────────────────────────── // ─── Module stats mini-cards ───────────────────────────────────────────────────
const ModuleStats: React.FC = () => ( const ModuleStats: React.FC = () => {
const t = useTranslations('Cabinet');
return (
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3"> <div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
{[ {[
{ value: MODULE_STATS.total, label: 'Jami modullar' }, { value: MODULE_STATS.total, label: t('totalModules') },
{ value: MODULE_STATS.freeInternet, label: 'Bepul internet manbalari' }, { value: MODULE_STATS.freeInternet, label: t('freeInternetSources') },
{ value: MODULE_STATS.aiModules, label: 'AI tahlil modullari' }, { value: MODULE_STATS.aiModules, label: t('aiAnalysisModules') },
{ value: MODULE_STATS.categories, label: 'Kategoriya' }, { value: MODULE_STATS.categories, label: t('categories') },
].map(({ value, label }) => ( ].map(({ value, label }) => (
<div <div
key={label} key={label}
@@ -27,7 +31,8 @@ const ModuleStats: React.FC = () => (
</div> </div>
))} ))}
</div> </div>
); );
};
// ─── Tag badge ───────────────────────────────────────────────────────────────── // ─── Tag badge ─────────────────────────────────────────────────────────────────
@@ -62,7 +67,6 @@ interface ModuleCardProps {
const ModuleCard: React.FC<ModuleCardProps> = ({ name, desc, tags, index }) => ( const ModuleCard: React.FC<ModuleCardProps> = ({ name, desc, tags, index }) => (
<div className="bg-white border border-slate-100 rounded-xl p-4 flex items-start gap-3 hover:border-slate-200 hover:shadow-sm transition-all duration-150"> <div className="bg-white border border-slate-100 rounded-xl p-4 flex items-start gap-3 hover:border-slate-200 hover:shadow-sm transition-all duration-150">
{/* Index number */}
<span className="w-6 h-6 rounded-full bg-slate-100 flex items-center justify-center text-[10px] font-semibold text-slate-500 shrink-0 mt-0.5"> <span className="w-6 h-6 rounded-full bg-slate-100 flex items-center justify-center text-[10px] font-semibold text-slate-500 shrink-0 mt-0.5">
{index} {index}
</span> </span>
@@ -83,7 +87,7 @@ const ModuleCard: React.FC<ModuleCardProps> = ({ name, desc, tags, index }) => (
// ─── Modules section ─────────────────────────────────────────────────────────── // ─── Modules section ───────────────────────────────────────────────────────────
export const ModulesSection: React.FC = () => { export const ModulesSection: React.FC = () => {
// running counter across all categories const t = useTranslations('Cabinet');
let counter = 0; let counter = 0;
return ( return (
@@ -91,14 +95,14 @@ export const ModulesSection: React.FC = () => {
<div className="flex items-center justify-between mb-4"> <div className="flex items-center justify-between mb-4">
<div> <div>
<h3 className="text-sm font-semibold text-slate-800"> <h3 className="text-sm font-semibold text-slate-800">
Tekshiruv modullari {t('checkModules')}
</h3> </h3>
<p className="text-xs text-slate-400 mt-0.5"> <p className="text-xs text-slate-400 mt-0.5">
Plagiat aniqlashda foydalaniladigan barcha manbalar {t('checkModulesDesc')}
</p> </p>
</div> </div>
<span className="text-xs text-slate-400 bg-slate-100 px-2.5 py-1 rounded-lg font-medium"> <span className="text-xs text-slate-400 bg-slate-100 px-2.5 py-1 rounded-lg font-medium">
{MODULE_STATS.total} ta modul {t('modulesCount', { count: MODULE_STATS.total })}
</span> </span>
</div> </div>

View File

@@ -1,6 +1,8 @@
'use client';
import React from 'react'; import React from 'react';
import { TrendingUp, Calendar, Wallet, Loader2 } from 'lucide-react'; import { TrendingUp, Calendar, Wallet, Loader2 } from 'lucide-react';
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { useTranslations } from 'next-intl';
import { apiRequest } from '@/shared/request/apiRequest'; import { apiRequest } from '@/shared/request/apiRequest';
import { links } from '@/shared/request/links'; import { links } from '@/shared/request/links';
@@ -54,6 +56,7 @@ const StatCardSkeleton = () => (
// ─── Grid ────────────────────────────────────────────────────────────────────── // ─── Grid ──────────────────────────────────────────────────────────────────────
export const StatsCards = () => { export const StatsCards = () => {
const t = useTranslations('Cabinet');
const { data, isLoading } = useQuery({ const { data, isLoading } = useQuery({
queryKey: ['statistics'], queryKey: ['statistics'],
queryFn: (): Promise<Stats> => queryFn: (): Promise<Stats> =>
@@ -74,7 +77,7 @@ export const StatsCards = () => {
return ( return (
<div className="flex items-center justify-center py-10 gap-2 text-slate-400"> <div className="flex items-center justify-center py-10 gap-2 text-slate-400">
<Loader2 size={18} className="animate-spin" /> <Loader2 size={18} className="animate-spin" />
<span className="text-sm">Ma&apos;lumot topilmadi</span> <span className="text-sm">{t('noData')}</span>
</div> </div>
); );
} }
@@ -83,21 +86,21 @@ export const StatsCards = () => {
<div className="grid grid-cols-2 lg:grid-cols-3 gap-4"> <div className="grid grid-cols-2 lg:grid-cols-3 gap-4">
<StatCard <StatCard
icon={TrendingUp} icon={TrendingUp}
label="Jami tekshiruvlar" label={t('totalChecks')}
value={String(data.total_documents)} value={String(data.total_documents)}
iconColor="text-blue-600" iconColor="text-blue-600"
iconBg="bg-blue-50" iconBg="bg-blue-50"
/> />
<StatCard <StatCard
icon={Calendar} icon={Calendar}
label="Bu oy" label={t('thisMonth')}
value={String(data.this_month_documents)} value={String(data.this_month_documents)}
iconColor="text-emerald-600" iconColor="text-emerald-600"
iconBg="bg-emerald-50" iconBg="bg-emerald-50"
/> />
<StatCard <StatCard
icon={Wallet} icon={Wallet}
label="To'langan summa" label={t('paidAmount')}
value={`${data.paid_price.toLocaleString('uz-UZ')} UZS`} value={`${data.paid_price.toLocaleString('uz-UZ')} UZS`}
iconColor="text-violet-600" iconColor="text-violet-600"
iconBg="bg-violet-50" iconBg="bg-violet-50"

View File

@@ -1,4 +1,6 @@
'use client';
import React from 'react'; import React from 'react';
import { useTranslations } from 'next-intl';
import { CtaCards } from './CtaCards'; import { CtaCards } from './CtaCards';
import { StatsCards } from './StatsCards'; import { StatsCards } from './StatsCards';
import { ModulesSection } from './ModulesSection'; import { ModulesSection } from './ModulesSection';
@@ -7,22 +9,22 @@ interface DashboardProps {
userName: string; userName: string;
} }
export const Dashboard: React.FC<DashboardProps> = ({ userName }) => ( export const Dashboard: React.FC<DashboardProps> = ({ userName }) => {
const t = useTranslations('Cabinet');
return (
<div className="space-y-6"> <div className="space-y-6">
<div> <div>
<h2 className="text-xl font-bold text-slate-900"> <h2 className="text-xl font-bold text-slate-900">
Xush kelibsiz, {userName} 👋 {t('welcome', { userName })}
</h2> </h2>
<p className="text-sm text-slate-500 mt-0.5"> <p className="text-sm text-slate-500 mt-0.5">{t('welcomeDesc')}</p>
Shaxsiy kabinetingizga xush kelibsiz
</p>
</div> </div>
<StatsCards /> <StatsCards />
<div> <div>
<h3 className="text-xs font-semibold text-slate-400 uppercase tracking-widest mb-3"> <h3 className="text-xs font-semibold text-slate-400 uppercase tracking-widest mb-3">
Tezkor harakatlar {t('quickActions')}
</h3> </h3>
<CtaCards /> <CtaCards />
</div> </div>
@@ -31,4 +33,5 @@ export const Dashboard: React.FC<DashboardProps> = ({ userName }) => (
<ModulesSection /> <ModulesSection />
</div> </div>
</div> </div>
); );
};

View File

@@ -1,7 +1,9 @@
'use client'; 'use client';
import React from 'react'; import React, { useState } from 'react';
import { User, Phone, Lock, Save, CheckCircle } from 'lucide-react'; import { User, Lock, Save, CheckCircle, Phone } from 'lucide-react';
import { useTranslations } from 'next-intl';
import { useProfile } from '../../lib/hooks/useProfile'; import { useProfile } from '../../lib/hooks/useProfile';
import { formatPhone, normalizeDigits } from '@/shared/lib/formatPhone';
// ─── Input field ─────────────────────────────────────────────────────────────── // ─── Input field ───────────────────────────────────────────────────────────────
@@ -13,6 +15,7 @@ interface InputFieldProps {
icon: React.ElementType; icon: React.ElementType;
placeholder?: string; placeholder?: string;
disabled?: boolean; disabled?: boolean;
error?: string;
} }
const InputField: React.FC<InputFieldProps> = ({ const InputField: React.FC<InputFieldProps> = ({
@@ -23,6 +26,7 @@ const InputField: React.FC<InputFieldProps> = ({
icon: Icon, icon: Icon,
placeholder, placeholder,
disabled, disabled,
error,
}) => ( }) => (
<div> <div>
<label className="block text-xs font-semibold text-slate-600 mb-1.5"> <label className="block text-xs font-semibold text-slate-600 mb-1.5">
@@ -39,19 +43,73 @@ const InputField: React.FC<InputFieldProps> = ({
onChange={(e) => onChange(e.target.value)} onChange={(e) => onChange(e.target.value)}
placeholder={placeholder} placeholder={placeholder}
disabled={disabled} disabled={disabled}
className=" className={`
w-full pl-9 pr-4 py-2.5 text-sm rounded-xl w-full pl-9 pr-4 py-2.5 text-sm rounded-xl
border border-slate-200 bg-white border bg-white
text-slate-800 placeholder:text-slate-300 text-slate-800 placeholder:text-slate-300
focus:outline-none focus:ring-2 focus:ring-blue-100 focus:border-blue-400 focus:outline-none focus:ring-2 focus:ring-blue-100 focus:border-blue-400
disabled:bg-slate-50 disabled:text-slate-400 disabled:cursor-not-allowed disabled:bg-slate-50 disabled:text-slate-400 disabled:cursor-not-allowed
transition-all duration-150 transition-all duration-150
" ${error ? 'border-rose-400 bg-rose-50' : 'border-slate-200'}
`}
/> />
</div> </div>
{error && <p className="mt-1 text-xs text-rose-500">{error}</p>}
</div> </div>
); );
// ─── Phone input ───────────────────────────────────────────────────────────────
interface PhoneFieldProps {
value: string;
onChange: (val: string) => void;
error?: string;
}
const PhoneField: React.FC<PhoneFieldProps> = ({ value, onChange, error }) => {
const t = useTranslations('Cabinet');
const [isFocused, setIsFocused] = useState(false);
return (
<div>
<label className="block text-xs font-semibold text-slate-600 mb-1.5">
{t('phone')}
</label>
<div className="relative">
{/* +998 prefix */}
<div className="absolute left-3.5 top-1/2 -translate-y-1/2 flex items-center gap-1.5 pointer-events-none">
<Phone
size={14}
className={isFocused ? 'text-blue-500' : 'text-slate-400'}
/>
<span
className={`text-sm font-semibold ${isFocused ? 'text-blue-500' : 'text-slate-400'}`}
>
+998
</span>
<span className="text-slate-300">|</span>
</div>
<input
type="tel"
value={formatPhone(value)}
onChange={(e) => onChange(normalizeDigits(e.target.value))}
onFocus={() => setIsFocused(true)}
onBlur={() => setIsFocused(false)}
placeholder="90 123 45 67"
className={`
w-full pl-24 pr-4 py-2.5 text-sm rounded-xl
border bg-white
text-slate-800 placeholder:text-slate-300
focus:outline-none focus:ring-2 focus:ring-blue-100 focus:border-blue-400
transition-all duration-150
${error ? 'border-rose-400 bg-rose-50' : 'border-slate-200'}
`}
/>
</div>
{error && <p className="mt-1 text-xs text-rose-500">{error}</p>}
</div>
);
};
// ─── Skeleton ────────────────────────────────────────────────────────────────── // ─── Skeleton ──────────────────────────────────────────────────────────────────
const FieldSkeleton = () => ( const FieldSkeleton = () => (
@@ -64,12 +122,14 @@ const FieldSkeleton = () => (
// ─── Form ────────────────────────────────────────────────────────────────────── // ─── Form ──────────────────────────────────────────────────────────────────────
export const ProfileForm: React.FC = () => { export const ProfileForm: React.FC = () => {
const t = useTranslations('Cabinet');
const { const {
form, form,
profile, profile,
isLoading, isLoading,
isSaving, isSaving,
saved, saved,
errors,
handleChange, handleChange,
handleSave, handleSave,
} = useProfile(); } = useProfile();
@@ -79,7 +139,7 @@ export const ProfileForm: React.FC = () => {
{/* Personal info */} {/* Personal info */}
<div className="bg-white rounded-2xl border border-slate-100 shadow-sm p-6"> <div className="bg-white rounded-2xl border border-slate-100 shadow-sm p-6">
<h3 className="text-sm font-semibold text-slate-800 mb-4"> <h3 className="text-sm font-semibold text-slate-800 mb-4">
Shaxsiy ma&apos;lumotlar {t('personalInfo')}
</h3> </h3>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4"> <div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
{isLoading ? ( {isLoading ? (
@@ -92,28 +152,28 @@ export const ProfileForm: React.FC = () => {
) : ( ) : (
<> <>
<InputField <InputField
label="Ism" label={t('firstName')}
value={form.first_name} value={form.first_name}
onChange={(v) => handleChange('first_name', v)} onChange={(v) => handleChange('first_name', v)}
icon={User} icon={User}
placeholder="Ali" placeholder="Ali"
error={errors.first_name}
/> />
<InputField <InputField
label="Familiya" label={t('lastName')}
value={form.last_name} value={form.last_name}
onChange={(v) => handleChange('last_name', v)} onChange={(v) => handleChange('last_name', v)}
icon={User} icon={User}
placeholder="Karimov" placeholder="Karimov"
error={errors.last_name}
/> />
<InputField <PhoneField
label="Telefon"
value={form.phone} value={form.phone}
onChange={(v) => handleChange('phone', v)} onChange={(v) => handleChange('phone', v)}
icon={Phone} error={errors.phone}
placeholder="+998 90 123 45 67"
/> />
<InputField <InputField
label="Email" label={t('email')}
value={profile?.email ?? ''} value={profile?.email ?? ''}
onChange={() => {}} onChange={() => {}}
icon={User} icon={User}
@@ -128,16 +188,17 @@ export const ProfileForm: React.FC = () => {
{/* Password */} {/* Password */}
<div className="bg-white rounded-2xl border border-slate-100 shadow-sm p-6"> <div className="bg-white rounded-2xl border border-slate-100 shadow-sm p-6">
<h3 className="text-sm font-semibold text-slate-800 mb-4"> <h3 className="text-sm font-semibold text-slate-800 mb-4">
Parol o&apos;zgartirish {t('changePassword')}
</h3> </h3>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4"> <div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<InputField <InputField
label="Yangi parol" label={t('newPassword')}
value={form.password} value={form.password}
onChange={(v) => handleChange('password', v)} onChange={(v) => handleChange('password', v)}
type="password" type="password"
icon={Lock} icon={Lock}
placeholder="••••••••" placeholder="••••••••"
error={errors.password}
/> />
</div> </div>
</div> </div>
@@ -161,16 +222,16 @@ export const ProfileForm: React.FC = () => {
> >
{saved ? ( {saved ? (
<> <>
<CheckCircle size={15} /> Saqlandi <CheckCircle size={15} /> {t('saved')}
</> </>
) : isSaving ? ( ) : isSaving ? (
<> <>
<span className="w-4 h-4 border-2 border-white/40 border-t-white rounded-full animate-spin" /> <span className="w-4 h-4 border-2 border-white/40 border-t-white rounded-full animate-spin" />
Saqlanmoqda {t('saving')}
</> </>
) : ( ) : (
<> <>
<Save size={15} /> Saqlash <Save size={15} /> {t('save')}
</> </>
)} )}
</button> </button>

View File

@@ -1,5 +1,6 @@
'use client';
import React from 'react'; import React from 'react';
import { DiscountProgress } from './DiscountProgress'; import { useTranslations } from 'next-intl';
import { ProfileForm } from './ProfileForm'; import { ProfileForm } from './ProfileForm';
import type { CabinetStats } from '../../lib/types'; import type { CabinetStats } from '../../lib/types';
@@ -7,16 +8,18 @@ interface ProfileSectionProps {
stats: CabinetStats; stats: CabinetStats;
} }
export const ProfileSection: React.FC<ProfileSectionProps> = ({ stats }) => ( export const ProfileSection: React.FC<ProfileSectionProps> = ({ stats }) => {
const t = useTranslations('Cabinet');
console.log(stats);
return (
<div className="space-y-6"> <div className="space-y-6">
<div> <div>
<h2 className="text-xl font-bold text-slate-900">Profil</h2> <h2 className="text-xl font-bold text-slate-900">{t('profile')}</h2>
<p className="text-sm text-slate-500 mt-0.5"> <p className="text-sm text-slate-500 mt-0.5">{t('profileDesc')}</p>
Ma&apos;lumotlaringizni boshqaring
</p>
</div> </div>
<DiscountProgress stats={stats} /> {/* <DiscountProgress stats={stats} /> */}
<ProfileForm /> <ProfileForm />
</div> </div>
); );
};

View File

@@ -2,6 +2,7 @@
import React, { useState } from 'react'; import React, { useState } from 'react';
import { Clock, XCircle, ReceiptText } from 'lucide-react'; import { Clock, XCircle, ReceiptText } from 'lucide-react';
import { useMutation, useQuery } from '@tanstack/react-query'; import { useMutation, useQuery } from '@tanstack/react-query';
import { useTranslations } from 'next-intl';
import { apiRequest } from '@/shared/request/apiRequest'; import { apiRequest } from '@/shared/request/apiRequest';
import { links } from '@/shared/request/links'; import { links } from '@/shared/request/links';
import PaymentStatus from '@/widgets/detail/paidStatus'; import PaymentStatus from '@/widgets/detail/paidStatus';
@@ -36,6 +37,7 @@ function formatPrice(price: string) {
// ─── Component ───────────────────────────────────────────────────────────────── // ─── Component ─────────────────────────────────────────────────────────────────
export function PaymentsTable() { export function PaymentsTable() {
const t = useTranslations('Cabinet');
const [isPaymentOpen, setIsPaymentOpen] = useState(false); const [isPaymentOpen, setIsPaymentOpen] = useState(false);
const { data, isLoading } = useQuery({ const { data, isLoading } = useQuery({
queryKey: ['pay_history'], queryKey: ['pay_history'],
@@ -50,9 +52,7 @@ export function PaymentsTable() {
mutationFn: ({ order_id }: { order_id: number }) => mutationFn: ({ order_id }: { order_id: number }) =>
apiRequest<{ payment_link: string }>('POST', links.payment(order_id)), apiRequest<{ payment_link: string }>('POST', links.payment(order_id)),
onSuccess: (res) => { onSuccess: (res) => {
console.log('payment res: ', res);
window.open(res.data.payment_link, '_self'); window.open(res.data.payment_link, '_self');
//route.push(`/${document_id}`);
setIsPaymentOpen(false); setIsPaymentOpen(false);
}, },
onError: (err) => { onError: (err) => {
@@ -75,11 +75,9 @@ export function PaymentsTable() {
<> <>
<div className="space-y-4"> <div className="space-y-4">
<div> <div>
<h2 className="text-xl font-bold text-slate-900"> <h2 className="text-xl font-bold text-slate-900">{t('payments')}</h2>
To&apos;lovlar tarixi
</h2>
<p className="text-sm text-slate-500 mt-0.5"> <p className="text-sm text-slate-500 mt-0.5">
{data?.length ?? 0} ta to&apos;lov {t('paymentsCount', { count: data?.length ?? 0 })}
</p> </p>
</div> </div>
@@ -87,28 +85,33 @@ export function PaymentsTable() {
{isLoading ? ( {isLoading ? (
<div className="flex items-center justify-center py-16 gap-3 text-slate-400"> <div className="flex items-center justify-center py-16 gap-3 text-slate-400">
<Clock size={20} className="animate-spin" /> <Clock size={20} className="animate-spin" />
<span className="text-sm">Yuklanmoqda...</span> <span className="text-sm">{t('loading')}</span>
</div> </div>
) : !data || data.length === 0 ? ( ) : !data || data.length === 0 ? (
<div className="flex flex-col items-center justify-center py-16 gap-3 text-slate-400"> <div className="flex flex-col items-center justify-center py-16 gap-3 text-slate-400">
<ReceiptText size={40} strokeWidth={1.5} /> <ReceiptText size={40} strokeWidth={1.5} />
<p className="text-sm">To&apos;lovlar tarixi mavjud emas</p> <p className="text-sm">{t('noPayments')}</p>
</div> </div>
) : ( ) : (
<div className="overflow-x-auto"> <div className="overflow-x-auto">
<table className="w-full text-sm"> <table className="w-full text-sm">
<thead> <thead>
<tr className="bg-slate-50 border-b border-slate-100"> <tr className="bg-slate-50 border-b border-slate-100">
{['#', 'Xizmat', 'Summa', 'Chegirma', 'Sana', 'Holat'].map( {[
(h) => ( t('tableNum'),
t('service'),
t('amount'),
t('discount'),
t('date'),
t('status'),
].map((h) => (
<th <th
key={h} key={h}
className="text-left px-5 py-3 text-[11px] font-semibold text-slate-400 uppercase tracking-wider whitespace-nowrap" className="text-left px-5 py-3 text-[11px] font-semibold text-slate-400 uppercase tracking-wider whitespace-nowrap"
> >
{h} {h}
</th> </th>
), ))}
)}
</tr> </tr>
</thead> </thead>
<tbody className="divide-y divide-slate-50"> <tbody className="divide-y divide-slate-50">
@@ -155,7 +158,7 @@ export function PaymentsTable() {
) : ( ) : (
<span className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-lg text-xs font-medium text-slate-400 bg-slate-50"> <span className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-lg text-xs font-medium text-slate-400 bg-slate-50">
<XCircle size={12} /> <XCircle size={12} />
Noma&apos;lum {t('unknown')}
</span> </span>
)} )}
</td> </td>

View File

@@ -2,6 +2,7 @@
import React from 'react'; import React from 'react';
import { Download, CreditCard, Eye } from 'lucide-react'; import { Download, CreditCard, Eye } from 'lucide-react';
import { useMutation } from '@tanstack/react-query'; import { useMutation } from '@tanstack/react-query';
import { useTranslations } from 'next-intl';
import { useSiHistory } from '../../lib/hooks/useSiHistory'; import { useSiHistory } from '../../lib/hooks/useSiHistory';
import { formatDate } from '@/widgets/history/lib/utils'; import { formatDate } from '@/widgets/history/lib/utils';
import { apiRequest } from '@/shared/request/apiRequest'; import { apiRequest } from '@/shared/request/apiRequest';
@@ -14,6 +15,7 @@ import { useRouter, useParams } from 'next/navigation';
// ─── State badge ─────────────────────────────────────────────────────────────── // ─── State badge ───────────────────────────────────────────────────────────────
const StateBadge: React.FC<{ state: 'paid' | 'unpaid' }> = ({ state }) => { const StateBadge: React.FC<{ state: 'paid' | 'unpaid' }> = ({ state }) => {
const t = useTranslations('Cabinet');
const isPaid = state === 'paid'; const isPaid = state === 'paid';
return ( return (
<span <span
@@ -28,7 +30,7 @@ const StateBadge: React.FC<{ state: 'paid' | 'unpaid' }> = ({ state }) => {
isPaid ? 'bg-emerald-500' : 'bg-rose-500', isPaid ? 'bg-emerald-500' : 'bg-rose-500',
].join(' ')} ].join(' ')}
/> />
{isPaid ? "To'langan" : "To'lanmagan"} {isPaid ? t('paid') : t('unpaid')}
</span> </span>
); );
}; };
@@ -68,21 +70,27 @@ const SkeletonRow = () => (
// ─── Empty / Error states ────────────────────────────────────────────────────── // ─── Empty / Error states ──────────────────────────────────────────────────────
const EmptyState = () => ( const EmptyState = () => {
const t = useTranslations('Cabinet');
return (
<tr> <tr>
<td colSpan={7} className="px-5 py-12 text-center text-slate-400 text-sm"> <td colSpan={7} className="px-5 py-12 text-center text-slate-400 text-sm">
Hozircha SI tekshiruvlar yo&apos;q {t('noSiChecks')}
</td> </td>
</tr> </tr>
); );
};
const ErrorState = () => ( const ErrorState = () => {
const t = useTranslations('Cabinet');
return (
<tr> <tr>
<td colSpan={7} className="px-5 py-12 text-center text-red-400 text-sm"> <td colSpan={7} className="px-5 py-12 text-center text-red-400 text-sm">
Ma&apos;lumotlarni yuklashda xatolik yuz berdi {t('loadError')}
</td> </td>
</tr> </tr>
); );
};
// ─── Row ─────────────────────────────────────────────────────────────────────── // ─── Row ───────────────────────────────────────────────────────────────────────
@@ -90,6 +98,7 @@ const SiRow: React.FC<{ item: SiDocument; index: number }> = ({
item, item,
index, index,
}) => { }) => {
const t = useTranslations('Cabinet');
const router = useRouter(); const router = useRouter();
const { locale } = useParams() as { locale: string }; const { locale } = useParams() as { locale: string };
@@ -107,19 +116,14 @@ const SiRow: React.FC<{ item: SiDocument; index: number }> = ({
return ( return (
<tr className="hover:bg-slate-50/60 transition-colors"> <tr className="hover:bg-slate-50/60 transition-colors">
{/* # */}
<td className="px-5 py-3.5 text-slate-400 font-mono text-xs"> <td className="px-5 py-3.5 text-slate-400 font-mono text-xs">
{String(index).padStart(2, '0')} {String(index).padStart(2, '0')}
</td> </td>
{/* Sarlavha */}
<td className="px-5 py-3.5"> <td className="px-5 py-3.5">
<span className="text-slate-800 font-medium max-w-45 truncate block text-sm"> <span className="text-slate-800 font-medium max-w-45 truncate block text-sm">
{item.title || '—'} {item.title || '—'}
</span> </span>
</td> </td>
{/* Fayl */}
<td className="px-5 py-3.5"> <td className="px-5 py-3.5">
{item.file ? ( {item.file ? (
<a <a
@@ -129,19 +133,15 @@ const SiRow: React.FC<{ item: SiDocument; index: number }> = ({
className="inline-flex items-center gap-1.5 text-slate-500 hover:text-blue-600 transition-colors" className="inline-flex items-center gap-1.5 text-slate-500 hover:text-blue-600 transition-colors"
> >
<Download size={14} /> <Download size={14} />
<span className="text-xs font-medium">Fayl</span> <span className="text-xs font-medium">{t('tableFile')}</span>
</a> </a>
) : ( ) : (
<span className="text-slate-200 text-xs"></span> <span className="text-slate-200 text-xs"></span>
)} )}
</td> </td>
{/* So'z */}
<td className="px-5 py-3.5 text-slate-500 tabular-nums text-sm"> <td className="px-5 py-3.5 text-slate-500 tabular-nums text-sm">
{item.total_words > 0 ? item.total_words.toLocaleString() : '—'} {item.total_words > 0 ? item.total_words.toLocaleString() : '—'}
</td> </td>
{/* SI% */}
<td className="px-5 py-3.5"> <td className="px-5 py-3.5">
{item.si_percantage != null && item.result != null ? ( {item.si_percantage != null && item.result != null ? (
<SiPercentBadge value={item.si_percantage} /> <SiPercentBadge value={item.si_percantage} />
@@ -149,18 +149,12 @@ const SiRow: React.FC<{ item: SiDocument; index: number }> = ({
<span className="text-slate-300 text-xs"></span> <span className="text-slate-300 text-xs"></span>
)} )}
</td> </td>
{/* Sana */}
<td className="px-5 py-3.5 text-slate-500 text-sm whitespace-nowrap"> <td className="px-5 py-3.5 text-slate-500 text-sm whitespace-nowrap">
{formatDate(item.created_at)} {formatDate(item.created_at)}
</td> </td>
{/* Holat */}
<td className="px-5 py-3.5"> <td className="px-5 py-3.5">
<StateBadge state={item.state} /> <StateBadge state={item.state} />
</td> </td>
{/* Amal */}
<td className="px-5 py-3.5 text-right"> <td className="px-5 py-3.5 text-right">
{item.state === 'unpaid' ? ( {item.state === 'unpaid' ? (
<button <button
@@ -169,7 +163,7 @@ const SiRow: React.FC<{ item: SiDocument; index: number }> = ({
className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium text-white bg-violet-600 hover:bg-violet-700 active:scale-95 transition-all duration-150 disabled:opacity-60" className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium text-white bg-violet-600 hover:bg-violet-700 active:scale-95 transition-all duration-150 disabled:opacity-60"
> >
<CreditCard size={11} /> <CreditCard size={11} />
{pay.isPending ? '...' : "To'lash"} {pay.isPending ? '...' : t('pay')}
</button> </button>
) : ( ) : (
<button <button
@@ -177,7 +171,7 @@ const SiRow: React.FC<{ item: SiDocument; index: number }> = ({
className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium text-blue-600 bg-blue-50 hover:bg-blue-100 active:scale-95 transition-all duration-150" className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium text-blue-600 bg-blue-50 hover:bg-blue-100 active:scale-95 transition-all duration-150"
> >
<Eye size={11} /> <Eye size={11} />
Ko&apos;rish {t('view')}
</button> </button>
)} )}
</td> </td>
@@ -188,15 +182,16 @@ const SiRow: React.FC<{ item: SiDocument; index: number }> = ({
// ─── SiTable ─────────────────────────────────────────────────────────────────── // ─── SiTable ───────────────────────────────────────────────────────────────────
export const SiTable: React.FC = () => { export const SiTable: React.FC = () => {
const t = useTranslations('Cabinet');
const { items, isLoading, isError } = useSiHistory(); const { items, isLoading, isError } = useSiHistory();
return ( return (
<div className="space-y-4"> <div className="space-y-4">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div> <div>
<h2 className="text-xl font-bold text-slate-900">SI detektor</h2> <h2 className="text-xl font-bold text-slate-900">{t('siNav')}</h2>
<p className="text-sm text-slate-500 mt-0.5"> <p className="text-sm text-slate-500 mt-0.5">
{isLoading ? '...' : `${items.length} ta tekshiruv`} {isLoading ? '...' : t('checksCount', { count: items.length })}
</p> </p>
</div> </div>
<SiButton /> <SiButton />
@@ -208,14 +203,14 @@ export const SiTable: React.FC = () => {
<thead> <thead>
<tr className="bg-slate-50 border-b border-slate-100"> <tr className="bg-slate-50 border-b border-slate-100">
{[ {[
'#', t('tableNum'),
'Sarlavha', t('tableTitle'),
'Fayl', t('tableFile'),
"So'z", t('words'),
'SI%', 'SI%',
'Sana', t('date'),
'Holat', t('status'),
'Amal', t('action'),
].map((h) => ( ].map((h) => (
<th <th
key={h} key={h}

View File

@@ -22,7 +22,7 @@ const PageHeader: React.FC = () => {
<p className="mt-1 text-sm text-slate-500">{t('description')}</p> <p className="mt-1 text-sm text-slate-500">{t('description')}</p>
</div> </div>
<Link <Link
href={'/plagat'} href={'/plagiat'}
className={`${pathname === '/cabinet' ? 'flex' : 'hidden'} className={`${pathname === '/cabinet' ? 'flex' : 'hidden'}
items-center gap-2 px-2 py-1 group relative overflow-hidden rounded-sm bg-linear-to-br items-center gap-2 px-2 py-1 group relative overflow-hidden rounded-sm bg-linear-to-br
from-blue-500 to-blue-600 text-left shadow-md hover:shadow-xl transition-all duration-200 from-blue-500 to-blue-600 text-left shadow-md hover:shadow-xl transition-all duration-200

View File

@@ -17,6 +17,7 @@ export const StateBadge: React.FC<{ state: 'paid' | 'unpaid' }> = ({
state, state,
}) => { }) => {
const isPaid = state === 'paid'; const isPaid = state === 'paid';
const t = useTranslations();
return ( return (
<span <span
className={[ className={[
@@ -30,7 +31,7 @@ export const StateBadge: React.FC<{ state: 'paid' | 'unpaid' }> = ({
isPaid ? 'bg-emerald-500' : 'bg-rose-500', isPaid ? 'bg-emerald-500' : 'bg-rose-500',
].join(' ')} ].join(' ')}
/> />
{isPaid ? "To'langan" : "To'lanmagan"} {isPaid ? t('Cabinet.paid') : t('Cabinet.unpaid')}
</span> </span>
); );
}; };
@@ -127,7 +128,7 @@ export const HistoryTableRow: React.FC<
}} }}
className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium text-slate-600 bg-white border border-slate-200 hover:border-slate-300 hover:text-slate-900 hover:bg-slate-50 active:scale-95 transition-all duration-150" className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium text-slate-600 bg-white border border-slate-200 hover:border-slate-300 hover:text-slate-900 hover:bg-slate-50 active:scale-95 transition-all duration-150"
> >
Ko&apos;rish {tUnknown('HistoryPage.view')}
<ArrowRight size={11} /> <ArrowRight size={11} />
</button> </button>
</td> </td>

View File

@@ -12,7 +12,7 @@ const PlagiarismLanding = () => {
const data = localStorage.getItem('user'); const data = localStorage.getItem('user');
if (data) { if (data) {
route.push('/plagat'); route.push('/plagiat');
} }
}, []); }, []);
return ( return (

View File

@@ -42,6 +42,11 @@ export function validatePlagiarismForm(
} }
} }
// docuemnt - type validation
if (!state.type) {
errors.type = 'Type is required!';
}
return errors; return errors;
} }

View File

@@ -182,6 +182,8 @@ export function PlagiarismCheckForm() {
value={form.type} value={form.type}
onChange={setOption} onChange={setOption}
disabled={submission.status === 'success'} disabled={submission.status === 'success'}
hasError={!!errors.type}
error={errors.type}
/> />
{/* Submit */} {/* Submit */}

View File

@@ -1,5 +1,5 @@
'use client'; 'use client';
import React from 'react'; import React, { useEffect } from 'react';
import { useTranslations } from 'next-intl'; import { useTranslations } from 'next-intl';
import { FieldWrapper } from './Plagiraismui'; import { FieldWrapper } from './Plagiraismui';
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
@@ -16,12 +16,16 @@ interface DocumentsTypesProps {
value: number; value: number;
onChange: (value: number) => void; onChange: (value: number) => void;
disabled?: boolean; disabled?: boolean;
hasError?: boolean;
error?: string;
} }
export default function DocumentsTypes({ export default function DocumentsTypes({
value, value,
onChange, onChange,
disabled, disabled,
hasError,
error,
}: DocumentsTypesProps) { }: DocumentsTypesProps) {
const t = useTranslations('DocumentTypes'); const t = useTranslations('DocumentTypes');
const { data, isLoading } = useQuery({ const { data, isLoading } = useQuery({
@@ -32,18 +36,29 @@ export default function DocumentsTypes({
), ),
}); });
const selected = data?.find((item) => item.id === value); useEffect(() => {
console.log({ value });
}, [value]);
return ( return (
<FieldWrapper htmlFor="document_type" label={t('label')}> <FieldWrapper
htmlFor="document_type"
label={t('label')}
error={error}
required
>
<select <select
id="document_type" id="document_type"
value={selected?.name} value={value || ''}
onChange={(e) => { onChange={(e) => {
onChange(Number(e.target.value)); onChange(Number(e.target.value));
}} }}
disabled={isLoading || disabled} disabled={isLoading || disabled}
className={`${inputCls} cursor-pointer`} className={`${inputCls} cursor-pointer ${
hasError
? 'border-rose-400 bg-rose-50 hover:bg-rose-50'
: 'border-stone-300 bg-stone-50 hover:border-blue-500 hover:bg-blue-50'
}`}
> >
<option value="" disabled> <option value="" disabled>
{isLoading ? t('loading') : t('placeholder')} {isLoading ? t('loading') : t('placeholder')}