diff --git a/frontend/src/features/orders/components/ApprovalBanner.jsx b/frontend/src/features/orders/components/ApprovalBanner.jsx index 652b6d1..481c90e 100644 --- a/frontend/src/features/orders/components/ApprovalBanner.jsx +++ b/frontend/src/features/orders/components/ApprovalBanner.jsx @@ -1,6 +1,6 @@ import { useState } from 'react' import { Button } from '../../../components/Button' -import { getErrorMessage } from '../../../utils/apiErrorMessage.js' +import { getOrdersErrorMessage } from '../ordersErrorMessage.js' import { formatUnitsString } from '../../../utils/format.js' import { useApproveOrder } from '../hooks/useApproveOrder.js' import './ApprovalBanner.css' @@ -26,7 +26,7 @@ export function ApprovalBanner({ order, onApproved }) {

{status === 'error' && (

- {getErrorMessage(error)} + {getOrdersErrorMessage(error)}

)}
diff --git a/frontend/src/features/orders/components/OrderForm.css b/frontend/src/features/orders/components/OrderForm.css index 0dfc5b3..82b1261 100644 --- a/frontend/src/features/orders/components/OrderForm.css +++ b/frontend/src/features/orders/components/OrderForm.css @@ -1,8 +1,25 @@ +/* One column below 768px; Security+Side and Quantity+Reference price pair up into a 2-column + grid at 768px and above, matching FeesPage.css/FundingPage.css's established breakpoint + convention in this codebase (a literal px value, since --breakpoint-md can't be read inside a + media query). The preview, error, and submit button each span the full row regardless of + column count -- pairing a number with its own row of controls reads worse than a clean divider + between input fields and the outcome/action below them. */ .tu-order-form { - display: flex; - flex-direction: column; + display: grid; + grid-template-columns: minmax(0, 1fr); gap: var(--space-4); - max-width: 360px; +} + +@media (min-width: 768px) { + .tu-order-form { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } +} + +.tu-order-form__preview, +.tu-order-form__error, +.tu-order-form__submit { + grid-column: 1 / -1; } .tu-order-form__preview { @@ -37,3 +54,8 @@ color: var(--color-error); font-size: var(--font-size-caption); } + +.tu-order-form__empty { + grid-column: 1 / -1; + color: var(--color-text-secondary); +} diff --git a/frontend/src/features/orders/components/OrderForm.jsx b/frontend/src/features/orders/components/OrderForm.jsx index e58964d..e5cfb6d 100644 --- a/frontend/src/features/orders/components/OrderForm.jsx +++ b/frontend/src/features/orders/components/OrderForm.jsx @@ -8,8 +8,8 @@ import { Select } from '../../../components/Select' import { Skeleton } from '../../../components/Skeleton' import { useToast } from '../../../components/Toast' import { useSecurities } from '../../portfolio/hooks/useSecurities.js' -import { getErrorMessage } from '../../../utils/apiErrorMessage.js' import { formatMoney } from '../../../utils/format.js' +import { getOrdersErrorMessage } from '../ordersErrorMessage.js' import { usePlaceOrder } from '../hooks/usePlaceOrder.js' import { parseQuantity } from '../parseQuantity.js' import './OrderForm.css' @@ -24,14 +24,19 @@ export function OrderForm() { const [side, setSide] = useState('buy') const [quantity, setQuantity] = useState('') const [referencePrice, setReferencePrice] = useState('') - const [validationMessage, setValidationMessage] = useState(null) + const [quantityError, setQuantityError] = useState(null) + const [referencePriceError, setReferencePriceError] = useState(null) if (securitiesStatus === 'idle' || securitiesStatus === 'loading') { return } if (securitiesStatus === 'error') { - return + return + } + + if (securities.length === 0) { + return

No tradable securities are available right now.

} const selectedSecurity = securities.find((security) => security.security_id === securityId) || securities[0] @@ -50,18 +55,18 @@ export function OrderForm() { const handleSubmit = (event) => { event.preventDefault() - setValidationMessage(null) + setQuantityError(null) + setReferencePriceError(null) + const parsedQuantity = parseQuantity(quantity) if (!parsedQuantity) { - setValidationMessage('Enter a valid quantity greater than zero.') + setQuantityError('Enter a valid quantity greater than zero.') return } - if (!selectedSecurity) { - setValidationMessage('Choose a security.') - return - } - if (!referencePrice || Number.isNaN(Number(referencePrice)) || Number(referencePrice) <= 0) { - setValidationMessage('Enter a valid reference price greater than zero.') + + const trimmedReferencePrice = referencePrice.trim() + if (!trimmedReferencePrice || Number.isNaN(Number(trimmedReferencePrice)) || Number(trimmedReferencePrice) <= 0) { + setReferencePriceError('Enter a valid reference price greater than zero.') return } @@ -69,7 +74,7 @@ export function OrderForm() { securityId: selectedSecurity.security_id, side, quantity: parsedQuantity, - referencePrice, + referencePrice: trimmedReferencePrice, }) .then((order) => { showToast({ message: 'Order placed.', tone: 'success' }) @@ -78,18 +83,24 @@ export function OrderForm() { .catch(() => {}) } - const displayError = validationMessage || (status === 'error' ? getErrorMessage(error) : null) + const submitError = status === 'error' ? getOrdersErrorMessage(error) : null return (
- setSecurityId(event.target.value)} + > {securities.map((security) => ( ))} - setSide(event.target.value)}> @@ -100,6 +111,7 @@ export function OrderForm() { value={quantity} onChange={(event) => setQuantity(event.target.value)} placeholder="0" + error={quantityError} required /> setReferencePrice(event.target.value)} placeholder="0.00" + error={referencePriceError} required /> {notional && ( @@ -117,12 +130,12 @@ export function OrderForm() { {formatMoney(notional)}
)} - {displayError && ( + {submitError && (

- {displayError} + {submitError}

)} - diff --git a/frontend/src/features/orders/components/OrderList.jsx b/frontend/src/features/orders/components/OrderList.jsx index fea97bc..9dbab3d 100644 --- a/frontend/src/features/orders/components/OrderList.jsx +++ b/frontend/src/features/orders/components/OrderList.jsx @@ -4,8 +4,8 @@ import { EmptyState } from '../../../components/EmptyState' import { ErrorState } from '../../../components/ErrorState' import { Skeleton } from '../../../components/Skeleton' import { Table } from '../../../components/Table' -import { getErrorMessage } from '../../../utils/apiErrorMessage.js' import { useOrders } from '../hooks/useOrders.js' +import { getOrdersErrorMessage } from '../ordersErrorMessage.js' import { OrderRow } from './OrderRow.jsx' export function OrderList() { @@ -23,7 +23,7 @@ export function OrderList() { } if (status === 'error') { - return + return } if (orders.length === 0) { diff --git a/frontend/src/features/orders/ordersErrorMessage.js b/frontend/src/features/orders/ordersErrorMessage.js new file mode 100644 index 0000000..1593387 --- /dev/null +++ b/frontend/src/features/orders/ordersErrorMessage.js @@ -0,0 +1,19 @@ +import { getErrorMessage } from '../../utils/apiErrorMessage.js' + +// order_service.py / orders.py error codes -- every real rejection reason a POST /orders or +// POST /orders//approve can return, so a real cause is never swallowed into the generic +// fallback (which is what "some error occurred" looked like before this map existed). +const SPECIFIC_MESSAGES = { + customer_not_eligible: "Your account isn't eligible to place orders yet.", + kyc_verification_pending: 'Your identity verification is still pending — you can place orders once it completes.', + kyc_verification_rejected: 'Your identity verification was not approved. Contact support to resolve this.', + account_approval_pending: 'Your account is still pending approval — you can place orders once it completes.', + account_approval_rejected: 'Your account was not approved. Contact support to resolve this.', + insufficient_investable_cash: "You don't have enough investable cash for this order.", +} + +export function getOrdersErrorMessage(error) { + if (!error) return null + if (SPECIFIC_MESSAGES[error.code]) return SPECIFIC_MESSAGES[error.code] + return getErrorMessage(error) +} diff --git a/frontend/src/pages/OrderNewPage.css b/frontend/src/pages/OrderNewPage.css new file mode 100644 index 0000000..445db5d --- /dev/null +++ b/frontend/src/pages/OrderNewPage.css @@ -0,0 +1,7 @@ +/* The order form is four short fields, not a reading-length document -- the page's own + --reading-max-width (640px) gives it real room for a 2-column field grid (OrderForm.css) + without stretching to the full --content-max-width, which would leave the fields looking lost + in a mostly-empty row on a wide viewport. */ +.tu-page.tu-order-new-page { + max-width: var(--reading-max-width); +} diff --git a/frontend/src/pages/OrderNewPage.jsx b/frontend/src/pages/OrderNewPage.jsx index 1c12adb..8f2860c 100644 --- a/frontend/src/pages/OrderNewPage.jsx +++ b/frontend/src/pages/OrderNewPage.jsx @@ -1,11 +1,15 @@ +import { Card } from '../components/Card' import { OrderForm } from '../features/orders/components/OrderForm.jsx' +import './OrderNewPage.css' import './PageLayout.css' export function OrderNewPage() { return ( -
+

Place an order

- + + +
) }