payment modal complated
This commit is contained in:
@@ -30,6 +30,7 @@ export function usePlagiarismForm() {
|
|||||||
|
|
||||||
const [form, setForm] = useState<PlagiarismFormState>(INITIAL_FORM);
|
const [form, setForm] = useState<PlagiarismFormState>(INITIAL_FORM);
|
||||||
const [errors, setErrors] = useState<PlagiarismFormErrors>({});
|
const [errors, setErrors] = useState<PlagiarismFormErrors>({});
|
||||||
|
const [isPaymentOpen, setIsPaymentOpen] = useState(false);
|
||||||
const [submission, setSubmission] =
|
const [submission, setSubmission] =
|
||||||
useState<SubmissionState>(INITIAL_SUBMISSION);
|
useState<SubmissionState>(INITIAL_SUBMISSION);
|
||||||
|
|
||||||
@@ -51,18 +52,26 @@ export function usePlagiarismForm() {
|
|||||||
|
|
||||||
// ── Submission ───────────────────────────────────────────────────────────
|
// ── Submission ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
const handleSubmit = useCallback(
|
// 1. Wrap the form's onSubmit to intercept the event properly
|
||||||
|
const handleSubmitWithModal = useCallback(
|
||||||
async (e: React.FormEvent) => {
|
async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
||||||
|
// Run validation first
|
||||||
const validationErrors = validatePlagiarismForm(form);
|
const validationErrors = validatePlagiarismForm(form);
|
||||||
if (!isFormValid(validationErrors)) {
|
if (!isFormValid(validationErrors)) {
|
||||||
setErrors(validationErrors);
|
setErrors(validationErrors);
|
||||||
return;
|
return; // Don't open modal if invalid
|
||||||
}
|
}
|
||||||
|
|
||||||
setSubmission({ status: 'loading', response: null, error: null });
|
// Validation passed → open the payment modal
|
||||||
|
setIsPaymentOpen(true);
|
||||||
|
},
|
||||||
|
[form],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleSubmit = useCallback(async () => {
|
||||||
|
setSubmission({ status: 'loading', response: null, error: null });
|
||||||
try {
|
try {
|
||||||
const response = await submitPlagiarismCheck({
|
const response = await submitPlagiarismCheck({
|
||||||
topic: form.topic.trim(),
|
topic: form.topic.trim(),
|
||||||
@@ -70,17 +79,15 @@ export function usePlagiarismForm() {
|
|||||||
file: form.file!,
|
file: form.file!,
|
||||||
withCertificate: form.withCertificate,
|
withCertificate: form.withCertificate,
|
||||||
});
|
});
|
||||||
|
|
||||||
setSubmission({ status: 'success', response, error: null });
|
setSubmission({ status: 'success', response, error: null });
|
||||||
setForm(INITIAL_FORM); // Reset form on success
|
setForm(INITIAL_FORM);
|
||||||
|
setIsPaymentOpen(false); // Close modal on success
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const message =
|
const message =
|
||||||
err instanceof Error ? err.message : 'An unexpected error occurred.';
|
err instanceof Error ? err.message : 'An unexpected error occurred.';
|
||||||
setSubmission({ status: 'error', response: null, error: message });
|
setSubmission({ status: 'error', response: null, error: message });
|
||||||
}
|
}
|
||||||
},
|
}, [form, senderFullName]);
|
||||||
[form, senderFullName],
|
|
||||||
);
|
|
||||||
|
|
||||||
const resetSubmission = useCallback(() => {
|
const resetSubmission = useCallback(() => {
|
||||||
setSubmission(INITIAL_SUBMISSION);
|
setSubmission(INITIAL_SUBMISSION);
|
||||||
@@ -101,5 +108,8 @@ export function usePlagiarismForm() {
|
|||||||
toggleCertificate,
|
toggleCertificate,
|
||||||
handleSubmit,
|
handleSubmit,
|
||||||
resetSubmission,
|
resetSubmission,
|
||||||
|
handleSubmitWithModal,
|
||||||
|
setIsPaymentOpen,
|
||||||
|
isPaymentOpen,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
StatusBanner,
|
StatusBanner,
|
||||||
} from './Plagiraismui';
|
} from './Plagiraismui';
|
||||||
import { usePlagiarismForm } from '../lib/usePlagiraism';
|
import { usePlagiarismForm } from '../lib/usePlagiraism';
|
||||||
|
import { PaymentModal } from '@/widgets/history/ui/Paymentmodal';
|
||||||
|
|
||||||
// ─── UserIcon (inline) ───────────────────────────────────────────────────────
|
// ─── UserIcon (inline) ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -45,9 +46,13 @@ export function PlagiarismCheckForm() {
|
|||||||
toggleCertificate,
|
toggleCertificate,
|
||||||
handleSubmit,
|
handleSubmit,
|
||||||
resetSubmission,
|
resetSubmission,
|
||||||
|
handleSubmitWithModal,
|
||||||
|
isPaymentOpen,
|
||||||
|
setIsPaymentOpen,
|
||||||
} = usePlagiarismForm();
|
} = usePlagiarismForm();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<>
|
||||||
<div className=" bg-[#f4f5ffec] flex items-center justify-center p-4 font-['DM_Sans',sans-serif]">
|
<div className=" bg-[#f4f5ffec] flex items-center justify-center p-4 font-['DM_Sans',sans-serif]">
|
||||||
<div className="w-full max-w-4xl">
|
<div className="w-full max-w-4xl">
|
||||||
{/* ── Header ────────────────────────────────────────────────────── */}
|
{/* ── Header ────────────────────────────────────────────────────── */}
|
||||||
@@ -71,7 +76,7 @@ export function PlagiarismCheckForm() {
|
|||||||
<div className="h-1 w-full bg-linear-to-r from-blue-400 via-blue-500 to-indigo-400" />
|
<div className="h-1 w-full bg-linear-to-r from-blue-400 via-blue-500 to-indigo-400" />
|
||||||
|
|
||||||
<form
|
<form
|
||||||
onSubmit={handleSubmit}
|
onSubmit={handleSubmitWithModal}
|
||||||
noValidate
|
noValidate
|
||||||
className="p-7 flex md:flex-row flex-col gap-6"
|
className="p-7 flex md:flex-row flex-col gap-6"
|
||||||
>
|
>
|
||||||
@@ -135,7 +140,11 @@ export function PlagiarismCheckForm() {
|
|||||||
{/* right part */}
|
{/* right part */}
|
||||||
<div className="flex flex-col gap-6 md:max-w-[50%] w-full">
|
<div className="flex flex-col gap-6 md:max-w-[50%] w-full">
|
||||||
{/* File Upload */}
|
{/* File Upload */}
|
||||||
<FieldWrapper label="Document File" error={errors.file} required>
|
<FieldWrapper
|
||||||
|
label="Document File"
|
||||||
|
error={errors.file}
|
||||||
|
required
|
||||||
|
>
|
||||||
<FileUploadField
|
<FileUploadField
|
||||||
file={form.file}
|
file={form.file}
|
||||||
onFileChange={setFile}
|
onFileChange={setFile}
|
||||||
@@ -154,10 +163,19 @@ export function PlagiarismCheckForm() {
|
|||||||
|
|
||||||
{/* Footer note */}
|
{/* Footer note */}
|
||||||
<p className="text-center text-xs text-stone-400 mt-5">
|
<p className="text-center text-xs text-stone-400 mt-5">
|
||||||
Your document is processed securely and not stored beyond the analysis
|
Your document is processed securely and not stored beyond the
|
||||||
period.
|
analysis period.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<PaymentModal
|
||||||
|
isOpen={isPaymentOpen}
|
||||||
|
onClose={() => setIsPaymentOpen(false)}
|
||||||
|
hasCertificate={form.withCertificate}
|
||||||
|
onConfirmPayment={handleSubmit}
|
||||||
|
isLoading={isLoading}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import React from 'react';
|
import React, { useEffect } from 'react';
|
||||||
|
|
||||||
// ─── FieldWrapper ────────────────────────────────────────────────────────────
|
// ─── FieldWrapper ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -308,10 +308,14 @@ export function StatusBanner({
|
|||||||
onDismiss,
|
onDismiss,
|
||||||
}: StatusBannerProps) {
|
}: StatusBannerProps) {
|
||||||
const isSuccess = status === 'success';
|
const isSuccess = status === 'success';
|
||||||
|
useEffect(() => {
|
||||||
|
setTimeout(onDismiss, 3000);
|
||||||
|
}, []);
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={`
|
className={`
|
||||||
flex items-start gap-3 px-4 py-4 rounded-xl border-2
|
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'}
|
${isSuccess ? 'bg-emerald-50 border-emerald-400' : 'bg-rose-50 border-rose-400'}
|
||||||
`}
|
`}
|
||||||
>
|
>
|
||||||
|
|||||||
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>
|
||||||
|
);
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user