From db0fad7e00936b63a015a94ec520db6ab8a859b0 Mon Sep 17 00:00:00 2001 From: "nabijonovdavronbek619@gmail.com" Date: Mon, 6 Apr 2026 17:55:54 +0500 Subject: [PATCH] profile page ui complated --- package-lock.json | 100 +++++++++ package.json | 1 + public/sitemap.xml | 89 +++++--- src/app/sitemap.ts | 17 +- src/features/modals/siModal/index.ts | 14 ++ src/features/modals/siModal/page.tsx | 52 +++++ src/features/modals/siModal/ui/dropZone.tsx | 67 ++++++ .../modals/siModal/ui/fileUploadModal.tsx | 199 ++++++++++++++++++ src/features/modals/siModal/ui/modalParts.tsx | 126 +++++++++++ src/features/modals/siModal/utils/pricing.ts | 20 ++ src/features/modals/siModal/utils/tyeps.ts | 24 +++ .../modals/siModal/utils/useFileUpload.ts | 128 +++++++++++ .../modals/siModal/utils/wordCount.ts | 93 ++++++++ src/shared/ui/navigation-menu.tsx | 2 +- src/widgets/cabinet/ui/CabinetNav.tsx | 43 ++++ src/widgets/cabinet/ui/Sidebar.tsx | 7 + src/widgets/cabinet/ui/dashboard/CtaCards.tsx | 74 +++---- src/widgets/cabinet/ui/index.tsx | 5 +- 18 files changed, 976 insertions(+), 85 deletions(-) create mode 100644 src/features/modals/siModal/index.ts create mode 100644 src/features/modals/siModal/page.tsx create mode 100644 src/features/modals/siModal/ui/dropZone.tsx create mode 100644 src/features/modals/siModal/ui/fileUploadModal.tsx create mode 100644 src/features/modals/siModal/ui/modalParts.tsx create mode 100644 src/features/modals/siModal/utils/pricing.ts create mode 100644 src/features/modals/siModal/utils/tyeps.ts create mode 100644 src/features/modals/siModal/utils/useFileUpload.ts create mode 100644 src/features/modals/siModal/utils/wordCount.ts create mode 100644 src/widgets/cabinet/ui/CabinetNav.tsx diff --git a/package-lock.json b/package-lock.json index 75440b7..94db69e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -35,6 +35,7 @@ "clsx": "^2.1.1", "dayjs": "^1.11.13", "framer-motion": "^12.38.0", + "jszip": "^3.10.1", "lucide-react": "^0.503.0", "next": "15.5.9", "next-intl": "^4.3.9", @@ -4965,6 +4966,12 @@ "dev": true, "license": "MIT" }, + "node_modules/core-util-is": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", + "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==", + "license": "MIT" + }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -6570,6 +6577,12 @@ "node": ">= 4" } }, + "node_modules/immediate": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz", + "integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==", + "license": "MIT" + }, "node_modules/import-fresh": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", @@ -6597,6 +6610,12 @@ "node": ">=0.8.19" } }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, "node_modules/internal-slot": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.1.0.tgz", @@ -7165,6 +7184,18 @@ "node": ">=4.0" } }, + "node_modules/jszip": { + "version": "3.10.1", + "resolved": "https://registry.npmjs.org/jszip/-/jszip-3.10.1.tgz", + "integrity": "sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==", + "license": "(MIT OR GPL-3.0-or-later)", + "dependencies": { + "lie": "~3.3.0", + "pako": "~1.0.2", + "readable-stream": "~2.3.6", + "setimmediate": "^1.0.5" + } + }, "node_modules/keyv": { "version": "4.5.4", "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", @@ -7209,6 +7240,15 @@ "node": ">= 0.8.0" } }, + "node_modules/lie": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz", + "integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==", + "license": "MIT", + "dependencies": { + "immediate": "~3.0.5" + } + }, "node_modules/lightningcss": { "version": "1.30.2", "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.30.2.tgz", @@ -8160,6 +8200,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/pako": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz", + "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==", + "license": "(MIT AND Zlib)" + }, "node_modules/parent-module": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", @@ -8314,6 +8360,12 @@ "node": ">=6.0.0" } }, + "node_modules/process-nextick-args": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", + "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", + "license": "MIT" + }, "node_modules/prop-types": { "version": "15.8.1", "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", @@ -8505,6 +8557,27 @@ "react-dom": ">=16.6.0" } }, + "node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "license": "MIT", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/readable-stream/node_modules/isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", + "license": "MIT" + }, "node_modules/recharts": { "version": "2.15.4", "resolved": "https://registry.npmjs.org/recharts/-/recharts-2.15.4.tgz", @@ -8713,6 +8786,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "license": "MIT" + }, "node_modules/safe-json-stringify": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/safe-json-stringify/-/safe-json-stringify-1.2.0.tgz", @@ -8822,6 +8901,12 @@ "node": ">= 0.4" } }, + "node_modules/setimmediate": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz", + "integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==", + "license": "MIT" + }, "node_modules/sharp": { "version": "0.34.5", "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.5.tgz", @@ -9049,6 +9134,15 @@ "node": ">= 0.4" } }, + "node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, "node_modules/string-argv": { "version": "0.3.2", "resolved": "https://registry.npmjs.org/string-argv/-/string-argv-0.3.2.tgz", @@ -9680,6 +9774,12 @@ "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "license": "MIT" + }, "node_modules/vaul": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/vaul/-/vaul-1.1.2.tgz", diff --git a/package.json b/package.json index 9b02c25..b33017f 100644 --- a/package.json +++ b/package.json @@ -38,6 +38,7 @@ "clsx": "^2.1.1", "dayjs": "^1.11.13", "framer-motion": "^12.38.0", + "jszip": "^3.10.1", "lucide-react": "^0.503.0", "next": "15.5.9", "next-intl": "^4.3.9", diff --git a/public/sitemap.xml b/public/sitemap.xml index 1e280d0..570f0d9 100644 --- a/public/sitemap.xml +++ b/public/sitemap.xml @@ -1,57 +1,84 @@ - https://antiplagiat.uz/uz - 2026-04-04 + https://anti-plagiat.uz/uz + 2026-04-06 daily 1.0 - - - + + + - https://antiplagiat.uz/uz/plagat - 2026-04-04 + https://anti-plagiat.uz/uz/plagat + 2026-04-06 weekly 0.8 - - - + + + - https://antiplagiat.uz/ru - 2026-04-04 + https://anti-plagiat.uz/uz/cabinet + 2026-04-06 + weekly + 0.7 + + + + + + https://anti-plagiat.uz/ru + 2026-04-06 daily 1.0 - - - + + + - https://antiplagiat.uz/ru/plagat - 2026-04-04 + https://anti-plagiat.uz/ru/plagat + 2026-04-06 weekly 0.8 - - - + + + - https://antiplagiat.uz/en - 2026-04-04 + https://anti-plagiat.uz/ru/cabinet + 2026-04-06 + weekly + 0.7 + + + + + + https://anti-plagiat.uz/en + 2026-04-06 daily 1.0 - - - + + + - https://antiplagiat.uz/en/plagat - 2026-04-04 + https://anti-plagiat.uz/en/plagat + 2026-04-06 weekly 0.8 - - - + + + - \ No newline at end of file + + https://anti-plagiat.uz/en/cabinet + 2026-04-06 + weekly + 0.7 + + + + + diff --git a/src/app/sitemap.ts b/src/app/sitemap.ts index 9d2e03f..170b8c5 100644 --- a/src/app/sitemap.ts +++ b/src/app/sitemap.ts @@ -1,10 +1,13 @@ import { MetadataRoute } from 'next'; -const SITE_URL = process.env.NEXT_PUBLIC_SITE_URL ?? 'https://antiplagiat.uz'; +const SITE_URL = process.env.NEXT_PUBLIC_SITE_URL ?? 'https://anti-plagiat.uz'; const LOCALES = ['uz', 'ru', 'en'] as const; -// Add your static page slugs here -const STATIC_ROUTES = ['', '/plagat']; +const STATIC_ROUTES = [ + { path: '', changeFreq: 'daily' as const, priority: 1.0 }, + { path: '/plagat', changeFreq: 'weekly' as const, priority: 0.8 }, + { path: '/cabinet', changeFreq: 'weekly' as const, priority: 0.7 }, +]; export default function sitemap(): MetadataRoute.Sitemap { const entries: MetadataRoute.Sitemap = []; @@ -12,13 +15,13 @@ export default function sitemap(): MetadataRoute.Sitemap { for (const locale of LOCALES) { for (const route of STATIC_ROUTES) { entries.push({ - url: `${SITE_URL}/${locale}${route}`, + url: `${SITE_URL}/${locale}${route.path}`, lastModified: new Date(), - changeFrequency: route === '' ? 'daily' : 'weekly', - priority: route === '' ? 1.0 : 0.8, + changeFrequency: route.changeFreq, + priority: route.priority, alternates: { languages: Object.fromEntries( - LOCALES.map((l) => [l, `${SITE_URL}/${l}${route}`]), + LOCALES.map((l) => [l, `${SITE_URL}/${l}${route.path}`]), ), }, }); diff --git a/src/features/modals/siModal/index.ts b/src/features/modals/siModal/index.ts new file mode 100644 index 0000000..e31ca23 --- /dev/null +++ b/src/features/modals/siModal/index.ts @@ -0,0 +1,14 @@ +export { FileUploadModal } from './ui/fileUploadModal'; +export { useFileUpload } from './utils/useFileUpload'; +export { + countWordsFromFile, + SUPPORTED_EXTENSIONS, + SUPPORTED_MIME_TYPES, +} from './utils/wordCount'; +export { calculatePrice, formatPrice, DEFAULT_PRICING } from './utils/pricing'; +export type { + FileUploadModalProps, + UploadedFile, + PricingConfig, + WordCountResult, +} from './utils/tyeps'; diff --git a/src/features/modals/siModal/page.tsx b/src/features/modals/siModal/page.tsx new file mode 100644 index 0000000..2254d0c --- /dev/null +++ b/src/features/modals/siModal/page.tsx @@ -0,0 +1,52 @@ +'use client'; + +import { useState } from 'react'; +import { ArrowRight, BrainCircuit } from 'lucide-react'; +import { FileUploadModal } from './ui/fileUploadModal'; + +export default function SiCTACard() { + const [isOpen, setIsOpen] = useState(false); + const [lastSubmission, setLastSubmission] = useState<{ + name: string; + words: number; + } | null>(null); + + const handleSubmit = ( + documentName: string, + _file: File, + wordCount: number, + ) => { + // Here you would send the file to your backend for plagiarism check. + // The word count is already computed client-side for instant pricing display. + console.log(lastSubmission); + console.log('Submitting:', { documentName, wordCount }); + setLastSubmission({ name: documentName, words: wordCount }); + setIsOpen(false); + }; + + return ( + <> + + setIsOpen(false)} + onSubmit={handleSubmit} + /> + + ); +} diff --git a/src/features/modals/siModal/ui/dropZone.tsx b/src/features/modals/siModal/ui/dropZone.tsx new file mode 100644 index 0000000..d009834 --- /dev/null +++ b/src/features/modals/siModal/ui/dropZone.tsx @@ -0,0 +1,67 @@ +'use client'; + +import { Upload } from 'lucide-react'; +import { SUPPORTED_EXTENSIONS } from '../utils/wordCount'; + +interface DropZoneProps { + isDragging: boolean; + onDrop: (e: React.DragEvent) => void; + onDragOver: (e: React.DragEvent) => void; + onDragLeave: () => void; + onClick: () => void; +} + +export function DropZone({ + isDragging, + onDrop, + onDragOver, + onDragLeave, + onClick, +}: DropZoneProps) { + return ( +
e.key === 'Enter' && onClick()} + className={` + relative flex flex-col items-center justify-center gap-3 + rounded-xl border-2 border-dashed px-6 py-2 + cursor-pointer select-none transition-all duration-200 outline-none + focus-visible:ring-2 focus-visible:ring-blue-400 focus-visible:ring-offset-2 + ${ + isDragging + ? 'border-blue-400 bg-blue-50 scale-[1.01]' + : 'border-slate-200 bg-slate-50 hover:border-blue-300 hover:bg-blue-50/50' + } + `} + > +
+ +
+ +
+

