From 0495f16e5e8bb8cba2da1beff13e1a93b6c328c3 Mon Sep 17 00:00:00 2001 From: "nabijonovdavronbek619@gmail.com" Date: Tue, 31 Mar 2026 12:12:15 +0500 Subject: [PATCH] payment modal complated --- src/widgets/fileUpload/lib/usePlagiraism.ts | 50 ++-- .../fileUpload/ui/Plagiraismcheckform.tsx | 232 ++++++++++-------- src/widgets/fileUpload/ui/Plagiraismui.tsx | 8 +- src/widgets/history/lib/constant.ts | 22 ++ src/widgets/history/lib/types.ts | 48 ++++ src/widgets/history/lib/usePayment.ts | 70 ++++++ src/widgets/history/lib/utils.ts | 71 ++++++ src/widgets/history/ui/Paymebutton.tsx | 99 ++++++++ src/widgets/history/ui/Paymentmodal.tsx | 207 ++++++++++++++++ src/widgets/history/ui/Pricesummary.tsx | 80 ++++++ 10 files changed, 758 insertions(+), 129 deletions(-) create mode 100644 src/widgets/history/lib/constant.ts create mode 100644 src/widgets/history/lib/types.ts create mode 100644 src/widgets/history/lib/usePayment.ts create mode 100644 src/widgets/history/lib/utils.ts create mode 100644 src/widgets/history/ui/Paymebutton.tsx create mode 100644 src/widgets/history/ui/Paymentmodal.tsx create mode 100644 src/widgets/history/ui/Pricesummary.tsx diff --git a/src/widgets/fileUpload/lib/usePlagiraism.ts b/src/widgets/fileUpload/lib/usePlagiraism.ts index 92adb8b..6956277 100644 --- a/src/widgets/fileUpload/lib/usePlagiraism.ts +++ b/src/widgets/fileUpload/lib/usePlagiraism.ts @@ -30,6 +30,7 @@ export function usePlagiarismForm() { const [form, setForm] = useState(INITIAL_FORM); const [errors, setErrors] = useState({}); + const [isPaymentOpen, setIsPaymentOpen] = useState(false); const [submission, setSubmission] = useState(INITIAL_SUBMISSION); @@ -51,37 +52,43 @@ export function usePlagiarismForm() { // ── Submission ─────────────────────────────────────────────────────────── - const handleSubmit = useCallback( + // 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; + return; // Don't open modal if invalid } - 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); // Reset form on success - } catch (err) { - const message = - err instanceof Error ? err.message : 'An unexpected error occurred.'; - setSubmission({ status: 'error', response: null, error: message }); - } + // Validation passed → open the payment modal + setIsPaymentOpen(true); }, - [form, senderFullName], + [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); }, []); @@ -101,5 +108,8 @@ export function usePlagiarismForm() { toggleCertificate, handleSubmit, resetSubmission, + handleSubmitWithModal, + setIsPaymentOpen, + isPaymentOpen, }; } diff --git a/src/widgets/fileUpload/ui/Plagiraismcheckform.tsx b/src/widgets/fileUpload/ui/Plagiraismcheckform.tsx index fe414ed..78e7452 100644 --- a/src/widgets/fileUpload/ui/Plagiraismcheckform.tsx +++ b/src/widgets/fileUpload/ui/Plagiraismcheckform.tsx @@ -10,6 +10,7 @@ import { StatusBanner, } from './Plagiraismui'; import { usePlagiarismForm } from '../lib/usePlagiraism'; +import { PaymentModal } from '@/widgets/history/ui/Paymentmodal'; // ─── UserIcon (inline) ─────────────────────────────────────────────────────── @@ -45,119 +46,136 @@ export function PlagiarismCheckForm() { toggleCertificate, handleSubmit, resetSubmission, + handleSubmitWithModal, + isPaymentOpen, + setIsPaymentOpen, } = usePlagiarismForm(); return ( -
-
- {/* ── Header ────────────────────────────────────────────────────── */} -
-
- - Originality Check + <> +
+
+ {/* ── Header ────────────────────────────────────────────────────── */} +
+
+ + Originality Check +
+

+ Submit Your Document +

+

+ Upload a document to verify its originality. Results are typically + ready within a few minutes. +

-

- Submit Your Document -

-

- Upload a document to verify its originality. Results are typically - ready within a few minutes. + + {/* ── Card ──────────────────────────────────────────────────────── */} +

+ {/* Progress bar accent */} +
+ +
+ {/* Status banners */} + {submission.status === 'success' && submission.response && ( + + )} + {submission.status === 'error' && submission.error && ( + + )} + + {/* left part */} +
+ {/* Topic */} + + setTopic(e.target.value)} + hasError={!!errors.topic} + maxLength={200} + disabled={isLoading} + /> + + + {/* Sender Full Name (read-only) */} + + } + /> + + + {/* Certificate Option */} +
+

+ Certificate Option +

+ +
+
+ + {/* right part */} +
+ {/* File Upload */} + + + + + {/* Divider */} +
+ + {/* Submit */} + +
+ +
+ + {/* Footer note */} +

+ Your document is processed securely and not stored beyond the + analysis period.

- - {/* ── Card ──────────────────────────────────────────────────────── */} -
- {/* Progress bar accent */} -
- -
- {/* Status banners */} - {submission.status === 'success' && submission.response && ( - - )} - {submission.status === 'error' && submission.error && ( - - )} - - {/* left part */} -
- {/* Topic */} - - setTopic(e.target.value)} - hasError={!!errors.topic} - maxLength={200} - disabled={isLoading} - /> - - - {/* Sender Full Name (read-only) */} - - } - /> - - - {/* Certificate Option */} -
-

