Merge pull request #2 from DavronNabijonv/dev

Dev
This commit is contained in:
Davronbek Nabijonov
2026-03-31 17:42:35 +05:00
committed by GitHub
43 changed files with 2065 additions and 305 deletions

View 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} />;
}

View File

@@ -1,8 +1,6 @@
import type { Metadata } from 'next';
import '../globals.css';
import { golosText } from '@/shared/config/fonts';
import { ThemeProvider } from '@/shared/config/theme-provider';
import { PRODUCT_INFO } from '@/shared/constants/data';
import { hasLocale, Locale, NextIntlClientProvider } from 'next-intl';
import { routing } from '@/shared/config/i18n/routing';
import { notFound } from 'next/navigation';
@@ -14,12 +12,6 @@ import QueryProvider from '@/shared/config/react-query/QueryProvider';
import Script from 'next/script';
import Provider from '@/features/providers/provider';
export const metadata: Metadata = {
title: PRODUCT_INFO.name,
description: PRODUCT_INFO.description,
icons: PRODUCT_INFO.favicon,
};
type Props = {
children: ReactNode;
params: Promise<{ locale: Locale }>;

View File

@@ -1,13 +1,11 @@
import { getPosts } from '@/shared/config/api/testApi';
import { PlagiarismCheckForm } from '@/widgets/fileUpload/ui/Plagiraismcheckform';
import { HistoryPage } from '@/widgets/history';
export default async function Home() {
const res = await getPosts({ _limit: 1 });
console.log('SSR res', res.data);
export default function Home() {
return (
<div>
<div className="bg-[#f4f5ffec]">
<PlagiarismCheckForm />
<HistoryPage />
</div>
);
}

View File

@@ -29,12 +29,12 @@ export function LoginForm() {
<>
{/* Backdrop */}
<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}
/>
{/* 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>
<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 */}

View File

@@ -1,3 +1,5 @@
'use client';
import { useCallback, useState } from 'react';
import { useRegisterZustand } from './registerZustand';
import { validateRegister, RegisterErrors } from './validateRegister';

View File

@@ -12,12 +12,12 @@ export function RegisterForm() {
<>
{/* Backdrop */}
<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}
/>
{/* 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>
<div className="pointer-events-auto bg-background rounded-2xl shadow-2xl p-8 w-full max-w-sm mx-4">
{/* Close button */}

View File

@@ -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 };

View File

@@ -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;

View File

@@ -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 };

View File

@@ -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;
}

View File

@@ -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 };

View File

