added new fieldds to file upload and get sertificate components

This commit is contained in:
nabijonovdavronbek619@gmail.com
2026-04-06 09:50:48 +05:00
parent 38886ad8b5
commit d70360841a
13 changed files with 578 additions and 74 deletions

View File

@@ -4,7 +4,7 @@ const SITE_URL = process.env.NEXT_PUBLIC_SITE_URL ?? 'https://antiplagiat.uz';
const LOCALES = ['uz', 'ru', 'en'] as const;
// Add your static page slugs here
const STATIC_ROUTES = ['', '/about', '/history', '/contact'];
const STATIC_ROUTES = ['', '/plagat'];
export default function sitemap(): MetadataRoute.Sitemap {
const entries: MetadataRoute.Sitemap = [];

View File

@@ -6,8 +6,8 @@ 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 './ui/sertificate/sertifikat';
// ── Types ────────────────────────────────────────────────────────────────────
@@ -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,7 +395,7 @@ 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)} />}
{doc.file && (

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

@@ -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,219 @@
'use client';
import {
X,
Award,
User,
FileText,
BookOpen,
Layers,
Loader2,
CheckCircle2,
} from 'lucide-react';
import { useCertificateModal } from './useSertificateModal';
import { Field, inputCls } from './modalField';
import { DOCUMENT_TYPES, SertificateModalProps } from './types';
export default function SertificateModal({
document_id,
open,
setOpen,
}: SertificateModalProps) {
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="Sertifikat yaratish"
>
{/* 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">
Sertifikat yaratish
</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="Yopish"
>
<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="Muallifning to'liq ismi"
>
<input
id="fullname"
ref={inputRef}
type="text"
value={form.fullname}
onChange={(e) => updateField('fullname', e.target.value)}
disabled={loading || success}
placeholder="Ismingizni kiriting..."
className={inputCls}
/>
</Field>
{/* Document theme */}
<Field
htmlFor="document_theme"
icon={
<BookOpen
className="w-3.5 h-3.5 text-slate-400"
strokeWidth={2}
/>
}
label="Hujjat mavzusi"
>
<input
id="document_theme"
type="text"
value={form.document_theme}
onChange={(e) => updateField('document_theme', e.target.value)}
disabled={loading || success}
placeholder="Mavzuni kiriting..."
className={inputCls}
/>
</Field>
{/* Document type */}
<Field
htmlFor="document_type"
icon={
<Layers className="w-3.5 h-3.5 text-slate-400" strokeWidth={2} />
}
label="Hujjat turi"
>
<select
id="document_type"
value={form.document_type}
onChange={(e) =>
updateField(
'document_type',
e.target.value as typeof form.document_type,
)
}
disabled={loading || success}
className={`${inputCls} cursor-pointer`}
>
<option value="" disabled>
Hujjat turini tanlang...
</option>
{DOCUMENT_TYPES.map((type) => (
<option key={type.value} value={type.value}>
{type.label}
</option>
))}
</select>
</Field>
{/* 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">Hujjat ID</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>Yaratilmoqda...</span>
</>
) : success ? (
<>
<CheckCircle2 className="w-4 h-4" strokeWidth={2.5} />
<span>Sertifikat yaratildi!</span>
</>
) : (
<span>Sertifikat yaratish</span>
)}
</button>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,79 @@
'use client';
import { useTranslations } from 'next-intl';
import { FileDown, Loader2 } from 'lucide-react';
import React, { useEffect, useState } from 'react';
import SertificateModal from './sertificateModal';
// 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 [openModal, setOpenModal] = useState(false);
useEffect(() => {
setLoading(false);
console.log(loading);
}, []);
// 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);
// }
// };
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,23 @@
export const DOCUMENT_TYPES = [
{ value: 'metodik_ishlanma', label: 'Metodik ishlanma' },
{ value: 'ilmiy_maqola', label: 'Ilmiy maqola' },
{ value: 'bmi', label: 'BMI' },
{ value: 'magistrlik', label: 'Magistrlik' },
{ value: 'kurs_ishi', label: 'Kurs ishi' },
{ value: 'boshqa', label: 'Boshqa' },
] as const;
export type DocumentTypeValue = (typeof DOCUMENT_TYPES)[number]['value'];
export interface CertificateFormData {
fullname: string;
document_theme: string;
document_type: DocumentTypeValue | '';
document_id: number;
}
export interface SertificateModalProps {
document_id: number;
open: boolean;
setOpen: () => void;
}

View File

@@ -0,0 +1,112 @@
import { useState, useEffect, useRef } from 'react';
import { CertificateFormData } from './types';
interface UseCertificateModalProps {
document_id: number;
open: boolean;
setOpen: () => void;
}
export function useCertificateModal({
document_id,
open,
setOpen,
}: UseCertificateModalProps) {
const [form, setForm] = useState<CertificateFormData>({
fullname: '',
document_theme: '',
document_type: '',
document_id,
});
const [loading, setLoading] = useState(false);
const [success, setSuccess] = useState(false);
const [visible, setVisible] = useState(false);
const inputRef = useRef<HTMLInputElement>(null);
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.document_type;
/** Payload ready to send to backend */
const buildPayload = (): CertificateFormData => ({ ...form });
const handleSubmit = async () => {
if (!isFormValid || loading) return;
setLoading(true);
try {
const payload = buildPayload();
const response = await fetch(`/api/certificates`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
if (!response.ok) throw new Error('Failed');
setSuccess(true);
setTimeout(() => {
setOpen();
setSuccess(false);
}, 1800);
} catch {
// Demo mode: simulate success
setSuccess(true);
setTimeout(() => {
setOpen();
setSuccess(false);
}, 1800);
} finally {
setLoading(false);
}
};
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,
success,
visible,
isFormValid,
inputRef,
handleSubmit,
handleKeyDown,
handleBackdropClick,
};
}

View File

@@ -1,5 +1,7 @@
// ─── Domain Types ───────────────────────────────────────────────────────────
import { DocumentTypeValue } from '@/widgets/detail/ui/sertificate/types';
export interface User {
id: string;
firstName: string;
@@ -29,6 +31,7 @@ export interface PlagiarismFormState {
certificate: boolean;
text?: string;
total_price: number;
document_type: DocumentTypeValue;
}
export type PlagiarismFormErrors = Partial<

View File

@@ -11,6 +11,7 @@ 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 { DocumentTypeValue } from '@/widgets/detail/ui/sertificate/types';
// ─── Initial States ──────────────────────────────────────────────────────────
@@ -20,6 +21,7 @@ const INITIAL_FORM: PlagiarismFormState = {
certificate: true,
text: '',
total_price: 41200,
document_type: 'boshqa',
};
const INITIAL_SUBMISSION: SubmissionState = {
@@ -105,6 +107,11 @@ export function usePlagiarismForm() {
setErrors((prev) => ({ ...prev, file: undefined }));
}, []);
const setOption = useCallback((option: DocumentTypeValue) => {
setForm((prev) => ({ ...prev, document_type: option }));
setErrors((prev) => ({ ...prev, document_type: undefined }));
}, []);
const toggleCertificate = useCallback(() => {
setForm((prev) => ({ ...prev, certificate: !prev.certificate }));
}, []);
@@ -169,5 +176,6 @@ export function usePlagiarismForm() {
handleSubmitWithModal,
setIsPaymentOpen,
isPaymentOpen,
setOption,
};
}

View File

@@ -12,7 +12,17 @@ import {
import { usePlagiarismForm } from '../lib/usePlagiraism';
import { PaymentModal } from '@/widgets/paymentModal/ui/Paymentmodal';
import { useTranslations } from 'next-intl';
import { DOCUMENT_TYPES } from '@/widgets/detail/ui/sertificate/types';
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,6 +61,7 @@ export function PlagiarismCheckForm() {
resetSubmission,
handleSubmitWithModal,
isPaymentOpen,
setOption,
setIsPaymentOpen,
} = usePlagiarismForm();
@@ -101,7 +112,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 +156,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 +176,28 @@ export function PlagiarismCheckForm() {
{/* Divider */}
<div className="border-t border-stone-100" />
{/* Document type */}
<FieldWrapper htmlFor="document_type" label="Hujjat turi">
<select
id="document_type"
value={form.document_type}
onChange={(e) =>
setOption(e.target.value as typeof form.document_type)
}
disabled={isLoading || submission.status === 'success'}
className={`${inputCls} cursor-pointer`}
>
<option value="" disabled>
Hujjat turini tanlang...
</option>
{DOCUMENT_TYPES.map((type) => (
<option key={type.value} value={type.value}>
{type.label}
</option>
))}
</select>
</FieldWrapper>
{/* Submit */}
<SubmitButton
isLoading={isLoading}

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