@@ -12,6 +12,7 @@ import { ReactNode } from 'react';
|
|||||||
import { setRequestLocale } from 'next-intl/server';
|
import { setRequestLocale } from 'next-intl/server';
|
||||||
import QueryProvider from '@/shared/config/react-query/QueryProvider';
|
import QueryProvider from '@/shared/config/react-query/QueryProvider';
|
||||||
import Script from 'next/script';
|
import Script from 'next/script';
|
||||||
|
import Provider from '@/features/providers/provider';
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: PRODUCT_INFO.name,
|
title: PRODUCT_INFO.name,
|
||||||
@@ -48,9 +49,11 @@ export default async function RootLayout({ children, params }: Props) {
|
|||||||
disableTransitionOnChange
|
disableTransitionOnChange
|
||||||
>
|
>
|
||||||
<QueryProvider>
|
<QueryProvider>
|
||||||
|
<Provider>
|
||||||
<Navbar />
|
<Navbar />
|
||||||
{children}
|
{children}
|
||||||
<Footer />
|
<Footer />
|
||||||
|
</Provider>
|
||||||
</QueryProvider>
|
</QueryProvider>
|
||||||
</ThemeProvider>
|
</ThemeProvider>
|
||||||
</NextIntlClientProvider>
|
</NextIntlClientProvider>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { getPosts } from '@/shared/config/api/testApi';
|
import { getPosts } from '@/shared/config/api/testApi';
|
||||||
import Welcome from '@/widgets/welcome';
|
import { PlagiarismCheckForm } from '@/widgets/fileUpload/ui/Plagiraismcheckform';
|
||||||
|
|
||||||
export default async function Home() {
|
export default async function Home() {
|
||||||
const res = await getPosts({ _limit: 1 });
|
const res = await getPosts({ _limit: 1 });
|
||||||
@@ -7,7 +7,7 @@ export default async function Home() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<Welcome />
|
<PlagiarismCheckForm />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,13 +0,0 @@
|
|||||||
export const formatPhone = (value: string) => {
|
|
||||||
if (value.length <= 2) return value;
|
|
||||||
if (value.length <= 5) return `${value.slice(0, 2)} ${value.slice(2)}`;
|
|
||||||
if (value.length <= 7)
|
|
||||||
return `${value.slice(0, 2)} ${value.slice(2, 5)} ${value.slice(5)}`;
|
|
||||||
return `${value.slice(0, 2)} ${value.slice(2, 5)} ${value.slice(
|
|
||||||
5,
|
|
||||||
7,
|
|
||||||
)} ${value.slice(7)}`;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const normalizeDigits = (value: string) =>
|
|
||||||
value.replace(/\D/g, '').slice(0, 9);
|
|
||||||
@@ -1 +1,3 @@
|
|||||||
// Ushbu fayl loyiha structurasi uchun qo'shilgan. O'chirib tashlasangiz bo'ladi
|
// Ushbu fayl loyiha structurasi uchun qo'shilgan. O'chirib tashlasangiz bo'ladi
|
||||||
|
export * from './useLoginForm';
|
||||||
|
export * from '../../../../shared/lib/formatPhone';
|
||||||
|
|||||||
@@ -1,14 +0,0 @@
|
|||||||
import { create } from 'zustand';
|
|
||||||
|
|
||||||
interface LoginModalStore {
|
|
||||||
openLoginModal: boolean;
|
|
||||||
toggleLoginModal: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
const useLoginModal = create<LoginModalStore>((set) => ({
|
|
||||||
openLoginModal: false,
|
|
||||||
toggleLoginModal: () =>
|
|
||||||
set((state) => ({ openLoginModal: !state.openLoginModal })),
|
|
||||||
}));
|
|
||||||
|
|
||||||
export { useLoginModal };
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
export { RegisterModal } from './model';
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
function RegisterModal() {
|
|
||||||
return <div></div>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export { RegisterModal };
|
|
||||||
@@ -1,56 +1,78 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useTranslations } from 'next-intl';
|
|
||||||
import { useCallback, useState } from 'react';
|
import { useCallback, useState } from 'react';
|
||||||
import { formatPhone, normalizeDigits } from '../lib/formatPhone';
|
|
||||||
import { Input } from '@/shared/ui/input';
|
|
||||||
import { Button } from '@/shared/ui/button';
|
|
||||||
import PhonePrefix from './phonePrefix';
|
|
||||||
import { useLoginForm } from '../lib/useLoginForm';
|
|
||||||
import { Label } from '@/shared/ui/label';
|
|
||||||
import { X } from 'lucide-react';
|
import { X } from 'lucide-react';
|
||||||
import { useLoginModal } from '../lib/togle';
|
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 { useLoginModal } from '@/shared/zustand/auth';
|
||||||
|
import Link from 'next/link';
|
||||||
|
|
||||||
// ============================= //
|
export function LoginForm() {
|
||||||
|
|
||||||
export default function LoginForm() {
|
|
||||||
const t = useTranslations();
|
|
||||||
const [isFocused, setIsFocused] = useState(false);
|
const [isFocused, setIsFocused] = useState(false);
|
||||||
|
|
||||||
// ========== Handlers ========== //
|
const { phone, setPhone, submit, error, loading } = useLoginForm();
|
||||||
|
const toggleLoginModal = useLoginModal((state) => state.toggleLoginModal);
|
||||||
|
|
||||||
const handlePhoneChange = useCallback(
|
const handlePhoneChange = useCallback(
|
||||||
(e: React.ChangeEvent<HTMLInputElement>) => {
|
(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
setPhone(normalizeDigits(e.target.value));
|
setPhone(normalizeDigits(e.target.value));
|
||||||
},
|
},
|
||||||
[],
|
[setPhone],
|
||||||
);
|
);
|
||||||
|
|
||||||
// ============================== //
|
|
||||||
|
|
||||||
const { phone, setPhone, submit, error, loading } = useLoginForm();
|
|
||||||
const toggleLoginModal = useLoginModal((state) => state.toggleLoginModal);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<>
|
||||||
<header className="w-full " onClick={toggleLoginModal}>
|
{/* Backdrop */}
|
||||||
<X />
|
<div
|
||||||
</header>
|
className="fixed inset-0 z-10 bg-black/40 backdrop-blur-sm"
|
||||||
<form onSubmit={submit} className="space-y-6 text-center">
|
onClick={toggleLoginModal}
|
||||||
{/* PHONE FIELD */}
|
/>
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="phone" className="text-sm font-medium">
|
{/* Modal */}
|
||||||
{t('auth.login_phone')}
|
<div className="fixed inset-0 z-20 flex items-center justify-center pointer-events-none px-6">
|
||||||
</Label>
|
<MotionWrapper>
|
||||||
|
<div className="pointer-events-auto w-full max-w-sm rounded border border-stone-200 bg-white px-8 pb-8 pt-10 shadow-sm">
|
||||||
|
{/* Close */}
|
||||||
|
<div className="flex justify-end -mt-2 mb-4">
|
||||||
|
<button
|
||||||
|
onClick={toggleLoginModal}
|
||||||
|
className="flex h-7 w-7 items-center justify-center rounded-sm text-stone-400 transition-colors hover:bg-stone-100 hover:text-stone-700"
|
||||||
|
>
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Header */}
|
||||||
|
<p className="mb-1 text-[0.65rem] font-semibold uppercase tracking-[0.16em] text-stone-400">
|
||||||
|
Xush kelibsiz
|
||||||
|
</p>
|
||||||
|
<h1 className="mb-1 font-serif text-3xl leading-tight text-stone-900">
|
||||||
|
Kirish
|
||||||
|
</h1>
|
||||||
|
<p className="mb-8 text-sm text-stone-400">
|
||||||
|
Telefon raqamingizni kiriting.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<form onSubmit={submit} noValidate className="flex flex-col gap-5">
|
||||||
|
{/* Phone field */}
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<label
|
||||||
|
htmlFor="phone"
|
||||||
|
className="text-[0.7rem] font-medium tracking-widest uppercase text-stone-500"
|
||||||
|
>
|
||||||
|
Telefon raqam
|
||||||
|
</label>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
className={`relative transition-all duration-300 ${
|
className={`relative transition-transform duration-200 ${isFocused ? 'scale-[1.01]' : ''}`}
|
||||||
isFocused ? 'scale-[1.02]' : ''
|
|
||||||
}`}
|
|
||||||
>
|
>
|
||||||
<PhonePrefix isFocused={isFocused} />
|
<PhonePrefix isFocused={isFocused} />
|
||||||
|
<input
|
||||||
<Input
|
|
||||||
id="phone"
|
id="phone"
|
||||||
type="tel"
|
type="tel"
|
||||||
placeholder="90 123 45 67"
|
placeholder="90 123 45 67"
|
||||||
@@ -58,62 +80,86 @@ export default function LoginForm() {
|
|||||||
onChange={handlePhoneChange}
|
onChange={handlePhoneChange}
|
||||||
onFocus={() => setIsFocused(true)}
|
onFocus={() => setIsFocused(true)}
|
||||||
onBlur={() => setIsFocused(false)}
|
onBlur={() => setIsFocused(false)}
|
||||||
className={`pl-30 h-12 text-base font-medium border-2 transition-all ${
|
className={`
|
||||||
isFocused
|
w-full rounded-sm border bg-stone-50 py-2.5 pl-30 pr-3.5
|
||||||
? 'border-kok shadow-md shadow-kok/20 bg-kok/5'
|
text-[0.95rem] font-medium text-stone-900 outline-none
|
||||||
: 'border-border hover:border-kok/50'
|
placeholder:text-stone-300 transition-all duration-150
|
||||||
} ${error && 'border-destructive'}`}
|
${
|
||||||
|
error
|
||||||
|
? 'border-red-400 ring-2 ring-red-200/40'
|
||||||
|
: isFocused
|
||||||
|
? 'border-stone-400 ring-2 ring-stone-300/30'
|
||||||
|
: 'border-stone-200 hover:border-stone-300'
|
||||||
|
}
|
||||||
|
`}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Helper text */}
|
{/* Digit counter / complete hint */}
|
||||||
<div className="flex justify-between items-center px-1">
|
<div className="flex items-center justify-between px-0.5">
|
||||||
<p className="text-xs text-muted-foreground">
|
<span className="text-[0.7rem] text-stone-400">
|
||||||
{phone.length > 0 &&
|
{phone.length > 0 && `${phone.length} ta raqam kiritildi`}
|
||||||
t('auth.entered_digits', { count: phone.length })}
|
</span>
|
||||||
</p>
|
|
||||||
|
|
||||||
{phone.length === 9 && (
|
{phone.length === 9 && (
|
||||||
<span className="text-xs text-green-600 font-medium slide-in-from-right-2">
|
<span className="text-[0.7rem] font-medium text-emerald-600">
|
||||||
✓ {t('auth.full')}
|
✓ To‘liq
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Error */}
|
||||||
{error && (
|
{error && (
|
||||||
<div className="text-sm text-destructive bg-destructive/10 p-3 rounded-md border border-destructive/20 animate-in">
|
<div className="rounded-sm border border-red-200 bg-red-50 px-3.5 py-2.5 text-[0.8rem] text-red-500">
|
||||||
{error}
|
{error}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Button
|
{/* Submit */}
|
||||||
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={loading || phone.length !== 9}
|
disabled={loading || phone.length !== 9}
|
||||||
className="w-full h-12 mt-5 bg-linear-to-r from-blue-600 to-indigo-600 hover:scale-[1.02] text-base font-semibold transition-all shadow-lg hover:shadow-xl active:scale-95"
|
className="
|
||||||
|
mt-1 w-full rounded-sm bg-stone-900 py-3
|
||||||
|
text-[0.82rem] font-semibold uppercase tracking-widest text-stone-100
|
||||||
|
transition-all duration-150
|
||||||
|
hover:bg-stone-800 active:scale-[0.99]
|
||||||
|
disabled:cursor-not-allowed disabled:opacity-40
|
||||||
|
"
|
||||||
>
|
>
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<span className="flex items-center gap-2">
|
<span className="flex items-center justify-center gap-2">
|
||||||
<span className="h-4 w-4 border-2 border-white/30 border-t-white rounded-full animate-spin" />
|
<span className="h-3.5 w-3.5 rounded-full border-2 border-stone-500 border-t-stone-100 animate-spin" />
|
||||||
{t('auth.otp_sending')}
|
Yuborilmoqda…
|
||||||
</span>
|
</span>
|
||||||
) : (
|
) : (
|
||||||
t('auth.otp')
|
'Kodni yuborish'
|
||||||
)}
|
)}
|
||||||
</Button>
|
</button>
|
||||||
|
|
||||||
{/* DIVIDER */}
|
{/* Divider */}
|
||||||
<div className="relative">
|
<div className="relative flex items-center gap-3 py-1">
|
||||||
<div className="absolute inset-0 flex items-center">
|
<span className="h-px flex-1 bg-stone-200" />
|
||||||
<span className="w-full border-t border-border" />
|
<span className="text-[0.65rem] font-medium uppercase tracking-widest text-stone-400">
|
||||||
</div>
|
yoki
|
||||||
<div className="relative flex justify-center text-xs uppercase">
|
|
||||||
<span className="bg-background px-2 text-muted-foreground">
|
|
||||||
{t('auth.or_continue')}
|
|
||||||
</span>
|
</span>
|
||||||
|
<span className="h-px flex-1 bg-stone-200" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
{/* Register hint */}
|
||||||
|
<p className="text-center text-[0.78rem] text-stone-400">
|
||||||
|
Hisobingiz yo‘qmi?
|
||||||
|
<Link
|
||||||
|
href="/register"
|
||||||
|
className="text-stone-800 underline underline-offset-2 hover:text-stone-600 transition-colors"
|
||||||
|
>
|
||||||
|
Ro‘yxatdan o‘tish
|
||||||
|
</Link>
|
||||||
|
</p>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
</MotionWrapper>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,2 +1,2 @@
|
|||||||
// Ushbu fayl loyiha structurasi uchun qo'shilgan. O'chirib tashlasangiz bo'ladi
|
// Ushbu fayl loyiha structurasi uchun qo'shilgan. O'chirib tashlasangiz bo'ladi
|
||||||
export { MotionWrapper } from './motion';
|
export { LoginForm } from './form';
|
||||||
|
|||||||
21
src/features/auth/model.tsx
Normal file
21
src/features/auth/model.tsx
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
'use client';
|
||||||
|
import { AnimatePresence } from 'framer-motion';
|
||||||
|
import { LoginForm } from './login/ui';
|
||||||
|
import { useLoginModal, useRegisterModal } from '@/shared/zustand/auth';
|
||||||
|
import { RegisterForm } from './register/ui';
|
||||||
|
function AuthModals() {
|
||||||
|
const openLoginModal = useLoginModal((state) => state.openLoginModal);
|
||||||
|
const openRegisterModal = useRegisterModal(
|
||||||
|
(state) => state.openRegisterModal,
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<AnimatePresence>
|
||||||
|
{openLoginModal && <LoginForm />}{' '}
|
||||||
|
{openRegisterModal && <RegisterForm />}
|
||||||
|
</AnimatePresence>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export { AuthModals };
|
||||||
41
src/features/auth/register/lib/registerZustand.ts
Normal file
41
src/features/auth/register/lib/registerZustand.ts
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
import { create } from 'zustand';
|
||||||
|
|
||||||
|
export interface Registertype {
|
||||||
|
name: string;
|
||||||
|
surname: string;
|
||||||
|
phone: string;
|
||||||
|
oferta?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RegisterZustandType {
|
||||||
|
registerData: Registertype;
|
||||||
|
setItem: (key: keyof Registertype, value: string) => void;
|
||||||
|
setOferta: (value: boolean) => void;
|
||||||
|
setRegisterData: (data: Registertype) => void;
|
||||||
|
clearRegisterData: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const INITIAL: Registertype = {
|
||||||
|
name: '',
|
||||||
|
surname: '',
|
||||||
|
phone: '',
|
||||||
|
oferta: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useRegisterZustand = create<RegisterZustandType>((set) => ({
|
||||||
|
registerData: INITIAL,
|
||||||
|
|
||||||
|
setItem: (key, value) =>
|
||||||
|
set((state) => ({
|
||||||
|
registerData: { ...state.registerData, [key]: value },
|
||||||
|
})),
|
||||||
|
|
||||||
|
setOferta: (value) =>
|
||||||
|
set((state) => ({
|
||||||
|
registerData: { ...state.registerData, oferta: value },
|
||||||
|
})),
|
||||||
|
|
||||||
|
setRegisterData: (data) => set({ registerData: data }),
|
||||||
|
|
||||||
|
clearRegisterData: () => set({ registerData: INITIAL }),
|
||||||
|
}));
|
||||||
64
src/features/auth/register/lib/useRegisterForm.ts
Normal file
64
src/features/auth/register/lib/useRegisterForm.ts
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
import { useCallback, useState } from 'react';
|
||||||
|
import { useRegisterZustand } from './registerZustand';
|
||||||
|
import { validateRegister, RegisterErrors } from './validateRegister';
|
||||||
|
|
||||||
|
export function useRegisterForm() {
|
||||||
|
const { registerData, setItem, setOferta, clearRegisterData } =
|
||||||
|
useRegisterZustand();
|
||||||
|
const [errors, setErrors] = useState<RegisterErrors>({});
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [success, setSuccess] = useState(false);
|
||||||
|
|
||||||
|
const handleChange = useCallback(
|
||||||
|
(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const { name, value } = e.target;
|
||||||
|
setItem(name as keyof typeof registerData, value);
|
||||||
|
// clear the error for this field on change
|
||||||
|
setErrors((prev) => ({ ...prev, [name]: undefined }));
|
||||||
|
},
|
||||||
|
[setItem],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleOferta = useCallback(
|
||||||
|
(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
setOferta(e.target.checked);
|
||||||
|
setErrors((prev) => ({ ...prev, oferta: undefined }));
|
||||||
|
},
|
||||||
|
[setOferta],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleSubmit = useCallback(
|
||||||
|
async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const validationErrors = validateRegister(registerData);
|
||||||
|
if (Object.keys(validationErrors).length > 0) {
|
||||||
|
setErrors(validationErrors);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
// Replace with your real API call:
|
||||||
|
// await api.post('/register', registerData);
|
||||||
|
await new Promise((r) => setTimeout(r, 1200)); // simulated delay
|
||||||
|
setSuccess(true);
|
||||||
|
clearRegisterData();
|
||||||
|
} catch {
|
||||||
|
setErrors({ name: 'Something went wrong. Please try again.' });
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[registerData, clearRegisterData],
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
registerData,
|
||||||
|
errors,
|
||||||
|
loading,
|
||||||
|
success,
|
||||||
|
handleChange,
|
||||||
|
handleOferta,
|
||||||
|
handleSubmit,
|
||||||
|
};
|
||||||
|
}
|
||||||
40
src/features/auth/register/lib/validateRegister.ts
Normal file
40
src/features/auth/register/lib/validateRegister.ts
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
export interface RegisterErrors {
|
||||||
|
name?: string;
|
||||||
|
surname?: string;
|
||||||
|
phone?: string;
|
||||||
|
oferta?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function validateRegister(data: {
|
||||||
|
name: string;
|
||||||
|
surname: string;
|
||||||
|
phone: string;
|
||||||
|
oferta?: boolean;
|
||||||
|
}): RegisterErrors {
|
||||||
|
const errors: RegisterErrors = {};
|
||||||
|
|
||||||
|
if (!data.name.trim()) {
|
||||||
|
errors.name = 'Name is required';
|
||||||
|
} else if (data.name.trim().length < 2) {
|
||||||
|
errors.name = 'Name must be at least 2 characters';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!data.surname.trim()) {
|
||||||
|
errors.surname = 'Surname is required';
|
||||||
|
} else if (data.surname.trim().length < 2) {
|
||||||
|
errors.surname = 'Surname must be at least 2 characters';
|
||||||
|
}
|
||||||
|
|
||||||
|
const digits = data.phone.replace(/\D/g, '');
|
||||||
|
if (!digits) {
|
||||||
|
errors.phone = 'Phone is required';
|
||||||
|
} else if (digits.length !== 9) {
|
||||||
|
errors.phone = 'Enter a valid 9-digit phone number';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!data.oferta) {
|
||||||
|
errors.oferta = 'You must accept the terms to continue';
|
||||||
|
}
|
||||||
|
|
||||||
|
return errors;
|
||||||
|
}
|
||||||
249
src/features/auth/register/ui/form.tsx
Normal file
249
src/features/auth/register/ui/form.tsx
Normal file
@@ -0,0 +1,249 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import React, { useCallback } from 'react';
|
||||||
|
import { useRegisterForm } from '../lib/useRegisterForm';
|
||||||
|
import { formatPhone, normalizeDigits } from '@/shared/lib/formatPhone';
|
||||||
|
import PhonePrefix from '@/shared/ui/phonePrefix';
|
||||||
|
|
||||||
|
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() {
|
||||||
|
const {
|
||||||
|
registerData,
|
||||||
|
errors,
|
||||||
|
loading,
|
||||||
|
success,
|
||||||
|
handleChange,
|
||||||
|
handleOferta,
|
||||||
|
handleSubmit,
|
||||||
|
} = useRegisterForm();
|
||||||
|
const [phone, setPhone] = React.useState(registerData.phone || '');
|
||||||
|
const [isFocused, setIsFocused] = React.useState(false);
|
||||||
|
const handlePhoneChange = useCallback(
|
||||||
|
(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
setPhone(normalizeDigits(e.target.value));
|
||||||
|
},
|
||||||
|
[setPhone],
|
||||||
|
);
|
||||||
|
|
||||||
|
// ── Success state ──────────────────────────────────────
|
||||||
|
if (success) {
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-screen items-center justify-center bg-stone-100 p-8">
|
||||||
|
<div className="w-full max-w-sm rounded border border-stone-200 bg-white px-10 py-16 text-center shadow-sm">
|
||||||
|
<div className="mx-auto mb-5 flex h-14 w-14 items-center justify-center rounded-full bg-emerald-50 text-2xl text-emerald-600">
|
||||||
|
✓
|
||||||
|
</div>
|
||||||
|
<h2 className="mb-1 font-serif text-2xl text-stone-900">
|
||||||
|
You‘re registered
|
||||||
|
</h2>
|
||||||
|
<p className="text-sm text-stone-400">
|
||||||
|
We‘ll be in touch shortly.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Form ───────────────────────────────────────────────
|
||||||
|
return (
|
||||||
|
<div className="w-full">
|
||||||
|
{/* Header */}
|
||||||
|
<p className="mb-1 text-[0.65rem] font-semibold uppercase tracking-[0.16em] text-stone-400">
|
||||||
|
Create account
|
||||||
|
</p>
|
||||||
|
<h1 className="mb-1 font-serif text-3xl leading-tight text-stone-900">
|
||||||
|
Register
|
||||||
|
</h1>
|
||||||
|
<p className="mb-8 text-sm text-stone-400">
|
||||||
|
Fill in your details to get started.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit} noValidate className="flex flex-col gap-5">
|
||||||
|
{/* Name + Surname row */}
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<Field
|
||||||
|
id="name"
|
||||||
|
name="name"
|
||||||
|
label="Name"
|
||||||
|
placeholder="Ali"
|
||||||
|
value={registerData.name}
|
||||||
|
onChange={handleChange}
|
||||||
|
error={errors.name}
|
||||||
|
/>
|
||||||
|
<Field
|
||||||
|
id="surname"
|
||||||
|
name="surname"
|
||||||
|
label="Surname"
|
||||||
|
placeholder="Karimov"
|
||||||
|
value={registerData.surname}
|
||||||
|
onChange={handleChange}
|
||||||
|
error={errors.surname}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Phone */}
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
htmlFor="phone"
|
||||||
|
className="text-[0.7rem] font-medium tracking-widest uppercase text-stone-500"
|
||||||
|
>
|
||||||
|
Telefon raqam
|
||||||
|
</label>
|
||||||
|
<div
|
||||||
|
className={`relative transition-transform duration-200 ${isFocused ? 'scale-[1.01]' : ''}`}
|
||||||
|
>
|
||||||
|
<PhonePrefix isFocused={isFocused} />
|
||||||
|
<input
|
||||||
|
id="phone"
|
||||||
|
type="tel"
|
||||||
|
placeholder="90 123 45 67"
|
||||||
|
value={formatPhone(phone)}
|
||||||
|
onChange={handlePhoneChange}
|
||||||
|
onFocus={() => setIsFocused(true)}
|
||||||
|
onBlur={() => setIsFocused(false)}
|
||||||
|
className={`
|
||||||
|
w-full rounded-sm border bg-stone-50 py-2.5 pl-30 pr-3.5
|
||||||
|
text-[0.95rem] font-medium text-stone-900 outline-none
|
||||||
|
placeholder:text-stone-300 transition-all duration-150
|
||||||
|
${
|
||||||
|
isFocused
|
||||||
|
? 'border-stone-400 ring-2 ring-stone-300/30'
|
||||||
|
: 'border-stone-200 hover:border-stone-300'
|
||||||
|
}
|
||||||
|
`}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Digit counter / complete hint */}
|
||||||
|
<div className="flex items-center justify-between px-0.5">
|
||||||
|
<span className="text-[0.7rem] text-stone-400">
|
||||||
|
{phone.length > 0 && `${phone.length} ta raqam kiritildi`}
|
||||||
|
</span>
|
||||||
|
{phone.length === 9 && (
|
||||||
|
<span className="text-[0.7rem] font-medium text-emerald-600">
|
||||||
|
✓ To‘liq
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Terms checkbox */}
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<div className="flex items-start gap-2.5">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
id="oferta"
|
||||||
|
checked={!!registerData.oferta}
|
||||||
|
onChange={handleOferta}
|
||||||
|
className="mt-0.5 h-4 w-4 shrink-0 cursor-pointer accent-stone-700"
|
||||||
|
/>
|
||||||
|
<label
|
||||||
|
htmlFor="oferta"
|
||||||
|
className="cursor-pointer text-[0.78rem] leading-relaxed text-stone-500"
|
||||||
|
>
|
||||||
|
I agree to the
|
||||||
|
<a
|
||||||
|
href="/terms"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="text-stone-800 underline underline-offset-2 hover:text-stone-600"
|
||||||
|
>
|
||||||
|
Terms of Service
|
||||||
|
</a>
|
||||||
|
and
|
||||||
|
<a
|
||||||
|
href="/privacy"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="text-stone-800 underline underline-offset-2 hover:text-stone-600"
|
||||||
|
>
|
||||||
|
Privacy Policy
|
||||||
|
</a>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
{errors.oferta && (
|
||||||
|
<p className="text-[0.7rem] text-red-500">{errors.oferta}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Submit */}
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={loading}
|
||||||
|
className="
|
||||||
|
mt-1 w-full rounded-sm bg-stone-900 py-3
|
||||||
|
text-[0.82rem] font-semibold uppercase tracking-widest text-stone-100
|
||||||
|
transition-all duration-150
|
||||||
|
hover:bg-stone-800 active:scale-[0.99]
|
||||||
|
disabled:cursor-not-allowed disabled:opacity-40
|
||||||
|
"
|
||||||
|
>
|
||||||
|
{loading ? (
|
||||||
|
<span className="flex items-center justify-center gap-2">
|
||||||
|
<span className="h-3.5 w-3.5 rounded-full border-2 border-stone-500 border-t-stone-100 animate-spin" />
|
||||||
|
Submitting…
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
'Create account'
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
39
src/features/auth/register/ui/index.tsx
Normal file
39
src/features/auth/register/ui/index.tsx
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
import { MotionWrapper } from '@/shared/ui/motion';
|
||||||
|
import { useRegisterModal } from '@/shared/zustand/auth';
|
||||||
|
import { X } from 'lucide-react';
|
||||||
|
import React from 'react';
|
||||||
|
import { RegisterFormUI } from './form';
|
||||||
|
|
||||||
|
export function RegisterForm() {
|
||||||
|
const toggleRegisterModal = useRegisterModal(
|
||||||
|
(state) => state.toggleRegisterModal,
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{/* Backdrop */}
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 z-10 bg-black/40 backdrop-blur-sm"
|
||||||
|
onClick={toggleRegisterModal}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Modal */}
|
||||||
|
<div className="fixed inset-0 z-20 flex items-center justify-center pointer-events-none">
|
||||||
|
<MotionWrapper>
|
||||||
|
<div className="pointer-events-auto bg-background rounded-2xl shadow-2xl p-8 w-full max-w-sm mx-4">
|
||||||
|
{/* Close button */}
|
||||||
|
<div className="flex justify-end mb-4">
|
||||||
|
<button
|
||||||
|
onClick={toggleRegisterModal}
|
||||||
|
className="p-1 rounded-full hover:bg-muted transition-colors"
|
||||||
|
>
|
||||||
|
<X className="h-5 w-5 text-muted-foreground" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<RegisterFormUI />
|
||||||
|
</div>
|
||||||
|
</MotionWrapper>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
11
src/features/providers/provider.tsx
Normal file
11
src/features/providers/provider.tsx
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { AuthModals } from '../auth/model';
|
||||||
|
|
||||||
|
export default function Provider({ children }: { children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<AuthModals />
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
41
src/request/plagiarismapi.ts
Normal file
41
src/request/plagiarismapi.ts
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
// ─── Constants ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
import {
|
||||||
|
PlagiarismSubmissionPayload,
|
||||||
|
PlagiarismSubmissionResponse,
|
||||||
|
} from '@/widgets/fileUpload/lib/types';
|
||||||
|
|
||||||
|
const API_BASE_URL = process.env.VITE_API_BASE_URL ?? '/api';
|
||||||
|
const ENDPOINT = `${API_BASE_URL}/plagiarism/submit`;
|
||||||
|
|
||||||
|
// ─── API Function ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Submits a document for plagiarism checking.
|
||||||
|
* Sends a multipart/form-data request to the backend API.
|
||||||
|
*/
|
||||||
|
export async function submitPlagiarismCheck(
|
||||||
|
payload: PlagiarismSubmissionPayload,
|
||||||
|
): Promise<PlagiarismSubmissionResponse> {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('topic', payload.topic);
|
||||||
|
formData.append('senderFullName', payload.senderFullName);
|
||||||
|
formData.append('file', payload.file);
|
||||||
|
formData.append('withCertificate', String(payload.withCertificate));
|
||||||
|
|
||||||
|
const response = await fetch(ENDPOINT, {
|
||||||
|
method: 'POST',
|
||||||
|
body: formData,
|
||||||
|
// Do NOT set Content-Type manually — the browser sets it with the boundary
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorBody = await response.json().catch(() => ({}));
|
||||||
|
throw new Error(
|
||||||
|
(errorBody as { message?: string }).message ??
|
||||||
|
`Request failed with status ${response.status}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.json() as Promise<PlagiarismSubmissionResponse>;
|
||||||
|
}
|
||||||
@@ -1,38 +1,14 @@
|
|||||||
/**
|
|
||||||
* Format the number (+998 00 111-22-33)
|
|
||||||
* @param value Number to be formatted
|
|
||||||
* @returns string +998 00 111-22-33
|
|
||||||
*/
|
|
||||||
const formatPhone = (value: string) => {
|
const formatPhone = (value: string) => {
|
||||||
// Keep only numbers
|
if (value.length <= 2) return value;
|
||||||
const digits = value.replace(/\D/g, '');
|
if (value.length <= 5) return `${value.slice(0, 2)} ${value.slice(2)}`;
|
||||||
|
if (value.length <= 7)
|
||||||
// Return empty string if data is not available
|
return `${value.slice(0, 2)} ${value.slice(2, 5)} ${value.slice(5)}`;
|
||||||
if (digits.length === 0) {
|
return `${value.slice(0, 2)} ${value.slice(2, 5)} ${value.slice(
|
||||||
return '';
|
5,
|
||||||
}
|
7,
|
||||||
|
)} ${value.slice(7)}`;
|
||||||
const prefix = digits.startsWith('998') ? '+998 ' : '+998 ';
|
|
||||||
|
|
||||||
let formattedNumber = prefix;
|
|
||||||
|
|
||||||
if (digits.length > 3) {
|
|
||||||
formattedNumber += digits.slice(3, 5);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (digits.length > 5) {
|
|
||||||
formattedNumber += ' ' + digits.slice(5, 8);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (digits.length > 8) {
|
|
||||||
formattedNumber += '-' + digits.slice(8, 10);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (digits.length > 10) {
|
|
||||||
formattedNumber += '-' + digits.slice(10, 12);
|
|
||||||
}
|
|
||||||
|
|
||||||
return formattedNumber.trim();
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export default formatPhone;
|
const normalizeDigits = (value: string) => value.replace(/\D/g, '').slice(0, 9);
|
||||||
|
|
||||||
|
export { formatPhone, normalizeDigits };
|
||||||
|
|||||||
@@ -6,9 +6,10 @@ import { ReactNode } from 'react';
|
|||||||
function MotionWrapper({ children }: { children: ReactNode }) {
|
function MotionWrapper({ children }: { children: ReactNode }) {
|
||||||
return (
|
return (
|
||||||
<motion.div
|
<motion.div
|
||||||
initial={{ opacity: 0, x: -50 }}
|
initial={{ opacity: 0, scale: 0.95, y: 10 }}
|
||||||
animate={{ opacity: 1, x: 0 }}
|
animate={{ opacity: 1, scale: 1, y: 0 }}
|
||||||
transition={{ duration: 0.5 }}
|
exit={{ opacity: 0, scale: 0.95, y: 10 }}
|
||||||
|
transition={{ duration: 0.2, ease: 'easeOut' }}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
</motion.div>
|
</motion.div>
|
||||||
26
src/shared/zustand/auth.ts
Normal file
26
src/shared/zustand/auth.ts
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
import { create } from 'zustand';
|
||||||
|
|
||||||
|
interface LoginModalStore {
|
||||||
|
openLoginModal: boolean;
|
||||||
|
toggleLoginModal: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const useLoginModal = create<LoginModalStore>((set) => ({
|
||||||
|
openLoginModal: false,
|
||||||
|
toggleLoginModal: () =>
|
||||||
|
set((state) => ({ openLoginModal: !state.openLoginModal })),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// REGISTER MODAL STORE ====================== ////////
|
||||||
|
interface RegisterModalStore {
|
||||||
|
openRegisterModal: boolean;
|
||||||
|
toggleRegisterModal: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const useRegisterModal = create<RegisterModalStore>((set) => ({
|
||||||
|
openRegisterModal: false,
|
||||||
|
toggleRegisterModal: () =>
|
||||||
|
set((state) => ({ openRegisterModal: !state.openRegisterModal })),
|
||||||
|
}));
|
||||||
|
|
||||||
|
export { useLoginModal, useRegisterModal };
|
||||||
0
src/widgets/fileUpload/index.ts
Normal file
0
src/widgets/fileUpload/index.ts
Normal file
44
src/widgets/fileUpload/lib/types.ts
Normal file
44
src/widgets/fileUpload/lib/types.ts
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
// ─── Domain Types ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface User {
|
||||||
|
id: string;
|
||||||
|
firstName: string;
|
||||||
|
lastName: string;
|
||||||
|
email: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PlagiarismSubmissionPayload {
|
||||||
|
topic: string;
|
||||||
|
senderFullName: string;
|
||||||
|
file: File;
|
||||||
|
withCertificate: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PlagiarismSubmissionResponse {
|
||||||
|
submissionId: string;
|
||||||
|
status: 'pending' | 'processing' | 'completed' | 'failed';
|
||||||
|
message: string;
|
||||||
|
certificateUrl?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Form State Types ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface PlagiarismFormState {
|
||||||
|
topic: string;
|
||||||
|
file: File | null;
|
||||||
|
withCertificate: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type PlagiarismFormErrors = Partial<
|
||||||
|
Record<keyof PlagiarismFormState, string>
|
||||||
|
>;
|
||||||
|
|
||||||
|
// ─── UI State Types ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export type SubmissionStatus = 'idle' | 'loading' | 'success' | 'error';
|
||||||
|
|
||||||
|
export interface SubmissionState {
|
||||||
|
status: SubmissionStatus;
|
||||||
|
response: PlagiarismSubmissionResponse | null;
|
||||||
|
error: string | null;
|
||||||
|
}
|
||||||
115
src/widgets/fileUpload/lib/usePlagiraism.ts
Normal file
115
src/widgets/fileUpload/lib/usePlagiraism.ts
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
'use client';
|
||||||
|
import { useState, useCallback } from 'react';
|
||||||
|
import {
|
||||||
|
PlagiarismFormErrors,
|
||||||
|
PlagiarismFormState,
|
||||||
|
SubmissionState,
|
||||||
|
} from './types';
|
||||||
|
import { selectFullName, useUserStore } from './userStore';
|
||||||
|
import { isFormValid, validatePlagiarismForm } from './validation';
|
||||||
|
import { submitPlagiarismCheck } from '@/request/plagiarismapi';
|
||||||
|
|
||||||
|
// ─── Initial States ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const INITIAL_FORM: PlagiarismFormState = {
|
||||||
|
topic: '',
|
||||||
|
file: null,
|
||||||
|
withCertificate: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
const INITIAL_SUBMISSION: SubmissionState = {
|
||||||
|
status: 'idle',
|
||||||
|
response: null,
|
||||||
|
error: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
// ─── Hook ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export function usePlagiarismForm() {
|
||||||
|
const senderFullName = useUserStore(selectFullName);
|
||||||
|
|
||||||
|
const [form, setForm] = useState<PlagiarismFormState>(INITIAL_FORM);
|
||||||
|
const [errors, setErrors] = useState<PlagiarismFormErrors>({});
|
||||||
|
const [isPaymentOpen, setIsPaymentOpen] = useState(false);
|
||||||
|
const [submission, setSubmission] =
|
||||||
|
useState<SubmissionState>(INITIAL_SUBMISSION);
|
||||||
|
|
||||||
|
// ── Field updaters ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const setTopic = useCallback((topic: string) => {
|
||||||
|
setForm((prev) => ({ ...prev, topic }));
|
||||||
|
setErrors((prev) => ({ ...prev, topic: undefined }));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const setFile = useCallback((file: File | null) => {
|
||||||
|
setForm((prev) => ({ ...prev, file }));
|
||||||
|
setErrors((prev) => ({ ...prev, file: undefined }));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const toggleCertificate = useCallback(() => {
|
||||||
|
setForm((prev) => ({ ...prev, withCertificate: !prev.withCertificate }));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// ── Submission ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// 1. Wrap the form's onSubmit to intercept the event properly
|
||||||
|
const handleSubmitWithModal = useCallback(
|
||||||
|
async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
// Run validation first
|
||||||
|
const validationErrors = validatePlagiarismForm(form);
|
||||||
|
if (!isFormValid(validationErrors)) {
|
||||||
|
setErrors(validationErrors);
|
||||||
|
return; // Don't open modal if invalid
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validation passed → open the payment modal
|
||||||
|
setIsPaymentOpen(true);
|
||||||
|
},
|
||||||
|
[form],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleSubmit = useCallback(async () => {
|
||||||
|
setSubmission({ status: 'loading', response: null, error: null });
|
||||||
|
try {
|
||||||
|
const response = await submitPlagiarismCheck({
|
||||||
|
topic: form.topic.trim(),
|
||||||
|
senderFullName,
|
||||||
|
file: form.file!,
|
||||||
|
withCertificate: form.withCertificate,
|
||||||
|
});
|
||||||
|
setSubmission({ status: 'success', response, error: null });
|
||||||
|
setForm(INITIAL_FORM);
|
||||||
|
setIsPaymentOpen(false); // Close modal on success
|
||||||
|
} catch (err) {
|
||||||
|
const message =
|
||||||
|
err instanceof Error ? err.message : 'An unexpected error occurred.';
|
||||||
|
setSubmission({ status: 'error', response: null, error: message });
|
||||||
|
}
|
||||||
|
}, [form, senderFullName]);
|
||||||
|
|
||||||
|
const resetSubmission = useCallback(() => {
|
||||||
|
setSubmission(INITIAL_SUBMISSION);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// ── Derived state ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const isLoading = submission.status === 'loading';
|
||||||
|
|
||||||
|
return {
|
||||||
|
form,
|
||||||
|
errors,
|
||||||
|
submission,
|
||||||
|
senderFullName,
|
||||||
|
isLoading,
|
||||||
|
setTopic,
|
||||||
|
setFile,
|
||||||
|
toggleCertificate,
|
||||||
|
handleSubmit,
|
||||||
|
resetSubmission,
|
||||||
|
handleSubmitWithModal,
|
||||||
|
setIsPaymentOpen,
|
||||||
|
isPaymentOpen,
|
||||||
|
};
|
||||||
|
}
|
||||||
33
src/widgets/fileUpload/lib/userStore.ts
Normal file
33
src/widgets/fileUpload/lib/userStore.ts
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
import { create } from 'zustand';
|
||||||
|
import { User } from './types';
|
||||||
|
|
||||||
|
// ─── Store Interface ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
interface UserStore {
|
||||||
|
user: User | null;
|
||||||
|
isAuthenticated: boolean;
|
||||||
|
setUser: (user: User) => void;
|
||||||
|
clearUser: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Store Implementation ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export const useUserStore = create<UserStore>((set) => ({
|
||||||
|
// Mock: pre-populated as if the user is already logged in
|
||||||
|
user: {
|
||||||
|
id: 'usr_01',
|
||||||
|
firstName: 'Amir',
|
||||||
|
lastName: 'Karimov',
|
||||||
|
email: 'amir.karimov@example.com',
|
||||||
|
},
|
||||||
|
isAuthenticated: true,
|
||||||
|
|
||||||
|
setUser: (user) => set({ user, isAuthenticated: true }),
|
||||||
|
clearUser: () => set({ user: null, isAuthenticated: false }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// ─── Selectors ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export const selectUser = (state: UserStore) => state.user;
|
||||||
|
export const selectFullName = (state: UserStore): string =>
|
||||||
|
state.user ? `${state.user.firstName} ${state.user.lastName}` : '';
|
||||||
50
src/widgets/fileUpload/lib/validation.ts
Normal file
50
src/widgets/fileUpload/lib/validation.ts
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
// ─── Constants ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
import { PlagiarismFormErrors, PlagiarismFormState } from './types';
|
||||||
|
|
||||||
|
const MAX_FILE_SIZE_MB = 20;
|
||||||
|
const MAX_FILE_SIZE_BYTES = MAX_FILE_SIZE_MB * 1024 * 1024;
|
||||||
|
|
||||||
|
const ALLOWED_MIME_TYPES = [
|
||||||
|
'application/pdf',
|
||||||
|
'application/msword',
|
||||||
|
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
||||||
|
'text/plain',
|
||||||
|
];
|
||||||
|
|
||||||
|
const ALLOWED_EXTENSIONS = '.pdf, .doc, .docx, .txt';
|
||||||
|
|
||||||
|
// ─── Validator ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export function validatePlagiarismForm(
|
||||||
|
state: PlagiarismFormState,
|
||||||
|
): PlagiarismFormErrors {
|
||||||
|
const errors: PlagiarismFormErrors = {};
|
||||||
|
|
||||||
|
// Topic validation
|
||||||
|
const trimmedTopic = state.topic.trim();
|
||||||
|
if (!trimmedTopic) {
|
||||||
|
errors.topic = 'Topic is required.';
|
||||||
|
} else if (trimmedTopic.length < 3) {
|
||||||
|
errors.topic = 'Topic must be at least 3 characters.';
|
||||||
|
} else if (trimmedTopic.length > 200) {
|
||||||
|
errors.topic = 'Topic must not exceed 200 characters.';
|
||||||
|
}
|
||||||
|
|
||||||
|
// File validation
|
||||||
|
if (!state.file) {
|
||||||
|
errors.file = 'Please upload a document.';
|
||||||
|
} else {
|
||||||
|
if (state.file.size > MAX_FILE_SIZE_BYTES) {
|
||||||
|
errors.file = `File size must not exceed ${MAX_FILE_SIZE_MB} MB.`;
|
||||||
|
} else if (!ALLOWED_MIME_TYPES.includes(state.file.type)) {
|
||||||
|
errors.file = `Unsupported file type. Allowed: ${ALLOWED_EXTENSIONS}.`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return errors;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isFormValid(errors: PlagiarismFormErrors): boolean {
|
||||||
|
return Object.keys(errors).length === 0;
|
||||||
|
}
|
||||||
181
src/widgets/fileUpload/ui/Plagiraismcheckform.tsx
Normal file
181
src/widgets/fileUpload/ui/Plagiraismcheckform.tsx
Normal file
@@ -0,0 +1,181 @@
|
|||||||
|
'use client';
|
||||||
|
import React from 'react';
|
||||||
|
import {
|
||||||
|
FieldWrapper,
|
||||||
|
TextInput,
|
||||||
|
ReadonlyField,
|
||||||
|
FileUploadField,
|
||||||
|
CertificateCheckbox,
|
||||||
|
SubmitButton,
|
||||||
|
StatusBanner,
|
||||||
|
} from './Plagiraismui';
|
||||||
|
import { usePlagiarismForm } from '../lib/usePlagiraism';
|
||||||
|
import { PaymentModal } from '@/widgets/history/ui/Paymentmodal';
|
||||||
|
|
||||||
|
// ─── UserIcon (inline) ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function UserIcon() {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
className="w-4 h-4"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth={1.5}
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
d="M15.75 6a3.75 3.75 0 11-7.5 0 3.75 3.75 0 017.5 0zM4.501 20.118a7.5 7.5 0 0114.998 0A17.933 17.933 0 0112 21.75c-2.676 0-5.216-.584-7.499-1.632z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Component ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export function PlagiarismCheckForm() {
|
||||||
|
const {
|
||||||
|
form,
|
||||||
|
errors,
|
||||||
|
submission,
|
||||||
|
senderFullName,
|
||||||
|
isLoading,
|
||||||
|
setTopic,
|
||||||
|
setFile,
|
||||||
|
toggleCertificate,
|
||||||
|
handleSubmit,
|
||||||
|
resetSubmission,
|
||||||
|
handleSubmitWithModal,
|
||||||
|
isPaymentOpen,
|
||||||
|
setIsPaymentOpen,
|
||||||
|
} = usePlagiarismForm();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className=" bg-[#f4f5ffec] flex items-center justify-center p-4 font-['DM_Sans',sans-serif]">
|
||||||
|
<div className="w-full max-w-4xl">
|
||||||
|
{/* ── Header ────────────────────────────────────────────────────── */}
|
||||||
|
<div className="mb-8">
|
||||||
|
<div className="inline-flex items-center gap-2 bg-blue-100 text-blue-700 text-xs font-bold uppercase tracking-widest px-3 py-1.5 rounded-full mb-4">
|
||||||
|
<span className="w-1.5 h-1.5 rounded-full bg-blue-500" />
|
||||||
|
Originality Check
|
||||||
|
</div>
|
||||||
|
<h1 className="text-3xl font-black text-stone-900 leading-tight">
|
||||||
|
Submit Your Document
|
||||||
|
</h1>
|
||||||
|
<p className="text-stone-500 mt-2 text-sm leading-relaxed">
|
||||||
|
Upload a document to verify its originality. Results are typically
|
||||||
|
ready within a few minutes.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Card ──────────────────────────────────────────────────────── */}
|
||||||
|
<div className="bg-white rounded-3xl shadow-xl shadow-stone-200/80 border border-stone-100 overflow-hidden">
|
||||||
|
{/* Progress bar accent */}
|
||||||
|
<div className="h-1 w-full bg-linear-to-r from-blue-400 via-blue-500 to-indigo-400" />
|
||||||
|
|
||||||
|
<form
|
||||||
|
onSubmit={handleSubmitWithModal}
|
||||||
|
noValidate
|
||||||
|
className="p-7 flex md:flex-row flex-col gap-6"
|
||||||
|
>
|
||||||
|
{/* Status banners */}
|
||||||
|
{submission.status === 'success' && submission.response && (
|
||||||
|
<StatusBanner
|
||||||
|
status="success"
|
||||||
|
message={`Submission successful! ID: ${submission.response.submissionId}. ${submission.response.message}`}
|
||||||
|
onDismiss={resetSubmission}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{submission.status === 'error' && submission.error && (
|
||||||
|
<StatusBanner
|
||||||
|
status="error"
|
||||||
|
message={submission.error}
|
||||||
|
onDismiss={resetSubmission}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* left part */}
|
||||||
|
<div className="flex flex-col gap-6 md:max-w-[50%] w-full">
|
||||||
|
{/* Topic */}
|
||||||
|
<FieldWrapper
|
||||||
|
label="Document Topic"
|
||||||
|
htmlFor="topic"
|
||||||
|
error={errors.topic}
|
||||||
|
required
|
||||||
|
>
|
||||||
|
<TextInput
|
||||||
|
id="topic"
|
||||||
|
type="text"
|
||||||
|
placeholder="e.g. The Impact of Artificial Intelligence on Education"
|
||||||
|
value={form.topic}
|
||||||
|
onChange={(e) => setTopic(e.target.value)}
|
||||||
|
hasError={!!errors.topic}
|
||||||
|
maxLength={200}
|
||||||
|
disabled={isLoading}
|
||||||
|
/>
|
||||||
|
</FieldWrapper>
|
||||||
|
|
||||||
|
{/* Sender Full Name (read-only) */}
|
||||||
|
<FieldWrapper label="Sender Full Name">
|
||||||
|
<ReadonlyField
|
||||||
|
value={senderFullName || 'Not logged in'}
|
||||||
|
icon={<UserIcon />}
|
||||||
|
/>
|
||||||
|
</FieldWrapper>
|
||||||
|
|
||||||
|
{/* Certificate Option */}
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-semibold tracking-wide text-stone-700 uppercase mb-2">
|
||||||
|
Certificate Option
|
||||||
|
</p>
|
||||||
|
<CertificateCheckbox
|
||||||
|
checked={form.withCertificate}
|
||||||
|
onChange={toggleCertificate}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* right part */}
|
||||||
|
<div className="flex flex-col gap-6 md:max-w-[50%] w-full">
|
||||||
|
{/* File Upload */}
|
||||||
|
<FieldWrapper
|
||||||
|
label="Document File"
|
||||||
|
error={errors.file}
|
||||||
|
required
|
||||||
|
>
|
||||||
|
<FileUploadField
|
||||||
|
file={form.file}
|
||||||
|
onFileChange={setFile}
|
||||||
|
hasError={!!errors.file}
|
||||||
|
/>
|
||||||
|
</FieldWrapper>
|
||||||
|
|
||||||
|
{/* Divider */}
|
||||||
|
<div className="border-t border-stone-100" />
|
||||||
|
|
||||||
|
{/* Submit */}
|
||||||
|
<SubmitButton isLoading={isLoading} />
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Footer note */}
|
||||||
|
<p className="text-center text-xs text-stone-400 mt-5">
|
||||||
|
Your document is processed securely and not stored beyond the
|
||||||
|
analysis period.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<PaymentModal
|
||||||
|
isOpen={isPaymentOpen}
|
||||||
|
onClose={() => setIsPaymentOpen(false)}
|
||||||
|
hasCertificate={form.withCertificate}
|
||||||
|
onConfirmPayment={handleSubmit}
|
||||||
|
isLoading={isLoading}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
435
src/widgets/fileUpload/ui/Plagiraismui.tsx
Normal file
435
src/widgets/fileUpload/ui/Plagiraismui.tsx
Normal file
@@ -0,0 +1,435 @@
|
|||||||
|
import React, { useEffect } from 'react';
|
||||||
|
|
||||||
|
// ─── FieldWrapper ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
interface FieldWrapperProps {
|
||||||
|
label: string;
|
||||||
|
htmlFor?: string;
|
||||||
|
error?: string;
|
||||||
|
required?: boolean;
|
||||||
|
children: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FieldWrapper({
|
||||||
|
label,
|
||||||
|
htmlFor,
|
||||||
|
error,
|
||||||
|
required,
|
||||||
|
children,
|
||||||
|
}: FieldWrapperProps) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<label
|
||||||
|
htmlFor={htmlFor}
|
||||||
|
className="text-sm font-semibold tracking-wide text-stone-700 uppercase"
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
{required && <span className="ml-1 text-rose-500">*</span>}
|
||||||
|
</label>
|
||||||
|
{children}
|
||||||
|
{error && (
|
||||||
|
<p className="flex items-center gap-1.5 text-xs text-rose-600 font-medium">
|
||||||
|
<span className="inline-block w-1 h-1 rounded-full bg-rose-500" />
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── TextInput ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
interface TextInputProps extends React.InputHTMLAttributes<HTMLInputElement> {
|
||||||
|
hasError?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TextInput({
|
||||||
|
hasError,
|
||||||
|
className = '',
|
||||||
|
...props
|
||||||
|
}: TextInputProps) {
|
||||||
|
return (
|
||||||
|
<input
|
||||||
|
className={`
|
||||||
|
w-full px-4 py-3 rounded-xl border-2 bg-white
|
||||||
|
text-stone-800 placeholder:text-stone-400 text-sm font-medium
|
||||||
|
transition-all duration-200 outline-none
|
||||||
|
${
|
||||||
|
hasError
|
||||||
|
? 'border-rose-400 focus:border-rose-500 ring-2 ring-rose-100'
|
||||||
|
: 'border-stone-200 focus:border-blue-500 focus:ring-2 focus:ring-blue-100'
|
||||||
|
}
|
||||||
|
${className}
|
||||||
|
`}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── ReadonlyField ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
interface ReadonlyFieldProps {
|
||||||
|
value: string;
|
||||||
|
icon?: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ReadonlyField({ value, icon }: ReadonlyFieldProps) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-3 px-4 py-3 rounded-xl bg-stone-100 border-2 border-stone-200">
|
||||||
|
{icon && <span className="text-stone-500 shrink-0">{icon}</span>}
|
||||||
|
<span className="text-sm font-semibold text-stone-700">{value}</span>
|
||||||
|
<span className="ml-auto text-xs text-stone-400 italic font-medium">
|
||||||
|
Auto-filled
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── FileUploadField ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
interface FileUploadFieldProps {
|
||||||
|
file: File | null;
|
||||||
|
onFileChange: (file: File | null) => void;
|
||||||
|
hasError?: boolean;
|
||||||
|
accept?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FileUploadField({
|
||||||
|
file,
|
||||||
|
onFileChange,
|
||||||
|
hasError,
|
||||||
|
accept = '.pdf,.doc,.docx,.txt',
|
||||||
|
}: FileUploadFieldProps) {
|
||||||
|
const inputRef = React.useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const selected = e.target.files?.[0] ?? null;
|
||||||
|
onFileChange(selected);
|
||||||
|
// Reset so the same file can be re-selected after removal
|
||||||
|
e.target.value = '';
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRemove = () => {
|
||||||
|
onFileChange(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatBytes = (bytes: number) => {
|
||||||
|
if (bytes < 1024) return `${bytes} B`;
|
||||||
|
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||||
|
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<input
|
||||||
|
ref={inputRef}
|
||||||
|
type="file"
|
||||||
|
accept={accept}
|
||||||
|
onChange={handleChange}
|
||||||
|
className="sr-only"
|
||||||
|
id="file-upload"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{!file ? (
|
||||||
|
<label
|
||||||
|
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
|
||||||
|
cursor-pointer transition-all duration-200
|
||||||
|
${
|
||||||
|
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'
|
||||||
|
}
|
||||||
|
`}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={`
|
||||||
|
w-12 h-12 rounded-2xl flex items-center justify-center
|
||||||
|
transition-colors duration-200
|
||||||
|
${hasError ? 'bg-rose-100' : 'bg-white group-hover:bg-blue-100'}
|
||||||
|
`}
|
||||||
|
>
|
||||||
|
<UploadIcon
|
||||||
|
className={
|
||||||
|
hasError
|
||||||
|
? 'text-rose-400'
|
||||||
|
: 'text-stone-400 group-hover:text-blue-500'
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="text-center">
|
||||||
|
<p
|
||||||
|
className={`text-sm font-semibold transition-colors ${hasError ? 'text-rose-600' : 'text-stone-600 group-hover:text-blue-700'}`}
|
||||||
|
>
|
||||||
|
Click to upload document
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-stone-400 mt-0.5">
|
||||||
|
PDF, DOC, DOCX, TXT · Max 20 MB
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
) : (
|
||||||
|
<div className="flex items-center gap-3 px-4 py-3.5 rounded-xl border-2 border-blue-400 bg-blue-50">
|
||||||
|
<div className="w-9 h-9 rounded-lg bg-blue-100 flex items-center justify-center shrink-0">
|
||||||
|
<DocumentIcon className="text-blue-600" />
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="text-sm font-semibold text-stone-800 truncate">
|
||||||
|
{file.name}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-stone-500">{formatBytes(file.size)}</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleRemove}
|
||||||
|
className="w-7 h-7 rounded-lg flex items-center justify-center text-stone-400 hover:text-rose-500 hover:bg-rose-100 transition-colors shrink-0"
|
||||||
|
aria-label="Remove file"
|
||||||
|
>
|
||||||
|
<XIcon />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── CertificateCheckbox ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
interface CertificateCheckboxProps {
|
||||||
|
checked: boolean;
|
||||||
|
onChange: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CertificateCheckbox({
|
||||||
|
checked,
|
||||||
|
onChange,
|
||||||
|
}: CertificateCheckboxProps) {
|
||||||
|
return (
|
||||||
|
<label
|
||||||
|
className={`
|
||||||
|
flex items-start gap-3 px-4 py-4 rounded-xl border-2 cursor-pointer
|
||||||
|
transition-all duration-200
|
||||||
|
${
|
||||||
|
checked
|
||||||
|
? 'border-blue-400 bg-blue-50'
|
||||||
|
: 'border-stone-200 bg-white hover:border-stone-300'
|
||||||
|
}
|
||||||
|
`}
|
||||||
|
>
|
||||||
|
<div className="relative mt-0.5 shrink-0">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={checked}
|
||||||
|
onChange={onChange}
|
||||||
|
className="sr-only"
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
className={`
|
||||||
|
w-5 h-5 rounded-md border-2 flex items-center justify-center
|
||||||
|
transition-all duration-150
|
||||||
|
${checked ? 'bg-blue-500 border-blue-500' : 'bg-white border-stone-300'}
|
||||||
|
`}
|
||||||
|
>
|
||||||
|
{checked && (
|
||||||
|
<svg className="w-3 h-3 text-white" fill="none" viewBox="0 0 12 12">
|
||||||
|
<path
|
||||||
|
d="M2 6l3 3 5-5"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-semibold text-stone-800">
|
||||||
|
Return result with certificate
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-stone-500 mt-0.5">
|
||||||
|
An official certificate will be attached to your originality report.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── SubmitButton ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
interface SubmitButtonProps {
|
||||||
|
isLoading: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SubmitButton({ isLoading }: SubmitButtonProps) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={isLoading}
|
||||||
|
className={`
|
||||||
|
w-full flex items-center justify-center gap-2.5
|
||||||
|
px-6 py-4 rounded-xl font-bold text-sm tracking-wide uppercase
|
||||||
|
transition-all duration-200
|
||||||
|
${
|
||||||
|
isLoading
|
||||||
|
? 'bg-stone-200 text-stone-400 cursor-not-allowed'
|
||||||
|
: 'bg-stone-900 text-white hover:bg-blue-600 active:scale-[0.99] shadow-lg shadow-stone-900/20 hover:shadow-blue-600/30'
|
||||||
|
}
|
||||||
|
`}
|
||||||
|
>
|
||||||
|
{isLoading ? (
|
||||||
|
<>
|
||||||
|
<SpinnerIcon />
|
||||||
|
Submitting…
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<ShieldIcon />
|
||||||
|
Submit for Originality Check
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── StatusBanner ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
interface StatusBannerProps {
|
||||||
|
status: 'success' | 'error';
|
||||||
|
message: string;
|
||||||
|
onDismiss: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function StatusBanner({
|
||||||
|
status,
|
||||||
|
message,
|
||||||
|
onDismiss,
|
||||||
|
}: StatusBannerProps) {
|
||||||
|
const isSuccess = status === 'success';
|
||||||
|
useEffect(() => {
|
||||||
|
setTimeout(onDismiss, 3000);
|
||||||
|
}, []);
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`
|
||||||
|
max-w-md w-full
|
||||||
|
flex items-center gap-3 px-4 py-4 rounded-xl border-2 absolute top-6 right-6 z-50
|
||||||
|
${isSuccess ? 'bg-emerald-50 border-emerald-400' : 'bg-rose-50 border-rose-400'}
|
||||||
|
`}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={`text-lg mt-0.5 ${isSuccess ? 'text-emerald-600' : 'text-rose-600'}`}
|
||||||
|
>
|
||||||
|
{isSuccess ? '✓' : '✕'}
|
||||||
|
</span>
|
||||||
|
<p
|
||||||
|
className={`flex-1 text-sm font-medium ${isSuccess ? 'text-emerald-800' : 'text-rose-800'}`}
|
||||||
|
>
|
||||||
|
{message}
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onDismiss}
|
||||||
|
className={`text-xs font-bold uppercase ${isSuccess ? 'text-emerald-600 hover:text-emerald-800' : 'text-rose-600 hover:text-rose-800'}`}
|
||||||
|
>
|
||||||
|
Dismiss
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Inline SVG Icons ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function UploadIcon({ className = '' }: { className?: string }) {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
className={`w-6 h-6 ${className}`}
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth={1.5}
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
d="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5m-13.5-9L12 3m0 0l4.5 4.5M12 3v13.5"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DocumentIcon({ className = '' }: { className?: string }) {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
className={`w-5 h-5 ${className}`}
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth={1.5}
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
d="M19.5 14.25v-2.625a3.375 3.375 0 00-3.375-3.375h-1.5A1.125 1.125 0 0113.5 7.125v-1.5a3.375 3.375 0 00-3.375-3.375H8.25m0 12.75h7.5m-7.5 3H12M10.5 2.25H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 00-9-9z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function XIcon() {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
className="w-4 h-4"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth={2}
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
d="M6 18L18 6M6 6l12 12"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ShieldIcon() {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
className="w-4 h-4"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth={2}
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
d="M9 12.75L11.25 15 15 9.75m-3-7.036A11.959 11.959 0 013.598 6 11.99 11.99 0 003 9.749c0 5.592 3.824 10.29 9 11.623 5.176-1.332 9-6.03 9-11.622 0-1.31-.21-2.571-.598-3.751h-.152c-3.196 0-6.1-1.248-8.25-3.285z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SpinnerIcon() {
|
||||||
|
return (
|
||||||
|
<svg className="w-4 h-4 animate-spin" fill="none" viewBox="0 0 24 24">
|
||||||
|
<circle
|
||||||
|
className="opacity-25"
|
||||||
|
cx="12"
|
||||||
|
cy="12"
|
||||||
|
r="10"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="4"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
className="opacity-75"
|
||||||
|
fill="currentColor"
|
||||||
|
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,75 +1,30 @@
|
|||||||
import { PRODUCT_INFO } from '@/shared/constants/data';
|
import { PRODUCT_INFO } from '@/shared/constants/data';
|
||||||
import { InstagramIcon, YoutubeIcon } from 'lucide-react';
|
|
||||||
import { sections } from '../lib/data';
|
|
||||||
import { ModeToggle } from '@/shared/ui/theme-toggle';
|
|
||||||
|
|
||||||
const Footer = () => {
|
const Footer = () => {
|
||||||
|
const shortLinks = [
|
||||||
|
{ name: 'About', href: '/about' },
|
||||||
|
{ name: 'Contact', href: '/contact' },
|
||||||
|
];
|
||||||
return (
|
return (
|
||||||
<section className="py-32">
|
<section className="py-10">
|
||||||
<div className="custom-container">
|
<div className="custom-container">
|
||||||
<div className="flex w-full flex-col items-center justify-between gap-10 text-center lg:flex-row lg:items-start lg:text-left">
|
<div className="flex items-baseline justify-between gap-2">
|
||||||
<div className="flex w-full flex-col items-center justify-between gap-6 lg:items-start">
|
<div>PLAGAT</div>
|
||||||
{/* Logo */}
|
<div className="flex items-center gap-5">
|
||||||
<div className="flex items-center gap-2 lg:justify-start">
|
{shortLinks.map((link) => (
|
||||||
<a href="https://shadcnblocks.com">
|
<a
|
||||||
<img
|
key={link.name}
|
||||||
src={PRODUCT_INFO.logo}
|
href={link.href}
|
||||||
alt={PRODUCT_INFO.name}
|
className="text-sm text-muted-foreground hover:text-primary transition-colors"
|
||||||
title={PRODUCT_INFO.name}
|
|
||||||
className="h-8"
|
|
||||||
/>
|
|
||||||
</a>
|
|
||||||
<h2 className="text-xl font-semibold">{PRODUCT_INFO.name}</h2>
|
|
||||||
</div>
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
A collection of 100+ responsive HTML templates for your startup
|
|
||||||
business or side project.
|
|
||||||
</p>
|
|
||||||
<ul className="flex items-center space-x-6 text-muted-foreground">
|
|
||||||
<li className="font-medium hover:text-primary">
|
|
||||||
<a href="#">
|
|
||||||
<InstagramIcon className="size-6" />
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
<li className="font-medium hover:text-primary">
|
|
||||||
<a href="#">
|
|
||||||
<YoutubeIcon className="size-6" />
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
<li className="font-medium hover:text-primary">
|
|
||||||
<a href="#">
|
|
||||||
<InstagramIcon className="size-6" />
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
<li className="font-medium hover:text-primary">
|
|
||||||
<a href="#">
|
|
||||||
<InstagramIcon className="size-6" />
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
<ModeToggle />
|
|
||||||
</div>
|
|
||||||
<div className="grid w-full grid-cols-3 gap-6 lg:gap-20">
|
|
||||||
{sections.map((section, sectionIdx) => (
|
|
||||||
<div key={sectionIdx}>
|
|
||||||
<h3 className="mb-6 font-bold">{section.title}</h3>
|
|
||||||
<ul className="space-y-4 text-sm text-muted-foreground">
|
|
||||||
{section.links.map((link, linkIdx) => (
|
|
||||||
<li
|
|
||||||
key={linkIdx}
|
|
||||||
className="font-medium hover:text-primary"
|
|
||||||
>
|
>
|
||||||
<a href={link.href}>{link.name}</a>
|
{link.name}
|
||||||
</li>
|
</a>
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-8 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">
|
<div className="mt-8 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">
|
||||||
<p>
|
<p>
|
||||||
© {new Date().getFullYear()} {PRODUCT_INFO.creator}. All rights
|
© {new Date().getFullYear()} Felix IT Solutions. All rights
|
||||||
reserved.
|
reserved.
|
||||||
</p>
|
</p>
|
||||||
<ul className="flex justify-center gap-4 lg:justify-start">
|
<ul className="flex justify-center gap-4 lg:justify-start">
|
||||||
|
|||||||
22
src/widgets/history/lib/constant.ts
Normal file
22
src/widgets/history/lib/constant.ts
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
// ─── 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;
|
||||||
48
src/widgets/history/lib/types.ts
Normal file
48
src/widgets/history/lib/types.ts
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
// ─── Domain Types ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface ServicePricing {
|
||||||
|
serviceFee: number;
|
||||||
|
certificateFee: number;
|
||||||
|
currency: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OrderSummary {
|
||||||
|
hasCertificate: boolean;
|
||||||
|
pricing: ServicePricing;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PaymePaymentRequest {
|
||||||
|
amount: number; // in tiyin (1 UZS = 100 tiyin)
|
||||||
|
orderId: string;
|
||||||
|
description: string;
|
||||||
|
returnUrl: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PaymePaymentResponse {
|
||||||
|
redirectUrl: string;
|
||||||
|
transactionId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type PaymentStatus = 'idle' | 'loading' | 'success' | 'error';
|
||||||
|
|
||||||
|
// ─── Component Props ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface PaymentModalProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
hasCertificate: boolean;
|
||||||
|
onConfirmPayment: () => void;
|
||||||
|
isLoading: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PriceSummaryProps {
|
||||||
|
hasCertificate: boolean;
|
||||||
|
pricing: ServicePricing;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PaymeButtonProps {
|
||||||
|
amount: number;
|
||||||
|
orderId: string;
|
||||||
|
onSuccess?: (response: PaymePaymentResponse) => void;
|
||||||
|
onError?: (error: Error) => void;
|
||||||
|
}
|
||||||
70
src/widgets/history/lib/usePayment.ts
Normal file
70
src/widgets/history/lib/usePayment.ts
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
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 };
|
||||||
|
};
|
||||||
71
src/widgets/history/lib/utils.ts
Normal file
71
src/widgets/history/lib/utils.ts
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
// ─── Pricing Utilities ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
import { PAYME_CONFIG, PRICING } from './constant';
|
||||||
|
import {
|
||||||
|
PaymePaymentRequest,
|
||||||
|
PaymePaymentResponse,
|
||||||
|
ServicePricing,
|
||||||
|
} from './types';
|
||||||
|
|
||||||
|
export const getPricing = (): ServicePricing => ({
|
||||||
|
serviceFee: PRICING.SERVICE_FEE,
|
||||||
|
certificateFee: PRICING.CERTIFICATE_FEE,
|
||||||
|
currency: PRICING.CURRENCY,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const calculateTotal = (hasCertificate: boolean): number => {
|
||||||
|
const base = PRICING.SERVICE_FEE;
|
||||||
|
return hasCertificate ? base + PRICING.CERTIFICATE_FEE : base;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const toTiyin = (uzs: number): number => uzs * PRICING.TIYIN_MULTIPLIER;
|
||||||
|
|
||||||
|
export const formatPrice = (amount: number, currency: string): string =>
|
||||||
|
`${amount.toLocaleString('uz-UZ')} ${currency}`;
|
||||||
|
|
||||||
|
// ─── Order ID Generator ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export const generateOrderId = (): string => {
|
||||||
|
const timestamp = Date.now();
|
||||||
|
const random = Math.random().toString(36).slice(2, 8).toUpperCase();
|
||||||
|
return `ORDER-${timestamp}-${random}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
// ─── Payme API ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sends payment details to the backend, which creates a Payme transaction
|
||||||
|
* and returns a redirect URL to the Payme checkout page.
|
||||||
|
*/
|
||||||
|
export const createPaymePayment = async (
|
||||||
|
request: PaymePaymentRequest,
|
||||||
|
): Promise<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;
|
||||||
|
};
|
||||||
99
src/widgets/history/ui/Paymebutton.tsx
Normal file
99
src/widgets/history/ui/Paymebutton.tsx
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { PaymentStatus } from '../lib/types';
|
||||||
|
|
||||||
|
// ─── Payme Logo SVG ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const PaymeLogo: React.FC = () => (
|
||||||
|
<svg
|
||||||
|
width="72"
|
||||||
|
height="18"
|
||||||
|
viewBox="0 0 72 18"
|
||||||
|
fill="none"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
aria-label="Payme"
|
||||||
|
>
|
||||||
|
<text
|
||||||
|
x="0"
|
||||||
|
y="14"
|
||||||
|
fontFamily="'Arial Black', sans-serif"
|
||||||
|
fontWeight="900"
|
||||||
|
fontSize="16"
|
||||||
|
fill="white"
|
||||||
|
letterSpacing="-0.5"
|
||||||
|
>
|
||||||
|
Payme
|
||||||
|
</text>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
// ─── Spinner ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const Spinner: React.FC = () => (
|
||||||
|
<svg
|
||||||
|
className="animate-spin h-5 w-5 text-white"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<circle
|
||||||
|
className="opacity-25"
|
||||||
|
cx="12"
|
||||||
|
cy="12"
|
||||||
|
r="10"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="4"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
className="opacity-75"
|
||||||
|
fill="currentColor"
|
||||||
|
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
// ─── PaymeButton ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
interface PaymeButtonProps {
|
||||||
|
onClick: () => void;
|
||||||
|
status: PaymentStatus;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const PaymeButton: React.FC<PaymeButtonProps> = ({
|
||||||
|
onClick,
|
||||||
|
status,
|
||||||
|
}) => {
|
||||||
|
const isLoading = status === 'loading';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
onClick={onClick}
|
||||||
|
disabled={isLoading}
|
||||||
|
aria-busy={isLoading}
|
||||||
|
aria-label="Pay with Payme"
|
||||||
|
className={`
|
||||||
|
w-full flex items-center justify-center gap-3
|
||||||
|
rounded-xl px-6 py-4
|
||||||
|
font-semibold text-white text-sm tracking-wide
|
||||||
|
transition-all duration-200 ease-out
|
||||||
|
focus:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-blue-500
|
||||||
|
${
|
||||||
|
isLoading
|
||||||
|
? 'bg-blue-400 cursor-not-allowed opacity-80'
|
||||||
|
: 'bg-[#00AAFF] hover:bg-[#009AEE] active:scale-[0.98] shadow-md hover:shadow-lg'
|
||||||
|
}
|
||||||
|
`}
|
||||||
|
>
|
||||||
|
{isLoading ? (
|
||||||
|
<>
|
||||||
|
<Spinner />
|
||||||
|
<span>Connecting to Payme…</span>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<PaymeLogo />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
};
|
||||||
207
src/widgets/history/ui/Paymentmodal.tsx
Normal file
207
src/widgets/history/ui/Paymentmodal.tsx
Normal file
@@ -0,0 +1,207 @@
|
|||||||
|
import React, { useEffect, useRef } from 'react';
|
||||||
|
import { PaymentModalProps } from '../lib/types';
|
||||||
|
import { getPricing } from '../lib/utils';
|
||||||
|
import { PriceSummary } from './Pricesummary';
|
||||||
|
import { PaymeButton } from './Paymebutton';
|
||||||
|
|
||||||
|
// ─── Close Button ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const CloseButton: React.FC<{ onClick: () => void }> = ({ onClick }) => (
|
||||||
|
<button
|
||||||
|
onClick={onClick}
|
||||||
|
aria-label="Close payment modal"
|
||||||
|
className="
|
||||||
|
absolute top-4 right-4
|
||||||
|
p-2 rounded-lg text-slate-400
|
||||||
|
hover:text-slate-700 hover:bg-slate-100
|
||||||
|
transition-colors duration-150
|
||||||
|
focus:outline-none focus-visible:ring-2 focus-visible:ring-slate-300
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
width="18"
|
||||||
|
height="18"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2.5"
|
||||||
|
strokeLinecap="round"
|
||||||
|
>
|
||||||
|
<line x1="18" y1="6" x2="6" y2="18" />
|
||||||
|
<line x1="6" y1="6" x2="18" y2="18" />
|
||||||
|
</svg>
|
||||||
|
</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 = () => (
|
||||||
|
<div className="flex items-center justify-center gap-1.5 text-xs text-slate-400">
|
||||||
|
<svg width="12" height="12" viewBox="0 0 24 24" fill="currentColor">
|
||||||
|
<path d="M12 1L3 5v6c0 5.55 3.84 10.74 9 12 5.16-1.26 9-6.45 9-12V5l-9-4zm-2 16l-4-4 1.41-1.41L10 14.17l6.59-6.59L18 9l-8 8z" />
|
||||||
|
</svg>
|
||||||
|
<span>Secured by Payme · SSL encrypted</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
// ─── PaymentModal ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export const PaymentModal: React.FC<PaymentModalProps> = ({
|
||||||
|
isOpen,
|
||||||
|
onClose,
|
||||||
|
hasCertificate,
|
||||||
|
onConfirmPayment,
|
||||||
|
isLoading,
|
||||||
|
}) => {
|
||||||
|
const dialogRef = useRef<HTMLDivElement>(null);
|
||||||
|
const pricing = getPricing();
|
||||||
|
const status = isLoading ? 'loading' : 'idle';
|
||||||
|
|
||||||
|
// ── Close on Escape ──────────────────────────────────────────────────────────
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isOpen) return;
|
||||||
|
|
||||||
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === 'Escape') onClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener('keydown', handleKeyDown);
|
||||||
|
return () => document.removeEventListener('keydown', handleKeyDown);
|
||||||
|
}, [isOpen, onClose]);
|
||||||
|
|
||||||
|
// ── Lock body scroll ─────────────────────────────────────────────────────────
|
||||||
|
useEffect(() => {
|
||||||
|
if (isOpen) {
|
||||||
|
document.body.style.overflow = 'hidden';
|
||||||
|
} else {
|
||||||
|
document.body.style.overflow = '';
|
||||||
|
}
|
||||||
|
return () => {
|
||||||
|
document.body.style.overflow = '';
|
||||||
|
};
|
||||||
|
}, [isOpen]);
|
||||||
|
|
||||||
|
// ── Focus trap ───────────────────────────────────────────────────────────────
|
||||||
|
useEffect(() => {
|
||||||
|
if (isOpen) {
|
||||||
|
dialogRef.current?.focus();
|
||||||
|
}
|
||||||
|
}, [isOpen]);
|
||||||
|
|
||||||
|
if (!isOpen) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
/* Backdrop */
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 z-40 flex items-center justify-center p-4"
|
||||||
|
aria-modal="true"
|
||||||
|
role="dialog"
|
||||||
|
aria-labelledby="payment-modal-title"
|
||||||
|
>
|
||||||
|
{/* Dimmed overlay */}
|
||||||
|
<div
|
||||||
|
className="absolute inset-0 bg-black/40 backdrop-blur-sm"
|
||||||
|
onClick={onClose}
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Modal card */}
|
||||||
|
<div
|
||||||
|
ref={dialogRef}
|
||||||
|
tabIndex={-1}
|
||||||
|
className="
|
||||||
|
relative z-10 w-full max-w-md
|
||||||
|
bg-white rounded-2xl shadow-2xl
|
||||||
|
outline-none
|
||||||
|
animate-in fade-in zoom-in-95 duration-200
|
||||||
|
"
|
||||||
|
>
|
||||||
|
{/* Header */}
|
||||||
|
<div className="px-6 pt-6 pb-5 border-b border-slate-100">
|
||||||
|
<CloseButton onClick={onClose} />
|
||||||
|
<div className="pr-8">
|
||||||
|
<h2
|
||||||
|
id="payment-modal-title"
|
||||||
|
className="text-lg font-semibold text-slate-900"
|
||||||
|
>
|
||||||
|
Payment
|
||||||
|
</h2>
|
||||||
|
<p className="mt-0.5 text-sm text-slate-500">
|
||||||
|
Review your order and pay securely
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Body */}
|
||||||
|
<div className="px-6 py-5 space-y-5">
|
||||||
|
{/* Order details */}
|
||||||
|
<div>
|
||||||
|
<h3 className="text-xs font-semibold uppercase tracking-widest text-slate-400 mb-3">
|
||||||
|
Order Summary
|
||||||
|
</h3>
|
||||||
|
<PriceSummary hasCertificate={hasCertificate} pricing={pricing} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Certificate badge */}
|
||||||
|
{hasCertificate && (
|
||||||
|
<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"
|
||||||
|
height="15"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="currentColor"
|
||||||
|
className="shrink-0"
|
||||||
|
>
|
||||||
|
<path d="M12 1l2.753 5.527 6.247.907-4.5 4.385 1.063 6.181L12 15.027l-5.563 2.973 1.063-6.181L3 7.434l6.247-.907z" />
|
||||||
|
</svg>
|
||||||
|
<span>Certificate of completion included</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Payment method label */}
|
||||||
|
<div>
|
||||||
|
<h3 className="text-xs font-semibold uppercase tracking-widest text-slate-400 mb-3">
|
||||||
|
Payment Method
|
||||||
|
</h3>
|
||||||
|
<PaymeButton onClick={onConfirmPayment} status={status} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Security note */}
|
||||||
|
<SecurityBadge />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
80
src/widgets/history/ui/Pricesummary.tsx
Normal file
80
src/widgets/history/ui/Pricesummary.tsx
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { formatPrice } from '../lib/utils';
|
||||||
|
import { PriceSummaryProps } from '../lib/types';
|
||||||
|
|
||||||
|
// ─── Price Row ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
interface PriceRowProps {
|
||||||
|
label: string;
|
||||||
|
amount: number;
|
||||||
|
currency: 'UZS' | string;
|
||||||
|
highlight?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const PriceRow: React.FC<PriceRowProps> = ({
|
||||||
|
label,
|
||||||
|
amount,
|
||||||
|
currency,
|
||||||
|
highlight,
|
||||||
|
}) => (
|
||||||
|
<div
|
||||||
|
className={`flex items-center justify-between py-3 ${
|
||||||
|
highlight ? 'border-t border-slate-200 mt-1 pt-4' : ''
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={
|
||||||
|
highlight
|
||||||
|
? 'text-base font-semibold text-slate-800'
|
||||||
|
: 'text-sm text-slate-500'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
className={
|
||||||
|
highlight
|
||||||
|
? 'text-lg font-bold text-slate-900'
|
||||||
|
: 'text-sm font-medium text-slate-700'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{formatPrice(amount, currency)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
// ─── Price Summary ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export const PriceSummary: React.FC<PriceSummaryProps> = ({
|
||||||
|
hasCertificate,
|
||||||
|
pricing,
|
||||||
|
}) => {
|
||||||
|
const total = hasCertificate
|
||||||
|
? pricing.serviceFee + pricing.certificateFee
|
||||||
|
: pricing.serviceFee;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="rounded-xl bg-slate-50 border border-slate-100 px-5 py-2 space-y-0">
|
||||||
|
<PriceRow
|
||||||
|
label="Service fee"
|
||||||
|
amount={pricing.serviceFee}
|
||||||
|
currency={pricing.currency}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{hasCertificate && (
|
||||||
|
<PriceRow
|
||||||
|
label="Certificate"
|
||||||
|
amount={pricing.certificateFee}
|
||||||
|
currency={pricing.currency}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<PriceRow
|
||||||
|
label="Total"
|
||||||
|
amount={total}
|
||||||
|
currency={pricing.currency}
|
||||||
|
highlight
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -1,5 +1,4 @@
|
|||||||
'use client';
|
'use client';
|
||||||
import { useLoginModal } from '@/features/auth/login/lib/togle';
|
|
||||||
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 { useUserLogin } from '@/shared/zustand/userLogin';
|
||||||
@@ -11,6 +10,7 @@ import {
|
|||||||
} from '@/shared/ui/navigation-menu';
|
} from '@/shared/ui/navigation-menu';
|
||||||
import SubMenuLink from './SubMenuLink';
|
import SubMenuLink from './SubMenuLink';
|
||||||
import { ChangeLang } from './ChangeLang';
|
import { ChangeLang } from './ChangeLang';
|
||||||
|
import { useLoginModal, useRegisterModal } from '@/shared/zustand/auth';
|
||||||
|
|
||||||
function AuthButtons() {
|
function AuthButtons() {
|
||||||
const auth = {
|
const auth = {
|
||||||
@@ -24,6 +24,9 @@ function AuthButtons() {
|
|||||||
];
|
];
|
||||||
|
|
||||||
const toggleLoginModal = useLoginModal((state) => state.toggleLoginModal);
|
const toggleLoginModal = useLoginModal((state) => state.toggleLoginModal);
|
||||||
|
const toggleRegisterModal = useRegisterModal(
|
||||||
|
(state) => state.toggleRegisterModal,
|
||||||
|
);
|
||||||
const user = useUserLogin((state) => state.user);
|
const user = useUserLogin((state) => state.user);
|
||||||
|
|
||||||
if (user) {
|
if (user) {
|
||||||
@@ -46,10 +49,10 @@ function AuthButtons() {
|
|||||||
<div className="lg:flex hidden">
|
<div className="lg:flex hidden">
|
||||||
<ChangeLang />
|
<ChangeLang />
|
||||||
</div>
|
</div>
|
||||||
<Button asChild variant="outline" onClick={toggleLoginModal}>
|
<Button variant="outline" onClick={() => toggleLoginModal()}>
|
||||||
<Link href={auth.login.url}>{auth.login.title}</Link>
|
<Link href={auth.login.url}>{auth.login.title}</Link>
|
||||||
</Button>
|
</Button>
|
||||||
<Button asChild>
|
<Button onClick={() => toggleRegisterModal()}>
|
||||||
<Link href={auth.signup.url}>{auth.signup.title}</Link>
|
<Link href={auth.signup.url}>{auth.signup.title}</Link>
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user