payment modal complated
This commit is contained in:
@@ -30,6 +30,7 @@ export function usePlagiarismForm() {
|
||||
|
||||
const [form, setForm] = useState<PlagiarismFormState>(INITIAL_FORM);
|
||||
const [errors, setErrors] = useState<PlagiarismFormErrors>({});
|
||||
const [isPaymentOpen, setIsPaymentOpen] = useState(false);
|
||||
const [submission, setSubmission] =
|
||||
useState<SubmissionState>(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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
<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 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>
|
||||
<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.
|
||||
|
||||
{/* ── 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>
|
||||
|
||||
{/* ── 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={handleSubmit}
|
||||
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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
<div
|
||||
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'}
|
||||
`}
|
||||
>
|
||||
|
||||
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