card to card

This commit is contained in:
hamid zarghami
2026-06-15 11:02:05 +03:30
parent da896b8772
commit 2229c13784
8 changed files with 208 additions and 30 deletions
@@ -1,44 +1,51 @@
'use client'; 'use client';
import { Card, Icon, Wallet2 } from 'iconsax-react'; import { Card, Icon, Wallet2 } from 'iconsax-react';
import { useCallback, useMemo } from 'react'; import { useCallback, useMemo, useRef } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group'; import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group';
import { useGetPaymentMethod } from '../../hooks/useOrderData'; import { useGetPaymentMethod } from '../../hooks/useOrderData';
import { useCheckoutStore } from '../../store/Store'; import { useCheckoutStore } from '../../store/Store';
import { useGetUserWallet } from '@/app/[name]/(Main)/transactions/hooks/useTransactionData'; import { useGetUserWallet } from '@/app/[name]/(Main)/transactions/hooks/useTransactionData';
import { useGetCartItems } from '@/app/[name]/(Main)/cart/hooks/useCartData'; import { useGetCartItems } from '@/app/[name]/(Main)/cart/hooks/useCartData';
import { useGetProfile } from '@/app/[name]/(Profile)/profile/hooks/userProfileData'; import { useGetProfile, useSingleUpload } from '@/app/[name]/(Profile)/profile/hooks/userProfileData';
import { PaymentMethodEnum } from '../../enum/Enum';
import { toast } from '@/components/Toast';
import { extractErrorMessage } from '@/lib/func';
import Image from 'next/image';
type PaymentSectionProps = { type PaymentSectionProps = {
paymentType: string; paymentType: string;
onPaymentTypeChange: (id: string) => void; onPaymentTypeChange: (id: string) => void;
}; };
// eslint-disable-next-line @typescript-eslint/no-unused-vars const getIconByMethod = (method: PaymentMethodEnum): Icon => {
const getIconByMethod = (method: "CardOnDelivery" | "Cash" | "Online", _gateway: string | null): Icon => { if (method === PaymentMethodEnum.Online || method === PaymentMethodEnum.CreditCard) {
if (method === "Online" || method === "CardOnDelivery") {
return Card; return Card;
} }
if (method === "Cash") { if (method === PaymentMethodEnum.Cash || method === PaymentMethodEnum.Wallet) {
return Wallet2; return Wallet2;
} }
return Card; return Card;
}; };
const isWalletPayment = (method: string, gateway: string | null): boolean => {
return gateway?.toLowerCase() === 'wallet' || method.toLowerCase() === 'wallet';
};
export const PaymentSection = ({ paymentType, onPaymentTypeChange }: PaymentSectionProps) => { export const PaymentSection = ({ paymentType, onPaymentTypeChange }: PaymentSectionProps) => {
const { t: tOrderDetail } = useTranslation('parallels', { keyPrefix: 'OrderDetail' }); const { t: tOrderDetail } = useTranslation('parallels', { keyPrefix: 'OrderDetail' });
const { t: tOrders } = useTranslation('orders'); const { t: tOrders } = useTranslation('orders');
const { data: paymentMethod } = useGetPaymentMethod(); const { data: paymentMethod } = useGetPaymentMethod();
const { setIsSelectedPayment } = useCheckoutStore(); const {
setIsSelectedPayment,
paymentDesc,
setPaymentDesc,
paymentAttachments,
setPaymentAttachments,
} = useCheckoutStore();
const { data: userWallet } = useGetUserWallet(); const { data: userWallet } = useGetUserWallet();
const { isSuccess } = useGetProfile(); const { isSuccess } = useGetProfile();
const { data: cartData } = useGetCartItems(isSuccess); const { data: cartData } = useGetCartItems(isSuccess);
const { mutateAsync: singleUpload, isPending: isUploading } = useSingleUpload();
const fileInputRef = useRef<HTMLInputElement>(null);
const walletAmount = userWallet?.data?.balance ?? 0; const walletAmount = userWallet?.data?.balance ?? 0;
const totalAmount = cartData?.data?.total ?? 0; const totalAmount = cartData?.data?.total ?? 0;
@@ -63,14 +70,50 @@ export const PaymentSection = ({ paymentType, onPaymentTypeChange }: PaymentSect
.sort((a, b) => a.order - b.order) .sort((a, b) => a.order - b.order)
.map(item => ({ .map(item => ({
id: item.id, id: item.id,
label: getPaymentMethodTranslation(item.method, item.title), label: getPaymentMethodTranslation(item.method, item.description),
icon: getIconByMethod(item.method, item.gateway), icon: getIconByMethod(item.method),
method: item.method, method: item.method,
gateway: item.gateway, cardNumber: item.cardNumber,
isWallet: isWalletPayment(item.method, item.gateway), description: item.description,
isWallet: item.method === PaymentMethodEnum.Wallet,
})); }));
}, [paymentMethod, getPaymentMethodTranslation]); }, [paymentMethod, getPaymentMethodTranslation]);
const selectedCreditCard = useMemo(() => {
const selected = paymentOptions.find(opt => opt.id === paymentType);
if (selected?.method === PaymentMethodEnum.CreditCard) return selected;
return null;
}, [paymentOptions, paymentType]);
const handlePaymentTypeChange = (value: string) => {
const selected = paymentOptions.find(opt => opt.id === value);
if (selected?.method !== PaymentMethodEnum.CreditCard) {
setPaymentDesc('');
setPaymentAttachments([]);
}
onPaymentTypeChange(value);
setIsSelectedPayment(true);
};
const handleReceiptUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
try {
const response = await singleUpload(file);
if (response?.data?.url) {
setPaymentAttachments([response.data.url]);
toast(tOrderDetail('SectionPayment.CreditCard.UploadSuccess'), 'success');
}
} catch (error) {
toast(extractErrorMessage(error), 'error');
} finally {
if (fileInputRef.current) {
fileInputRef.current.value = '';
}
}
};
return ( return (
<section className="bg-container rounded-container box-shadow-normal p-4"> <section className="bg-container rounded-container box-shadow-normal p-4">
<div className="py-3"> <div className="py-3">
@@ -78,15 +121,11 @@ export const PaymentSection = ({ paymentType, onPaymentTypeChange }: PaymentSect
<RadioGroup <RadioGroup
value={paymentType} value={paymentType}
onValueChange={(value) => { onValueChange={handlePaymentTypeChange}
onPaymentTypeChange(value);
setIsSelectedPayment(true);
}}
className='flex flex-col gap-6 mt-6' className='flex flex-col gap-6 mt-6'
> >
{paymentOptions.map(({ id, label, icon: Icon, isWallet }) => { {paymentOptions.map(({ id, label, icon: Icon, isWallet }) => {
const isDisabled = isWallet && isWalletInsufficient; const isDisabled = isWallet && isWalletInsufficient;
const walletOption = paymentOptions.find(opt => opt.id === id && opt.isWallet);
return ( return (
<div key={id} className='flex items-center justify-between'> <div key={id} className='flex items-center justify-between'>
@@ -100,12 +139,11 @@ export const PaymentSection = ({ paymentType, onPaymentTypeChange }: PaymentSect
htmlFor={`paymentMethod${id}`} htmlFor={`paymentMethod${id}`}
> >
<div className='flex gap-1'> <div className='flex gap-1'>
{walletOption && ( {isWallet && (
<span className='text-xs text-gray-500 dark:text-gray-400 mt-1'> <span className='text-xs text-gray-500 dark:text-gray-400 mt-1'>
({formatPrice(walletAmount)} T) ({formatPrice(walletAmount)} T)
</span> </span>
)} )}
<span className='text-xs mt-0.5'>{label}</span> <span className='text-xs mt-0.5'>{label}</span>
</div> </div>
<Icon <Icon
@@ -118,8 +156,83 @@ export const PaymentSection = ({ paymentType, onPaymentTypeChange }: PaymentSect
); );
})} })}
</RadioGroup> </RadioGroup>
{selectedCreditCard && (
<div className='mt-6 pt-4 border-t border-border flex flex-col gap-4'>
{selectedCreditCard.cardNumber && (
<div>
<span className='text-xs text-gray-500 dark:text-gray-400'>
{tOrderDetail('SectionPayment.CreditCard.CardNumberLabel')}
</span>
<p className='text-sm font-medium mt-1' dir='ltr'>
{selectedCreditCard.cardNumber}
</p>
</div>
)}
{selectedCreditCard.description && (
<p className='text-xs text-gray-500 dark:text-gray-400 leading-5'>
{selectedCreditCard.description}
</p>
)}
<div>
<label className='text-xs text-gray-500 dark:text-gray-400' htmlFor='paymentDesc'>
{tOrderDetail('SectionPayment.CreditCard.PaymentDescLabel')}
</label>
<textarea
id='paymentDesc'
className='w-full px-4 py-2.5 mt-2 text-sm2 leading-6 outline-0 border border-border rounded-normal resize-none focus:ring-0'
placeholder={tOrderDetail('SectionPayment.CreditCard.PaymentDescPlaceholder')}
value={paymentDesc}
onChange={(e) => setPaymentDesc(e.target.value)}
rows={2}
/>
</div>
<div>
<span className='text-xs text-gray-500 dark:text-gray-400'>
{tOrderDetail('SectionPayment.CreditCard.ReceiptLabel')}
</span>
<input
ref={fileInputRef}
type='file'
accept='image/*'
className='hidden'
onChange={handleReceiptUpload}
/>
{paymentAttachments[0] ? (
<div className='mt-2 relative w-full h-32 rounded-normal overflow-hidden border border-border'>
<Image
src={paymentAttachments[0]}
alt='receipt'
fill
className='object-contain'
/>
<button
type='button'
className='absolute top-2 left-2 text-xs bg-white/90 dark:bg-background/90 px-2 py-1 rounded-normal'
onClick={() => fileInputRef.current?.click()}
disabled={isUploading}
>
{isUploading
? tOrderDetail('SectionPayment.CreditCard.Uploading')
: tOrderDetail('SectionPayment.CreditCard.UploadReceipt')}
</button>
</div>
) : (
<button
type='button'
className='w-full mt-2 py-3 text-sm2 border border-dashed border-border rounded-normal text-gray-500 dark:text-gray-400 hover:border-primary transition-colors'
onClick={() => fileInputRef.current?.click()}
disabled={isUploading}
>
{isUploading
? tOrderDetail('SectionPayment.CreditCard.Uploading')
: tOrderDetail('SectionPayment.CreditCard.UploadReceipt')}
</button>
)}
</div>
</div>
)}
</div> </div>
</section> </section>
); );
}; };
@@ -12,9 +12,10 @@ import { useParams } from 'next/navigation';
import { useGetCartItems, useSaveAllMethod } from '@/app/[name]/(Main)/cart/hooks/useCartData'; import { useGetCartItems, useSaveAllMethod } from '@/app/[name]/(Main)/cart/hooks/useCartData';
import { useGetProfile } from '@/app/[name]/(Profile)/profile/hooks/userProfileData'; import { useGetProfile } from '@/app/[name]/(Profile)/profile/hooks/userProfileData';
import { useCallback, useMemo } from 'react'; import { useCallback, useMemo } from 'react';
import { useGetShipmentMethod } from '../../hooks/useOrderData'; import { useGetShipmentMethod, useGetPaymentMethod } from '../../hooks/useOrderData';
import CartItemsList from '@/app/[name]/(Main)/cart/components/CartItemsList'; import CartItemsList from '@/app/[name]/(Main)/cart/components/CartItemsList';
import { useCartStore } from '@/app/[name]/(Main)/cart/store/Store'; import { useCartStore } from '@/app/[name]/(Main)/cart/store/Store';
import { PaymentMethodEnum } from '../../enum/Enum';
type SummarySectionProps = { type SummarySectionProps = {
cartModal: boolean; cartModal: boolean;
@@ -25,7 +26,7 @@ type SummarySectionProps = {
export const SummarySection = ({ cartModal, onToggleCartModal, deliveryType }: SummarySectionProps) => { export const SummarySection = ({ cartModal, onToggleCartModal, deliveryType }: SummarySectionProps) => {
const { t } = useTranslation('parallels', { keyPrefix: 'OrderDetail' }); const { t } = useTranslation('parallels', { keyPrefix: 'OrderDetail' });
const { isSelectedShipment, isSelectedPayment, deliveryType: storeDeliveryType, paymentType, selectedAddressId, tableNumber, carAddress, clear } = useCheckoutStore(); const { isSelectedShipment, isSelectedPayment, deliveryType: storeDeliveryType, paymentType, selectedAddressId, tableNumber, carAddress, paymentDesc, paymentAttachments, clear } = useCheckoutStore();
const { description, reset: resetCart } = useCartStore(); const { description, reset: resetCart } = useCartStore();
const { mutate: createOrder, isPending: isCreateOrderPending } = useCreateOrder(); const { mutate: createOrder, isPending: isCreateOrderPending } = useCreateOrder();
const { mutate: saveAllMethod, isPending: isSaveAllMethodPending } = useSaveAllMethod() const { mutate: saveAllMethod, isPending: isSaveAllMethodPending } = useSaveAllMethod()
@@ -34,6 +35,7 @@ export const SummarySection = ({ cartModal, onToggleCartModal, deliveryType }: S
const { isSuccess } = useGetProfile(); const { isSuccess } = useGetProfile();
const { data: cartData } = useGetCartItems(isSuccess); const { data: cartData } = useGetCartItems(isSuccess);
const { data: shipmentMethod } = useGetShipmentMethod(); const { data: shipmentMethod } = useGetShipmentMethod();
const { data: paymentMethod } = useGetPaymentMethod();
const selectedDeliveryMethod = useMemo(() => { const selectedDeliveryMethod = useMemo(() => {
if (!shipmentMethod?.data || !deliveryType) return null; if (!shipmentMethod?.data || !deliveryType) return null;
@@ -52,6 +54,17 @@ export const SummarySection = ({ cartModal, onToggleCartModal, deliveryType }: S
return selectedDeliveryMethod?.method === 'dineIn'; return selectedDeliveryMethod?.method === 'dineIn';
}, [selectedDeliveryMethod]); }, [selectedDeliveryMethod]);
const selectedPaymentMethod = useMemo(() => {
if (!paymentMethod?.data || !paymentType) return null;
return paymentMethod.data.find((item) => item.id === paymentType);
}, [paymentMethod?.data, paymentType]);
const isCreditCardPayment = selectedPaymentMethod?.method === PaymentMethodEnum.CreditCard;
const isCreditCardIncomplete = isCreditCardPayment && (
!paymentDesc.trim() || paymentAttachments.length === 0
);
const formatPrice = useCallback( const formatPrice = useCallback(
(value: number) => value.toLocaleString('fa-IR'), (value: number) => value.toLocaleString('fa-IR'),
[] []
@@ -91,6 +104,17 @@ export const SummarySection = ({ cartModal, onToggleCartModal, deliveryType }: S
return; return;
} }
if (isCreditCardPayment) {
if (!paymentDesc.trim()) {
toast('لطفا توضیحات پرداخت را وارد کنید', 'error');
return;
}
if (paymentAttachments.length === 0) {
toast('لطفا تصویر رسید پرداخت را آپلود کنید', 'error');
return;
}
}
// if (!isDeliveryCar && !isDineIn && !selectedAddressId) { // if (!isDeliveryCar && !isDineIn && !selectedAddressId) {
// toast('لطفا آدرس را انتخاب کنید', 'error'); // toast('لطفا آدرس را انتخاب کنید', 'error');
// return; // return;
@@ -103,6 +127,10 @@ export const SummarySection = ({ cartModal, onToggleCartModal, deliveryType }: S
tableNumber: isDineIn ? tableNumber.toString() : undefined, tableNumber: isDineIn ? tableNumber.toString() : undefined,
...(isCourierDelivery && selectedAddressId && { addressId: selectedAddressId }), ...(isCourierDelivery && selectedAddressId && { addressId: selectedAddressId }),
...(isDeliveryCar && carAddress && { carAddress }), ...(isDeliveryCar && carAddress && { carAddress }),
...(isCreditCardPayment && {
attachments: paymentAttachments,
paymentDesc: paymentDesc.trim(),
}),
}; };
saveAllMethod(saveAllMethodData, { saveAllMethod(saveAllMethodData, {
onSuccess: () => { onSuccess: () => {
@@ -192,7 +220,12 @@ export const SummarySection = ({ cartModal, onToggleCartModal, deliveryType }: S
<div className='text-xs text-gray-400 dark:text-gray-500'>{t("PayableAmountLabel")}</div> <div className='text-xs text-gray-400 dark:text-gray-500'>{t("PayableAmountLabel")}</div>
<div className='text-sm mt-2 font-semibold'>{formatPrice(total)} تومان</div> <div className='text-sm mt-2 font-semibold'>{formatPrice(total)} تومان</div>
</div> </div>
<Button pending={isCreateOrderPending || isSaveAllMethodPending} onClick={handleSubmit} className='px-10 w-fit dark:bg-white dark:text-black! dark:hover:bg-gray-100'> <Button
pending={isCreateOrderPending || isSaveAllMethodPending}
disabled={isCreditCardIncomplete}
onClick={handleSubmit}
className='px-10 w-fit dark:bg-white dark:text-black! dark:hover:bg-gray-100'
>
{t("ButtonSubmit")} {t("ButtonSubmit")}
</Button> </Button>
</div> </div>
@@ -1,3 +1,10 @@
export enum PaymentMethodEnum {
Online = "Online",
Cash = "Cash",
Wallet = "Wallet",
CreditCard = "CreditCard",
}
export const enum OrderStatus { export const enum OrderStatus {
NEW = "new", NEW = "new",
PENDING_PAYMENT = "pendingPayment", PENDING_PAYMENT = "pendingPayment",
@@ -15,6 +15,8 @@ const initialState = {
carColor: string; carColor: string;
plateNumber: string; plateNumber: string;
} | null, } | null,
paymentDesc: "",
paymentAttachments: [] as string[],
lastUpdated: null as number | null, lastUpdated: null as number | null,
}; };
@@ -38,6 +40,10 @@ export const useCheckoutStore = create<CheckoutStoreType>()(
set({ tableNumber: value, lastUpdated: Date.now() }), set({ tableNumber: value, lastUpdated: Date.now() }),
setCarAddress: (value) => setCarAddress: (value) =>
set({ carAddress: value, lastUpdated: Date.now() }), set({ carAddress: value, lastUpdated: Date.now() }),
setPaymentDesc: (value) =>
set({ paymentDesc: value, lastUpdated: Date.now() }),
setPaymentAttachments: (value) =>
set({ paymentAttachments: value, lastUpdated: Date.now() }),
setLastUpdated: (value) => set({ lastUpdated: value }), setLastUpdated: (value) => set({ lastUpdated: value }),
clear: () => set({ ...initialState }), clear: () => set({ ...initialState }),
}), }),
@@ -1,4 +1,5 @@
import { BaseResponse } from "@/app/[name]/(Main)/types/Types"; import { BaseResponse } from "@/app/[name]/(Main)/types/Types";
import { PaymentMethodEnum } from "../enum/Enum";
export interface ShipmentMethod { export interface ShipmentMethod {
id: string; id: string;
@@ -107,10 +108,10 @@ export interface PaymentMethodResponse {
updatedAt: string; updatedAt: string;
deletedAt: string | null; deletedAt: string | null;
restaurant: Restaurant; restaurant: Restaurant;
title: string; method: PaymentMethodEnum;
method: "CardOnDelivery" | "Cash" | "Online";
gateway: string | null; gateway: string | null;
description: string; description: string;
cardNumber: string | null;
enabled: boolean; enabled: boolean;
order: number; order: number;
merchantId: string | null; merchantId: string | null;
@@ -145,6 +146,10 @@ export type CheckoutStoreType = {
plateNumber: string; plateNumber: string;
} | null } | null
) => void; ) => void;
paymentDesc: string;
setPaymentDesc: (value: string) => void;
paymentAttachments: string[];
setPaymentAttachments: (value: string[]) => void;
lastUpdated: number | null; lastUpdated: number | null;
setLastUpdated: (value: number | null) => void; setLastUpdated: (value: number | null) => void;
clear: () => void; clear: () => void;
@@ -353,9 +358,10 @@ export interface OrderDetailPaymentMethod {
updatedAt: string; updatedAt: string;
deletedAt: string | null; deletedAt: string | null;
restaurant: string; restaurant: string;
method: "CardOnDelivery" | "Cash" | "Online"; method: PaymentMethodEnum;
gateway: string | null; gateway: string | null;
description: string; description: string;
cardNumber: string | null;
enabled: boolean; enabled: boolean;
order: number; order: number;
merchantId: string | null; merchantId: string | null;
@@ -91,11 +91,14 @@ export type SaveAllMethod = {
deliveryMethodId: string; deliveryMethodId: string;
addressId?: string; addressId?: string;
paymentMethodId: string; paymentMethodId: string;
tableNumber?: string;
carAddress?: { carAddress?: {
carModel: string; carModel: string;
carColor: string; carColor: string;
plateNumber: string; plateNumber: string;
}; };
attachments?: string[];
paymentDesc?: string;
}; };
export type CartStoreType = { export type CartStoreType = {
+2 -1
View File
@@ -17,7 +17,8 @@
"paymentMethod": { "paymentMethod": {
"Cash": "پرداخت نقدی", "Cash": "پرداخت نقدی",
"Online": "پرداخت آنلاین", "Online": "پرداخت آنلاین",
"Wallet": "پرداخت با کیف پول" "Wallet": "پرداخت با کیف پول",
"CreditCard": "کارت به کارت"
}, },
"status": { "status": {
"new": "سفارش جدید", "new": "سفارش جدید",
+9
View File
@@ -67,6 +67,15 @@
}, },
"SectionPayment": { "SectionPayment": {
"Title": "انتخاب شیوه پرداخت", "Title": "انتخاب شیوه پرداخت",
"CreditCard": {
"CardNumberLabel": "شماره کارت",
"PaymentDescLabel": "توضیحات پرداخت",
"PaymentDescPlaceholder": "مثال: واریز از حساب با شماره ۱۲۳۴",
"ReceiptLabel": "تصویر رسید پرداخت",
"UploadReceipt": "آپلود رسید",
"Uploading": "در حال آپلود...",
"UploadSuccess": "رسید با موفقیت آپلود شد"
},
"InputPaymentType": { "InputPaymentType": {
"Label": "", "Label": "",
"Options": { "Options": {