Merge pull request #20 from DavronNabijonv/new_changes

New changes
This commit is contained in:
Davronbek Nabijonov
2026-04-10 17:07:30 +05:00
committed by GitHub
89 changed files with 5069 additions and 603 deletions

100
package-lock.json generated
View File

@@ -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",

View File

@@ -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",

178
public/modules.html Normal file
View File

@@ -0,0 +1,178 @@
<style>
.stat-row{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:10px;margin-bottom:2rem}
.stat{background:var(--color-background-secondary);border-radius:var(--border-radius-md);padding:14px 16px}
.stat-n{font-size:22px;font-weight:500;color:var(--color-text-primary);line-height:1}
.stat-l{font-size:12px;color:var(--color-text-secondary);margin-top:4px}
.cat-header{display:flex;align-items:center;gap:10px;margin:1.75rem 0 0.85rem}
.cat-line{flex:1;height:0.5px;background:var(--color-border-tertiary)}
.cat-label{font-size:11px;font-weight:500;letter-spacing:.07em;color:var(--color-text-secondary);white-space:nowrap}
.grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(260px,1fr));gap:10px}
.card{background:var(--color-background-primary);border:0.5px solid var(--color-border-tertiary);border-radius:var(--border-radius-lg);padding:12px 14px;display:flex;align-items:flex-start;gap:10px}
.icon{width:34px;height:34px;border-radius:var(--border-radius-md);display:flex;align-items:center;justify-content:center;flex-shrink:0}
.body{flex:1;min-width:0}
.name{font-size:13px;font-weight:500;color:var(--color-text-primary);margin-bottom:3px;line-height:1.4}
.desc{font-size:12px;color:var(--color-text-secondary);line-height:1.5;margin-bottom:6px}
.tags{display:flex;flex-wrap:wrap;gap:5px}
.tag{font-size:10px;font-weight:500;padding:2px 8px;border-radius:20px}
.t-sci{background:#E6F1FB;color:#0C447C}
.t-ai{background:#EEEDFE;color:#3C3489}
.t-legal{background:#FAEEDA;color:#633806}
.t-med{background:#FBEAF0;color:#72243E}
.t-int{background:#E1F5EE;color:#085041}
.t-free{background:#EAF3DE;color:#27500A}
.t-oav{background:#F1EFE8;color:#444441}
.t-inner{background:#F1EFE8;color:#444441}
.num{display:inline-flex;align-items:center;justify-content:center;width:18px;height:18px;border-radius:50%;background:var(--color-background-secondary);font-size:10px;font-weight:500;color:var(--color-text-secondary);flex-shrink:0;margin-top:1px}
</style>
<div style="padding:1.5rem 0 2rem">
<div class="stat-row">
<div class="stat"><div class="stat-n">30</div><div class="stat-l">Jami modullar</div></div>
<div class="stat"><div class="stat-n">10</div><div class="stat-l">Bepul internet manbalari</div></div>
<div class="stat"><div class="stat-n">5</div><div class="stat-l">AI tahlil modullari</div></div>
<div class="stat"><div class="stat-n">4</div><div class="stat-l">Kategoriya</div></div>
</div>
<div class="cat-header"><div class="cat-line"></div><span class="cat-label">Ilmiy va ta'lim bazalari</span><div class="cat-line"></div></div>
<div class="grid">
<div class="card">
<div class="icon" style="background:#E6F1FB"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#185FA5" stroke-width="2"><circle cx="11" cy="11" r="8"/><path d="m21 21-4.35-4.35"/></svg></div>
<div class="body"><div class="name">eLIBRARY.RU</div><div class="desc">Rus va xorijiy tillardagi ilmiy maqolalarning to'liq matnlari bazasi</div><div class="tags"><span class="tag t-sci">Ilmiy</span></div></div>
</div>
<div class="card">
<div class="icon" style="background:#EEEDFE"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#534AB7" stroke-width="2"><path d="M12 2L2 7l10 5 10-5-10-5z"/><path d="M2 17l10 5 10-5M2 12l10 5 10-5"/></svg></div>
<div class="body"><div class="name">Публикации eLIBRARY (tarjima va qayta bayon)</div><div class="desc">Tarjima va parafraz qilingan maqolalarni aniqlash</div><div class="tags"><span class="tag t-ai">AI tahlil</span></div></div>
</div>
<div class="card">
<div class="icon" style="background:#E6F1FB"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#185FA5" stroke-width="2"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/></svg></div>
<div class="body"><div class="name">RDK to'plami</div><div class="desc">Rossiya Davlat kutubxonasidan dissertatsiya va avtoreferatlar</div><div class="tags"><span class="tag t-sci">Ilmiy</span></div></div>
</div>
<div class="card">
<div class="icon" style="background:#E6F1FB"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#185FA5" stroke-width="2"><path d="M12 20h9"/><path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4L16.5 3.5z"/></svg></div>
<div class="body"><div class="name">BMK dissertatsiyalari</div><div class="desc">Belarus milliy kutubxonasi dissertatsiyalari va avtoreferatlari</div><div class="tags"><span class="tag t-sci">Ilmiy</span></div></div>
</div>
<div class="card">
<div class="icon" style="background:#EAF3DE"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#3B6D11" stroke-width="2"><rect x="2" y="3" width="20" height="14" rx="2"/><path d="M8 21h8m-4-4v4"/></svg></div>
<div class="body"><div class="name">IEEE</div><div class="desc">Xalqaro elektrotexnika va elektronika muhandislari instituti bazasi</div><div class="tags"><span class="tag t-sci">Ilmiy</span></div></div>
</div>
<div class="card">
<div class="icon" style="background:#EEEDFE"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#534AB7" stroke-width="2"><polyline points="9 11 12 14 22 4"/><path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11"/></svg></div>
<div class="body"><div class="name">IEEE parafraz moduli</div><div class="desc">IEEE maqolalarining qayta bayon qilingan variantlarini aniqlash</div><div class="tags"><span class="tag t-ai">AI tahlil</span></div></div>
</div>
<div class="card">
<div class="icon" style="background:#F1EFE8"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#5F5E5A" stroke-width="2"><rect x="2" y="2" width="20" height="20" rx="2"/><path d="M9 9h6M9 12h6M9 15h4"/></svg></div>
<div class="body"><div class="name">Elektron-kutubxona tizimlari</div><div class="desc">Book.ru, Юрайт, Лань, Айбукс va boshqa ELS bazalari</div><div class="tags"><span class="tag t-sci">Ilmiy</span></div></div>
</div>
<div class="card">
<div class="icon" style="background:#E1F5EE"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#0F6E56" stroke-width="2"><path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/></svg></div>
<div class="body"><div class="name">OTMlar halqasi</div><div class="desc">O'zbekiston oliy ta'lim muassasalari birgalikdagi bazasi</div><div class="tags"><span class="tag t-sci">Ilmiy</span></div></div>
</div>
<div class="card">
<div class="icon" style="background:#E1F5EE"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#0F6E56" stroke-width="2"><path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/></svg></div>
<div class="body"><div class="name">Коллекция НБУ</div><div class="desc">O'zbekiston milliy kutubxonasi to'plami</div><div class="tags"><span class="tag t-sci">Ilmiy</span></div></div>
</div>
</div>
<div class="cat-header"><div class="cat-line"></div><span class="cat-label">Huquqiy va normativ bazalar</span><div class="cat-line"></div></div>
<div class="grid">
<div class="card">
<div class="icon" style="background:#FAEEDA"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#854F0B" stroke-width="2"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/></svg></div>
<div class="body"><div class="name">Patentlar</div><div class="desc">SSSR, O'zbekiston, Rossiya va MDH davlatlari patentlari bazasi</div><div class="tags"><span class="tag t-legal">Huquqiy</span></div></div>
</div>
<div class="card">
<div class="icon" style="background:#FAEEDA"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#854F0B" stroke-width="2"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><path d="M9 13h6M9 17h6M9 9h1"/></svg></div>
<div class="body"><div class="name">ИПС Адилет</div><div class="desc">O'zbekiston qonunchilik bazasi hujjatlari</div><div class="tags"><span class="tag t-legal">Huquqiy</span></div></div>
</div>
</div>
<div class="cat-header"><div class="cat-line"></div><span class="cat-label">Internet tekshiruv modullari</span><div class="cat-line"></div></div>
<div class="grid">
<div class="card">
<div class="icon" style="background:#E1F5EE"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#0F6E56" stroke-width="2"><circle cx="12" cy="12" r="10"/><path d="M2 12h20M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/></svg></div>
<div class="body"><div class="name">Internet PLUS moduli</div><div class="desc">Internet bo'ylab kengaytirilgan chuqur skanerlash</div><div class="tags"><span class="tag t-int">Internet</span></div></div>
</div>
<div class="card">
<div class="icon" style="background:#E1F5EE"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#0F6E56" stroke-width="2"><path d="M5 12h14M12 5l7 7-7 7"/></svg></div>
<div class="body"><div class="name">Internet RU — parafraz</div><div class="desc">Rus internet segmentidagi qayta bayon qilingan qarzlar</div><div class="tags"><span class="tag t-int">Internet</span><span class="tag t-ai">AI tahlil</span></div></div>
</div>
<div class="card">
<div class="icon" style="background:#E1F5EE"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#0F6E56" stroke-width="2"><path d="M5 12h14M12 5l7 7-7 7"/></svg></div>
<div class="body"><div class="name">Internet EN — parafraz</div><div class="desc">Ingliz internet segmentidagi qayta bayon qilingan qarzlar</div><div class="tags"><span class="tag t-int">Internet</span><span class="tag t-ai">AI tahlil</span></div></div>
</div>
<div class="card">
<div class="icon" style="background:#E1F5EE"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#0F6E56" stroke-width="2"><circle cx="12" cy="12" r="10"/><path d="M5 12h14M12 5l7 7-7 7"/></svg></div>
<div class="body"><div class="name">Internet RU — tarjima</div><div class="desc">Rus internet segmentidagi tarjima qilingan qarzlar</div><div class="tags"><span class="tag t-int">Internet</span><span class="tag t-ai">AI tahlil</span></div></div>
</div>
<div class="card">
<div class="icon" style="background:#E1F5EE"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#0F6E56" stroke-width="2"><circle cx="12" cy="12" r="10"/><path d="M5 12h14M12 5l7 7-7 7"/></svg></div>
<div class="body"><div class="name">Internet EN — tarjima</div><div class="desc">Ingliz internet segmentidagi tarjima qilingan qarzlar</div><div class="tags"><span class="tag t-int">Internet</span><span class="tag t-ai">AI tahlil</span></div></div>
</div>
<div class="card">
<div class="icon" style="background:#F1EFE8"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#5F5E5A" stroke-width="2"><circle cx="12" cy="12" r="10"/><path d="M2 12h20M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/></svg></div>
<div class="body"><div class="name">СМИ России и СНГ</div><div class="desc">Rossiya va MDH ommaviy axborot vositalari maqolalari</div><div class="tags"><span class="tag t-oav">OAV</span></div></div>
</div>
<div class="card">
<div class="icon" style="background:#F1EFE8"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#5F5E5A" stroke-width="2"><circle cx="12" cy="12" r="10"/><path d="M2 12h20M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/></svg></div>
<div class="body"><div class="name">Собственная коллекция компании</div><div class="desc">Antiplag.uz ichki hujjatlar to'plami</div><div class="tags"><span class="tag t-inner">Ichki baza</span></div></div>
</div>
</div>
<div class="cat-header"><div class="cat-line"></div><span class="cat-label">Yangi bepul internet manbalari</span><div class="cat-line"></div></div>
<div class="grid">
<div class="card">
<div class="icon" style="background:#FAEEDA"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#854F0B" stroke-width="2"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><path d="M9 13h6M9 17h6"/></svg></div>
<div class="body"><div class="name">consultant.ru</div><div class="desc">Rossiya qonunchiligining elektron bazasi</div><div class="tags"><span class="tag t-free">Bepul</span><span class="tag t-legal">Huquqiy</span></div></div>
</div>
<div class="card">
<div class="icon" style="background:#FAEEDA"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#854F0B" stroke-width="2"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/></svg></div>
<div class="body"><div class="name">kremlin.ru</div><div class="desc">Rossiya prezidenti farmonlari va rasmiy qonunlar</div><div class="tags"><span class="tag t-free">Bepul</span><span class="tag t-legal">Huquqiy</span></div></div>
</div>
<div class="card">
<div class="icon" style="background:#FAEEDA"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#854F0B" stroke-width="2"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/></svg></div>
<div class="body"><div class="name">pravo.gov.ru</div><div class="desc">Rossiya rasmiy huquqiy hujjatlar nashriyoti portali</div><div class="tags"><span class="tag t-free">Bepul</span><span class="tag t-legal">Huquqiy</span></div></div>
</div>
<div class="card">
<div class="icon" style="background:#FAEEDA"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#854F0B" stroke-width="2"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><path d="M9 13h6M9 17h6"/></svg></div>
<div class="body"><div class="name">docs.cntd.ru</div><div class="desc">Texnik normalar va standartlar hujjatlari bazasi</div><div class="tags"><span class="tag t-free">Bepul</span><span class="tag t-legal">Standartlar</span></div></div>
</div>
<div class="card">
<div class="icon" style="background:#E6F1FB"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#185FA5" stroke-width="2"><path d="M22 10v6M2 10l10-5 10 5-10 5z"/><path d="M6 12v5c3 3 9 3 12 0v-5"/></svg></div>
<div class="body"><div class="name">rumc.mininuniver.ru</div><div class="desc">Minin universiteti adaptiv ta'lim dasturlari resursi</div><div class="tags"><span class="tag t-free">Bepul</span><span class="tag t-sci">Ta'lim</span></div></div>
</div>
<div class="card">
<div class="icon" style="background:#E6F1FB"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#185FA5" stroke-width="2"><path d="M22 10v6M2 10l10-5 10 5-10 5z"/><path d="M6 12v5c3 3 9 3 12 0v-5"/></svg></div>
<div class="body"><div class="name">moodle.kstu.ru</div><div class="desc">KSTU universiteti Moodle platformasi o'quv resurslari</div><div class="tags"><span class="tag t-free">Bepul</span><span class="tag t-sci">Ta'lim</span></div></div>
</div>
<div class="card">
<div class="icon" style="background:#E6F1FB"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#185FA5" stroke-width="2"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><line x1="16" y1="13" x2="8" y2="13"/></svg></div>
<div class="body"><div class="name">freereferats.ru</div><div class="desc">Dissertatsiya avtoreferatlari ochiq to'plami (PDF)</div><div class="tags"><span class="tag t-free">Bepul</span><span class="tag t-sci">Ilmiy</span></div></div>
</div>
<div class="card">
<div class="icon" style="background:#E1F5EE"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#0F6E56" stroke-width="2"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/></svg></div>
<div class="body"><div class="name">ktzszmoik.gov.by</div><div class="desc">Belarus nogironlarni ijtimoiy himoya qilish davlat portali</div><div class="tags"><span class="tag t-free">Bepul</span><span class="tag t-int">Internet</span></div></div>
</div>
<div class="card">
<div class="icon" style="background:#F1EFE8"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#5F5E5A" stroke-width="2"><rect x="2" y="7" width="20" height="14" rx="2"/><path d="M16 3H8M12 3v4"/></svg></div>
<div class="body"><div class="name">bizlog.ru</div><div class="desc">Iqtisodiy-boshqaruv terminologiyasi va izohli lug'at</div><div class="tags"><span class="tag t-free">Bepul</span><span class="tag t-int">Internet</span></div></div>
</div>
<div class="card">
<div class="icon" style="background:#E1F5EE"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#0F6E56" stroke-width="2"><circle cx="12" cy="12" r="10"/><path d="M2 12h20M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/></svg></div>
<div class="body"><div class="name">disabilityartsinternational.org</div><div class="desc">Nogironlik va madaniyat bo'yicha xalqaro resurs</div><div class="tags"><span class="tag t-free">Bepul</span><span class="tag t-int">Xalqaro</span></div></div>
</div>
</div>
<div class="cat-header"><div class="cat-line"></div><span class="cat-label">Yordamchi modullar</span><div class="cat-line"></div></div>
<div class="grid">
<div class="card">
<div class="icon" style="background:#EEEDFE"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#534AB7" stroke-width="2"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg></div>
<div class="body"><div class="name">Shablon iboralar</div><div class="desc">Standart kirish so'zlari, universitet nomlari va klişe iboralarni aniqlash</div><div class="tags"><span class="tag t-ai">Avtomatik</span></div></div>
</div>
<div class="card">
<div class="icon" style="background:#EEEDFE"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#534AB7" stroke-width="2"><path d="M3 21c3 0 7-1 7-8V5c0-1.25-.756-2.017-2-2H4c-1.25 0-2 .75-2 1.972V11c0 1.25.75 2 2 2 1 0 1 0 1 1v1c0 1-1 2-2 2s-1 .008-1 1.031V20c0 1 0 1 1 1z"/><path d="M15 21c3 0 7-1 7-8V5c0-1.25-.757-2.017-2-2h-4c-1.25 0-2 .75-2 1.972V11c0 1.25.75 2 2 2h.75c0 2.25.25 4-2.75 4v3c0 1 0 1 1 1z"/></svg></div>
<div class="body"><div class="name">Iqtibos keltirish moduli</div><div class="desc">Hujjatda to'g'ri rasmiylashtirilgan iqtiboslarni avtomatik aniqlash</div><div class="tags"><span class="tag t-ai">Avtomatik</span></div></div>
</div>
</div>
</div>

84
public/sitemap.xml Normal file
View File

@@ -0,0 +1,84 @@
<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9" xmlns:xhtml="http://www.w3.org/1999/xhtml">
<url>
<loc>https://anti-plagiat.uz/uz</loc>
<lastmod>2026-04-06</lastmod>
<changefreq>daily</changefreq>
<priority>1.0</priority>
<xhtml:link rel="alternate" hreflang="uz" href="https://anti-plagiat.uz/uz"/>
<xhtml:link rel="alternate" hreflang="ru" href="https://anti-plagiat.uz/ru"/>
<xhtml:link rel="alternate" hreflang="en" href="https://anti-plagiat.uz/en"/>
</url>
<url>
<loc>https://anti-plagiat.uz/uz/plagat</loc>
<lastmod>2026-04-06</lastmod>
<changefreq>weekly</changefreq>
<priority>0.8</priority>
<xhtml:link rel="alternate" hreflang="uz" href="https://anti-plagiat.uz/uz/plagat"/>
<xhtml:link rel="alternate" hreflang="ru" href="https://anti-plagiat.uz/ru/plagat"/>
<xhtml:link rel="alternate" hreflang="en" href="https://anti-plagiat.uz/en/plagat"/>
</url>
<url>
<loc>https://anti-plagiat.uz/uz/cabinet</loc>
<lastmod>2026-04-06</lastmod>
<changefreq>weekly</changefreq>
<priority>0.7</priority>
<xhtml:link rel="alternate" hreflang="uz" href="https://anti-plagiat.uz/uz/cabinet"/>
<xhtml:link rel="alternate" hreflang="ru" href="https://anti-plagiat.uz/ru/cabinet"/>
<xhtml:link rel="alternate" hreflang="en" href="https://anti-plagiat.uz/en/cabinet"/>
</url>
<url>
<loc>https://anti-plagiat.uz/ru</loc>
<lastmod>2026-04-06</lastmod>
<changefreq>daily</changefreq>
<priority>1.0</priority>
<xhtml:link rel="alternate" hreflang="uz" href="https://anti-plagiat.uz/uz"/>
<xhtml:link rel="alternate" hreflang="ru" href="https://anti-plagiat.uz/ru"/>
<xhtml:link rel="alternate" hreflang="en" href="https://anti-plagiat.uz/en"/>
</url>
<url>
<loc>https://anti-plagiat.uz/ru/plagat</loc>
<lastmod>2026-04-06</lastmod>
<changefreq>weekly</changefreq>
<priority>0.8</priority>
<xhtml:link rel="alternate" hreflang="uz" href="https://anti-plagiat.uz/uz/plagat"/>
<xhtml:link rel="alternate" hreflang="ru" href="https://anti-plagiat.uz/ru/plagat"/>
<xhtml:link rel="alternate" hreflang="en" href="https://anti-plagiat.uz/en/plagat"/>
</url>
<url>
<loc>https://anti-plagiat.uz/ru/cabinet</loc>
<lastmod>2026-04-06</lastmod>
<changefreq>weekly</changefreq>
<priority>0.7</priority>
<xhtml:link rel="alternate" hreflang="uz" href="https://anti-plagiat.uz/uz/cabinet"/>
<xhtml:link rel="alternate" hreflang="ru" href="https://anti-plagiat.uz/ru/cabinet"/>
<xhtml:link rel="alternate" hreflang="en" href="https://anti-plagiat.uz/en/cabinet"/>
</url>
<url>
<loc>https://anti-plagiat.uz/en</loc>
<lastmod>2026-04-06</lastmod>
<changefreq>daily</changefreq>
<priority>1.0</priority>
<xhtml:link rel="alternate" hreflang="uz" href="https://anti-plagiat.uz/uz"/>
<xhtml:link rel="alternate" hreflang="ru" href="https://anti-plagiat.uz/ru"/>
<xhtml:link rel="alternate" hreflang="en" href="https://anti-plagiat.uz/en"/>
</url>
<url>
<loc>https://anti-plagiat.uz/en/plagat</loc>
<lastmod>2026-04-06</lastmod>
<changefreq>weekly</changefreq>
<priority>0.8</priority>
<xhtml:link rel="alternate" hreflang="uz" href="https://anti-plagiat.uz/uz/plagat"/>
<xhtml:link rel="alternate" hreflang="ru" href="https://anti-plagiat.uz/ru/plagat"/>
<xhtml:link rel="alternate" hreflang="en" href="https://anti-plagiat.uz/en/plagat"/>
</url>
<url>
<loc>https://anti-plagiat.uz/en/cabinet</loc>
<lastmod>2026-04-06</lastmod>
<changefreq>weekly</changefreq>
<priority>0.7</priority>
<xhtml:link rel="alternate" hreflang="uz" href="https://anti-plagiat.uz/uz/cabinet"/>
<xhtml:link rel="alternate" hreflang="ru" href="https://anti-plagiat.uz/ru/cabinet"/>
<xhtml:link rel="alternate" hreflang="en" href="https://anti-plagiat.uz/en/cabinet"/>
</url>
</urlset>

View File

@@ -0,0 +1,5 @@
import { CabinetLayout } from '@/widgets/cabinet/ui';
export default function CabinetPage() {
return <CabinetLayout />;
}

View File

@@ -1,5 +1,5 @@
import { PlagiarismCheckForm } from '@/widgets/fileUpload/ui/Plagiraismcheckform';
import { HistoryPage } from '@/widgets/history';
import { PlagiarismCheckForm } from '@/widgets/plagiatCheck/ui/Plagiraismcheckform';
export default function Page() {
return (

View File

@@ -0,0 +1,10 @@
import SiDetailPage from '@/widgets/detail/SiDetailPage';
interface Props {
params: Promise<{ id: string }>;
}
export default async function SiDetail({ params }: Props) {
const { id } = await params;
return <SiDetailPage id={Number(id)} />;
}

View File

@@ -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 = ['', '/about', '/history', '/contact'];
const STATIC_ROUTES = [
{ path: '', changeFreq: 'daily' as const, priority: 1.0 },
{ path: '/plagiat', 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}`]),
),
},
});

View File

@@ -52,13 +52,11 @@ export function useLoginForm() {
console.log('Login successful:', data);
toggleLoginModal();
toast.success('Kirish muvaffaqiyatli!');
route.push('/plagat');
route.push('/plagiat');
},
onError: (err) => {
console.log('Login failed:', err);
setError(err instanceof Error ? err.message : 'Unknown error');
// toggleLoginModal();
toast.error(err instanceof Error ? err.message : 'Unknown error');
},
});
@@ -74,7 +72,6 @@ export function useLoginForm() {
}
loginReqest.mutate({ phone: `998${phone}`, password: password });
sessionStorage.setItem('prev_page', 'login');
};
return {

View File

@@ -122,7 +122,6 @@ export function LoginForm() {
onChange={(e) => setPassword(e.target.value)}
require={true}
type="password"
maxLength={8}
minLength={8}
/>
</div>

View File

@@ -53,12 +53,10 @@ export function useRegisterForm() {
toggleRegisterModal();
setSuccess(true);
toast.success("Ro'yxatdan o'tish muvaffaqiyatli!");
route.push('/plagat');
route.push('/plagiat');
},
onError: (err) => {
// toggleLoginModal();
console.log('Register failed:', err);
toast.error(err instanceof Error ? err.message : 'Unknown error');
},
});

View File

@@ -1,16 +1,5 @@
// ─── 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;
@@ -18,31 +7,21 @@ export interface PaymePaymentRequest {
returnUrl: string;
}
export interface PaymePaymentResponse {
redirectUrl: string;
transactionId: string;
}
export type PaymentStatus = 'idle' | 'loading' | 'success' | 'error';
// ─── Component Props ───────────────────────────────────────────────────────────
export interface PriceCalculate {
service_fee: number;
discount?: number;
certificate?: number;
total_price: number;
}
export interface PaymentModalProps {
isOpen: boolean;
onClose: () => void;
hasCertificate: boolean;
price: PriceCalculate;
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;
hasSertificate: boolean;
}

View File

@@ -0,0 +1,12 @@
// ─── Pricing Utilities ─────────────────────────────────────────────────────────
export const formatPrice = (amount: number, currency: string): string =>
`${amount.toLocaleString('uz-UZ')} ${currency}`;
// ─── Payme API ─────────────────────────────────────────────────────────────────
/**
* Redirects the user to the Payme checkout page.
*/
export const redirectToPayme = (redirectUrl: string): void => {
window.location.href = redirectUrl;
};

View File

@@ -1,7 +1,6 @@
'use client';
import React, { useEffect, useRef } from 'react';
import { PaymentModalProps } from '../lib/types';
import { getPricing } from '../lib/utils';
import { PriceSummary } from './Pricesummary';
import { PaymeButton } from './Paymebutton';
import { useTranslations } from 'next-intl';
@@ -35,38 +34,6 @@ const CloseButton: React.FC<{ onClick: () => void }> = ({ onClick }) => (
</button>
);
// ─── Error Banner ──────────────────────────────────────────────────────────────
// const ErrorBanner: React.FC<{ message: string; onDismiss: () => void }> = ({
// message,
// onDismiss,
// }) => (
// <div
// role="alert"
// className="flex items-start gap-3 rounded-lg bg-red-50 border border-red-200 px-4 py-3"
// >
// <svg
// className="shrink-0 mt-0.5 text-red-500"
// 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>
// <p className="text-sm text-red-700 flex-1">{message}</p>
// <button
// onClick={onDismiss}
// aria-label="Dismiss error"
// className="text-red-400 hover:text-red-600 transition-colors"
// >
// <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
// <line x1="18" y1="6" x2="6" y2="18" /><line x1="6" y1="6" x2="18" y2="18" />
// </svg>
// </button>
// </div>
// );
// ─── Security Badge ────────────────────────────────────────────────────────────
const SecurityBadge: React.FC<{ securityText: string }> = ({
@@ -85,12 +52,12 @@ const SecurityBadge: React.FC<{ securityText: string }> = ({
export const PaymentModal: React.FC<PaymentModalProps> = ({
isOpen,
onClose,
hasCertificate,
price,
onConfirmPayment,
isLoading,
hasSertificate,
}) => {
const dialogRef = useRef<HTMLDivElement>(null);
const pricing = getPricing();
const status = isLoading ? 'loading' : 'idle';
const t = useTranslations('Payment');
@@ -174,11 +141,11 @@ export const PaymentModal: React.FC<PaymentModalProps> = ({
<h3 className="text-xs font-semibold uppercase tracking-widest text-slate-400 mb-3">
{t('orderSummary')}
</h3>
<PriceSummary hasCertificate={hasCertificate} pricing={pricing} />
<PriceSummary priceCalculate={price} />
</div>
{/* Certificate badge */}
{hasCertificate && (
{hasSertificate && (
<div className="flex items-center gap-2 text-sm text-emerald-700 bg-emerald-50 border border-emerald-100 rounded-lg px-3.5 py-2.5">
<svg
width="15"

View File

@@ -1,8 +1,8 @@
'use client';
import React from 'react';
import { formatPrice } from '../lib/utils';
import { PriceSummaryProps } from '../lib/types';
import { useTranslations } from 'next-intl';
import { PriceCalculate } from '../lib/types';
// ─── Price Row ─────────────────────────────────────────────────────────────────
@@ -47,34 +47,45 @@ const PriceRow: React.FC<PriceRowProps> = ({
// ─── Price Summary ─────────────────────────────────────────────────────────────
export const PriceSummary: React.FC<PriceSummaryProps> = ({
hasCertificate,
pricing,
export const PriceSummary = ({
priceCalculate,
}: {
priceCalculate: PriceCalculate;
}) => {
console.log(hasCertificate);
const total = 41200;
const t = useTranslations('Payment');
return (
<div className="rounded-xl bg-slate-50 border border-slate-100 px-5 py-2 space-y-0">
<PriceRow
label={t('serviceFee')}
amount={41200}
currency={pricing.currency}
amount={priceCalculate.service_fee || 0}
currency="UZS"
/>
{/* {hasCertificate && (
{priceCalculate.discount ? (
<PriceRow
label={t('certificateLabel')}
amount={pricing.certificateFee}
currency={pricing.currency}
label={t('discountLabel')}
amount={priceCalculate.discount}
currency="UZS"
/>
)} */}
) : (
''
)}
{priceCalculate.certificate ? (
<PriceRow
label={t('sertificateLabel')}
amount={priceCalculate.certificate}
currency="UZS"
/>
) : (
''
)}
<PriceRow
label={t('total')}
amount={total}
currency={pricing.currency}
amount={priceCalculate.total_price}
currency="UZS"
highlight
/>
</div>

View File

@@ -0,0 +1,37 @@
'use client';
import { type ReactNode } from 'react';
/* ── Field wrapper ─────────────────────────────────────────── */
interface FieldProps {
htmlFor: string;
icon: ReactNode;
label: string;
children: ReactNode;
}
export function Field({ htmlFor, icon, label, children }: FieldProps) {
return (
<div className="space-y-1.5">
<label
htmlFor={htmlFor}
className="flex items-center gap-1.5 text-[13px] font-medium text-slate-600"
>
{icon}
{label}
</label>
{children}
</div>
);
}
/* ── Shared input class ────────────────────────────────────── */
export const inputCls = `
w-full px-3.5 py-2.5 text-[14px] text-slate-800
bg-slate-50 border border-slate-200 rounded-xl
placeholder:text-slate-400
focus:outline-none focus:ring-2 focus:ring-emerald-400/40 focus:border-emerald-400
hover:border-slate-300
transition-all duration-150
disabled:opacity-60 disabled:cursor-not-allowed
`.trim();

View File

@@ -0,0 +1,198 @@
'use client';
import {
X,
Award,
User,
FileText,
BookOpen,
Loader2,
CheckCircle2,
} from 'lucide-react';
import { useTranslations } from 'next-intl';
import { useCertificateModal } from './useSertificateModal';
import { Field, inputCls } from './modalField';
import { SertificateModalProps } from './types';
import DocumentsTypes from '@/widgets/plagiatCheck/ui/documentsType';
export default function SertificateModal({
document_id,
open,
setOpen,
}: SertificateModalProps) {
const t = useTranslations('CertificateModal');
const {
form,
updateField,
loading,
success,
visible,
isFormValid,
inputRef,
handleSubmit,
handleKeyDown,
handleBackdropClick,
} = useCertificateModal({ document_id, open, setOpen });
if (!visible) return null;
return (
<div
className={`fixed inset-0 z-50 flex items-center justify-center px-4
transition-all duration-300 ease-out
${open ? 'opacity-100' : 'opacity-0 pointer-events-none'}`}
onClick={handleBackdropClick}
onKeyDown={handleKeyDown}
role="dialog"
aria-modal="true"
aria-label={t('title')}
>
{/* Backdrop */}
<div
onClick={setOpen}
className={`absolute inset-0 bg-slate-900/50 backdrop-blur-[2px]
transition-opacity duration-300
${open ? 'opacity-100' : 'opacity-0'}`}
/>
{/* Modal panel */}
<div
className={`relative w-full max-w-md bg-white rounded-2xl shadow-2xl
border border-slate-100
transition-all duration-300 ease-out
${
open
? 'opacity-100 scale-100 translate-y-0'
: 'opacity-0 scale-95 translate-y-4'
}`}
>
{/* Top accent bar */}
<div className="absolute top-0 left-6 right-6 h-0.5 rounded-b-full bg-linear-to-r from-emerald-400 via-teal-400 to-emerald-500 opacity-80" />
{/* Header */}
<div className="flex items-center justify-between px-6 pt-7 pb-5">
<div className="flex items-center gap-3">
<div className="flex items-center justify-center w-9 h-9 rounded-xl bg-emerald-50 border border-emerald-100">
<Award className="w-5 h-5 text-emerald-600" strokeWidth={1.8} />
</div>
<h2 className="text-[17px] font-semibold text-slate-800 tracking-tight">
{t('title')}
</h2>
</div>
<button
onClick={setOpen}
disabled={loading}
className="flex items-center justify-center w-8 h-8 rounded-lg
text-slate-400 hover:text-slate-600 hover:bg-slate-100
transition-all duration-150 disabled:opacity-40 disabled:cursor-not-allowed"
aria-label={t('close')}
>
<X className="w-4 h-4" strokeWidth={2.2} />
</button>
</div>
{/* Divider */}
<div className="mx-6 h-px bg-slate-100" />
{/* Body */}
<div className="px-6 py-5 space-y-4">
{/* Full name */}
<Field
htmlFor="fullname"
icon={
<User className="w-3.5 h-3.5 text-slate-400" strokeWidth={2} />
}
label={t('authorName')}
>
<input
id="fullname"
ref={inputRef}
type="text"
value={form.fullname}
onChange={(e) => updateField('fullname', e.target.value)}
disabled={loading || success}
placeholder={t('namePlaceholder')}
className={inputCls}
/>
</Field>
{/* Document theme */}
<Field
htmlFor="document_theme"
icon={
<BookOpen
className="w-3.5 h-3.5 text-slate-400"
strokeWidth={2}
/>
}
label={t('documentTheme')}
>
<input
id="document_theme"
type="text"
value={form.document_theme}
onChange={(e) => updateField('document_theme', e.target.value)}
disabled={loading || success}
placeholder={t('themePlaceholder')}
className={inputCls}
/>
</Field>
{/* Document type */}
<DocumentsTypes
value={form.type}
onChange={(val) => updateField('type', val)}
disabled={loading || success}
/>
{/* Document ID (read-only) */}
<div className="flex items-center gap-2 px-3.5 py-2.5 rounded-xl bg-slate-50 border border-slate-100">
<FileText
className="w-4 h-4 text-slate-400 shrink-0"
strokeWidth={1.8}
/>
<div className="flex items-center justify-between w-full">
<span className="text-[13px] text-slate-500">
{t('documentId')}
</span>
<span className="text-[13px] font-mono font-medium text-slate-700">
#{document_id}
</span>
</div>
</div>
</div>
{/* Footer */}
<div className="px-6 pb-6">
<button
onClick={handleSubmit}
disabled={loading || !isFormValid || success}
className={`w-full flex items-center justify-center gap-2
py-2.5 rounded-xl text-[14px] font-semibold
transition-all duration-200
${
success
? 'bg-emerald-500 text-white scale-[0.98]'
: 'bg-emerald-500 hover:bg-emerald-600 active:scale-[0.98] text-white shadow-sm shadow-emerald-200'
}
disabled:opacity-60 disabled:cursor-not-allowed disabled:active:scale-100`}
>
{loading ? (
<>
<Loader2 className="w-4 h-4 animate-spin" strokeWidth={2.5} />
<span>{t('creating')}</span>
</>
) : success ? (
<>
<CheckCircle2 className="w-4 h-4" strokeWidth={2.5} />
<span>{t('created')}</span>
</>
) : (
<span>{t('create')}</span>
)}
</button>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,56 @@
'use client';
import { useTranslations } from 'next-intl';
import { FileDown, Loader2 } from 'lucide-react';
import React, { useEffect, useState } from 'react';
import SertificateModal from './sertificateModal';
export default function Sertifikat({ document_id }: { document_id: number }) {
const t = useTranslations();
const [loading, setLoading] = useState(false);
const [openModal, setOpenModal] = useState(false);
useEffect(() => {
setLoading(false);
console.log(loading);
}, []);
return (
<>
<button
onClick={() => {
setOpenModal(true);
}}
disabled={loading}
className="
group relative inline-flex items-center gap-2.5
px-5 py-2.5 rounded-xl
bg-linear-to-br from-amber-400 to-amber-500
hover:from-amber-500 hover:to-amber-600
disabled:from-amber-300 disabled:to-amber-400
text-white font-semibold text-sm
shadow-md shadow-amber-200
hover:shadow-lg hover:shadow-amber-300
transition-all duration-200
active:scale-[0.97]
disabled:cursor-not-allowed disabled:scale-100
"
>
{loading ? (
<Loader2 size={16} className="animate-spin shrink-0" />
) : (
<FileDown
size={16}
className="shrink-0 transition-transform duration-200 group-hover:-translate-y-0.5 group-hover:translate-x-0.5"
/>
)}
{loading ? '...' : t('upload')}
</button>
<SertificateModal
document_id={document_id}
open={openModal}
setOpen={() => {
setOpenModal(false);
}}
/>
</>
);
}

View File

@@ -0,0 +1,12 @@
export interface CertificateFormData {
fullname: string;
document_theme: string;
type: number;
document_id: number;
}
export interface SertificateModalProps {
document_id: number;
open: boolean;
setOpen: () => void;
}

View File

@@ -0,0 +1,140 @@
import React, { useState, useEffect, useRef } from 'react';
import { useMutation } from '@tanstack/react-query';
import { AxiosError } from 'axios';
import { apiRequest } from '@/shared/request/apiRequest';
import { links } from '@/shared/request/links';
import { CertificateFormData } from './types';
import { SIPaymentResponse } from '../siModal/utils/useFileUpload';
interface UseCertificateModalProps {
document_id: number;
open: boolean;
setOpen: () => void;
}
interface CertificatePayload {
full_name: string;
file_name: string;
document_type: number;
}
export function useCertificateModal({
document_id,
open,
setOpen,
}: UseCertificateModalProps) {
const [form, setForm] = useState<CertificateFormData>({
fullname: '',
document_theme: '',
type: 0,
document_id,
});
const [success, setSuccess] = useState(false);
const [visible, setVisible] = useState(false);
const inputRef = useRef<HTMLInputElement>(null);
const payment = useMutation({
mutationFn: (id: number) =>
apiRequest<SIPaymentResponse>('POST', links.demo_pay(id)),
onSuccess: (res) => {
window.open(res?.data?.payment_link, '_self');
},
});
const certificateMutation = useMutation({
mutationFn: (payload: CertificatePayload) =>
apiRequest<ArrayBuffer>('POST', links.sertifikat(document_id), payload, {
responseType: 'arraybuffer',
}).then((res) => res.data),
onSuccess: (data: ArrayBuffer) => {
if (data) {
const blob = new Blob([data], { type: 'application/pdf' });
const objectUrl = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = objectUrl;
a.download = `certificate-${document_id}.pdf`;
a.click();
setTimeout(() => URL.revokeObjectURL(objectUrl), 1000);
}
setSuccess(true);
setTimeout(() => {
setOpen();
setSuccess(false);
resetForm();
}, 1500);
},
onError: (error: AxiosError<{ code?: string }>) => {
if (error?.response?.data?.code === 'not_paid') {
payment.mutate(document_id);
}
},
});
const resetForm = () => {
setForm({
fullname: '',
document_theme: '',
type: 0,
document_id,
});
};
useEffect(() => {
if (open) {
setVisible(true);
setSuccess(false);
setForm((prev) => ({ ...prev, document_id }));
setTimeout(() => inputRef.current?.focus(), 300);
const data = localStorage.getItem('user');
if (data) {
const user = JSON.parse(data);
setForm((prev) => ({
...prev,
fullname: `${user.name} ${user.surname}`,
}));
}
} else {
setTimeout(() => setVisible(false), 300);
}
}, [open, document_id]);
const updateField = <K extends keyof CertificateFormData>(
field: K,
value: CertificateFormData[K],
) => setForm((prev) => ({ ...prev, [field]: value }));
const isFormValid =
!!form.fullname.trim() && !!form.document_theme.trim() && !!form.type;
const handleSubmit = () => {
if (!isFormValid || certificateMutation.isPending) return;
certificateMutation.mutate({
full_name: form.fullname,
file_name: form.document_theme,
document_type: Number(form.type),
});
};
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Escape') setOpen();
if (e.key === 'Enter') handleSubmit();
};
const handleBackdropClick = (e: React.MouseEvent<HTMLDivElement>) => {
if (e.target === e.currentTarget) setOpen();
};
return {
form,
updateField,
loading: certificateMutation.isPending,
success,
visible,
isFormValid,
inputRef,
handleSubmit,
handleKeyDown,
handleBackdropClick,
};
}

View File

@@ -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 { formatPrice, DEFAULT_PRICING } from './utils/pricing';
export type {
FileUploadModalProps,
UploadedFile,
PricingConfig,
WordCountResult,
} from './utils/tyeps';

View File

@@ -0,0 +1,54 @@
'use client';
import { useState } from 'react';
import { ArrowRight, BrainCircuit, Plus } from 'lucide-react';
import { FileUploadModal } from './ui/fileUploadModal';
import { useTranslations } from 'next-intl';
export function SiCTACard() {
const [isOpen, setIsOpen] = useState(false);
const t = useTranslations('Cabinet');
return (
<>
<button
onClick={() => setIsOpen(true)}
className="group relative overflow-hidden rounded-2xl bg-linear-to-br from-violet-500 to-violet-600 p-6 text-left shadow-md hover:shadow-xl transition-all duration-200 hover:-translate-y-0.5 active:translate-y-0 active:shadow-md"
>
<div className="absolute right-3 top-3 opacity-10 pointer-events-none">
<BrainCircuit size={72} className="text-white" />
</div>
<BrainCircuit size={26} className="text-white mb-4" />
<h3 className="text-white font-semibold text-base mb-1">
{t('siDetector')}
</h3>
<p className="text-violet-100 text-sm mb-4 leading-relaxed">
{t('siDesc')}
</p>
<span className="inline-flex items-center gap-1.5 text-white text-xs font-medium bg-white/20 rounded-lg px-3 py-1.5 group-hover:bg-white/30 transition-colors">
{t('submit')} <ArrowRight size={12} />
</span>
</button>
<FileUploadModal isOpen={isOpen} onClose={() => setIsOpen(false)} />
</>
);
}
export function SiButton() {
const [isOpen, setIsOpen] = useState<boolean>(false);
const t = useTranslations('Cabinet');
return (
<>
<button
onClick={() => setIsOpen(true)}
className="flex items-center gap-2 py-1 px-2 group relative overflow-hidden rounded-sm bg-linear-to-br from-violet-500 to-violet-600 text-left shadow-md hover:shadow-xl transition-all duration-200 hover:-translate-y-0.5 active:translate-y-0 active:shadow-md"
>
<Plus size={15} className="text-white" />
<h3 className="text-white font-semibold text-base">
{t('siDetector')}
</h3>
</button>
<FileUploadModal isOpen={isOpen} onClose={() => setIsOpen(false)} />
</>
);
}

View File

@@ -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 (
<div
role="button"
tabIndex={0}
onClick={onClick}
onDrop={onDrop}
onDragOver={onDragOver}
onDragLeave={onDragLeave}
onKeyDown={(e) => 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'
}
`}
>
<div
className={`
w-12 h-12 rounded-full flex items-center justify-center
transition-all duration-200
${isDragging ? 'bg-blue-100 scale-110' : 'bg-white shadow-sm'}
`}
>
<Upload
size={22}
className={`transition-colors duration-200 ${
isDragging ? 'text-blue-500' : 'text-slate-400'
}`}
/>
</div>
<div className="text-center">
<p className="text-sm font-medium text-slate-700">
{isDragging ? 'Drop file here' : 'Drag & drop or click to browse'}
</p>
<p className="mt-1 text-xs text-slate-400">
Supported: {SUPPORTED_EXTENSIONS.join(', ')} · Max 50 MB
</p>
</div>
</div>
);
}

View File

@@ -0,0 +1,190 @@
'use client';
import { useEffect } from 'react';
import { X } from 'lucide-react';
import { 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,
pricing = DEFAULT_PRICING,
}: FileUploadModalProps) {
const {
documentName,
setDocumentName,
uploadedFile,
isDragging,
isProcessing,
error,
fileInputRef,
handleFileSelect,
handleDrop,
handleDragOver,
handleDragLeave,
handleRemoveFile,
openFilePicker,
canSubmit,
handleSubmit,
} = 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 wordCount = uploadedFile?.word_count ?? 0;
const totalPrice = uploadedFile?.total_price ?? 0;
return (
// Backdrop
<div
className="
fixed inset-0 z-50 flex items-center justify-center p-4
bg-slate-900/40 backdrop-blur-sm
animate-in fade-in duration-200
"
onClick={(e) => e.target === e.currentTarget && onClose()}
role="dialog"
aria-modal="true"
aria-labelledby="modal-title"
>
{/* Panel */}
<div
className="
relative w-full max-w-140 rounded-2xl bg-white shadow-2xl
animate-in zoom-in-95 slide-in-from-bottom-4 duration-300
flex flex-col gap-5 p-6
"
>
{/* Header */}
<div className="flex items-center justify-between">
<h2
id="modal-title"
className="text-xl font-semibold text-slate-800 tracking-tight"
>
Select file
</h2>
<button
onClick={onClose}
className="
w-8 h-8 rounded-full flex items-center justify-center
text-slate-400 hover:text-slate-600 hover:bg-slate-100
transition-colors focus-visible:outline-none focus-visible:ring-2
focus-visible:ring-slate-300
"
aria-label="Close modal"
>
<X size={18} />
</button>
</div>
{/* Document name */}
<div className="flex flex-col gap-1.5">
<label
htmlFor="doc-name"
className="text-sm font-medium text-slate-700"
>
Document name{' '}
<span className="text-red-500" aria-hidden>
*
</span>
</label>
<input
id="doc-name"
type="text"
value={documentName}
onChange={(e) => 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
"
/>
</div>
{/* Hidden file input */}
<input
ref={fileInputRef}
type="file"
accept={SUPPORTED_EXTENSIONS.join(',')}
className="hidden"
aria-hidden="true"
onChange={(e) => {
const file = e.target.files?.[0];
if (file) handleFileSelect(file);
}}
/>
{/* Drop zone OR file chip */}
{uploadedFile ? (
<div className="animate-in fade-in slide-in-from-top-1 duration-200">
<FileChip file={uploadedFile} onRemove={handleRemoveFile} />
</div>
) : (
<DropZone
isDragging={isDragging}
onDrop={handleDrop}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onClick={openFilePicker}
/>
)}
{/* Error */}
{error && <ErrorBanner message={error} />}
{/* Pricing info — shown once word count is ready */}
{uploadedFile?.status === 'done' && wordCount > 0 && (
<PricingInfo
wordCount={wordCount}
minimumPrice={formatPrice(pricing.minimumPayment)}
totalPrice={formatPrice(totalPrice)}
/>
)}
{/* Footer */}
<div className="flex justify-end">
<button
onClick={handleSubmit}
disabled={!canSubmit}
className="
relative flex items-center gap-2
rounded-xl bg-blue-600 px-6 py-3
text-sm font-semibold text-white
shadow-lg shadow-blue-500/25
transition-all duration-150
hover:bg-blue-700 hover:shadow-blue-500/40
active:scale-[0.98]
disabled:opacity-50 disabled:cursor-not-allowed disabled:shadow-none
focus-visible:outline-none focus-visible:ring-2
focus-visible:ring-blue-400 focus-visible:ring-offset-2
"
>
{isProcessing && <Spinner />}
Check
</button>
</div>
</div>
</div>
);
}

View File

@@ -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 (
<Loader2
className={`animate-spin text-white ${className}`}
size={16}
aria-label="Processing…"
/>
);
}
// ── File chip shown after upload ─────────────────────────────────────────────
interface FileChipProps {
file: UploadedFile;
onRemove: () => void;
}
export function FileChip({ file, onRemove }: FileChipProps) {
const isLoading = file.status === 'uploading';
return (
<div
className={`
flex items-center justify-between rounded-xl px-4 py-3 transition-all duration-300
${
file.status === 'error'
? 'bg-red-500/10 border border-red-400/30'
: 'bg-emerald-500 shadow-lg shadow-emerald-500/25'
}
`}
>
<div className="flex items-center gap-3 min-w-0">
<FileText
size={18}
className={file.status === 'error' ? 'text-red-400' : 'text-white/80'}
/>
<div className="min-w-0">
<p
className={`text-sm font-medium truncate max-w-65 ${
file.status === 'error' ? 'text-red-300' : 'text-white'
}`}
>
{file.name}
</p>
<p
className={`text-xs ${
file.status === 'error' ? 'text-red-400' : 'text-emerald-100/70'
}`}
>
{file.sizeKB} KB
</p>
</div>
</div>
<div className="flex items-center gap-3 shrink-0">
{isLoading ? (
<Spinner />
) : (
<span
className={`text-xs font-medium ${
file.status === 'error' ? 'text-red-400' : 'text-white/90'
}`}
>
{file.status === 'error' ? 'Error' : 'Upload complete'}
</span>
)}
<button
onClick={onRemove}
className="w-6 h-6 rounded-full bg-white/20 hover:bg-white/30 flex items-center justify-center transition-colors"
aria-label="Remove file"
>
<X size={12} className="text-white" />
</button>
</div>
</div>
);
}
// ── Pricing info block ───────────────────────────────────────────────────────
interface PricingInfoProps {
wordCount: number;
minimumPrice: string;
totalPrice: string;
}
export function PricingInfo({
wordCount,
minimumPrice,
totalPrice,
}: PricingInfoProps) {
return (
<div
className="
animate-in fade-in slide-in-from-bottom-2 duration-300
rounded-xl bg-amber-50 border border-amber-100 px-4 py-3 space-y-1
"
>
<p className="text-xs text-slate-500 leading-relaxed">
Document check price is determined by the number of words in the
document.
</p>
<p className="text-sm font-semibold text-amber-500">
Minimum payment for one document: {minimumPrice}
</p>
<p className="text-sm font-semibold text-emerald-600">
Document check price: {wordCount.toLocaleString()} words for{' '}
{totalPrice}
</p>
</div>
);
}
// ── Error banner ─────────────────────────────────────────────────────────────
export function ErrorBanner({ message }: { message: string }) {
return (
<div className="flex items-start gap-2 rounded-xl bg-red-50 border border-red-200 px-4 py-3 animate-in fade-in duration-200">
<AlertCircle size={16} className="text-red-500 mt-0.5 shrink-0" />
<p className="text-sm text-red-600">{message}</p>
</div>
);
}

View File

@@ -0,0 +1,12 @@
import { PricingConfig } from './tyeps';
const DEFAULT_PRICING: PricingConfig = {
pricePerWord: 4, // 4 so'm per word
minimumPayment: 10000, // 10 000 so'm minimum
};
export function formatPrice(amount: number): string {
return new Intl.NumberFormat('uz-UZ').format(amount) + " so'm";
}
export { DEFAULT_PRICING };

View File

@@ -0,0 +1,24 @@
export interface UploadedFile {
file: File;
name: string;
sizeKB: number;
word_count: number;
total_price: 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;
pricing?: PricingConfig;
}
export interface WordCountResult {
count: number;
error?: string;
}

View File

@@ -0,0 +1,225 @@
'use client';
import { useState, useCallback, useRef } from 'react';
import { UploadedFile } from './tyeps';
import { SUPPORTED_EXTENSIONS } from './wordCount';
import { useMutation } from '@tanstack/react-query';
import { apiRequest } from '@/shared/request/apiRequest';
import { links } from '@/shared/request/links';
import { toast } from 'react-toastify';
// ── API response types ────────────────────────────────────────────────────────
interface WordCountApiResponse {
word_count: number;
total_price: number;
}
interface CreateSIOrderResponse {
id: number;
order_id: number;
}
export interface SIPaymentResponse {
payment_link: string;
}
// ── Return type ───────────────────────────────────────────────────────────────
interface UseFileUploadReturn {
documentName: string;
setDocumentName: (name: string) => void;
uploadedFile: UploadedFile | null;
isDragging: boolean;
isProcessing: boolean;
error: string | null;
fileInputRef: React.RefObject<HTMLInputElement | null>;
handleFileSelect: (file: File) => void;
handleDrop: (e: React.DragEvent) => void;
handleDragOver: (e: React.DragEvent) => void;
handleDragLeave: () => void;
handleRemoveFile: () => void;
openFilePicker: () => void;
handleSubmit: () => void;
canSubmit: boolean;
}
// ── Hook ─────────────────────────────────────────────────────────────────────
export function useFileUpload(): UseFileUploadReturn {
const [documentName, setDocumentName] = useState('');
const [uploadedFile, setUploadedFile] = useState<UploadedFile | null>(null);
const [isDragging, setIsDragging] = useState(false);
const [error, setError] = useState<string | null>(null);
const fileInputRef = useRef<HTMLInputElement | null>(null);
// ── Step 3: Payment ────────────────────────────────────────────────────────
const siPayment = useMutation({
mutationKey: ['si-payment'],
mutationFn: (order_id: number) =>
apiRequest<SIPaymentResponse>('POST', links.demo_pay(order_id)),
onSuccess: (res) => {
window.open(res.data.payment_link, '_self');
},
onError: (err) => {
toast.error(
err instanceof Error ? err.message : "To'lovda xatolik yuz berdi",
);
},
});
// ── Step 2: Create SI order ────────────────────────────────────────────────
const createSIOrder = useMutation({
mutationKey: ['si-create'],
mutationFn: (data: FormData) =>
apiRequest<CreateSIOrderResponse>('POST', links.si_create, data),
onSuccess: (res) => {
siPayment.mutate(res.data.order_id);
},
onError: (err) => {
toast.error(err instanceof Error ? err.message : 'Xatolik yuz berdi');
},
});
// ── Step 1: Upload file & get word count + price ───────────────────────────
const wordCountMutation = useMutation({
mutationKey: ['si-word-count'],
mutationFn: (data: FormData) =>
apiRequest<WordCountApiResponse>('POST', links.wordCount, data),
onSuccess: (res) => {
setUploadedFile((prev) =>
prev
? {
...prev,
status: 'done',
word_count: res.data.word_count ?? 0,
total_price: res.data.total_price ?? 0,
}
: prev,
);
},
onError: (err) => {
const message = err instanceof Error ? err.message : 'Fayl yuklanmadi';
setError(message);
setUploadedFile((prev) => (prev ? { ...prev, status: 'error' } : prev));
},
});
// ── File validation ────────────────────────────────────────────────────────
const validateFile = (file: File): string | null => {
const ext = '.' + file.name.split('.').pop()?.toLowerCase();
if (!SUPPORTED_EXTENSIONS.includes(ext)) {
return `Qo'llab-quvvatlanmaydigan fayl turi. Ruxsat etilgan: ${SUPPORTED_EXTENSIONS.join(', ')}`;
}
if (file.size > 50 * 1024 * 1024) {
return 'Fayl hajmi 50 MB dan oshmasligi kerak';
}
return null;
};
// ── Step 1 trigger ─────────────────────────────────────────────────────────
const handleFileSelect = useCallback(
(file: File) => {
setError(null);
const validationError = validateFile(file);
if (validationError) {
setError(validationError);
return;
}
// Optimistic: show file chip immediately while backend responds
setUploadedFile({
file,
name: file.name,
sizeKB: Math.round(file.size / 1024),
word_count: 0,
total_price: 0,
status: 'uploading',
});
// Auto-fill document name if blank
setDocumentName((prev) =>
prev.trim() === '' ? file.name.replace(/\.[^/.]+$/, '') : prev,
);
const fd = new FormData();
fd.append('file', file);
wordCountMutation.mutate(fd);
},
[wordCountMutation],
);
// ── Step 2 trigger (Check button) ─────────────────────────────────────────
const handleSubmit = useCallback(() => {
if (!uploadedFile?.file || !documentName.trim()) return;
const fd = new FormData();
fd.append('title', documentName.trim());
fd.append('file', uploadedFile.file);
createSIOrder.mutate(fd);
}, [uploadedFile, documentName, createSIOrder]);
// ── Drag & drop ────────────────────────────────────────────────────────────
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();
}, []);
// ── Derived state ──────────────────────────────────────────────────────────
const isProcessing =
wordCountMutation.isPending ||
createSIOrder.isPending ||
siPayment.isPending;
const canSubmit =
documentName.trim().length > 0 &&
uploadedFile?.status === 'done' &&
!isProcessing;
return {
documentName,
setDocumentName,
uploadedFile,
isDragging,
isProcessing,
error,
fileInputRef,
handleFileSelect,
handleDrop,
handleDragOver,
handleDragLeave,
handleRemoveFile,
openFilePicker,
handleSubmit,
canSubmit,
};
}

View File

@@ -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<WordCountResult> {
const text = await file.text();
return { count: countWordsFromText(text) };
}
// ── .docx (reads raw XML, strips tags) ─────────────────────────────────────
async function countFromDocx(file: File): Promise<WordCountResult> {
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<WordCountResult> {
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<WordCountResult> {
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',
];

View File

@@ -4,7 +4,7 @@
"about": "Go to the about page"
},
"Navbar": {
"logo": "Plagat",
"logo": "Plagiat",
"aboutSite": "About Site",
"contact": "Contact",
"login": "Login",
@@ -61,6 +61,8 @@
"date": "Date",
"amount": "Amount",
"result": "Result",
"fileName":"File name",
"count":"N_",
"actions": "",
"state": "Payment status",
"emptyMessage": "No plagiarism checks found.",
@@ -74,7 +76,8 @@
"resultClean": "Clean",
"resultPlagiarismFound": "Plagiarism Found",
"resultPending": "Pending",
"resultFailed": "Failed"
"resultFailed": "Failed",
"plagiatCheck": "Plagiarism Check"
},
"DetailPage": {
"id": "ID",
@@ -124,7 +127,11 @@
"unknownError": "Unknown error",
"words": "words",
"aiProbabilityText": "Probability that the text was generated with AI has been detected",
"documentNumber": "Document subject"
"documentNumber": "Document subject",
"scoreAiContent": "Self-citation",
"scoreOriginality": "Originality",
"scorePlagiarism": "Plagiarism",
"scoreCitation": "Citation"
},
"Hero": {
"badge": "Academic Integrity Platform",
@@ -229,7 +236,8 @@
"paymentMethod": "Payment Method",
"security": "Secured by Payme · SSL encrypted",
"serviceFee": "Service fee",
"certificateLabel": "Certificate",
"discountLabel": "Discount",
"sertificateLabel":"Certificate",
"total": "Total",
"paymentRequired": "Payment not completed",
"connecting": "Connecting to Payme…",
@@ -237,5 +245,122 @@
},
"unknownUser": "Username not found",
"file": "File",
"upload": "Download certificate"
"upload": "Download certificate",
"Cabinet": {
"plagiatCheck": "Plagiarism Check",
"checkDesc": "Check your document for originality",
"submit": "Submit",
"siDetector": "AI Detector",
"siDesc": "Check text for AI content",
"home": "Home",
"plagiat": "Plagiarism",
"siNav": "AI Detector",
"payments": "Payment History",
"profile": "Profile",
"close": "Close",
"personalCabinet": "Personal Cabinet",
"plagiatChecks": "Plagiarism Checks",
"dashboard": "Dashboard",
"welcome": "Welcome, {userName} 👋",
"welcomeDesc": "Welcome to your personal cabinet",
"quickActions": "Quick Actions",
"totalChecks": "Total Checks",
"thisMonth": "This Month",
"paidAmount": "Amount Paid",
"noData": "No data found",
"checkModules": "Check Modules",
"checkModulesDesc": "All sources used for plagiarism detection",
"modulesCount": "{count} modules",
"totalModules": "Total Modules",
"freeInternetSources": "Free Internet Sources",
"aiAnalysisModules": "AI Analysis Modules",
"categories": "Categories",
"paymentsCount": "{count} payments",
"loading": "Loading...",
"noPayments": "No payment history",
"tableNum": "#",
"service": "Service",
"amount": "Amount",
"discount": "Discount",
"date": "Date",
"status": "Status",
"unknown": "Unknown",
"noSiChecks": "No AI checks yet",
"loadError": "Failed to load data",
"paid": "Paid",
"unpaid": "Unpaid",
"checksCount": "{count} checks",
"tableTitle": "Title",
"tableFile": "File",
"words": "Words",
"action": "Action",
"pay": "Pay",
"view": "View",
"profileDesc": "Manage your information",
"personalInfo": "Personal Information",
"changePassword": "Change Password",
"firstName": "First Name",
"lastName": "Last Name",
"phone": "Phone",
"email": "Email",
"newPassword": "New Password",
"saved": "Saved",
"saving": "Saving…",
"save": "Save",
"firstNameRequired": "First name is required",
"lastNameRequired": "Last name is required",
"phoneInvalid": "Phone number must be 9 digits",
"passwordTooShort": "Password must be at least 8 characters",
"discountThisMonth": "Discount this month",
"discountRemaining": "Discount expires after {remaining} documents",
"discountAllUsed": "All discounts for this month have been used",
"discountUsed": "{count} used"
},
"SiDetail": {
"siCheck": "AI Check",
"basicInfo": "Basic Information",
"documentInfo": "Document Information",
"documentName": "Document Name",
"checker": "Checker",
"uploadedAt": "Document Upload Time",
"originalFileName": "Original File Name",
"wordCount": "Word Count",
"fileExt": "File Extension",
"fileSize": "File Size",
"amountCharged": "Amount Charged",
"downloadOriginal": "Download Original Document",
"siResultsTitle": "Document AI Detector Results",
"siResultsDesc": "This window displays the analysis results of the text uploaded by the user regarding the probability of being written with the help of artificial intelligence. The detector evaluates the stylistic, grammatical and semantic features of the text and shows in percentage how likely it was generated by artificial intelligence.",
"download": "Download",
"originalText": "Original Text",
"possibleAi": "Possible AI",
"aiContent": "Artificial Intelligence"
},
"PlagiatResult": {
"plagiarismLevel": "Plagiarism Level",
"aiWritten": "AI Written",
"originality": "Originality",
"citation": "Citation",
"plagiat": "Plagiarism",
"aiGeneration": "AI Generation",
"original": "Original",
"checked": "Checked"
},
"CertificateModal": {
"title": "Create Certificate",
"close": "Close",
"authorName": "Author's Full Name",
"namePlaceholder": "Enter your name...",
"documentTheme": "Document Theme",
"themePlaceholder": "Enter the topic...",
"documentId": "Document ID",
"creating": "Creating...",
"created": "Certificate Created!",
"create": "Create Certificate"
},
"DocumentTypes": {
"label": "Document Type",
"loading": "Loading...",
"placeholder": "Select document type..."
}
}

View File

@@ -4,7 +4,7 @@
"about": "Перейти на страницу о нас"
},
"Navbar": {
"logo": "Plagat",
"logo": "Plagiat",
"aboutSite": "О сайте",
"contact": "Контакты",
"login": "Войти",
@@ -61,6 +61,7 @@
"date": "Дата",
"amount": "Сумма",
"result": "Результат",
"count":"H_",
"actions": "",
"state": "Статус оплаты",
"emptyMessage": "Проверки на плагиат не найдены.",
@@ -74,7 +75,8 @@
"resultClean": "Чисто",
"resultPlagiarismFound": "Обнаружен плагиат",
"resultPending": "В ожидании",
"resultFailed": "Не удалось"
"resultFailed": "Не удалось",
"plagiatCheck": "Проверка на плагиат"
},
"DetailPage": {
"id": "ID",
@@ -124,7 +126,11 @@
"unknownError": "Неизвестная ошибка",
"words": "слов",
"aiProbabilityText": "Обнаружена вероятность того, что текст создан с помощью ИИ",
"documentNumber": "Тема документа"
"documentNumber": "Тема документа",
"scoreAiContent": "Самоцитирование",
"scoreOriginality": "Оригинальность",
"scorePlagiarism": "Плагиат",
"scoreCitation": "Цитирование"
},
"Hero": {
"badge": "Платформа академической честности",
@@ -229,7 +235,8 @@
"paymentMethod": "Способ оплаты",
"security": "Защищено Payme · SSL шифрование",
"serviceFee": "Стоимость услуги",
"certificateLabel": "Сертификат",
"discountLabel": "Скидка",
"sertificateLabel":"Сертификат",
"total": "Итого",
"paymentRequired": "Оплата не произведена",
"connecting": "Подключение к Payme…",
@@ -237,5 +244,122 @@
},
"unknownUser": "Имя пользователя не найдено",
"file": "Файл",
"upload": "Скачать сертификат"
"upload": "Скачать сертификат",
"Cabinet": {
"plagiatCheck": "Проверка на плагиат",
"checkDesc": "Проверьте ваш документ на оригинальность",
"submit": "Отправить",
"siDetector": "ИИ детектор",
"siDesc": "Проверьте текст на искусственный интеллект",
"home": "Главная",
"plagiat": "Плагиат",
"siNav": "ИИ детектор",
"payments": "История платежей",
"profile": "Профиль",
"close": "Закрыть",
"personalCabinet": "Личный кабинет",
"plagiatChecks": "Проверки на плагиат",
"dashboard": "Dashboard",
"welcome": "Добро пожаловать, {userName} 👋",
"welcomeDesc": "Добро пожаловать в ваш личный кабинет",
"quickActions": "Быстрые действия",
"totalChecks": "Всего проверок",
"thisMonth": "Этот месяц",
"paidAmount": "Оплаченная сумма",
"noData": "Данные не найдены",
"checkModules": "Модули проверки",
"checkModulesDesc": "Все источники, используемые для обнаружения плагиата",
"modulesCount": "{count} модулей",
"totalModules": "Всего модулей",
"freeInternetSources": "Бесплатные интернет-источники",
"aiAnalysisModules": "Модули AI анализа",
"categories": "Категория",
"paymentsCount": "{count} платежей",
"loading": "Загрузка...",
"noPayments": "История платежей отсутствует",
"tableNum": "#",
"service": "Услуга",
"amount": "Сумма",
"discount": "Скидка",
"date": "Дата",
"status": "Статус",
"unknown": "Неизвестно",
"noSiChecks": "Проверок ИИ пока нет",
"loadError": "Ошибка загрузки данных",
"paid": "Оплачено",
"unpaid": "Не оплачено",
"checksCount": "{count} проверок",
"tableTitle": "Заголовок",
"tableFile": "Файл",
"words": "Слов",
"action": "Действие",
"pay": "Оплатить",
"view": "Просмотр",
"profileDesc": "Управляйте своими данными",
"personalInfo": "Личные данные",
"changePassword": "Изменить пароль",
"firstName": "Имя",
"lastName": "Фамилия",
"phone": "Телефон",
"email": "Email",
"newPassword": "Новый пароль",
"saved": "Сохранено",
"saving": "Сохранение…",
"save": "Сохранить",
"firstNameRequired": "Имя обязательно",
"lastNameRequired": "Фамилия обязательна",
"phoneInvalid": "Номер телефона должен содержать 9 цифр",
"passwordTooShort": "Пароль должен содержать не менее 8 символов",
"discountThisMonth": "Скидка в этом месяце",
"discountRemaining": "Скидка истекает через {remaining} документов",
"discountAllUsed": "Все скидки этого месяца использованы",
"discountUsed": "{count} использовано"
},
"SiDetail": {
"siCheck": "Проверка ИИ",
"basicInfo": "Основная информация",
"documentInfo": "Информация о документе",
"documentName": "Название документа",
"checker": "Проверяющий",
"uploadedAt": "Дата загрузки документа",
"originalFileName": "Оригинальное имя файла",
"wordCount": "Количество слов",
"fileExt": "Расширение файла",
"fileSize": "Размер файла",
"amountCharged": "Списанная сумма",
"downloadOriginal": "Скачать оригинальный документ",
"siResultsTitle": "Результаты ИИ детектора документа",
"siResultsDesc": "В этом окне отображены результаты анализа текста, загруженного пользователем, на вероятность написания с помощью искусственного интеллекта. Детектор оценивает стилистические, грамматические и семантические особенности текста и показывает в процентах, насколько вероятно, что он был сгенерирован искусственным интеллектом.",
"download": "Скачать",
"originalText": "Оригинальный текст",
"possibleAi": "Возможный ИИ",
"aiContent": "Искусственный интеллект"
},
"PlagiatResult": {
"plagiarismLevel": "Уровень плагиата",
"aiWritten": "Написано ИИ",
"originality": "Оригинальность",
"citation": "Цитирование",
"plagiat": "Плагиат",
"aiGeneration": "Генерация ИИ",
"original": "Оригинал",
"checked": "Проверено"
},
"CertificateModal": {
"title": "Создать сертификат",
"close": "Закрыть",
"authorName": "Полное имя автора",
"namePlaceholder": "Введите ваше имя...",
"documentTheme": "Тема документа",
"themePlaceholder": "Введите тему...",
"documentId": "ID документа",
"creating": "Создание...",
"created": "Сертификат создан!",
"create": "Создать сертификат"
},
"DocumentTypes": {
"label": "Тип документа",
"loading": "Загрузка...",
"placeholder": "Выберите тип документа..."
}
}

View File

@@ -7,7 +7,7 @@ declare const messages: {
about: "Biz haqimizda sahifasiga o'ting";
};
Navbar: {
logo: 'Plagat';
logo: 'Plagiat';
aboutSite: 'Sayt haqida';
contact: 'Aloqa';
login: 'Kirish';
@@ -61,6 +61,8 @@ declare const messages: {
description: 'Siz tomonidan yuborilgan barcha plagiat tekshiruvlari';
sender: 'Yuboruvchi';
file: 'Fayl';
fileName: 'Fayl nomi';
count: 'N_';
date: 'Sana';
amount: 'Summa';
result: 'Natija';
@@ -78,6 +80,7 @@ declare const messages: {
resultPlagiarismFound: 'Plagiat topildi';
resultPending: 'Kutilmoqda';
resultFailed: 'Muvaffaqiyatsiz';
plagiatCheck: 'Plagiat tekshiruvi';
};
DetailPage: {
id: 'ID';
@@ -128,6 +131,10 @@ declare const messages: {
words: "so'z";
aiProbabilityText: 'Ai yordamida yaratilganlik ehtimoli aniqlandi';
documentNumber: 'Dokument mavzusi';
scoreAiContent: "O'zidan iqtibos keltirish";
scoreOriginality: 'Originallik';
scorePlagiarism: 'Plagiat';
scoreCitation: 'Iqtibos';
};
Hero: {
badge: 'Akademik halollik platformasi';
@@ -232,7 +239,8 @@ declare const messages: {
paymentMethod: "To'lov usuli";
security: 'Payme tomonidan himoyalangan · SSL shifrlash';
serviceFee: "Xizmat to'lovi";
certificateLabel: 'Sertifikat';
discountLabel: 'Chegirma';
sertificateLabel: 'Sertifikat';
total: 'Jami';
paymentRequired: "To'lov qilinmagan";
connecting: 'Paymega ulanmoqda…';
@@ -241,5 +249,122 @@ declare const messages: {
unknownUser: 'Foydalanuvchi topilmadi';
file: 'Fayl';
upload: 'Sertifikatni yuklab olish';
Cabinet: {
plagiatCheck: 'Plagiat tekshiruvi';
checkDesc: 'Hujjatingizni originallik uchun tekshiring';
submit: 'Yuborish';
siDetector: 'SI detektor';
siDesc: "Matnni sun'iy intellekt uchun tekshiring";
home: 'Bosh sahifa';
plagiat: 'Plagiat';
siNav: 'SI detektor';
payments: "To'lovlar tarixi";
profile: 'Profil';
close: 'Yopish';
personalCabinet: 'Shaxsiy kabinet';
plagiatChecks: 'Plagiat tekshiruvlar';
dashboard: 'Dashboard';
welcome: 'Xush kelibsiz, {userName} 👋';
welcomeDesc: 'Shaxsiy kabinetingizga xush kelibsiz';
quickActions: 'Tezkor harakatlar';
totalChecks: 'Jami tekshiruvlar';
thisMonth: 'Bu oy';
paidAmount: "To'langan summa";
noData: "Ma'lumot topilmadi";
checkModules: 'Tekshiruv modullari';
checkModulesDesc: 'Plagiat aniqlashda foydalaniladigan barcha manbalar';
modulesCount: '{count} ta modul';
totalModules: 'Jami modullar';
freeInternetSources: 'Bepul internet manbalari';
aiAnalysisModules: 'AI tahlil modullari';
categories: 'Kategoriya';
paymentsCount: "{count} ta to'lov";
loading: 'Yuklanmoqda...';
noPayments: "To'lovlar tarixi mavjud emas";
tableNum: '#';
service: 'Xizmat';
amount: 'Summa';
discount: 'Chegirma';
date: 'Sana';
status: 'Holat';
unknown: "Noma'lum";
noSiChecks: "Hozircha SI tekshiruvlar yo'q";
loadError: "Ma'lumotlarni yuklashda xatolik yuz berdi";
paid: "To'langan";
unpaid: "To'lanmagan";
checksCount: '{count} ta tekshiruv';
tableTitle: 'Sarlavha';
tableFile: 'Fayl';
words: "So'z";
action: 'Amal';
pay: "To'lash";
view: "Ko'rish";
profileDesc: "Ma'lumotlaringizni boshqaring";
personalInfo: "Shaxsiy ma'lumotlar";
changePassword: "Parol o'zgartirish";
firstName: 'Ism';
lastName: 'Familiya';
phone: 'Telefon';
email: 'Email';
newPassword: 'Yangi parol';
saved: 'Saqlandi';
saving: 'Saqlanmoqda…';
save: 'Saqlash';
firstNameRequired: 'Ism kiritilishi shart';
lastNameRequired: 'Familiya kiritilishi shart';
phoneInvalid: "Telefon raqami 9 ta raqamdan iborat bo'lishi kerak";
passwordTooShort: "Parol kamida 8 ta belgidan iborat bo'lishi kerak";
discountThisMonth: 'Bu oyda chegirma';
discountRemaining: '{remaining} ta hujjatdan keyin chegirma tugaydi';
discountAllUsed: 'Bu oyda barcha chegirmalar ishlatildi';
discountUsed: '{count} ta ishlatildi';
};
SiDetail: {
siCheck: 'SI tekshiruv';
basicInfo: "Asosiy ma'lumotlar";
documentInfo: "Hujjat haqida ma'lumotlar";
documentName: 'Hujjat nomi';
checker: 'Tekshiruvchi';
uploadedAt: 'Hujjat yuklangan vaqti';
originalFileName: 'Hujjat faylining original nomi';
wordCount: "So'zlar soni";
fileExt: 'Hujjat fayli kenggaytmasi';
fileSize: "Hujjat fayli o'lchami";
amountCharged: 'Yechilgan summa';
downloadOriginal: 'Original hujjatni yuklab olish';
siResultsTitle: 'Hujjatning SI detektori natijalari';
siResultsDesc: "Ushbu oynada foydalanuvchi tomonidan yuklangan matn sun'iy intellekt (SI) yordamida yozilgan bo'lish ehtimoli bo'yicha tahlil natijalari aks etirilgan. Detektor matnning stilistik, grammatik va semantik xususiyatlarini baholab, uning qanchalik darajada sun'iy intellekt tomonidan generatsiya qilingan bo'lishi mumkinligini foizlik ko'rinishida ko'rsatadi.";
download: 'Yuklab olish';
originalText: 'Original matn';
possibleAi: "Ehtimoliy Sun'iy Intellekt";
aiContent: "Sun'iy Intellekt";
};
PlagiatResult: {
plagiarismLevel: 'Plagiat darajasi';
aiWritten: 'AI yozgan';
originality: 'Originallik';
citation: 'Iqtibos';
plagiat: 'Plagiat';
aiGeneration: 'AI generatsiya';
original: 'Original';
checked: 'Tekshirilgan';
};
CertificateModal: {
title: 'Sertifikat yaratish';
close: 'Yopish';
authorName: "Muallifning to'liq ismi";
namePlaceholder: 'Ismingizni kiriting...';
documentTheme: 'Hujjat mavzusi';
themePlaceholder: 'Mavzuni kiriting...';
documentId: 'Hujjat ID';
creating: 'Yaratilmoqda...';
created: 'Sertifikat yaratildi!';
create: 'Sertifikat yaratish';
};
DocumentTypes: {
label: 'Hujjat turi';
loading: 'Yuklanmoqda...';
placeholder: 'Hujjat turini tanlang...';
};
};
export default messages;

View File

@@ -4,7 +4,7 @@
"about": "Biz haqimizda sahifasiga o'ting"
},
"Navbar": {
"logo": "Plagat",
"logo": "Plagiat",
"aboutSite": "Sayt haqida",
"contact": "Aloqa",
"login": "Kirish",
@@ -58,6 +58,8 @@
"description": "Siz tomonidan yuborilgan barcha plagiat tekshiruvlari",
"sender": "Yuboruvchi",
"file": "Fayl",
"fileName":"Fayl nomi",
"count":"N_",
"date": "Sana",
"amount": "Summa",
"result": "Natija",
@@ -74,7 +76,8 @@
"resultClean": "Toza",
"resultPlagiarismFound": "Plagiat topildi",
"resultPending": "Kutilmoqda",
"resultFailed": "Muvaffaqiyatsiz"
"resultFailed": "Muvaffaqiyatsiz",
"plagiatCheck": "Plagiat tekshiruvi"
},
"DetailPage": {
"id": "ID",
@@ -124,7 +127,11 @@
"unknownError": "Noma'lum xato",
"words": "so'z",
"aiProbabilityText":"Ai yordamida yaratilganlik ehtimoli aniqlandi",
"documentNumber":"Dokument mavzusi"
"documentNumber":"Dokument mavzusi",
"scoreAiContent": "O'zidan iqtibos keltirish",
"scoreOriginality": "Originallik",
"scorePlagiarism": "Plagiat",
"scoreCitation": "Iqtibos"
},
"Hero": {
"badge": "Akademik halollik platformasi",
@@ -229,7 +236,8 @@
"paymentMethod": "To'lov usuli",
"security": "Payme tomonidan himoyalangan · SSL shifrlash",
"serviceFee": "Xizmat to'lovi",
"certificateLabel": "Sertifikat",
"discountLabel": "Chegirma",
"sertificateLabel":"Sertifikat",
"total": "Jami",
"paymentRequired":"To'lov qilinmagan",
"connecting": "Paymega ulanmoqda…",
@@ -237,5 +245,122 @@
},
"unknownUser": "Foydalanuvchi topilmadi",
"file": "Fayl",
"upload": "Sertifikatni yuklab olish"
"upload": "Sertifikatni yuklab olish",
"Cabinet": {
"plagiatCheck": "Plagiat tekshiruvi",
"checkDesc": "Hujjatingizni originallik uchun tekshiring",
"submit": "Yuborish",
"siDetector": "SI detektor",
"siDesc": "Matnni sun'iy intellekt uchun tekshiring",
"home": "Bosh sahifa",
"plagiat": "Plagiat",
"siNav": "SI detektor",
"payments": "To'lovlar tarixi",
"profile": "Profil",
"close": "Yopish",
"personalCabinet": "Shaxsiy kabinet",
"plagiatChecks": "Plagiat tekshiruvlar",
"dashboard": "Dashboard",
"welcome": "Xush kelibsiz, {userName} 👋",
"welcomeDesc": "Shaxsiy kabinetingizga xush kelibsiz",
"quickActions": "Tezkor harakatlar",
"totalChecks": "Jami tekshiruvlar",
"thisMonth": "Bu oy",
"paidAmount": "To'langan summa",
"noData": "Ma'lumot topilmadi",
"checkModules": "Tekshiruv modullari",
"checkModulesDesc": "Plagiat aniqlashda foydalaniladigan barcha manbalar",
"modulesCount": "{count} ta modul",
"totalModules": "Jami modullar",
"freeInternetSources": "Bepul internet manbalari",
"aiAnalysisModules": "AI tahlil modullari",
"categories": "Kategoriya",
"paymentsCount": "{count} ta to'lov",
"loading": "Yuklanmoqda...",
"noPayments": "To'lovlar tarixi mavjud emas",
"tableNum": "#",
"service": "Xizmat",
"amount": "Summa",
"discount": "Chegirma",
"date": "Sana",
"status": "Holat",
"unknown": "Noma'lum",
"noSiChecks": "Hozircha SI tekshiruvlar yo'q",
"loadError": "Ma'lumotlarni yuklashda xatolik yuz berdi",
"paid": "To'langan",
"unpaid": "To'lanmagan",
"checksCount": "{count} ta tekshiruv",
"tableTitle": "Sarlavha",
"tableFile": "Fayl",
"words": "So'z",
"action": "Amal",
"pay": "To'lash",
"view": "Ko'rish",
"profileDesc": "Ma'lumotlaringizni boshqaring",
"personalInfo": "Shaxsiy ma'lumotlar",
"changePassword": "Parol o'zgartirish",
"firstName": "Ism",
"lastName": "Familiya",
"phone": "Telefon",
"email": "Email",
"newPassword": "Yangi parol",
"saved": "Saqlandi",
"saving": "Saqlanmoqda…",
"save": "Saqlash",
"firstNameRequired": "Ism kiritilishi shart",
"lastNameRequired": "Familiya kiritilishi shart",
"phoneInvalid": "Telefon raqami 9 ta raqamdan iborat bo'lishi kerak",
"passwordTooShort": "Parol kamida 8 ta belgidan iborat bo'lishi kerak",
"discountThisMonth": "Bu oyda chegirma",
"discountRemaining": "{remaining} ta hujjatdan keyin chegirma tugaydi",
"discountAllUsed": "Bu oyda barcha chegirmalar ishlatildi",
"discountUsed": "{count} ta ishlatildi"
},
"SiDetail": {
"siCheck": "SI tekshiruv",
"basicInfo": "Asosiy ma'lumotlar",
"documentInfo": "Hujjat haqida ma'lumotlar",
"documentName": "Hujjat nomi",
"checker": "Tekshiruvchi",
"uploadedAt": "Hujjat yuklangan vaqti",
"originalFileName": "Hujjat faylining original nomi",
"wordCount": "So'zlar soni",
"fileExt": "Hujjat fayli kenggaytmasi",
"fileSize": "Hujjat fayli o'lchami",
"amountCharged": "Yechilgan summa",
"downloadOriginal": "Original hujjatni yuklab olish",
"siResultsTitle": "Hujjatning SI detektori natijalari",
"siResultsDesc": "Ushbu oynada foydalanuvchi tomonidan yuklangan matn sun'iy intellekt (SI) yordamida yozilgan bo'lish ehtimoli bo'yicha tahlil natijalari aks etirilgan. Detektor matnning stilistik, grammatik va semantik xususiyatlarini baholab, uning qanchalik darajada sun'iy intellekt tomonidan generatsiya qilingan bo'lishi mumkinligini foizlik ko'rinishida ko'rsatadi.",
"download": "Yuklab olish",
"originalText": "Original matn",
"possibleAi": "Ehtimoliy Sun'iy Intellekt",
"aiContent": "Sun'iy Intellekt"
},
"PlagiatResult": {
"plagiarismLevel": "Plagiat darajasi",
"aiWritten": "AI yozgan",
"originality": "Originallik",
"citation": "Iqtibos",
"plagiat": "Plagiat",
"aiGeneration": "AI generatsiya",
"original": "Original",
"checked": "Tekshirilgan"
},
"CertificateModal": {
"title": "Sertifikat yaratish",
"close": "Yopish",
"authorName": "Muallifning to'liq ismi",
"namePlaceholder": "Ismingizni kiriting...",
"documentTheme": "Hujjat mavzusi",
"themePlaceholder": "Mavzuni kiriting...",
"documentId": "Hujjat ID",
"creating": "Yaratilmoqda...",
"created": "Sertifikat yaratildi!",
"create": "Sertifikat yaratish"
},
"DocumentTypes": {
"label": "Hujjat turi",
"loading": "Yuklanmoqda...",
"placeholder": "Hujjat turini tanlang..."
}
}

View File

@@ -3,6 +3,9 @@ import { SEO_DATA, type SupportedLocale } from '../config/seo.config';
// ─── Site-wide constants ───────────────────────────────────────────────────────
export const SERTIFICATE_PRICE = 20600;
export const PLAGIAT_SERVICE_FEE = 20600;
const SITE_URL = process.env.NEXT_PUBLIC_SITE_URL ?? 'https://antiplagiat.uz';
const OG_IMAGE_URL = `${SITE_URL}/og-image.png`; // 1200×630 px recommended
const TWITTER_HANDLE = '@antiplagiatuz'; // update or remove if unused

View File

@@ -4,8 +4,44 @@ import axios, {
AxiosError,
InternalAxiosRequestConfig,
} from 'axios';
import { toast } from 'react-toastify';
import { getRouteLang } from './getLanguage';
// ─── Error message extractor ───────────────────────────────────────────────────
function extractErrorMessage(error: AxiosError): string {
const data = error.response?.data as Record<string, unknown> | undefined;
if (!data) {
if (error.code === 'ECONNABORTED')
return 'Request timed out. Please try again.';
if (!navigator.onLine) return 'No internet connection.';
return error.message || 'An unexpected error occurred.';
}
// Simple string fields: { message, detail, error }
if (typeof data.message === 'string' && data.message) return data.message;
if (typeof data.detail === 'string' && data.detail) return data.detail;
if (typeof data.error === 'string' && data.error) return data.error;
// Wrapped: { errors: { field: ["msg"] } }
if (data.errors && typeof data.errors === 'object') {
const first = Object.values(data.errors as Record<string, unknown>)[0];
if (Array.isArray(first) && first.length > 0) return String(first[0]);
if (typeof first === 'string') return first;
}
// DRF field-level errors at top level: { phone: ["msg"], name: ["msg"] }
for (const val of Object.values(data)) {
if (Array.isArray(val) && val.length > 0 && typeof val[0] === 'string') {
return val[0];
}
if (typeof val === 'string' && val) return val;
}
return 'An unexpected error occurred.';
}
// ─── Constants ─────────────────────────────────────────────────────────────────
// const baseUrl = process.env.NEXT_PUBLIC_API_BASE_URL;
@@ -107,9 +143,14 @@ api.interceptors.response.use(
};
const status = error.response?.status;
const requestUrl = originalRequest.url ?? '';
const isAuthEndpoint =
requestUrl.includes('/users/login/') ||
requestUrl.includes('/users/register/');
// Only attempt refresh on 401 and only once per request
if (status !== 401 || originalRequest._retry) {
// For auth endpoints, 401 means wrong credentials — show error, don't refresh
if (isAuthEndpoint || status !== 401 || originalRequest._retry) {
toast.error(extractErrorMessage(error));
return Promise.reject(error);
}
@@ -178,7 +219,7 @@ api.interceptors.response.use(
// ─── Public request function ───────────────────────────────────────────────────
export const apiRequest = async <T>(
method: 'GET' | 'POST' | 'PUT' | 'DELETE',
method: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE',
url: string,
data?: unknown,
config?: Omit<AxiosRequestConfig, 'method' | 'url' | 'data'>,
@@ -195,5 +236,7 @@ export const apiRequest = async <T>(
},
});
console.log('resposne: ', response);
return response;
};

View File

@@ -6,5 +6,16 @@ export const links = {
detail: (id: number) => `/shared/documents/${id}/`,
payment: (order_id: number) => `/users/payme/link/${order_id}/`,
sertifikat: (document_id: number) =>
`/shared/certificate/${document_id}/pdf/`,
`/shared/certificate/${document_id}/download/`,
si: '/shared/ai_document/list/',
si_id: (si_id: number) => `/shared/ai_document/${si_id}/`,
si_payment: (document_id: number) =>
`/shared/ai_document/pay/${document_id}/`,
si_create: '/shared/ai_document/create/',
document_types: '/shared/documents/types/',
pay_history: '/shared/orders/all/',
statistics: '/shared/statistics/',
wordCount: '/shared/check_file/',
users: '/users/profile/',
demo_pay: (order_id: number) => `/users/payme/link/${order_id}/`,
};

View File

@@ -3,7 +3,7 @@
import {
PlagiarismSubmissionPayload,
PlagiarismSubmissionResponse,
} from '@/widgets/fileUpload/lib/types';
} from '@/widgets/plagiatCheck/lib/types';
const API_BASE_URL = process.env.VITE_API_BASE_URL ?? '/api';
const ENDPOINT = `${API_BASE_URL}/plagiarism/submit`;

View File

@@ -18,7 +18,7 @@ function NavigationMenu({
data-slot="navigation-menu"
data-viewport={viewport}
className={cn(
'group/navigation-menu relative flex max-w-max flex-1 items-center justify-center',
' relative flex max-w-max flex-1 items-center justify-center',
className,
)}
{...props}
@@ -75,7 +75,7 @@ function NavigationMenuTrigger({
>
{children}{' '}
<ChevronDownIcon
className="relative top-[1px] ml-1 size-3 transition duration-300 group-data-[state=open]:rotate-180"
className="relative top-px ml-1 size-3 transition duration-300 group-data-[state=open]:rotate-180"
aria-hidden="true"
/>
</NavigationMenuPrimitive.Trigger>
@@ -145,7 +145,7 @@ function NavigationMenuIndicator({
<NavigationMenuPrimitive.Indicator
data-slot="navigation-menu-indicator"
className={cn(
'data-[state=visible]:animate-in data-[state=hidden]:animate-out data-[state=hidden]:fade-out data-[state=visible]:fade-in top-full z-[1] flex h-1.5 items-end justify-center overflow-hidden',
'data-[state=visible]:animate-in data-[state=hidden]:animate-out data-[state=hidden]:fade-out data-[state=visible]:fade-in top-full z-1 flex h-1.5 items-end justify-center overflow-hidden',
className,
)}
{...props}

View File

@@ -0,0 +1,12 @@
import { CabinetSection } from '@/widgets/cabinet/lib/types';
import { create } from 'zustand';
type CabinetNavZustand = {
navItem: CabinetSection;
setNavItem: (item: CabinetSection) => void;
};
export const useCabinetNav = create<CabinetNavZustand>((set) => ({
navItem: 'dashboard',
setNavItem: (item: CabinetSection) => set({ navItem: item }),
}));

View File

@@ -0,0 +1,47 @@
'use client';
import { useEffect, useState } from 'react';
import { useCabinetNav } from '@/shared/zustand/cabinetNav';
import type { CabinetSection } from '../types';
const VALID_SECTIONS: CabinetSection[] = [
'dashboard',
'plagiat',
'si',
'payments',
'profile',
];
export const useCabinet = () => {
const { navItem, setNavItem } = useCabinetNav();
const navItemZustrand = useCabinetNav((state) => state.navItem);
const [isSidebarOpen, setIsSidebarOpen] = useState(false);
const [activeSection, setActiveSection] =
useState<CabinetSection>('dashboard');
useEffect(() => {
if (navItemZustrand) {
const activeSection: CabinetSection = VALID_SECTIONS.includes(
navItemZustrand as CabinetSection,
)
? (navItemZustrand as CabinetSection)
: 'dashboard';
setActiveSection(activeSection);
} else {
const activeSection: CabinetSection = VALID_SECTIONS.includes(
navItem as CabinetSection,
)
? (navItem as CabinetSection)
: 'dashboard';
setActiveSection(activeSection);
}
}, [navItemZustrand]);
const navigate = (section: CabinetSection) => {
setNavItem(section);
setIsSidebarOpen(false);
};
const toggleSidebar = () => setIsSidebarOpen((prev) => !prev);
return { activeSection, navigate, isSidebarOpen, toggleSidebar };
};

View File

@@ -0,0 +1,105 @@
'use client';
import { useState, useEffect } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useTranslations } from 'next-intl';
import { apiRequest } from '@/shared/request/apiRequest';
import { links } from '@/shared/request/links';
import type { UserProfile } from '../types';
interface ProfileFormState {
first_name: string;
last_name: string;
phone: string; // 9 digits only, without 998 prefix
password: string;
}
export interface ProfileFormErrors {
first_name?: string;
last_name?: string;
phone?: string;
password?: string;
}
function stripPrefix(phone: string): string {
if (phone.startsWith('998')) return phone.slice(3);
return phone;
}
export const useProfile = () => {
const t = useTranslations('Cabinet');
const queryClient = useQueryClient();
const [saved, setSaved] = useState(false);
const [errors, setErrors] = useState<ProfileFormErrors>({});
const [form, setForm] = useState<ProfileFormState>({
first_name: '',
last_name: '',
phone: '',
password: '',
});
const { data: profile, isLoading } = useQuery({
queryKey: ['profile'],
queryFn: () => apiRequest<UserProfile>('GET', links.users),
select: (res) => res.data,
});
useEffect(() => {
if (profile) {
setForm({
first_name: profile.first_name,
last_name: profile.last_name,
phone: stripPrefix(profile.phone ?? ''),
password: '',
});
}
}, [profile]);
const { mutate, isPending: isSaving } = useMutation({
mutationFn: (payload: Record<string, string>) =>
apiRequest<UserProfile>('PATCH', links.users, payload),
onSuccess: () => {
setSaved(true);
queryClient.invalidateQueries({ queryKey: ['profile'] });
setTimeout(() => setSaved(false), 3000);
},
});
const handleChange = (field: keyof ProfileFormState, value: string) => {
setForm((prev) => ({ ...prev, [field]: value }));
setErrors((prev) => ({ ...prev, [field]: undefined }));
setSaved(false);
};
const validate = (): boolean => {
const next: ProfileFormErrors = {};
if (!form.first_name.trim()) next.first_name = t('firstNameRequired');
if (!form.last_name.trim()) next.last_name = t('lastNameRequired');
if (form.phone && form.phone.length !== 9) next.phone = t('phoneInvalid');
if (form.password && form.password.length < 8)
next.password = t('passwordTooShort');
setErrors(next);
return Object.keys(next).length === 0;
};
const handleSave = () => {
if (!validate()) return;
const payload: Record<string, string> = {
first_name: form.first_name,
last_name: form.last_name,
};
if (form.phone) payload.phone = `998${form.phone}`;
if (form.password) payload.password = form.password;
mutate(payload);
};
return {
form,
profile,
isLoading,
isSaving,
saved,
errors,
handleChange,
handleSave,
};
};

View File

@@ -0,0 +1,20 @@
'use client';
import { useQuery } from '@tanstack/react-query';
import { apiRequest } from '@/shared/request/apiRequest';
import { links } from '@/shared/request/links';
import type { SiDocument } from '../types';
export const useSiHistory = () => {
const { data, isLoading, isError, refetch } = useQuery({
queryKey: ['si-history'],
queryFn: () => apiRequest<SiDocument[]>('GET', links.si),
select: (res) => res.data,
});
return {
items: data ?? [],
isLoading,
isError,
refetch,
};
};

View File

@@ -0,0 +1,151 @@
import type {
CabinetStats,
Payment,
PlagiatCheck,
SiCheck,
UserProfile,
} from './types';
export const MOCK_USER: UserProfile = {
first_name: 'Ali',
last_name: 'Karimov',
email: 'ali.karimov@gmail.com',
phone: '+998 90 123 45 67',
};
export const MOCK_STATS: CabinetStats = {
total: 24,
thisMonth: 7,
discountUsed: 7,
discountTotal: 10,
balance: 0,
currency: 'UZS',
};
export const MOCK_PLAGIAT: PlagiatCheck[] = [
{
id: 1,
file: 'diplom_ishi.docx',
type: 'Diplom',
percent: 12,
date: '2026-04-01',
status: 'completed',
downloadUrl: '#',
},
{
id: 2,
file: 'kurs_ishi_2.pdf',
type: 'Kurs ishi',
percent: 8,
date: '2026-03-28',
status: 'completed',
downloadUrl: '#',
},
{
id: 3,
file: 'referat_fizika.docx',
type: 'Referat',
percent: 23,
date: '2026-03-20',
status: 'completed',
downloadUrl: '#',
},
{
id: 4,
file: 'magistr_disser.pdf',
type: 'Magistrlik',
percent: 5,
date: '2026-03-15',
status: 'completed',
downloadUrl: '#',
},
{
id: 5,
file: 'tahlil_hisobot.docx',
type: 'Hisobot',
percent: 0,
date: '2026-04-05',
status: 'pending',
},
];
export const MOCK_SI: SiCheck[] = [
{
id: 1,
file: 'kurs_ishi_1.docx',
words: 4200,
siPercent: 18,
date: '2026-04-02',
status: 'completed',
reportUrl: '#',
},
{
id: 2,
file: 'maqola_2026.pdf',
words: 1800,
siPercent: 42,
date: '2026-03-29',
status: 'completed',
reportUrl: '#',
},
{
id: 3,
file: 'tezis_draft.docx',
words: 950,
siPercent: 7,
date: '2026-03-22',
status: 'completed',
reportUrl: '#',
},
{
id: 4,
file: 'annotatsiya.txt',
words: 320,
siPercent: 0,
date: '2026-04-05',
status: 'pending',
},
];
export const MOCK_PAYMENTS: Payment[] = [
{
id: 1,
service: 'Plagiat tekshiruvi',
amount: 41200,
discount: 5200,
date: '2026-04-01',
status: 'paid',
},
{
id: 2,
service: 'SI detektor',
amount: 30000,
discount: 0,
date: '2026-03-28',
status: 'paid',
},
{
id: 3,
service: 'Plagiat tekshiruvi',
amount: 41200,
discount: 5200,
date: '2026-03-20',
status: 'paid',
},
{
id: 4,
service: 'Plagiat tekshiruvi',
amount: 41200,
discount: 0,
date: '2026-03-15',
status: 'paid',
},
{
id: 5,
service: 'SI detektor',
amount: 30000,
discount: 0,
date: '2026-04-05',
status: 'pending',
},
];

View File

@@ -0,0 +1,230 @@
// ─── Tag types ─────────────────────────────────────────────────────────────────
export type ModuleTag =
| 'Ilmiy'
| 'AI tahlil'
| 'Huquqiy'
| 'Internet'
| 'Bepul'
| 'OAV'
| 'Ichki baza'
| 'Standartlar'
| "Ta'lim"
| 'Xalqaro'
| 'Avtomatik';
export interface Module {
name: string;
desc: string;
tags: ModuleTag[];
}
export interface ModuleCategory {
label: string;
modules: Module[];
}
// ─── Tag style map ─────────────────────────────────────────────────────────────
export const TAG_STYLES: Record<ModuleTag, string> = {
Ilmiy: 'bg-blue-50 text-blue-700',
'AI tahlil': 'bg-violet-50 text-violet-700',
Huquqiy: 'bg-amber-50 text-amber-700',
Internet: 'bg-emerald-50 text-emerald-700',
Bepul: 'bg-lime-50 text-lime-700',
OAV: 'bg-stone-100 text-stone-600',
'Ichki baza': 'bg-stone-100 text-stone-600',
Standartlar: 'bg-amber-50 text-amber-700',
"Ta'lim": 'bg-blue-50 text-blue-700',
Xalqaro: 'bg-emerald-50 text-emerald-700',
Avtomatik: 'bg-violet-50 text-violet-700',
};
// ─── Module stats ──────────────────────────────────────────────────────────────
export const MODULE_STATS = {
total: 30,
freeInternet: 10,
aiModules: 5,
categories: 4,
} as const;
// ─── Module data ───────────────────────────────────────────────────────────────
export const MODULE_CATEGORIES: ModuleCategory[] = [
{
label: "Ilmiy va ta'lim bazalari",
modules: [
{
name: 'eLIBRARY.RU',
desc: "Rus va xorijiy tillardagi ilmiy maqolalarning to'liq matnlari bazasi",
tags: ['Ilmiy'],
},
{
name: 'eLIBRARY nashrlari (tarjima va qayta bayon)',
desc: 'Tarjima va parafraz qilingan maqolalarni aniqlash',
tags: ['AI tahlil'],
},
{
name: "RDK to'plami",
desc: 'Rossiya Davlat kutubxonasidan dissertatsiya va avtoreferatlar',
tags: ['Ilmiy'],
},
{
name: 'BMK dissertatsiyalari',
desc: 'Belarus milliy kutubxonasi dissertatsiyalari va avtoreferatlari',
tags: ['Ilmiy'],
},
{
name: 'IEEE',
desc: 'Xalqaro elektrotexnika va elektronika muhandislari instituti bazasi',
tags: ['Ilmiy'],
},
{
name: 'IEEE parafraz moduli',
desc: 'IEEE maqolalarining qayta bayon qilingan variantlarini aniqlash',
tags: ['AI tahlil'],
},
{
name: 'Elektron-kutubxona tizimlari',
desc: 'Book.ru, Yurait, Lans, Aybuks va boshqa ELS bazalari',
tags: ['Ilmiy'],
},
{
name: 'OTMlar halqasi',
desc: "O'zbekiston oliy ta'lim muassasalari birgalikdagi bazasi",
tags: ['Ilmiy'],
},
{
name: 'Kolleksiya NMU',
desc: "O'zbekiston milliy kutubxonasi to'plami",
tags: ['Ilmiy'],
},
],
},
{
label: 'Huquqiy va normativ bazalar',
modules: [
{
name: 'Patentlar',
desc: "SSSR, O'zbekiston, Rossiya va MDH davlatlari patentlari bazasi",
tags: ['Huquqiy'],
},
{
name: 'NBS Adilex',
desc: "O'zbekiston qonunchilik bazasi hujjatlari",
tags: ['Huquqiy'],
},
],
},
{
label: 'Internet tekshiruv modullari',
modules: [
{
name: 'Internet PLUS moduli',
desc: "Internet bo'ylab kengaytirilgan chuqur skanerlash",
tags: ['Internet'],
},
{
name: 'Internet RU parafraz',
desc: 'Rus internet segmentidagi qayta bayon qilingan qarzlar',
tags: ['Internet', 'AI tahlil'],
},
{
name: 'Internet EN parafraz',
desc: 'Ingliz internet segmentidagi qayta bayon qilingan qarzlar',
tags: ['Internet', 'AI tahlil'],
},
{
name: 'Internet RU tarjima',
desc: 'Rus internet segmentidagi tarjima qilingan qarzlar',
tags: ['Internet', 'AI tahlil'],
},
{
name: 'Internet EN tarjima',
desc: 'Ingliz internet segmentidagi tarjima qilingan qarzlar',
tags: ['Internet', 'AI tahlil'],
},
{
name: 'SMI Rossii va MDH',
desc: 'Rossiya va MDH ommaviy axborot vositalari maqolalari',
tags: ['OAV'],
},
{
name: "Kompaniyaning ichki to'plami",
desc: "Antiplag.uz ichki hujjatlar to'plami",
tags: ['Ichki baza'],
},
],
},
{
label: 'Yangi bepul internet manbalari',
modules: [
{
name: 'consultant.ru',
desc: 'Rossiya qonunchiligining elektron bazasi',
tags: ['Bepul', 'Huquqiy'],
},
{
name: 'kremlin.ru',
desc: 'Rossiya prezidenti farmonlari va rasmiy qonunlar',
tags: ['Bepul', 'Huquqiy'],
},
{
name: 'pravo.gov.ru',
desc: 'Rossiya rasmiy huquqiy hujjatlar nashriyoti portali',
tags: ['Bepul', 'Huquqiy'],
},
{
name: 'docs.cntd.ru',
desc: 'Texnik normalar va standartlar hujjatlari bazasi',
tags: ['Bepul', 'Standartlar'],
},
{
name: 'rumc.mininuniver.ru',
desc: "Minin universiteti adaptiv ta'lim dasturlari resursi",
tags: ['Bepul', "Ta'lim"],
},
{
name: 'moodle.kstu.ru',
desc: "KSTU universiteti Moodle platformasi o'quv resurslari",
tags: ['Bepul', "Ta'lim"],
},
{
name: 'freereferats.ru',
desc: "Dissertatsiya avtoreferatlari ochiq to'plami (PDF)",
tags: ['Bepul', 'Ilmiy'],
},
{
name: 'ktzszmoik.gov.by',
desc: 'Belarus nogironlarni ijtimoiy himoya qilish davlat portali',
tags: ['Bepul', 'Internet'],
},
{
name: 'bizlog.ru',
desc: "Iqtisodiy-boshqaruv terminologiyasi va izohli lug'at",
tags: ['Bepul', 'Internet'],
},
{
name: 'disabilityartsinternational.org',
desc: "Nogironlik va madaniyat bo'yicha xalqaro resurs",
tags: ['Bepul', 'Xalqaro'],
},
],
},
{
label: 'Yordamchi modullar',
modules: [
{
name: 'Shablon iboralar',
desc: "Standart kirish so'zlari, universitet nomlari va klishe iboralarni aniqlash",
tags: ['Avtomatik'],
},
{
name: 'Iqtibos keltirish moduli',
desc: "Hujjatda to'g'ri rasmiylashtirilgan iqtiboslarni avtomatik aniqlash",
tags: ['Avtomatik'],
},
],
},
];

View File

@@ -0,0 +1,70 @@
// ─── Navigation ────────────────────────────────────────────────────────────────
export type CabinetSection =
| 'dashboard'
| 'plagiat'
| 'si'
| 'payments'
| 'profile';
// ─── Domain ────────────────────────────────────────────────────────────────────
export interface UserProfile {
first_name: string;
last_name: string;
email: string;
phone: string;
}
export interface PlagiatCheck {
id: number;
file: string;
type: string;
percent: number;
date: string;
status: 'completed' | 'pending' | 'failed';
downloadUrl?: string;
}
export interface SiCheck {
id: number;
file: string;
words: number;
siPercent: number;
date: string;
status: 'completed' | 'pending' | 'failed';
reportUrl?: string;
}
export interface Payment {
id: number;
service: string;
amount: number;
discount: number;
date: string;
status: 'paid' | 'pending' | 'failed';
}
// ─── SI Document (API) ─────────────────────────────────────────────────────────
export interface SiDocument {
id: number;
title: string;
file: string;
created_at: string;
updated_at: string;
result: null | unknown;
state: 'paid' | 'unpaid';
ai_order_id: string;
total_words: number;
si_percantage: number | null;
}
export interface CabinetStats {
total: number;
thisMonth: number;
discountUsed: number;
discountTotal: number;
balance: number;
currency: string;
}

View File

@@ -0,0 +1,46 @@
'use client';
import React from 'react';
import { Menu } from 'lucide-react';
import { useTranslations } from 'next-intl';
import type { CabinetSection } from '../lib/types';
// ─── Props ─────────────────────────────────────────────────────────────────────
interface CabinetNavProps {
activeSection: CabinetSection;
onMenuClick: () => void;
}
// ─── Component ─────────────────────────────────────────────────────────────────
export const CabinetNav: React.FC<CabinetNavProps> = ({
activeSection,
onMenuClick,
}) => {
const t = useTranslations('Cabinet');
const SECTION_LABELS: Record<CabinetSection, string> = {
dashboard: t('dashboard'),
plagiat: t('plagiatChecks'),
si: t('siNav'),
payments: t('payments'),
profile: t('profile'),
};
return (
<header className="h-14 px-4 md:px-6 flex lg:hidden items-center justify-between border-b border-slate-100 bg-white/80 backdrop-blur-sm sticky top-0 z-20">
<div className="flex items-center gap-3">
<button
onClick={onMenuClick}
className="lg:hidden p-2 rounded-lg text-slate-500 hover:bg-slate-100 transition-colors"
aria-label="Menu"
>
<Menu size={18} />
</button>
<h1 className="text-sm font-semibold text-slate-800">
{SECTION_LABELS[activeSection]}
</h1>
</div>
</header>
);
};

View File

@@ -0,0 +1,149 @@
'use client';
import React from 'react';
import {
LayoutDashboard,
FileSearch,
BrainCircuit,
CreditCard,
User,
Home,
X,
} from 'lucide-react';
import { Link } from '@/shared/config/i18n/navigation';
import { useTranslations } from 'next-intl';
import type { CabinetSection } from '../lib/types';
// ─── Props ─────────────────────────────────────────────────────────────────────
interface SidebarProps {
active: CabinetSection;
onNavigate: (section: CabinetSection) => void;
isOpen: boolean;
onClose: () => void;
userName: string;
}
// ─── Component ─────────────────────────────────────────────────────────────────
export const Sidebar: React.FC<SidebarProps> = ({
active,
onNavigate,
isOpen,
onClose,
userName,
}) => {
const t = useTranslations('Cabinet');
console.log(userName);
const NAV_ITEMS = [
{ id: 'home' as const, label: t('home'), icon: Home, href: '/plagiat' },
{
id: 'dashboard' as CabinetSection,
label: t('dashboard'),
icon: LayoutDashboard,
},
{ id: 'plagiat' as CabinetSection, label: t('plagiat'), icon: FileSearch },
{ id: 'si' as CabinetSection, label: t('siNav'), icon: BrainCircuit },
{
id: 'payments' as CabinetSection,
label: t('payments'),
icon: CreditCard,
},
{ id: 'profile' as CabinetSection, label: t('profile'), icon: User },
];
return (
<>
{/* Mobile backdrop */}
{isOpen && (
<div
className="fixed inset-0 z-30 bg-black/30 backdrop-blur-sm lg:hidden"
onClick={onClose}
/>
)}
<aside
className={`
fixed top-0 left-0 z-40 h-full w-60 bg-white border-r border-slate-100
flex flex-col shadow-xl
transition-transform duration-300 ease-out
lg:sticky lg:top-0 lg:h-screen lg:translate-x-0 lg:shadow-none lg:z-auto
${isOpen ? 'translate-x-0' : '-translate-x-full'}
`}
>
{/* Brand */}
<div className="lg:hidden flex items-center justify-end px-5 py-4 border-b border-slate-100">
<button
onClick={onClose}
className=" p-1.5 rounded-lg text-slate-400 hover:text-slate-600 hover:bg-slate-100 transition-colors"
aria-label={t('close')}
>
<X size={15} />
</button>
</div>
{/* User pill */}
{/* <div className="px-3 pt-4 pb-2">
<div className="flex items-center gap-3 px-3 py-2.5 rounded-xl bg-slate-50 border border-slate-100">
<div className="w-8 h-8 rounded-full bg-blue-100 flex items-center justify-center shrink-0">
<span className="text-blue-600 text-xs font-semibold">
{userName.charAt(0).toUpperCase()}
</span>
</div>
<div className="min-w-0">
<p className="text-sm font-medium text-slate-800 truncate">
{userName}
</p>
<p className="text-[11px] text-slate-400">
{t('personalCabinet')}
</p>
</div>
</div>
</div> */}
{/* Navigation */}
<nav className="flex-1 px-3 py-2 space-y-0.5 overflow-y-auto">
{NAV_ITEMS.map((item) => {
const Icon = item.icon;
if (item.id === 'home') {
return (
<Link
key="home"
href={item.href as string}
className="flex items-center gap-3 px-3 py-2.5 rounded-xl text-sm font-medium text-slate-500 hover:text-slate-800 hover:bg-slate-50 transition-all duration-150"
>
<Icon size={17} />
<span>{item.label}</span>
</Link>
);
}
const isActive = item.id === active;
return (
<button
key={item.id}
onClick={() => onNavigate(item.id as CabinetSection)}
className={`
w-full flex items-center gap-3 px-3 py-2.5 rounded-xl text-sm font-medium
transition-all duration-150
${
isActive
? 'bg-blue-50 text-blue-600'
: 'text-slate-500 hover:text-slate-800 hover:bg-slate-50'
}
`}
>
<Icon size={17} />
<span>{item.label}</span>
{isActive && (
<span className="ml-auto w-1.5 h-1.5 rounded-full bg-blue-500 shrink-0" />
)}
</button>
);
})}
</nav>
</aside>
</>
);
};

View File

@@ -0,0 +1,38 @@
'use client';
import React from 'react';
import { FileSearch, ArrowRight } from 'lucide-react';
import Link from 'next/link';
import { SiCTACard } from '@/features/modals/siModal/page';
import { useTranslations } from 'next-intl';
export const CtaCards = () => {
const t = useTranslations('Cabinet');
return (
<>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
{/* Plagiat */}
<Link
href={'/plagiat'}
className="group relative overflow-hidden rounded-2xl bg-linear-to-br from-blue-500 to-blue-600 p-6 text-left shadow-md hover:shadow-xl transition-all duration-200 hover:-translate-y-0.5 active:translate-y-0 active:shadow-md"
>
<div className="absolute right-3 top-3 opacity-10 pointer-events-none">
<FileSearch size={72} className="text-white" />
</div>
<FileSearch size={26} className="text-white mb-4" />
<h3 className="text-white font-semibold text-base mb-1">
{t('plagiatCheck')}
</h3>
<p className="text-blue-100 text-sm mb-4 leading-relaxed">
{t('checkDesc')}
</p>
<span className="inline-flex items-center gap-1.5 text-white text-xs font-medium bg-white/20 rounded-lg px-3 py-1.5 group-hover:bg-white/30 transition-colors">
{t('submit')} <ArrowRight size={12} />
</span>
</Link>
{/* SI */}
<SiCTACard />
</div>
</>
);
};

View File

@@ -0,0 +1,132 @@
'use client';
import React from 'react';
import { useTranslations } from 'next-intl';
import {
MODULE_CATEGORIES,
MODULE_STATS,
TAG_STYLES,
type ModuleTag,
} from '../../lib/modules';
// ─── Module stats mini-cards ───────────────────────────────────────────────────
const ModuleStats: React.FC = () => {
const t = useTranslations('Cabinet');
return (
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
{[
{ value: MODULE_STATS.total, label: t('totalModules') },
{ value: MODULE_STATS.freeInternet, label: t('freeInternetSources') },
{ value: MODULE_STATS.aiModules, label: t('aiAnalysisModules') },
{ value: MODULE_STATS.categories, label: t('categories') },
].map(({ value, label }) => (
<div
key={label}
className="bg-slate-50 border border-slate-100 rounded-xl px-4 py-3"
>
<p className="text-2xl font-bold text-slate-900 tabular-nums leading-none">
{value}
</p>
<p className="text-xs text-slate-500 mt-1">{label}</p>
</div>
))}
</div>
);
};
// ─── Tag badge ─────────────────────────────────────────────────────────────────
const Tag: React.FC<{ tag: ModuleTag }> = ({ tag }) => (
<span
className={`inline-block text-[10px] font-semibold px-2 py-0.5 rounded-full ${TAG_STYLES[tag]}`}
>
{tag}
</span>
);
// ─── Category divider ──────────────────────────────────────────────────────────
const CategoryHeader: React.FC<{ label: string }> = ({ label }) => (
<div className="flex items-center gap-3 my-5">
<div className="flex-1 h-px bg-slate-100" />
<span className="text-[11px] font-semibold tracking-widest text-slate-400 uppercase whitespace-nowrap">
{label}
</span>
<div className="flex-1 h-px bg-slate-100" />
</div>
);
// ─── Single module card ────────────────────────────────────────────────────────
interface ModuleCardProps {
name: string;
desc: string;
tags: ModuleTag[];
index: number;
}
const ModuleCard: React.FC<ModuleCardProps> = ({ name, desc, tags, index }) => (
<div className="bg-white border border-slate-100 rounded-xl p-4 flex items-start gap-3 hover:border-slate-200 hover:shadow-sm transition-all duration-150">
<span className="w-6 h-6 rounded-full bg-slate-100 flex items-center justify-center text-[10px] font-semibold text-slate-500 shrink-0 mt-0.5">
{index}
</span>
<div className="min-w-0 flex-1">
<p className="text-sm font-semibold text-slate-800 leading-snug mb-1 truncate">
{name}
</p>
<p className="text-xs text-slate-500 leading-relaxed mb-2.5">{desc}</p>
<div className="flex flex-wrap gap-1.5">
{tags.map((tag) => (
<Tag key={tag} tag={tag} />
))}
</div>
</div>
</div>
);
// ─── Modules section ───────────────────────────────────────────────────────────
export const ModulesSection: React.FC = () => {
const t = useTranslations('Cabinet');
let counter = 0;
return (
<div>
<div className="flex items-center justify-between mb-4">
<div>
<h3 className="text-sm font-semibold text-slate-800">
{t('checkModules')}
</h3>
<p className="text-xs text-slate-400 mt-0.5">
{t('checkModulesDesc')}
</p>
</div>
<span className="text-xs text-slate-400 bg-slate-100 px-2.5 py-1 rounded-lg font-medium">
{t('modulesCount', { count: MODULE_STATS.total })}
</span>
</div>
<ModuleStats />
{MODULE_CATEGORIES.map((cat) => (
<div key={cat.label}>
<CategoryHeader label={cat.label} />
<div className="grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-3 gap-3">
{cat.modules.map((mod) => {
counter += 1;
return (
<ModuleCard
key={mod.name}
name={mod.name}
desc={mod.desc}
tags={mod.tags}
index={counter}
/>
);
})}
</div>
</div>
))}
</div>
);
};

View File

@@ -0,0 +1,110 @@
'use client';
import React from 'react';
import { TrendingUp, Calendar, Wallet, Loader2 } from 'lucide-react';
import { useQuery } from '@tanstack/react-query';
import { useTranslations } from 'next-intl';
import { apiRequest } from '@/shared/request/apiRequest';
import { links } from '@/shared/request/links';
// ─── Types ─────────────────────────────────────────────────────────────────────
type Stats = {
total_documents: number;
this_month_documents: number;
paid_price: number;
};
// ─── Single stat card ──────────────────────────────────────────────────────────
interface StatCardProps {
icon: React.ElementType;
label: string;
value: string;
sub?: string;
iconColor: string;
iconBg: string;
}
const StatCard: React.FC<StatCardProps> = ({
icon: Icon,
label,
value,
sub,
iconColor,
iconBg,
}) => (
<div className="bg-white rounded-2xl border border-slate-100 p-5 shadow-sm hover:shadow-md transition-shadow duration-200">
<div
className={`w-10 h-10 rounded-xl flex items-center justify-center mb-4 ${iconBg}`}
>
<Icon size={18} className={iconColor} />
</div>
<p className="text-2xl font-bold text-slate-900 tabular-nums">{value}</p>
<p className="text-xs text-slate-500 mt-0.5">{label}</p>
{sub && <p className="text-[11px] text-slate-400 mt-1">{sub}</p>}
</div>
);
const StatCardSkeleton = () => (
<div className="bg-white rounded-2xl border border-slate-100 p-5 shadow-sm animate-pulse">
<div className="w-10 h-10 rounded-xl bg-slate-100 mb-4" />
<div className="h-7 w-16 bg-slate-100 rounded mb-2" />
<div className="h-3 w-24 bg-slate-100 rounded" />
</div>
);
// ─── Grid ──────────────────────────────────────────────────────────────────────
export const StatsCards = () => {
const t = useTranslations('Cabinet');
const { data, isLoading } = useQuery({
queryKey: ['statistics'],
queryFn: (): Promise<Stats> =>
apiRequest('GET', links.statistics).then((res) => res.data as Stats),
});
if (isLoading) {
return (
<div className="grid grid-cols-2 lg:grid-cols-3 gap-4">
<StatCardSkeleton />
<StatCardSkeleton />
<StatCardSkeleton />
</div>
);
}
if (!data) {
return (
<div className="flex items-center justify-center py-10 gap-2 text-slate-400">
<Loader2 size={18} className="animate-spin" />
<span className="text-sm">{t('noData')}</span>
</div>
);
}
return (
<div className="grid grid-cols-2 lg:grid-cols-3 gap-4">
<StatCard
icon={TrendingUp}
label={t('totalChecks')}
value={String(data.total_documents)}
iconColor="text-blue-600"
iconBg="bg-blue-50"
/>
<StatCard
icon={Calendar}
label={t('thisMonth')}
value={String(data.this_month_documents)}
iconColor="text-emerald-600"
iconBg="bg-emerald-50"
/>
<StatCard
icon={Wallet}
label={t('paidAmount')}
value={`${data.paid_price.toLocaleString('uz-UZ')} UZS`}
iconColor="text-violet-600"
iconBg="bg-violet-50"
/>
</div>
);
};

View File

@@ -0,0 +1,37 @@
'use client';
import React from 'react';
import { useTranslations } from 'next-intl';
import { CtaCards } from './CtaCards';
import { StatsCards } from './StatsCards';
import { ModulesSection } from './ModulesSection';
interface DashboardProps {
userName: string;
}
export const Dashboard: React.FC<DashboardProps> = ({ userName }) => {
const t = useTranslations('Cabinet');
return (
<div className="space-y-6">
<div>
<h2 className="text-xl font-bold text-slate-900">
{t('welcome', { userName })}
</h2>
<p className="text-sm text-slate-500 mt-0.5">{t('welcomeDesc')}</p>
</div>
<StatsCards />
<div>
<h3 className="text-xs font-semibold text-slate-400 uppercase tracking-widest mb-3">
{t('quickActions')}
</h3>
<CtaCards />
</div>
<div className="border-t border-slate-100 pt-6">
<ModulesSection />
</div>
</div>
);
};

View File

@@ -0,0 +1,99 @@
'use client';
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 { MOCK_USER, MOCK_STATS } from '../lib/mock';
import type { CabinetSection } from '../lib/types';
// ─── Lazy sections (separate JS chunks) ───────────────────────────────────────
const Skeleton = () => (
<div className="space-y-3">
{Array.from({ length: 4 }).map((_, i) => (
<div key={i} className="h-12 bg-slate-100 rounded-xl animate-pulse" />
))}
</div>
);
const PlagiatTable = dynamic(
() =>
import('./tables/PlagiatTable').then((m) => ({ default: m.PlagiatTable })),
{ loading: Skeleton },
);
const SiTable = dynamic(
() => import('./tables/SiTable').then((m) => ({ default: m.SiTable })),
{ loading: Skeleton },
);
const PaymentsTable = dynamic(
() =>
import('./tables/PaymentsTable').then((m) => ({
default: m.PaymentsTable,
})),
{ loading: Skeleton },
);
const ProfileSection = dynamic(
() => import('./profile').then((m) => ({ default: m.ProfileSection })),
{ loading: Skeleton },
);
// ─── Section switcher ──────────────────────────────────────────────────────────
function SectionContent({ section }: { section: CabinetSection }) {
switch (section) {
case 'dashboard':
return <Dashboard userName={MOCK_USER.first_name} />;
case 'plagiat':
return <PlagiatTable />;
case 'si':
return <SiTable />;
case 'payments':
return <PaymentsTable />;
case 'profile':
return <ProfileSection stats={MOCK_STATS} />;
}
}
// ─── Animation ────────────────────────────────────────────────────────────────
const FADE = {
initial: { opacity: 0, y: 10 },
animate: { opacity: 1, y: 0 },
exit: { opacity: 0, y: -6 },
transition: { duration: 0.18, ease: 'easeOut' },
} as const;
// ─── CabinetLayout ────────────────────────────────────────────────────────────
export const CabinetLayout: React.FC = () => {
const { activeSection, navigate, isSidebarOpen, toggleSidebar } =
useCabinet();
const fullName = `${MOCK_USER.first_name} ${MOCK_USER.last_name}`;
return (
<div className="flex bg-slate-50 min-h-screen">
<Sidebar
active={activeSection}
onNavigate={navigate}
isOpen={isSidebarOpen}
onClose={toggleSidebar}
userName={fullName}
/>
<div className="flex-1 flex flex-col min-w-0">
<CabinetNav activeSection={activeSection} onMenuClick={toggleSidebar} />
<main className="flex-1 p-4 md:p-6 lg:p-8 max-w-6xl mx-auto w-full">
<AnimatePresence mode="wait">
<motion.div key={activeSection} {...FADE}>
<SectionContent section={activeSection} />
</motion.div>
</AnimatePresence>
</main>
</div>
</div>
);
};

View File

@@ -0,0 +1,52 @@
import React from 'react';
import { Tag } from 'lucide-react';
import type { CabinetStats } from '../../lib/types';
interface DiscountProgressProps {
stats: CabinetStats;
}
export const DiscountProgress: React.FC<DiscountProgressProps> = ({
stats,
}) => {
const pct = Math.round((stats.discountUsed / stats.discountTotal) * 100);
const remaining = stats.discountTotal - stats.discountUsed;
return (
<div className="bg-gradient-to-br from-amber-50 to-orange-50 border border-amber-100 rounded-2xl p-5">
<div className="flex items-start justify-between mb-4">
<div className="flex items-center gap-2">
<div className="w-8 h-8 rounded-xl bg-amber-100 flex items-center justify-center">
<Tag size={15} className="text-amber-600" />
</div>
<div>
<h3 className="text-sm font-semibold text-amber-800">
Bu oyda chegirma
</h3>
<p className="text-xs text-amber-600 mt-0.5">
{remaining > 0
? `${remaining} ta hujjatdan keyin chegirma tugaydi`
: 'Bu oyda barcha chegirmalar ishlatildi'}
</p>
</div>
</div>
<span className="text-2xl font-bold text-amber-700 tabular-nums">
{stats.discountUsed}/{stats.discountTotal}
</span>
</div>
{/* Bar */}
<div className="h-2 bg-amber-100 rounded-full overflow-hidden mb-2.5">
<div
className="h-full bg-amber-400 rounded-full transition-[width] duration-700 ease-out"
style={{ width: `${pct}%` }}
/>
</div>
<div className="flex justify-between text-[11px] text-amber-600">
<span>{stats.discountUsed} ta ishlatildi</span>
<span>{pct}%</span>
</div>
</div>
);
};

View File

@@ -0,0 +1,241 @@
'use client';
import React, { useState } from 'react';
import { User, Lock, Save, CheckCircle, Phone } from 'lucide-react';
import { useTranslations } from 'next-intl';
import { useProfile } from '../../lib/hooks/useProfile';
import { formatPhone, normalizeDigits } from '@/shared/lib/formatPhone';
// ─── Input field ───────────────────────────────────────────────────────────────
interface InputFieldProps {
label: string;
value: string;
onChange: (val: string) => void;
type?: string;
icon: React.ElementType;
placeholder?: string;
disabled?: boolean;
error?: string;
}
const InputField: React.FC<InputFieldProps> = ({
label,
value,
onChange,
type = 'text',
icon: Icon,
placeholder,
disabled,
error,
}) => (
<div>
<label className="block text-xs font-semibold text-slate-600 mb-1.5">
{label}
</label>
<div className="relative">
<Icon
size={14}
className="absolute left-3.5 top-1/2 -translate-y-1/2 text-slate-400 pointer-events-none"
/>
<input
type={type}
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder={placeholder}
disabled={disabled}
className={`
w-full pl-9 pr-4 py-2.5 text-sm rounded-xl
border bg-white
text-slate-800 placeholder:text-slate-300
focus:outline-none focus:ring-2 focus:ring-blue-100 focus:border-blue-400
disabled:bg-slate-50 disabled:text-slate-400 disabled:cursor-not-allowed
transition-all duration-150
${error ? 'border-rose-400 bg-rose-50' : 'border-slate-200'}
`}
/>
</div>
{error && <p className="mt-1 text-xs text-rose-500">{error}</p>}
</div>
);
// ─── Phone input ───────────────────────────────────────────────────────────────
interface PhoneFieldProps {
value: string;
onChange: (val: string) => void;
error?: string;
}
const PhoneField: React.FC<PhoneFieldProps> = ({ value, onChange, error }) => {
const t = useTranslations('Cabinet');
const [isFocused, setIsFocused] = useState(false);
return (
<div>
<label className="block text-xs font-semibold text-slate-600 mb-1.5">
{t('phone')}
</label>
<div className="relative">
{/* +998 prefix */}
<div className="absolute left-3.5 top-1/2 -translate-y-1/2 flex items-center gap-1.5 pointer-events-none">
<Phone
size={14}
className={isFocused ? 'text-blue-500' : 'text-slate-400'}
/>
<span
className={`text-sm font-semibold ${isFocused ? 'text-blue-500' : 'text-slate-400'}`}
>
+998
</span>
<span className="text-slate-300">|</span>
</div>
<input
type="tel"
value={formatPhone(value)}
onChange={(e) => onChange(normalizeDigits(e.target.value))}
onFocus={() => setIsFocused(true)}
onBlur={() => setIsFocused(false)}
placeholder="90 123 45 67"
className={`
w-full pl-24 pr-4 py-2.5 text-sm rounded-xl
border bg-white
text-slate-800 placeholder:text-slate-300
focus:outline-none focus:ring-2 focus:ring-blue-100 focus:border-blue-400
transition-all duration-150
${error ? 'border-rose-400 bg-rose-50' : 'border-slate-200'}
`}
/>
</div>
{error && <p className="mt-1 text-xs text-rose-500">{error}</p>}
</div>
);
};
// ─── Skeleton ──────────────────────────────────────────────────────────────────
const FieldSkeleton = () => (
<div className="space-y-1.5">
<div className="h-3.5 w-20 bg-slate-200 rounded animate-pulse" />
<div className="h-10 w-full bg-slate-100 rounded-xl animate-pulse" />
</div>
);
// ─── Form ──────────────────────────────────────────────────────────────────────
export const ProfileForm: React.FC = () => {
const t = useTranslations('Cabinet');
const {
form,
profile,
isLoading,
isSaving,
saved,
errors,
handleChange,
handleSave,
} = useProfile();
return (
<div className="space-y-5">
{/* Personal info */}
<div className="bg-white rounded-2xl border border-slate-100 shadow-sm p-6">
<h3 className="text-sm font-semibold text-slate-800 mb-4">
{t('personalInfo')}
</h3>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
{isLoading ? (
<>
<FieldSkeleton />
<FieldSkeleton />
<FieldSkeleton />
<FieldSkeleton />
</>
) : (
<>
<InputField
label={t('firstName')}
value={form.first_name}
onChange={(v) => handleChange('first_name', v)}
icon={User}
placeholder="Ali"
error={errors.first_name}
/>
<InputField
label={t('lastName')}
value={form.last_name}
onChange={(v) => handleChange('last_name', v)}
icon={User}
placeholder="Karimov"
error={errors.last_name}
/>
<PhoneField
value={form.phone}
onChange={(v) => handleChange('phone', v)}
error={errors.phone}
/>
<InputField
label={t('email')}
value={profile?.email ?? ''}
onChange={() => {}}
icon={User}
placeholder="email@example.com"
disabled
/>
</>
)}
</div>
</div>
{/* Password */}
<div className="bg-white rounded-2xl border border-slate-100 shadow-sm p-6">
<h3 className="text-sm font-semibold text-slate-800 mb-4">
{t('changePassword')}
</h3>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<InputField
label={t('newPassword')}
value={form.password}
onChange={(v) => handleChange('password', v)}
type="password"
icon={Lock}
placeholder="••••••••"
error={errors.password}
/>
</div>
</div>
{/* Save */}
<div className="flex justify-end">
<button
onClick={handleSave}
disabled={isSaving || isLoading}
className={`
flex items-center gap-2 px-6 py-2.5 rounded-xl text-sm font-semibold text-white
transition-all duration-200
${
isSaving
? 'bg-blue-300 cursor-not-allowed'
: saved
? 'bg-emerald-500 hover:bg-emerald-600'
: 'bg-blue-500 hover:bg-blue-600 active:scale-95 shadow-md hover:shadow-lg'
}
`}
>
{saved ? (
<>
<CheckCircle size={15} /> {t('saved')}
</>
) : isSaving ? (
<>
<span className="w-4 h-4 border-2 border-white/40 border-t-white rounded-full animate-spin" />
{t('saving')}
</>
) : (
<>
<Save size={15} /> {t('save')}
</>
)}
</button>
</div>
</div>
);
};

View File

@@ -0,0 +1,25 @@
'use client';
import React from 'react';
import { useTranslations } from 'next-intl';
import { ProfileForm } from './ProfileForm';
import type { CabinetStats } from '../../lib/types';
interface ProfileSectionProps {
stats: CabinetStats;
}
export const ProfileSection: React.FC<ProfileSectionProps> = ({ stats }) => {
const t = useTranslations('Cabinet');
console.log(stats);
return (
<div className="space-y-6">
<div>
<h2 className="text-xl font-bold text-slate-900">{t('profile')}</h2>
<p className="text-sm text-slate-500 mt-0.5">{t('profileDesc')}</p>
</div>
{/* <DiscountProgress stats={stats} /> */}
<ProfileForm />
</div>
);
};

View File

@@ -0,0 +1,182 @@
'use client';
import React from 'react';
import { Clock, XCircle, ReceiptText } from 'lucide-react';
import { useQuery } from '@tanstack/react-query';
import { useTranslations } from 'next-intl';
import { apiRequest } from '@/shared/request/apiRequest';
import { links } from '@/shared/request/links';
import PaymentStatus from '@/widgets/detail/paidStatus';
// import { toast } from 'react-toastify';
// import { PaymentModal } from '@/features/modals/paymentModal/ui/Paymentmodal';
// ─── Types ─────────────────────────────────────────────────────────────────────
type Inspection = {
created_at: string;
discount: string | null;
id: number;
state: 'paid' | 'unpaid' | null;
total_price: string;
turi: string;
};
// ─── Helpers ───────────────────────────────────────────────────────────────────
function formatDate(iso: string) {
return new Date(iso).toLocaleDateString('uz-UZ', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
});
}
function formatPrice(price: string) {
return Number(price).toLocaleString('uz-UZ');
}
// ─── Component ─────────────────────────────────────────────────────────────────
export function PaymentsTable() {
const t = useTranslations('Cabinet');
// const [isPaymentOpen, setIsPaymentOpen] = useState(false);
const { data, isLoading } = useQuery({
queryKey: ['pay_history'],
queryFn: (): Promise<Inspection[]> =>
apiRequest('GET', links.pay_history).then(
(res) => res.data as Inspection[],
),
});
// const payment = useMutation({
// mutationKey: ['payload'],
// mutationFn: ({ order_id }: { order_id: number }) =>
// apiRequest<{ payment_link: string }>('POST', links.payment(order_id)),
// onSuccess: (res) => {
// window.open(res.data.payment_link, '_self');
// // setIsPaymentOpen(false);
// },
// onError: (err) => {
// const message =
// err instanceof Error ? err.message : 'An unexpected error occurred.';
// toast.error(message);
// // setIsPaymentOpen(false);
// },
// });
// const handleSubmit = ({ document_id }: { document_id: number }) => {
// if (document_id === 0) {
// toast.error('Id not found');
// return;
// }
// payment.mutate({ order_id: document_id });
// };
return (
<>
<div className="space-y-4">
<div>
<h2 className="text-xl font-bold text-slate-900">{t('payments')}</h2>
<p className="text-sm text-slate-500 mt-0.5">
{t('paymentsCount', { count: data?.length ?? 0 })}
</p>
</div>
<div className="bg-white rounded-2xl border border-slate-100 shadow-sm overflow-hidden">
{isLoading ? (
<div className="flex items-center justify-center py-16 gap-3 text-slate-400">
<Clock size={20} className="animate-spin" />
<span className="text-sm">{t('loading')}</span>
</div>
) : !data || data.length === 0 ? (
<div className="flex flex-col items-center justify-center py-16 gap-3 text-slate-400">
<ReceiptText size={40} strokeWidth={1.5} />
<p className="text-sm">{t('noPayments')}</p>
</div>
) : (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="bg-slate-50 border-b border-slate-100">
{[
t('tableNum'),
t('service'),
t('amount'),
t('discount'),
t('date'),
t('status'),
].map((h) => (
<th
key={h}
className="text-left px-5 py-3 text-[11px] font-semibold text-slate-400 uppercase tracking-wider whitespace-nowrap"
>
{h}
</th>
))}
</tr>
</thead>
<tbody className="divide-y divide-slate-50">
{data.map((row) => {
// const service_fee = row.total_price + row.discount;
return (
<tr
key={row.id}
className="hover:bg-slate-50/60 transition-colors"
>
<td className="px-5 py-3.5 text-slate-400 font-mono text-xs">
{String(row.id).padStart(2, '0')}
</td>
<td className="px-5 py-3.5 text-slate-800 font-medium whitespace-nowrap">
{row.turi}
</td>
<td className="px-5 py-3.5 text-slate-800 font-semibold tabular-nums whitespace-nowrap">
{formatPrice(row.total_price)} UZS
</td>
<td className="px-5 py-3.5 tabular-nums whitespace-nowrap">
{row.discount ? (
<span className="text-emerald-600 font-medium">
-{formatPrice(row.discount)} UZS
</span>
) : (
<span className="text-slate-300"></span>
)}
</td>
<td className="px-5 py-3.5 text-slate-500 whitespace-nowrap">
{formatDate(row.created_at)}
</td>
<td className="px-5 py-3.5">
{row.state ? (
<span className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-lg text-xs font-medium text-emerald-600 bg-emerald-50">
<PaymentStatus status={row.state} />
</span>
) : (
<span className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-lg text-xs font-medium text-slate-400 bg-slate-50">
<XCircle size={12} />
{t('unknown')}
</span>
)}
</td>
{/* <PaymentModal
isOpen={isPaymentOpen}
onClose={() => setIsPaymentOpen(false)}
price={{
service_fee: Number(service_fee),
discount: Number(row.discount) || 0,
total_price: Number(row.total_price) || 0,
}}
onConfirmPayment={() => {
handleSubmit({ document_id: 0 });
}}
isLoading={payment.isPending}
/> */}
</tr>
);
})}
</tbody>
</table>
</div>
)}
</div>
</div>
</>
);
}

View File

@@ -0,0 +1,6 @@
import React from 'react';
import { HistoryPage } from '@/widgets/history';
export const PlagiatTable = () => {
return <HistoryPage />;
};

View File

@@ -0,0 +1,242 @@
'use client';
import React from 'react';
import { Download, CreditCard, Eye } from 'lucide-react';
import { useMutation } from '@tanstack/react-query';
import { useTranslations } from 'next-intl';
import { useSiHistory } from '../../lib/hooks/useSiHistory';
import { formatDate } from '@/widgets/history/lib/utils';
import { apiRequest } from '@/shared/request/apiRequest';
import { links } from '@/shared/request/links';
import { toast } from 'react-toastify';
import type { SiDocument } from '../../lib/types';
import { SiButton } from '@/features/modals/siModal/page';
import { useRouter, useParams } from 'next/navigation';
// ─── State badge ───────────────────────────────────────────────────────────────
const StateBadge: React.FC<{ state: 'paid' | 'unpaid' }> = ({ state }) => {
const t = useTranslations('Cabinet');
const isPaid = state === 'paid';
return (
<span
className={[
'inline-flex items-center gap-1.5 px-2.5 py-1 rounded-lg text-xs font-semibold whitespace-nowrap select-none',
isPaid ? 'bg-emerald-50 text-emerald-700' : 'bg-rose-50 text-rose-600',
].join(' ')}
>
<span
className={[
'w-1.5 h-1.5 rounded-full shrink-0',
isPaid ? 'bg-emerald-500' : 'bg-rose-500',
].join(' ')}
/>
{isPaid ? t('paid') : t('unpaid')}
</span>
);
};
// ─── SI% badge ─────────────────────────────────────────────────────────────────
const SiPercentBadge: React.FC<{ value: number }> = ({ value }) => {
const cls =
value < 20
? 'text-emerald-700 bg-emerald-50'
: value < 40
? 'text-amber-700 bg-amber-50'
: 'text-red-700 bg-red-50';
return (
<span
className={`inline-block px-2 py-0.5 rounded-md text-xs font-semibold ${cls}`}
>
{value}%
</span>
);
};
// ─── Skeleton ──────────────────────────────────────────────────────────────────
const SkeletonRow = () => (
<tr>
{Array.from({ length: 7 }).map((_, i) => (
<td key={i} className="px-5 py-3.5">
<div
className="h-4 bg-slate-100 rounded animate-pulse"
style={{ width: i === 1 ? 120 : 60 }}
/>
</td>
))}
</tr>
);
// ─── Empty / Error states ──────────────────────────────────────────────────────
const EmptyState = () => {
const t = useTranslations('Cabinet');
return (
<tr>
<td colSpan={7} className="px-5 py-12 text-center text-slate-400 text-sm">
{t('noSiChecks')}
</td>
</tr>
);
};
const ErrorState = () => {
const t = useTranslations('Cabinet');
return (
<tr>
<td colSpan={7} className="px-5 py-12 text-center text-red-400 text-sm">
{t('loadError')}
</td>
</tr>
);
};
// ─── Row ───────────────────────────────────────────────────────────────────────
const SiRow: React.FC<{ item: SiDocument; index: number }> = ({
item,
index,
}) => {
const t = useTranslations('Cabinet');
const router = useRouter();
const { locale } = useParams() as { locale: string };
const pay = useMutation({
mutationKey: ['si-payment', item.id],
mutationFn: () =>
apiRequest<{ payment_link: string }>('POST', links.demo_pay(item.id)),
onSuccess: (res) => {
window.open(res.data.payment_link, '_self');
},
onError: (err) => {
toast.error(err instanceof Error ? err.message : 'Xatolik yuz berdi');
},
});
return (
<tr className="hover:bg-slate-50/60 transition-colors">
<td className="px-5 py-3.5 text-slate-400 font-mono text-xs">
{String(index).padStart(2, '0')}
</td>
<td className="px-5 py-3.5">
<span className="text-slate-800 font-medium max-w-45 truncate block text-sm">
{item.title || '—'}
</span>
</td>
<td className="px-5 py-3.5">
{item.file ? (
<a
href={item.file}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1.5 text-slate-500 hover:text-blue-600 transition-colors"
>
<Download size={14} />
<span className="text-xs font-medium">{t('tableFile')}</span>
</a>
) : (
<span className="text-slate-200 text-xs"></span>
)}
</td>
<td className="px-5 py-3.5 text-slate-500 tabular-nums text-sm">
{item.total_words > 0 ? item.total_words.toLocaleString() : '—'}
</td>
<td className="px-5 py-3.5">
{item.si_percantage != null && item.result != null ? (
<SiPercentBadge value={item.si_percantage} />
) : (
<span className="text-slate-300 text-xs"></span>
)}
</td>
<td className="px-5 py-3.5 text-slate-500 text-sm whitespace-nowrap">
{formatDate(item.created_at)}
</td>
<td className="px-5 py-3.5">
<StateBadge state={item.state} />
</td>
<td className="px-5 py-3.5 text-right">
{item.state === 'unpaid' ? (
<button
onClick={() => pay.mutate()}
disabled={pay.isPending}
className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium text-white bg-violet-600 hover:bg-violet-700 active:scale-95 transition-all duration-150 disabled:opacity-60"
>
<CreditCard size={11} />
{pay.isPending ? '...' : t('pay')}
</button>
) : (
<button
onClick={() => router.push(`/${locale}/si/${item.id}`)}
className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium text-blue-600 bg-blue-50 hover:bg-blue-100 active:scale-95 transition-all duration-150"
>
<Eye size={11} />
{t('view')}
</button>
)}
</td>
</tr>
);
};
// ─── SiTable ───────────────────────────────────────────────────────────────────
export const SiTable: React.FC = () => {
const t = useTranslations('Cabinet');
const { items, isLoading, isError } = useSiHistory();
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<div>
<h2 className="text-xl font-bold text-slate-900">{t('siNav')}</h2>
<p className="text-sm text-slate-500 mt-0.5">
{isLoading ? '...' : t('checksCount', { count: items.length })}
</p>
</div>
<SiButton />
</div>
<div className="bg-white rounded-2xl border border-slate-100 shadow-sm overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="bg-slate-50 border-b border-slate-100">
{[
t('tableNum'),
t('tableTitle'),
t('tableFile'),
t('words'),
'SI%',
t('date'),
t('status'),
t('action'),
].map((h) => (
<th
key={h}
className="text-left px-5 py-3 text-[11px] font-semibold text-slate-400 uppercase tracking-wider whitespace-nowrap last:text-right"
>
{h}
</th>
))}
</tr>
</thead>
<tbody className="divide-y divide-slate-50">
{isLoading &&
Array.from({ length: 5 }).map((_, i) => (
<SkeletonRow key={i} />
))}
{isError && <ErrorState />}
{!isLoading && !isError && items.length === 0 && <EmptyState />}
{!isLoading &&
!isError &&
items.map((item, i) => (
<SiRow key={item.id} item={item} index={i + 1} />
))}
</tbody>
</table>
</div>
</div>
</div>
);
};

View File

@@ -0,0 +1,365 @@
'use client';
import React, { useEffect } from 'react';
import { useQuery } from '@tanstack/react-query';
import { useParams } from 'next/navigation';
import { useTranslations } from 'next-intl';
import { apiRequest } from '@/shared/request/apiRequest';
import { links } from '@/shared/request/links';
import { Download } from 'lucide-react';
// ── Types ────────────────────────────────────────────────────────────────────
type SiResultRes = {
ai: number;
hash: string;
text: string;
citation: number;
plagiarism: number;
originality: number;
};
type SiResultData = {
ok: boolean;
res: SiResultRes;
error: string;
success: string;
text_res: string;
analyze_text?: Record<string, unknown>;
};
type SiResult = {
id: number;
document: number;
result: SiResultData;
};
type SiDetail = {
id: number;
title: string;
file: string;
created_at: string;
updated_at: string;
state: 'paid' | 'unpaid';
total_words: number;
si_percantage: number | null;
result: SiResult | null;
file_size?: number;
file_extension?: string;
total_price?: number | string;
user?: { name: string; surname: string };
};
// ── Helpers ───────────────────────────────────────────────────────────────────
function formatDate(iso: string): string {
return new Date(iso).toLocaleString('uz-UZ', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
});
}
function fileExtension(url: string): string {
const name = url.split('/').pop() ?? '';
const ext = name.split('.').pop();
return ext ? `.${ext}` : '—';
}
function fileName(url: string): string {
return url.split('/').pop() ?? '—';
}
function fileSizeMb(bytes?: number): string {
if (!bytes) return '—';
return `${(bytes / 1024 / 1024).toFixed(2)} MB`;
}
// ── Sub-components ────────────────────────────────────────────────────────────
function InfoRow({ label, value }: { label: string; value: React.ReactNode }) {
return (
<div className="flex items-center justify-between py-4 border-b border-slate-100 last:border-0">
<span className="text-sm font-medium text-slate-500">{label}</span>
<span className="text-sm font-semibold text-slate-800 text-right max-w-[60%] break-all">
{value}
</span>
</div>
);
}
function SiBar({
label,
value,
color,
}: {
label: string;
value: number;
color: string;
}) {
return (
<div className="space-y-1.5">
<div className="flex items-center justify-between text-sm">
<span className="text-slate-600 font-medium">{label}</span>
<span className="font-bold text-slate-800">{value}%</span>
</div>
<div className="h-2.5 w-full bg-slate-100 rounded-full overflow-hidden">
<div
className={`h-full rounded-full transition-all duration-700 ${color}`}
style={{ width: `${value}%` }}
/>
</div>
</div>
);
}
// ── Skeleton ──────────────────────────────────────────────────────────────────
function Skeleton({ className }: { className?: string }) {
return (
<div
className={`animate-pulse bg-slate-200 rounded-lg ${className ?? ''}`}
/>
);
}
function LoadingSkeleton() {
return (
<div className="min-h-screen bg-slate-50">
<div className="h-16 bg-white border-b border-slate-200" />
<div className="max-w-4xl mx-auto px-6 py-10 space-y-6">
<Skeleton className="h-8 w-48" />
<div className="bg-white rounded-2xl border border-slate-100 p-6 space-y-4">
{Array.from({ length: 8 }).map((_, i) => (
<div key={i} className="flex justify-between">
<Skeleton className="h-4 w-40" />
<Skeleton className="h-4 w-32" />
</div>
))}
</div>
<div className="bg-white rounded-2xl border border-slate-100 p-6 space-y-5">
{Array.from({ length: 3 }).map((_, i) => (
<Skeleton key={i} className="h-6 w-full" />
))}
</div>
</div>
</div>
);
}
// ── Main Page ─────────────────────────────────────────────────────────────────
export default function SiDetailPage({ id }: { id: number }) {
const t = useTranslations('SiDetail');
const { locale } = useParams() as { locale: string };
useEffect(() => {
console.log(locale);
}, []);
const {
data: doc,
isLoading,
isError,
} = useQuery({
queryKey: ['si-detail', id],
queryFn: (): Promise<SiDetail> =>
apiRequest('GET', links.si_id(id)).then((res) => res.data as SiDetail),
enabled: !!id,
staleTime: 1000 * 60 * 5,
});
if (isLoading) return <LoadingSkeleton />;
if (isError || !doc) {
return (
<div className="min-h-screen bg-slate-50 flex items-center justify-center">
<div className="text-center space-y-3">
<p className="text-slate-700 font-semibold">
Ma&apos;lumot topilmadi
</p>
<button
onClick={() => window.history.back()}
className="text-sm text-blue-600 hover:underline"
>
Ortga qaytish
</button>
</div>
</div>
);
}
// Derive SI percentages
const res = doc.result?.result?.res;
const original = res?.originality ?? 100 - (doc.si_percantage ?? 0);
const aiPossible = res?.plagiarism ?? 0;
const ai = res?.ai ?? doc.si_percantage ?? 0;
return (
<div className="min-h-screen bg-slate-50 font-sans">
{/* ── Header ── */}
<header className="bg-white border-b border-slate-200 sticky top-0 z-20">
<div className="max-w-4xl mx-auto px-6 py-4 flex items-center gap-4">
<button
onClick={() => window.history.back()}
className="w-8 h-8 rounded-lg border border-slate-200 flex items-center justify-center hover:bg-slate-50 transition-colors shrink-0"
>
<svg
className="w-4 h-4 text-slate-600"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M15 19l-7-7 7-7"
/>
</svg>
</button>
<h1 className="text-base font-bold text-slate-800 truncate">
{doc.title || t('siCheck')}
</h1>
</div>
</header>
<main className="max-w-4xl mx-auto px-6 py-8 space-y-6">
{/* ── Section 1: Asosiy ma'lumotlar ── */}
<div className="bg-white rounded-2xl border border-slate-100 shadow-sm overflow-hidden">
{/* Section header */}
<div className="bg-slate-50 border-b border-slate-100 px-6 py-4 text-center">
<h2 className="text-sm font-bold uppercase tracking-widest text-blue-600">
{t('basicInfo')}
</h2>
<div className="w-10 h-0.5 bg-blue-600 mx-auto mt-1.5" />
</div>
<div className="px-6">
{/* Sub-header */}
<p className="text-[11px] font-bold uppercase tracking-widest text-slate-400 py-4 border-b border-slate-100">
{t('documentInfo')}
</p>
<InfoRow
label={t('documentName')}
value={doc.title || fileName(doc.file)}
/>
{doc.user && (
<InfoRow
label={t('checker')}
value={`${doc.user.name} ${doc.user.surname}`}
/>
)}
<InfoRow
label={t('uploadedAt')}
value={formatDate(doc.created_at)}
/>
<InfoRow label={t('originalFileName')} value={fileName(doc.file)} />
<InfoRow
label={t('wordCount')}
value={
doc.total_words > 0
? doc.total_words.toLocaleString('uz-UZ')
: '—'
}
/>
<InfoRow label={t('fileExt')} value={fileExtension(doc.file)} />
{doc.file_size !== undefined && (
<InfoRow
label={t('fileSize')}
value={fileSizeMb(doc.file_size)}
/>
)}
{doc.total_price !== undefined && (
<InfoRow
label={t('amountCharged')}
value={`${Number(doc.total_price).toLocaleString('uz-UZ')} so'm`}
/>
)}
{/* Download button */}
{doc.file && (
<div className="py-5 flex justify-end">
<a
href={doc.file}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-2 px-5 py-2.5 rounded-xl border border-cyan-500 text-cyan-600 text-sm font-semibold hover:bg-cyan-50 transition-colors"
>
<Download size={15} />
{t('downloadOriginal')}
</a>
</div>
)}
</div>
</div>
{/* ── Section 2: SI detektor natijalari ── */}
<div className="bg-white rounded-2xl border border-slate-100 shadow-sm overflow-hidden">
{/* Header row */}
<div className="px-6 py-5 flex items-start justify-between gap-4 border-b border-slate-100">
<div className="flex items-start gap-4">
{/* PDF icon */}
<div className="w-12 h-14 bg-red-100 rounded-lg flex items-center justify-center shrink-0">
<svg
className="w-7 h-7 text-red-500"
viewBox="0 0 24 24"
fill="currentColor"
>
<path d="M7 2a2 2 0 00-2 2v16a2 2 0 002 2h10a2 2 0 002-2V8l-6-6H7zm5 1l5 5h-5V3z" />
<text
x="5"
y="20"
fontSize="5"
fill="white"
fontWeight="bold"
>
PDF
</text>
</svg>
</div>
<div>
<h2 className="text-xl font-black text-slate-800 leading-snug">
{t('siResultsTitle')}
</h2>
<p className="text-sm text-slate-500 mt-1.5 leading-relaxed max-w-lg">
{t('siResultsDesc')}
</p>
</div>
</div>
{/* {doc.file && (
<a
href={doc.file}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-2 px-5 py-2.5 rounded-xl bg-slate-800 text-white text-sm font-semibold hover:bg-slate-700 transition-colors shrink-0"
>
<CloudDownload size={16} />
{t('download')}
</a>
)} */}
</div>
{/* Bars */}
<div className="px-6 py-6 space-y-5">
<SiBar
label={t('originalText')}
value={original}
color="bg-emerald-400"
/>
<SiBar
label={t('possibleAi')}
value={aiPossible}
color="bg-amber-400"
/>
<SiBar label={t('aiContent')} value={ai} color="bg-red-500" />
</div>
</div>
</main>
</div>
);
}

View File

@@ -6,11 +6,11 @@ import { useTranslations } from 'next-intl';
import { useParams } from 'next/navigation';
import { links } from '@/shared/request/links';
import { apiRequest } from '@/shared/request/apiRequest';
import Sertifikat from './sertifikat';
import PaymentStatus from './paidStatus';
import Sertifikat from '@/features/modals/sertificateModal/sertifikat';
// ── Types ────────────────────────────────────────────────────────────────────
const baseUrl = 'https://api.anti-plagiat.uz/api/v1';
interface AnalyzeText {
[key: string]: number | string;
}
@@ -365,7 +365,7 @@ export default function DocumentDetailPage({ id }: { id: number }) {
<div className="min-h-screen bg-slate-50 font-sans">
{/* ── Header ── */}
<header className="bg-white border-b border-slate-200 sticky top-0 z-20">
<div className="max-w-5xl mx-auto px-6 py-4 flex items-center justify-between">
<div className="max-w-5xl mx-auto px-6 py-4 flex max-sm:flex-col sm:items-center justify-between max-sm:gap-5">
<div className="flex items-center gap-3">
<button
onClick={() => window.history.back()}
@@ -395,14 +395,29 @@ export default function DocumentDetailPage({ id }: { id: number }) {
</div>
</div>
<div className="flex items-center gap-3">
<div className="flex flex-wrap items-center gap-3">
<PaymentStatus status={doc.state} />
{doc.certificate && <Sertifikat document_id={Number(id)} />}
<Sertifikat document_id={Number(id)} />
{doc.file && (
<a
href={doc.file}
target="_blank"
rel="noopener noreferrer"
<button
onClick={async () => {
const url = `${baseUrl}/shared/documents/${doc.id}/download`;
const res = await apiRequest<ArrayBuffer>(
'GET',
url,
undefined,
{ responseType: 'arraybuffer', baseURL: baseUrl },
);
const blob = new Blob([res.data], {
type: 'application/pdf',
});
const objectUrl = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = objectUrl;
a.download = `document-${id}.pdf`;
a.click();
setTimeout(() => URL.revokeObjectURL(objectUrl), 1000);
}}
className="inline-flex items-center gap-2 px-4 py-2 rounded-lg bg-slate-900 text-white text-xs font-semibold hover:bg-slate-700 transition-colors"
>
<svg
@@ -419,7 +434,7 @@ export default function DocumentDetailPage({ id }: { id: number }) {
/>
</svg>
{t('downloadPdf')}
</a>
</button>
)}
</div>
</div>
@@ -487,20 +502,25 @@ export default function DocumentDetailPage({ id }: { id: number }) {
<div className="flex flex-wrap justify-around gap-8">
<ScoreRing
value={res.originality}
label="Originality"
label={t('scoreOriginality')}
color="#10b981"
/>
<ScoreRing
value={res.plagiarism}
label="Plagiarism"
label={t('scorePlagiarism')}
color="#f59e0b"
/>
<ScoreRing
value={res.citation}
label="Citation"
label={t('scoreCitation')}
color="#6366f1"
/>
<ScoreRing value={res.ai} label="AI Content" color="#ef4444" />
<ScoreRing
value={res.ai}
label={t('scoreAiContent')}
color="#ef4444"
/>
</div>
<div

View File

@@ -1,67 +0,0 @@
'use client';
import { useTranslations } from 'next-intl';
import { FileDown, Loader2 } from 'lucide-react';
import React, { useState } from 'react';
// const baseUrl = process.env.NEXT_PUBLIC_API_BASE_URL;
const baseUrl = 'https://api.anti-plagiat.uz/api/v1';
export default function Sertifikat({ document_id }: { document_id: number }) {
const t = useTranslations();
const [loading, setLoading] = useState(false);
const handleClick = async () => {
setLoading(true);
try {
const url = `${baseUrl}/shared/certificate/${document_id}/pdf/`;
const res = await fetch(url);
const blob = await res.blob();
const objectUrl = URL.createObjectURL(blob);
// ✅ window.open o'rniga <a> tag bilan download
const a = document.createElement('a');
a.href = objectUrl;
a.download = `certificate-${document_id}.pdf`;
a.click();
URL.revokeObjectURL(objectUrl);
} finally {
setLoading(false);
}
};
// const handleClick = () => {
// const url = `${baseUrl}/documents/${document_id}/pdf/`;
// window.open(url, '_blank');
// };
return (
<button
onClick={handleClick}
disabled={loading}
className="
group relative inline-flex items-center gap-2.5
px-5 py-2.5 rounded-xl
bg-linear-to-br from-amber-400 to-amber-500
hover:from-amber-500 hover:to-amber-600
disabled:from-amber-300 disabled:to-amber-400
text-white font-semibold text-sm
shadow-md shadow-amber-200
hover:shadow-lg hover:shadow-amber-300
transition-all duration-200
active:scale-[0.97]
disabled:cursor-not-allowed disabled:scale-100
"
>
{loading ? (
<Loader2 size={16} className="animate-spin shrink-0" />
) : (
<FileDown
size={16}
className="shrink-0 transition-transform duration-200 group-hover:-translate-y-0.5 group-hover:translate-x-0.5"
/>
)}
{loading ? '...' : t('upload')}
</button>
);
}

View File

@@ -1,3 +1,5 @@
'use client';
import { useTranslations } from 'next-intl';
import { blue } from '../../lib/constant';
import { PlagiatData } from '../../lib/types';
import BarRow from './BarRow';
@@ -14,27 +16,28 @@ export default function GaugeWithBars({
originalityPercent,
citationPercent,
}: Props) {
const t = useTranslations('PlagiatResult');
return (
<div className="flex items-center gap-5 mb-5">
<CircleGauge value={plagiarismPercent || 0} />
<div className="flex-1">
<BarRow
label="Plagiat"
label={t('plagiat')}
value={plagiarismPercent || 0}
color={blue[900]}
/>
<BarRow
label="AI generatsiya"
label={t('aiGeneration')}
value={aiPercent || 0}
color={blue[600]}
/>
<BarRow
label="Original"
label={t('original')}
value={originalityPercent || 0}
color={blue[400]}
/>
<BarRow
label="Iqtibos"
label={t('citation')}
value={citationPercent || 0}
color={blue[200]}
/>

View File

@@ -1,3 +1,5 @@
'use client';
import { useTranslations } from 'next-intl';
import { blue } from '../../lib/constant';
import { PlagiatData } from '../../lib/types';
@@ -21,6 +23,7 @@ export default function Header({
location,
checkedAt,
}: Props) {
const t = useTranslations('PlagiatResult');
return (
<div className="flex items-center gap-3 mb-5">
<div
@@ -41,7 +44,7 @@ export default function Header({
className="text-xs px-2.5 py-0.5 rounded-md font-medium"
style={{ background: blue[100], color: blue[800] }}
>
{plagiarismPercent}% plagiat
{plagiarismPercent}% {t('plagiat').toLowerCase()}
</span>
</div>
<p className="text-[13px] mt-0.5 truncate" style={{ color: blue[400] }}>
@@ -50,7 +53,7 @@ export default function Header({
</div>
<div className="text-right shrink-0">
<p className="text-[11px]" style={{ color: blue[400] }}>
Tekshirilgan
{t('checked')}
</p>
<p className="text-[12px] mt-0.5" style={{ color: blue[600] }}>
{checkedAt}

View File

@@ -1,3 +1,5 @@
'use client';
import { useTranslations } from 'next-intl';
import { PlagiatData } from '../../lib/types';
import MetricCard from './Metriccard';
@@ -12,12 +14,16 @@ export default function TopMetrics({
originalityPercent,
citationPercent,
}: Props) {
const t = useTranslations('PlagiatResult');
return (
<div className="grid grid-cols-4 gap-2.5 mb-5">
<MetricCard label="Plagiat darajasi" value={`${plagiarismPercent}%`} />
<MetricCard label="AI yozgan" value={`${aiPercent}%`} />
<MetricCard label="Originallik" value={`${originalityPercent}%`} />
<MetricCard label="Iqtibos" value={`${citationPercent}%`} />
<MetricCard
label={t('plagiarismLevel')}
value={`${plagiarismPercent}%`}
/>
<MetricCard label={t('aiWritten')} value={`${aiPercent}%`} />
<MetricCard label={t('originality')} value={`${originalityPercent}%`} />
<MetricCard label={t('citation')} value={`${citationPercent}%`} />
</div>
);
}

View File

@@ -145,26 +145,6 @@ function Skeleton() {
);
}
// ─── Error state ──────────────────────────────────────────────────────────────
function ErrorState() {
return (
<div className="min-h-screen flex items-center justify-center p-6 bg-slate-50">
<div
className="rounded-xl p-8 text-center"
style={{ background: '#fff', border: `0.5px solid ${blue[100]}` }}
>
<p className="text-sm font-medium mb-1" style={{ color: blue[900] }}>
Ma&apos;lumot topilmadi
</p>
<p className="text-xs" style={{ color: blue[400] }}>
Ushbu tekshiruv mavjud emas yoki o&apos;chirilgan
</p>
</div>
</div>
);
}
// ─── Component ────────────────────────────────────────────────────────────────
export default function PlagiatResult({ id }: { id: number }) {
@@ -186,7 +166,22 @@ export default function PlagiatResult({ id }: { id: number }) {
});
if (isLoading) return <Skeleton />;
if (isError || !rawData) return <ErrorState />;
if (isError || !rawData)
return (
<div className="min-h-screen flex items-center justify-center p-6 bg-slate-50">
<div
className="rounded-xl p-8 text-center"
style={{ background: '#fff', border: `0.5px solid ${blue[100]}` }}
>
<p className="text-sm font-medium mb-1" style={{ color: blue[900] }}>
Ma&apos;lumot topilmadi
</p>
<p className="text-xs" style={{ color: blue[400] }}>
Ushbu tekshiruv mavjud emas yoki o&apos;chirilgan
</p>
</div>
</div>
);
const data: PlagiatData = transformResponse(rawData);

View File

@@ -8,9 +8,9 @@ const Footer = () => {
// { name: 'Contact', href: '/contact' },
// ];
return (
<section className="py-10">
<div className="custom-container">
<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">
<section className="py-5">
<div className="max-w-6xl w-full mx-auto">
<div className=" flex flex-col justify-between gap-4 text-center text-sm font-medium text-muted-foreground lg:flex-row lg:items-center lg:text-left">
<p>{t('copyright', { year: new Date().getFullYear() })}</p>
<ul className="flex justify-center gap-4 lg:justify-start">
<li className="hover:text-primary">

View File

@@ -3,8 +3,9 @@
import { CheckResult } from './types';
export const TABLE_COLUMNS = [
{ key: 'senderFullName', labelKey: 'sender' },
{ key: 'fileName', labelKey: 'file' },
{ key: 'id', labelKey: 'count' },
{ key: 'fileName', labelKey: 'fileName' },
{ key: 'file', labelKey: 'file' },
{ key: 'date', labelKey: 'date' },
{ key: 'state', labelKey: 'state' },
{ key: 'actions', labelKey: 'actions' },

View File

@@ -1,5 +1,7 @@
// ─── Domain Types ──────────────────────────────────────────────────────────────
import { PriceCalculate } from '@/features/modals/paymentModal/lib/types';
export type CheckResult = 'clean' | 'plagiarism_found' | 'pending' | 'failed';
export interface DocumentData {
@@ -13,6 +15,7 @@ export interface DocumentData {
results: [];
state: 'paid' | 'unpaid';
order_id: number;
price_calculation?: PriceCalculate;
}
export interface PlagiarismCheckDetail extends DocumentData {

View File

@@ -4,19 +4,36 @@ import { useTranslations } from 'next-intl';
import { useHistory } from '../lib/useHistory';
import { HistoryTable } from './historyTable';
import { Pagination } from './pagination';
import Link from 'next/link';
import { Plus } from 'lucide-react';
import { usePathname } from '@/shared/config/i18n/navigation';
// ─── Page Header ───────────────────────────────────────────────────────────────
const PageHeader: React.FC = () => {
const t = useTranslations('HistoryPage');
const pathname = usePathname();
return (
<div className="flex items-center gap-2 justify-between">
<div className="mb-6">
<h1 className="text-2xl font-semibold text-slate-900 tracking-tight">
{t('title')}
</h1>
<p className="mt-1 text-sm text-slate-500">{t('description')}</p>
</div>
<Link
href={'/plagiat'}
className={`${pathname === '/cabinet' ? 'flex' : 'hidden'}
items-center gap-2 px-2 py-1 group relative overflow-hidden rounded-sm bg-linear-to-br
from-blue-500 to-blue-600 text-left shadow-md hover:shadow-xl transition-all duration-200
hover:-translate-y-0.5 active:translate-y-0 active:shadow-md`}
>
<Plus size={15} className="text-white" />
<h3 className="text-white font-semibold text-base">
{t('plagiatCheck')}
</h3>
</Link>
</div>
);
};

View File

@@ -72,7 +72,7 @@ const TableBody: React.FC<HistoryTableFullProps> = ({
return (
<tbody>
{items.map((item) => (
<HistoryTableRow key={item.id} item={item} />
<HistoryTableRow key={item.id} index={item.id} item={item} />
))}
</tbody>
);

View File

@@ -1,158 +1,137 @@
'use client';
import React, { useEffect, useState } from 'react';
import React, { useState } from 'react';
import { useTranslations } from 'next-intl';
import { ArrowRight, Download } from 'lucide-react';
import { HistoryTableRowProps } from '../lib/types';
import { formatDate } from '../lib/utils';
import { useRouter } from '@/shared/config/i18n/navigation';
import { useUserPlagiatStore } from '@/shared/zustand/user';
import PaymentStatus from '@/widgets/detail/paidStatus';
import { PaymentModal } from '@/widgets/paymentModal/ui/Paymentmodal';
import { useMutation } from '@tanstack/react-query';
import { apiRequest } from '@/shared/request/apiRequest';
import { links } from '@/shared/request/links';
import { toast } from 'react-toastify';
import { PaymentModal } from '@/features/modals/paymentModal/ui/Paymentmodal';
import { PLAGIAT_SERVICE_FEE, SERTIFICATE_PRICE } from '@/shared/lib/metadata';
export const HistoryTableRow: React.FC<HistoryTableRowProps> = ({ item }) => {
// ─── State badge ───────────────────────────────────────────────────────────────
export const StateBadge: React.FC<{ state: 'paid' | 'unpaid' }> = ({
state,
}) => {
const isPaid = state === 'paid';
const t = useTranslations();
return (
<span
className={[
'inline-flex items-center gap-1.5 px-2.5 py-1 rounded-lg text-xs font-semibold whitespace-nowrap select-none',
isPaid ? 'bg-emerald-50 text-emerald-700' : 'bg-rose-50 text-rose-600',
].join(' ')}
>
<span
className={[
'w-1.5 h-1.5 rounded-full shrink-0',
isPaid ? 'bg-emerald-500' : 'bg-rose-500',
].join(' ')}
/>
{isPaid ? t('Cabinet.paid') : t('Cabinet.unpaid')}
</span>
);
};
// ─── Row ───────────────────────────────────────────────────────────────────────
export const HistoryTableRow: React.FC<
HistoryTableRowProps & { index: number }
> = ({ item, index }) => {
const router = useRouter();
const t = useTranslations('HistoryPage');
const tPay = useTranslations('Payment');
const tUnknown = useTranslations();
const user = useUserPlagiatStore((state) => state.user);
const [isPaymentOpen, setIsPaymentOpen] = useState(false);
const [localUser, setLocalUser] = useState<{
id: number;
name: string;
surname: string;
} | null>(null);
useEffect(() => {
const data = localStorage.getItem('user');
if (data) {
setLocalUser(JSON.parse(data));
} else {
setLocalUser(null);
}
}, [user]);
const userName = localUser
? `${localUser.name} ${localUser.surname}`
: tUnknown('unknownUser');
const payment = useMutation({
mutationKey: ['payload'],
mutationKey: ['payment', item.order_id],
mutationFn: ({ order_id }: { order_id: number }) =>
apiRequest<{ payment_link: string }>('POST', links.payment(order_id)),
apiRequest<{ payment_link: string }>('POST', links.demo_pay(order_id)),
onSuccess: (res) => {
console.log('payment res: ', res);
window.open(res.data.payment_link, '_self');
//route.push(`/${document_id}`);
setIsPaymentOpen(false);
},
onError: (err) => {
const message =
err instanceof Error ? err.message : 'An unexpected error occurred.';
toast.error(message);
toast.error(err instanceof Error ? err.message : 'Xatolik yuz berdi');
setIsPaymentOpen(false);
},
});
const handleSubmit = ({ document_id }: { document_id: number }) => {
payment.mutate({ order_id: document_id });
const price = item.price_calculation ?? {
service_fee: item.state === 'unpaid' ? PLAGIAT_SERVICE_FEE : 0,
discount: 0,
certificate: item.certificate ? SERTIFICATE_PRICE : 0,
total_price: 41200,
currency: 'UZS',
};
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">
{userName}
<tr className="hover:bg-slate-50/60 transition-colors">
{/* # */}
<td className="px-5 py-3.5 text-slate-400 font-mono text-xs">
{String(index).padStart(2, '0')}
</td>
{/* Sarlavha */}
<td className="px-5 py-3.5">
<span className="text-slate-800 font-medium max-w-45 truncate block text-sm">
{item.title || '—'}
</span>
</td>
{/* File Name */}
<td className="px-4 py-3.5">
{/* Fayl */}
<td className="px-5 py-3.5">
{item.file ? (
<a
href={item.file}
target="_blank"
className="flex items-center gap-2 underline"
rel="noopener noreferrer"
className="inline-flex items-center gap-1.5 text-slate-500 hover:text-blue-600 transition-colors"
aria-label={tUnknown('file')}
>
<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.file}
>
{tUnknown('file')}
</span>
<Download size={14} />
<span className="text-xs font-medium">{tUnknown('file')}</span>
</a>
) : (
<span className="text-slate-200 text-xs"></span>
)}
</td>
{/* Date */}
<td className="px-4 py-3.5">
<span className="text-sm text-slate-500 whitespace-nowrap">
{/* Sana */}
<td className="px-5 py-3.5 text-slate-500 text-sm whitespace-nowrap">
{formatDate(item.created_at)}
</span>
</td>
{/* State */}
<td className="px-4 py-3.5">
{/* Holat */}
<td className="px-5 py-3.5">
<span
onClick={() => {
if (item.state === 'unpaid') {
setIsPaymentOpen(true);
}
if (item.state === 'unpaid') setIsPaymentOpen(true);
}}
className="text-sm font-medium text-slate-700 whitespace-nowrap tabular-nums"
className={item.state === 'unpaid' ? 'cursor-pointer' : ''}
>
<PaymentStatus status={item.state} />
<StateBadge state={item.state} />
</span>
</td>
{/* View Button */}
<td className="px-4 py-3.5 text-right">
{/* Amal */}
<td className="px-5 py-3.5 text-right">
<button
onClick={() => {
if (item.state === 'paid') {
router.push(`/${item.id}`);
if (item.state === 'unpaid') {
toast.error("To'lov qilinmagan");
} else {
toast.error(tPay('paymentRequired'));
router.push(`/${item.id}`);
}
}}
aria-label={t('viewDetails', { sender: item.title })}
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
"
className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium text-slate-600 bg-white border border-slate-200 hover:border-slate-300 hover:text-slate-900 hover:bg-slate-50 active:scale-95 transition-all duration-150"
>
{t('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>
{tUnknown('HistoryPage.view')}
<ArrowRight size={11} />
</button>
</td>
</tr>
@@ -160,11 +139,12 @@ export const HistoryTableRow: React.FC<HistoryTableRowProps> = ({ item }) => {
<PaymentModal
isOpen={isPaymentOpen}
onClose={() => setIsPaymentOpen(false)}
hasCertificate={false}
onConfirmPayment={() => {
handleSubmit({ document_id: Number(item.order_id) });
}}
price={price}
onConfirmPayment={() =>
payment.mutate({ order_id: Number(item.order_id) })
}
isLoading={payment.isPending}
hasSertificate={item.certificate}
/>
</>
);

View File

@@ -1,10 +1,20 @@
'use client';
import { useEffect } from 'react';
import Hero from './components/Hero';
import InfoSection from './components/InfoSection';
import StepsSection from './components/StepsSection';
import Ticker from './components/Ticker';
import { useRouter } from '@/shared/config/i18n/navigation';
const PlagiarismLanding = () => {
const route = useRouter();
useEffect(() => {
const data = localStorage.getItem('user');
if (data) {
route.push('/plagiat');
}
}, []);
return (
<>
<Hero />

View File

@@ -1,26 +1,5 @@
import { MenuItem } from './model';
import { LanguageRoutes } from '@/shared/config/i18n/types';
const getMenu = (t: (key: string) => string): MenuItem[] => [
{ title: t('aboutSite'), url: '/about' },
// {
// title: 'Products',
// url: '#',
// items: [
// {
// title: 'Blog',
// description: 'The latest industry news, updates, and info',
// icon: Book,
// url: '#',
// },
// ],
// },
{
title: t('contact'),
url: '/contact',
},
];
const languages: { name: string; key: LanguageRoutes }[] = [
{
name: "O'zbekcha",
@@ -36,4 +15,4 @@ const languages: { name: string; key: LanguageRoutes }[] = [
},
];
export { getMenu, languages };
export { languages };

View File

@@ -4,4 +4,5 @@ export interface MenuItem {
description?: string;
icon?: React.ComponentType<{ className?: string }>;
items?: MenuItem[];
key: string;
}

View File

@@ -1,3 +1,5 @@
'use client';
import { usePathname } from 'next/navigation';
import { MenuItem } from '../lib/model';
const SubMenuLink = ({
@@ -7,11 +9,17 @@ const SubMenuLink = ({
item: MenuItem;
logOut?: () => void;
}) => {
const pathname = usePathname();
const isCabinet = pathname.includes('/cabinet');
return (
<a
className="flex flex-row gap-4 rounded-md p-3 leading-none no-underline transition-colors outline-none select-none hover:bg-muted hover:text-accent-foreground"
href={item.url}
onClick={() => {
href={isCabinet && item.url === '/cabinet' ? undefined : item.url}
onClick={(e) => {
if (isCabinet && item.url === '/cabinet') {
e.preventDefault();
}
if (logOut) {
logOut();
}

View File

@@ -2,34 +2,71 @@
import { Link } from '@/shared/config/i18n/navigation';
import { Button } from '@/shared/ui/button';
import {
NavigationMenu,
NavigationMenuContent,
NavigationMenuItem,
NavigationMenuLink,
NavigationMenuTrigger,
} from '@/shared/ui/navigation-menu';
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/shared/ui/dropdown-menu';
import SubMenuLink from './SubMenuLink';
import { ChangeLang } from './ChangeLang';
import { useLoginModal, useRegisterModal } from '@/shared/zustand/auth';
import { useTranslations } from 'next-intl';
import { useUserPlagiatStore } from '@/shared/zustand/user';
import { LogOut } from 'lucide-react';
import {
ChevronDown,
LogOut,
User,
LayoutDashboard,
FileSearch,
BrainCircuit,
CreditCard,
} from 'lucide-react';
import { useEffect, useState } from 'react';
import { useCabinetNav } from '@/shared/zustand/cabinetNav';
function AuthButtons() {
const t = useTranslations('Navbar');
const t_cab = useTranslations('Cabinet');
const setNavItem = useCabinetNav((state) => state.setNavItem);
const [localUser, setLocalUser] = useState<{
id: number;
name: string;
surname: string;
} | null>(null);
const [open, setOpen] = useState(false);
const auth = {
login: { title: t('login'), url: '#' },
signup: { title: t('signup'), url: '#' },
};
const userItem = [{ title: t('logout'), url: '/', icon: LogOut }];
const userItem = [
{ title: t('profile'), url: '/cabinet', icon: User, key: 'profile' },
{
url: '/cabinet',
title: t_cab('dashboard'),
icon: LayoutDashboard,
key: 'dashboard',
},
{
url: '/cabinet',
title: t_cab('plagiat'),
icon: FileSearch,
key: 'plagiat',
},
{
url: '/cabinet',
title: t_cab('siNav'),
icon: BrainCircuit,
key: 'si',
},
{
url: '/cabinet',
title: t_cab('payments'),
icon: CreditCard,
key: 'payments',
},
{ title: t('logout'), url: '/', icon: LogOut, key: 'logout' },
];
const toggleLoginModal = useLoginModal((state) => state.toggleLoginModal);
const toggleRegisterModal = useRegisterModal(
@@ -47,7 +84,6 @@ function AuthButtons() {
useEffect(() => {
const data = localStorage.getItem('user');
if (data) {
setLocalUser(JSON.parse(data));
} else {
@@ -61,24 +97,31 @@ function AuthButtons() {
<div className="sm:flex hidden">
<ChangeLang />
</div>
<NavigationMenu>
<NavigationMenuItem>
<NavigationMenuTrigger className="text-xl">
{localUser.name} {localUser.surname}
</NavigationMenuTrigger>
<NavigationMenuContent className="bg-popover text-popover-foreground">
<DropdownMenu open={open} onOpenChange={setOpen}>
<DropdownMenuTrigger className="inline-flex items-center gap-1 text-lg font-medium outline-none">
{localUser.name}
<ChevronDown className="size-4" />
</DropdownMenuTrigger>
<DropdownMenuContent className="">
{userItem.map((subItem) => (
<NavigationMenuLink
asChild
key={subItem.title}
className="w-80"
>
<SubMenuLink logOut={clearTokens} item={subItem} />
</NavigationMenuLink>
<DropdownMenuItem key={subItem.title} asChild>
<SubMenuLink
logOut={() => {
setOpen(false);
if (subItem.url !== '/cabinet') {
clearTokens();
} else {
setNavItem(
subItem.key as import('@/widgets/cabinet/lib/types').CabinetSection,
);
}
}}
item={subItem}
/>
</DropdownMenuItem>
))}
</NavigationMenuContent>
</NavigationMenuItem>
</NavigationMenu>
</DropdownMenuContent>
</DropdownMenu>
</div>
);
}

View File

@@ -1,4 +1,3 @@
import { Accordion } from '@/shared/ui/accordion';
import { Button } from '@/shared/ui/button';
import {
Sheet,
@@ -8,8 +7,6 @@ import {
SheetTrigger,
} from '@/shared/ui/sheet';
import { Menu } from 'lucide-react';
import { getMenu } from '../lib/data';
import RenderMobileMenuItem from './RenderMobileMenuItem';
import { ChangeLang } from './ChangeLang';
import Link from 'next/link';
import { AuthButtons } from './authButtons';
@@ -19,10 +16,9 @@ import Image from 'next/image';
const Navbar = () => {
const t = useTranslations('Navbar');
const menu = getMenu(t);
return (
<section className="py-4 flex items-center justify-center w-full ">
<section className="py-1 flex items-center justify-center w-full ">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 w-full">
{/* Desktop Menu */}
<nav className="justify-between items-center flex max-sm:flex-col gap-5">
@@ -34,10 +30,10 @@ const Navbar = () => {
>
<Image
src={Logo_image}
className="min-h-4"
className="min-h-2"
alt="Anti-Plagiat.uz"
width={200}
height={50}
width={140}
height={10}
/>
</Link>
<div className="flex sm:hidden items-center justify-center">
@@ -72,13 +68,6 @@ const Navbar = () => {
</SheetTitle>
</SheetHeader>
<div className="flex flex-col gap-6 p-4">
<Accordion
type="single"
collapsible
className="flex w-full flex-col gap-4"
>
{menu.map((item) => RenderMobileMenuItem(item))}
</Accordion>
<AuthButtons />
</div>
</SheetContent>

View File

@@ -1,22 +0,0 @@
// ─── Pricing Constants ─────────────────────────────────────────────────────────
export const PRICING = {
SERVICE_FEE: 45_000,
CERTIFICATE_FEE: 15_000,
CURRENCY: 'UZS',
// Payme works in tiyin (1 UZS = 100 tiyin)
TIYIN_MULTIPLIER: 100,
} as const;
// ─── Payme Config ──────────────────────────────────────────────────────────────
export const PAYME_CONFIG = {
MERCHANT_ID: process.env.NEXT_PUBLIC_PAYME_MERCHANT_ID ?? 'your_merchant_id',
BASE_URL: 'https://checkout.paycom.uz',
// In development, point to your own backend
API_ENDPOINT: '/api/payments/payme/create',
RETURN_URL:
typeof window !== 'undefined'
? `${window.location.origin}/payment/success`
: 'https://yourapp.uz/payment/success',
} as const;

View File

@@ -1,71 +0,0 @@
'use client';
import { useState, useCallback } from 'react';
import { PaymentStatus, PaymePaymentResponse } from './types';
import {
calculateTotal,
createPaymePayment,
generateOrderId,
redirectToPayme,
toTiyin,
} from './utils';
import { PAYME_CONFIG } from './constant';
interface UsePaymentOptions {
hasCertificate: boolean;
onSuccess?: (response: PaymePaymentResponse) => void;
onError?: (error: Error) => void;
}
interface UsePaymentReturn {
status: PaymentStatus;
error: string | null;
totalAmount: number;
handlePaymePayment: () => Promise<void>;
resetError: () => void;
}
export const usePayment = ({
hasCertificate,
onSuccess,
onError,
}: UsePaymentOptions): UsePaymentReturn => {
const [status, setStatus] = useState<PaymentStatus>('idle');
const [error, setError] = useState<string | null>(null);
const totalAmount = calculateTotal(hasCertificate);
const handlePaymePayment = useCallback(async () => {
setStatus('loading');
setError(null);
const orderId = generateOrderId();
try {
const response = await createPaymePayment({
amount: toTiyin(totalAmount),
orderId,
description: `Service fee${hasCertificate ? ' + Certificate' : ''}`,
returnUrl: PAYME_CONFIG.RETURN_URL,
});
setStatus('success');
onSuccess?.(response);
redirectToPayme(response.redirectUrl);
} catch (err) {
const paymentError =
err instanceof Error
? err
: new Error('Payment failed. Please try again.');
setStatus('error');
setError(paymentError.message);
onError?.(paymentError);
}
}, [totalAmount, hasCertificate, onSuccess, onError]);
const resetError = useCallback(() => {
setError(null);
setStatus('idle');
}, []);
return { status, error, totalAmount, handlePaymePayment, resetError };
};

View File

@@ -1,71 +0,0 @@
// ─── Pricing Utilities ─────────────────────────────────────────────────────────
import { PAYME_CONFIG, PRICING } from './constant';
import {
PaymePaymentRequest,
PaymePaymentResponse,
ServicePricing,
} from './types';
export const getPricing = (): ServicePricing => ({
serviceFee: PRICING.SERVICE_FEE,
certificateFee: PRICING.CERTIFICATE_FEE,
currency: PRICING.CURRENCY,
});
export const calculateTotal = (hasCertificate: boolean): number => {
const base = PRICING.SERVICE_FEE;
return hasCertificate ? base + PRICING.CERTIFICATE_FEE : base;
};
export const toTiyin = (uzs: number): number => uzs * PRICING.TIYIN_MULTIPLIER;
export const formatPrice = (amount: number, currency: string): string =>
`${amount.toLocaleString('uz-UZ')} ${currency}`;
// ─── Order ID Generator ────────────────────────────────────────────────────────
export const generateOrderId = (): string => {
const timestamp = Date.now();
const random = Math.random().toString(36).slice(2, 8).toUpperCase();
return `ORDER-${timestamp}-${random}`;
};
// ─── Payme API ─────────────────────────────────────────────────────────────────
/**
* Sends payment details to the backend, which creates a Payme transaction
* and returns a redirect URL to the Payme checkout page.
*/
export const createPaymePayment = async (
request: PaymePaymentRequest,
): Promise<PaymePaymentResponse> => {
const response = await fetch(PAYME_CONFIG.API_ENDPOINT, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
amount: request.amount, // in tiyin
order_id: request.orderId,
description: request.description,
return_url: request.returnUrl,
}),
});
if (!response.ok) {
const errorBody = await response.json().catch(() => ({}));
throw new Error(
(errorBody as { message?: string }).message ??
`Payment request failed with status ${response.status}`,
);
}
const data = (await response.json()) as PaymePaymentResponse;
return data;
};
/**
* Redirects the user to the Payme checkout page.
*/
export const redirectToPayme = (redirectUrl: string): void => {
window.location.href = redirectUrl;
};

View File

@@ -1,5 +1,7 @@
// ─── Domain Types ───────────────────────────────────────────────────────────
import { PriceCalculate } from '@/features/modals/paymentModal/lib/types';
export interface User {
id: string;
firstName: string;
@@ -21,6 +23,11 @@ export interface PlagiarismSubmissionResponse {
certificateUrl?: string;
}
export interface CheckDocumentRequestResponse extends PriceCalculate {
id: number;
order_id: number;
}
// ─── Form State Types ────────────────────────────────────────────────────────
export interface PlagiarismFormState {
@@ -28,7 +35,7 @@ export interface PlagiarismFormState {
file: File | null;
certificate: boolean;
text?: string;
total_price: number;
type: number;
}
export type PlagiarismFormErrors = Partial<

View File

@@ -1,6 +1,7 @@
'use client';
import { useState, useCallback, useEffect } from 'react';
import {
CheckDocumentRequestResponse,
PlagiarismFormErrors,
PlagiarismFormState,
SubmissionState,
@@ -11,6 +12,9 @@ import { useUserPlagiatStore } from '@/shared/zustand/user';
import { useMutation } from '@tanstack/react-query';
import { links } from '@/shared/request/links';
import { apiRequest } from '@/shared/request/apiRequest';
import { PriceCalculate } from '@/features/modals/paymentModal/lib/types';
import { SERTIFICATE_PRICE, PLAGIAT_SERVICE_FEE } from '@/shared/lib/metadata';
// import { fromTheme } from 'tailwind-merge';
// ─── Initial States ──────────────────────────────────────────────────────────
@@ -19,7 +23,14 @@ const INITIAL_FORM: PlagiarismFormState = {
file: null,
certificate: true,
text: '',
total_price: 41200,
type: 0,
};
const PRICE: PriceCalculate = {
service_fee: PLAGIAT_SERVICE_FEE,
certificate: SERTIFICATE_PRICE,
discount: 0,
total_price: 0,
};
const INITIAL_SUBMISSION: SubmissionState = {
@@ -50,9 +61,8 @@ export function usePlagiarismForm() {
const [isPaymentOpen, setIsPaymentOpen] = useState(false);
const [submission, setSubmission] =
useState<SubmissionState>(INITIAL_SUBMISSION);
// const route = useRouter();
// const [document_id, setDocument_id] = useState<number>(0);
const [order_id, setOrder_id] = useState<number>(0);
const [prices, setPrices] = useState<PriceCalculate>(PRICE);
const checkdocumentRequest = useMutation({
mutationKey: ['plagiarismCheck'],
@@ -60,9 +70,15 @@ export function usePlagiarismForm() {
apiRequest('POST', links.plagiarismCheck, data),
onSuccess: (res) => {
console.log('uploda: ', res);
const resdata = res.data as { id: number; order_id: number };
const resdata = res.data as CheckDocumentRequestResponse;
const priceInfo: PriceCalculate = {
total_price: resdata?.total_price || 0,
discount: resdata?.discount || 0,
certificate: form.certificate ? SERTIFICATE_PRICE : 0,
service_fee: PLAGIAT_SERVICE_FEE,
};
setPrices(priceInfo);
console.log('order_id:', resdata.id);
// setDocument_id(resdata.id);
setOrder_id(resdata.order_id);
setSubmission({ status: 'success', error: null });
setForm(INITIAL_FORM);
@@ -78,11 +94,10 @@ export function usePlagiarismForm() {
const payment = useMutation({
mutationKey: ['payload'],
mutationFn: ({ order_id }: { order_id: number }) =>
apiRequest<{ payment_link: string }>('POST', links.payment(order_id)),
apiRequest<{ payment_link: string }>('POST', links.demo_pay(order_id)),
onSuccess: (res) => {
console.log('payment res: ', res);
window.open(res.data.payment_link, '_self');
//route.push(`/${document_id}`);
setIsPaymentOpen(false);
},
onError: (err) => {
@@ -105,6 +120,11 @@ export function usePlagiarismForm() {
setErrors((prev) => ({ ...prev, file: undefined }));
}, []);
const setOption = useCallback((option: number) => {
setForm((prev) => ({ ...prev, type: option }));
setErrors((prev) => ({ ...prev, type: undefined }));
}, []);
const toggleCertificate = useCallback(() => {
setForm((prev) => ({ ...prev, certificate: !prev.certificate }));
}, []);
@@ -128,15 +148,17 @@ export function usePlagiarismForm() {
return; // Don't open modal if invalid
}
console.log('new');
const fd = new FormData();
fd.append('title', form.title.trim());
fd.append('text', `${user?.name} ${user?.surname}` || '');
fd.append('file', form.file!); // File object — multipart/form-data
fd.append('text', form.text || '');
fd.append('file', form.file!);
fd.append('certificate', String(form.certificate));
fd.append('total_price', '41200');
fd.append('type', String(form.type));
console.log('sended data: ', fd);
checkdocumentRequest.mutate(fd);
},
[form],
[form, localUser],
);
const handleSubmit = useCallback(async () => {
@@ -169,5 +191,7 @@ export function usePlagiarismForm() {
handleSubmitWithModal,
setIsPaymentOpen,
isPaymentOpen,
setOption,
prices,
};
}

View File

@@ -42,6 +42,11 @@ export function validatePlagiarismForm(
}
}
// docuemnt - type validation
if (!state.type) {
errors.type = 'Type is required!';
}
return errors;
}

View File

@@ -10,9 +10,19 @@ import {
StatusBanner,
} from './Plagiraismui';
import { usePlagiarismForm } from '../lib/usePlagiraism';
import { PaymentModal } from '@/widgets/paymentModal/ui/Paymentmodal';
import { useTranslations } from 'next-intl';
import { PaymentModal } from '@/features/modals/paymentModal/ui/Paymentmodal';
import DocumentsTypes from './documentsType';
export const inputCls = `
w-full px-3.5 py-3.5 text-[14px] text-slate-800
bg-blue-50 border border-blue-200 rounded-xl
placeholder:text-blue-400
focus:outline-none focus:ring-2 focus:ring-blue-400/40 focus:border-blue-400
hover:border-blue-300
transition-all duration-150
disabled:opacity-60 disabled:cursor-not-allowed
`.trim();
// ─── UserIcon (inline) ───────────────────────────────────────────────────────
function UserIcon() {
@@ -51,7 +61,9 @@ export function PlagiarismCheckForm() {
resetSubmission,
handleSubmitWithModal,
isPaymentOpen,
setOption,
setIsPaymentOpen,
prices,
} = usePlagiarismForm();
return (
@@ -101,7 +113,7 @@ export function PlagiarismCheckForm() {
)}
{/* left part */}
<div className="flex flex-col gap-6 md:max-w-[50%] w-full">
<div className="flex flex-col gap-9 md:max-w-[50%] w-full">
{/* Topic */}
<FieldWrapper
label={t('documentTopic')}
@@ -145,7 +157,7 @@ export function PlagiarismCheckForm() {
</div>
{/* right part */}
<div className="flex flex-col gap-6 md:max-w-[50%] w-full">
<div className="flex flex-col gap-4 md:max-w-[50%] w-full">
{/* File Upload */}
<FieldWrapper
label={t('documentFile')}
@@ -165,6 +177,15 @@ export function PlagiarismCheckForm() {
{/* Divider */}
<div className="border-t border-stone-100" />
{/* Document type */}
<DocumentsTypes
value={form.type}
onChange={setOption}
disabled={submission.status === 'success'}
hasError={!!errors.type}
error={errors.type}
/>
{/* Submit */}
<SubmitButton
isLoading={isLoading}
@@ -185,9 +206,10 @@ export function PlagiarismCheckForm() {
<PaymentModal
isOpen={isPaymentOpen}
onClose={() => setIsPaymentOpen(false)}
hasCertificate={form.certificate}
price={prices}
onConfirmPayment={handleSubmit}
isLoading={isLoading}
hasSertificate={!!form.certificate}
/>
</>
);

View File

@@ -146,7 +146,7 @@ export function FileUploadField({
htmlFor="file-upload"
className={`
group flex flex-col items-center justify-center gap-3
w-full px-6 py-8 rounded-xl border-2 border-dashed
w-full px-6 py-2 rounded-xl border-2 border-dashed
cursor-pointer transition-all duration-200
${
hasError

View File

@@ -0,0 +1,74 @@
'use client';
import React, { useEffect } from 'react';
import { useTranslations } from 'next-intl';
import { FieldWrapper } from './Plagiraismui';
import { useQuery } from '@tanstack/react-query';
import { apiRequest } from '@/shared/request/apiRequest';
import { links } from '@/shared/request/links';
import { inputCls } from './Plagiraismcheckform';
type DocumentType = {
id: number;
name: string;
};
interface DocumentsTypesProps {
value: number;
onChange: (value: number) => void;
disabled?: boolean;
hasError?: boolean;
error?: string;
}
export default function DocumentsTypes({
value,
onChange,
disabled,
hasError,
error,
}: DocumentsTypesProps) {
const t = useTranslations('DocumentTypes');
const { data, isLoading } = useQuery({
queryKey: ['document_types'],
queryFn: (): Promise<DocumentType[]> =>
apiRequest('GET', links.document_types).then(
(res) => res.data as DocumentType[],
),
});
useEffect(() => {
console.log({ value });
}, [value]);
return (
<FieldWrapper
htmlFor="document_type"
label={t('label')}
error={error}
required
>
<select
id="document_type"
value={value || ''}
onChange={(e) => {
onChange(Number(e.target.value));
}}
disabled={isLoading || disabled}
className={`${inputCls} cursor-pointer ${
hasError
? 'border-rose-400 bg-rose-50 hover:bg-rose-50'
: 'border-stone-300 bg-stone-50 hover:border-blue-500 hover:bg-blue-50'
}`}
>
<option value="" disabled>
{isLoading ? t('loading') : t('placeholder')}
</option>
{data?.map((type) => (
<option key={type.id} value={type.id}>
{type.name}
</option>
))}
</select>
</FieldWrapper>
);
}