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 { 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 }>;
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 */}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
'use client';
|
||||
|
||||
import { useCallback, useState } from 'react';
|
||||
import { useRegisterZustand } from './registerZustand';
|
||||
import { validateRegister, RegisterErrors } from './validateRegister';
|
||||
|
||||
@@ -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 */}
|
||||
|
||||
@@ -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 locale = await getLocale();
|
||||
switch (locale) {
|
||||
case 'ki':
|
||||
case 'en':
|
||||
return 'uz';
|
||||
case 'uz':
|
||||
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={`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>
|
||||
);
|
||||
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';
|
||||
}
|
||||
64
src/widgets/detail/lib/constant.ts
Normal file
64
src/widgets/detail/lib/constant.ts
Normal 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',
|
||||
},
|
||||
},
|
||||
};
|
||||
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;
|
||||
}
|
||||
47
src/widgets/detail/lib/useDetail.ts
Normal file
47
src/widgets/detail/lib/useDetail.ts
Normal 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 };
|
||||
}
|
||||
484
src/widgets/detail/ui/detailPage.tsx
Normal file
484
src/widgets/detail/ui/detailPage.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
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 ──────────────────────────────────────────────────────────────
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
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 {
|
||||
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}`;
|
||||
};
|
||||
|
||||
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>
|
||||
);
|
||||
@@ -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()}>
|
||||
|
||||
@@ -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>
|
||||
|
||||
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 { PaymentStatus, PaymePaymentResponse } from './types';
|
||||
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 { PaymentModalProps } from '../lib/types';
|
||||
import { getPricing } from '../lib/utils';
|
||||
@@ -1,3 +1,4 @@
|
||||
'use client';
|
||||
import React from 'react';
|
||||
import { formatPrice } from '../lib/utils';
|
||||
import { PriceSummaryProps } from '../lib/types';
|
||||
@@ -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;
|
||||
Reference in New Issue
Block a user