@@ -14,7 +14,7 @@ dayjs.extend(relativeTime);
const getCurrentLocale = async () => {
const locale = await getLocale();
switch (locale) {
case 'ki':
case 'en':
return 'uz';
case 'uz':
return 'uz-latn';

View File

@@ -1,6 +0,0 @@
export interface TestApiType {
userId: number;
id: number;
title: string;
body: string;
}

View 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={`w-full 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>
);

View 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';
}

View File

@@ -0,0 +1,64 @@
import { PlagiarismCheck } from './types';
export const MOCK_CHECKS: Record<string, PlagiarismCheck> = {
'1': {
id: 'chk-001',
sender: {
id: 'usr-101',
name: 'Doston Nabijonov',
email: 'doston.dev@example.com',
avatarUrl: 'https://i.pravatar.cc/150?img=12',
},
fileName: 'machine_learning_thesis.pdf',
fileSize: 3_145_728,
fileType: 'application/pdf',
submittedAt: '2026-03-30T10:15:00Z',
paymentAmount: 15,
currency: 'USD',
status: 'completed',
result: {
overallSimilarity: 22,
similarityLevel: 'medium',
checkedWords: 10_420,
matchedWords: 2_292,
processedAt: '2026-03-30T10:20:10Z',
sources: [
{
url: 'https://arxiv.org/abs/1706.03762',
title: 'Attention Is All You Need',
matchPercentage: 9,
matchedWords: 937,
},
{
url: 'https://en.wikipedia.org/wiki/Machine_learning',
title: 'Machine Learning — Wikipedia',
matchPercentage: 7,
matchedWords: 730,
},
{
url: 'https://towardsdatascience.com/introduction-to-neural-networks',
title: 'Introduction to Neural Networks',
matchPercentage: 6,
matchedWords: 625,
},
],
},
certificate: {
id: 'cert-9001',
issuedAt: '2026-03-30T10:21:00Z',
expiresAt: '2027-03-30T10:21:00Z',
verificationCode: 'PLAG-9001-VERIFY',
issuerName: 'Global Plagiarism Checker',
downloadUrl: '/certificates/cert-9001.pdf',
},
},
};

View 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; // 0100
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;
}

View File

@@ -0,0 +1,47 @@
'use client';
// ─────────────────────────────────────────────────────────────
// State Management — usePlagiarismDetail hook
// ─────────────────────────────────────────────────────────────
import { useState, useEffect, useCallback } from 'react';
import { PlagiarismCheck } from './types';
import { MOCK_CHECKS } from './constant';
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(MOCK_CHECKS['1'] || null);
setLoadingState('success');
} catch (err) {
console.log(err);
// setError(err instanceof Error ? err.message : 'Unknown error occurred.');
setLoadingState('success');
}
}, [checkId]);
useEffect(() => {
load();
}, [load]);
return { check, loadingState, error, reload: load };
}

View File

@@ -0,0 +1,484 @@
// ─────────────────────────────────────────────────────────────
// 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-60 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-linear-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 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} />
<div className="flex items-start gap-4 w-full">
<CertificateCard check={check} />
<SubmissionInfoCard check={check} />
</div>
<ResultCard check={check} />
</div>
);
// ── Page ──────────────────────────────────────────────────────
interface PlagiarismDetailPageProps {
checkId: string;
onBack?: () => void;
}
export const PlagiarismDetailPage: React.FC<PlagiarismDetailPageProps> = ({
checkId,
}) => {
const { check, loadingState, error, reload } = usePlagiarismDetail(checkId);
return (
<div className="bg-slate-50 font-sans">
<main className="max-w-300 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>
);
};

View File

@@ -10,7 +10,7 @@ import {
StatusBanner,
} from './Plagiraismui';
import { usePlagiarismForm } from '../lib/usePlagiraism';
import { PaymentModal } from '@/widgets/history/ui/Paymentmodal';
import { PaymentModal } from '@/widgets/paymentModal/ui/Paymentmodal';
// ─── UserIcon (inline) ───────────────────────────────────────────────────────
@@ -53,7 +53,7 @@ export function PlagiarismCheckForm() {
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">
{/* ── Header ────────────────────────────────────────────────────── */}
<div className="mb-8">

View File

@@ -1,35 +1,19 @@
import { PRODUCT_INFO } from '@/shared/constants/data';
const Footer = () => {
const shortLinks = [
{ name: 'About', href: '/about' },
{ name: 'Contact', href: '/contact' },
];
// const shortLinks = [
// { name: 'About', href: '/about' },
// { name: 'Contact', href: '/contact' },
// ];
return (
<section className="py-10">
<div className="custom-container">
<div className="flex items-baseline justify-between gap-2">
<div>PLAGAT</div>
<div className="flex items-center gap-5">
{shortLinks.map((link) => (
<a
key={link.name}
href={link.href}
className="text-sm text-muted-foreground hover:text-primary transition-colors"
>
{link.name}
</a>
))}
</div>
</div>
<div className="mt-8 flex flex-col justify-between gap-4 border-t pt-8 text-center text-sm font-medium text-muted-foreground lg:flex-row lg:items-center lg:text-left">
<div className=" flex flex-col justify-between gap-4 border-t pt-8 text-center text-sm font-medium text-muted-foreground lg:flex-row lg:items-center lg:text-left">
<p>
© {new Date().getFullYear()} Felix IT Solutions. All rights
reserved.
</p>
<ul className="flex justify-center gap-4 lg:justify-start">
<li className="hover:text-primary">
<a href={PRODUCT_INFO.terms_of_use}>Terms and Conditions</a>
<a href="#">Terms and Conditions</a>
</li>
</ul>
</div>

View 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';

View 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;

View 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',
},
];

View File

@@ -1,48 +1,62 @@
// ─── Domain Types ──────────────────────────────────────────────────────────────
export interface ServicePricing {
serviceFee: number;
certificateFee: number;
export type CheckResult = 'clean' | 'plagiarism_found' | 'pending' | 'failed';
export interface PlagiarismCheck {
id: string;
senderFullName: string;
fileName: string;
date: string; // ISO 8601
paymentAmount: number;
currency: string;
result: CheckResult;
}
export interface OrderSummary {
hasCertificate: boolean;
pricing: ServicePricing;
export interface PlagiarismCheckDetail extends PlagiarismCheck {
topic: string;
plagiarismPercentage?: number;
reportUrl?: string;
withCertificate: boolean;
}
export interface PaymePaymentRequest {
amount: number; // in tiyin (1 UZS = 100 tiyin)
orderId: string;
description: string;
returnUrl: string;
}
// ─── API Response Types ────────────────────────────────────────────────────────
export interface PaymePaymentResponse {
redirectUrl: string;
transactionId: string;
export interface HistoryApiResponse {
items: PlagiarismCheck[];
total: number;
page: number;
pageSize: number;
}
export type PaymentStatus = 'idle' | 'loading' | 'success' | 'error';
// ─── Component Props ───────────────────────────────────────────────────────────
export interface PaymentModalProps {
isOpen: boolean;
onClose: () => void;
hasCertificate: boolean;
onConfirmPayment: () => void;
export interface HistoryTableProps {
items: PlagiarismCheck[];
isLoading: boolean;
}
export interface PriceSummaryProps {
hasCertificate: boolean;
pricing: ServicePricing;
export interface HistoryTableRowProps {
item: PlagiarismCheck;
}
export interface PaymeButtonProps {
amount: number;
orderId: string;
onSuccess?: (response: PaymePaymentResponse) => void;
onError?: (error: Error) => void;
export interface ResultBadgeProps {
result: CheckResult;
}
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;
}

View 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,
};
};

View File

@@ -1,71 +1,73 @@
// ─── Pricing Utilities ─────────────────────────────────────────────────────────
// ─── API Functions ─────────────────────────────────────────────────────────────
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 ─────────────────────────────────────────────────────────────────
import { API_ENDPOINTS } from './constants';
import { HistoryApiResponse, PlagiarismCheckDetail } from './types';
/**
* Sends payment details to the backend, which creates a Payme transaction
* and returns a redirect URL to the Payme checkout page.
* Fetches the paginated list of plagiarism checks for the current user.
*/
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,
}),
});
export const fetchPlagiarismHistory = async (
page = 1,
pageSize = 10,
): Promise<HistoryApiResponse> => {
const url = new URL(API_ENDPOINTS.HISTORY, window.location.origin);
url.searchParams.set('page', String(page));
url.searchParams.set('pageSize', String(pageSize));
const response = await fetch(url.toString());
if (!response.ok) {
const errorBody = await response.json().catch(() => ({}));
throw new Error(
(errorBody as { message?: string }).message ??
`Payment request failed with status ${response.status}`,
);
throw new Error(`Failed to load history (${response.status})`);
}
const data = (await response.json()) as PaymePaymentResponse;
return data;
return response.json() as Promise<HistoryApiResponse>;
};
/**
* Redirects the user to the Payme checkout page.
* Fetches the full detail for a single plagiarism check.
*/
export const redirectToPayme = (redirectUrl: string): void => {
window.location.href = redirectUrl;
export const fetchPlagiarismDetail = async (
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}`;
};

View 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>
);
};

