detail page done
This commit is contained in:
10
src/app/[locale]/[detail]/page.tsx
Normal file
10
src/app/[locale]/[detail]/page.tsx
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import { PlagiarismDetailPage } from '@/widgets/detail/ui/detailPage';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
params: Promise<{ detail: string }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function DetailPage({ params }: Props) {
|
||||||
|
const { detail } = await params;
|
||||||
|
return <PlagiarismDetailPage checkId={detail} />;
|
||||||
|
}
|
||||||
@@ -1,8 +1,6 @@
|
|||||||
import type { Metadata } from 'next';
|
|
||||||
import '../globals.css';
|
import '../globals.css';
|
||||||
import { golosText } from '@/shared/config/fonts';
|
import { golosText } from '@/shared/config/fonts';
|
||||||
import { ThemeProvider } from '@/shared/config/theme-provider';
|
import { ThemeProvider } from '@/shared/config/theme-provider';
|
||||||
import { PRODUCT_INFO } from '@/shared/constants/data';
|
|
||||||
import { hasLocale, Locale, NextIntlClientProvider } from 'next-intl';
|
import { hasLocale, Locale, NextIntlClientProvider } from 'next-intl';
|
||||||
import { routing } from '@/shared/config/i18n/routing';
|
import { routing } from '@/shared/config/i18n/routing';
|
||||||
import { notFound } from 'next/navigation';
|
import { notFound } from 'next/navigation';
|
||||||
@@ -14,12 +12,6 @@ import QueryProvider from '@/shared/config/react-query/QueryProvider';
|
|||||||
import Script from 'next/script';
|
import Script from 'next/script';
|
||||||
import Provider from '@/features/providers/provider';
|
import Provider from '@/features/providers/provider';
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
|
||||||
title: PRODUCT_INFO.name,
|
|
||||||
description: PRODUCT_INFO.description,
|
|
||||||
icons: PRODUCT_INFO.favicon,
|
|
||||||
};
|
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
params: Promise<{ locale: Locale }>;
|
params: Promise<{ locale: Locale }>;
|
||||||
|
|||||||
@@ -1,13 +1,11 @@
|
|||||||
import { getPosts } from '@/shared/config/api/testApi';
|
|
||||||
import { PlagiarismCheckForm } from '@/widgets/fileUpload/ui/Plagiraismcheckform';
|
import { PlagiarismCheckForm } from '@/widgets/fileUpload/ui/Plagiraismcheckform';
|
||||||
|
import { HistoryPage } from '@/widgets/history';
|
||||||
|
|
||||||
export default async function Home() {
|
export default function Home() {
|
||||||
const res = await getPosts({ _limit: 1 });
|
|
||||||
console.log('SSR res', res.data);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div className="bg-[#f4f5ffec]">
|
||||||
<PlagiarismCheckForm />
|
<PlagiarismCheckForm />
|
||||||
|
<HistoryPage />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,12 +29,12 @@ export function LoginForm() {
|
|||||||
<>
|
<>
|
||||||
{/* Backdrop */}
|
{/* Backdrop */}
|
||||||
<div
|
<div
|
||||||
className="fixed inset-0 z-10 bg-black/40 backdrop-blur-sm"
|
className="fixed inset-0 z-20 bg-black/40 backdrop-blur-sm"
|
||||||
onClick={toggleLoginModal}
|
onClick={toggleLoginModal}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Modal */}
|
{/* Modal */}
|
||||||
<div className="fixed inset-0 z-20 flex items-center justify-center pointer-events-none px-6">
|
<div className="fixed inset-0 z-30 flex items-center justify-center pointer-events-none px-6">
|
||||||
<MotionWrapper>
|
<MotionWrapper>
|
||||||
<div className="pointer-events-auto w-full max-w-sm rounded border border-stone-200 bg-white px-8 pb-8 pt-10 shadow-sm">
|
<div className="pointer-events-auto w-full max-w-sm rounded border border-stone-200 bg-white px-8 pb-8 pt-10 shadow-sm">
|
||||||
{/* Close */}
|
{/* Close */}
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
import { useCallback, useState } from 'react';
|
import { useCallback, useState } from 'react';
|
||||||
import { useRegisterZustand } from './registerZustand';
|
import { useRegisterZustand } from './registerZustand';
|
||||||
import { validateRegister, RegisterErrors } from './validateRegister';
|
import { validateRegister, RegisterErrors } from './validateRegister';
|
||||||
|
|||||||
@@ -12,12 +12,12 @@ export function RegisterForm() {
|
|||||||
<>
|
<>
|
||||||
{/* Backdrop */}
|
{/* Backdrop */}
|
||||||
<div
|
<div
|
||||||
className="fixed inset-0 z-10 bg-black/40 backdrop-blur-sm"
|
className="fixed inset-0 z-20 bg-black/40 backdrop-blur-sm"
|
||||||
onClick={toggleRegisterModal}
|
onClick={toggleRegisterModal}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Modal */}
|
{/* Modal */}
|
||||||
<div className="fixed inset-0 z-20 flex items-center justify-center pointer-events-none">
|
<div className="fixed inset-0 z-30 flex items-center justify-center pointer-events-none">
|
||||||
<MotionWrapper>
|
<MotionWrapper>
|
||||||
<div className="pointer-events-auto bg-background rounded-2xl shadow-2xl p-8 w-full max-w-sm mx-4">
|
<div className="pointer-events-auto bg-background rounded-2xl shadow-2xl p-8 w-full max-w-sm mx-4">
|
||||||
{/* Close button */}
|
{/* Close button */}
|
||||||
|
|||||||
@@ -1,6 +0,0 @@
|
|||||||
const BASE_URL =
|
|
||||||
process.env.NEXT_PUBLIC_API_URL || 'https://jsonplaceholder.typicode.com';
|
|
||||||
|
|
||||||
const ENDP_POSTS = '/posts/';
|
|
||||||
|
|
||||||
export { BASE_URL, ENDP_POSTS };
|
|
||||||
@@ -1,44 +0,0 @@
|
|||||||
import getLocaleCS from '@/shared/lib/getLocaleCS';
|
|
||||||
import axios from 'axios';
|
|
||||||
import { getLocale } from 'next-intl/server';
|
|
||||||
import { LanguageRoutes } from '../i18n/types';
|
|
||||||
import { BASE_URL } from './URLs';
|
|
||||||
|
|
||||||
const httpClient = axios.create({
|
|
||||||
baseURL: BASE_URL,
|
|
||||||
timeout: 10000,
|
|
||||||
});
|
|
||||||
|
|
||||||
httpClient.interceptors.request.use(
|
|
||||||
async (config) => {
|
|
||||||
console.log(`API REQUEST to ${config.url}`, config);
|
|
||||||
|
|
||||||
// Language configs
|
|
||||||
let language = LanguageRoutes.UZ;
|
|
||||||
try {
|
|
||||||
language = (await getLocale()) as LanguageRoutes;
|
|
||||||
} catch (e) {
|
|
||||||
console.log('error', e);
|
|
||||||
language = getLocaleCS() || LanguageRoutes.UZ;
|
|
||||||
}
|
|
||||||
|
|
||||||
config.headers['Accept-Language'] = language;
|
|
||||||
// const accessToken = localStorage.getItem('accessToken');
|
|
||||||
// if (accessToken) {
|
|
||||||
// config.headers['Authorization'] = `Bearer ${accessToken}`;
|
|
||||||
// }
|
|
||||||
|
|
||||||
return config;
|
|
||||||
},
|
|
||||||
(error) => Promise.reject(error),
|
|
||||||
);
|
|
||||||
|
|
||||||
httpClient.interceptors.response.use(
|
|
||||||
(response) => response,
|
|
||||||
(error) => {
|
|
||||||
console.error('API error:', error);
|
|
||||||
return Promise.reject(error);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
export default httpClient;
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
import { ENDP_POSTS } from '@/shared/config/api/URLs';
|
|
||||||
import { ReqWithPagination } from './types';
|
|
||||||
import { AxiosResponse } from 'axios';
|
|
||||||
import { TestApiType } from '@/shared/types/testApi';
|
|
||||||
import httpClient from './httpClient';
|
|
||||||
|
|
||||||
const getPosts = async (
|
|
||||||
pagination?: ReqWithPagination,
|
|
||||||
): Promise<AxiosResponse<TestApiType>> => {
|
|
||||||
const response = await httpClient.get(ENDP_POSTS, { params: pagination });
|
|
||||||
return response;
|
|
||||||
};
|
|
||||||
|
|
||||||
export { getPosts };
|
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
export interface ResWithPagination<T> {
|
|
||||||
success: boolean;
|
|
||||||
message: string;
|
|
||||||
links: Links;
|
|
||||||
total_items: number;
|
|
||||||
total_pages: number;
|
|
||||||
page_size: number;
|
|
||||||
current_page: number;
|
|
||||||
data: T[];
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Links {
|
|
||||||
next: number | null;
|
|
||||||
previous: number | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ReqWithPagination {
|
|
||||||
_start?: number;
|
|
||||||
_limit?: number;
|
|
||||||
}
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
const PRODUCT_INFO = {
|
|
||||||
name: 'FIAS App',
|
|
||||||
description: 'Generated by create next app',
|
|
||||||
logo: '/favicon.png',
|
|
||||||
favicon: '/favicon.svg',
|
|
||||||
url: 'https://www.shadcnblocks.com',
|
|
||||||
socials: {
|
|
||||||
telegram: 'https://t.me/usmanov_dev',
|
|
||||||
instagram: 'https://t.me/usmanov_dev',
|
|
||||||
youtube: 'https://t.me/usmanov_dev',
|
|
||||||
linkedin: 'https://www.linkedin.com/in/usmonov-azizbek/',
|
|
||||||
},
|
|
||||||
contact: {
|
|
||||||
phone: '+998901234567',
|
|
||||||
email: 'contact@fias.uz',
|
|
||||||
},
|
|
||||||
terms_of_use: '',
|
|
||||||
creator: 'FIAS App',
|
|
||||||
};
|
|
||||||
|
|
||||||
export { PRODUCT_INFO };
|
|
||||||
@@ -14,7 +14,7 @@ dayjs.extend(relativeTime);
|
|||||||
const getCurrentLocale = async () => {
|
const getCurrentLocale = async () => {
|
||||||
const locale = await getLocale();
|
const locale = await getLocale();
|
||||||
switch (locale) {
|
switch (locale) {
|
||||||
case 'ki':
|
case 'en':
|
||||||
return 'uz';
|
return 'uz';
|
||||||
case 'uz':
|
case 'uz':
|
||||||
return 'uz-latn';
|
return 'uz-latn';
|
||||||
|
|||||||
@@ -1,6 +0,0 @@
|
|||||||
export interface TestApiType {
|
|
||||||
userId: number;
|
|
||||||
id: number;
|
|
||||||
title: string;
|
|
||||||
body: string;
|
|
||||||
}
|
|
||||||
311
src/widgets/detail/index.tsx
Normal file
311
src/widgets/detail/index.tsx
Normal file
@@ -0,0 +1,311 @@
|
|||||||
|
// ─────────────────────────────────────────────────────────────
|
||||||
|
// Reusable UI Components
|
||||||
|
// ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import { CheckStatus, SimilarityLevel } from './lib/types';
|
||||||
|
|
||||||
|
// ── InfoRow ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
interface InfoRowProps {
|
||||||
|
label: string;
|
||||||
|
value: React.ReactNode;
|
||||||
|
icon?: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const InfoRow: React.FC<InfoRowProps> = ({ label, value, icon }) => (
|
||||||
|
<div className="flex items-start justify-between gap-4 py-3 border-b border-slate-100 last:border-0">
|
||||||
|
<span className="flex items-center gap-2 text-sm font-medium text-slate-500 min-w-[140px]">
|
||||||
|
{icon && <span className="text-slate-400">{icon}</span>}
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
<span className="text-sm text-slate-800 text-right font-medium">
|
||||||
|
{value}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
// ── SectionCard ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
interface SectionCardProps {
|
||||||
|
title: string;
|
||||||
|
icon?: React.ReactNode;
|
||||||
|
children: React.ReactNode;
|
||||||
|
className?: string;
|
||||||
|
accent?: 'blue' | 'green' | 'red' | 'amber' | 'violet';
|
||||||
|
}
|
||||||
|
|
||||||
|
const accentMap: Record<string, string> = {
|
||||||
|
blue: 'border-t-blue-500',
|
||||||
|
green: 'border-t-emerald-500',
|
||||||
|
red: 'border-t-red-500',
|
||||||
|
amber: 'border-t-amber-500',
|
||||||
|
violet: 'border-t-violet-500',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const SectionCard: React.FC<SectionCardProps> = ({
|
||||||
|
title,
|
||||||
|
icon,
|
||||||
|
children,
|
||||||
|
className = '',
|
||||||
|
accent = 'blue',
|
||||||
|
}) => (
|
||||||
|
<div
|
||||||
|
className={`bg-white rounded-2xl shadow-sm border border-slate-100 border-t-4 ${accentMap[accent]} overflow-hidden ${className}`}
|
||||||
|
>
|
||||||
|
<div className="px-6 py-4 border-b border-slate-50">
|
||||||
|
<h2 className="text-sm font-semibold text-slate-700 uppercase tracking-widest flex items-center gap-2">
|
||||||
|
{icon && <span>{icon}</span>}
|
||||||
|
{title}
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
<div className="px-6 py-4">{children}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
// ── StatusBadge ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
interface StatusBadgeProps {
|
||||||
|
status: CheckStatus;
|
||||||
|
}
|
||||||
|
|
||||||
|
const statusStyles: Record<CheckStatus, string> = {
|
||||||
|
pending: 'bg-slate-100 text-slate-600',
|
||||||
|
processing: 'bg-blue-100 text-blue-700',
|
||||||
|
completed: 'bg-emerald-100 text-emerald-700',
|
||||||
|
failed: 'bg-red-100 text-red-700',
|
||||||
|
};
|
||||||
|
|
||||||
|
const statusDots: Record<CheckStatus, string> = {
|
||||||
|
pending: 'bg-slate-400',
|
||||||
|
processing: 'bg-blue-500 animate-pulse',
|
||||||
|
completed: 'bg-emerald-500',
|
||||||
|
failed: 'bg-red-500',
|
||||||
|
};
|
||||||
|
|
||||||
|
const statusLabels: Record<CheckStatus, string> = {
|
||||||
|
pending: 'Pending',
|
||||||
|
processing: 'Processing',
|
||||||
|
completed: 'Completed',
|
||||||
|
failed: 'Failed',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const StatusBadge: React.FC<StatusBadgeProps> = ({ status }) => (
|
||||||
|
<span
|
||||||
|
className={`inline-flex items-center gap-1.5 px-3 py-1 rounded-full text-xs font-semibold ${statusStyles[status]}`}
|
||||||
|
>
|
||||||
|
<span className={`w-1.5 h-1.5 rounded-full ${statusDots[status]}`} />
|
||||||
|
{statusLabels[status]}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
|
||||||
|
// ── SimilarityMeter ───────────────────────────────────────────
|
||||||
|
|
||||||
|
interface SimilarityMeterProps {
|
||||||
|
percentage: number;
|
||||||
|
level: SimilarityLevel;
|
||||||
|
}
|
||||||
|
|
||||||
|
const levelColors: Record<SimilarityLevel, string> = {
|
||||||
|
low: 'from-emerald-400 to-emerald-500',
|
||||||
|
medium: 'from-amber-400 to-amber-500',
|
||||||
|
high: 'from-red-400 to-red-500',
|
||||||
|
};
|
||||||
|
|
||||||
|
const levelTextColors: Record<SimilarityLevel, string> = {
|
||||||
|
low: 'text-emerald-600',
|
||||||
|
medium: 'text-amber-600',
|
||||||
|
high: 'text-red-600',
|
||||||
|
};
|
||||||
|
|
||||||
|
const levelBgColors: Record<SimilarityLevel, string> = {
|
||||||
|
low: 'bg-emerald-50 border-emerald-200',
|
||||||
|
medium: 'bg-amber-50 border-amber-200',
|
||||||
|
high: 'bg-red-50 border-red-200',
|
||||||
|
};
|
||||||
|
|
||||||
|
const levelLabels: Record<SimilarityLevel, string> = {
|
||||||
|
low: 'Low Similarity — Likely Original',
|
||||||
|
medium: 'Medium Similarity — Review Recommended',
|
||||||
|
high: 'High Similarity — Action Required',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const SimilarityMeter: React.FC<SimilarityMeterProps> = ({
|
||||||
|
percentage,
|
||||||
|
level,
|
||||||
|
}) => (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex items-end justify-between">
|
||||||
|
<span className="text-4xl font-bold tabular-nums text-slate-800">
|
||||||
|
{percentage}
|
||||||
|
<span className="text-xl text-slate-400 font-medium">%</span>
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
className={`text-xs font-semibold uppercase tracking-wide ${levelTextColors[level]}`}
|
||||||
|
>
|
||||||
|
{level} risk
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Track */}
|
||||||
|
<div className="relative h-3 bg-slate-100 rounded-full overflow-hidden">
|
||||||
|
<div
|
||||||
|
className={`absolute inset-y-0 left-0 rounded-full bg-gradient-to-r ${levelColors[level]} transition-all duration-700`}
|
||||||
|
style={{ width: `${percentage}%` }}
|
||||||
|
/>
|
||||||
|
{/* Threshold markers */}
|
||||||
|
<div className="absolute inset-y-0 left-[30%] w-px bg-slate-300 opacity-60" />
|
||||||
|
<div className="absolute inset-y-0 left-[60%] w-px bg-slate-300 opacity-60" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex text-[10px] text-slate-400 justify-between font-medium">
|
||||||
|
<span>0%</span>
|
||||||
|
<span>30%</span>
|
||||||
|
<span>60%</span>
|
||||||
|
<span>100%</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className={`flex items-center gap-2 p-3 rounded-xl border text-sm ${levelBgColors[level]} ${levelTextColors[level]} font-medium`}
|
||||||
|
>
|
||||||
|
<span>{levelLabels[level]}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
// ── Avatar ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
interface AvatarProps {
|
||||||
|
name: string;
|
||||||
|
avatarUrl?: string;
|
||||||
|
size?: 'sm' | 'md' | 'lg';
|
||||||
|
}
|
||||||
|
|
||||||
|
const sizeMap = {
|
||||||
|
sm: 'w-8 h-8 text-xs',
|
||||||
|
md: 'w-10 h-10 text-sm',
|
||||||
|
lg: 'w-14 h-14 text-lg',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const Avatar: React.FC<AvatarProps> = ({
|
||||||
|
name,
|
||||||
|
avatarUrl,
|
||||||
|
size = 'md',
|
||||||
|
}) => {
|
||||||
|
const initials = name
|
||||||
|
.split(' ')
|
||||||
|
.map((n) => n[0])
|
||||||
|
.join('')
|
||||||
|
.slice(0, 2)
|
||||||
|
.toUpperCase();
|
||||||
|
|
||||||
|
if (avatarUrl) {
|
||||||
|
return (
|
||||||
|
<img
|
||||||
|
src={avatarUrl}
|
||||||
|
alt={name}
|
||||||
|
className={`${sizeMap[size]} rounded-full object-cover ring-2 ring-white`}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`${sizeMap[size]} rounded-full bg-gradient-to-br from-violet-500 to-indigo-600 flex items-center justify-center text-white font-bold ring-2 ring-white`}
|
||||||
|
>
|
||||||
|
{initials}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── SkeletonLoader ────────────────────────────────────────────
|
||||||
|
|
||||||
|
export const SkeletonLoader: React.FC = () => (
|
||||||
|
<div className="animate-pulse space-y-4">
|
||||||
|
{/* Header skeleton */}
|
||||||
|
<div className="bg-white rounded-2xl p-6 border border-slate-100">
|
||||||
|
<div className="flex items-center gap-4 mb-4">
|
||||||
|
<div className="w-14 h-14 rounded-full bg-slate-200" />
|
||||||
|
<div className="space-y-2 flex-1">
|
||||||
|
<div className="h-5 bg-slate-200 rounded w-48" />
|
||||||
|
<div className="h-4 bg-slate-100 rounded w-36" />
|
||||||
|
</div>
|
||||||
|
<div className="h-6 w-24 bg-slate-200 rounded-full" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/* Cards skeleton */}
|
||||||
|
{[1, 2, 3].map((i) => (
|
||||||
|
<div key={i} className="bg-white rounded-2xl border border-slate-100">
|
||||||
|
<div className="px-6 py-4 border-b border-slate-50">
|
||||||
|
<div className="h-4 bg-slate-200 rounded w-32" />
|
||||||
|
</div>
|
||||||
|
<div className="px-6 py-4 space-y-3">
|
||||||
|
{[1, 2, 3].map((j) => (
|
||||||
|
<div key={j} className="h-4 bg-slate-100 rounded w-full" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
// ── ErrorState ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
interface ErrorStateProps {
|
||||||
|
message: string;
|
||||||
|
onRetry?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ErrorState: React.FC<ErrorStateProps> = ({ message, onRetry }) => (
|
||||||
|
<div className="flex flex-col items-center justify-center py-20 text-center space-y-4">
|
||||||
|
<div className="w-16 h-16 rounded-full bg-red-50 flex items-center justify-center">
|
||||||
|
<svg
|
||||||
|
className="w-8 h-8 text-red-400"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={1.5}
|
||||||
|
d="M12 9v4m0 4h.01M10.29 3.86L1.82 18a2 2 0 001.71 3h16.94a2 2 0 001.71-3L13.71 3.86a2 2 0 00-3.42 0z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="font-semibold text-slate-700">Failed to load check</p>
|
||||||
|
<p className="text-sm text-slate-500 mt-1">{message}</p>
|
||||||
|
</div>
|
||||||
|
{onRetry && (
|
||||||
|
<button
|
||||||
|
onClick={onRetry}
|
||||||
|
className="px-5 py-2 bg-slate-800 text-white text-sm font-medium rounded-xl hover:bg-slate-700 transition-colors"
|
||||||
|
>
|
||||||
|
Try again
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
// ── FileTypeBadge ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
interface FileTypeBadgeProps {
|
||||||
|
extension: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const extColors: Record<string, string> = {
|
||||||
|
PDF: 'bg-red-100 text-red-700',
|
||||||
|
DOCX: 'bg-blue-100 text-blue-700',
|
||||||
|
DOC: 'bg-blue-100 text-blue-700',
|
||||||
|
TXT: 'bg-slate-100 text-slate-700',
|
||||||
|
ODT: 'bg-green-100 text-green-700',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const FileTypeBadge: React.FC<FileTypeBadgeProps> = ({ extension }) => (
|
||||||
|
<span
|
||||||
|
className={`inline-flex items-center px-2 py-0.5 rounded text-xs font-bold tracking-wide ${extColors[extension] ?? 'bg-slate-100 text-slate-600'}`}
|
||||||
|
>
|
||||||
|
{extension}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
192
src/widgets/detail/lib/api.ts
Normal file
192
src/widgets/detail/lib/api.ts
Normal file
@@ -0,0 +1,192 @@
|
|||||||
|
// ─────────────────────────────────────────────────────────────
|
||||||
|
// API / Business Logic Layer
|
||||||
|
// ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
import { PlagiarismCheck, SimilarityLevel } from './types';
|
||||||
|
|
||||||
|
// ── Mock data ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const MOCK_CHECKS: Record<string, PlagiarismCheck> = {
|
||||||
|
'chk-001': {
|
||||||
|
id: 'chk-001',
|
||||||
|
sender: {
|
||||||
|
id: 'usr-42',
|
||||||
|
name: 'Amir Tashkentov',
|
||||||
|
email: 'amir.t@university.uz',
|
||||||
|
avatarUrl: undefined,
|
||||||
|
},
|
||||||
|
fileName: 'research_paper_final_v3.pdf',
|
||||||
|
fileSize: 2_457_600,
|
||||||
|
fileType: 'application/pdf',
|
||||||
|
submittedAt: '2025-03-28T09:14:00Z',
|
||||||
|
paymentAmount: 12.5,
|
||||||
|
currency: 'USD',
|
||||||
|
status: 'completed',
|
||||||
|
result: {
|
||||||
|
overallSimilarity: 18,
|
||||||
|
similarityLevel: 'low',
|
||||||
|
checkedWords: 8_432,
|
||||||
|
matchedWords: 1_518,
|
||||||
|
processedAt: '2025-03-28T09:22:37Z',
|
||||||
|
sources: [
|
||||||
|
{
|
||||||
|
url: 'https://journals.example.com/paper/2023-ai-ethics',
|
||||||
|
title: 'Ethical Considerations in Modern AI Systems',
|
||||||
|
matchPercentage: 7,
|
||||||
|
matchedWords: 590,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
url: 'https://arxiv.org/abs/2301.00001',
|
||||||
|
title: 'Large Language Models: A Survey',
|
||||||
|
matchPercentage: 6,
|
||||||
|
matchedWords: 506,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
url: 'https://wikipedia.org/wiki/Natural_language_processing',
|
||||||
|
title: 'Natural Language Processing — Wikipedia',
|
||||||
|
matchPercentage: 5,
|
||||||
|
matchedWords: 422,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
certificate: {
|
||||||
|
id: 'cert-8821',
|
||||||
|
issuedAt: '2025-03-28T09:23:00Z',
|
||||||
|
expiresAt: '2026-03-28T09:23:00Z',
|
||||||
|
verificationCode: 'PLGR-8821-XKTZ-2025',
|
||||||
|
issuerName: 'PlagCheck Authority',
|
||||||
|
downloadUrl: '#',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
'chk-002': {
|
||||||
|
id: 'chk-002',
|
||||||
|
sender: {
|
||||||
|
id: 'usr-17',
|
||||||
|
name: 'Dilnoza Yusupova',
|
||||||
|
email: 'd.yusupova@edu.uz',
|
||||||
|
avatarUrl: undefined,
|
||||||
|
},
|
||||||
|
fileName: 'thesis_chapter_2_methodology.docx',
|
||||||
|
fileSize: 845_000,
|
||||||
|
fileType:
|
||||||
|
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
||||||
|
submittedAt: '2025-03-29T14:05:00Z',
|
||||||
|
paymentAmount: 8.0,
|
||||||
|
currency: 'USD',
|
||||||
|
status: 'completed',
|
||||||
|
result: {
|
||||||
|
overallSimilarity: 63,
|
||||||
|
similarityLevel: 'high',
|
||||||
|
checkedWords: 4_210,
|
||||||
|
matchedWords: 2_652,
|
||||||
|
processedAt: '2025-03-29T14:11:20Z',
|
||||||
|
sources: [
|
||||||
|
{
|
||||||
|
url: 'https://journals.example.com/methodology-guide',
|
||||||
|
title: 'Qualitative Research Methodology Guide',
|
||||||
|
matchPercentage: 38,
|
||||||
|
matchedWords: 1_600,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
url: 'https://scholar.example.com/thesis-2022',
|
||||||
|
title: "2022 Master's Thesis — UzSU",
|
||||||
|
matchPercentage: 25,
|
||||||
|
matchedWords: 1_052,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
certificate: undefined,
|
||||||
|
},
|
||||||
|
|
||||||
|
'chk-003': {
|
||||||
|
id: 'chk-003',
|
||||||
|
sender: {
|
||||||
|
id: 'usr-89',
|
||||||
|
name: 'Bobur Mirzayev',
|
||||||
|
email: 'bobur.m@research.uz',
|
||||||
|
avatarUrl: undefined,
|
||||||
|
},
|
||||||
|
fileName: 'conference_abstract.txt',
|
||||||
|
fileSize: 12_800,
|
||||||
|
fileType: 'text/plain',
|
||||||
|
submittedAt: '2025-03-30T08:30:00Z',
|
||||||
|
paymentAmount: 3.0,
|
||||||
|
currency: 'USD',
|
||||||
|
status: 'processing',
|
||||||
|
result: undefined,
|
||||||
|
certificate: undefined,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── API functions ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** Simulates a network delay */
|
||||||
|
const delay = (ms: number) =>
|
||||||
|
new Promise<void>((resolve) => setTimeout(resolve, ms));
|
||||||
|
|
||||||
|
/** Fetch a single plagiarism check by ID */
|
||||||
|
export async function fetchPlagiarismCheck(
|
||||||
|
id: string,
|
||||||
|
): Promise<PlagiarismCheck> {
|
||||||
|
await delay(800);
|
||||||
|
const check = MOCK_CHECKS[id];
|
||||||
|
if (!check) throw new Error(`Check with id "${id}" not found.`);
|
||||||
|
return structuredClone(check);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Fetch all checks (list view) */
|
||||||
|
export async function fetchAllChecks(): Promise<PlagiarismCheck[]> {
|
||||||
|
await delay(600);
|
||||||
|
return Object.values(MOCK_CHECKS).map((c) => structuredClone(c));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Pure helpers (business logic) ────────────────────────────
|
||||||
|
|
||||||
|
export function formatFileSize(bytes: number): string {
|
||||||
|
if (bytes < 1024) return `${bytes} B`;
|
||||||
|
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||||
|
return `${(bytes / (1024 * 1024)).toFixed(2)} MB`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatDate(iso: string): string {
|
||||||
|
return new Intl.DateTimeFormat('en-GB', {
|
||||||
|
day: '2-digit',
|
||||||
|
month: 'short',
|
||||||
|
year: 'numeric',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
}).format(new Date(iso));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatCurrency(amount: number, currency: string): string {
|
||||||
|
return new Intl.NumberFormat('en-US', {
|
||||||
|
style: 'currency',
|
||||||
|
currency,
|
||||||
|
minimumFractionDigits: 2,
|
||||||
|
}).format(amount);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getSimilarityColor(level: SimilarityLevel): string {
|
||||||
|
return (
|
||||||
|
{
|
||||||
|
low: '#22c55e',
|
||||||
|
medium: '#f59e0b',
|
||||||
|
high: '#ef4444',
|
||||||
|
}[level] ?? '#6b7280'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getSimilarityLabel(level: SimilarityLevel): string {
|
||||||
|
return (
|
||||||
|
{
|
||||||
|
low: 'Low Similarity',
|
||||||
|
medium: 'Medium Similarity',
|
||||||
|
high: 'High Similarity',
|
||||||
|
}[level] ?? 'Unknown'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getFileExtension(fileName: string): string {
|
||||||
|
return fileName.split('.').pop()?.toUpperCase() ?? 'FILE';
|
||||||
|
}
|
||||||
53
src/widgets/detail/lib/types.ts
Normal file
53
src/widgets/detail/lib/types.ts
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
// ─────────────────────────────────────────────────────────────
|
||||||
|
// Domain Types — Plagiarism Check
|
||||||
|
// ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export type CheckStatus = 'pending' | 'processing' | 'completed' | 'failed';
|
||||||
|
|
||||||
|
export type SimilarityLevel = 'low' | 'medium' | 'high';
|
||||||
|
|
||||||
|
export interface Sender {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
email: string;
|
||||||
|
avatarUrl?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Certificate {
|
||||||
|
id: string;
|
||||||
|
issuedAt: string; // ISO date string
|
||||||
|
expiresAt: string; // ISO date string
|
||||||
|
verificationCode: string;
|
||||||
|
issuerName: string;
|
||||||
|
downloadUrl: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SimilaritySource {
|
||||||
|
url: string;
|
||||||
|
title: string;
|
||||||
|
matchPercentage: number;
|
||||||
|
matchedWords: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PlagiarismResult {
|
||||||
|
overallSimilarity: number; // 0–100
|
||||||
|
similarityLevel: SimilarityLevel;
|
||||||
|
checkedWords: number;
|
||||||
|
matchedWords: number;
|
||||||
|
sources: SimilaritySource[];
|
||||||
|
processedAt: string; // ISO date string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PlagiarismCheck {
|
||||||
|
id: string;
|
||||||
|
sender: Sender;
|
||||||
|
fileName: string;
|
||||||
|
fileSize: number; // bytes
|
||||||
|
fileType: string;
|
||||||
|
submittedAt: string; // ISO date string
|
||||||
|
paymentAmount: number;
|
||||||
|
currency: string;
|
||||||
|
status: CheckStatus;
|
||||||
|
result?: PlagiarismResult;
|
||||||
|
certificate?: Certificate;
|
||||||
|
}
|
||||||
46
src/widgets/detail/lib/useDetail.ts
Normal file
46
src/widgets/detail/lib/useDetail.ts
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────
|
||||||
|
// State Management — usePlagiarismDetail hook
|
||||||
|
// ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
|
import { PlagiarismCheck } from './types';
|
||||||
|
import { fetchPlagiarismCheck } from './api';
|
||||||
|
|
||||||
|
export type LoadingState = 'idle' | 'loading' | 'success' | 'error';
|
||||||
|
|
||||||
|
export interface UsePlagiarismDetailReturn {
|
||||||
|
check: PlagiarismCheck | null;
|
||||||
|
loadingState: LoadingState;
|
||||||
|
error: string | null;
|
||||||
|
reload: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function usePlagiarismDetail(
|
||||||
|
checkId: string,
|
||||||
|
): UsePlagiarismDetailReturn {
|
||||||
|
const [check, setCheck] = useState<PlagiarismCheck | null>(null);
|
||||||
|
const [loadingState, setLoadingState] = useState<LoadingState>('idle');
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
if (!checkId) return;
|
||||||
|
setLoadingState('loading');
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const data = await fetchPlagiarismCheck(checkId);
|
||||||
|
setCheck(data);
|
||||||
|
setLoadingState('success');
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : 'Unknown error occurred.');
|
||||||
|
setLoadingState('error');
|
||||||
|
}
|
||||||
|
}, [checkId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
load();
|
||||||
|
}, [load]);
|
||||||
|
|
||||||
|
return { check, loadingState, error, reload: load };
|
||||||
|
}
|
||||||
503
src/widgets/detail/ui/detailPage.tsx
Normal file
503
src/widgets/detail/ui/detailPage.tsx
Normal file
@@ -0,0 +1,503 @@
|
|||||||
|
// ─────────────────────────────────────────────────────────────
|
||||||
|
// PlagiarismDetailPage — Main Feature Component
|
||||||
|
// ─────────────────────────────────────────────────────────────
|
||||||
|
'use client';
|
||||||
|
import React from 'react';
|
||||||
|
import { PlagiarismCheck } from '../lib/types';
|
||||||
|
import {
|
||||||
|
getFileExtension,
|
||||||
|
formatDate,
|
||||||
|
formatFileSize,
|
||||||
|
formatCurrency,
|
||||||
|
} from '../lib/api';
|
||||||
|
import { usePlagiarismDetail } from '../lib/useDetail';
|
||||||
|
import {
|
||||||
|
FileTypeBadge,
|
||||||
|
InfoRow,
|
||||||
|
SectionCard,
|
||||||
|
StatusBadge,
|
||||||
|
SimilarityMeter,
|
||||||
|
Avatar,
|
||||||
|
SkeletonLoader,
|
||||||
|
ErrorState,
|
||||||
|
} from '..';
|
||||||
|
|
||||||
|
// ── Icons (inline SVG for zero-dep) ──────────────────────────
|
||||||
|
|
||||||
|
const IconUser = () => (
|
||||||
|
<svg
|
||||||
|
className="w-4 h-4"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={1.8}
|
||||||
|
d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
const IconFile = () => (
|
||||||
|
<svg
|
||||||
|
className="w-4 h-4"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={1.8}
|
||||||
|
d="M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
const IconCalendar = () => (
|
||||||
|
<svg
|
||||||
|
className="w-4 h-4"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={1.8}
|
||||||
|
d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
const IconPayment = () => (
|
||||||
|
<svg
|
||||||
|
className="w-4 h-4"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={1.8}
|
||||||
|
d="M3 10h18M7 15h1m4 0h1m-7 4h12a3 3 0 003-3V8a3 3 0 00-3-3H6a3 3 0 00-3 3v8a3 3 0 003 3z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
const IconShield = () => (
|
||||||
|
<svg
|
||||||
|
className="w-4 h-4"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={1.8}
|
||||||
|
d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
const IconCert = () => (
|
||||||
|
<svg
|
||||||
|
className="w-4 h-4"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={1.8}
|
||||||
|
d="M9 12l2 2 4-4M7.835 4.697a3.42 3.42 0 001.946-.806 3.42 3.42 0 014.438 0 3.42 3.42 0 001.946.806 3.42 3.42 0 013.138 3.138 3.42 3.42 0 00.806 1.946 3.42 3.42 0 010 4.438 3.42 3.42 0 00-.806 1.946 3.42 3.42 0 01-3.138 3.138 3.42 3.42 0 00-1.946.806 3.42 3.42 0 01-4.438 0 3.42 3.42 0 00-1.946-.806 3.42 3.42 0 01-3.138-3.138 3.42 3.42 0 00-.806-1.946 3.42 3.42 0 010-4.438 3.42 3.42 0 00.806-1.946 3.42 3.42 0 013.138-3.138z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
const IconDownload = () => (
|
||||||
|
<svg
|
||||||
|
className="w-4 h-4"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={1.8}
|
||||||
|
d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
const IconBack = () => (
|
||||||
|
<svg
|
||||||
|
className="w-4 h-4"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M10 19l-7-7m0 0l7-7m-7 7h18"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
const IconSource = () => (
|
||||||
|
<svg
|
||||||
|
className="w-3.5 h-3.5"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
// ── Sub-components ────────────────────────────────────────────
|
||||||
|
|
||||||
|
interface CheckDetailViewProps {
|
||||||
|
check: PlagiarismCheck;
|
||||||
|
}
|
||||||
|
|
||||||
|
const CheckHeader: React.FC<CheckDetailViewProps> = ({ check }) => (
|
||||||
|
<div className="bg-white rounded-2xl shadow-sm border border-slate-100 p-6">
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-4">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<Avatar
|
||||||
|
name={check.sender.name}
|
||||||
|
avatarUrl={check.sender.avatarUrl}
|
||||||
|
size="lg"
|
||||||
|
/>
|
||||||
|
<div>
|
||||||
|
<h1 className="text-xl font-bold text-slate-900">
|
||||||
|
{check.sender.name}
|
||||||
|
</h1>
|
||||||
|
<p className="text-sm text-slate-500">{check.sender.email}</p>
|
||||||
|
<p className="text-xs text-slate-400 mt-1 font-mono">
|
||||||
|
ID: {check.id}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<StatusBadge status={check.status} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
const SubmissionInfoCard: React.FC<CheckDetailViewProps> = ({ check }) => (
|
||||||
|
<SectionCard title="Submission Details" icon={<IconFile />} accent="blue">
|
||||||
|
<InfoRow
|
||||||
|
label="Sender"
|
||||||
|
icon={<IconUser />}
|
||||||
|
value={
|
||||||
|
<span className="flex items-center gap-2 justify-end">
|
||||||
|
<Avatar name={check.sender.name} size="sm" />
|
||||||
|
{check.sender.name}
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<InfoRow
|
||||||
|
label="File Name"
|
||||||
|
icon={<IconFile />}
|
||||||
|
value={
|
||||||
|
<span className="flex items-center gap-2 justify-end flex-wrap">
|
||||||
|
<FileTypeBadge extension={getFileExtension(check.fileName)} />
|
||||||
|
<span className="font-mono text-xs text-slate-700 break-all max-w-[240px] text-right">
|
||||||
|
{check.fileName}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<InfoRow
|
||||||
|
label="File Size"
|
||||||
|
value={
|
||||||
|
<span className="text-slate-600">{formatFileSize(check.fileSize)}</span>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<InfoRow
|
||||||
|
label="Submitted"
|
||||||
|
icon={<IconCalendar />}
|
||||||
|
value={formatDate(check.submittedAt)}
|
||||||
|
/>
|
||||||
|
<InfoRow
|
||||||
|
label="Payment"
|
||||||
|
icon={<IconPayment />}
|
||||||
|
value={
|
||||||
|
<span className="text-emerald-600 font-bold">
|
||||||
|
{formatCurrency(check.paymentAmount, check.currency)}
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</SectionCard>
|
||||||
|
);
|
||||||
|
|
||||||
|
const ResultCard: React.FC<CheckDetailViewProps> = ({ check }) => {
|
||||||
|
if (check.status === 'processing' || check.status === 'pending') {
|
||||||
|
return (
|
||||||
|
<SectionCard title="Result" icon={<IconShield />} accent="violet">
|
||||||
|
<div className="flex flex-col items-center justify-center py-10 text-center space-y-3">
|
||||||
|
<div className="w-12 h-12 rounded-full bg-blue-50 flex items-center justify-center">
|
||||||
|
<svg
|
||||||
|
className="w-6 h-6 text-blue-400 animate-spin"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<circle
|
||||||
|
className="opacity-25"
|
||||||
|
cx="12"
|
||||||
|
cy="12"
|
||||||
|
r="10"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="4"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
className="opacity-75"
|
||||||
|
fill="currentColor"
|
||||||
|
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm font-semibold text-slate-600">
|
||||||
|
Analysis in progress
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-slate-400">
|
||||||
|
Results will appear once processing is complete.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</SectionCard>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!check.result) {
|
||||||
|
return (
|
||||||
|
<SectionCard title="Result" icon={<IconShield />} accent="violet">
|
||||||
|
<p className="text-sm text-slate-500 py-4">No result available.</p>
|
||||||
|
</SectionCard>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { result } = check;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SectionCard
|
||||||
|
title="Plagiarism Result"
|
||||||
|
icon={<IconShield />}
|
||||||
|
accent={
|
||||||
|
result.similarityLevel === 'low'
|
||||||
|
? 'green'
|
||||||
|
: result.similarityLevel === 'high'
|
||||||
|
? 'red'
|
||||||
|
: 'amber'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Meter */}
|
||||||
|
<SimilarityMeter
|
||||||
|
percentage={result.overallSimilarity}
|
||||||
|
level={result.similarityLevel}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Stats grid */}
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div className="bg-slate-50 rounded-xl p-4 text-center">
|
||||||
|
<p className="text-2xl font-bold text-slate-800 tabular-nums">
|
||||||
|
{result.checkedWords.toLocaleString()}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-slate-500 mt-1">Words Checked</p>
|
||||||
|
</div>
|
||||||
|
<div className="bg-slate-50 rounded-xl p-4 text-center">
|
||||||
|
<p className="text-2xl font-bold text-slate-800 tabular-nums">
|
||||||
|
{result.matchedWords.toLocaleString()}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-slate-500 mt-1">Words Matched</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Sources */}
|
||||||
|
{result.sources.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<h3 className="text-xs font-semibold text-slate-500 uppercase tracking-widest mb-3">
|
||||||
|
Matched Sources
|
||||||
|
</h3>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{result.sources.map((src, i) => (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
className="flex items-center gap-3 p-3 bg-slate-50 rounded-xl hover:bg-slate-100 transition-colors group"
|
||||||
|
>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="text-sm font-medium text-slate-700 truncate">
|
||||||
|
{src.title}
|
||||||
|
</p>
|
||||||
|
<a
|
||||||
|
href={src.url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="text-xs text-slate-400 hover:text-blue-500 flex items-center gap-1 mt-0.5 transition-colors"
|
||||||
|
>
|
||||||
|
<IconSource />
|
||||||
|
<span className="truncate">{src.url}</span>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<div className="text-right shrink-0">
|
||||||
|
<span
|
||||||
|
className={`text-sm font-bold ${src.matchPercentage >= 30 ? 'text-red-600' : src.matchPercentage >= 15 ? 'text-amber-600' : 'text-emerald-600'}`}
|
||||||
|
>
|
||||||
|
{src.matchPercentage}%
|
||||||
|
</span>
|
||||||
|
<p className="text-xs text-slate-400">
|
||||||
|
{src.matchedWords.toLocaleString()} words
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<InfoRow
|
||||||
|
label="Processed At"
|
||||||
|
icon={<IconCalendar />}
|
||||||
|
value={formatDate(result.processedAt)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</SectionCard>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const CertificateCard: React.FC<CheckDetailViewProps> = ({ check }) => {
|
||||||
|
if (!check.certificate) {
|
||||||
|
return (
|
||||||
|
<SectionCard title="Certificate" icon={<IconCert />} accent="violet">
|
||||||
|
<div className="flex flex-col items-center justify-center py-8 text-center space-y-2">
|
||||||
|
<div className="w-10 h-10 rounded-full bg-slate-100 flex items-center justify-center">
|
||||||
|
<IconCert />
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-slate-500">
|
||||||
|
No certificate issued for this check.
|
||||||
|
</p>
|
||||||
|
{check.result?.similarityLevel === 'high' && (
|
||||||
|
<p className="text-xs text-red-500">
|
||||||
|
Certificates are not issued for high-similarity results.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</SectionCard>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { certificate } = check;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SectionCard title="Certificate" icon={<IconCert />} accent="green">
|
||||||
|
{/* Certificate visual */}
|
||||||
|
<div className="relative rounded-xl border-2 border-dashed border-emerald-200 bg-gradient-to-br from-emerald-50 to-white p-5 mb-4 overflow-hidden">
|
||||||
|
<div className="absolute top-2 right-2 opacity-10">
|
||||||
|
<svg
|
||||||
|
className="w-20 h-20 text-emerald-600"
|
||||||
|
fill="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-2 14.5v-9l6 4.5-6 4.5z" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs font-semibold text-emerald-700 uppercase tracking-widest mb-1">
|
||||||
|
{certificate.issuerName}
|
||||||
|
</p>
|
||||||
|
<p className="text-lg font-bold text-slate-800 font-mono tracking-wide">
|
||||||
|
{certificate.verificationCode}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-slate-500 mt-1">
|
||||||
|
Certificate ID: {certificate.id}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<InfoRow
|
||||||
|
label="Issued"
|
||||||
|
icon={<IconCalendar />}
|
||||||
|
value={formatDate(certificate.issuedAt)}
|
||||||
|
/>
|
||||||
|
<InfoRow
|
||||||
|
label="Expires"
|
||||||
|
icon={<IconCalendar />}
|
||||||
|
value={formatDate(certificate.expiresAt)}
|
||||||
|
/>
|
||||||
|
<InfoRow label="Issuer" value={certificate.issuerName} />
|
||||||
|
|
||||||
|
<div className="mt-4">
|
||||||
|
<a
|
||||||
|
href={certificate.downloadUrl}
|
||||||
|
className="flex items-center justify-center gap-2 w-full py-2.5 px-4 bg-emerald-600 hover:bg-emerald-700 text-white text-sm font-semibold rounded-xl transition-colors"
|
||||||
|
>
|
||||||
|
<IconDownload />
|
||||||
|
Download Certificate
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</SectionCard>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Detail View (assembled) ───────────────────────────────────
|
||||||
|
|
||||||
|
const CheckDetailView: React.FC<CheckDetailViewProps> = ({ check }) => (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<CheckHeader check={check} />
|
||||||
|
<SubmissionInfoCard check={check} />
|
||||||
|
<ResultCard check={check} />
|
||||||
|
<CertificateCard check={check} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
// ── Page ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
interface PlagiarismDetailPageProps {
|
||||||
|
checkId: string;
|
||||||
|
onBack?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const PlagiarismDetailPage: React.FC<PlagiarismDetailPageProps> = ({
|
||||||
|
checkId,
|
||||||
|
onBack,
|
||||||
|
}) => {
|
||||||
|
const { check, loadingState, error, reload } = usePlagiarismDetail(checkId);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-slate-50 font-sans">
|
||||||
|
{/* Top nav */}
|
||||||
|
<header className="sticky top-0 z-10 bg-white border-b border-slate-100 px-4 py-3 flex items-center gap-3 shadow-sm">
|
||||||
|
{onBack && (
|
||||||
|
<button
|
||||||
|
onClick={onBack}
|
||||||
|
className="p-2 rounded-xl hover:bg-slate-100 text-slate-500 hover:text-slate-800 transition-colors"
|
||||||
|
aria-label="Go back"
|
||||||
|
>
|
||||||
|
<IconBack />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<div>
|
||||||
|
<h1 className="font-bold text-slate-900 text-sm leading-tight">
|
||||||
|
Plagiarism Check Detail
|
||||||
|
</h1>
|
||||||
|
<p className="text-xs text-slate-400 font-mono">{checkId}</p>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{/* Body */}
|
||||||
|
<main className="max-w-2xl mx-auto px-4 py-6">
|
||||||
|
{loadingState === 'loading' && <SkeletonLoader />}
|
||||||
|
{loadingState === 'error' && (
|
||||||
|
<ErrorState message={error ?? 'Unknown error'} onRetry={reload} />
|
||||||
|
)}
|
||||||
|
{loadingState === 'success' && check && (
|
||||||
|
<CheckDetailView check={check} />
|
||||||
|
)}
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -10,7 +10,7 @@ import {
|
|||||||
StatusBanner,
|
StatusBanner,
|
||||||
} from './Plagiraismui';
|
} from './Plagiraismui';
|
||||||
import { usePlagiarismForm } from '../lib/usePlagiraism';
|
import { usePlagiarismForm } from '../lib/usePlagiraism';
|
||||||
import { PaymentModal } from '@/widgets/history/ui/Paymentmodal';
|
import { PaymentModal } from '@/widgets/paymentModal/ui/Paymentmodal';
|
||||||
|
|
||||||
// ─── UserIcon (inline) ───────────────────────────────────────────────────────
|
// ─── UserIcon (inline) ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -53,7 +53,7 @@ export function PlagiarismCheckForm() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className=" bg-[#f4f5ffec] flex items-center justify-center p-4 font-['DM_Sans',sans-serif]">
|
<div className=" flex items-center justify-center p-4 font-['DM_Sans',sans-serif]">
|
||||||
<div className="w-full max-w-4xl">
|
<div className="w-full max-w-4xl">
|
||||||
{/* ── Header ────────────────────────────────────────────────────── */}
|
{/* ── Header ────────────────────────────────────────────────────── */}
|
||||||
<div className="mb-8">
|
<div className="mb-8">
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import { PRODUCT_INFO } from '@/shared/constants/data';
|
|
||||||
const Footer = () => {
|
const Footer = () => {
|
||||||
const shortLinks = [
|
const shortLinks = [
|
||||||
{ name: 'About', href: '/about' },
|
{ name: 'About', href: '/about' },
|
||||||
@@ -29,7 +28,7 @@ const Footer = () => {
|
|||||||
</p>
|
</p>
|
||||||
<ul className="flex justify-center gap-4 lg:justify-start">
|
<ul className="flex justify-center gap-4 lg:justify-start">
|
||||||
<li className="hover:text-primary">
|
<li className="hover:text-primary">
|
||||||
<a href={PRODUCT_INFO.terms_of_use}>Terms and Conditions</a>
|
<a href="#">Terms and Conditions</a>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
45
src/widgets/history/index.ts
Normal file
45
src/widgets/history/index.ts
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
// Pages
|
||||||
|
export { HistoryPage } from './ui/historyPage';
|
||||||
|
|
||||||
|
// Components
|
||||||
|
export { HistoryTable } from './ui/historyTable';
|
||||||
|
export { HistoryTableRow } from './ui/historyTableRow';
|
||||||
|
export { ResultBadge } from './ui/resultBadge';
|
||||||
|
export { Pagination } from './ui/pagination';
|
||||||
|
export { EmptyState, SkeletonRow, ErrorState } from './ui/tableStates';
|
||||||
|
|
||||||
|
// Hook
|
||||||
|
export { useHistory } from './lib/useHistory';
|
||||||
|
|
||||||
|
// Utils
|
||||||
|
export {
|
||||||
|
fetchPlagiarismHistory,
|
||||||
|
fetchPlagiarismDetail,
|
||||||
|
formatDate,
|
||||||
|
formatAmount,
|
||||||
|
truncateFileName,
|
||||||
|
} from './lib/utils';
|
||||||
|
|
||||||
|
// Types
|
||||||
|
export type {
|
||||||
|
PlagiarismCheck,
|
||||||
|
PlagiarismCheckDetail,
|
||||||
|
CheckResult,
|
||||||
|
HistoryApiResponse,
|
||||||
|
HistoryTableProps,
|
||||||
|
HistoryTableRowProps,
|
||||||
|
ResultBadgeProps,
|
||||||
|
FetchStatus,
|
||||||
|
HistoryState,
|
||||||
|
} from './lib/types';
|
||||||
|
|
||||||
|
// Constants
|
||||||
|
export {
|
||||||
|
TABLE_COLUMNS,
|
||||||
|
RESULT_CONFIG,
|
||||||
|
DEFAULT_PAGE_SIZE,
|
||||||
|
API_ENDPOINTS,
|
||||||
|
} from './lib/constants';
|
||||||
|
|
||||||
|
// Mock (for development/testing)
|
||||||
|
export { DEFAULT_HISTORY_ITEMS } from './lib/mock';
|
||||||
47
src/widgets/history/lib/constants.ts
Normal file
47
src/widgets/history/lib/constants.ts
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
// ─── Table Columns ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
import { CheckResult } from './types';
|
||||||
|
|
||||||
|
export const TABLE_COLUMNS = [
|
||||||
|
{ key: 'senderFullName', label: 'Sender' },
|
||||||
|
{ key: 'fileName', label: 'File' },
|
||||||
|
{ key: 'date', label: 'Date' },
|
||||||
|
{ key: 'paymentAmount', label: 'Amount' },
|
||||||
|
{ key: 'result', label: 'Result' },
|
||||||
|
{ key: 'actions', label: '' },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
// ─── Result Labels & Styles ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export const RESULT_CONFIG: Record<
|
||||||
|
CheckResult,
|
||||||
|
{ label: string; className: string }
|
||||||
|
> = {
|
||||||
|
clean: {
|
||||||
|
label: 'Clean',
|
||||||
|
className: 'bg-emerald-50 text-emerald-700 ring-1 ring-emerald-200',
|
||||||
|
},
|
||||||
|
plagiarism_found: {
|
||||||
|
label: 'Plagiarism Found',
|
||||||
|
className: 'bg-red-50 text-red-700 ring-1 ring-red-200',
|
||||||
|
},
|
||||||
|
pending: {
|
||||||
|
label: 'Pending',
|
||||||
|
className: 'bg-amber-50 text-amber-700 ring-1 ring-amber-200',
|
||||||
|
},
|
||||||
|
failed: {
|
||||||
|
label: 'Failed',
|
||||||
|
className: 'bg-slate-100 text-slate-500 ring-1 ring-slate-200',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// ─── Pagination ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export const DEFAULT_PAGE_SIZE = 10;
|
||||||
|
|
||||||
|
// ─── API ───────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export const API_ENDPOINTS = {
|
||||||
|
HISTORY: '/api/plagiarism/history',
|
||||||
|
DETAIL: (id: string) => `/api/plagiarism/${id}`,
|
||||||
|
} as const;
|
||||||
71
src/widgets/history/lib/mock.ts
Normal file
71
src/widgets/history/lib/mock.ts
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
import { PlagiarismCheck } from './types';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Default mock items used to preview the history table design.
|
||||||
|
* Replace with real API data in production via useHistory() hook.
|
||||||
|
*/
|
||||||
|
export const DEFAULT_HISTORY_ITEMS: PlagiarismCheck[] = [
|
||||||
|
{
|
||||||
|
id: '1',
|
||||||
|
senderFullName: 'Alijon Toshmatov',
|
||||||
|
fileName: 'thesis-final-v3.pdf',
|
||||||
|
date: '2024-03-15T10:30:00Z',
|
||||||
|
paymentAmount: 45000,
|
||||||
|
currency: 'UZS',
|
||||||
|
result: 'clean',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '2',
|
||||||
|
senderFullName: 'Malika Yusupova',
|
||||||
|
fileName: 'research-paper-2024.docx',
|
||||||
|
date: '2024-03-14T09:15:00Z',
|
||||||
|
paymentAmount: 60000,
|
||||||
|
currency: 'UZS',
|
||||||
|
result: 'plagiarism_found',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '3',
|
||||||
|
senderFullName: 'Bobur Rahimov',
|
||||||
|
fileName: 'coursework-economics.pdf',
|
||||||
|
date: '2024-03-13T14:45:00Z',
|
||||||
|
paymentAmount: 45000,
|
||||||
|
currency: 'UZS',
|
||||||
|
result: 'pending',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '4',
|
||||||
|
senderFullName: 'Zulfiya Nazarova',
|
||||||
|
fileName: 'dissertation-chapter1.pdf',
|
||||||
|
date: '2024-03-12T11:20:00Z',
|
||||||
|
paymentAmount: 60000,
|
||||||
|
currency: 'UZS',
|
||||||
|
result: 'clean',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '5',
|
||||||
|
senderFullName: 'Jasur Mirzayev',
|
||||||
|
fileName: 'lab-report-chemistry.docx',
|
||||||
|
date: '2024-03-11T16:10:00Z',
|
||||||
|
paymentAmount: 45000,
|
||||||
|
currency: 'UZS',
|
||||||
|
result: 'failed',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '6',
|
||||||
|
senderFullName: 'Nilufar Karimova',
|
||||||
|
fileName: 'bachelor-thesis-law.pdf',
|
||||||
|
date: '2024-03-10T08:55:00Z',
|
||||||
|
paymentAmount: 60000,
|
||||||
|
currency: 'UZS',
|
||||||
|
result: 'plagiarism_found',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '7',
|
||||||
|
senderFullName: 'Dilnoza Ergasheva',
|
||||||
|
fileName: 'essay-history-uzbekistan.pdf',
|
||||||
|
date: '2024-03-09T13:40:00Z',
|
||||||
|
paymentAmount: 45000,
|
||||||
|
currency: 'UZS',
|
||||||
|
result: 'clean',
|
||||||
|
},
|
||||||
|
];
|
||||||
@@ -1,48 +1,62 @@
|
|||||||
// ─── Domain Types ──────────────────────────────────────────────────────────────
|
// ─── Domain Types ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export interface ServicePricing {
|
export type CheckResult = 'clean' | 'plagiarism_found' | 'pending' | 'failed';
|
||||||
serviceFee: number;
|
|
||||||
certificateFee: number;
|
export interface PlagiarismCheck {
|
||||||
|
id: string;
|
||||||
|
senderFullName: string;
|
||||||
|
fileName: string;
|
||||||
|
date: string; // ISO 8601
|
||||||
|
paymentAmount: number;
|
||||||
currency: string;
|
currency: string;
|
||||||
|
result: CheckResult;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface OrderSummary {
|
export interface PlagiarismCheckDetail extends PlagiarismCheck {
|
||||||
hasCertificate: boolean;
|
topic: string;
|
||||||
pricing: ServicePricing;
|
plagiarismPercentage?: number;
|
||||||
|
reportUrl?: string;
|
||||||
|
withCertificate: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PaymePaymentRequest {
|
// ─── API Response Types ────────────────────────────────────────────────────────
|
||||||
amount: number; // in tiyin (1 UZS = 100 tiyin)
|
|
||||||
orderId: string;
|
|
||||||
description: string;
|
|
||||||
returnUrl: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface PaymePaymentResponse {
|
export interface HistoryApiResponse {
|
||||||
redirectUrl: string;
|
items: PlagiarismCheck[];
|
||||||
transactionId: string;
|
total: number;
|
||||||
|
page: number;
|
||||||
|
pageSize: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type PaymentStatus = 'idle' | 'loading' | 'success' | 'error';
|
|
||||||
|
|
||||||
// ─── Component Props ───────────────────────────────────────────────────────────
|
// ─── Component Props ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export interface PaymentModalProps {
|
export interface HistoryTableProps {
|
||||||
isOpen: boolean;
|
items: PlagiarismCheck[];
|
||||||
onClose: () => void;
|
|
||||||
hasCertificate: boolean;
|
|
||||||
onConfirmPayment: () => void;
|
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PriceSummaryProps {
|
export interface HistoryTableRowProps {
|
||||||
hasCertificate: boolean;
|
item: PlagiarismCheck;
|
||||||
pricing: ServicePricing;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PaymeButtonProps {
|
export interface ResultBadgeProps {
|
||||||
amount: number;
|
result: CheckResult;
|
||||||
orderId: string;
|
}
|
||||||
onSuccess?: (response: PaymePaymentResponse) => void;
|
|
||||||
onError?: (error: Error) => void;
|
export interface EmptyStateProps {
|
||||||
|
message?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SkeletonRowProps {
|
||||||
|
columns: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── State Types ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export type FetchStatus = 'idle' | 'loading' | 'success' | 'error';
|
||||||
|
|
||||||
|
export interface HistoryState {
|
||||||
|
items: PlagiarismCheck[];
|
||||||
|
status: FetchStatus;
|
||||||
|
error: string | null;
|
||||||
}
|
}
|
||||||
|
|||||||
53
src/widgets/history/lib/useHistory.ts
Normal file
53
src/widgets/history/lib/useHistory.ts
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
'use client';
|
||||||
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
|
import { DEFAULT_PAGE_SIZE } from './constants';
|
||||||
|
import { HistoryState } from './types';
|
||||||
|
import { DEFAULT_HISTORY_ITEMS } from './mock';
|
||||||
|
|
||||||
|
interface UseHistoryReturn extends HistoryState {
|
||||||
|
refetch: () => void;
|
||||||
|
currentPage: number;
|
||||||
|
totalPages: number;
|
||||||
|
goToPage: (page: number) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useHistory = (pageSize = DEFAULT_PAGE_SIZE): UseHistoryReturn => {
|
||||||
|
const [state, setState] = useState<HistoryState>({
|
||||||
|
items: DEFAULT_HISTORY_ITEMS,
|
||||||
|
status: 'idle',
|
||||||
|
error: null,
|
||||||
|
});
|
||||||
|
const [currentPage, setCurrentPage] = useState(1);
|
||||||
|
const [total, setTotal] = useState(0);
|
||||||
|
|
||||||
|
const loadHistory = useCallback(
|
||||||
|
async (page: number) => {
|
||||||
|
setState((prev) => ({ ...prev, status: 'loading', error: null }));
|
||||||
|
setTotal(0);
|
||||||
|
console.log(page);
|
||||||
|
},
|
||||||
|
[pageSize],
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadHistory(currentPage);
|
||||||
|
}, [currentPage, loadHistory]);
|
||||||
|
|
||||||
|
const goToPage = useCallback((page: number) => {
|
||||||
|
setCurrentPage(page);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const refetch = useCallback(() => {
|
||||||
|
loadHistory(currentPage);
|
||||||
|
}, [currentPage, loadHistory]);
|
||||||
|
|
||||||
|
const totalPages = Math.ceil(total / pageSize);
|
||||||
|
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
refetch,
|
||||||
|
currentPage,
|
||||||
|
totalPages,
|
||||||
|
goToPage,
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -1,71 +1,73 @@
|
|||||||
// ─── Pricing Utilities ─────────────────────────────────────────────────────────
|
// ─── API Functions ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
import { PAYME_CONFIG, PRICING } from './constant';
|
import { API_ENDPOINTS } from './constants';
|
||||||
import {
|
import { HistoryApiResponse, PlagiarismCheckDetail } from './types';
|
||||||
PaymePaymentRequest,
|
|
||||||
PaymePaymentResponse,
|
|
||||||
ServicePricing,
|
|
||||||
} from './types';
|
|
||||||
|
|
||||||
export const getPricing = (): ServicePricing => ({
|
|
||||||
serviceFee: PRICING.SERVICE_FEE,
|
|
||||||
certificateFee: PRICING.CERTIFICATE_FEE,
|
|
||||||
currency: PRICING.CURRENCY,
|
|
||||||
});
|
|
||||||
|
|
||||||
export const calculateTotal = (hasCertificate: boolean): number => {
|
|
||||||
const base = PRICING.SERVICE_FEE;
|
|
||||||
return hasCertificate ? base + PRICING.CERTIFICATE_FEE : base;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const toTiyin = (uzs: number): number => uzs * PRICING.TIYIN_MULTIPLIER;
|
|
||||||
|
|
||||||
export const formatPrice = (amount: number, currency: string): string =>
|
|
||||||
`${amount.toLocaleString('uz-UZ')} ${currency}`;
|
|
||||||
|
|
||||||
// ─── Order ID Generator ────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
export const generateOrderId = (): string => {
|
|
||||||
const timestamp = Date.now();
|
|
||||||
const random = Math.random().toString(36).slice(2, 8).toUpperCase();
|
|
||||||
return `ORDER-${timestamp}-${random}`;
|
|
||||||
};
|
|
||||||
|
|
||||||
// ─── Payme API ─────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Sends payment details to the backend, which creates a Payme transaction
|
* Fetches the paginated list of plagiarism checks for the current user.
|
||||||
* and returns a redirect URL to the Payme checkout page.
|
|
||||||
*/
|
*/
|
||||||
export const createPaymePayment = async (
|
export const fetchPlagiarismHistory = async (
|
||||||
request: PaymePaymentRequest,
|
page = 1,
|
||||||
): Promise<PaymePaymentResponse> => {
|
pageSize = 10,
|
||||||
const response = await fetch(PAYME_CONFIG.API_ENDPOINT, {
|
): Promise<HistoryApiResponse> => {
|
||||||
method: 'POST',
|
const url = new URL(API_ENDPOINTS.HISTORY, window.location.origin);
|
||||||
headers: { 'Content-Type': 'application/json' },
|
url.searchParams.set('page', String(page));
|
||||||
body: JSON.stringify({
|
url.searchParams.set('pageSize', String(pageSize));
|
||||||
amount: request.amount, // in tiyin
|
|
||||||
order_id: request.orderId,
|
const response = await fetch(url.toString());
|
||||||
description: request.description,
|
|
||||||
return_url: request.returnUrl,
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
const errorBody = await response.json().catch(() => ({}));
|
throw new Error(`Failed to load history (${response.status})`);
|
||||||
throw new Error(
|
|
||||||
(errorBody as { message?: string }).message ??
|
|
||||||
`Payment request failed with status ${response.status}`,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const data = (await response.json()) as PaymePaymentResponse;
|
return response.json() as Promise<HistoryApiResponse>;
|
||||||
return data;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Redirects the user to the Payme checkout page.
|
* Fetches the full detail for a single plagiarism check.
|
||||||
*/
|
*/
|
||||||
export const redirectToPayme = (redirectUrl: string): void => {
|
export const fetchPlagiarismDetail = async (
|
||||||
window.location.href = redirectUrl;
|
id: string,
|
||||||
|
): Promise<PlagiarismCheckDetail> => {
|
||||||
|
const response = await fetch(API_ENDPOINTS.DETAIL(id));
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Failed to load record (${response.status})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.json() as Promise<PlagiarismCheckDetail>;
|
||||||
|
};
|
||||||
|
|
||||||
|
// ─── Formatting Utilities ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Formats an ISO date string into a human-readable date.
|
||||||
|
* e.g. "2024-03-15T10:30:00Z" → "15 Mar 2024"
|
||||||
|
*/
|
||||||
|
export const formatDate = (iso: string): string => {
|
||||||
|
const date = new Date(iso);
|
||||||
|
if (isNaN(date.getTime())) return '—';
|
||||||
|
return date.toLocaleDateString('en-GB', {
|
||||||
|
day: '2-digit',
|
||||||
|
month: 'short',
|
||||||
|
year: 'numeric',
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Formats a payment amount with currency.
|
||||||
|
* e.g. (45000, "UZS") → "45,000 UZS"
|
||||||
|
*/
|
||||||
|
export const formatAmount = (amount: number, currency: string): string =>
|
||||||
|
`${amount.toLocaleString('uz-UZ')} ${currency}`;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Truncates a long filename for display.
|
||||||
|
* e.g. "my-very-long-thesis-document.pdf" → "my-very-long-th….pdf"
|
||||||
|
*/
|
||||||
|
export const truncateFileName = (name: string, maxLength = 28): string => {
|
||||||
|
if (name.length <= maxLength) return name;
|
||||||
|
const ext = name.includes('.') ? name.slice(name.lastIndexOf('.')) : '';
|
||||||
|
const base = name.slice(0, maxLength - ext.length - 1);
|
||||||
|
return `${base}…${ext}`;
|
||||||
};
|
};
|
||||||
|
|||||||
46
src/widgets/history/ui/historyPage.tsx
Normal file
46
src/widgets/history/ui/historyPage.tsx
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
'use client';
|
||||||
|
import React from 'react';
|
||||||
|
import { useHistory } from '../lib/useHistory';
|
||||||
|
import { HistoryTable } from './historyTable';
|
||||||
|
import { Pagination } from './pagination';
|
||||||
|
|
||||||
|
// ─── Page Header ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const PageHeader: React.FC = () => (
|
||||||
|
<div className="mb-6">
|
||||||
|
<h1 className="text-2xl font-semibold text-slate-900 tracking-tight">
|
||||||
|
Check History
|
||||||
|
</h1>
|
||||||
|
<p className="mt-1 text-sm text-slate-500">
|
||||||
|
All plagiarism checks submitted by you
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
// ─── HistoryPage ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export const HistoryPage = () => {
|
||||||
|
const { items, status, error, refetch, currentPage, totalPages, goToPage } =
|
||||||
|
useHistory();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className=" px-4 py-10 sm:px-6 lg:px-8">
|
||||||
|
<div className="mx-auto max-w-6xl">
|
||||||
|
<PageHeader />
|
||||||
|
|
||||||
|
<HistoryTable
|
||||||
|
items={items}
|
||||||
|
isLoading={false}
|
||||||
|
error={status === 'error' ? error : null}
|
||||||
|
onRetry={refetch}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Pagination
|
||||||
|
currentPage={currentPage}
|
||||||
|
totalPages={totalPages}
|
||||||
|
onPageChange={goToPage}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
85
src/widgets/history/ui/historyTable.tsx
Normal file
85
src/widgets/history/ui/historyTable.tsx
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
'use client';
|
||||||
|
import React from 'react';
|
||||||
|
import { TABLE_COLUMNS } from '../lib/constants';
|
||||||
|
import { EmptyState, ErrorState, SkeletonRow } from './tableStates';
|
||||||
|
import { HistoryTableRow } from './historyTableRow';
|
||||||
|
import { HistoryTableProps } from '../lib/types';
|
||||||
|
|
||||||
|
interface HistoryTableFullProps extends HistoryTableProps {
|
||||||
|
error?: string | null;
|
||||||
|
onRetry?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Table Header ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const TableHead: React.FC = () => (
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-slate-200 bg-slate-50/80">
|
||||||
|
{TABLE_COLUMNS.map((col) => (
|
||||||
|
<th
|
||||||
|
key={col.key}
|
||||||
|
scope="col"
|
||||||
|
className={`px-4 py-3 text-left text-xs font-semibold text-slate-500 uppercase tracking-wider whitespace-nowrap ${
|
||||||
|
col.key === 'actions' ? 'text-right' : ''
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{col.label}
|
||||||
|
</th>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
);
|
||||||
|
|
||||||
|
// ─── Table Body ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const TableBody: React.FC<HistoryTableFullProps> = ({
|
||||||
|
items,
|
||||||
|
isLoading,
|
||||||
|
error,
|
||||||
|
onRetry,
|
||||||
|
}) => {
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<tbody>
|
||||||
|
{Array.from({ length: 6 }).map((_, i) => (
|
||||||
|
<SkeletonRow key={i} columns={TABLE_COLUMNS.length} />
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (false) {
|
||||||
|
return (
|
||||||
|
<tbody>
|
||||||
|
<ErrorState message={error} onRetry={onRetry ?? (() => {})} />
|
||||||
|
</tbody>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (false) {
|
||||||
|
return (
|
||||||
|
<tbody>
|
||||||
|
<EmptyState />
|
||||||
|
</tbody>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<tbody>
|
||||||
|
{items.map((item) => (
|
||||||
|
<HistoryTableRow key={item.id} item={item} />
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// ─── HistoryTable ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export const HistoryTable: React.FC<HistoryTableFullProps> = (props) => (
|
||||||
|
<div className="w-full overflow-x-auto rounded-xl border border-slate-200 bg-white shadow-sm">
|
||||||
|
<table className="w-full min-w-160 border-collapse text-left">
|
||||||
|
<TableHead />
|
||||||
|
<TableBody {...props} />
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
93
src/widgets/history/ui/historyTableRow.tsx
Normal file
93
src/widgets/history/ui/historyTableRow.tsx
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
'use client';
|
||||||
|
import React from 'react';
|
||||||
|
import { HistoryTableRowProps } from '../lib/types';
|
||||||
|
import { formatDate, truncateFileName } from '../lib/utils';
|
||||||
|
import { ResultBadge } from './resultBadge';
|
||||||
|
import { useRouter } from '@/shared/config/i18n/navigation';
|
||||||
|
|
||||||
|
export const HistoryTableRow: React.FC<HistoryTableRowProps> = ({ item }) => {
|
||||||
|
const router = useRouter();
|
||||||
|
return (
|
||||||
|
<tr className="border-b border-slate-100 hover:bg-slate-50/70 transition-colors duration-100 group">
|
||||||
|
{/* Sender */}
|
||||||
|
<td className="px-4 py-3.5">
|
||||||
|
<span className="text-sm font-medium text-slate-800 whitespace-nowrap">
|
||||||
|
{item.senderFullName}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
|
||||||
|
{/* File Name */}
|
||||||
|
<td className="px-4 py-3.5">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<svg
|
||||||
|
width="14"
|
||||||
|
height="14"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="1.75"
|
||||||
|
className="text-slate-400 shrink-0"
|
||||||
|
>
|
||||||
|
<path d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
||||||
|
</svg>
|
||||||
|
<span
|
||||||
|
className="text-sm text-slate-600 font-mono"
|
||||||
|
title={item.fileName}
|
||||||
|
>
|
||||||
|
{truncateFileName(item.fileName)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
|
||||||
|
{/* Date */}
|
||||||
|
<td className="px-4 py-3.5">
|
||||||
|
<span className="text-sm text-slate-500 whitespace-nowrap">
|
||||||
|
{formatDate(item.date)}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
|
||||||
|
{/* Amount */}
|
||||||
|
<td className="px-4 py-3.5">
|
||||||
|
<span className="text-sm font-medium text-slate-700 whitespace-nowrap tabular-nums">
|
||||||
|
{item.paymentAmount}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
|
||||||
|
{/* Result */}
|
||||||
|
<td className="px-4 py-3.5">
|
||||||
|
<ResultBadge result={item.result} />
|
||||||
|
</td>
|
||||||
|
|
||||||
|
{/* View Button */}
|
||||||
|
<td className="px-4 py-3.5 text-right">
|
||||||
|
<button
|
||||||
|
onClick={() => router.push(`/${item.id}`)}
|
||||||
|
aria-label={`View details for ${item.senderFullName}`}
|
||||||
|
className="
|
||||||
|
inline-flex items-center gap-1.5 px-3 py-1.5
|
||||||
|
text-xs font-medium text-slate-600
|
||||||
|
bg-white border border-slate-200 rounded-lg
|
||||||
|
hover:border-slate-300 hover:text-slate-900 hover:bg-slate-50
|
||||||
|
active:scale-95
|
||||||
|
transition-all duration-150
|
||||||
|
focus:outline-none focus-visible:ring-2 focus-visible:ring-slate-300
|
||||||
|
"
|
||||||
|
>
|
||||||
|
View
|
||||||
|
<svg
|
||||||
|
width="11"
|
||||||
|
height="11"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2.5"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
>
|
||||||
|
<path d="M5 12h14M12 5l7 7-7 7" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
};
|
||||||
115
src/widgets/history/ui/pagination.tsx
Normal file
115
src/widgets/history/ui/pagination.tsx
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
'use client';
|
||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
interface PaginationProps {
|
||||||
|
currentPage: number;
|
||||||
|
totalPages: number;
|
||||||
|
onPageChange: (page: number) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ChevronIcon: React.FC<{ direction: 'left' | 'right' }> = ({
|
||||||
|
direction,
|
||||||
|
}) => (
|
||||||
|
<svg
|
||||||
|
width="14"
|
||||||
|
height="14"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2.5"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
>
|
||||||
|
{direction === 'left' ? (
|
||||||
|
<path d="M15 18l-6-6 6-6" />
|
||||||
|
) : (
|
||||||
|
<path d="M9 18l6-6-6-6" />
|
||||||
|
)}
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
export const Pagination: React.FC<PaginationProps> = ({
|
||||||
|
currentPage,
|
||||||
|
totalPages,
|
||||||
|
onPageChange,
|
||||||
|
}) => {
|
||||||
|
if (totalPages <= 1) return null;
|
||||||
|
|
||||||
|
// Build visible page numbers with ellipsis
|
||||||
|
const getPages = (): (number | '…')[] => {
|
||||||
|
if (totalPages <= 7)
|
||||||
|
return Array.from({ length: totalPages }, (_, i) => i + 1);
|
||||||
|
|
||||||
|
const pages: (number | '…')[] = [1];
|
||||||
|
if (currentPage > 3) pages.push('…');
|
||||||
|
for (
|
||||||
|
let i = Math.max(2, currentPage - 1);
|
||||||
|
i <= Math.min(totalPages - 1, currentPage + 1);
|
||||||
|
i++
|
||||||
|
) {
|
||||||
|
pages.push(i);
|
||||||
|
}
|
||||||
|
if (currentPage < totalPages - 2) pages.push('…');
|
||||||
|
pages.push(totalPages);
|
||||||
|
return pages;
|
||||||
|
};
|
||||||
|
|
||||||
|
const btnBase =
|
||||||
|
'inline-flex items-center justify-center w-8 h-8 text-sm rounded-lg border transition-all duration-150 focus:outline-none focus-visible:ring-2 focus-visible:ring-slate-300';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-between px-4 py-3 border-t border-slate-100">
|
||||||
|
<span className="text-xs text-slate-400">
|
||||||
|
Page {currentPage} of {totalPages}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
{/* Prev */}
|
||||||
|
<button
|
||||||
|
onClick={() => onPageChange(currentPage - 1)}
|
||||||
|
disabled={currentPage === 1}
|
||||||
|
aria-label="Previous page"
|
||||||
|
className={`${btnBase} border-slate-200 text-slate-500 hover:bg-slate-50 hover:text-slate-800 disabled:opacity-30 disabled:cursor-not-allowed`}
|
||||||
|
>
|
||||||
|
<ChevronIcon direction="left" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Page numbers */}
|
||||||
|
{getPages().map((p, i) =>
|
||||||
|
p === '…' ? (
|
||||||
|
<span
|
||||||
|
key={`ellipsis-${i}`}
|
||||||
|
className="w-8 h-8 inline-flex items-center justify-center text-sm text-slate-400"
|
||||||
|
>
|
||||||
|
…
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
key={p}
|
||||||
|
onClick={() => onPageChange(p as number)}
|
||||||
|
aria-label={`Page ${p}`}
|
||||||
|
aria-current={p === currentPage ? 'page' : undefined}
|
||||||
|
className={`${btnBase} ${
|
||||||
|
p === currentPage
|
||||||
|
? 'bg-slate-900 text-white border-slate-900'
|
||||||
|
: 'border-slate-200 text-slate-600 hover:bg-slate-50 hover:text-slate-900'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{p}
|
||||||
|
</button>
|
||||||
|
),
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Next */}
|
||||||
|
<button
|
||||||
|
onClick={() => onPageChange(currentPage + 1)}
|
||||||
|
disabled={currentPage === totalPages}
|
||||||
|
aria-label="Next page"
|
||||||
|
className={`${btnBase} border-slate-200 text-slate-500 hover:bg-slate-50 hover:text-slate-800 disabled:opacity-30 disabled:cursor-not-allowed`}
|
||||||
|
>
|
||||||
|
<ChevronIcon direction="right" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
16
src/widgets/history/ui/resultBadge.tsx
Normal file
16
src/widgets/history/ui/resultBadge.tsx
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
'use client';
|
||||||
|
import React from 'react';
|
||||||
|
import { ResultBadgeProps } from '../lib/types';
|
||||||
|
import { RESULT_CONFIG } from '../lib/constants';
|
||||||
|
|
||||||
|
export const ResultBadge: React.FC<ResultBadgeProps> = ({ result }) => {
|
||||||
|
const { label, className } = RESULT_CONFIG[result];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium whitespace-nowrap ${className}`}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
};
|
||||||
75
src/widgets/history/ui/tableStates.tsx
Normal file
75
src/widgets/history/ui/tableStates.tsx
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
'use client';
|
||||||
|
import React from 'react';
|
||||||
|
import { EmptyStateProps, SkeletonRowProps } from '../lib/types';
|
||||||
|
|
||||||
|
// ─── Empty State ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export const EmptyState: React.FC<EmptyStateProps> = ({
|
||||||
|
message = 'No plagiarism checks found.',
|
||||||
|
}) => (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={6}>
|
||||||
|
<div className="flex flex-col items-center justify-center py-16 text-slate-400 gap-3">
|
||||||
|
<svg
|
||||||
|
width="40"
|
||||||
|
height="40"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="1.5"
|
||||||
|
className="text-slate-300"
|
||||||
|
>
|
||||||
|
<path d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
||||||
|
</svg>
|
||||||
|
<span className="text-sm font-medium">{message}</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
|
||||||
|
// ─── Skeleton Row ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const SkeletonCell: React.FC<{ width?: string }> = ({ width = 'w-24' }) => (
|
||||||
|
<td className="px-4 py-3.5">
|
||||||
|
<div className={`h-4 ${width} bg-slate-100 rounded animate-pulse`} />
|
||||||
|
</td>
|
||||||
|
);
|
||||||
|
|
||||||
|
export const SkeletonRow: React.FC<SkeletonRowProps> = ({ columns }) => {
|
||||||
|
const widths = ['w-32', 'w-40', 'w-20', 'w-24', 'w-20', 'w-16'];
|
||||||
|
return (
|
||||||
|
<tr className="border-b border-slate-100">
|
||||||
|
{Array.from({ length: columns }).map((_, i) => (
|
||||||
|
<SkeletonCell key={i} width={widths[i] ?? 'w-20'} />
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// ─── Error State ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
interface ErrorStateProps {
|
||||||
|
message: string | null | undefined;
|
||||||
|
onRetry: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ErrorState: React.FC<ErrorStateProps> = ({ message, onRetry }) => (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={6}>
|
||||||
|
<div className="flex flex-col items-center justify-center py-14 gap-3">
|
||||||
|
<div className="flex items-center gap-2 text-red-600 text-sm font-medium">
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
|
||||||
|
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm1 15h-2v-2h2v2zm0-4h-2V7h2v6z" />
|
||||||
|
</svg>
|
||||||
|
{message}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={onRetry}
|
||||||
|
className="text-xs text-slate-500 underline underline-offset-2 hover:text-slate-800 transition-colors"
|
||||||
|
>
|
||||||
|
Try again
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
@@ -13,7 +13,6 @@ import {
|
|||||||
} from '@/shared/ui/sheet';
|
} from '@/shared/ui/sheet';
|
||||||
import { Menu } from 'lucide-react';
|
import { Menu } from 'lucide-react';
|
||||||
import { menu } from '../lib/data';
|
import { menu } from '../lib/data';
|
||||||
import { PRODUCT_INFO } from '@/shared/constants/data';
|
|
||||||
import RenderMenuItem from './RenderItem';
|
import RenderMenuItem from './RenderItem';
|
||||||
import RenderMobileMenuItem from './RenderMobileMenuItem';
|
import RenderMobileMenuItem from './RenderMobileMenuItem';
|
||||||
import { ChangeLang } from './ChangeLang';
|
import { ChangeLang } from './ChangeLang';
|
||||||
@@ -29,14 +28,7 @@ const Navbar = () => {
|
|||||||
<div className="flex items-center gap-6">
|
<div className="flex items-center gap-6">
|
||||||
{/* Logo */}
|
{/* Logo */}
|
||||||
<Link href={'/'} className="flex items-center gap-2">
|
<Link href={'/'} className="flex items-center gap-2">
|
||||||
<img
|
Plagat
|
||||||
src={PRODUCT_INFO.logo}
|
|
||||||
className="max-h-8"
|
|
||||||
alt={PRODUCT_INFO.name}
|
|
||||||
/>
|
|
||||||
<span className="text-lg font-semibold tracking-tighter">
|
|
||||||
{PRODUCT_INFO.name}
|
|
||||||
</span>
|
|
||||||
</Link>
|
</Link>
|
||||||
<div className="flex items-center">
|
<div className="flex items-center">
|
||||||
<NavigationMenu>
|
<NavigationMenu>
|
||||||
@@ -54,11 +46,7 @@ const Navbar = () => {
|
|||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
{/* Logo */}
|
{/* Logo */}
|
||||||
<Link href={'/'} className="flex items-center gap-2">
|
<Link href={'/'} className="flex items-center gap-2">
|
||||||
<img
|
Plagat
|
||||||
src={PRODUCT_INFO.logo}
|
|
||||||
className="max-h-8"
|
|
||||||
alt={PRODUCT_INFO.name}
|
|
||||||
/>
|
|
||||||
</Link>
|
</Link>
|
||||||
<Sheet>
|
<Sheet>
|
||||||
<div className="space-x-2">
|
<div className="space-x-2">
|
||||||
@@ -73,11 +61,7 @@ const Navbar = () => {
|
|||||||
<SheetHeader>
|
<SheetHeader>
|
||||||
<SheetTitle>
|
<SheetTitle>
|
||||||
<Link href={'/'} className="flex items-center gap-2">
|
<Link href={'/'} className="flex items-center gap-2">
|
||||||
<img
|
Plagat
|
||||||
src={PRODUCT_INFO.logo}
|
|
||||||
className="max-h-8"
|
|
||||||
alt={PRODUCT_INFO.name}
|
|
||||||
/>
|
|
||||||
</Link>
|
</Link>
|
||||||
</SheetTitle>
|
</SheetTitle>
|
||||||
</SheetHeader>
|
</SheetHeader>
|
||||||
|
|||||||
48
src/widgets/paymentModal/lib/types.ts
Normal file
48
src/widgets/paymentModal/lib/types.ts
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
// ─── Domain Types ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface ServicePricing {
|
||||||
|
serviceFee: number;
|
||||||
|
certificateFee: number;
|
||||||
|
currency: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OrderSummary {
|
||||||
|
hasCertificate: boolean;
|
||||||
|
pricing: ServicePricing;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PaymePaymentRequest {
|
||||||
|
amount: number; // in tiyin (1 UZS = 100 tiyin)
|
||||||
|
orderId: string;
|
||||||
|
description: string;
|
||||||
|
returnUrl: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PaymePaymentResponse {
|
||||||
|
redirectUrl: string;
|
||||||
|
transactionId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type PaymentStatus = 'idle' | 'loading' | 'success' | 'error';
|
||||||
|
|
||||||
|
// ─── Component Props ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface PaymentModalProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
hasCertificate: boolean;
|
||||||
|
onConfirmPayment: () => void;
|
||||||
|
isLoading: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PriceSummaryProps {
|
||||||
|
hasCertificate: boolean;
|
||||||
|
pricing: ServicePricing;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PaymeButtonProps {
|
||||||
|
amount: number;
|
||||||
|
orderId: string;
|
||||||
|
onSuccess?: (response: PaymePaymentResponse) => void;
|
||||||
|
onError?: (error: Error) => void;
|
||||||
|
}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
'use client';
|
||||||
import { useState, useCallback } from 'react';
|
import { useState, useCallback } from 'react';
|
||||||
import { PaymentStatus, PaymePaymentResponse } from './types';
|
import { PaymentStatus, PaymePaymentResponse } from './types';
|
||||||
import {
|
import {
|
||||||
71
src/widgets/paymentModal/lib/utils.ts
Normal file
71
src/widgets/paymentModal/lib/utils.ts
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
// ─── Pricing Utilities ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
import { PAYME_CONFIG, PRICING } from './constant';
|
||||||
|
import {
|
||||||
|
PaymePaymentRequest,
|
||||||
|
PaymePaymentResponse,
|
||||||
|
ServicePricing,
|
||||||
|
} from './types';
|
||||||
|
|
||||||
|
export const getPricing = (): ServicePricing => ({
|
||||||
|
serviceFee: PRICING.SERVICE_FEE,
|
||||||
|
certificateFee: PRICING.CERTIFICATE_FEE,
|
||||||
|
currency: PRICING.CURRENCY,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const calculateTotal = (hasCertificate: boolean): number => {
|
||||||
|
const base = PRICING.SERVICE_FEE;
|
||||||
|
return hasCertificate ? base + PRICING.CERTIFICATE_FEE : base;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const toTiyin = (uzs: number): number => uzs * PRICING.TIYIN_MULTIPLIER;
|
||||||
|
|
||||||
|
export const formatPrice = (amount: number, currency: string): string =>
|
||||||
|
`${amount.toLocaleString('uz-UZ')} ${currency}`;
|
||||||
|
|
||||||
|
// ─── Order ID Generator ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export const generateOrderId = (): string => {
|
||||||
|
const timestamp = Date.now();
|
||||||
|
const random = Math.random().toString(36).slice(2, 8).toUpperCase();
|
||||||
|
return `ORDER-${timestamp}-${random}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
// ─── Payme API ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sends payment details to the backend, which creates a Payme transaction
|
||||||
|
* and returns a redirect URL to the Payme checkout page.
|
||||||
|
*/
|
||||||
|
export const createPaymePayment = async (
|
||||||
|
request: PaymePaymentRequest,
|
||||||
|
): Promise<PaymePaymentResponse> => {
|
||||||
|
const response = await fetch(PAYME_CONFIG.API_ENDPOINT, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
amount: request.amount, // in tiyin
|
||||||
|
order_id: request.orderId,
|
||||||
|
description: request.description,
|
||||||
|
return_url: request.returnUrl,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorBody = await response.json().catch(() => ({}));
|
||||||
|
throw new Error(
|
||||||
|
(errorBody as { message?: string }).message ??
|
||||||
|
`Payment request failed with status ${response.status}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = (await response.json()) as PaymePaymentResponse;
|
||||||
|
return data;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Redirects the user to the Payme checkout page.
|
||||||
|
*/
|
||||||
|
export const redirectToPayme = (redirectUrl: string): void => {
|
||||||
|
window.location.href = redirectUrl;
|
||||||
|
};
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
'use client';
|
||||||
import React, { useEffect, useRef } from 'react';
|
import React, { useEffect, useRef } from 'react';
|
||||||
import { PaymentModalProps } from '../lib/types';
|
import { PaymentModalProps } from '../lib/types';
|
||||||
import { getPricing } from '../lib/utils';
|
import { getPricing } from '../lib/utils';
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
'use client';
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { formatPrice } from '../lib/utils';
|
import { formatPrice } from '../lib/utils';
|
||||||
import { PriceSummaryProps } from '../lib/types';
|
import { PriceSummaryProps } from '../lib/types';
|
||||||
Reference in New Issue
Block a user