+ {isDragging ? 'Drop file here' : 'Drag & drop or click to browse'} +

+

+ Supported: {SUPPORTED_EXTENSIONS.join(', ')} · Max 50 MB +

+
+
+ ); +} diff --git a/src/features/modals/siModal/ui/fileUploadModal.tsx b/src/features/modals/siModal/ui/fileUploadModal.tsx new file mode 100644 index 0000000..7abb502 --- /dev/null +++ b/src/features/modals/siModal/ui/fileUploadModal.tsx @@ -0,0 +1,199 @@ +'use client'; + +import { useEffect } from 'react'; +import { X } from 'lucide-react'; +import { calculatePrice, DEFAULT_PRICING, formatPrice } from '../utils/pricing'; +import { FileUploadModalProps } from '../utils/tyeps'; +import { useFileUpload } from '../utils/useFileUpload'; +import { SUPPORTED_EXTENSIONS } from '../utils/wordCount'; +import { ErrorBanner, FileChip, PricingInfo, Spinner } from './modalParts'; +import { DropZone } from './dropZone'; + +export function FileUploadModal({ + isOpen, + onClose, + onSubmit, + pricing = DEFAULT_PRICING, +}: FileUploadModalProps) { + const { + documentName, + setDocumentName, + uploadedFile, + isDragging, + isProcessing, + error, + fileInputRef, + handleFileSelect, + handleDrop, + handleDragOver, + handleDragLeave, + handleRemoveFile, + openFilePicker, + } = useFileUpload(); + + // Close on Escape key + useEffect(() => { + if (!isOpen) return; + const onKey = (e: KeyboardEvent) => e.key === 'Escape' && onClose(); + window.addEventListener('keydown', onKey); + return () => window.removeEventListener('keydown', onKey); + }, [isOpen, onClose]); + + // Lock body scroll while open + useEffect(() => { + document.body.style.overflow = isOpen ? 'hidden' : ''; + return () => { + document.body.style.overflow = ''; + }; + }, [isOpen]); + + if (!isOpen) return null; + + const canSubmit = + documentName.trim().length > 0 && + uploadedFile?.status === 'done' && + !isProcessing; + + const wordCount = uploadedFile?.wordCount ?? 0; + const totalPrice = calculatePrice(wordCount, pricing); + + const handleSubmit = () => { + if (!canSubmit || !uploadedFile) return; + onSubmit(documentName.trim(), uploadedFile.file, wordCount); + }; + + return ( + // Backdrop +
e.target === e.currentTarget && onClose()} + role="dialog" + aria-modal="true" + aria-labelledby="modal-title" + > + {/* Panel */} +
+ {/* Header */} +
+ + +
+ + {/* Document name */} +
+ + setDocumentName(e.target.value)} + placeholder="Enter document name…" + className=" + w-full rounded-xl border border-slate-200 bg-white px-4 py-3 + text-sm text-slate-800 placeholder:text-slate-400 + outline-none transition-all duration-150 + focus:border-blue-400 focus:ring-4 focus:ring-blue-400/10 + " + /> +
+ + {/* Hidden file input */} + { + const file = e.target.files?.[0]; + if (file) handleFileSelect(file); + }} + /> + + {/* Drop zone OR file chip */} + {uploadedFile ? ( +
+ +
+ ) : ( + + )} + + {/* Error */} + {error && } + + {/* Pricing info — shown once word count is ready */} + {uploadedFile?.status === 'done' && wordCount > 0 && ( + + )} + + {/* Footer */} +
+ +
+
+
+ ); +} diff --git a/src/features/modals/siModal/ui/modalParts.tsx b/src/features/modals/siModal/ui/modalParts.tsx new file mode 100644 index 0000000..05836eb --- /dev/null +++ b/src/features/modals/siModal/ui/modalParts.tsx @@ -0,0 +1,126 @@ +'use client'; + +import { X, FileText, Loader2, AlertCircle } from 'lucide-react'; +import { UploadedFile } from '../utils/tyeps'; + +// ── Spinner ───────────────────────────────────────────────────────────────── +export function Spinner({ className = '' }: { className?: string }) { + return ( + + ); +} + +// ── File chip shown after upload ───────────────────────────────────────────── +interface FileChipProps { + file: UploadedFile; + onRemove: () => void; +} + +export function FileChip({ file, onRemove }: FileChipProps) { + const isLoading = file.status === 'uploading'; + + return ( +
+
+ +
+

