diff --git a/src/app/[locale]/[detail]/page.tsx b/src/app/[locale]/[detail]/page.tsx new file mode 100644 index 0000000..1871be4 --- /dev/null +++ b/src/app/[locale]/[detail]/page.tsx @@ -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 ; +} diff --git a/src/app/[locale]/layout.tsx b/src/app/[locale]/layout.tsx index 2cfe2fa..229166b 100644 --- a/src/app/[locale]/layout.tsx +++ b/src/app/[locale]/layout.tsx @@ -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 }>; diff --git a/src/app/[locale]/page.tsx b/src/app/[locale]/page.tsx index 5bdd1ee..09d434c 100644 --- a/src/app/[locale]/page.tsx +++ b/src/app/[locale]/page.tsx @@ -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 ( -
+
+
); } diff --git a/src/features/auth/login/ui/form.tsx b/src/features/auth/login/ui/form.tsx index 0d2a4e7..ab00bd1 100644 --- a/src/features/auth/login/ui/form.tsx +++ b/src/features/auth/login/ui/form.tsx @@ -29,12 +29,12 @@ export function LoginForm() { <> {/* Backdrop */}
{/* Modal */} -
+
{/* Close */} diff --git a/src/features/auth/register/lib/useRegisterForm.ts b/src/features/auth/register/lib/useRegisterForm.ts index 9a3a3c3..5121d52 100644 --- a/src/features/auth/register/lib/useRegisterForm.ts +++ b/src/features/auth/register/lib/useRegisterForm.ts @@ -1,3 +1,5 @@ +'use client'; + import { useCallback, useState } from 'react'; import { useRegisterZustand } from './registerZustand'; import { validateRegister, RegisterErrors } from './validateRegister'; diff --git a/src/features/auth/register/ui/index.tsx b/src/features/auth/register/ui/index.tsx index 1dc1b1d..48a5d49 100644 --- a/src/features/auth/register/ui/index.tsx +++ b/src/features/auth/register/ui/index.tsx @@ -12,12 +12,12 @@ export function RegisterForm() { <> {/* Backdrop */}
{/* Modal */} -
+
{/* Close button */} diff --git a/src/shared/config/api/URLs.ts b/src/shared/config/api/URLs.ts deleted file mode 100644 index da7306a..0000000 --- a/src/shared/config/api/URLs.ts +++ /dev/null @@ -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 }; diff --git a/src/shared/config/api/httpClient.ts b/src/shared/config/api/httpClient.ts deleted file mode 100644 index 92b475b..0000000 --- a/src/shared/config/api/httpClient.ts +++ /dev/null @@ -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; diff --git a/src/shared/config/api/testApi.ts b/src/shared/config/api/testApi.ts deleted file mode 100644 index 8cf23ad..0000000 --- a/src/shared/config/api/testApi.ts +++ /dev/null @@ -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> => { - const response = await httpClient.get(ENDP_POSTS, { params: pagination }); - return response; -}; - -export { getPosts }; diff --git a/src/shared/config/api/types.ts b/src/shared/config/api/types.ts deleted file mode 100644 index 063c748..0000000 --- a/src/shared/config/api/types.ts +++ /dev/null @@ -1,20 +0,0 @@ -export interface ResWithPagination { - 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; -} diff --git a/src/shared/constants/data.ts b/src/shared/constants/data.ts deleted file mode 100644 index 92ac152..0000000 --- a/src/shared/constants/data.ts +++ /dev/null @@ -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 }; diff --git a/src/shared/lib/formatDate.ts b/src/shared/lib/formatDate.ts index 6724fcd..ad641ca 100644 --- a/src/shared/lib/formatDate.ts +++ b/src/shared/lib/formatDate.ts @@ -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'; diff --git a/src/shared/types/testApi.ts b/src/shared/types/testApi.ts deleted file mode 100644 index 874d514..0000000 --- a/src/shared/types/testApi.ts +++ /dev/null @@ -1,6 +0,0 @@ -export interface TestApiType { - userId: number; - id: number; - title: string; - body: string; -} diff --git a/src/widgets/detail/index.tsx b/src/widgets/detail/index.tsx new file mode 100644 index 0000000..2082ea3 --- /dev/null +++ b/src/widgets/detail/index.tsx @@ -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 = ({ label, value, icon }) => ( +
+ + {icon && {icon}} + {label} + + + {value} + +
+); + +// ── SectionCard ─────────────────────────────────────────────── + +interface SectionCardProps { + title: string; + icon?: React.ReactNode; + children: React.ReactNode; + className?: string; + accent?: 'blue' | 'green' | 'red' | 'amber' | 'violet'; +} + +const accentMap: Record = { + 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 = ({ + title, + icon, + children, + className = '', + accent = 'blue', +}) => ( +
+
+

