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 = (
-
- handleCreateDispute()}>
- Dispute
-
- setOpenReleaseModal(true)} disabled={loading}>
- Release
-
-
- );
- } 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 = (
+
+ handleCreateDispute()}>
+ Dispute
+
+
+ );
+ } 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 ? (
- handleCreateDispute()}
- >
- Dispute
-
- ) : (
- handleMarkAsPaid()}>
- Mark as paid
-
- )}
+ handleCreateDispute()}>
+ Dispute
+
setOpenCancelModal(true)}
+ onClick={() => setOpenReleaseModal(true)}
disabled={loading}
>
- Cancel
+ Release
-
- );
+ );
+ }
+ } 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')}
+
+ handleCreateDispute()}>
+ Dispute
+
+ handleBuyerConfirmReceipt()}
+ >
+ Confirm Receipt
+
+
+
+ );
+ } 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 ? (
+ handleCreateDispute()}
+ >
+ Dispute
+
+ ) : (
+ handleMarkAsPaid()}>
+ Mark as paid
+
+ )}
+ setOpenCancelModal(true)}
+ disabled={loading}
+ >
+ Cancel
+
+
+
+ );
+ }
}
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 */}
+
+ {
+ setOfferCategory('XEC');
+ setValue('option', '');
+ setValue('percentage', 0);
+ // Clear offerCategory for XEC offers (null = XEC_TRADING)
+ setValue('offerCategory', null);
+ }}
+ disabled={isEdit}
+ >
+ XEC Trading
+
+ {
+ setOfferCategory('GOODS_SERVICES');
+ setValue('option', '');
+ setValue('percentage', 0);
+ // Set offerCategory to mark as Goods/Services offer
+ setValue('offerCategory', OfferCategory.GOODS_SERVICES);
+ }}
+ disabled={isEdit}
+ >
+ Goods & Services
+
+
+
{/* Buy/Sell buttons */}
= props => {
{/* Description */}
- {isBuyOffer
- ? 'You are buying XEC. Your offer will be listed for users who want to SELL XEC.'
- : 'You are selling XEC. Your offer will be listed for users who want to BUY XEC.'}
+ {offerCategory === 'GOODS_SERVICES'
+ ? isBuyOffer
+ ? 'You are buying Goods / Services. Your offer will be listed for users who want to SELL Goods / Services.'
+ : 'You are selling Goods / Services. Your offer will be listed for users who want to BUY Goods / Services.'
+ : isBuyOffer
+ ? 'You are buying XEC. Your offer will be listed for users who want to SELL XEC.'
+ : 'You are selling XEC. Your offer will be listed for users who want to BUY XEC.'}
- {/* Payment method */}
+ {/* Payment method - Show for both categories, exclude Goods & Services option */}
Payment method
@@ -694,27 +866,23 @@ const CreateOfferModal: React.FC = props => {
style={{ display: 'grid', gridTemplateColumns: 'repeat(2, 1fr)', columnGap: '16px' }}
value={value}
onChange={e => {
- if (e.target.value === '5') {
- setValue('percentage', 0);
- setValue('coin', null);
- setValue('currency', null);
- setValue('coinOthers', null);
- }
onChange(e);
}}
onBlur={onBlur}
ref={ref}
>
- {paymentMethods.map(item => (
-
- }
- label={item.name}
- />
-
- ))}
+ {paymentMethods
+ .filter(item => item.id !== PAYMENT_METHOD.GOODS_SERVICES)
+ .map(item => (
+
+ }
+ label={item.name}
+ />
+
+ ))}
)}
/>
@@ -856,7 +1024,9 @@ const CreateOfferModal: React.FC = props => {
>
{LIST_COIN.map(item => {
- if (item.ticker === 'XEC') return null;
+ // For XEC Trading, hide XEC (since we're trading XEC for other currencies)
+ // For Goods & Services, show XEC (users can accept XEC as payment)
+ if (item.ticker === 'XEC' && offerCategory !== 'GOODS_SERVICES') return null;
return (
{item.name} {item.isDisplayTicker && `(${item.ticker})`}
@@ -1165,7 +1335,7 @@ const CreateOfferModal: React.FC = props => {
{showMarginComponent() && }
{/* Price fields for goods and services */}
- {option === PAYMENT_METHOD.GOODS_SERVICES && (
+ {offerCategory === 'GOODS_SERVICES' && (
<>
@@ -1206,15 +1376,20 @@ const CreateOfferModal: React.FC = props => {
endAdornment: (
t.name === tickerPriceGoodsServicesValue)
+ ? tickerPriceGoodsServicesValue
+ : availablePriceTickers[0]?.name ?? DEFAULT_TICKER_GOODS_SERVICES
+ }
onChange={event => {
setValue('tickerPriceGoodsServices', event.target.value);
}}
variant="standard"
disableUnderline
- disabled={isEdit}
+ disabled={isEdit || availablePriceTickers.length === 1}
>
- {LIST_TICKER_GOODS_SERVICES.map(item => (
+ {availablePriceTickers.map(item => (
{item.name}
@@ -1390,7 +1565,7 @@ const CreateOfferModal: React.FC = props => {
helperText={isGoodService ? errors.priceGoodsServices?.message : errors.priceCoinOthers?.message}
variant="standard"
InputProps={{
- endAdornment: isGoodService ? offer?.tickerPriceGoodsServices : 'USD'
+ endAdornment: 'USD'
}}
/>
@@ -1404,7 +1579,12 @@ const CreateOfferModal: React.FC = props => {
Price: {' '}
{isGoodService ? getValues('priceGoodsServices') : getValues('priceCoinOthers')}{' '}
- {isGoodService ? getValues('tickerPriceGoodsServices') : 'USD'}
+ {isGoodService
+ ? tickerPriceGoodsServicesValue &&
+ availablePriceTickers.some(t => t.name === tickerPriceGoodsServicesValue)
+ ? tickerPriceGoodsServicesValue
+ : availablePriceTickers[0]?.name || DEFAULT_TICKER_GOODS_SERVICES
+ : 'USD'}
);
@@ -1468,7 +1648,11 @@ const CreateOfferModal: React.FC = props => {
Payment currency
- {isGoodService ? 'XEC' : coinCurrency}
+
+ {isGoodService
+ ? getValues('tickerPriceGoodsServices') || DEFAULT_TICKER_GOODS_SERVICES
+ : coinCurrency}
+
diff --git a/apps/telegram-ecash-escrow/src/components/FilterList/ShoppingCurrencyModal.tsx b/apps/telegram-ecash-escrow/src/components/FilterList/ShoppingCurrencyModal.tsx
index 932a0d35..bc67e3ff 100644
--- a/apps/telegram-ecash-escrow/src/components/FilterList/ShoppingCurrencyModal.tsx
+++ b/apps/telegram-ecash-escrow/src/components/FilterList/ShoppingCurrencyModal.tsx
@@ -1,6 +1,7 @@
'use client';
-import { LIST_CURRENCIES_USED } from '@bcpros/lixi-models';
+import { LIST_COIN } from '@/src/store/constants';
+import { LIST_CURRENCIES_USED, PAYMENT_METHOD } from '@bcpros/lixi-models';
import { ChevronLeft } from '@mui/icons-material';
import {
Box,
@@ -10,6 +11,8 @@ import {
DialogTitle,
IconButton,
Slide,
+ Tab,
+ Tabs,
TextField,
Typography,
useMediaQuery,
@@ -17,7 +20,7 @@ import {
} from '@mui/material';
import { styled } from '@mui/material/styles';
import { TransitionProps } from '@mui/material/transitions';
-import React, { useId, useMemo, useState } from 'react';
+import React, { useId, useState } from 'react';
import { FilterCurrencyType } from '../../store/type/types';
interface ShoppingCurrencyModalProps {
@@ -67,7 +70,7 @@ const StyledDialog = styled(Dialog)(({ theme }) => ({
},
'.MuiDialogContent-root': {
- padding: '16px'
+ padding: 0
},
button: {
@@ -75,6 +78,24 @@ const StyledDialog = styled(Dialog)(({ theme }) => ({
}
}));
+const StyledTabs = styled(Tabs)(({ theme }) => ({
+ '.MuiTab-root': {
+ color: theme.custom.colorPrimary,
+ textTransform: 'none',
+ fontWeight: 600,
+ fontSize: '16px',
+
+ '&.Mui-selected': {
+ backgroundColor: 'rgba(255, 255, 255, 0.08)',
+ backdropFilter: 'blur(8px)'
+ }
+ },
+
+ '.MuiTabs-indicator': {
+ backgroundColor: theme.palette.primary.main || '#0076c4'
+ }
+}));
+
const Transition = React.forwardRef(function Transition(
props: TransitionProps & {
children: React.ReactElement;
@@ -85,43 +106,41 @@ const Transition = React.forwardRef(function Transition(
});
/**
- * Simplified currency modal for Shopping tab
- * Shows fiat currencies + XEC in a single list, sorted alphabetically
+ * Currency modal for Shopping tab - Shows Fiat and Crypto tabs
+ * Similar to P2P trading currency modal but for Goods & Services
*/
const ShoppingCurrencyModal: React.FC = props => {
const { isOpen, onDismissModal, setSelectedItem } = props;
+ const keyFilterTab = 'shopping-currency-tab';
const theme = useTheme();
const fullScreen = useMediaQuery(theme.breakpoints.down('md'));
-
- const [searchTerm, setSearchTerm] = useState('');
const titleId = useId();
- // Build combined list of fiat currencies + XEC, sorted alphabetically by code
- const currencyList = useMemo(() => {
- // Add XEC as a currency option
- const xecOption = { code: 'XEC', name: 'eCash' };
-
- // Combine fiat currencies with XEC
- const allCurrencies = [...LIST_CURRENCIES_USED, xecOption];
-
- // Sort alphabetically by code
- return allCurrencies.sort((a, b) => a.code.localeCompare(b.code));
- }, []);
-
- // Filter currencies based on search term
- const filteredCurrencies = useMemo(() => {
- if (!searchTerm) return currencyList;
+ const [searchTerm, setSearchTerm] = useState('');
+ const [value, setValue] = useState(() => {
+ if (typeof window === 'undefined') return 0;
+ try {
+ return Number(sessionStorage.getItem(keyFilterTab)) || 0;
+ } catch {
+ return 0;
+ }
+ });
- const lowerSearch = searchTerm.toLowerCase();
- return currencyList.filter(
- option => option.code.toLowerCase().includes(lowerSearch) || option.name.toLowerCase().includes(lowerSearch)
- );
- }, [currencyList, searchTerm]);
+ const handleTabChange = (event: React.SyntheticEvent, newValue: number) => {
+ try {
+ sessionStorage.setItem(keyFilterTab, newValue.toString());
+ } catch (error) {
+ // Ignore storage errors (e.g., quota exceeded, unavailable)
+ console.error('Failed to persist shopping currency tab selection to sessionStorage.', error);
+ }
+ setValue(newValue);
+ setSearchTerm('');
+ };
- const handleSelect = (currency: { code: string; name: string }) => {
+ const handleSelect = (currency: any) => {
const filterCurrency: FilterCurrencyType = {
- paymentMethod: 5, // PAYMENT_METHOD.GOODS_SERVICES
- value: currency.code
+ paymentMethod: PAYMENT_METHOD.GOODS_SERVICES,
+ value: currency?.code ?? currency?.ticker ?? currency
};
setSelectedItem?.(filterCurrency);
onDismissModal?.(false);
@@ -129,7 +148,7 @@ const ShoppingCurrencyModal: React.FC = props => {
};
const handleClear = () => {
- setSelectedItem?.({ paymentMethod: 5, value: '' });
+ setSelectedItem?.({ paymentMethod: PAYMENT_METHOD.GOODS_SERVICES, value: '' });
onDismissModal?.(false);
setSearchTerm('');
};
@@ -139,6 +158,76 @@ const ShoppingCurrencyModal: React.FC = props => {
setSearchTerm('');
};
+ const searchTextField = (
+ setSearchTerm(e.target.value)}
+ value={searchTerm}
+ autoFocus
+ />
+ );
+
+ const contentFiat = () => {
+ const filteredFiats = LIST_CURRENCIES_USED.filter(option =>
+ `${option?.name} (${option?.code})`.toLowerCase().includes(searchTerm.toLowerCase())
+ );
+
+ return (
+
+ {searchTextField}
+
+ {filteredFiats.map(option => (
+ handleSelect(option)}
+ fullWidth
+ variant="text"
+ style={{ textTransform: 'capitalize', fontSize: '1.1rem' }}
+ sx={{ justifyContent: 'flex-start', textAlign: 'left' }}
+ >
+ {option?.name} ({option?.code})
+
+ ))}
+ {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 => (
+ handleSelect(option)}
+ fullWidth
+ variant="text"
+ style={{ fontSize: '1.1rem', textTransform: 'none' }}
+ sx={{ justifyContent: 'flex-start', textAlign: 'left' }}
+ >
+ {option?.name} {option?.isDisplayTicker && `(${option?.ticker})`}
+
+ ))}
+ {filteredCrypto.length === 0 && (
+ No crypto found
+ )}
+
+
+ );
+ };
+
return (
= props => {
- setSearchTerm(e.target.value)}
- value={searchTerm}
- autoFocus
- />
-
- {filteredCurrencies.map(option => (
- handleSelect(option)}
- fullWidth
- variant="text"
- style={{ textTransform: 'none', fontSize: '1.1rem' }}
- sx={{ justifyContent: 'flex-start', textAlign: 'left' }}
- >
- {option.code} - {option.name}
-
- ))}
- {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 00000000..d03a158f
--- /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
+ } />
+
+ router.push('/wallet')} className="heading-profile">
+ Wallet
+ } />
+
+
+ {formatAddress(address)}
+ {
+ dispatch(
+ showToast('info', {
+ message: 'info',
+ description: 'Address copied to clipboard'
+ })
+ );
+ }}
+ >
+ } />
+
+
+
+
+ {totalValidAmount} {COIN.XEC}
+
+
+ router.push('/settings')} className="heading-profile">
+ Settings
+ } />
+
+
+ );
+
+ return (
+ <>
+
+
+ Hello
+
+ {data?.user.name ?? 'Anonymous'}
+
+
+
+
setOpenCurrencyModal(true)}>
+ {!currencyInfo ? (
+ // No currency selected - show filter icon
+
+ ) : currencyInfo.type === 'fiat' ? (
+ // Fiat currency - show country flag or fallback to filter icon
+ flagLoadError[currencyInfo.code] ? (
+
+ ) : (
+ setFlagLoadError(prev => ({ ...prev, [currencyInfo.code]: true }))}
+ />
+ )
+ ) : // Crypto currency - show eCash logo for XEC, otherwise ticker text
+ currencyInfo.ticker === 'XEC' ? (
+
+ ) : (
+
+ {currencyInfo.ticker}
+
+ )}
+
+
handlePopoverOpen(e)}>
+ {avatarPath?.getLocaleCashAvatar ? (
+
+
+
+
+
+ ) : (
+
+ )}
+
+
+ setAnchorEl(null)}
+ open={open}
+ anchorEl={anchorEl}
+ anchorOrigin={{
+ vertical: 'bottom',
+ horizontal: 'right'
+ }}
+ transformOrigin={{
+ vertical: 'top',
+ horizontal: 'right'
+ }}
+ >
+ {contentMoreAction}
+
+
+ setOpenCurrencyModal(value)}
+ />
+ >
+ );
+}
diff --git a/apps/telegram-ecash-escrow/src/components/PlaceAnOrderModal/PlaceAnOrderModal.tsx b/apps/telegram-ecash-escrow/src/components/PlaceAnOrderModal/PlaceAnOrderModal.tsx
index 49e90f67..0dcb7f5d 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, OfferCategory } 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';
@@ -15,6 +15,7 @@ import {
formatAmountFor1MXEC,
formatAmountForGoodsServices,
formatNumber,
+ formatPriceByType,
getNumberFromFormatNumber,
getOrderLimitText,
hexEncode,
@@ -320,6 +321,52 @@ const PlaceAnOrderModal: React.FC = props => {
const [isGoodsServicesConversion, setIsGoodsServicesConversion] = useState(() =>
isConvertGoodsServices(post?.postOffer?.priceGoodsServices, post?.postOffer?.tickerPriceGoodsServices)
);
+ /**
+ * Determines if this offer 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
+ * Flow: Seller deposits collateral → Buyer confirms receipt → Collateral returned to seller
+ * Buyer action: BUYER_CONFIRM_RECEIPT
+ *
+ * 2. DIRECT PAYMENT (buyer deposits XEC):
+ * - G&S category + Crypto XEC (paymentMethodId = 4, coinPayment = 'XEC'): Direct XEC payment
+ * Flow: Buyer deposits XEC → Seller releases XEC to buyer (standard escrow)
+ * Buyer action: Standard release flow (not BUYER_CONFIRM_RECEIPT)
+ */
+ const isExternalPayment = useMemo(() => {
+ const hasGoodsServicesCategory =
+ (post?.postOffer as { offerCategory?: string })?.offerCategory === OfferCategory.GOODS_SERVICES;
+ const paymentMethodId = post?.postOffer?.paymentMethods[0]?.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 = (post?.postOffer?.coinPayment || 'XEC').toUpperCase();
+
+ // Case 1: Legacy G&S offers (paymentMethodId = 5) are treated as external payment
+ // These offers were created before offerCategory field existed
+ 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) payment = direct XEC payment, NOT external
+ // This bypasses the collateral mechanism and uses standard buyer deposit flow
+ if (paymentMethodId === PAYMENT_METHOD.CRYPTO && coinPayment === 'XEC') {
+ return false;
+ }
+
+ // Case 4: All other G&S category offers (Bank, Payment App, non-XEC Crypto) = external payment
+ return true;
+ }, [post?.postOffer]);
const selectedWalletPath = useLixiSliceSelector(getSelectedWalletPath);
const { useCreateEscrowOrderMutation, useGetModeratorAccountQuery, useGetRandomArbitratorAccountQuery } =
@@ -839,6 +886,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 {
@@ -1318,7 +1373,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}
@@ -1393,6 +1452,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 dc6174f5..4de2edb7 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';
+/**
+ * Offer category to distinguish XEC trading vs Goods & Services marketplace
+ * - XEC_TRADING: Traditional P2P exchange (buying/selling XEC for fiat/crypto)
+ * - GOODS_SERVICES: Marketplace for goods and services (paid in XEC)
+ */
+export enum OfferCategory {
+ XEC_TRADING = 'XEC_TRADING', // P2P XEC trading (default when null)
+ GOODS_SERVICES = 'GOODS_SERVICES' // Goods & Services marketplace
+}
+
+export const LIST_OFFER_CATEGORY = [
+ {
+ value: OfferCategory.XEC_TRADING,
+ label: 'XEC Trading',
+ description: 'Trade XEC for fiat or other cryptocurrencies'
+ },
+ {
+ value: OfferCategory.GOODS_SERVICES,
+ label: 'Goods & Services',
+ description: 'Buy or sell goods and services for XEC'
+ }
+];
+
export const LIST_USD_STABLECOIN = [
{
id: 1,
diff --git a/apps/telegram-ecash-escrow/src/store/util.ts b/apps/telegram-ecash-escrow/src/store/util.ts
index 6e61ada2..a88775b0 100644
--- a/apps/telegram-ecash-escrow/src/store/util.ts
+++ b/apps/telegram-ecash-escrow/src/store/util.ts
@@ -288,6 +288,39 @@ 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;
+
+ // Handle NaN values from invalid string inputs
+ if (isNaN(numPrice)) return '0';
+
+ // Currencies with no decimal places (THB uses 2 decimal places per ISO 4217)
+ const noDecimalCurrencies = ['VND', 'JPY', 'KRW', 'TWD', 'PHP', 'IDR'];
+
+ 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.
*
diff --git a/docs/GOODS_SERVICES_PAYMENT_FLOWS.drawio b/docs/GOODS_SERVICES_PAYMENT_FLOWS.drawio
new file mode 100644
index 00000000..8021faf3
--- /dev/null
+++ b/docs/GOODS_SERVICES_PAYMENT_FLOWS.drawio
@@ -0,0 +1,403 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/docs/GOODS_SERVICES_PAYMENT_FLOWS.md b/docs/GOODS_SERVICES_PAYMENT_FLOWS.md
new file mode 100644
index 00000000..ddb9d84f
--- /dev/null
+++ b/docs/GOODS_SERVICES_PAYMENT_FLOWS.md
@@ -0,0 +1,238 @@
+# Goods & Services Payment Flow Documentation
+
+## Overview
+
+The Goods & Services (G&S) marketplace supports two distinct payment flows depending on the payment method selected:
+
+1. **External Payment Flow** - Seller escrows collateral while buyer pays externally
+2. **Direct XEC Payment Flow** - Buyer deposits XEC directly (standard escrow)
+
+## Quick Reference
+
+### When is it External Payment?
+
+- ✅ Legacy G&S offers (paymentMethodId = 5)
+- ✅ G&S + Bank Transfer (paymentMethodId = 2)
+- ✅ G&S + Payment App (paymentMethodId = 3)
+- ✅ G&S + Other Cryptocurrencies (paymentMethodId = 4, coinPayment != 'XEC')
+
+### When is it Direct XEC Payment?
+
+- 🔷 G&S + XEC (paymentMethodId = 4, coinPayment = 'XEC')
+
+## Payment Scenarios Explained
+
+### External Payment Scenarios
+
+**The buyer pays the seller OUTSIDE the blockchain** (via bank, app, or other crypto). The seller escrows XEC as collateral to ensure they deliver. When buyer confirms receipt, collateral is released back to seller.
+
+#### Scenario A: Legacy G&S (paymentMethodId = 5)
+
+**Deprecated but still supported for backward compatibility**
+
+- Seller deposits XEC as collateral
+- Buyer transfers money externally
+- Buyer confirms receipt → Collateral released to seller
+
+#### Scenario B: Bank Transfer (paymentMethodId = 2)
+
+**Modern G&S with traditional banking**
+
+- Seller deposits XEC as collateral
+- Buyer transfers via bank → Seller receives funds
+- Buyer confirms receipt → Collateral released to seller
+
+#### Scenario C: Payment App (paymentMethodId = 3)
+
+**Modern G&S with payment apps**
+
+- Seller deposits XEC as collateral
+- Buyer transfers via PayPal/Venmo/etc → Seller receives funds
+- Buyer confirms receipt → Collateral released to seller
+
+#### Scenario D: Non-XEC Crypto (paymentMethodId = 4, e.g., BTC)
+
+**Modern G&S with alternative cryptocurrencies**
+
+- Seller deposits XEC as collateral
+- Buyer transfers BTC/ETH/etc to seller's wallet
+- Buyer confirms receipt → Collateral released to seller
+
+### Direct XEC Payment
+
+#### Scenario E: XEC Direct Payment (paymentMethodId = 4, coinPayment = 'XEC')
+
+**Modern G&S with direct XEC payment**
+
+- **IMPORTANT**: This is NOT external payment
+- Buyer deposits XEC directly into escrow (like standard XEC trading)
+- Seller delivers goods/services
+- Seller releases XEC to buyer (standard release flow)
+- No seller collateral mechanism - buyer's XEC is held in escrow
+
+## Frontend Implementation
+
+### PlaceAnOrderModal.tsx
+
+```typescript
+const isExternalPayment = useMemo(() => {
+ // Determines if order uses external payment (seller collateral)
+ // or direct payment (buyer deposits)
+
+ const hasGoodsServicesCategory = offerCategory === 'GOODS_SERVICES';
+ const paymentMethodId = paymentMethod?.id;
+ const coinPayment = (offer?.coinPayment || '').toUpperCase();
+
+ // Case 1: Legacy G&S → External
+ if (paymentMethodId === PAYMENT_METHOD.GOODS_SERVICES) return true;
+
+ // Case 2: Not G&S → Direct (standard XEC trading)
+ if (!hasGoodsServicesCategory) return false;
+
+ // Case 3: G&S + XEC → Direct (NOT external!)
+ if (paymentMethodId === PAYMENT_METHOD.CRYPTO && coinPayment === 'XEC') {
+ return false;
+ }
+
+ // Case 4: All other G&S → External
+ return true;
+}, [offer, paymentMethod]);
+```
+
+### order-detail/page.tsx
+
+Same logic to determine `isExternalPaymentOrder`:
+
+- Shows "Seller Collateral Escrowed" UI only for external payments
+- Shows "Confirm Receipt" button only for external payments
+- Hides these UI elements for direct XEC payment
+
+## UI Behavior Differences
+
+### External Payment Order (Buyer View)
+
+```
+Status: ESCROW
+├─ 🔐 Seller Collateral Escrowed
+├─ 💰 Pay the seller externally for the goods/services
+├─ 📝 Payment Details: [Bank Account / Payment App / Crypto Address]
+├─ ⏳ Waiting for goods/services delivery...
+└─ ✅ [Confirm Receipt] button enabled
+```
+
+### Direct XEC Payment Order (Buyer View)
+
+```
+Status: ESCROW
+├─ Order Details (standard)
+├─ Price: [amount] XEC
+├─ ⏳ Waiting for seller to deliver...
+└─ No special external payment messaging
+```
+
+## Backend Validation (escrow-order.resolver.ts)
+
+### BUYER_CONFIRM_RECEIPT Action
+
+This action is **ONLY** for external payment orders:
+
+✅ **Valid for**:
+
+- Legacy G&S (paymentMethodId = 5)
+- G&S + Bank Transfer (paymentMethodId = 2)
+- G&S + Payment App (paymentMethodId = 3)
+- G&S + Non-XEC Crypto (paymentMethodId = 4, coinPayment != 'XEC')
+
+❌ **NOT valid for**:
+
+- G&S + XEC (paymentMethodId = 4, coinPayment = 'XEC')
+ - Error: "BUYER_CONFIRM_RECEIPT cannot be used for direct XEC payment orders. Use standard release flow instead."
+- Standard XEC trading (no G&S category)
+
+### Implementation
+
+```typescript
+// Check if direct XEC payment (not allowed for BUYER_CONFIRM_RECEIPT)
+if (offerCategory === 'GOODS_SERVICES' && paymentMethodId === PAYMENT_METHOD.CRYPTO && coinPayment === 'XEC') {
+ throw new Error(
+ 'BUYER_CONFIRM_RECEIPT cannot be used for direct XEC payment orders. Use standard release flow instead.'
+ );
+}
+```
+
+## Decision Tree
+
+```
+User creates/views offer
+ ↓
+Is it G&S category?
+│
+├─ NO → Standard XEC Trading
+│ • Buyer deposits XEC
+│ • Seller releases to buyer
+│ • Standard actions: RELEASE, RETURN
+│
+└─ YES → Check Payment Method
+ │
+ ├─ paymentMethodId = 5 (Legacy)
+ │ └─ EXTERNAL PAYMENT ✅ BUYER_CONFIRM_RECEIPT
+ │
+ ├─ paymentMethodId = 2 (Bank)
+ │ └─ EXTERNAL PAYMENT ✅ BUYER_CONFIRM_RECEIPT
+ │
+ ├─ paymentMethodId = 3 (App)
+ │ └─ EXTERNAL PAYMENT ✅ BUYER_CONFIRM_RECEIPT
+ │
+ ├─ paymentMethodId = 4 (Crypto)
+ │ │
+ │ ├─ coinPayment = 'XEC'
+ │ │ └─ DIRECT PAYMENT (buyer deposit)
+ │ │ ✅ Standard release/return
+ │ │ ❌ NOT BUYER_CONFIRM_RECEIPT
+ │ │
+ │ └─ coinPayment = 'BTC'/'ETH'/etc
+ │ └─ EXTERNAL PAYMENT ✅ BUYER_CONFIRM_RECEIPT
+```
+
+## Error Messages
+
+### User-Facing Errors
+
+**Trying to use BUYER_CONFIRM_RECEIPT on non-G&S order:**
+
+> "BUYER_CONFIRM_RECEIPT can only be used for Goods & Services marketplace orders"
+
+**Trying to use BUYER_CONFIRM_RECEIPT on direct XEC payment:**
+
+> "BUYER_CONFIRM_RECEIPT cannot be used for direct XEC payment orders. Use standard release flow instead."
+
+**Non-buyer trying to confirm receipt:**
+
+> "Only the buyer can confirm receipt of goods/services"
+
+**Confirming receipt when order not in escrow:**
+
+> "Escrow order is not in escrow status"
+
+## Testing Checklist
+
+### External Payment Flows
+
+- [ ] Create G&S + Bank Transfer offer → Shows "Seller Collateral" UI
+- [ ] Create G&S + Payment App offer → Shows "Seller Collateral" UI
+- [ ] Create G&S + Bitcoin offer → Shows "Seller Collateral" UI
+- [ ] Buyer can confirm receipt → Collateral released to seller
+- [ ] Buyer sees payment details to submit externally
+
+### Direct XEC Payment Flow
+
+- [ ] Create G&S + XEC offer → Does NOT show "Seller Collateral" UI
+- [ ] Buyer deposits XEC → Enters escrow (standard)
+- [ ] BUYER_CONFIRM_RECEIPT rejected with proper error
+- [ ] Standard release/return flows work
+
+### Backward Compatibility
+
+- [ ] Legacy G&S offers (paymentMethodId = 5) still work
+- [ ] Legacy offers show "Seller Collateral" UI
+- [ ] Legacy buyers can use BUYER_CONFIRM_RECEIPT