From 04b000eedea7e7ccd57709a2ff31630ff20114ea Mon Sep 17 00:00:00 2001 From: nghiacc Date: Wed, 4 Feb 2026 08:27:06 +0700 Subject: [PATCH 01/13] feat: Implement external payment UI for Goods & Services offers - Added GoodsServicesPaymentType enum to frontend constants (IN_APP, EXTERNAL) - Added LIST_GOODS_SERVICES_PAYMENT_TYPE array with labels and descriptions for payment type selector - Updated CreateOfferModal: - Added paymentTypeGoodsServices field to form interface and default values - Added radio button UI to select payment type when creating G&S offers - Included field in CreateOfferInput when submitting - Updated PlaceAnOrderModal: - Detect external payment offers - Skip buyer deposit flow for external payment orders - Display informational banner about external payment process - Updated order-detail page: - Detect external payment orders - Added handleBuyerConfirmReceipt() function for buyer to confirm receipt - Show different UI for external payment: - Seller view: Message about collateral being held, Dispute button only - Buyer view: "Confirm Receipt" button to release collateral back to seller --- .../src/app/order-detail/page.tsx | 243 +++++++++++++----- .../CreateOfferModal/CreateOfferModal.tsx | 43 ++++ .../PlaceAnOrderModal/PlaceAnOrderModal.tsx | 41 ++- .../src/store/constants.ts | 23 ++ 4 files changed, 289 insertions(+), 61 deletions(-) diff --git a/apps/telegram-ecash-escrow/src/app/order-detail/page.tsx b/apps/telegram-ecash-escrow/src/app/order-detail/page.tsx index 15651df..0f46229 100644 --- a/apps/telegram-ecash-escrow/src/app/order-detail/page.tsx +++ b/apps/telegram-ecash-escrow/src/app/order-detail/page.tsx @@ -7,7 +7,7 @@ import MobileLayout from '@/src/components/layout/MobileLayout'; import QRCode from '@/src/components/QRcode/QRcode'; import TelegramButton from '@/src/components/TelegramButton/TelegramButton'; import TickerHeader from '@/src/components/TickerHeader/TickerHeader'; -import { securityDepositPercentage } from '@/src/store/constants'; +import { GoodsServicesPaymentType, securityDepositPercentage } from '@/src/store/constants'; import { SettingContext } from '@/src/store/context/settingProvider'; import { UtxoContext } from '@/src/store/context/utxoProvider'; import { buildReleaseTx, buildReturnFeeTx, buildReturnTx, sellerBuildDepositTx } from '@/src/store/escrow'; @@ -150,6 +150,11 @@ const OrderDetail = () => { const isBuyOffer = currentData?.escrowOrder?.escrowOffer?.type === OfferType.Buy; + // Check if this is an external payment order (seller escrows as collateral) + const isExternalPaymentOrder = + (currentData?.escrowOrder?.escrowOffer as { paymentTypeGoodsServices?: string })?.paymentTypeGoodsServices === + GoodsServicesPaymentType.EXTERNAL; + useEffect(() => { if ( currentData?.escrowOrder.escrowOrderStatus !== EscrowOrderStatus.Complete && @@ -385,6 +390,54 @@ const OrderDetail = () => { setLoading(false); }; + /** + * Handler for buyer to confirm receipt in external payment orders + * This releases the seller's collateral back to the seller + */ + const handleBuyerConfirmReceipt = async () => { + setLoading(true); + + if (currentData?.escrowOrder.escrowOrderStatus === EscrowOrderStatus.Complete) { + dispatch( + showToast('warning', { + message: 'warning', + description: 'Order has already been completed!' + }) + ); + return; + } + + try { + const buyerSk = fromHex(selectedWalletPath?.privateKey); + const buyerPk = fromHex(selectedWalletPath.publicKey as string); + const buyerPkh = shaRmd160(buyerPk); + const nonce = currentData?.escrowOrder.nonce as string; + // Use BUYER_RETURN action to sign (XEC goes back to seller) + const buyerSignatory = SignOracleSignatory(buyerSk, ACTION.BUYER_RETURN, nonce); + + await updateEscrowOrderSignatoryTrigger({ + input: { + orderId: id!, + action: 'BUYER_CONFIRM_RECEIPT' as EscrowOrderAction, + signatory: hexEncode(buyerSignatory), + signatoryOwnerHash160: hexEncode(buyerPkh) + } + }).unwrap(); + + dispatch( + showToast('success', { + message: 'success', + description: 'Receipt confirmed! Seller collateral released.' + }) + ); + } catch (e) { + console.log(e); + showError(); + } + + setLoading(false); + }; + const handleBuyerClaimEscrow = async () => { setLoading(true); try { @@ -1157,74 +1210,144 @@ const OrderDetail = () => { // Default escrow state if (isSeller) { - state.statusComponent = ( - - Only release the escrowed funds once you have confirmed that the buyer has completed the payment or - goods/services. - - ); - state.actionButtons = ( -
- - -
- ); - } else { - state.statusColor = '#66bb6a'; - state.statusComponent = ( - - - Successfully Escrowed! + // For external payment, seller sees different message (they deposited as collateral) + if (isExternalPaymentOrder) { + state.statusComponent = ( + + Your XEC collateral is held in escrow. The buyer will confirm receipt after you deliver the + goods/services. Your collateral will be released back to you upon confirmation. - - - - - - {`${currentData.escrowOrder.amount} XEC has been safely locked. You are now safe to send payments or goods to settle the order.`} + ); + state.actionButtons = ( +
+ +
+ ); + } else { + state.statusComponent = ( + + Only release the escrowed funds once you have confirmed that the buyer has completed the payment or + goods/services. -
- ); - state.actionButtons = ( -
- {telegramButton('Chat with seller for payment details')} + ); + state.actionButtons = (
- {currentData.escrowOrder?.markAsPaid ? ( - - ) : ( - - )} +
-
- ); + ); + } + } else { + state.statusColor = '#66bb6a'; + + // For external payment, buyer sees different message and actions + if (isExternalPaymentOrder) { + state.statusComponent = ( + + + Seller Collateral Escrowed! + + + + + + + {`${currentData.escrowOrder.amount} XEC has been locked as seller's collateral.`} + + + Pay the seller externally for the goods/services. Once you receive them, click "Confirm Receipt" to + release the collateral back to the seller. + + + ); + state.actionButtons = ( +
+ {telegramButton('Chat with seller for payment details')} +
+ + +
+
+ ); + } else { + state.statusComponent = ( + + + Successfully Escrowed! + + + + + + + {`${currentData.escrowOrder.amount} XEC has been safely locked. You are now safe to send payments or goods to settle the order.`} + + + ); + state.actionButtons = ( +
+ {telegramButton('Chat with seller for payment details')} +
+ {currentData.escrowOrder?.markAsPaid ? ( + + ) : ( + + )} + +
+
+ ); + } } break; diff --git a/apps/telegram-ecash-escrow/src/components/CreateOfferModal/CreateOfferModal.tsx b/apps/telegram-ecash-escrow/src/components/CreateOfferModal/CreateOfferModal.tsx index 5f78429..d9e7e59 100644 --- a/apps/telegram-ecash-escrow/src/components/CreateOfferModal/CreateOfferModal.tsx +++ b/apps/telegram-ecash-escrow/src/components/CreateOfferModal/CreateOfferModal.tsx @@ -4,7 +4,9 @@ import { COIN_OTHERS, COIN_USD_STABLECOIN_TICKER, DEFAULT_TICKER_GOODS_SERVICES, + GoodsServicesPaymentType, LIST_COIN, + LIST_GOODS_SERVICES_PAYMENT_TYPE, LIST_TICKER_GOODS_SERVICES } from '@/src/store/constants'; import { LIST_PAYMENT_APP } from '@/src/store/constants/list-payment-app'; @@ -262,6 +264,7 @@ interface OfferFormValues { priceCoinOthers: number | null; priceGoodsServices?: number | null; tickerPriceGoodsServices?: string | null; + paymentTypeGoodsServices?: GoodsServicesPaymentType | null; percentage: number; note: string; country: Country | null; @@ -340,6 +343,7 @@ const CreateOfferModal: React.FC = props => { priceCoinOthers: offer?.priceCoinOthers ?? null, priceGoodsServices: offer?.priceGoodsServices ?? null, tickerPriceGoodsServices: offer?.tickerPriceGoodsServices ?? DEFAULT_TICKER_GOODS_SERVICES, + paymentTypeGoodsServices: offer?.paymentTypeGoodsServices ?? GoodsServicesPaymentType.IN_APP, percentage: offer?.marginPercentage ?? 0, note: offer?.noteOffer ?? '', country: null, @@ -354,6 +358,7 @@ const CreateOfferModal: React.FC = props => { const percentageValue = watch('percentage'); const currencyValue = watch('currency'); const coinValue = watch('coin'); + const paymentTypeGoodsServicesValue = watch('paymentTypeGoodsServices'); const isGoodService = option === PAYMENT_METHOD.GOODS_SERVICES; @@ -396,6 +401,7 @@ const CreateOfferModal: React.FC = props => { ? getNumberFromFormatNumber(data.priceGoodsServices as unknown as string) : 0, tickerPriceGoodsServices: data?.tickerPriceGoodsServices ? data.tickerPriceGoodsServices : null, + paymentTypeGoodsServices: data?.paymentTypeGoodsServices ?? null, localCurrency: data?.currency ? data.currency.split(':')[0] : null, paymentApp: data?.paymentApp ? data.paymentApp : null, marginPercentage: Number(data?.percentage ?? 0), @@ -1228,6 +1234,43 @@ const CreateOfferModal: React.FC = props => { )} /> + + {/* Payment Type for Goods & Services */} + + + Payment Type + + + + + ( + onChange(e.target.value)}> + {LIST_GOODS_SERVICES_PAYMENT_TYPE.map(item => ( + } + label={ +
+ + {item.label} + + + {item.description} + +
+ } + disabled={isEdit} + sx={{ alignItems: 'flex-start', mb: 1 }} + /> + ))} +
+ )} + /> +
)} diff --git a/apps/telegram-ecash-escrow/src/components/PlaceAnOrderModal/PlaceAnOrderModal.tsx b/apps/telegram-ecash-escrow/src/components/PlaceAnOrderModal/PlaceAnOrderModal.tsx index 49e90f6..6597702 100644 --- a/apps/telegram-ecash-escrow/src/components/PlaceAnOrderModal/PlaceAnOrderModal.tsx +++ b/apps/telegram-ecash-escrow/src/components/PlaceAnOrderModal/PlaceAnOrderModal.tsx @@ -1,7 +1,7 @@ 'use client'; import FiatRateErrorBanner from '@/src/components/Common/FiatRateErrorBanner'; -import { COIN_OTHERS, DEFAULT_TICKER_GOODS_SERVICES } from '@/src/store/constants'; +import { COIN_OTHERS, DEFAULT_TICKER_GOODS_SERVICES, GoodsServicesPaymentType } from '@/src/store/constants'; import { LIST_BANK } from '@/src/store/constants/list-bank'; import { SettingContext } from '@/src/store/context/settingProvider'; import { UtxoContext } from '@/src/store/context/utxoProvider'; @@ -320,6 +320,11 @@ const PlaceAnOrderModal: React.FC = props => { const [isGoodsServicesConversion, setIsGoodsServicesConversion] = useState(() => isConvertGoodsServices(post?.postOffer?.priceGoodsServices, post?.postOffer?.tickerPriceGoodsServices) ); + // Check if this is an external payment offer (seller escrows as collateral) + const isExternalPayment = + isGoodsServices && + (post?.postOffer as { paymentTypeGoodsServices?: string })?.paymentTypeGoodsServices === + GoodsServicesPaymentType.EXTERNAL; const selectedWalletPath = useLixiSliceSelector(getSelectedWalletPath); const { useCreateEscrowOrderMutation, useGetModeratorAccountQuery, useGetRandomArbitratorAccountQuery } = @@ -839,6 +844,14 @@ const PlaceAnOrderModal: React.FC = props => { const isValid = await trigger(); if (!isValid) return; + // For external payment, buyer doesn't need to deposit - seller will deposit as collateral later + if (isExternalPayment) { + handleSubmit(data => { + handleCreateEscrowOrder(data, false); + })(); + return; + } + if (checkBuyerEnoughFund() && !isBuyOffer) { setOpenConfirmDeposit(true); } else { @@ -1393,6 +1406,32 @@ const PlaceAnOrderModal: React.FC = props => { {errors?.paymentMethod?.message as string} )} + + {/* External Payment Notice */} + {isExternalPayment && ( + + + External Payment (Seller Collateral) + + + This is an external payment offer. You will pay the seller directly outside the system (offline). The + seller will escrow XEC as collateral to guarantee the delivery. + + + After receiving the goods/services, please confirm delivery to release the collateral back to the + seller. + + + )} + {isBuyOffer && InfoPaymentDetail()} diff --git a/apps/telegram-ecash-escrow/src/store/constants.ts b/apps/telegram-ecash-escrow/src/store/constants.ts index dc6174f..a795b6c 100644 --- a/apps/telegram-ecash-escrow/src/store/constants.ts +++ b/apps/telegram-ecash-escrow/src/store/constants.ts @@ -106,6 +106,29 @@ export const LIST_TICKER_GOODS_SERVICES = [ ]; export const DEFAULT_TICKER_GOODS_SERVICES = 'XEC'; +/** + * Payment type for Goods & Services offers + * - IN_APP: Buyer pays XEC through the escrow system (traditional flow) + * - EXTERNAL: Payment arranged outside the system (seller escrows as collateral) + */ +export enum GoodsServicesPaymentType { + IN_APP = 'IN_APP', // Buyer pays XEC in-app (default) + EXTERNAL = 'EXTERNAL' // Payment outside system, seller escrows as collateral +} + +export const LIST_GOODS_SERVICES_PAYMENT_TYPE = [ + { + value: GoodsServicesPaymentType.IN_APP, + label: 'Buyer pays in XEC', + description: 'Buyer sends XEC to escrow, you receive XEC after delivering goods/services' + }, + { + value: GoodsServicesPaymentType.EXTERNAL, + label: 'External payment (Seller collateral)', + description: 'You escrow XEC as collateral, buyer pays you externally, then releases your XEC back' + } +]; + export const LIST_USD_STABLECOIN = [ { id: 1, From 4447a8a7bac0e560369703794160cd445bd3ad2f Mon Sep 17 00:00:00 2001 From: nghiacc Date: Wed, 4 Feb 2026 08:27:06 +0700 Subject: [PATCH 02/13] feat: Implement external payment UI for Goods & Services offers - Added GoodsServicesPaymentType enum to frontend constants (IN_APP, EXTERNAL) - Added LIST_GOODS_SERVICES_PAYMENT_TYPE array with labels and descriptions for payment type selector - Updated CreateOfferModal: - Added paymentTypeGoodsServices field to form interface and default values - Added radio button UI to select payment type when creating G&S offers - Included field in CreateOfferInput when submitting - Updated PlaceAnOrderModal: - Detect external payment offers - Skip buyer deposit flow for external payment orders - Display informational banner about external payment process - Updated order-detail page: - Detect external payment orders - Added handleBuyerConfirmReceipt() function for buyer to confirm receipt - Show different UI for external payment: - Seller view: Message about collateral being held, Dispute button only - Buyer view: "Confirm Receipt" button to release collateral back to seller --- .../CreateOfferModal/CreateOfferModal.tsx | 2 +- .../PlaceAnOrderModal/PlaceAnOrderModal.tsx | 7 ++++- apps/telegram-ecash-escrow/src/store/util.ts | 30 +++++++++++++++++++ 3 files changed, 37 insertions(+), 2 deletions(-) diff --git a/apps/telegram-ecash-escrow/src/components/CreateOfferModal/CreateOfferModal.tsx b/apps/telegram-ecash-escrow/src/components/CreateOfferModal/CreateOfferModal.tsx index d9e7e59..4f10459 100644 --- a/apps/telegram-ecash-escrow/src/components/CreateOfferModal/CreateOfferModal.tsx +++ b/apps/telegram-ecash-escrow/src/components/CreateOfferModal/CreateOfferModal.tsx @@ -343,7 +343,7 @@ const CreateOfferModal: React.FC = props => { priceCoinOthers: offer?.priceCoinOthers ?? null, priceGoodsServices: offer?.priceGoodsServices ?? null, tickerPriceGoodsServices: offer?.tickerPriceGoodsServices ?? DEFAULT_TICKER_GOODS_SERVICES, - paymentTypeGoodsServices: offer?.paymentTypeGoodsServices ?? GoodsServicesPaymentType.IN_APP, + paymentTypeGoodsServices: (offer as any)?.paymentTypeGoodsServices ?? GoodsServicesPaymentType.IN_APP, percentage: offer?.marginPercentage ?? 0, note: offer?.noteOffer ?? '', country: null, diff --git a/apps/telegram-ecash-escrow/src/components/PlaceAnOrderModal/PlaceAnOrderModal.tsx b/apps/telegram-ecash-escrow/src/components/PlaceAnOrderModal/PlaceAnOrderModal.tsx index 6597702..57d5a7c 100644 --- a/apps/telegram-ecash-escrow/src/components/PlaceAnOrderModal/PlaceAnOrderModal.tsx +++ b/apps/telegram-ecash-escrow/src/components/PlaceAnOrderModal/PlaceAnOrderModal.tsx @@ -15,6 +15,7 @@ import { formatAmountFor1MXEC, formatAmountForGoodsServices, formatNumber, + formatPriceByType, getNumberFromFormatNumber, getOrderLimitText, hexEncode, @@ -1331,7 +1332,11 @@ const PlaceAnOrderModal: React.FC = props => { DEFAULT_TICKER_GOODS_SERVICES ? ( {' '} - ({post.postOffer.priceGoodsServices}{' '} + ( + {formatPriceByType( + post.postOffer.priceGoodsServices, + post.postOffer.tickerPriceGoodsServices ?? 'USD' + )}{' '} {post.postOffer.tickerPriceGoodsServices ?? 'USD'}) ) : null} diff --git a/apps/telegram-ecash-escrow/src/store/util.ts b/apps/telegram-ecash-escrow/src/store/util.ts index 6e61ada..213178b 100644 --- a/apps/telegram-ecash-escrow/src/store/util.ts +++ b/apps/telegram-ecash-escrow/src/store/util.ts @@ -288,6 +288,36 @@ export function formatAmountForGoodsServices(amount) { return `${formatNumber(roundedAmount)} XEC / ${GOODS_SERVICES_UNIT}`; } +/** + * Formats a price based on currency type: + * - XEC: 2 decimal places with thousands separators + * - Currencies like VND: thousands separators, no decimals + * - Other currencies: thousands separators, 2 decimal places + * @param price - The price value to format + * @param currency - The currency code (e.g., 'XEC', 'VND', 'USD') + * @returns Formatted price string + */ +export function formatPriceByType(price: number | string, currency: string): string { + if (!price && price !== 0) return '0'; + + const numPrice = typeof price === 'string' ? parseFloat(price) : price; + + // Currencies with no decimal places + const noDecimalCurrencies = ['VND', 'JPY', 'KRW', 'TWD', 'PHP', 'IDR', 'THB']; + + if (noDecimalCurrencies.includes(currency?.toUpperCase())) { + // Format with thousands separators, no decimals + return Math.round(numPrice).toLocaleString('en-US'); + } + + // XEC and most other currencies: 2 decimal places + const roundedPrice = Math.round(numPrice * 100) / 100; + return roundedPrice.toLocaleString('en-US', { + minimumFractionDigits: 2, + maximumFractionDigits: 2 + }); +} + /** * Transforms fiat rate data from backend format to frontend format. * From 837ea4204224a786a07d75455b315c6b1e155b80 Mon Sep 17 00:00:00 2001 From: nghiacc Date: Wed, 4 Feb 2026 13:06:58 +0700 Subject: [PATCH 03/13] feat: Refactor payment type handling to use offer categories for Goods & Services --- .../src/app/order-detail/page.tsx | 6 +- .../CreateOfferModal/CreateOfferModal.tsx | 160 ++++++++++-------- .../PlaceAnOrderModal/PlaceAnOrderModal.tsx | 6 +- .../src/store/constants.ts | 26 +-- 4 files changed, 105 insertions(+), 93 deletions(-) diff --git a/apps/telegram-ecash-escrow/src/app/order-detail/page.tsx b/apps/telegram-ecash-escrow/src/app/order-detail/page.tsx index 0f46229..5ac6d76 100644 --- a/apps/telegram-ecash-escrow/src/app/order-detail/page.tsx +++ b/apps/telegram-ecash-escrow/src/app/order-detail/page.tsx @@ -7,7 +7,7 @@ import MobileLayout from '@/src/components/layout/MobileLayout'; import QRCode from '@/src/components/QRcode/QRcode'; import TelegramButton from '@/src/components/TelegramButton/TelegramButton'; import TickerHeader from '@/src/components/TickerHeader/TickerHeader'; -import { GoodsServicesPaymentType, securityDepositPercentage } from '@/src/store/constants'; +import { OfferCategory, securityDepositPercentage } from '@/src/store/constants'; import { SettingContext } from '@/src/store/context/settingProvider'; import { UtxoContext } from '@/src/store/context/utxoProvider'; import { buildReleaseTx, buildReturnFeeTx, buildReturnTx, sellerBuildDepositTx } from '@/src/store/escrow'; @@ -152,8 +152,8 @@ const OrderDetail = () => { // Check if this is an external payment order (seller escrows as collateral) const isExternalPaymentOrder = - (currentData?.escrowOrder?.escrowOffer as { paymentTypeGoodsServices?: string })?.paymentTypeGoodsServices === - GoodsServicesPaymentType.EXTERNAL; + (currentData?.escrowOrder?.escrowOffer as { offerCategory?: string })?.offerCategory === + OfferCategory.GOODS_SERVICES; useEffect(() => { if ( diff --git a/apps/telegram-ecash-escrow/src/components/CreateOfferModal/CreateOfferModal.tsx b/apps/telegram-ecash-escrow/src/components/CreateOfferModal/CreateOfferModal.tsx index 4f10459..3f0d9cf 100644 --- a/apps/telegram-ecash-escrow/src/components/CreateOfferModal/CreateOfferModal.tsx +++ b/apps/telegram-ecash-escrow/src/components/CreateOfferModal/CreateOfferModal.tsx @@ -4,10 +4,9 @@ import { COIN_OTHERS, COIN_USD_STABLECOIN_TICKER, DEFAULT_TICKER_GOODS_SERVICES, - GoodsServicesPaymentType, LIST_COIN, - LIST_GOODS_SERVICES_PAYMENT_TYPE, - LIST_TICKER_GOODS_SERVICES + LIST_TICKER_GOODS_SERVICES, + OfferCategory } from '@/src/store/constants'; import { LIST_PAYMENT_APP } from '@/src/store/constants/list-payment-app'; import { SettingContext } from '@/src/store/context/settingProvider'; @@ -264,7 +263,7 @@ interface OfferFormValues { priceCoinOthers: number | null; priceGoodsServices?: number | null; tickerPriceGoodsServices?: string | null; - paymentTypeGoodsServices?: GoodsServicesPaymentType | null; + offerCategory?: OfferCategory | null; percentage: number; note: string; country: Country | null; @@ -313,6 +312,17 @@ const CreateOfferModal: React.FC = props => { const [fixAmount, setFixAmount] = useState(1000); const [isBuyOffer, setIsBuyOffer] = useState(offer?.type ? offer?.type === OfferType.Buy : true); const [isHiddenOffer, setIsHiddenOffer] = useState(true); + // Offer category: 'XEC' for P2P trading, 'GOODS_SERVICES' for goods/services marketplace + // Check offerCategory first (new structure), then fallback to paymentMethodId=5 (legacy) + const [offerCategory, setOfferCategory] = useState<'XEC' | 'GOODS_SERVICES'>(() => { + if ((offer as any)?.offerCategory === OfferCategory.GOODS_SERVICES) { + return 'GOODS_SERVICES'; + } + if (offer?.paymentMethods[0]?.paymentMethod.id === PAYMENT_METHOD.GOODS_SERVICES) { + return 'GOODS_SERVICES'; + } + return 'XEC'; + }); // Modal state const [openCountryList, setOpenCountryList] = useState(false); @@ -343,7 +353,7 @@ const CreateOfferModal: React.FC = props => { priceCoinOthers: offer?.priceCoinOthers ?? null, priceGoodsServices: offer?.priceGoodsServices ?? null, tickerPriceGoodsServices: offer?.tickerPriceGoodsServices ?? DEFAULT_TICKER_GOODS_SERVICES, - paymentTypeGoodsServices: (offer as any)?.paymentTypeGoodsServices ?? GoodsServicesPaymentType.IN_APP, + offerCategory: (offer as any)?.offerCategory ?? null, percentage: offer?.marginPercentage ?? 0, note: offer?.noteOffer ?? '', country: null, @@ -358,9 +368,10 @@ const CreateOfferModal: React.FC = props => { const percentageValue = watch('percentage'); const currencyValue = watch('currency'); const coinValue = watch('coin'); - const paymentTypeGoodsServicesValue = watch('paymentTypeGoodsServices'); + const offerCategoryValue = watch('offerCategory'); - const isGoodService = option === PAYMENT_METHOD.GOODS_SERVICES; + // Check if this is a Goods & Services offer based on offerCategory + const isGoodService = offerCategory === 'GOODS_SERVICES'; // Use shared renderTextWithLinks utility imported above @@ -378,8 +389,8 @@ const CreateOfferModal: React.FC = props => { }, second * 1000); }; - // Helper function to check if margin should be shown - const showMarginComponent = () => option !== PAYMENT_METHOD.GOODS_SERVICES; + // Helper function to check if margin should be shown (hide for Goods & Services offers) + const showMarginComponent = () => offerCategory !== 'GOODS_SERVICES'; // Handler for creating/updating offers const handleCreateOffer = async (data: OfferFormValues, isHidden: boolean) => { @@ -401,7 +412,7 @@ const CreateOfferModal: React.FC = props => { ? getNumberFromFormatNumber(data.priceGoodsServices as unknown as string) : 0, tickerPriceGoodsServices: data?.tickerPriceGoodsServices ? data.tickerPriceGoodsServices : null, - paymentTypeGoodsServices: data?.paymentTypeGoodsServices ?? null, + offerCategory: data?.offerCategory ?? null, localCurrency: data?.currency ? data.currency.split(':')[0] : null, paymentApp: data?.paymentApp ? data.paymentApp : null, marginPercentage: Number(data?.percentage ?? 0), @@ -416,7 +427,8 @@ const CreateOfferModal: React.FC = props => { input.locationId = null; } - if (option === PAYMENT_METHOD.GOODS_SERVICES) { + // For Goods & Services offers, clear coin-related fields + if (offerCategory === 'GOODS_SERVICES') { input.priceCoinOthers = 0; input.coinOthers = null; } @@ -486,6 +498,11 @@ const CreateOfferModal: React.FC = props => { // Helper function for offer note placeholder text const getPlaceholderOfferNote = (): string => { + // For Goods & Services offers, show specific placeholder + if (offerCategory === 'GOODS_SERVICES') { + return 'A public note attached to your offer. For example: "Exchanging XEC for a logo design. Send your proposal along with a proposed price."'; + } + switch (option) { case PAYMENT_METHOD.CASH_IN_PERSON: return 'A public note attached to your offer. For example: "Exchanging XEC to cash, only meeting in public places at daytime!"'; @@ -494,8 +511,6 @@ const CreateOfferModal: React.FC = props => { return 'A public note attached to your offer. For example: "Bank transfer in Vietnam only. Available from 9AM to 5PM workdays."'; case PAYMENT_METHOD.CRYPTO: return 'A public note attached to your offer. For example: "Accepting USDT on TRX and ETH network."'; - case PAYMENT_METHOD.GOODS_SERVICES: - return 'A public note attached to your offer. For example: "Exchanging XEC for a logo design. Send your proposal along with a proposed price.'; default: return 'Input offer note'; } @@ -548,8 +563,8 @@ const CreateOfferModal: React.FC = props => { // Effect to update coinCurrency when related form values change useEffect(() => { - // If the user selected Goods & Services payment method, show the unit label - if (option === PAYMENT_METHOD.GOODS_SERVICES) { + // If this is a Goods & Services offer, show the unit label + if (offerCategory === 'GOODS_SERVICES') { setCoinCurrency(GOODS_SERVICES_UNIT); return; } @@ -558,7 +573,7 @@ const CreateOfferModal: React.FC = props => { const coin = coinValue?.split(':')[0]; setCoinCurrency(currency ?? (coin?.includes(COIN_OTHERS) ? getValues('coinOthers') : coin) ?? GOODS_SERVICES_UNIT); - }, [currencyValue, coinValue, getValues('coinOthers'), option]); + }, [currencyValue, coinValue, getValues('coinOthers'), offerCategory]); // Effect to load payment methods and countries on component mount useEffect(() => { @@ -652,6 +667,40 @@ const CreateOfferModal: React.FC = props => { const Step1Content = () => (
+ {/* Offer Category Selector */} + + + + + {/* Buy/Sell buttons */} + ))} + {filteredFiats.length === 0 && ( + No currencies found + )} + +
+ ); + }; + + const contentCrypto = () => { + // Include XEC in the crypto list for Goods & Services (users can pay directly in XEC) + const filteredCrypto = LIST_COIN.filter(option => { + return `${option?.name} (${option?.ticker})`.toLowerCase().includes(searchTerm.toLowerCase()); + }); + + return ( +
+ {searchTextField} + + {filteredCrypto.map(option => ( + + ))} + {filteredCrypto.length === 0 && ( + No crypto found + )} + +
+ ); + }; + return ( = props => { - setSearchTerm(e.target.value)} - value={searchTerm} - autoFocus - /> - - {filteredCurrencies.map(option => ( - - ))} - {filteredCurrencies.length === 0 && ( - No currencies found - )} + + + + + + {value === 0 && contentFiat()} + {value === 1 && contentCrypto()} diff --git a/apps/telegram-ecash-escrow/src/components/Header/ShoppingHeader.tsx b/apps/telegram-ecash-escrow/src/components/Header/ShoppingHeader.tsx new file mode 100644 index 0000000..d03a158 --- /dev/null +++ b/apps/telegram-ecash-escrow/src/components/Header/ShoppingHeader.tsx @@ -0,0 +1,319 @@ +'use client'; + +import { LIST_COIN } from '@/src/store/constants'; +import { UtxoContext } from '@/src/store/context/utxoProvider'; +import { FilterCurrencyType } from '@/src/store/type/types'; +import { COIN, LIST_CURRENCIES_USED } from '@bcpros/lixi-models'; +import { + accountsApi, + getSelectedAccount, + getSelectedWalletPath, + parseCashAddressToPrefix, + showToast, + useSliceDispatch as useLixiSliceDispatch, + useSliceSelector as useLixiSliceSelector +} from '@bcpros/redux-store'; +import { CopyAllOutlined, SettingsOutlined, Wallet } from '@mui/icons-material'; +import AccountCircleRoundedIcon from '@mui/icons-material/AccountCircleRounded'; +import FilterListIcon from '@mui/icons-material/FilterList'; +import Person2Icon from '@mui/icons-material/Person2'; +import { Button, IconButton, Popover, Typography } from '@mui/material'; +import { styled } from '@mui/material/styles'; +import { useSession } from 'next-auth/react'; +import { useRouter } from 'next/navigation'; +import React, { useContext, useMemo, useState } from 'react'; +import CopyToClipboard from 'react-copy-to-clipboard'; +import useAuthorization from '../Auth/use-authorization.hooks'; +import ShoppingCurrencyModal from '../FilterList/ShoppingCurrencyModal'; + +const StyledHeader = styled('div')(({ theme }) => ({ + display: 'flex', + justifyContent: 'space-between', + alignItems: 'center', + + '.greeting': { + '.handle-name': { + fontWeight: 600 + } + }, + + '.right-section': { + display: 'flex', + alignItems: 'center', + gap: '8px' + }, + + '.currency-filter-btn': { + width: '40px', + height: '40px', + padding: '8px', + borderRadius: '50%', + border: 'none', + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + + '&:hover': { + backgroundColor: 'rgba(0, 118, 196, 0.08)' + }, + + '.flag-icon': { + width: '24px', + height: '24px', + borderRadius: '50%', + objectFit: 'cover' + }, + + '.crypto-icon': { + width: '24px', + height: '24px', + objectFit: 'contain' + }, + + '.ecash-icon': { + width: '24px', + height: '24px', + objectFit: 'contain' + } + } +})); + +const PopoverStyled = styled('div')(({ theme }) => ({ + padding: '8px 10px', + background: theme.palette.background.paper, + + '.heading-profile': { + fontWeight: 600, + fontSize: 20, + marginBottom: 5, + display: 'flex', + alignItems: 'center', + justifyContent: 'space-between' + }, + '.item-address, .item-amount': { + display: 'flex', + justifyContent: 'space-between', + marginBottom: 7 + }, + '.address-amount': { + fontSize: 14, + color: theme.palette.text.secondary + }, + '.no-border-btn': { + padding: 0, + minWidth: 0 + }, + + button: { + color: theme.palette.text.secondary + } +})); + +const StyledAvatar = styled('div')(({ theme }) => ({ + width: '1.55em', + height: '1.55em', + borderRadius: 50, + overflow: 'hidden', + display: 'inline-block', + border: '2px solid #0076c4', + backgroundImage: 'url("./eCash.svg")', + backgroundSize: 'cover', + backgroundPosition: 'center', + backgroundRepeat: 'no-repeat', + '& img': { + width: '100%', + objectFit: 'cover' + } +})); + +interface ShoppingHeaderProps { + selectedCurrency: string | null; + onCurrencyChange: (currency: FilterCurrencyType) => void; +} + +export default function ShoppingHeader({ selectedCurrency, onCurrencyChange }: ShoppingHeaderProps) { + const dispatch = useLixiSliceDispatch(); + const router = useRouter(); + const { data, status } = useSession(); + const askAuthorization = useAuthorization(); + const { totalValidAmount } = useContext(UtxoContext); + const selectedWalletPath = useLixiSliceSelector(getSelectedWalletPath); + const selectedAccount = useLixiSliceSelector(getSelectedAccount); + + const { useGetLocaleCashAvatarQuery } = accountsApi; + const { data: avatarPath } = useGetLocaleCashAvatarQuery( + { accountId: selectedAccount?.id }, + { skip: !selectedAccount } + ); + + // Use useMemo to derive address from selectedWalletPath to handle wallet changes + const address = useMemo( + () => parseCashAddressToPrefix(COIN.XEC, selectedWalletPath?.cashAddress), + [selectedWalletPath?.cashAddress] + ); + const [anchorEl, setAnchorEl] = React.useState(null); + const [openCurrencyModal, setOpenCurrencyModal] = useState(false); + const [flagLoadError, setFlagLoadError] = useState>({}); + const open = Boolean(anchorEl); + + // Get currency info for displaying flag or crypto icon + const currencyInfo = useMemo(() => { + if (!selectedCurrency) return null; + + // Check if it's a fiat currency + const fiatCurrency = LIST_CURRENCIES_USED.find(c => c.code === selectedCurrency); + if (fiatCurrency) { + return { + type: 'fiat', + countryCode: fiatCurrency.country, + code: fiatCurrency.code + }; + } + + // Check if it's a crypto currency + const cryptoCurrency = LIST_COIN.find(c => c.ticker === selectedCurrency); + if (cryptoCurrency) { + return { + type: 'crypto', + ticker: cryptoCurrency.ticker, + name: cryptoCurrency.name + }; + } + + return null; + }, [selectedCurrency]); + + const handlePopoverOpen = (event: React.MouseEvent) => { + if (status === 'loading') return; + + if (status === 'unauthenticated') { + askAuthorization(); + } else { + setAnchorEl(anchorEl ? null : event.currentTarget); + } + }; + + const formatAddress = (address: string) => { + if (!address) return; + + return address.slice(0, 5) + '...' + address.slice(-8); + }; + + const handleCurrencySelect = (currency: FilterCurrencyType) => { + onCurrencyChange(currency); + setOpenCurrencyModal(false); + }; + + const contentMoreAction = ( + + router.push(`/profile?address=${selectedAccount?.address}`)} + className="heading-profile" + > + Profile +