+ {icon && {icon}} + {title} +

+
+
{children}
+
+); + +// ── StatusBadge ─────────────────────────────────────────────── + +interface StatusBadgeProps { + status: CheckStatus; +} + +const statusStyles: Record = { + 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 = { + pending: 'bg-slate-400', + processing: 'bg-blue-500 animate-pulse', + completed: 'bg-emerald-500', + failed: 'bg-red-500', +}; + +const statusLabels: Record = { + pending: 'Pending', + processing: 'Processing', + completed: 'Completed', + failed: 'Failed', +}; + +export const StatusBadge: React.FC = ({ status }) => ( + + + {statusLabels[status]} + +); + +// ── SimilarityMeter ─────────────────────────────────────────── + +interface SimilarityMeterProps { + percentage: number; + level: SimilarityLevel; +} + +const levelColors: Record = { + low: 'from-emerald-400 to-emerald-500', + medium: 'from-amber-400 to-amber-500', + high: 'from-red-400 to-red-500', +}; + +const levelTextColors: Record = { + low: 'text-emerald-600', + medium: 'text-amber-600', + high: 'text-red-600', +}; + +const levelBgColors: Record = { + low: 'bg-emerald-50 border-emerald-200', + medium: 'bg-amber-50 border-amber-200', + high: 'bg-red-50 border-red-200', +}; + +const levelLabels: Record = { + low: 'Low Similarity — Likely Original', + medium: 'Medium Similarity — Review Recommended', + high: 'High Similarity — Action Required', +}; + +export const SimilarityMeter: React.FC = ({ + percentage, + level, +}) => ( +
+
+ + {percentage} + % + + + {level} risk + +
+ + {/* Track */} +
+
+ {/* Threshold markers */} +
+
+
+ +
+ 0% + 30% + 60% + 100% +
+ +
+ {levelLabels[level]} +
+
+); + +// ── 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 = ({ + name, + avatarUrl, + size = 'md', +}) => { + const initials = name + .split(' ') + .map((n) => n[0]) + .join('') + .slice(0, 2) + .toUpperCase(); + + if (avatarUrl) { + return ( + {name} + ); + } + + return ( +
+ {initials} +
+ ); +}; + +// ── SkeletonLoader ──────────────────────────────────────────── + +export const SkeletonLoader: React.FC = () => ( +
+ {/* Header skeleton */} +
+
+
+
+
+
+
+
+
+
+ {/* Cards skeleton */} + {[1, 2, 3].map((i) => ( +
+
+
+
+
+ {[1, 2, 3].map((j) => ( +
+ ))} +
+
+ ))} +
+); + +// ── ErrorState ──────────────────────────────────────────────── + +interface ErrorStateProps { + message: string; + onRetry?: () => void; +} + +export const ErrorState: React.FC = ({ message, onRetry }) => ( +
+
+ + + +
+
+

Failed to load check

+

{message}

