sozsoft-platform/ui/src/components/orders/PaymentForm.tsx
2026-08-14 17:03:34 +03:00

448 lines
20 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import React, { useEffect, useState } from 'react'
import { FaCreditCard, FaLock, FaArrowLeft, FaCalendarAlt, FaClock } from 'react-icons/fa'
import {
BillingCycle,
BasketItem,
InstallmentOptionDto,
PaymentMethodDto,
} from '@/proxy/order/models'
import { OrderService } from '@/services/order.service'
import { CustomTenantDto } from '@/proxy/config/models'
import { useLocalization } from '@/utils/hooks/useLocalization'
import { Button } from '@/components/ui'
interface BasketData {
items: BasketItem[]
subtotal: number
vatTotal: number
total: number
globalBillingCycle: BillingCycle
globalPeriod: number
}
interface PaymentFormProps {
tenant: CustomTenantDto
onBack: () => void
onComplete: (paymentData: Record<string, unknown>) => void
basketData: BasketData
}
export const PaymentForm: React.FC<PaymentFormProps> = ({ onBack, onComplete, basketData }) => {
const defaultPaymentMethod = 'CreditCard'
const [selectedPaymentMethod, setSelectedPaymentMethod] = useState<string>(defaultPaymentMethod)
const [selectedInstallment, setSelectedInstallment] = useState<InstallmentOptionDto>()
const [paymentData, setPaymentData] = useState({
cardNumber: '',
expiryDate: '',
cvv: '',
cardName: '',
})
const [licenseStartTime, setLicenseStartTime] = useState(() =>
new Date().toISOString().slice(0, 10),
)
const { translate } = useLocalization()
const [paymentMethods, setPaymentMethods] = useState<PaymentMethodDto[]>([])
const [installmentOptions, setInstallmentOptions] = useState<InstallmentOptionDto[]>([])
const [loading, setLoading] = useState<boolean>(true)
useEffect(() => {
const fetchData = async () => {
const orderService = new OrderService()
try {
const paymentResponse = await orderService.getPaymentMethodList()
setPaymentMethods(paymentResponse.data)
if (paymentResponse.data.length > 0) {
setSelectedPaymentMethod(paymentResponse.data[1].name)
}
const installmentResponse = await orderService.getInstallmentOptionList()
setInstallmentOptions(installmentResponse.data)
} catch (err) {
console.error('Ödeme şekilleri ve Komisyon Bilgileri alınamadı', err)
} finally {
setLoading(false)
}
}
fetchData()
}, [])
const selectedMethod = paymentMethods.find((m) => m.name === selectedPaymentMethod)
let commission = 0
if (selectedPaymentMethod === defaultPaymentMethod && selectedInstallment) {
commission = basketData.total * selectedInstallment.commission
} else if (selectedMethod) {
commission = basketData.total * selectedMethod.commission
}
const finalTotal = basketData.total + commission
const period = basketData.globalPeriod > 0 ? basketData.globalPeriod : 1
const billingCycle = basketData.globalBillingCycle || 'yearly'
const licenseEndTime = calculateLicenseEndTime(new Date(licenseStartTime), billingCycle, period)
const formatPrice = (price: number) =>
new Intl.NumberFormat('tr-TR', {
style: 'currency',
currency: 'TRY',
minimumFractionDigits: 2,
}).format(price)
const vatRateLabel = (() => {
const rates = Array.from(
new Set(basketData.items.map((item) => Math.round((item.vatRate ?? 0) * 100))),
).filter((rate) => rate > 0)
return rates.length > 0 ? ` (${rates.map((rate) => `%${rate}`).join(', ')})` : ''
})()
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault()
onComplete({
...paymentData,
licenseStartTime: new Date(licenseStartTime).toISOString(),
licenseEndTime: licenseEndTime.toISOString(),
paymentMethodId: selectedMethod?.id,
installment:
selectedPaymentMethod === defaultPaymentMethod
? (selectedInstallment?.installment ?? 1)
: 1,
commission,
total: finalTotal,
})
}
const handleInputChange = (field: string, value: string) => {
setPaymentData((prev) => ({ ...prev, [field]: value }))
}
function calculateLicenseEndTime(
startTime: Date,
selectedBillingCycle: string,
selectedPeriod: number,
) {
const stopTime = new Date(startTime)
if (selectedBillingCycle === 'monthly') {
stopTime.setMonth(stopTime.getMonth() + selectedPeriod)
} else {
stopTime.setFullYear(stopTime.getFullYear() + selectedPeriod)
}
return stopTime
}
const formatDate = (date: Date) =>
new Intl.DateTimeFormat('tr-TR', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
}).format(date)
const billingCycleLabel =
billingCycle === 'monthly'
? translate('::App.StaticLookup.Monthly')
: billingCycle === 'yearly'
? translate('::App.StaticLookup.Yearly')
: '-'
return (
<div className="mx-auto px-4 lg:px-8 mt-5">
{loading ? (
<div className="text-center py-12 text-gray-500">{translate('::App.Platform.LoadingWithThreeDot')}</div>
) : (
<form onSubmit={handleSubmit} className="flex flex-col lg:flex-row gap-6">
{/* 3 Sütun: Ödeme Yöntemi | Taksit Seçenekleri | Sipariş Özeti */}
<div className="w-full lg:w-1/3 flex flex-col gap-6">
<div className="bg-white rounded-xl shadow border p-6 dark:border-gray-700 dark:bg-gray-900 dark:shadow-gray-950/40">
<h2 className="text-lg font-semibold mb-4 flex items-center text-gray-900 dark:text-gray-100">
<FaCalendarAlt className="w-5 h-5 text-green-600 mr-2" />
Lisans Bilgileri
</h2>
<div className="space-y-4 pt-4 border-t border-gray-200 dark:border-gray-700">
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
License Start Time
</label>
<input
type="date"
required
value={licenseStartTime}
onChange={(e) => setLicenseStartTime(e.target.value)}
className="w-full px-4 py-2 border border-gray-300 rounded-lg bg-white text-gray-900 placeholder-gray-500 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-100 dark:placeholder-gray-400 focus:ring-2 focus:ring-blue-500 focus:border-transparent"
/>
</div>
<div className="grid grid-cols-1 gap-3 text-sm text-gray-700 dark:text-gray-300">
<div className="flex items-center justify-between rounded-lg bg-gray-50 px-4 py-3 dark:bg-gray-800/70">
<span className="font-medium">Period</span>
<span>{period}</span>
</div>
<div className="flex items-center justify-between rounded-lg bg-gray-50 px-4 py-3 dark:bg-gray-800/70">
<span className="font-medium">Billing Cycle</span>
<span>{billingCycleLabel}</span>
</div>
<div className="flex items-center justify-between rounded-lg bg-blue-50 px-4 py-3 text-blue-800 dark:bg-blue-950/40 dark:text-blue-200">
<span className="font-medium flex items-center gap-2">
<FaClock className="h-4 w-4" />
License End Time
</span>
<span>{formatDate(licenseEndTime)}</span>
</div>
</div>
</div>
</div>
</div>
<div className="w-full lg:w-1/3 flex flex-col gap-6">
{/* 1. Sütun: Ödeme Yöntemi */}
<div className="bg-white rounded-xl shadow border p-6 dark:border-gray-700 dark:bg-gray-900 dark:shadow-gray-950/40">
<h2 className="text-lg font-semibold mb-4 flex items-center">
<FaLock className="w-5 h-5 text-green-600 mr-2" />{' '}
{translate('::App.Listform.ListformField.PaymentMethodId')}
</h2>
<div className="space-y-3">
{paymentMethods.map((method) => (
<label
key={method.id}
className={`group flex items-center gap-3 rounded-xl border p-4 cursor-pointer transition-all ${
selectedPaymentMethod === method.name
? 'border-blue-500 bg-blue-50 shadow-sm ring-1 ring-blue-500/20 dark:border-blue-400 dark:bg-blue-950/30 dark:ring-blue-400/20'
: 'border-gray-200 bg-white hover:border-blue-300 hover:bg-gray-50 dark:border-gray-700 dark:bg-gray-950/40 dark:hover:border-blue-500 dark:hover:bg-gray-800/70'
}`}
>
<input
type="radio"
name="paymentMethodId"
value={method.name}
checked={selectedPaymentMethod === method.name}
onChange={(e) => {
setSelectedPaymentMethod(e.target.value)
}}
className="sr-only"
/>
<span
className={`flex h-10 w-10 shrink-0 items-center justify-center rounded-lg text-xl ${
selectedPaymentMethod === method.name
? 'bg-blue-100 text-blue-700 dark:bg-blue-900/50 dark:text-blue-200'
: 'bg-gray-100 text-gray-500 group-hover:bg-blue-50 group-hover:text-blue-600 dark:bg-gray-800 dark:text-gray-300 dark:group-hover:bg-blue-900/30 dark:group-hover:text-blue-200'
}`}
>
{method.logo}
</span>
<div className="min-w-0 flex-1">
<div className="font-semibold text-gray-900 dark:text-gray-100">
{method.name}
</div>
<div className="text-sm text-gray-600 dark:text-gray-400">
{method.name === defaultPaymentMethod
? translate('::App.PaymentMethod.InstallmentsAvailable')
: translate('::App.PaymentMethod.NoCommission')}
</div>
</div>
<span
className={`h-3 w-3 shrink-0 rounded-full border ${
selectedPaymentMethod === method.name
? 'border-blue-500 bg-blue-500 dark:border-blue-300 dark:bg-blue-300'
: 'border-gray-300 dark:border-gray-600'
}`}
/>
</label>
))}
</div>
</div>
{/* Taksit Seçenekleri */}
{selectedPaymentMethod === defaultPaymentMethod && (
<div className="bg-white rounded-xl shadow border p-4 dark:border-gray-700 dark:bg-gray-900 dark:shadow-gray-950/40">
<h3 className="text-md font-medium text-gray-800 dark:text-gray-100 mb-2">
{translate('::App.Orders.InstallmentOptions')}
</h3>
<div className="grid grid-cols-2 md:grid-cols-3 gap-3">
{installmentOptions.map((option) => (
<label
key={option.id}
className={`flex flex-col items-center justify-center h-full p-4 border-2 rounded-xl cursor-pointer transition-all text-xs md:text-sm text-center select-none ${
selectedInstallment?.installment === option.installment
? 'border-blue-500 bg-blue-50 shadow-md scale-105 dark:border-blue-400 dark:bg-blue-950/30 dark:shadow-blue-950/20'
: 'border-gray-200 bg-white hover:border-blue-200 hover:bg-gray-50 dark:border-gray-700 dark:bg-gray-950/40 dark:hover:border-blue-500 dark:hover:bg-gray-800/70'
}`}
>
<input
type="radio"
name="installment"
value={option.installment}
checked={selectedInstallment?.installment === option.installment}
onChange={() => setSelectedInstallment(option)}
className="sr-only"
/>
<div className="font-semibold text-base mb-1">{option.name}</div>
<div className="text-gray-500 mb-1">
{translate('::App.Listform.ListformField.Commission')}{' '}
<span className="font-semibold">
%{(option.commission * 100).toFixed(1)}
</span>
</div>
<div className="font-bold mb-1">
{option.installment > 1
? `${option.installment} ${translate('::App.PaymentInstallments.Monthly')}`
: translate('::App.PaymentInstallments.Single')}
</div>
<div className="font-extrabold text-lg text-gray-900 dark:text-gray-100 mt-1">
{formatPrice(
(basketData.total + basketData.total * option.commission) /
option.installment,
)}
</div>
</label>
))}
</div>
</div>
)}
</div>
{/* 3. Sütun: Kart Bilgileri + Sipariş Özeti ve Butonlar */}
<div className="w-full lg:w-1/3 flex flex-col gap-6">
{/* Kart Bilgileri */}
{selectedPaymentMethod === defaultPaymentMethod && (
<div className="bg-white rounded-xl shadow border p-6 dark:border-gray-700 dark:bg-gray-900 dark:shadow-gray-950/40 space-y-3">
<h3 className="text-md font-medium text-gray-800 dark:text-gray-100 mb-3">
{translate('::App.PaymentCard.CardTitle')}
</h3>
<input
type="text"
required
value={paymentData.cardName}
onChange={(e) => handleInputChange('cardName', e.target.value)}
className="w-full px-4 py-2 border border-gray-300 rounded-lg bg-white text-gray-900 placeholder-gray-500 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-100 dark:placeholder-gray-400"
placeholder={translate('::App.PaymentCard.CardName')}
/>
<input
type="text"
required
value={paymentData.cardNumber}
onChange={(e) => handleInputChange('cardNumber', e.target.value)}
className="w-full px-4 py-2 border border-gray-300 rounded-lg bg-white text-gray-900 placeholder-gray-500 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-100 dark:placeholder-gray-400"
placeholder={translate('::App.Listform.ListformField.CardNumber')}
maxLength={19}
/>
<div className="grid grid-cols-2 gap-3">
<input
type="text"
required
value={paymentData.expiryDate}
onChange={(e) => handleInputChange('expiryDate', e.target.value)}
className="w-full px-4 py-2 border border-gray-300 rounded-lg bg-white text-gray-900 placeholder-gray-500 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-100 dark:placeholder-gray-400"
placeholder="MM/YY"
maxLength={5}
/>
<input
type="text"
required
value={paymentData.cvv}
onChange={(e) => handleInputChange('cvv', e.target.value)}
className="w-full px-4 py-2 border border-gray-300 rounded-lg bg-white text-gray-900 placeholder-gray-500 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-100 dark:placeholder-gray-400"
placeholder="CVV"
maxLength={4}
/>
</div>
</div>
)}
{/* Sipariş Özeti ve Butonlar */}
<div className="bg-white rounded-xl shadow border p-6 dark:border-gray-700 dark:bg-gray-900 dark:shadow-gray-950/40">
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-4">
{translate('::App.PaymentSummary.SummaryTitle')}
</h3>
<div className="space-y-4 mb-4">
{basketData.items.map((item, index) => (
<div key={`${item.product.id}-${index}`} className="text-sm">
<div className="flex items-center justify-between gap-4">
<div className="flex min-w-0 items-center gap-3">
{item.product.imageUrl && (
<img
src={item.product.imageUrl}
alt={translate('::' + item.product.name)}
className="h-10 w-10 shrink-0 rounded-md object-cover"
/>
)}
<div className="min-w-0">
<div className="truncate font-medium">
{translate('::' + item.product.name)}
</div>
{item.product.isQuantityBased && (
<div className="mt-1 text-xs font-medium text-gray-500 dark:text-gray-400">
x {item.quantity}
</div>
)}
</div>
</div>
<div className="font-medium text-right">{formatPrice(item.totalPrice)}</div>
</div>
</div>
))}
</div>
<div className="space-y-1 text-sm border-t border-gray-200 dark:border-gray-700 pt-4 text-gray-600 dark:text-gray-300">
<div className="flex justify-between">
<span>{translate('::App.Listform.ListformField.Subtotal')}</span>
<span>{formatPrice(basketData.subtotal)}</span>
</div>
<div className="flex justify-between">
<span>
{translate('::App.PublicProducts.Kdv')}
{vatRateLabel}
</span>
<span>{formatPrice(basketData.vatTotal)}</span>
</div>
<div className="flex justify-between">
<span>{translate('::App.Listform.ListformField.Commission')}</span>
<span>{formatPrice(commission)}</span>
</div>
{selectedPaymentMethod === defaultPaymentMethod &&
selectedInstallment?.installment &&
selectedInstallment.installment > 1 && (
<div className="flex justify-between text-blue-600">
<span>{translate('::App.PaymentSummary.MonthlyInstallment')}: </span>
<span>
{formatPrice(finalTotal / selectedInstallment.installment)} x{' '}
{selectedInstallment.installment}
</span>
</div>
)}
<div className="flex justify-between text-base font-bold pt-2 text-gray-900 dark:text-gray-100">
<span>{translate('::App.Listform.ListformField.Total')}</span>
<span className="text-blue-600">{formatPrice(finalTotal)}</span>
</div>
</div>
{/* Butonlar */}
<div className="flex justify-between items-center mt-6">
<Button
type="button"
onClick={onBack}
icon={<FaArrowLeft className="w-4 h-4" />}
variant="default"
size="sm"
>
{translate('::App.Platform.Back')}
</Button>
<Button
type="submit"
icon={<FaCreditCard className="w-5 h-5" />}
variant="solid"
size="sm"
>
{selectedPaymentMethod === 'bank-transfer'
? translate('::App.PaymentButtons.CompleteOrder')
: translate('::App.PaymentButtons.Pay')}
</Button>
</div>
</div>
</div>
</form>
)}
</div>
)
}