10
ROBOTS.txt
Normal file
10
ROBOTS.txt
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
User-agent: *
|
||||||
|
Allow: /
|
||||||
|
|
||||||
|
# Block admin and API routes from indexing
|
||||||
|
Disallow: /api/
|
||||||
|
Disallow: /_next/
|
||||||
|
Disallow: /admin/
|
||||||
|
|
||||||
|
# Sitemap location
|
||||||
|
Sitemap: https://antiplagiat.uz/sitemap.xml
|
||||||
@@ -12,18 +12,34 @@ import QueryProvider from '@/shared/config/react-query/QueryProvider';
|
|||||||
import Script from 'next/script';
|
import Script from 'next/script';
|
||||||
import Provider from '@/features/providers/provider';
|
import Provider from '@/features/providers/provider';
|
||||||
import { ToastContainer } from 'react-toastify';
|
import { ToastContainer } from 'react-toastify';
|
||||||
|
import type { Metadata } from 'next';
|
||||||
|
import { generateRootMetadata } from '@/shared/lib/metadata';
|
||||||
|
|
||||||
|
// ─── Types ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
params: Promise<{ locale: Locale }>;
|
params: Promise<{ locale: Locale }>;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// ─── Static params ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export function generateStaticParams() {
|
export function generateStaticParams() {
|
||||||
return routing.locales.map((locale) => ({ locale }));
|
return routing.locales.map((locale) => ({ locale }));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── Metadata (OpenGraph + SEO) ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export async function generateMetadata({ params }: Props): Promise<Metadata> {
|
||||||
|
const { locale } = await params;
|
||||||
|
return generateRootMetadata(locale);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Layout ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export default async function RootLayout({ children, params }: Props) {
|
export default async function RootLayout({ children, params }: Props) {
|
||||||
const { locale } = await params;
|
const { locale } = await params;
|
||||||
|
|
||||||
if (!hasLocale(routing.locales, locale)) {
|
if (!hasLocale(routing.locales, locale)) {
|
||||||
notFound();
|
notFound();
|
||||||
}
|
}
|
||||||
@@ -52,6 +68,7 @@ export default async function RootLayout({ children, params }: Props) {
|
|||||||
</ThemeProvider>
|
</ThemeProvider>
|
||||||
</NextIntlClientProvider>
|
</NextIntlClientProvider>
|
||||||
</body>
|
</body>
|
||||||
|
|
||||||
<Script
|
<Script
|
||||||
src="https://buttons.github.io/buttons.js"
|
src="https://buttons.github.io/buttons.js"
|
||||||
strategy="lazyOnload"
|
strategy="lazyOnload"
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 25 KiB |
29
src/app/sitemap.ts
Normal file
29
src/app/sitemap.ts
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
import { MetadataRoute } from 'next';
|
||||||
|
|
||||||
|
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'];
|
||||||
|
|
||||||
|
export default function sitemap(): MetadataRoute.Sitemap {
|
||||||
|
const entries: MetadataRoute.Sitemap = [];
|
||||||
|
|
||||||
|
for (const locale of LOCALES) {
|
||||||
|
for (const route of STATIC_ROUTES) {
|
||||||
|
entries.push({
|
||||||
|
url: `${SITE_URL}/${locale}${route}`,
|
||||||
|
lastModified: new Date(),
|
||||||
|
changeFrequency: route === '' ? 'daily' : 'weekly',
|
||||||
|
priority: route === '' ? 1.0 : 0.8,
|
||||||
|
alternates: {
|
||||||
|
languages: Object.fromEntries(
|
||||||
|
LOCALES.map((l) => [l, `${SITE_URL}/${l}${route}`]),
|
||||||
|
),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return entries;
|
||||||
|
}
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
// Ushbu fayl loyiha structurasi uchun qo'shilgan. O'chirib tashlasangiz bo'ladi
|
|
||||||
export * from './useLoginForm';
|
|
||||||
export * from '../../../../shared/lib/formatPhone';
|
|
||||||
@@ -7,27 +7,57 @@ import { apiRequest } from '@/shared/request/apiRequest';
|
|||||||
import { links } from '@/shared/request/links';
|
import { links } from '@/shared/request/links';
|
||||||
import { useLoginModal } from '@/shared/zustand/auth';
|
import { useLoginModal } from '@/shared/zustand/auth';
|
||||||
import { toast } from 'react-toastify';
|
import { toast } from 'react-toastify';
|
||||||
|
import { useRouter } from '@/shared/config/i18n/navigation';
|
||||||
|
import { useUserPlagiatStore } from '@/shared/zustand/user';
|
||||||
|
|
||||||
interface LoginData {
|
interface LoginData {
|
||||||
phone: string;
|
phone: string;
|
||||||
|
password: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AuthData {
|
||||||
|
access: string;
|
||||||
|
refresh: string;
|
||||||
|
user_id: number;
|
||||||
|
phone: string;
|
||||||
|
first_name: string;
|
||||||
|
last_name: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useLoginForm() {
|
export function useLoginForm() {
|
||||||
const [phone, setPhone] = useState('');
|
const [phone, setPhone] = useState('');
|
||||||
|
const [password, setPassword] = useState('');
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
|
const setUser = useUserPlagiatStore((state) => state.setUser);
|
||||||
|
const route = useRouter();
|
||||||
const toggleLoginModal = useLoginModal((state) => state.toggleLoginModal);
|
const toggleLoginModal = useLoginModal((state) => state.toggleLoginModal);
|
||||||
const loginReqest = useMutation({
|
const loginReqest = useMutation({
|
||||||
mutationKey: ['login'],
|
mutationKey: ['login'],
|
||||||
mutationFn: (data: LoginData) => apiRequest('POST', links.login, data),
|
mutationFn: (data: LoginData) => apiRequest('POST', links.login, data),
|
||||||
onSuccess: (data) => {
|
onSuccess: (data) => {
|
||||||
|
const resData = data?.data as AuthData;
|
||||||
|
const user = data
|
||||||
|
? {
|
||||||
|
id: resData?.user_id,
|
||||||
|
name: resData?.first_name,
|
||||||
|
surname: resData?.last_name,
|
||||||
|
}
|
||||||
|
: null;
|
||||||
|
if (user) {
|
||||||
|
localStorage.setItem('access_token', resData.access);
|
||||||
|
localStorage.setItem('refresh_token', resData.refresh);
|
||||||
|
localStorage.setItem('user', JSON.stringify(user));
|
||||||
|
}
|
||||||
|
setUser(user);
|
||||||
console.log('Login successful:', data);
|
console.log('Login successful:', data);
|
||||||
toggleLoginModal();
|
toggleLoginModal();
|
||||||
toast.success('Kirish muvaffaqiyatli!');
|
toast.success('Kirish muvaffaqiyatli!');
|
||||||
|
route.push('/plagat');
|
||||||
},
|
},
|
||||||
onError: (err) => {
|
onError: (err) => {
|
||||||
|
console.log('Login failed:', err);
|
||||||
setError(err instanceof Error ? err.message : 'Unknown error');
|
setError(err instanceof Error ? err.message : 'Unknown error');
|
||||||
// toggleLoginModal();
|
// toggleLoginModal();
|
||||||
console.error('Login failed:', err);
|
|
||||||
toast.error(err instanceof Error ? err.message : 'Unknown error');
|
toast.error(err instanceof Error ? err.message : 'Unknown error');
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -43,7 +73,7 @@ export function useLoginForm() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
loginReqest.mutate({ phone: `+998${phone}` });
|
loginReqest.mutate({ phone: `998${phone}`, password: password });
|
||||||
sessionStorage.setItem('prev_page', 'login');
|
sessionStorage.setItem('prev_page', 'login');
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -53,5 +83,7 @@ export function useLoginForm() {
|
|||||||
submit,
|
submit,
|
||||||
error,
|
error,
|
||||||
loading: false,
|
loading: false,
|
||||||
|
password,
|
||||||
|
setPassword,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,22 +2,21 @@
|
|||||||
|
|
||||||
import { useCallback, useState } from 'react';
|
import { useCallback, useState } from 'react';
|
||||||
import { X } from 'lucide-react';
|
import { X } from 'lucide-react';
|
||||||
import {
|
|
||||||
formatPhone,
|
|
||||||
normalizeDigits,
|
|
||||||
} from '../../../../shared/lib/formatPhone';
|
|
||||||
import PhonePrefix from '../../../../shared/ui/phonePrefix';
|
|
||||||
import { MotionWrapper } from '../../../../shared/ui/motion';
|
|
||||||
import { useLoginForm } from '../lib/useLoginForm';
|
import { useLoginForm } from '../lib/useLoginForm';
|
||||||
import { useLoginModal, useRegisterModal } from '@/shared/zustand/auth';
|
import { useLoginModal, useRegisterModal } from '@/shared/zustand/auth';
|
||||||
import { useTranslations } from 'next-intl';
|
import { useTranslations } from 'next-intl';
|
||||||
|
import { formatPhone, normalizeDigits } from '@/shared/lib/formatPhone';
|
||||||
|
import { MotionWrapper } from '@/shared/ui';
|
||||||
|
import PhonePrefix from '@/shared/ui/phonePrefix';
|
||||||
|
import { Field } from '@/shared/ui/field';
|
||||||
|
|
||||||
export function LoginForm() {
|
export function LoginForm() {
|
||||||
const [isFocused, setIsFocused] = useState(false);
|
const [isFocused, setIsFocused] = useState(false);
|
||||||
const t = useTranslations('Auth.Login');
|
const t = useTranslations('Auth.Login');
|
||||||
const tCommon = useTranslations('Common');
|
const tCommon = useTranslations('Common');
|
||||||
|
|
||||||
const { phone, setPhone, submit, error, loading } = useLoginForm();
|
const { phone, setPhone, submit, error, loading, password, setPassword } =
|
||||||
|
useLoginForm();
|
||||||
const toggleLoginModal = useLoginModal((state) => state.toggleLoginModal);
|
const toggleLoginModal = useLoginModal((state) => state.toggleLoginModal);
|
||||||
const toggleRegisterModal = useRegisterModal(
|
const toggleRegisterModal = useRegisterModal(
|
||||||
(state) => state.toggleRegisterModal,
|
(state) => state.toggleRegisterModal,
|
||||||
@@ -112,6 +111,22 @@ export function LoginForm() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Password */}
|
||||||
|
<div>
|
||||||
|
<Field
|
||||||
|
id="password"
|
||||||
|
name="password"
|
||||||
|
label="Password"
|
||||||
|
placeholder={t('passwordPlaceholder')}
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
require={true}
|
||||||
|
type="password"
|
||||||
|
maxLength={8}
|
||||||
|
minLength={8}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Error */}
|
{/* Error */}
|
||||||
{error && (
|
{error && (
|
||||||
<div className="rounded-sm border border-red-200 bg-red-50 px-3.5 py-2.5 text-[0.8rem] text-red-500">
|
<div className="rounded-sm border border-red-200 bg-red-50 px-3.5 py-2.5 text-[0.8rem] text-red-500">
|
||||||
@@ -151,18 +166,18 @@ export function LoginForm() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Register hint */}
|
{/* Register hint */}
|
||||||
<p className="text-center text-[0.78rem] text-stone-400 flex items-center justify-center gap-2">
|
<div className="text-center text-[0.78rem] text-stone-400 flex items-center justify-center gap-2">
|
||||||
{t('registerPrompt')}
|
{t('registerPrompt')}
|
||||||
<p
|
<p
|
||||||
|
className="text-stone-800 hover:cursor-pointer underline underline-offset-2 hover:text-stone-600 transition-colors"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
toggleLoginModal();
|
toggleLoginModal();
|
||||||
toggleRegisterModal();
|
toggleRegisterModal();
|
||||||
}}
|
}}
|
||||||
className="text-stone-800 hover:cursor-pointer underline underline-offset-2 hover:text-stone-600 transition-colors"
|
|
||||||
>
|
>
|
||||||
{t('registerLink')}
|
{t('registerLink')}
|
||||||
</p>
|
</p>
|
||||||
</p>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</MotionWrapper>
|
</MotionWrapper>
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ export interface Registertype {
|
|||||||
surname: string;
|
surname: string;
|
||||||
phone: string;
|
phone: string;
|
||||||
oferta?: boolean;
|
oferta?: boolean;
|
||||||
|
password: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface RegisterZustandType {
|
interface RegisterZustandType {
|
||||||
@@ -20,6 +21,7 @@ const INITIAL: Registertype = {
|
|||||||
surname: '',
|
surname: '',
|
||||||
phone: '',
|
phone: '',
|
||||||
oferta: false,
|
oferta: false,
|
||||||
|
password: '',
|
||||||
};
|
};
|
||||||
|
|
||||||
export const useRegisterZustand = create<RegisterZustandType>((set) => ({
|
export const useRegisterZustand = create<RegisterZustandType>((set) => ({
|
||||||
|
|||||||
@@ -8,11 +8,15 @@ import { useMutation } from '@tanstack/react-query';
|
|||||||
import { apiRequest } from '@/shared/request/apiRequest';
|
import { apiRequest } from '@/shared/request/apiRequest';
|
||||||
import { links } from '@/shared/request/links';
|
import { links } from '@/shared/request/links';
|
||||||
import { toast } from 'react-toastify';
|
import { toast } from 'react-toastify';
|
||||||
|
import { useRouter } from '@/shared/config/i18n/navigation';
|
||||||
|
import { AuthData } from '../../login/lib/useLoginForm';
|
||||||
|
import { useUserPlagiatStore } from '@/shared/zustand/user';
|
||||||
|
|
||||||
interface RegisterData {
|
interface RegisterData {
|
||||||
name: string;
|
first_name: string;
|
||||||
surname: string;
|
last_name: string;
|
||||||
phone: string;
|
phone: string;
|
||||||
|
password: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useRegisterForm() {
|
export function useRegisterForm() {
|
||||||
@@ -20,6 +24,8 @@ export function useRegisterForm() {
|
|||||||
useRegisterZustand();
|
useRegisterZustand();
|
||||||
const [errors, setErrors] = useState<RegisterErrors>({});
|
const [errors, setErrors] = useState<RegisterErrors>({});
|
||||||
const [success, setSuccess] = useState(false);
|
const [success, setSuccess] = useState(false);
|
||||||
|
const setUser = useUserPlagiatStore((state) => state.setUser);
|
||||||
|
const route = useRouter();
|
||||||
const toggleRegisterModal = useRegisterModal(
|
const toggleRegisterModal = useRegisterModal(
|
||||||
(state) => state.toggleRegisterModal,
|
(state) => state.toggleRegisterModal,
|
||||||
);
|
);
|
||||||
@@ -30,13 +36,28 @@ export function useRegisterForm() {
|
|||||||
apiRequest('POST', links.register, data),
|
apiRequest('POST', links.register, data),
|
||||||
onSuccess: (data) => {
|
onSuccess: (data) => {
|
||||||
console.log('Register successful:', data);
|
console.log('Register successful:', data);
|
||||||
|
const resData = data?.data as AuthData;
|
||||||
|
const user = data
|
||||||
|
? {
|
||||||
|
id: resData?.user_id,
|
||||||
|
name: resData?.first_name,
|
||||||
|
surname: resData?.last_name,
|
||||||
|
}
|
||||||
|
: null;
|
||||||
|
if (user) {
|
||||||
|
localStorage.setItem('access_token', resData.access);
|
||||||
|
localStorage.setItem('refresh_token', resData.refresh);
|
||||||
|
localStorage.setItem('user', JSON.stringify(user));
|
||||||
|
}
|
||||||
|
setUser(user);
|
||||||
toggleRegisterModal();
|
toggleRegisterModal();
|
||||||
setSuccess(true);
|
setSuccess(true);
|
||||||
toast.success("Ro'yxatdan o'tish muvaffaqiyatli!");
|
toast.success("Ro'yxatdan o'tish muvaffaqiyatli!");
|
||||||
|
route.push('/plagat');
|
||||||
},
|
},
|
||||||
onError: (err) => {
|
onError: (err) => {
|
||||||
// toggleLoginModal();
|
// toggleLoginModal();
|
||||||
console.error('Register failed:', err);
|
console.log('Register failed:', err);
|
||||||
toast.error(err instanceof Error ? err.message : 'Unknown error');
|
toast.error(err instanceof Error ? err.message : 'Unknown error');
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -68,7 +89,13 @@ export function useRegisterForm() {
|
|||||||
setErrors(validationErrors);
|
setErrors(validationErrors);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
registerRequest.mutate(registerData);
|
const sendedData = {
|
||||||
|
phone: registerData.phone,
|
||||||
|
first_name: registerData.name,
|
||||||
|
last_name: registerData.surname,
|
||||||
|
password: registerData.password,
|
||||||
|
};
|
||||||
|
registerRequest.mutate(sendedData);
|
||||||
},
|
},
|
||||||
[registerData, clearRegisterData],
|
[registerData, clearRegisterData],
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ export interface RegisterErrors {
|
|||||||
surname?: string;
|
surname?: string;
|
||||||
phone?: string;
|
phone?: string;
|
||||||
oferta?: string;
|
oferta?: string;
|
||||||
|
password?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function validateRegister(data: {
|
export function validateRegister(data: {
|
||||||
@@ -10,6 +11,7 @@ export function validateRegister(data: {
|
|||||||
surname: string;
|
surname: string;
|
||||||
phone: string;
|
phone: string;
|
||||||
oferta?: boolean;
|
oferta?: boolean;
|
||||||
|
password: string;
|
||||||
}): RegisterErrors {
|
}): RegisterErrors {
|
||||||
const errors: RegisterErrors = {};
|
const errors: RegisterErrors = {};
|
||||||
|
|
||||||
@@ -25,11 +27,15 @@ export function validateRegister(data: {
|
|||||||
errors.surname = 'Surname must be at least 2 characters';
|
errors.surname = 'Surname must be at least 2 characters';
|
||||||
}
|
}
|
||||||
|
|
||||||
const digits = data.phone.replace(/\D/g, '');
|
if (!data.phone || data.phone.length < 12) {
|
||||||
if (!digits) {
|
// "998" prefix (3) + 9 digits = 12
|
||||||
errors.phone = 'Phone is required';
|
errors.phone = 'Enter a valid 9-digit phone number';
|
||||||
} else if (digits.length !== 9 && digits.length !== 12) {
|
}
|
||||||
errors.phone = 'Enter a valid 9-digit or 13-digit phone number';
|
|
||||||
|
if (!data.password) {
|
||||||
|
errors.password = 'Password is required';
|
||||||
|
} else if (data.password.length < 6) {
|
||||||
|
errors.password = 'Password must be at least 6 characters';
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!data.oferta) {
|
if (!data.oferta) {
|
||||||
|
|||||||
@@ -6,63 +6,7 @@ import { formatPhone, normalizeDigits } from '@/shared/lib/formatPhone';
|
|||||||
import PhonePrefix from '@/shared/ui/phonePrefix';
|
import PhonePrefix from '@/shared/ui/phonePrefix';
|
||||||
import { useTranslations } from 'next-intl';
|
import { useTranslations } from 'next-intl';
|
||||||
import { useLoginModal, useRegisterModal } from '@/shared/zustand/auth';
|
import { useLoginModal, useRegisterModal } from '@/shared/zustand/auth';
|
||||||
|
import { Field } from '@/shared/ui/field';
|
||||||
function Field({
|
|
||||||
id,
|
|
||||||
label,
|
|
||||||
type = 'text',
|
|
||||||
placeholder,
|
|
||||||
value,
|
|
||||||
name,
|
|
||||||
onChange,
|
|
||||||
error,
|
|
||||||
}: {
|
|
||||||
id: string;
|
|
||||||
label: string;
|
|
||||||
type?: string;
|
|
||||||
placeholder?: string;
|
|
||||||
value: string;
|
|
||||||
name: string;
|
|
||||||
onChange: (e: React.ChangeEvent<HTMLInputElement>) => void;
|
|
||||||
error?: string;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<div className="flex flex-col gap-1.5">
|
|
||||||
<label
|
|
||||||
htmlFor={id}
|
|
||||||
className="text-[0.7rem] font-medium tracking-widest uppercase text-stone-500"
|
|
||||||
>
|
|
||||||
{label}
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
id={id}
|
|
||||||
name={name}
|
|
||||||
type={type}
|
|
||||||
placeholder={placeholder}
|
|
||||||
value={value}
|
|
||||||
onChange={onChange}
|
|
||||||
autoComplete="off"
|
|
||||||
className={`
|
|
||||||
w-full rounded-sm border bg-stone-50 px-3.5 py-2.5
|
|
||||||
font-sans text-[0.95rem] text-stone-900 outline-none
|
|
||||||
placeholder:text-stone-300
|
|
||||||
transition-all duration-150
|
|
||||||
focus:border-stone-400 focus:ring-2 focus:ring-stone-300/30
|
|
||||||
${
|
|
||||||
error
|
|
||||||
? 'border-red-400 ring-2 ring-red-200/40'
|
|
||||||
: 'border-stone-200 hover:border-stone-300'
|
|
||||||
}
|
|
||||||
`}
|
|
||||||
/>
|
|
||||||
{error && (
|
|
||||||
<span className="text-[0.7rem] text-red-500 tracking-tight">
|
|
||||||
{error}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function RegisterFormUI() {
|
export function RegisterFormUI() {
|
||||||
const [phone, setPhone] = React.useState('');
|
const [phone, setPhone] = React.useState('');
|
||||||
@@ -86,11 +30,12 @@ export function RegisterFormUI() {
|
|||||||
const [isFocused, setIsFocused] = React.useState(false);
|
const [isFocused, setIsFocused] = React.useState(false);
|
||||||
const handlePhoneChange = useCallback(
|
const handlePhoneChange = useCallback(
|
||||||
(e: React.ChangeEvent<HTMLInputElement>) => {
|
(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
setPhone(normalizeDigits(e.target.value));
|
const digits = normalizeDigits(e.target.value); // fresh value from event
|
||||||
if (normalizeDigits(e.target.value).length === 9)
|
setPhone(digits); // update local display state
|
||||||
setItem('phone', `998${phone}`);
|
|
||||||
|
setItem('phone', digits.length > 0 ? `998${digits}` : '');
|
||||||
},
|
},
|
||||||
[setPhone],
|
[setItem],
|
||||||
);
|
);
|
||||||
|
|
||||||
if (errors) {
|
if (errors) {
|
||||||
@@ -137,6 +82,7 @@ export function RegisterFormUI() {
|
|||||||
value={registerData.name}
|
value={registerData.name}
|
||||||
onChange={handleChange}
|
onChange={handleChange}
|
||||||
error={errors.name}
|
error={errors.name}
|
||||||
|
require={true}
|
||||||
/>
|
/>
|
||||||
<Field
|
<Field
|
||||||
id="surname"
|
id="surname"
|
||||||
@@ -146,6 +92,7 @@ export function RegisterFormUI() {
|
|||||||
value={registerData.surname}
|
value={registerData.surname}
|
||||||
onChange={handleChange}
|
onChange={handleChange}
|
||||||
error={errors.surname}
|
error={errors.surname}
|
||||||
|
require={true}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -195,6 +142,23 @@ export function RegisterFormUI() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Password */}
|
||||||
|
<div>
|
||||||
|
<Field
|
||||||
|
id="password"
|
||||||
|
name="password"
|
||||||
|
label="Password"
|
||||||
|
placeholder={t('passwordPlaceholder')}
|
||||||
|
value={registerData.password}
|
||||||
|
onChange={handleChange}
|
||||||
|
error={errors.password}
|
||||||
|
type="password"
|
||||||
|
maxLength={8}
|
||||||
|
minLength={8}
|
||||||
|
require={true}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Terms checkbox */}
|
{/* Terms checkbox */}
|
||||||
<div className="flex flex-col gap-1.5">
|
<div className="flex flex-col gap-1.5">
|
||||||
<div className="flex items-start gap-2.5">
|
<div className="flex items-start gap-2.5">
|
||||||
|
|||||||
@@ -106,7 +106,7 @@
|
|||||||
"mainHeading": "Is Your Work",
|
"mainHeading": "Is Your Work",
|
||||||
"mainHeadingItalic": "Truly Original?",
|
"mainHeadingItalic": "Truly Original?",
|
||||||
"description": "Plagiarism is presenting someone else's ideas or words as your own. In academia and professional life, it carries serious consequences. Our platform detects it in seconds — so you can submit with full confidence.",
|
"description": "Plagiarism is presenting someone else's ideas or words as your own. In academia and professional life, it carries serious consequences. Our platform detects it in seconds — so you can submit with full confidence.",
|
||||||
"certificateNote": "Certificate issued within 24h"
|
"certificateNote": "Certificate included!"
|
||||||
},
|
},
|
||||||
"Common": {
|
"Common": {
|
||||||
"startButton": "Start Checking →",
|
"startButton": "Start Checking →",
|
||||||
@@ -125,7 +125,7 @@
|
|||||||
"heading": "How It Works",
|
"heading": "How It Works",
|
||||||
"description": "Six simple steps from upload to certified report.",
|
"description": "Six simple steps from upload to certified report.",
|
||||||
"ctaHeading": "Ready to verify your document?",
|
"ctaHeading": "Ready to verify your document?",
|
||||||
"ctaDescription": "Get your originality certificate in under 24 hours."
|
"ctaDescription": "Protection of your copyright"
|
||||||
},
|
},
|
||||||
"Steps": {
|
"Steps": {
|
||||||
"step1Title": "Click Start",
|
"step1Title": "Click Start",
|
||||||
@@ -139,7 +139,7 @@
|
|||||||
"step5Title": "Complete Payment",
|
"step5Title": "Complete Payment",
|
||||||
"step5Desc": "Pay securely for your plagiarism check service",
|
"step5Desc": "Pay securely for your plagiarism check service",
|
||||||
"step6Title": "Get Your Report",
|
"step6Title": "Get Your Report",
|
||||||
"step6Desc": "Receive detailed results and certificate within 24 hours"
|
"step6Desc": "Get the results and the certificate"
|
||||||
},
|
},
|
||||||
"Stats": {
|
"Stats": {
|
||||||
"accuracy": "Detection accuracy",
|
"accuracy": "Detection accuracy",
|
||||||
@@ -172,9 +172,11 @@
|
|||||||
"phonePlaceholder": "90 123 45 67",
|
"phonePlaceholder": "90 123 45 67",
|
||||||
"digitsEntered": "{count} digits entered",
|
"digitsEntered": "{count} digits entered",
|
||||||
"sending": "Sending…",
|
"sending": "Sending…",
|
||||||
"sendCode": "Send code",
|
"sendCode": "Login",
|
||||||
"registerPrompt": "Don't have an account?",
|
"registerPrompt": "Don't have an account?",
|
||||||
"registerLink": "Register"
|
"registerLink": "Register",
|
||||||
|
"passwordLabel": "Password",
|
||||||
|
"passwordPlaceholder": "Must be at least 8 characters"
|
||||||
},
|
},
|
||||||
"Register": {
|
"Register": {
|
||||||
"successTitle": "You're registered",
|
"successTitle": "You're registered",
|
||||||
@@ -189,7 +191,9 @@
|
|||||||
"surnamePlaceholder": "Karimov",
|
"surnamePlaceholder": "Karimov",
|
||||||
"terms": "I agree to the Terms of Service and Privacy Policy",
|
"terms": "I agree to the Terms of Service and Privacy Policy",
|
||||||
"submitButton": "Create account",
|
"submitButton": "Create account",
|
||||||
"loginPrompt": "Already have an account?"
|
"loginPrompt": "Already have an account?",
|
||||||
|
"passwordLabel": "Password",
|
||||||
|
"passwordPlaceholder": "Must be at least 8 characters"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"Payment": {
|
"Payment": {
|
||||||
|
|||||||
@@ -106,7 +106,7 @@
|
|||||||
"mainHeading": "Ваша работа",
|
"mainHeading": "Ваша работа",
|
||||||
"mainHeadingItalic": "Действительно оригинальна?",
|
"mainHeadingItalic": "Действительно оригинальна?",
|
||||||
"description": "Плагиат - это представление идей или слов другого человека как своих собственных. В академической и профессиональной жизни это несет серьезные последствия. Наша платформа обнаруживает его за секунды — чтобы вы могли отправлять с полной уверенностью.",
|
"description": "Плагиат - это представление идей или слов другого человека как своих собственных. В академической и профессиональной жизни это несет серьезные последствия. Наша платформа обнаруживает его за секунды — чтобы вы могли отправлять с полной уверенностью.",
|
||||||
"certificateNote": "Сертификат выдается в течение 24 часов"
|
"certificateNote": "Сертификат прилагается!"
|
||||||
},
|
},
|
||||||
"Common": {
|
"Common": {
|
||||||
"startButton": "Начать проверку →",
|
"startButton": "Начать проверку →",
|
||||||
@@ -125,7 +125,7 @@
|
|||||||
"heading": "Как это работает",
|
"heading": "Как это работает",
|
||||||
"description": "Шесть простых шагов от загрузки до сертифицированного отчета.",
|
"description": "Шесть простых шагов от загрузки до сертифицированного отчета.",
|
||||||
"ctaHeading": "Готовы проверить ваш документ?",
|
"ctaHeading": "Готовы проверить ваш документ?",
|
||||||
"ctaDescription": "Получите сертификат оригинальности менее чем за 24 часа."
|
"ctaDescription": "Защита ваших авторских прав"
|
||||||
},
|
},
|
||||||
"Steps": {
|
"Steps": {
|
||||||
"step1Title": "Нажмите Начать",
|
"step1Title": "Нажмите Начать",
|
||||||
@@ -139,7 +139,7 @@
|
|||||||
"step5Title": "Завершите оплату",
|
"step5Title": "Завершите оплату",
|
||||||
"step5Desc": "Оплатите безопасно услугу проверки на плагиат",
|
"step5Desc": "Оплатите безопасно услугу проверки на плагиат",
|
||||||
"step6Title": "Получите ваш отчет",
|
"step6Title": "Получите ваш отчет",
|
||||||
"step6Desc": "Получите подробные результаты и сертификат в течение 24 часов"
|
"step6Desc": "Получите результаты и сертификат"
|
||||||
},
|
},
|
||||||
"Stats": {
|
"Stats": {
|
||||||
"accuracy": "Точность обнаружения",
|
"accuracy": "Точность обнаружения",
|
||||||
@@ -172,9 +172,11 @@
|
|||||||
"phonePlaceholder": "90 123 45 67",
|
"phonePlaceholder": "90 123 45 67",
|
||||||
"digitsEntered": "Введено {count} цифр",
|
"digitsEntered": "Введено {count} цифр",
|
||||||
"sending": "Отправка…",
|
"sending": "Отправка…",
|
||||||
"sendCode": "Отправить код",
|
"sendCode": "Вход",
|
||||||
"registerPrompt": "Нет аккаунта?",
|
"registerPrompt": "Нет аккаунта?",
|
||||||
"registerLink": "Зарегистрироваться"
|
"registerLink": "Зарегистрироваться",
|
||||||
|
"passwordLabel": "Пароль",
|
||||||
|
"passwordPlaceholder": "8 символов или более"
|
||||||
},
|
},
|
||||||
"Register": {
|
"Register": {
|
||||||
"successTitle": "Вы зарегистрированы",
|
"successTitle": "Вы зарегистрированы",
|
||||||
@@ -189,7 +191,9 @@
|
|||||||
"surnamePlaceholder": "Каримов",
|
"surnamePlaceholder": "Каримов",
|
||||||
"terms": "Я согласен с Условиями обслуживания и Политикой конфиденциальности",
|
"terms": "Я согласен с Условиями обслуживания и Политикой конфиденциальности",
|
||||||
"submitButton": "Создать аккаунт",
|
"submitButton": "Создать аккаунт",
|
||||||
"loginPrompt": "Уже есть аккаунт?"
|
"loginPrompt": "Уже есть аккаунт?",
|
||||||
|
"passwordLabel": "Пароль",
|
||||||
|
"passwordPlaceholder": "8 символов или более"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"Payment": {
|
"Payment": {
|
||||||
|
|||||||
@@ -109,7 +109,7 @@ declare const messages: {
|
|||||||
mainHeading: 'Sizning ishingiz';
|
mainHeading: 'Sizning ishingiz';
|
||||||
mainHeadingItalic: 'Haqiqatan ham originalmi?';
|
mainHeadingItalic: 'Haqiqatan ham originalmi?';
|
||||||
description: "Plagiat - bu boshqa birovning g'oyalarini yoki so'zlarini o'z g'oyalar yoki so'zlari sifatida taqdim etish. Akademik va professional hayotda bu jiddiy oqibatlarga olib keladi. Bizning platformamiz buni soniyalar ichida aniqlaydi — shuning uchun siz to'liq ishonch bilan yuborishingiz mumkin.";
|
description: "Plagiat - bu boshqa birovning g'oyalarini yoki so'zlarini o'z g'oyalar yoki so'zlari sifatida taqdim etish. Akademik va professional hayotda bu jiddiy oqibatlarga olib keladi. Bizning platformamiz buni soniyalar ichida aniqlaydi — shuning uchun siz to'liq ishonch bilan yuborishingiz mumkin.";
|
||||||
certificateNote: 'Sertifikat 24 soat ichida beriladi';
|
certificateNote: 'Sertifikat bilan birga!';
|
||||||
};
|
};
|
||||||
Common: {
|
Common: {
|
||||||
startButton: 'Tekshirishni boshlash →';
|
startButton: 'Tekshirishni boshlash →';
|
||||||
@@ -128,7 +128,7 @@ declare const messages: {
|
|||||||
heading: 'Bu qanday ishlaydi';
|
heading: 'Bu qanday ishlaydi';
|
||||||
description: 'Yuklashdan sertifikatlangan hisobotgacha oltita oddiy qadam.';
|
description: 'Yuklashdan sertifikatlangan hisobotgacha oltita oddiy qadam.';
|
||||||
ctaHeading: 'Hujjatingizni tekshirishga tayyormisiz?';
|
ctaHeading: 'Hujjatingizni tekshirishga tayyormisiz?';
|
||||||
ctaDescription: '24 soat ichida orijinallik sertifikatini oling.';
|
ctaDescription: 'Sizning mualliflik huquqingiz himoyasi';
|
||||||
};
|
};
|
||||||
Steps: {
|
Steps: {
|
||||||
step1Title: 'Boshlash tugmasini bosing';
|
step1Title: 'Boshlash tugmasini bosing';
|
||||||
@@ -142,7 +142,7 @@ declare const messages: {
|
|||||||
step5Title: "To'lovni amalga oshiring";
|
step5Title: "To'lovni amalga oshiring";
|
||||||
step5Desc: "Plagiat tekshiruvi xizmatini xavfsiz to'lang";
|
step5Desc: "Plagiat tekshiruvi xizmatini xavfsiz to'lang";
|
||||||
step6Title: 'Hisobotingizni oling';
|
step6Title: 'Hisobotingizni oling';
|
||||||
step6Desc: '24 soat ichida batafsil natijalar va sertifikatni oling';
|
step6Desc: 'Natijalar va sertifikatni oling';
|
||||||
};
|
};
|
||||||
Stats: {
|
Stats: {
|
||||||
accuracy: 'Aniqlash aniqligi';
|
accuracy: 'Aniqlash aniqligi';
|
||||||
@@ -175,9 +175,11 @@ declare const messages: {
|
|||||||
phonePlaceholder: '90 123 45 67';
|
phonePlaceholder: '90 123 45 67';
|
||||||
digitsEntered: '{count} ta raqam kiritildi';
|
digitsEntered: '{count} ta raqam kiritildi';
|
||||||
sending: 'Yuborilmoqda…';
|
sending: 'Yuborilmoqda…';
|
||||||
sendCode: 'Kodni yuborish';
|
sendCode: 'Kirish';
|
||||||
registerPrompt: "Hisobingiz yo'qmi?";
|
registerPrompt: "Hisobingiz yo'qmi?";
|
||||||
registerLink: "Ro'yxatdan o'tish";
|
registerLink: "Ro'yxatdan o'tish";
|
||||||
|
passwordLabel: 'Parol';
|
||||||
|
passwordPlaceholder: "8 ta belgidan iborat bo'lsin";
|
||||||
};
|
};
|
||||||
Register: {
|
Register: {
|
||||||
successTitle: "Siz ro'yxatdan o'tdingiz";
|
successTitle: "Siz ro'yxatdan o'tdingiz";
|
||||||
@@ -193,6 +195,8 @@ declare const messages: {
|
|||||||
terms: "Men Xizmat ko'rsatish shartlari va Maxfiylik siyosatiga roziman";
|
terms: "Men Xizmat ko'rsatish shartlari va Maxfiylik siyosatiga roziman";
|
||||||
submitButton: 'Hisob yaratish';
|
submitButton: 'Hisob yaratish';
|
||||||
loginPrompt: 'Hisobingiz bormi?';
|
loginPrompt: 'Hisobingiz bormi?';
|
||||||
|
passwordLabel: 'Parol';
|
||||||
|
passwordPlaceholder: "8 ta belgidan iborat bo'lsin";
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
Payment: {
|
Payment: {
|
||||||
|
|||||||
@@ -106,7 +106,7 @@
|
|||||||
"mainHeading": "Sizning ishingiz",
|
"mainHeading": "Sizning ishingiz",
|
||||||
"mainHeadingItalic": "Haqiqatan ham originalmi?",
|
"mainHeadingItalic": "Haqiqatan ham originalmi?",
|
||||||
"description": "Plagiat - bu boshqa birovning g'oyalarini yoki so'zlarini o'z g'oyalar yoki so'zlari sifatida taqdim etish. Akademik va professional hayotda bu jiddiy oqibatlarga olib keladi. Bizning platformamiz buni soniyalar ichida aniqlaydi — shuning uchun siz to'liq ishonch bilan yuborishingiz mumkin.",
|
"description": "Plagiat - bu boshqa birovning g'oyalarini yoki so'zlarini o'z g'oyalar yoki so'zlari sifatida taqdim etish. Akademik va professional hayotda bu jiddiy oqibatlarga olib keladi. Bizning platformamiz buni soniyalar ichida aniqlaydi — shuning uchun siz to'liq ishonch bilan yuborishingiz mumkin.",
|
||||||
"certificateNote": "Sertifikat 24 soat ichida beriladi"
|
"certificateNote": "Sertifikat bilan birga!"
|
||||||
},
|
},
|
||||||
"Common": {
|
"Common": {
|
||||||
"startButton": "Tekshirishni boshlash →",
|
"startButton": "Tekshirishni boshlash →",
|
||||||
@@ -125,7 +125,7 @@
|
|||||||
"heading": "Bu qanday ishlaydi",
|
"heading": "Bu qanday ishlaydi",
|
||||||
"description": "Yuklashdan sertifikatlangan hisobotgacha oltita oddiy qadam.",
|
"description": "Yuklashdan sertifikatlangan hisobotgacha oltita oddiy qadam.",
|
||||||
"ctaHeading": "Hujjatingizni tekshirishga tayyormisiz?",
|
"ctaHeading": "Hujjatingizni tekshirishga tayyormisiz?",
|
||||||
"ctaDescription": "24 soat ichida orijinallik sertifikatini oling."
|
"ctaDescription": "Sizning mualliflik huquqingiz himoyasi"
|
||||||
},
|
},
|
||||||
"Steps": {
|
"Steps": {
|
||||||
"step1Title": "Boshlash tugmasini bosing",
|
"step1Title": "Boshlash tugmasini bosing",
|
||||||
@@ -139,7 +139,7 @@
|
|||||||
"step5Title": "To'lovni amalga oshiring",
|
"step5Title": "To'lovni amalga oshiring",
|
||||||
"step5Desc": "Plagiat tekshiruvi xizmatini xavfsiz to'lang",
|
"step5Desc": "Plagiat tekshiruvi xizmatini xavfsiz to'lang",
|
||||||
"step6Title": "Hisobotingizni oling",
|
"step6Title": "Hisobotingizni oling",
|
||||||
"step6Desc": "24 soat ichida batafsil natijalar va sertifikatni oling"
|
"step6Desc": "Natijalar va sertifikatni oling"
|
||||||
},
|
},
|
||||||
"Stats": {
|
"Stats": {
|
||||||
"accuracy": "Aniqlash aniqligi",
|
"accuracy": "Aniqlash aniqligi",
|
||||||
@@ -172,9 +172,11 @@
|
|||||||
"phonePlaceholder": "90 123 45 67",
|
"phonePlaceholder": "90 123 45 67",
|
||||||
"digitsEntered": "{count} ta raqam kiritildi",
|
"digitsEntered": "{count} ta raqam kiritildi",
|
||||||
"sending": "Yuborilmoqda…",
|
"sending": "Yuborilmoqda…",
|
||||||
"sendCode": "Kodni yuborish",
|
"sendCode": "Kirish",
|
||||||
"registerPrompt": "Hisobingiz yo'qmi?",
|
"registerPrompt": "Hisobingiz yo'qmi?",
|
||||||
"registerLink": "Ro'yxatdan o'tish"
|
"registerLink": "Ro'yxatdan o'tish",
|
||||||
|
"passwordLabel": "Parol",
|
||||||
|
"passwordPlaceholder": "8 ta belgidan iborat bo'lsin"
|
||||||
},
|
},
|
||||||
"Register": {
|
"Register": {
|
||||||
"successTitle": "Siz ro'yxatdan o'tdingiz",
|
"successTitle": "Siz ro'yxatdan o'tdingiz",
|
||||||
@@ -189,7 +191,9 @@
|
|||||||
"surnamePlaceholder": "Karimov",
|
"surnamePlaceholder": "Karimov",
|
||||||
"terms": "Men Xizmat ko'rsatish shartlari va Maxfiylik siyosatiga roziman",
|
"terms": "Men Xizmat ko'rsatish shartlari va Maxfiylik siyosatiga roziman",
|
||||||
"submitButton": "Hisob yaratish",
|
"submitButton": "Hisob yaratish",
|
||||||
"loginPrompt": "Hisobingiz bormi?"
|
"loginPrompt": "Hisobingiz bormi?",
|
||||||
|
"passwordLabel": "Parol",
|
||||||
|
"passwordPlaceholder": "8 ta belgidan iborat bo'lsin"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"Payment": {
|
"Payment": {
|
||||||
|
|||||||
80
src/shared/config/jsonId.tsx
Normal file
80
src/shared/config/jsonId.tsx
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
/**
|
||||||
|
* JsonLd — injects structured data (schema.org) into <head>.
|
||||||
|
* Helps Google understand your site type, sitelinks searchbox, etc.
|
||||||
|
*
|
||||||
|
* Usage in layout.tsx (inside <head> or anywhere in the tree):
|
||||||
|
* import { JsonLd } from '@/shared/components/JsonLd';
|
||||||
|
* ...
|
||||||
|
* <JsonLd locale={locale} />
|
||||||
|
*/
|
||||||
|
|
||||||
|
const SITE_URL = process.env.NEXT_PUBLIC_SITE_URL ?? 'https://antiplagiat.uz';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
locale: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function JsonLd({ locale }: Props) {
|
||||||
|
const schema = {
|
||||||
|
'@context': 'https://schema.org',
|
||||||
|
'@graph': [
|
||||||
|
// ── Organization ──────────────────────────────────────────────────────
|
||||||
|
{
|
||||||
|
'@type': 'Organization',
|
||||||
|
'@id': `${SITE_URL}/#organization`,
|
||||||
|
name: 'AntiPlagiat.uz',
|
||||||
|
url: SITE_URL,
|
||||||
|
logo: {
|
||||||
|
'@type': 'ImageObject',
|
||||||
|
url: `${SITE_URL}/icons/icon-512.png`,
|
||||||
|
width: 512,
|
||||||
|
height: 512,
|
||||||
|
},
|
||||||
|
sameAs: [
|
||||||
|
// Add your social profile URLs here
|
||||||
|
// 'https://t.me/antiplagiatuz',
|
||||||
|
],
|
||||||
|
contactPoint: {
|
||||||
|
'@type': 'ContactPoint',
|
||||||
|
contactType: 'customer support',
|
||||||
|
availableLanguage: ['Uzbek', 'Russian', 'English'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── WebSite (enables Google Sitelinks Searchbox) ──────────────────────
|
||||||
|
{
|
||||||
|
'@type': 'WebSite',
|
||||||
|
'@id': `${SITE_URL}/#website`,
|
||||||
|
url: SITE_URL,
|
||||||
|
name: 'AntiPlagiat.uz',
|
||||||
|
publisher: { '@id': `${SITE_URL}/#organization` },
|
||||||
|
inLanguage: [locale],
|
||||||
|
potentialAction: {
|
||||||
|
'@type': 'SearchAction',
|
||||||
|
target: {
|
||||||
|
'@type': 'EntryPoint',
|
||||||
|
urlTemplate: `${SITE_URL}/search?q={search_term_string}`,
|
||||||
|
},
|
||||||
|
'query-input': 'required name=search_term_string',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── WebPage ───────────────────────────────────────────────────────────
|
||||||
|
{
|
||||||
|
'@type': 'WebPage',
|
||||||
|
'@id': `${SITE_URL}/${locale}/#webpage`,
|
||||||
|
url: `${SITE_URL}/${locale}`,
|
||||||
|
isPartOf: { '@id': `${SITE_URL}/#website` },
|
||||||
|
about: { '@id': `${SITE_URL}/#organization` },
|
||||||
|
inLanguage: locale,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<script
|
||||||
|
type="application/ld+json"
|
||||||
|
dangerouslySetInnerHTML={{ __html: JSON.stringify(schema) }}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
76
src/shared/config/seo.config.ts
Normal file
76
src/shared/config/seo.config.ts
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
// ─── SEO Metadata per locale ───────────────────────────────────────────────────
|
||||||
|
// Used by generateMetadata() in layout.tsx
|
||||||
|
|
||||||
|
export type SupportedLocale = 'uz' | 'ru' | 'en';
|
||||||
|
|
||||||
|
interface LocaleSeoData {
|
||||||
|
siteName: string;
|
||||||
|
title: string;
|
||||||
|
titleTemplate: string;
|
||||||
|
description: string;
|
||||||
|
keywords: string[];
|
||||||
|
ogTitle: string;
|
||||||
|
ogDescription: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SEO_DATA: Record<SupportedLocale, LocaleSeoData> = {
|
||||||
|
uz: {
|
||||||
|
siteName: 'AntiPlagiat.uz',
|
||||||
|
title: 'AntiPlagiat.uz — Plagiat tekshiruvi onlayn',
|
||||||
|
titleTemplate: '%s | AntiPlagiat.uz',
|
||||||
|
description:
|
||||||
|
"Dissertatsiya, kurs ishi va ilmiy maqolalaringizni tezkor va ishonchli plagiat tekshiruvidan o'tkazing. Natija daqiqalar ichida tayyor.",
|
||||||
|
keywords: [
|
||||||
|
'plagiat tekshiruvi',
|
||||||
|
'antiplagiat',
|
||||||
|
'dissertatsiya tekshiruvi',
|
||||||
|
'kurs ishi plagiat',
|
||||||
|
'ilmiy maqola tekshiruvi',
|
||||||
|
'onlayn plagiat',
|
||||||
|
"o'zbekiston antiplagiat",
|
||||||
|
],
|
||||||
|
ogTitle: 'AntiPlagiat.uz — Tezkor plagiat tekshiruvi',
|
||||||
|
ogDescription:
|
||||||
|
'Ishingizni bir necha daqiqada plagiatga tekshiring. Sertifikat bilan natija oling.',
|
||||||
|
},
|
||||||
|
|
||||||
|
ru: {
|
||||||
|
siteName: 'AntiPlagiat.uz',
|
||||||
|
title: 'AntiPlagiat.uz — Проверка на плагиат онлайн',
|
||||||
|
titleTemplate: '%s | AntiPlagiat.uz',
|
||||||
|
description:
|
||||||
|
'Быстрая и надёжная проверка диссертаций, курсовых работ и научных статей на плагиат. Результат готов за несколько минут.',
|
||||||
|
keywords: [
|
||||||
|
'проверка на плагиат',
|
||||||
|
'антиплагиат',
|
||||||
|
'проверка диссертации',
|
||||||
|
'курсовая плагиат',
|
||||||
|
'проверка научной статьи',
|
||||||
|
'онлайн антиплагиат',
|
||||||
|
'антиплагиат узбекистан',
|
||||||
|
],
|
||||||
|
ogTitle: 'AntiPlagiat.uz — Быстрая проверка на плагиат',
|
||||||
|
ogDescription:
|
||||||
|
'Проверьте вашу работу на плагиат за несколько минут. Получите результат с сертификатом.',
|
||||||
|
},
|
||||||
|
|
||||||
|
en: {
|
||||||
|
siteName: 'AntiPlagiat.uz',
|
||||||
|
title: 'AntiPlagiat.uz — Online Plagiarism Checker',
|
||||||
|
titleTemplate: '%s | AntiPlagiat.uz',
|
||||||
|
description:
|
||||||
|
'Fast and reliable plagiarism detection for dissertations, coursework, and research papers. Get your results in minutes.',
|
||||||
|
keywords: [
|
||||||
|
'plagiarism checker',
|
||||||
|
'anti-plagiarism',
|
||||||
|
'dissertation plagiarism check',
|
||||||
|
'coursework plagiarism',
|
||||||
|
'research paper checker',
|
||||||
|
'online plagiarism detection',
|
||||||
|
'uzbekistan plagiarism',
|
||||||
|
],
|
||||||
|
ogTitle: 'AntiPlagiat.uz — Fast Plagiarism Checker',
|
||||||
|
ogDescription:
|
||||||
|
'Check your work for plagiarism in minutes. Receive your result with an official certificate.',
|
||||||
|
},
|
||||||
|
};
|
||||||
1
src/shared/lib/index.ts
Normal file
1
src/shared/lib/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export * from './utils';
|
||||||
137
src/shared/lib/metadata.ts
Normal file
137
src/shared/lib/metadata.ts
Normal file
@@ -0,0 +1,137 @@
|
|||||||
|
import type { Metadata } from 'next';
|
||||||
|
import { SEO_DATA, type SupportedLocale } from '../config/seo.config';
|
||||||
|
|
||||||
|
// ─── Site-wide constants ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
// ─── Root layout metadata (called in app/[locale]/layout.tsx) ─────────────────
|
||||||
|
|
||||||
|
export function generateRootMetadata(locale: string): Metadata {
|
||||||
|
const seo = SEO_DATA[locale as SupportedLocale] ?? SEO_DATA.ru;
|
||||||
|
|
||||||
|
// Build alternate-language links for <head>
|
||||||
|
const languages: Record<string, string> = {
|
||||||
|
uz: `${SITE_URL}/uz`,
|
||||||
|
ru: `${SITE_URL}/ru`,
|
||||||
|
en: `${SITE_URL}/en`,
|
||||||
|
'x-default': `${SITE_URL}/ru`,
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
// ── Basic ──────────────────────────────────────────────────────────────────
|
||||||
|
metadataBase: new URL(SITE_URL),
|
||||||
|
title: {
|
||||||
|
default: seo.title,
|
||||||
|
template: seo.titleTemplate,
|
||||||
|
},
|
||||||
|
description: seo.description,
|
||||||
|
keywords: seo.keywords,
|
||||||
|
authors: [{ name: seo.siteName, url: SITE_URL }],
|
||||||
|
creator: seo.siteName,
|
||||||
|
publisher: seo.siteName,
|
||||||
|
applicationName: seo.siteName,
|
||||||
|
generator: 'Next.js',
|
||||||
|
|
||||||
|
// ── Canonical & alternates ─────────────────────────────────────────────────
|
||||||
|
alternates: {
|
||||||
|
canonical: `${SITE_URL}/${locale}`,
|
||||||
|
languages,
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── Robots ────────────────────────────────────────────────────────────────
|
||||||
|
robots: {
|
||||||
|
index: true,
|
||||||
|
follow: true,
|
||||||
|
nocache: false,
|
||||||
|
googleBot: {
|
||||||
|
index: true,
|
||||||
|
follow: true,
|
||||||
|
noimageindex: false,
|
||||||
|
'max-video-preview': -1,
|
||||||
|
'max-image-preview': 'large',
|
||||||
|
'max-snippet': -1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── Open Graph ────────────────────────────────────────────────────────────
|
||||||
|
openGraph: {
|
||||||
|
type: 'website',
|
||||||
|
locale: ogLocale(locale),
|
||||||
|
alternateLocale: ogAlternates(locale),
|
||||||
|
url: `${SITE_URL}/${locale}`,
|
||||||
|
siteName: seo.siteName,
|
||||||
|
title: seo.ogTitle,
|
||||||
|
description: seo.ogDescription,
|
||||||
|
images: [
|
||||||
|
{
|
||||||
|
url: OG_IMAGE_URL,
|
||||||
|
width: 1200,
|
||||||
|
height: 630,
|
||||||
|
alt: seo.ogTitle,
|
||||||
|
type: 'image/png',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── Twitter / X ───────────────────────────────────────────────────────────
|
||||||
|
twitter: {
|
||||||
|
card: 'summary_large_image',
|
||||||
|
site: TWITTER_HANDLE,
|
||||||
|
creator: TWITTER_HANDLE,
|
||||||
|
title: seo.ogTitle,
|
||||||
|
description: seo.ogDescription,
|
||||||
|
images: [OG_IMAGE_URL],
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── Icons ─────────────────────────────────────────────────────────────────
|
||||||
|
icons: {
|
||||||
|
icon: [
|
||||||
|
{ url: '/favicon.ico' },
|
||||||
|
{ url: '/icons/icon-16.png', sizes: '16x16', type: 'image/png' },
|
||||||
|
{ url: '/icons/icon-32.png', sizes: '32x32', type: 'image/png' },
|
||||||
|
{ url: '/icons/icon-192.png', sizes: '192x192', type: 'image/png' },
|
||||||
|
],
|
||||||
|
apple: [
|
||||||
|
{
|
||||||
|
url: '/icons/apple-touch-icon.png',
|
||||||
|
sizes: '180x180',
|
||||||
|
type: 'image/png',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
shortcut: '/favicon.ico',
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── Web app manifest ──────────────────────────────────────────────────────
|
||||||
|
manifest: '/manifest.webmanifest',
|
||||||
|
|
||||||
|
// ── Verification (add your Search Console token) ──────────────────────────
|
||||||
|
verification: {
|
||||||
|
google: process.env.NEXT_PUBLIC_GOOGLE_SITE_VERIFICATION ?? '',
|
||||||
|
yandex: process.env.NEXT_PUBLIC_YANDEX_VERIFICATION ?? '',
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── Extra meta ────────────────────────────────────────────────────────────
|
||||||
|
category: 'education',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** Maps locale code to Open Graph locale string */
|
||||||
|
function ogLocale(locale: string): string {
|
||||||
|
const map: Record<string, string> = {
|
||||||
|
uz: 'uz_UZ',
|
||||||
|
ru: 'ru_RU',
|
||||||
|
en: 'en_US',
|
||||||
|
};
|
||||||
|
return map[locale] ?? 'ru_RU';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Returns the other two OG locales for alternateLocale */
|
||||||
|
function ogAlternates(currentLocale: string): string[] {
|
||||||
|
const all = ['uz_UZ', 'ru_RU', 'en_US'];
|
||||||
|
return all.filter((l) => l !== ogLocale(currentLocale));
|
||||||
|
}
|
||||||
@@ -1,29 +1,198 @@
|
|||||||
import axios, { AxiosRequestConfig, AxiosResponse } from 'axios';
|
import axios, {
|
||||||
|
AxiosRequestConfig,
|
||||||
|
AxiosResponse,
|
||||||
|
AxiosError,
|
||||||
|
InternalAxiosRequestConfig,
|
||||||
|
} from 'axios';
|
||||||
import { getRouteLang } from './getLanguage';
|
import { getRouteLang } from './getLanguage';
|
||||||
|
|
||||||
|
// ─── Constants ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
const baseUrl = process.env.NEXT_PUBLIC_API_BASE_URL;
|
const baseUrl = process.env.NEXT_PUBLIC_API_BASE_URL;
|
||||||
|
const DEFAULT_LOCALE = 'uz'; // fallback locale for redirect
|
||||||
|
|
||||||
|
// ─── Token helpers ─────────────────────────────────────────────────────────────
|
||||||
|
// Adjust key names to match whatever your auth stores them under.
|
||||||
|
|
||||||
|
export const TokenStorage = {
|
||||||
|
getAccess: (): string | null => localStorage.getItem('access_token'),
|
||||||
|
getRefresh: (): string | null => localStorage.getItem('refresh_token'),
|
||||||
|
|
||||||
|
setAccess: (token: string): void =>
|
||||||
|
localStorage.setItem('access_token', token),
|
||||||
|
setRefresh: (token: string): void =>
|
||||||
|
localStorage.setItem('refresh_token', token),
|
||||||
|
|
||||||
|
setTokens: (access: string, refresh: string): void => {
|
||||||
|
localStorage.setItem('access_token', access);
|
||||||
|
localStorage.setItem('refresh_token', refresh);
|
||||||
|
},
|
||||||
|
|
||||||
|
clear: (): void => {
|
||||||
|
localStorage.removeItem('access_token');
|
||||||
|
localStorage.removeItem('refresh_token');
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// ─── Redirect helper ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function redirectToMain(): void {
|
||||||
|
// Detect current locale from the URL path, fall back to DEFAULT_LOCALE
|
||||||
|
const pathLocale = window.location.pathname.split('/')[1];
|
||||||
|
const validLocales = ['uz', 'ru', 'en'];
|
||||||
|
const locale = validLocales.includes(pathLocale)
|
||||||
|
? pathLocale
|
||||||
|
: DEFAULT_LOCALE;
|
||||||
|
|
||||||
|
window.location.href = `/${locale}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Axios instance ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
const api = axios.create({
|
const api = axios.create({
|
||||||
baseURL: baseUrl,
|
baseURL: baseUrl,
|
||||||
|
timeout: 15_000,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ─── Flag to prevent multiple simultaneous refresh calls ──────────────────────
|
||||||
|
|
||||||
|
let isRefreshing = false;
|
||||||
|
|
||||||
|
// Queue of failed requests waiting for the new token
|
||||||
|
type FailedRequestResolver = (token: string) => void;
|
||||||
|
let failedQueue: {
|
||||||
|
resolve: FailedRequestResolver;
|
||||||
|
reject: (err: unknown) => void;
|
||||||
|
}[] = [];
|
||||||
|
|
||||||
|
function processQueue(error: unknown, token: string | null = null): void {
|
||||||
|
failedQueue.forEach(({ resolve, reject }) => {
|
||||||
|
if (error) {
|
||||||
|
reject(error);
|
||||||
|
} else if (token) {
|
||||||
|
resolve(token);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
failedQueue = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Request interceptor — attach access token ────────────────────────────────
|
||||||
|
|
||||||
|
api.interceptors.request.use(
|
||||||
|
(config: InternalAxiosRequestConfig) => {
|
||||||
|
const token = TokenStorage.getAccess();
|
||||||
|
|
||||||
|
if (token) {
|
||||||
|
config.headers.Authorization = `Bearer ${token}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
config.headers['Accept-Language'] = getRouteLang();
|
||||||
|
|
||||||
|
return config;
|
||||||
|
},
|
||||||
|
(error: AxiosError) => Promise.reject(error),
|
||||||
|
);
|
||||||
|
|
||||||
|
// ─── Response interceptor — handle 401, refresh, retry ───────────────────────
|
||||||
|
|
||||||
|
api.interceptors.response.use(
|
||||||
|
// ── 2xx: pass through unchanged ────────────────────────────────────────────
|
||||||
|
(response: AxiosResponse) => response,
|
||||||
|
|
||||||
|
// ── Error: attempt token refresh on 401 ────────────────────────────────────
|
||||||
|
async (error: AxiosError) => {
|
||||||
|
const originalRequest = error.config as InternalAxiosRequestConfig & {
|
||||||
|
_retry?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
const status = error.response?.status;
|
||||||
|
|
||||||
|
// Only attempt refresh on 401 and only once per request
|
||||||
|
if (status !== 401 || originalRequest._retry) {
|
||||||
|
return Promise.reject(error);
|
||||||
|
}
|
||||||
|
|
||||||
|
const refreshToken = TokenStorage.getRefresh();
|
||||||
|
|
||||||
|
// No refresh token available — clear everything and redirect
|
||||||
|
if (!refreshToken) {
|
||||||
|
TokenStorage.clear();
|
||||||
|
redirectToMain();
|
||||||
|
return Promise.reject(error);
|
||||||
|
}
|
||||||
|
|
||||||
|
// If a refresh is already in progress, queue this request
|
||||||
|
if (isRefreshing) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
failedQueue.push({
|
||||||
|
resolve: (token: string) => {
|
||||||
|
originalRequest.headers.Authorization = `Bearer ${token}`;
|
||||||
|
resolve(api(originalRequest));
|
||||||
|
},
|
||||||
|
reject,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mark that we're refreshing and this request has been retried
|
||||||
|
originalRequest._retry = true;
|
||||||
|
isRefreshing = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// ── Call your refresh endpoint ──────────────────────────────────────────
|
||||||
|
// Adjust the URL and payload shape to match your backend.
|
||||||
|
const { data } = await axios.post<{
|
||||||
|
access_token: string;
|
||||||
|
refresh_token: string;
|
||||||
|
}>(
|
||||||
|
`${baseUrl}/auth/refresh`, // ← your refresh endpoint
|
||||||
|
{ refresh_token: refreshToken },
|
||||||
|
{ headers: { 'Accept-Language': getRouteLang() } },
|
||||||
|
);
|
||||||
|
|
||||||
|
const { access_token, refresh_token } = data;
|
||||||
|
|
||||||
|
TokenStorage.setTokens(access_token, refresh_token);
|
||||||
|
|
||||||
|
// Update the Authorization header for the retried request
|
||||||
|
originalRequest.headers.Authorization = `Bearer ${access_token}`;
|
||||||
|
|
||||||
|
// Unblock all queued requests with the new token
|
||||||
|
processQueue(null, access_token);
|
||||||
|
|
||||||
|
// Retry the original failed request
|
||||||
|
return api(originalRequest);
|
||||||
|
} catch (refreshError) {
|
||||||
|
// Refresh itself failed — clear tokens and redirect to home
|
||||||
|
processQueue(refreshError, null);
|
||||||
|
TokenStorage.clear();
|
||||||
|
redirectToMain();
|
||||||
|
return Promise.reject(refreshError);
|
||||||
|
} finally {
|
||||||
|
isRefreshing = false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// ─── Public request function ───────────────────────────────────────────────────
|
||||||
|
|
||||||
export const apiRequest = async <T>(
|
export const apiRequest = async <T>(
|
||||||
method: 'GET' | 'POST' | 'PUT' | 'DELETE',
|
method: 'GET' | 'POST' | 'PUT' | 'DELETE',
|
||||||
url: string,
|
url: string,
|
||||||
data?: unknown,
|
data?: unknown,
|
||||||
config?: Omit<AxiosRequestConfig, 'method' | 'url' | 'data'>,
|
config?: Omit<AxiosRequestConfig, 'method' | 'url' | 'data'>,
|
||||||
): Promise<T> => {
|
): Promise<AxiosResponse<T>> => {
|
||||||
const response: AxiosResponse<T> = await api.request<T>({
|
const response = await api.request<T>({
|
||||||
method,
|
method,
|
||||||
url,
|
url,
|
||||||
data,
|
data,
|
||||||
...config,
|
...config,
|
||||||
headers: {
|
headers: {
|
||||||
'Accept-Language': getRouteLang(), // evaluated per-request
|
|
||||||
...config?.headers,
|
...config?.headers,
|
||||||
|
// Accept-Language is already set in the request interceptor,
|
||||||
|
// but config?.headers can still override it if needed.
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
return response.data;
|
return response;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
export const links = {
|
export const links = {
|
||||||
login: '/users/login/',
|
login: '/users/login/',
|
||||||
register: '/users/register/',
|
register: '/users/register/',
|
||||||
|
plagiarismCheck: '/plagiarism/check/',
|
||||||
|
history: '/shared/documents/list/',
|
||||||
};
|
};
|
||||||
|
|||||||
65
src/shared/ui/field.tsx
Normal file
65
src/shared/ui/field.tsx
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
export function Field({
|
||||||
|
id,
|
||||||
|
label,
|
||||||
|
type = 'text',
|
||||||
|
placeholder,
|
||||||
|
value,
|
||||||
|
name,
|
||||||
|
onChange,
|
||||||
|
error,
|
||||||
|
maxLength,
|
||||||
|
minLength,
|
||||||
|
require = false,
|
||||||
|
}: {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
type?: string;
|
||||||
|
placeholder?: string;
|
||||||
|
value: string;
|
||||||
|
name: string;
|
||||||
|
onChange: (e: React.ChangeEvent<HTMLInputElement>) => void;
|
||||||
|
error?: string;
|
||||||
|
maxLength?: number;
|
||||||
|
minLength?: number;
|
||||||
|
require?: boolean;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<label
|
||||||
|
htmlFor={id}
|
||||||
|
className="text-[0.7rem] font-medium tracking-widest uppercase text-stone-500"
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id={id}
|
||||||
|
name={name}
|
||||||
|
type={type}
|
||||||
|
placeholder={placeholder}
|
||||||
|
value={value}
|
||||||
|
onChange={onChange}
|
||||||
|
maxLength={maxLength}
|
||||||
|
minLength={minLength}
|
||||||
|
autoComplete="off"
|
||||||
|
className={`
|
||||||
|
w-full rounded-sm border bg-stone-50 px-3.5 py-2.5
|
||||||
|
font-sans text-[0.95rem] text-stone-900 outline-none
|
||||||
|
placeholder:text-stone-300
|
||||||
|
transition-all duration-150
|
||||||
|
focus:border-stone-400 focus:ring-2 focus:ring-stone-300/30
|
||||||
|
${
|
||||||
|
error
|
||||||
|
? 'border-red-400 ring-2 ring-red-200/40'
|
||||||
|
: 'border-stone-200 hover:border-stone-300'
|
||||||
|
}
|
||||||
|
`}
|
||||||
|
required={require}
|
||||||
|
/>
|
||||||
|
{error && (
|
||||||
|
<span className="text-[0.7rem] text-red-500 tracking-tight">
|
||||||
|
{error}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
2
src/shared/ui/index.ts
Normal file
2
src/shared/ui/index.ts
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
export * from './motion';
|
||||||
|
export * from './phonePrefix';
|
||||||
@@ -1,21 +1,21 @@
|
|||||||
import { create } from 'zustand';
|
import { create } from 'zustand';
|
||||||
|
|
||||||
interface User {
|
interface User {
|
||||||
id: string;
|
id: number;
|
||||||
name: string;
|
name: string;
|
||||||
surname: string;
|
surname: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface UserLoginStore {
|
interface UserPlagiat {
|
||||||
user: User | null;
|
user: User | null;
|
||||||
setUser: (user: User) => void;
|
setUser: (user: User | null) => void;
|
||||||
clearUser: () => void;
|
clearUser: () => void;
|
||||||
getUser: () => User | null;
|
getUser: () => User | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useUserLogin = create<UserLoginStore>((set, get) => ({
|
export const useUserPlagiatStore = create<UserPlagiat>((set, get) => ({
|
||||||
user: null,
|
user: null,
|
||||||
setUser: (user: User) => set({ user }),
|
setUser: (user: User | null) => set({ user }),
|
||||||
clearUser: () => set({ user: null }),
|
clearUser: () => set({ user: null }),
|
||||||
getUser: () => get().user,
|
getUser: () => get().user,
|
||||||
}));
|
}));
|
||||||
@@ -8,6 +8,8 @@ import {
|
|||||||
import { selectFullName, useUserStore } from './userStore';
|
import { selectFullName, useUserStore } from './userStore';
|
||||||
import { isFormValid, validatePlagiarismForm } from './validation';
|
import { isFormValid, validatePlagiarismForm } from './validation';
|
||||||
import { submitPlagiarismCheck } from '@/shared/request/plagiarismapi';
|
import { submitPlagiarismCheck } from '@/shared/request/plagiarismapi';
|
||||||
|
import { toast } from 'react-toastify';
|
||||||
|
import { useUserPlagiatStore } from '@/shared/zustand/user';
|
||||||
|
|
||||||
// ─── Initial States ──────────────────────────────────────────────────────────
|
// ─── Initial States ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -27,13 +29,17 @@ const INITIAL_SUBMISSION: SubmissionState = {
|
|||||||
|
|
||||||
export function usePlagiarismForm() {
|
export function usePlagiarismForm() {
|
||||||
const senderFullName = useUserStore(selectFullName);
|
const senderFullName = useUserStore(selectFullName);
|
||||||
|
const user = useUserPlagiatStore((state) => state.user);
|
||||||
const [form, setForm] = useState<PlagiarismFormState>(INITIAL_FORM);
|
const [form, setForm] = useState<PlagiarismFormState>(INITIAL_FORM);
|
||||||
const [errors, setErrors] = useState<PlagiarismFormErrors>({});
|
const [errors, setErrors] = useState<PlagiarismFormErrors>({});
|
||||||
const [isPaymentOpen, setIsPaymentOpen] = useState(false);
|
const [isPaymentOpen, setIsPaymentOpen] = useState(false);
|
||||||
const [submission, setSubmission] =
|
const [submission, setSubmission] =
|
||||||
useState<SubmissionState>(INITIAL_SUBMISSION);
|
useState<SubmissionState>(INITIAL_SUBMISSION);
|
||||||
|
|
||||||
|
// const checkdocumentRequest = useMutation({
|
||||||
|
// mutationFn: (data:any) => apiRequest("POST",links.plagiarismCheck, data)
|
||||||
|
// })
|
||||||
|
|
||||||
// ── Field updaters ───────────────────────────────────────────────────────
|
// ── Field updaters ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
const setTopic = useCallback((topic: string) => {
|
const setTopic = useCallback((topic: string) => {
|
||||||
@@ -57,6 +63,11 @@ export function usePlagiarismForm() {
|
|||||||
async (e: React.FormEvent) => {
|
async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
||||||
|
console.log('Form submitted user:', user); // Debugging log
|
||||||
|
if (user === null) {
|
||||||
|
toast.error('Iltimos, avval tizimga kiring!');
|
||||||
|
return;
|
||||||
|
}
|
||||||
// Run validation first
|
// Run validation first
|
||||||
const validationErrors = validatePlagiarismForm(form);
|
const validationErrors = validatePlagiarismForm(form);
|
||||||
if (!isFormValid(validationErrors)) {
|
if (!isFormValid(validationErrors)) {
|
||||||
|
|||||||
@@ -4,11 +4,19 @@ import { useTranslations } from 'next-intl';
|
|||||||
import { useHistory } from '../lib/useHistory';
|
import { useHistory } from '../lib/useHistory';
|
||||||
import { HistoryTable } from './historyTable';
|
import { HistoryTable } from './historyTable';
|
||||||
import { Pagination } from './pagination';
|
import { Pagination } from './pagination';
|
||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { apiRequest } from '@/shared/request/apiRequest';
|
||||||
|
import { links } from '@/shared/request/links';
|
||||||
|
|
||||||
// ─── Page Header ───────────────────────────────────────────────────────────────
|
// ─── Page Header ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
const PageHeader: React.FC = () => {
|
const PageHeader: React.FC = () => {
|
||||||
const t = useTranslations('HistoryPage');
|
const t = useTranslations('HistoryPage');
|
||||||
|
const { data } = useQuery({
|
||||||
|
queryKey: ['history'],
|
||||||
|
queryFn: () => apiRequest('GET', links.history),
|
||||||
|
});
|
||||||
|
console.log('History data:', data); // Debugging log
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mb-6">
|
<div className="mb-6">
|
||||||
|
|||||||
@@ -183,6 +183,7 @@ const Hero = () => {
|
|||||||
paddingTop: 36,
|
paddingTop: 36,
|
||||||
borderTop: `1px solid ${C.border}`,
|
borderTop: `1px solid ${C.border}`,
|
||||||
}}
|
}}
|
||||||
|
className="flex items-center justify-around"
|
||||||
>
|
>
|
||||||
{STATS.map((s) => {
|
{STATS.map((s) => {
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { C } from '../tokens';
|
|||||||
import type { StatItem } from '../types';
|
import type { StatItem } from '../types';
|
||||||
|
|
||||||
const Stat: FC<StatItem> = ({ value, label }) => (
|
const Stat: FC<StatItem> = ({ value, label }) => (
|
||||||
<div>
|
<div className="flex items-center justify-center flex-col">
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
fontFamily: "'Playfair Display', serif",
|
fontFamily: "'Playfair Display', serif",
|
||||||
|
|||||||
@@ -44,7 +44,6 @@ export const STATS: StatItem[] = [
|
|||||||
{ value: '98.7%', label: 'Detection accuracy' },
|
{ value: '98.7%', label: 'Detection accuracy' },
|
||||||
{ value: '50K+', label: 'Documents checked' },
|
{ value: '50K+', label: 'Documents checked' },
|
||||||
{ value: '12+', label: 'Supported formats' },
|
{ value: '12+', label: 'Supported formats' },
|
||||||
{ value: '24h', label: 'Report turnaround' },
|
|
||||||
];
|
];
|
||||||
|
|
||||||
export const INFO_CARDS: InfoCardData[] = [
|
export const INFO_CARDS: InfoCardData[] = [
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
'use client';
|
'use client';
|
||||||
import { Link } from '@/shared/config/i18n/navigation';
|
import { Link } from '@/shared/config/i18n/navigation';
|
||||||
import { Button } from '@/shared/ui/button';
|
import { Button } from '@/shared/ui/button';
|
||||||
import { useUserLogin } from '@/shared/zustand/userLogin';
|
|
||||||
import {
|
import {
|
||||||
|
NavigationMenu,
|
||||||
NavigationMenuContent,
|
NavigationMenuContent,
|
||||||
NavigationMenuItem,
|
NavigationMenuItem,
|
||||||
NavigationMenuLink,
|
NavigationMenuLink,
|
||||||
@@ -12,6 +12,8 @@ import SubMenuLink from './SubMenuLink';
|
|||||||
import { ChangeLang } from './ChangeLang';
|
import { ChangeLang } from './ChangeLang';
|
||||||
import { useLoginModal, useRegisterModal } from '@/shared/zustand/auth';
|
import { useLoginModal, useRegisterModal } from '@/shared/zustand/auth';
|
||||||
import { useTranslations } from 'next-intl';
|
import { useTranslations } from 'next-intl';
|
||||||
|
import { useUserPlagiatStore } from '@/shared/zustand/user';
|
||||||
|
// husky error
|
||||||
|
|
||||||
function AuthButtons() {
|
function AuthButtons() {
|
||||||
const t = useTranslations('Navbar');
|
const t = useTranslations('Navbar');
|
||||||
@@ -21,35 +23,46 @@ function AuthButtons() {
|
|||||||
signup: { title: t('signup'), url: '#' },
|
signup: { title: t('signup'), url: '#' },
|
||||||
};
|
};
|
||||||
|
|
||||||
const userItem = [
|
const userItem = [{ title: t('logout'), url: '#' }];
|
||||||
{ title: t('profile'), url: '/profile' },
|
|
||||||
{ title: t('logout'), url: '#' },
|
|
||||||
];
|
|
||||||
|
|
||||||
const toggleLoginModal = useLoginModal((state) => state.toggleLoginModal);
|
const toggleLoginModal = useLoginModal((state) => state.toggleLoginModal);
|
||||||
const toggleRegisterModal = useRegisterModal(
|
const toggleRegisterModal = useRegisterModal(
|
||||||
(state) => state.toggleRegisterModal,
|
(state) => state.toggleRegisterModal,
|
||||||
);
|
);
|
||||||
const user = useUserLogin((state) => state.user);
|
const user = useUserPlagiatStore((state) => state.user);
|
||||||
|
console.log('Current user:', user);
|
||||||
|
|
||||||
if (user) {
|
if (user) {
|
||||||
return (
|
return (
|
||||||
<NavigationMenuItem>
|
<div className="flex flex-row max-sm:items-center max-sm:justify-around gap-3">
|
||||||
<NavigationMenuTrigger>{user.name}</NavigationMenuTrigger>
|
<div className="sm:flex hidden">
|
||||||
<NavigationMenuContent className="bg-popover text-popover-foreground">
|
<ChangeLang />
|
||||||
{userItem.map((subItem) => (
|
</div>
|
||||||
<NavigationMenuLink asChild key={subItem.title} className="w-80">
|
<NavigationMenu>
|
||||||
<SubMenuLink item={subItem} />
|
<NavigationMenuItem>
|
||||||
</NavigationMenuLink>
|
<NavigationMenuTrigger className="text-xl">
|
||||||
))}
|
{user.name} {user.surname}
|
||||||
</NavigationMenuContent>
|
</NavigationMenuTrigger>
|
||||||
</NavigationMenuItem>
|
<NavigationMenuContent className="bg-popover text-popover-foreground">
|
||||||
|
{userItem.map((subItem) => (
|
||||||
|
<NavigationMenuLink
|
||||||
|
asChild
|
||||||
|
key={subItem.title}
|
||||||
|
className="w-80"
|
||||||
|
>
|
||||||
|
<SubMenuLink item={subItem} />
|
||||||
|
</NavigationMenuLink>
|
||||||
|
))}
|
||||||
|
</NavigationMenuContent>
|
||||||
|
</NavigationMenuItem>
|
||||||
|
</NavigationMenu>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-row gap-3">
|
<div className="flex flex-row max-sm:items-center max-sm:justify-around gap-3">
|
||||||
<div className="flex">
|
<div className="sm:flex hidden">
|
||||||
<ChangeLang />
|
<ChangeLang />
|
||||||
</div>
|
</div>
|
||||||
<Button variant="outline" onClick={() => toggleLoginModal()}>
|
<Button variant="outline" onClick={() => toggleLoginModal()}>
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ const Navbar = () => {
|
|||||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 w-full">
|
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 w-full">
|
||||||
{/* Desktop Menu */}
|
{/* Desktop Menu */}
|
||||||
<nav className="justify-between flex max-sm:flex-col gap-5">
|
<nav className="justify-between flex max-sm:flex-col gap-5">
|
||||||
<div className="flex items-center gap-6">
|
<div className="flex items-center justify-between gap-6">
|
||||||
{/* Logo */}
|
{/* Logo */}
|
||||||
<Link
|
<Link
|
||||||
href={'/'}
|
href={'/'}
|
||||||
@@ -32,13 +32,9 @@ const Navbar = () => {
|
|||||||
>
|
>
|
||||||
{t('logo')}
|
{t('logo')}
|
||||||
</Link>
|
</Link>
|
||||||
{/* <div className="flex items-center">
|
<div className="flex sm:hidden">
|
||||||
<NavigationMenu>
|
<ChangeLang />
|
||||||
<NavigationMenuList>
|
</div>
|
||||||
{menu.map((item) => RenderMenuItem(item))}
|
|
||||||
</NavigationMenuList>
|
|
||||||
</NavigationMenu>
|
|
||||||
</div> */}
|
|
||||||
</div>
|
</div>
|
||||||
<AuthButtons />
|
<AuthButtons />
|
||||||
</nav>
|
</nav>
|
||||||
|
|||||||
Reference in New Issue
Block a user