+
+ {onRetry && ( + + )} +
+); + +// ── FileTypeBadge ───────────────────────────────────────────── + +interface FileTypeBadgeProps { + extension: string; +} + +const extColors: Record = { + 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 = ({ extension }) => ( + + {extension} + +); diff --git a/src/widgets/detail/lib/api.ts b/src/widgets/detail/lib/api.ts new file mode 100644 index 0000000..636cda5 --- /dev/null +++ b/src/widgets/detail/lib/api.ts @@ -0,0 +1,192 @@ +// ───────────────────────────────────────────────────────────── +// API / Business Logic Layer +// ───────────────────────────────────────────────────────────── + +import { PlagiarismCheck, SimilarityLevel } from './types'; + +// ── Mock data ──────────────────────────────────────────────── + +const MOCK_CHECKS: Record = { + '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((resolve) => setTimeout(resolve, ms)); + +/** Fetch a single plagiarism check by ID */ +export async function fetchPlagiarismCheck( + id: string, +): Promise { + 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 { + 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'; +} diff --git a/src/widgets/detail/lib/types.ts b/src/widgets/detail/lib/types.ts new file mode 100644 index 0000000..5491bc3 --- /dev/null +++ b/src/widgets/detail/lib/types.ts @@ -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; +} diff --git a/src/widgets/detail/lib/useDetail.ts b/src/widgets/detail/lib/useDetail.ts new file mode 100644 index 0000000..0fd5c3d --- /dev/null +++ b/src/widgets/detail/lib/useDetail.ts @@ -0,0 +1,46 @@ +'use client'; + +// ───────────────────────────────────────────────────────────── +// State Management — usePlagiarismDetail hook +// ───────────────────────────────────────────────────────────── + +import { useState, useEffect, useCallback } from 'react'; +import { PlagiarismCheck } from './types'; +import { fetchPlagiarismCheck } from './api'; + +export type LoadingState = 'idle' | 'loading' | 'success' | 'error'; + +export interface UsePlagiarismDetailReturn { + check: PlagiarismCheck | null; + loadingState: LoadingState; + error: string | null; + reload: () => void; +} + +export function usePlagiarismDetail( + checkId: string, +): UsePlagiarismDetailReturn { + const [check, setCheck] = useState(null); + const [loadingState, setLoadingState] = useState('idle'); + const [error, setError] = useState(null); + + const load = useCallback(async () => { + if (!checkId) return; + setLoadingState('loading'); + setError(null); + try { + const data = await fetchPlagiarismCheck(checkId); + setCheck(data); + setLoadingState('success'); + } catch (err) { + setError(err instanceof Error ? err.message : 'Unknown error occurred.'); + setLoadingState('error'); + } + }, [checkId]); + + useEffect(() => { + load(); + }, [load]); + + return { check, loadingState, error, reload: load }; +} diff --git a/src/widgets/detail/ui/detailPage.tsx b/src/widgets/detail/ui/detailPage.tsx new file mode 100644 index 0000000..fcacdee --- /dev/null +++ b/src/widgets/detail/ui/detailPage.tsx @@ -0,0 +1,503 @@ +// ───────────────────────────────────────────────────────────── +// PlagiarismDetailPage — Main Feature Component +// ───────────────────────────────────────────────────────────── +'use client'; +import React from 'react'; +import { PlagiarismCheck } from '../lib/types'; +import { + getFileExtension, + formatDate, + formatFileSize, + formatCurrency, +} from '../lib/api'; +import { usePlagiarismDetail } from '../lib/useDetail'; +import { + FileTypeBadge, + InfoRow, + SectionCard, + StatusBadge, + SimilarityMeter, + Avatar, + SkeletonLoader, + ErrorState, +} from '..'; + +// ── Icons (inline SVG for zero-dep) ────────────────────────── + +const IconUser = () => ( + + + +); +const IconFile = () => ( + + + +); +const IconCalendar = () => ( + + + +); +const IconPayment = () => ( + + + +); +const IconShield = () => ( + + + +); +const IconCert = () => ( + + + +); +const IconDownload = () => ( + + + +); +const IconBack = () => ( + + + +); +const IconSource = () => ( + + + +); + +// ── Sub-components ──────────────────────────────────────────── + +interface CheckDetailViewProps { + check: PlagiarismCheck; +} + +const CheckHeader: React.FC = ({ check }) => ( +
+
+
+ +
+

+ {check.sender.name} +

+

{check.sender.email}

+

+ ID: {check.id} +

+
+
+ +
+
+); + +const SubmissionInfoCard: React.FC = ({ check }) => ( + } accent="blue"> + } + value={ + + + {check.sender.name} + + } + /> + } + value={ + + + + {check.fileName} + + + } + /> + {formatFileSize(check.fileSize)} + } + /> + } + value={formatDate(check.submittedAt)} + /> + } + value={ + + {formatCurrency(check.paymentAmount, check.currency)} + + } + /> + +); + +const ResultCard: React.FC = ({ check }) => { + if (check.status === 'processing' || check.status === 'pending') { + return ( + } accent="violet"> +
+
+ + + + +
+

+ Analysis in progress +

+

+ Results will appear once processing is complete. +

+
+
+ ); + } + + if (!check.result) { + return ( + } accent="violet"> +

No result available.

+
+ ); + } + + const { result } = check; + + return ( + } + accent={ + result.similarityLevel === 'low' + ? 'green' + : result.similarityLevel === 'high' + ? 'red' + : 'amber' + } + > +
+ {/* Meter */} + + + {/* Stats grid */} +
+
+

+ {result.checkedWords.toLocaleString()} +