+ {file.name} +

+

+ {file.sizeKB} KB +

+
+
+ +
+ {isLoading ? ( + + ) : ( + + {file.status === 'error' ? 'Error' : 'Upload complete'} + + )} + +
+
+ ); +} + +// ── Pricing info block ─────────────────────────────────────────────────────── +interface PricingInfoProps { + wordCount: number; + minimumPrice: string; + totalPrice: string; +} + +export function PricingInfo({ + wordCount, + minimumPrice, + totalPrice, +}: PricingInfoProps) { + return ( +
+

+ Document check price is determined by the number of words in the + document. +

+

+ Minimum payment for one document: {minimumPrice} +

+

+ Document check price: {wordCount.toLocaleString()} words for{' '} + {totalPrice} +

+
+ ); +} + +// ── Error banner ───────────────────────────────────────────────────────────── +export function ErrorBanner({ message }: { message: string }) { + return ( +
+ +

{message}

+
+ ); +} diff --git a/src/features/modals/siModal/utils/pricing.ts b/src/features/modals/siModal/utils/pricing.ts new file mode 100644 index 0000000..5cdfebf --- /dev/null +++ b/src/features/modals/siModal/utils/pricing.ts @@ -0,0 +1,20 @@ +import { PricingConfig } from './tyeps'; + +const DEFAULT_PRICING: PricingConfig = { + pricePerWord: 4, // 4 so'm per word + minimumPayment: 10000, // 10 000 so'm minimum +}; + +export function calculatePrice( + wordCount: number, + config: PricingConfig = DEFAULT_PRICING, +): number { + const calculated = wordCount * config.pricePerWord; + return Math.max(calculated, config.minimumPayment); +} + +export function formatPrice(amount: number): string { + return new Intl.NumberFormat('uz-UZ').format(amount) + " so'm"; +} + +export { DEFAULT_PRICING }; diff --git a/src/features/modals/siModal/utils/tyeps.ts b/src/features/modals/siModal/utils/tyeps.ts new file mode 100644 index 0000000..1c705e0 --- /dev/null +++ b/src/features/modals/siModal/utils/tyeps.ts @@ -0,0 +1,24 @@ +export interface UploadedFile { + file: File; + name: string; + sizeKB: number; + wordCount: number; + status: 'uploading' | 'done' | 'error'; +} + +export interface PricingConfig { + pricePerWord: number; // in so'm + minimumPayment: number; // in so'm +} + +export interface FileUploadModalProps { + isOpen: boolean; + onClose: () => void; + onSubmit: (documentName: string, file: File, wordCount: number) => void; + pricing?: PricingConfig; +} + +export interface WordCountResult { + count: number; + error?: string; +} diff --git a/src/features/modals/siModal/utils/useFileUpload.ts b/src/features/modals/siModal/utils/useFileUpload.ts new file mode 100644 index 0000000..c65a766 --- /dev/null +++ b/src/features/modals/siModal/utils/useFileUpload.ts @@ -0,0 +1,128 @@ +'use client'; + +import { useState, useCallback, useRef } from 'react'; +import { UploadedFile } from './tyeps'; +import { countWordsFromFile, SUPPORTED_EXTENSIONS } from './wordCount'; + +interface UseFileUploadReturn { + documentName: string; + setDocumentName: (name: string) => void; + uploadedFile: UploadedFile | null; + isDragging: boolean; + isProcessing: boolean; + error: string | null; + fileInputRef: React.RefObject; + handleFileSelect: (file: File) => Promise; + handleDrop: (e: React.DragEvent) => void; + handleDragOver: (e: React.DragEvent) => void; + handleDragLeave: () => void; + handleRemoveFile: () => void; + openFilePicker: () => void; +} + +export function useFileUpload(): UseFileUploadReturn { + const [documentName, setDocumentName] = useState(''); + const [uploadedFile, setUploadedFile] = useState(null); + const [isDragging, setIsDragging] = useState(false); + const [isProcessing, setIsProcessing] = useState(false); + const [error, setError] = useState(null); + const fileInputRef = useRef(null); + + const validateFile = (file: File): string | null => { + const ext = '.' + file.name.split('.').pop()?.toLowerCase(); + if (!SUPPORTED_EXTENSIONS.includes(ext)) { + return `Unsupported file type. Allowed: ${SUPPORTED_EXTENSIONS.join(', ')}`; + } + if (file.size > 50 * 1024 * 1024) { + return 'File size must be less than 50 MB'; + } + return null; + }; + + const handleFileSelect = useCallback(async (file: File) => { + setError(null); + + const validationError = validateFile(file); + if (validationError) { + setError(validationError); + return; + } + + // Optimistic UI: show file immediately + const optimistic: UploadedFile = { + file, + name: file.name, + sizeKB: Math.round(file.size / 1024), + wordCount: 0, + status: 'uploading', + }; + setUploadedFile(optimistic); + setIsProcessing(true); + + // Auto-fill document name if empty + setDocumentName((prev) => + prev.trim() === '' ? file.name.replace(/\.[^/.]+$/, '') : prev, + ); + + // Count words on the frontend (no round-trip needed) + const result = await countWordsFromFile(file); + + if (result.error) { + setError(result.error); + setUploadedFile({ ...optimistic, status: 'error', wordCount: 0 }); + } else { + setUploadedFile({ + ...optimistic, + status: 'done', + wordCount: result.count, + }); + } + + setIsProcessing(false); + }, []); + + const handleDrop = useCallback( + (e: React.DragEvent) => { + e.preventDefault(); + setIsDragging(false); + const file = e.dataTransfer.files[0]; + if (file) handleFileSelect(file); + }, + [handleFileSelect], + ); + + const handleDragOver = useCallback((e: React.DragEvent) => { + e.preventDefault(); + setIsDragging(true); + }, []); + + const handleDragLeave = useCallback(() => { + setIsDragging(false); + }, []); + + const handleRemoveFile = useCallback(() => { + setUploadedFile(null); + setError(null); + if (fileInputRef.current) fileInputRef.current.value = ''; + }, []); + + const openFilePicker = useCallback(() => { + fileInputRef.current?.click(); + }, []); + + return { + documentName, + setDocumentName, + uploadedFile, + isDragging, + isProcessing, + error, + fileInputRef, + handleFileSelect, + handleDrop, + handleDragOver, + handleDragLeave, + handleRemoveFile, + openFilePicker, + }; +} diff --git a/src/features/modals/siModal/utils/wordCount.ts b/src/features/modals/siModal/utils/wordCount.ts new file mode 100644 index 0000000..6ab09a1 --- /dev/null +++ b/src/features/modals/siModal/utils/wordCount.ts @@ -0,0 +1,93 @@ +import { WordCountResult } from './tyeps'; + +// ── Plain text ────────────────────────────────────────────────────────────── +function countWordsFromText(text: string): number { + return text + .trim() + .split(/\s+/) + .filter((word) => word.length > 0).length; +} + +// ── .txt ──────────────────────────────────────────────────────────────────── +async function countFromTxt(file: File): Promise { + const text = await file.text(); + return { count: countWordsFromText(text) }; +} + +// ── .docx (reads raw XML, strips tags) ───────────────────────────────────── +async function countFromDocx(file: File): Promise { + try { + const { default: JSZip } = await import('jszip'); + const zip = await JSZip.loadAsync(await file.arrayBuffer()); + const xmlFile = zip.file('word/document.xml'); + + if (!xmlFile) { + return { count: 0, error: 'Could not read .docx content' }; + } + + const xml = await xmlFile.async('string'); + const text = xml + .replace(/<[^>]+>/g, ' ') // strip XML tags + .replace(/\s+/g, ' ') + .trim(); + + return { count: countWordsFromText(text) }; + } catch { + return { count: 0, error: 'Failed to parse .docx file' }; + } +} + +// ── .pdf (reads raw text layer; won't work for scanned PDFs) ─────────────── +async function countFromPdf(file: File): Promise { + try { + const buffer = await file.arrayBuffer(); + const bytes = new Uint8Array(buffer); + const raw = new TextDecoder('latin1').decode(bytes); + + // Extract text between BT/ET stream operators + const textMatches = raw.match(/BT[\s\S]*?ET/g) ?? []; + const words: string[] = []; + + for (const block of textMatches) { + // Match Tj / TJ operators + const strings = block.match(/\(([^)]*)\)\s*Tj|\[([^\]]*)\]\s*TJ/g) ?? []; + for (const s of strings) { + const inner = s.replace(/\(|\)\s*Tj|\[|\]\s*TJ/g, ''); + words.push(...inner.split(/\s+/).filter((w) => w.length > 0)); + } + } + + return { count: words.length }; + } catch { + return { count: 0, error: 'Failed to parse .pdf file' }; + } +} + +// ── Public API ────────────────────────────────────────────────────────────── +export async function countWordsFromFile(file: File): Promise { + const ext = file.name.split('.').pop()?.toLowerCase(); + + switch (ext) { + case 'txt': + return countFromTxt(file); + case 'docx': + return countFromDocx(file); + case 'pdf': + return countFromPdf(file); + default: + // Fallback: try reading as text + try { + const text = await file.text(); + return { count: countWordsFromText(text) }; + } catch { + return { count: 0, error: `Unsupported file type: .${ext}` }; + } + } +} + +export const SUPPORTED_EXTENSIONS = ['.txt', '.docx', '.pdf']; +export const SUPPORTED_MIME_TYPES = [ + 'text/plain', + 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', + 'application/pdf', +]; diff --git a/src/shared/ui/navigation-menu.tsx b/src/shared/ui/navigation-menu.tsx index 37bad3d..d242416 100644 --- a/src/shared/ui/navigation-menu.tsx +++ b/src/shared/ui/navigation-menu.tsx @@ -145,7 +145,7 @@ function NavigationMenuIndicator({ = { + dashboard: 'Dashboard', + plagiat: 'Plagiat tekshiruvlar', + si: 'SI detektor', + payments: "To'lovlar tarixi", + profile: 'Profil', +}; + +// ─── Props ───────────────────────────────────────────────────────────────────── + +interface CabinetNavProps { + activeSection: CabinetSection; + onMenuClick: () => void; +} + +// ─── Component ───────────────────────────────────────────────────────────────── + +export const CabinetNav: React.FC = ({ + activeSection, + onMenuClick, +}) => ( +
+
+ +

+ {SECTION_LABELS[activeSection]} +

+
+
+); diff --git a/src/widgets/cabinet/ui/Sidebar.tsx b/src/widgets/cabinet/ui/Sidebar.tsx index 3fe5f59..e269c86 100644 --- a/src/widgets/cabinet/ui/Sidebar.tsx +++ b/src/widgets/cabinet/ui/Sidebar.tsx @@ -140,6 +140,13 @@ export const Sidebar: React.FC = ({ ); })} + + {/* Footer */} +
+