View 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>
);

View 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>
);
};

View 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>
);
};

View 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>
);
};

View 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>
);

View File

@@ -45,8 +45,8 @@ function AuthButtons() {
}
return (
<div className="flex lg:flex-row flex-col gap-3">
<div className="lg:flex hidden">
<div className="flex flex-row gap-3">
<div className="flex">
<ChangeLang />
</div>
<Button variant="outline" onClick={() => toggleLoginModal()}>

View File

@@ -1,9 +1,5 @@
import { Accordion } from '@/shared/ui/accordion';
import { Button } from '@/shared/ui/button';
import {
NavigationMenu,
NavigationMenuList,
} from '@/shared/ui/navigation-menu';
import {
Sheet,
SheetContent,
@@ -13,8 +9,6 @@ import {
} from '@/shared/ui/sheet';
import { Menu } from 'lucide-react';
import { menu } from '../lib/data';
import { PRODUCT_INFO } from '@/shared/constants/data';
import RenderMenuItem from './RenderItem';
import RenderMobileMenuItem from './RenderMobileMenuItem';
import { ChangeLang } from './ChangeLang';
import Link from 'next/link';
@@ -25,40 +19,32 @@ const Navbar = () => {
<section className="py-4">
<div className="custom-container">
{/* Desktop Menu */}
<nav className="hidden justify-between lg:flex">
<nav className="justify-between flex max-sm:flex-col gap-5">
<div className="flex items-center gap-6">
{/* Logo */}
<Link href={'/'} className="flex items-center gap-2">
<img
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
href={'/'}
className="flex items-center gap-2 text-2xl font-bold "
>
Plagat
</Link>
<div className="flex items-center">
{/* <div className="flex items-center">
<NavigationMenu>
<NavigationMenuList>
{menu.map((item) => RenderMenuItem(item))}
</NavigationMenuList>
</NavigationMenu>
</div>
</div> */}
</div>
<AuthButtons />
</nav>
{/* Mobile Menu */}
<div className="block lg:hidden">
<div className="hidden">
<div className="flex items-center justify-between">
{/* Logo */}
<Link href={'/'} className="flex items-center gap-2">
<img
src={PRODUCT_INFO.logo}
className="max-h-8"
alt={PRODUCT_INFO.name}
/>
Plagat
</Link>
<Sheet>
<div className="space-x-2">
@@ -73,11 +59,7 @@ const Navbar = () => {
<SheetHeader>
<SheetTitle>
<Link href={'/'} className="flex items-center gap-2">
<img
src={PRODUCT_INFO.logo}
className="max-h-8"
alt={PRODUCT_INFO.name}
/>
Plagat
</Link>
</SheetTitle>
</SheetHeader>

View 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;
}

View File

@@ -1,3 +1,4 @@
'use client';
import { useState, useCallback } from 'react';
import { PaymentStatus, PaymePaymentResponse } from './types';
import {

View 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;
};

View File

@@ -1,3 +1,4 @@
'use client';
import React, { useEffect, useRef } from 'react';
import { PaymentModalProps } from '../lib/types';
import { getPricing } from '../lib/utils';

View File

@@ -1,3 +1,4 @@
'use client';
import React from 'react';
import { formatPrice } from '../lib/utils';
import { PriceSummaryProps } from '../lib/types';

View File

@@ -1,32 +0,0 @@
'use client';
import { getPosts } from '@/shared/config/api/testApi';
import { useQuery } from '@tanstack/react-query';
import Link from 'next/link';
import React from 'react';
const Welcome = () => {
const { data } = useQuery({
queryKey: ['posts'],
queryFn: () => getPosts({ _limit: 1 }),
});
console.log('CSR posts', data);
return (
<div className="custom-container h-full bg-accent min-h-[400px] rounded-2xl flex items-center justify-center">
<Link
className="github-button"
href="https://github.com/fiasuz/create-fias"
data-color-scheme="no-preference: light; light: light; dark: dark;"
data-icon="octicon-star"
data-size="large"
aria-label="Star fiasuz/create-fias on GitHub"
>
Star on github
</Link>
</div>
);
};
export default Welcome;