+

Words Checked

+
+
+

+ {result.matchedWords.toLocaleString()} +

+

Words Matched

+
+
+ + {/* Sources */} + {result.sources.length > 0 && ( +
+

+ Matched Sources +

+
+ {result.sources.map((src, i) => ( +
+
+

+ {src.title} +

+ + + {src.url} + +
+
+ = 30 ? 'text-red-600' : src.matchPercentage >= 15 ? 'text-amber-600' : 'text-emerald-600'}`} + > + {src.matchPercentage}% + +

+ {src.matchedWords.toLocaleString()} words +

+
+
+ ))} +
+
+ )} + + } + value={formatDate(result.processedAt)} + /> +
+
+ ); +}; + +const CertificateCard: React.FC = ({ check }) => { + if (!check.certificate) { + return ( + } accent="violet"> +
+
+ +
+

+ No certificate issued for this check. +

+ {check.result?.similarityLevel === 'high' && ( +

+ Certificates are not issued for high-similarity results. +

+ )} +
+
+ ); + } + + const { certificate } = check; + + return ( + } accent="green"> + {/* Certificate visual */} +
+
+ + + +
+

+ {certificate.issuerName} +

+

+ {certificate.verificationCode} +

+

+ Certificate ID: {certificate.id} +

+
+ + } + value={formatDate(certificate.issuedAt)} + /> + } + value={formatDate(certificate.expiresAt)} + /> + + + +
+ ); +}; + +// ── Detail View (assembled) ─────────────────────────────────── + +const CheckDetailView: React.FC = ({ check }) => ( +
+ + + + +
+); + +// ── Page ────────────────────────────────────────────────────── + +interface PlagiarismDetailPageProps { + checkId: string; + onBack?: () => void; +} + +export const PlagiarismDetailPage: React.FC = ({ + checkId, + onBack, +}) => { + const { check, loadingState, error, reload } = usePlagiarismDetail(checkId); + + return ( +
+ {/* Top nav */} +
+ {onBack && ( + + )} +
+

+ Plagiarism Check Detail +

+

{checkId}

+
+
+ + {/* Body */} +
+ {loadingState === 'loading' && } + {loadingState === 'error' && ( + + )} + {loadingState === 'success' && check && ( + + )} +
+
+ ); +}; diff --git a/src/widgets/fileUpload/ui/Plagiraismcheckform.tsx b/src/widgets/fileUpload/ui/Plagiraismcheckform.tsx index 78e7452..d1e2f5e 100644 --- a/src/widgets/fileUpload/ui/Plagiraismcheckform.tsx +++ b/src/widgets/fileUpload/ui/Plagiraismcheckform.tsx @@ -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 ( <> -
+
{/* ── Header ────────────────────────────────────────────────────── */}
diff --git a/src/widgets/footer/ui/index.tsx b/src/widgets/footer/ui/index.tsx index c32350b..a8033e4 100644 --- a/src/widgets/footer/ui/index.tsx +++ b/src/widgets/footer/ui/index.tsx @@ -1,4 +1,3 @@ -import { PRODUCT_INFO } from '@/shared/constants/data'; const Footer = () => { const shortLinks = [ { name: 'About', href: '/about' }, @@ -29,7 +28,7 @@ const Footer = () => {

diff --git a/src/widgets/history/index.ts b/src/widgets/history/index.ts new file mode 100644 index 0000000..8a0060e --- /dev/null +++ b/src/widgets/history/index.ts @@ -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'; diff --git a/src/widgets/history/lib/constants.ts b/src/widgets/history/lib/constants.ts new file mode 100644 index 0000000..62f9269 --- /dev/null +++ b/src/widgets/history/lib/constants.ts @@ -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; diff --git a/src/widgets/history/lib/mock.ts b/src/widgets/history/lib/mock.ts new file mode 100644 index 0000000..359882e --- /dev/null +++ b/src/widgets/history/lib/mock.ts @@ -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', + }, +]; diff --git a/src/widgets/history/lib/types.ts b/src/widgets/history/lib/types.ts index f93a154..c852077 100644 --- a/src/widgets/history/lib/types.ts +++ b/src/widgets/history/lib/types.ts @@ -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; } diff --git a/src/widgets/history/lib/useHistory.ts b/src/widgets/history/lib/useHistory.ts new file mode 100644 index 0000000..3c810b8 --- /dev/null +++ b/src/widgets/history/lib/useHistory.ts @@ -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({ + 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, + }; +}; diff --git a/src/widgets/history/lib/utils.ts b/src/widgets/history/lib/utils.ts index 0051dab..8e6324f 100644 --- a/src/widgets/history/lib/utils.ts +++ b/src/widgets/history/lib/utils.ts @@ -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 => { - 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 => { + 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; }; /** - * 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 => { + 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; +}; + +// ─── 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}`; }; diff --git a/src/widgets/history/ui/historyPage.tsx b/src/widgets/history/ui/historyPage.tsx new file mode 100644 index 0000000..a0e1231 --- /dev/null +++ b/src/widgets/history/ui/historyPage.tsx @@ -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 = () => ( +
+

+ Check History +

+

+ All plagiarism checks submitted by you +

+
+); + +// ─── HistoryPage ─────────────────────────────────────────────────────────────── + +export const HistoryPage = () => { + const { items, status, error, refetch, currentPage, totalPages, goToPage } = + useHistory(); + + return ( +
+
+ + + + + +
+
+ ); +}; diff --git a/src/widgets/history/ui/historyTable.tsx b/src/widgets/history/ui/historyTable.tsx new file mode 100644 index 0000000..5ebfec7 --- /dev/null +++ b/src/widgets/history/ui/historyTable.tsx @@ -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 = () => ( + + + {TABLE_COLUMNS.map((col) => ( + + {col.label} + + ))} + + +); + +// ─── Table Body ──────────────────────────────────────────────────────────────── + +const TableBody: React.FC = ({ + items, + isLoading, + error, + onRetry, +}) => { + if (isLoading) { + return ( + + {Array.from({ length: 6 }).map((_, i) => ( + + ))} + + ); + } + + if (false) { + return ( + + {})} /> + + ); + } + + if (false) { + return ( + + + + ); + } + + return ( + + {items.map((item) => ( + + ))} + + ); +}; + +// ─── HistoryTable ────────────────────────────────────────────────────────────── + +export const HistoryTable: React.FC = (props) => ( +
+ + + +
+
+); diff --git a/src/widgets/history/ui/historyTableRow.tsx b/src/widgets/history/ui/historyTableRow.tsx new file mode 100644 index 0000000..5406b5a --- /dev/null +++ b/src/widgets/history/ui/historyTableRow.tsx @@ -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 = ({ item }) => { + const router = useRouter(); + return ( + + {/* Sender */} + + + {item.senderFullName} + + + + {/* File Name */} + +
+ + + + + {truncateFileName(item.fileName)} + +
+ + + {/* Date */} + + + {formatDate(item.date)} + + + + {/* Amount */} + + + {item.paymentAmount} + + + + {/* Result */} + + + + + {/* View Button */} + + + + + ); +}; diff --git a/src/widgets/history/ui/pagination.tsx b/src/widgets/history/ui/pagination.tsx new file mode 100644 index 0000000..e5abbfe --- /dev/null +++ b/src/widgets/history/ui/pagination.tsx @@ -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, +}) => ( + + {direction === 'left' ? ( + + ) : ( + + )} + +); + +export const Pagination: React.FC = ({ + 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 ( +
+ + Page {currentPage} of {totalPages} + + +
+ {/* Prev */} + + + {/* Page numbers */} + {getPages().map((p, i) => + p === '…' ? ( + + … + + ) : ( + + ), + )} + + {/* Next */} + +
+
+ ); +}; diff --git a/src/widgets/history/ui/resultBadge.tsx b/src/widgets/history/ui/resultBadge.tsx new file mode 100644 index 0000000..ca7e07d --- /dev/null +++ b/src/widgets/history/ui/resultBadge.tsx @@ -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 = ({ result }) => { + const { label, className } = RESULT_CONFIG[result]; + + return ( + + {label} + + ); +}; diff --git a/src/widgets/history/ui/tableStates.tsx b/src/widgets/history/ui/tableStates.tsx new file mode 100644 index 0000000..ecb179d --- /dev/null +++ b/src/widgets/history/ui/tableStates.tsx @@ -0,0 +1,75 @@ +'use client'; +import React from 'react'; +import { EmptyStateProps, SkeletonRowProps } from '../lib/types'; + +// ─── Empty State ─────────────────────────────────────────────────────────────── + +export const EmptyState: React.FC = ({ + message = 'No plagiarism checks found.', +}) => ( + + +
+ + + + {message} +
+ + +); + +// ─── Skeleton Row ────────────────────────────────────────────────────────────── + +const SkeletonCell: React.FC<{ width?: string }> = ({ width = 'w-24' }) => ( + +
+ +); + +export const SkeletonRow: React.FC = ({ columns }) => { + const widths = ['w-32', 'w-40', 'w-20', 'w-24', 'w-20', 'w-16']; + return ( + + {Array.from({ length: columns }).map((_, i) => ( + + ))} + + ); +}; + +// ─── Error State ─────────────────────────────────────────────────────────────── + +interface ErrorStateProps { + message: string | null | undefined; + onRetry: () => void; +} + +export const ErrorState: React.FC = ({ message, onRetry }) => ( + + +
+
+ + + + {message} +
+ +
+ + +); diff --git a/src/widgets/navbar/ui/index.tsx b/src/widgets/navbar/ui/index.tsx index 65d499d..ce535fb 100644 --- a/src/widgets/navbar/ui/index.tsx +++ b/src/widgets/navbar/ui/index.tsx @@ -13,7 +13,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'; @@ -29,14 +28,7 @@ const Navbar = () => {
{/* Logo */} - {PRODUCT_INFO.name} - - {PRODUCT_INFO.name} - + Plagat
@@ -54,11 +46,7 @@ const Navbar = () => {
{/* Logo */} - {PRODUCT_INFO.name} + Plagat
@@ -73,11 +61,7 @@ const Navbar = () => { - {PRODUCT_INFO.name} + Plagat diff --git a/src/widgets/history/lib/constant.ts b/src/widgets/paymentModal/lib/constant.ts similarity index 100% rename from src/widgets/history/lib/constant.ts rename to src/widgets/paymentModal/lib/constant.ts diff --git a/src/widgets/paymentModal/lib/types.ts b/src/widgets/paymentModal/lib/types.ts new file mode 100644 index 0000000..f93a154 --- /dev/null +++ b/src/widgets/paymentModal/lib/types.ts @@ -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; +} diff --git a/src/widgets/history/lib/usePayment.ts b/src/widgets/paymentModal/lib/usePayment.ts similarity index 99% rename from src/widgets/history/lib/usePayment.ts rename to src/widgets/paymentModal/lib/usePayment.ts index b81965b..195e3aa 100644 --- a/src/widgets/history/lib/usePayment.ts +++ b/src/widgets/paymentModal/lib/usePayment.ts @@ -1,3 +1,4 @@ +'use client'; import { useState, useCallback } from 'react'; import { PaymentStatus, PaymePaymentResponse } from './types'; import { diff --git a/src/widgets/paymentModal/lib/utils.ts b/src/widgets/paymentModal/lib/utils.ts new file mode 100644 index 0000000..0051dab --- /dev/null +++ b/src/widgets/paymentModal/lib/utils.ts @@ -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 => { + 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; +}; diff --git a/src/widgets/history/ui/Paymebutton.tsx b/src/widgets/paymentModal/ui/Paymebutton.tsx similarity index 100% rename from src/widgets/history/ui/Paymebutton.tsx rename to src/widgets/paymentModal/ui/Paymebutton.tsx diff --git a/src/widgets/history/ui/Paymentmodal.tsx b/src/widgets/paymentModal/ui/Paymentmodal.tsx similarity index 99% rename from src/widgets/history/ui/Paymentmodal.tsx rename to src/widgets/paymentModal/ui/Paymentmodal.tsx index 4f5e1e2..ea0f212 100644 --- a/src/widgets/history/ui/Paymentmodal.tsx +++ b/src/widgets/paymentModal/ui/Paymentmodal.tsx @@ -1,3 +1,4 @@ +'use client'; import React, { useEffect, useRef } from 'react'; import { PaymentModalProps } from '../lib/types'; import { getPricing } from '../lib/utils'; diff --git a/src/widgets/history/ui/Pricesummary.tsx b/src/widgets/paymentModal/ui/Pricesummary.tsx similarity index 99% rename from src/widgets/history/ui/Pricesummary.tsx rename to src/widgets/paymentModal/ui/Pricesummary.tsx index 9d55b23..975404d 100644 --- a/src/widgets/history/ui/Pricesummary.tsx +++ b/src/widgets/paymentModal/ui/Pricesummary.tsx @@ -1,3 +1,4 @@ +'use client'; import React from 'react'; import { formatPrice } from '../lib/utils'; import { PriceSummaryProps } from '../lib/types';