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 15651dfd..070b942b 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 { 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'; @@ -30,7 +30,7 @@ import { hexToUint8Array, showPriceInfo } from '@/src/store/util'; -import { COIN, coinInfo } from '@bcpros/lixi-models'; +import { COIN, coinInfo, PAYMENT_METHOD } from '@bcpros/lixi-models'; import { DisputeStatus, EscrowOrderAction, @@ -150,6 +150,52 @@ const OrderDetail = () => { const isBuyOffer = currentData?.escrowOrder?.escrowOffer?.type === OfferType.Buy; + /** + * Determines if this order uses external payment flow (seller escrows collateral) + * vs direct payment flow (buyer deposits directly). + * + * PAYMENT FLOW TYPES: + * 1. EXTERNAL PAYMENT (seller escrows collateral): + * - Legacy G&S offers (paymentMethodId = 5): Seller escrows XEC as collateral + * - G&S category + Bank Transfer (paymentMethodId = 2): Buyer pays externally + * - G&S category + Payment App (paymentMethodId = 3): Buyer pays via app + * - G&S category + Crypto non-XEC (paymentMethodId = 4, coinPayment != 'XEC'): Buyer pays with other crypto + * UI: Shows "Seller Collateral Escrowed" and "Confirm Receipt" button for buyer + * Buyer action: Confirm receipt to release collateral + * + * 2. DIRECT PAYMENT (buyer deposits XEC): + * - G&S category + Crypto XEC (paymentMethodId = 4, coinPayment = 'XEC'): Direct XEC payment + * UI: Shows standard order details without external payment messaging + * Buyer action: Uses standard release/return flows + */ + const isExternalPaymentOrder = useMemo(() => { + const hasGoodsServicesCategory = + (currentData?.escrowOrder?.escrowOffer as { offerCategory?: string })?.offerCategory === + OfferCategory.GOODS_SERVICES; + const paymentMethodId = currentData?.escrowOrder?.paymentMethod?.id; + // Default missing coinPayment to 'XEC' to match behavior elsewhere + // This ensures G&S + CRYPTO with no coinPayment is treated as direct XEC payment (not external) + const coinPayment = (currentData?.escrowOrder?.escrowOffer?.coinPayment || 'XEC').toUpperCase(); + + // Case 1: Legacy G&S offers (paymentMethodId = 5) are treated as external payment + if (paymentMethodId === PAYMENT_METHOD.GOODS_SERVICES) { + return true; + } + + // Case 2: Not a G&S category offer = not external payment (standard XEC trading) + if (!hasGoodsServicesCategory) { + return false; + } + + // Case 3: G&S category with Crypto (XEC) = direct XEC payment, NOT external + if (paymentMethodId === PAYMENT_METHOD.CRYPTO && coinPayment === 'XEC') { + return false; + } + + // Case 4: All other G&S category offers = external payment + return true; + }, [currentData?.escrowOrder]); + useEffect(() => { if ( currentData?.escrowOrder.escrowOrderStatus !== EscrowOrderStatus.Complete && @@ -385,6 +431,57 @@ 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!' + }) + ); + setLoading(false); + 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!, + // Note: EscrowOrderAction.BuyerConfirmReceipt is the correct enum value + // Using string literal here for compatibility until types are regenerated + action: 'BUYER_CONFIRM_RECEIPT' as unknown 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 +1254,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/app/page.tsx b/apps/telegram-ecash-escrow/src/app/page.tsx index b6afa20e..e7d5980a 100644 --- a/apps/telegram-ecash-escrow/src/app/page.tsx +++ b/apps/telegram-ecash-escrow/src/app/page.tsx @@ -3,6 +3,7 @@ import FiatRateErrorBanner from '@/src/components/Common/FiatRateErrorBanner'; import Header from '@/src/components/Header/Header'; import OfferItem from '@/src/components/OfferItem/OfferItem'; +import { PAYMENT_METHOD } from '@bcpros/lixi-models'; import { OfferOrderField, OrderDirection, @@ -86,6 +87,22 @@ export default function Home() { const [visible, setVisible] = useState(true); const dispatch = useLixiSliceDispatch(); + // Filter out Goods & Services offers from P2P trading view + // Default to showing Buy offers (isBuyOffer: true means showing offers where users want to buy XEC) + const tradingFilterConfig = useMemo( + () => ({ + ...offerFilterConfig, + // Default to Buy offers if not specified (users typically want to buy XEC) + isBuyOffer: offerFilterConfig.isBuyOffer ?? true, + // Exclude Goods & Services payment method (ID = 5) + paymentMethodIds: + offerFilterConfig.paymentMethodIds && offerFilterConfig.paymentMethodIds.length > 0 + ? offerFilterConfig.paymentMethodIds.filter(id => id !== PAYMENT_METHOD.GOODS_SERVICES) + : [1, 2, 3, 4] // Default: Bank Transfer, Payment App, Crypto, Cash In Person (exclude Goods/Services) + }), + [offerFilterConfig] + ); + // Prefetch fiat rates in the background for better modal performance // This will cache the data so PlaceAnOrderModal can use it immediately const { @@ -97,7 +114,7 @@ export default function Home() { refetchOnMountOrArgChange: true }); - const isShowSortIcon = isShowAmountOrSortFilter(offerFilterConfig); + const isShowSortIcon = isShowAmountOrSortFilter(tradingFilterConfig); const { data: dataFilter, @@ -106,7 +123,7 @@ export default function Home() { fetchNext: fetchNextFilter, isLoading: isLoadingFilter, refetch - } = useInfiniteOfferFilterDatabaseQuery({ first: 20, offerFilterInput: offerFilterConfig }, false); + } = useInfiniteOfferFilterDatabaseQuery({ first: 20, offerFilterInput: tradingFilterConfig }, false); const loadMoreItemsFilter = () => { if (hasNextFilter && !isFetchingFilter) { diff --git a/apps/telegram-ecash-escrow/src/app/shopping/page.tsx b/apps/telegram-ecash-escrow/src/app/shopping/page.tsx index 33c9b1e3..db847d31 100644 --- a/apps/telegram-ecash-escrow/src/app/shopping/page.tsx +++ b/apps/telegram-ecash-escrow/src/app/shopping/page.tsx @@ -1,10 +1,9 @@ 'use client'; import FiatRateErrorBanner from '@/src/components/Common/FiatRateErrorBanner'; -import ShoppingFilterComponent from '@/src/components/FilterOffer/ShoppingFilterComponent'; -import Header from '@/src/components/Header/Header'; +import ShoppingHeader from '@/src/components/Header/ShoppingHeader'; import OfferItem from '@/src/components/OfferItem/OfferItem'; -import { ShoppingFilterConfig } from '@/src/shared/models/shoppingFilterConfig'; +import { FilterCurrencyType } from '@/src/store/type/types'; import { PAYMENT_METHOD } from '@bcpros/lixi-models'; import { OfferOrderField, @@ -98,9 +97,10 @@ export default function Shopping() { refetchOnMountOrArgChange: true }); - // Fixed filter config for shopping: only Goods & Services sell offers + // Fixed filter config for shopping: all Goods & Services offers (both buy and sell) const [shoppingFilterConfig, setShoppingFilterConfig] = useState({ - isBuyOffer: true, // Buy offers (users wanting to buy XEC by selling goods/services - so shoppers can buy the goods) + // Show both buy and sell offers (null = show all) + isBuyOffer: null, paymentMethodIds: [PAYMENT_METHOD.GOODS_SERVICES], tickerPriceGoodsServices: null, // NEW: Backend filter for G&S currency fiatCurrency: null, @@ -152,10 +152,10 @@ export default function Shopping() { dispatch(openModal('SortOfferModal', {})); }; - const handleConfigChange = (config: ShoppingFilterConfig) => { + const handleCurrencyChange = (currency: FilterCurrencyType) => { setShoppingFilterConfig(prev => ({ ...prev, - ...config + tickerPriceGoodsServices: currency.value || null })); }; @@ -175,16 +175,16 @@ export default function Shopping() { -
- - + {/* Show fiat rate error banner if service is down */}
- Offers {isShowSortIcon && ( ({ }, '.active': { color: '#fff' + }, + '&:first-of-type': { + marginBottom: '16px' } } }, @@ -248,6 +253,8 @@ interface CreateOfferModalProps { offer?: OfferQueryItem; isEdit?: boolean; isFirstOffer?: boolean; + initialCurrency?: string | null; // Currency pre-selected from Shopping filter + initialOfferCategory?: 'XEC' | 'GOODS_SERVICES'; // Pre-select offer category } interface OfferFormValues { @@ -262,6 +269,7 @@ interface OfferFormValues { priceCoinOthers: number | null; priceGoodsServices?: number | null; tickerPriceGoodsServices?: string | null; + offerCategory?: OfferCategory | null; percentage: number; note: string; country: Country | null; @@ -281,7 +289,7 @@ const Transition = React.forwardRef(function Transition( // Main component const CreateOfferModal: React.FC = props => { - const { isEdit = false, offer, isFirstOffer = false } = props; + const { isEdit = false, offer, isFirstOffer = false, initialCurrency = null, initialOfferCategory } = props; const dispatch = useLixiSliceDispatch(); const theme = useTheme(); const fullScreen = useMediaQuery(theme.breakpoints.down('md')); @@ -308,8 +316,35 @@ const CreateOfferModal: React.FC = props => { const [activeStep, setActiveStep] = useState(isEdit ? 2 : 1); const [coinCurrency, setCoinCurrency] = useState(COIN.XEC); const [fixAmount, setFixAmount] = useState(1000); - const [isBuyOffer, setIsBuyOffer] = useState(offer?.type ? offer?.type === OfferType.Buy : true); + // Default buy/sell based on context: + // - When editing, mirror the existing offer type. + // - For Goods & Services (e.g., Shopping page), default to Sell. + // - For XEC trading and other contexts, default to Buy (original behavior). + const [isBuyOffer, setIsBuyOffer] = useState(() => { + if (offer?.type) return offer.type === OfferType.Buy; + const isGoodsServicesContext = + initialOfferCategory === 'GOODS_SERVICES' || + (offer as any)?.offerCategory === OfferCategory.GOODS_SERVICES || + offer?.paymentMethods[0]?.paymentMethod.id === PAYMENT_METHOD.GOODS_SERVICES; + // For Goods & Services offers, users are typically selling items/services. + if (isGoodsServicesContext) return false; // Sell + // For XEC trading and other offers, preserve original default: Buy. + return 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'>(() => { + // Use initialOfferCategory if provided + if (initialOfferCategory) return initialOfferCategory; + 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); @@ -339,7 +374,12 @@ const CreateOfferModal: React.FC = props => { coinOthers: offer?.coinOthers ?? '', priceCoinOthers: offer?.priceCoinOthers ?? null, priceGoodsServices: offer?.priceGoodsServices ?? null, - tickerPriceGoodsServices: offer?.tickerPriceGoodsServices ?? DEFAULT_TICKER_GOODS_SERVICES, + // Use initialCurrency if provided, otherwise use offer's currency or default + tickerPriceGoodsServices: initialCurrency || offer?.tickerPriceGoodsServices || DEFAULT_TICKER_GOODS_SERVICES, + offerCategory: + initialOfferCategory === 'GOODS_SERVICES' + ? OfferCategory.GOODS_SERVICES + : (offer as any)?.offerCategory ?? null, percentage: offer?.marginPercentage ?? 0, note: offer?.noteOffer ?? '', country: null, @@ -354,8 +394,97 @@ const CreateOfferModal: React.FC = props => { const percentageValue = watch('percentage'); const currencyValue = watch('currency'); const coinValue = watch('coin'); + const tickerPriceGoodsServicesValue = watch('tickerPriceGoodsServices'); + const coinOthersValue = watch('coinOthers'); + + // Check if this is a Goods & Services offer based on offerCategory + const isGoodService = offerCategory === 'GOODS_SERVICES'; + + // Compute available price tickers for Goods & Services based on initialCurrency + // - If fiat currency selected: only that currency (locked) + // - If crypto selected (except USD stablecoins & Others): that crypto + USD oracle + // - If USD stablecoin selected: only that stablecoin (no USD oracle) + // - If XEC or no currency selected: show default list (XEC, USD, VND) + const availablePriceTickers = useMemo(() => { + // 1. Try to use form values first (user selection) + const paymentMethodId = Number(option); + + if (paymentMethodId === PAYMENT_METHOD.CRYPTO) { + const selectedCoinRaw = coinValue ? coinValue.split(':')[0] : null; + + if (selectedCoinRaw) { + // Special Case: USD Stablecoin + if (selectedCoinRaw === COIN_USD_STABLECOIN_TICKER) { + // If sub-type selected (e.g. USDT), use it. + if (coinOthersValue) return [{ id: 1, name: coinOthersValue }]; + // If not selected, show 'USD'? Or generic. + return [{ id: 1, name: 'USD' }]; + } + + // Special Case: Others + if (selectedCoinRaw === COIN_OTHERS) { + if (coinOthersValue) return [{ id: 1, name: coinOthersValue }]; + return [{ id: 1, name: 'Others' }]; + } + + // Regular Crypto: Coin + USD + return [ + { id: 1, name: selectedCoinRaw }, + { id: 2, name: 'USD' } + ]; + } + } else if (paymentMethodId !== 0 && paymentMethodId !== PAYMENT_METHOD.GOODS_SERVICES) { + // Fiat methods + const selectedCurrencyRaw = currencyValue ? currencyValue.split(':')[0] : null; + if (selectedCurrencyRaw) { + return [{ id: 1, name: selectedCurrencyRaw }]; + } + } + + if (!initialCurrency) { + // No initial currency - show default list + return LIST_TICKER_GOODS_SERVICES; + } + + // Check if it's a fiat currency + const isFiat = LIST_CURRENCIES_USED.some(c => c.code === initialCurrency); + if (isFiat) { + // Fiat currency - only that currency + return [{ id: 1, name: initialCurrency }]; + } + + // Check if it's a USD stablecoin + const isUSDStablecoin = LIST_USD_STABLECOIN.some(c => c.name === initialCurrency); + if (isUSDStablecoin) { + // USD stablecoin - only that stablecoin, no USD oracle + return [{ id: 1, name: initialCurrency }]; + } + + // Check if it's "Others" (COIN_OTHERS) + if (initialCurrency === COIN_OTHERS) { + // Others - only that option, no USD oracle + return [{ id: 1, name: initialCurrency }]; + } + + // It's a crypto (XEC, BTC, etc.) - show crypto + USD oracle option + return [ + { id: 1, name: initialCurrency }, + { id: 2, name: 'USD' } + ]; + }, [option, coinValue, currencyValue, coinOthersValue, initialCurrency]); + + // Effect to ensure ticker price matches available options + useEffect(() => { + if (isGoodService && availablePriceTickers.length > 0) { + const currentTicker = getValues('tickerPriceGoodsServices'); + // Check if current ticker is valid in the new list + const isValid = availablePriceTickers.some(t => t.name === currentTicker); - const isGoodService = option === PAYMENT_METHOD.GOODS_SERVICES; + if (!isValid) { + setValue('tickerPriceGoodsServices', availablePriceTickers[0].name); + } + } + }, [availablePriceTickers, isGoodService, getValues, setValue]); // Use shared renderTextWithLinks utility imported above @@ -373,8 +502,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) => { @@ -396,6 +525,7 @@ const CreateOfferModal: React.FC = props => { ? getNumberFromFormatNumber(data.priceGoodsServices as unknown as string) : 0, tickerPriceGoodsServices: data?.tickerPriceGoodsServices ? data.tickerPriceGoodsServices : null, + offerCategory: data?.offerCategory ?? null, localCurrency: data?.currency ? data.currency.split(':')[0] : null, paymentApp: data?.paymentApp ? data.paymentApp : null, marginPercentage: Number(data?.percentage ?? 0), @@ -410,7 +540,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; } @@ -480,6 +611,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!"'; @@ -488,8 +624,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'; } @@ -542,8 +676,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; } @@ -552,7 +686,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(() => { @@ -646,6 +780,40 @@ const CreateOfferModal: React.FC = props => { const Step1Content = () => (
+ {/* Offer Category Selector */} + + + + + {/* Buy/Sell buttons */}