+ © 2026 Plagat.uz +

+
); diff --git a/src/widgets/cabinet/ui/dashboard/CtaCards.tsx b/src/widgets/cabinet/ui/dashboard/CtaCards.tsx index 6ecf177..c8cfcec 100644 --- a/src/widgets/cabinet/ui/dashboard/CtaCards.tsx +++ b/src/widgets/cabinet/ui/dashboard/CtaCards.tsx @@ -1,49 +1,33 @@ import React from 'react'; -import { FileSearch, BrainCircuit, ArrowRight } from 'lucide-react'; -import type { CabinetSection } from '../../lib/types'; +import { FileSearch, ArrowRight } from 'lucide-react'; +import Link from 'next/link'; +import SiCTACard from '@/features/modals/siModal/page'; -interface CtaCardsProps { - onNavigate: (section: CabinetSection) => void; -} +export const CtaCards = () => ( + <> +
+ {/* Plagiat */} + +
+ +
+ +

+ Plagiat tekshiruvi +

+

+ Hujjatingizni originallik uchun tekshiring +

+ + Yuborish + + -export const CtaCards: React.FC = ({ onNavigate }) => ( -
- {/* Plagiat */} - - - {/* SI */} - -
+ {/* SI */} + +
+ ); diff --git a/src/widgets/cabinet/ui/index.tsx b/src/widgets/cabinet/ui/index.tsx index 1e626ec..8cd585c 100644 --- a/src/widgets/cabinet/ui/index.tsx +++ b/src/widgets/cabinet/ui/index.tsx @@ -3,6 +3,7 @@ import React from 'react'; import dynamic from 'next/dynamic'; import { AnimatePresence, motion } from 'framer-motion'; import { Sidebar } from './Sidebar'; +import { CabinetNav } from './CabinetNav'; import { Dashboard } from './dashboard'; import { useCabinet } from '../lib/hooks/useCabinet'; import { @@ -91,7 +92,7 @@ export const CabinetLayout: React.FC = () => { const fullName = `${MOCK_USER.name} ${MOCK_USER.surname}`; return ( -
+
{ />
+ +