- Certificate Option -

- -
-
- - {/* right part */} -
- {/* File Upload */} - - - - - {/* Divider */} -
- - {/* Submit */} - -
- -
- - {/* Footer note */} -

- Your document is processed securely and not stored beyond the analysis - period. -

-
+ + setIsPaymentOpen(false)} + hasCertificate={form.withCertificate} + onConfirmPayment={handleSubmit} + isLoading={isLoading} + /> + ); } diff --git a/src/widgets/fileUpload/ui/Plagiraismui.tsx b/src/widgets/fileUpload/ui/Plagiraismui.tsx index 79f7da9..28edfca 100644 --- a/src/widgets/fileUpload/ui/Plagiraismui.tsx +++ b/src/widgets/fileUpload/ui/Plagiraismui.tsx @@ -1,4 +1,4 @@ -import React from 'react'; +import React, { useEffect } from 'react'; // ─── FieldWrapper ──────────────────────────────────────────────────────────── @@ -308,10 +308,14 @@ export function StatusBanner({ onDismiss, }: StatusBannerProps) { const isSuccess = status === 'success'; + useEffect(() => { + setTimeout(onDismiss, 3000); + }, []); return (
diff --git a/src/widgets/history/lib/constant.ts b/src/widgets/history/lib/constant.ts new file mode 100644 index 0000000..b7a68f4 --- /dev/null +++ b/src/widgets/history/lib/constant.ts @@ -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; diff --git a/src/widgets/history/lib/types.ts b/src/widgets/history/lib/types.ts new file mode 100644 index 0000000..f93a154 --- /dev/null +++ b/src/widgets/history/lib/types.ts @@ -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; +} diff --git a/src/widgets/history/lib/usePayment.ts b/src/widgets/history/lib/usePayment.ts new file mode 100644 index 0000000..b81965b --- /dev/null +++ b/src/widgets/history/lib/usePayment.ts @@ -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; + resetError: () => void; +} + +export const usePayment = ({ + hasCertificate, + onSuccess, + onError, +}: UsePaymentOptions): UsePaymentReturn => { + const [status, setStatus] = useState('idle'); + const [error, setError] = useState(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 }; +}; diff --git a/src/widgets/history/lib/utils.ts b/src/widgets/history/lib/utils.ts new file mode 100644 index 0000000..0051dab --- /dev/null +++ b/src/widgets/history/lib/utils.ts @@ -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 => { + 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; +}; diff --git a/src/widgets/history/ui/Paymebutton.tsx b/src/widgets/history/ui/Paymebutton.tsx new file mode 100644 index 0000000..2a0d18a --- /dev/null +++ b/src/widgets/history/ui/Paymebutton.tsx @@ -0,0 +1,99 @@ +import React from 'react'; +import { PaymentStatus } from '../lib/types'; + +// ─── Payme Logo SVG ──────────────────────────────────────────────────────────── + +const PaymeLogo: React.FC = () => ( + + + Payme + + +); + +// ─── Spinner ─────────────────────────────────────────────────────────────────── + +const Spinner: React.FC = () => ( + +); + +// ─── PaymeButton ─────────────────────────────────────────────────────────────── + +interface PaymeButtonProps { + onClick: () => void; + status: PaymentStatus; +} + +export const PaymeButton: React.FC = ({ + onClick, + status, +}) => { + const isLoading = status === 'loading'; + + return ( + + ); +}; diff --git a/src/widgets/history/ui/Paymentmodal.tsx b/src/widgets/history/ui/Paymentmodal.tsx new file mode 100644 index 0000000..4f5e1e2 --- /dev/null +++ b/src/widgets/history/ui/Paymentmodal.tsx @@ -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 }) => ( + +); + +// ─── Error Banner ────────────────────────────────────────────────────────────── + +// const ErrorBanner: React.FC<{ message: string; onDismiss: () => void }> = ({ +// message, +// onDismiss, +// }) => ( +//
+// +// +// +//

{message}

+// +//
+// ); + +// ─── Security Badge ──────────────────────────────────────────────────────────── + +const SecurityBadge: React.FC = () => ( +
+ + + + Secured by Payme · SSL encrypted +
+); + +// ─── PaymentModal ────────────────────────────────────────────────────────────── + +export const PaymentModal: React.FC = ({ + isOpen, + onClose, + hasCertificate, + onConfirmPayment, + isLoading, +}) => { + const dialogRef = useRef(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 */ +
+ {/* Dimmed overlay */} + + ); +}; diff --git a/src/widgets/history/ui/Pricesummary.tsx b/src/widgets/history/ui/Pricesummary.tsx new file mode 100644 index 0000000..9d55b23 --- /dev/null +++ b/src/widgets/history/ui/Pricesummary.tsx @@ -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 = ({ + label, + amount, + currency, + highlight, +}) => ( +
+ + {label} + + + {formatPrice(amount, currency)} + +
+); + +// ─── Price Summary ───────────────────────────────────────────────────────────── + +export const PriceSummary: React.FC = ({ + hasCertificate, + pricing, +}) => { + const total = hasCertificate + ? pricing.serviceFee + pricing.certificateFee + : pricing.serviceFee; + + return ( +
+ + + {hasCertificate && ( + + )} + + +
+ ); +};