diff --git a/changelog.d/next/activity-hidden-order.fixed.md b/changelog.d/next/activity-hidden-order.fixed.md new file mode 100644 index 0000000000..13a0e7b22f --- /dev/null +++ b/changelog.d/next/activity-hidden-order.fixed.md @@ -0,0 +1 @@ +An Activity row for an order the Orders page does not list, such as a checkout cancelled before payment, now opens that order in a "From Activity" card with its state and short order ID. diff --git a/src/components/templates/Marketplace/MarketplaceOrders.test.tsx b/src/components/templates/Marketplace/MarketplaceOrders.test.tsx index 0ac9fb7686..bc19878d0f 100644 --- a/src/components/templates/Marketplace/MarketplaceOrders.test.tsx +++ b/src/components/templates/Marketplace/MarketplaceOrders.test.tsx @@ -812,3 +812,84 @@ describe('MarketplaceOrders seen checkpoint', () => { expect(markSeen).toHaveBeenCalledOnce(); }); }); + +describe('MarketplaceOrders Activity link to an order no section lists', () => { + const scrollIntoView = vi.fn(); + + beforeEach(() => { + ordersState.currentUserPubky = CURRENT_USER; + ordersState.adapterMode = 'transaction-service'; + ordersState.orders = [ + orderView('paid', 'Sold paid boots', 'seller'), + orderView('cancelled', 'Unpaid sold lamp', 'seller', { receiptId: null }), + ]; + scrollIntoView.mockReset(); + Element.prototype.scrollIntoView = scrollIntoView; + vi.spyOn(CommerceController, 'markOrdersAttentionSeen').mockResolvedValue(); + }); + + afterEach(() => { + window.history.replaceState(null, '', '/'); + }); + + it('shows the linked order with its state and short ID, and scrolls to it', async () => { + window.history.replaceState(null, '', '/marketplace/orders#order-test-unpaid-sold-lamp'); + + render(); + + const section = await screen.findByTestId('marketplace-linked-order'); + expect(within(section).getByRole('heading', { name: 'From Activity' })).toBeInTheDocument(); + expect(within(section).getByText('Your sale')).toBeInTheDocument(); + expect(within(section).getByTestId('marketplace-linked-order-state')).toHaveTextContent('Cancelled before payment'); + expect(within(section).getByText('Unpaid sold lamp × 1')).toBeInTheDocument(); + expect(within(section).getByTestId('order-reference-label')).toHaveTextContent('Order test-unp'); + const card = section.querySelector('[id="order-test-unpaid-sold-lamp"]'); + expect(card).not.toBeNull(); + await waitFor(() => expect(scrollIntoView).toHaveBeenCalled()); + expect(scrollIntoView.mock.contexts.some((element) => element === card)).toBe(true); + }); + + it('keeps the unpaid cancel out of the page without an Activity link', () => { + render(); + + expect(screen.queryByTestId('marketplace-linked-order')).toBeNull(); + expect(screen.queryByText(/Unpaid sold lamp/)).toBeNull(); + }); + + it('does not duplicate an order another section already lists', async () => { + window.history.replaceState(null, '', '/marketplace/orders#order-test-sold-paid-boots'); + + render(); + + await waitFor(() => expect(screen.getByText('Sold paid boots × 1')).toBeInTheDocument()); + expect(screen.queryByTestId('marketplace-linked-order')).toBeNull(); + expect(screen.getAllByText('Sold paid boots × 1')).toHaveLength(1); + }); + + it('shows nothing for a linked order the signed-in account is not part of', async () => { + const foreign = orderView('cancelled', 'Foreign unpaid lamp', 'seller', { + receiptId: null, + buyerPubky: OTHER_USER, + sellerPubky: ORDER_FIXTURE_SELLER, + }); + ordersState.orders = [orderView('paid', 'Sold paid boots', 'seller'), foreign]; + window.history.replaceState(null, '', `/marketplace/orders#order-${foreign.order.id}`); + + render(); + + await waitFor(() => expect(screen.getByText('Sold paid boots × 1')).toBeInTheDocument()); + expect(screen.queryByTestId('marketplace-linked-order')).toBeNull(); + expect(screen.queryByText(/Foreign unpaid lamp/)).toBeNull(); + expect(document.getElementById(`order-${foreign.order.id}`)).toBeNull(); + }); + + it('follows a hash change while the page is open', async () => { + render(); + expect(screen.queryByTestId('marketplace-linked-order')).toBeNull(); + + window.history.replaceState(null, '', '/marketplace/orders#order-test-unpaid-sold-lamp'); + window.dispatchEvent(new HashChangeEvent('hashchange')); + + expect(await screen.findByTestId('marketplace-linked-order-state')).toHaveTextContent('Cancelled before payment'); + }); +}); diff --git a/src/components/templates/Marketplace/MarketplaceOrders.tsx b/src/components/templates/Marketplace/MarketplaceOrders.tsx index b61e797eda..c2c30936f5 100644 --- a/src/components/templates/Marketplace/MarketplaceOrders.tsx +++ b/src/components/templates/Marketplace/MarketplaceOrders.tsx @@ -30,6 +30,7 @@ import { readCheckoutHashOrderId, reservedWhileYouPayCopy, sellerReservationCopy, + unlistedOrderStateLabel, } from '@/libs/commerce/checkout-phase'; import { formatCommerceMoney } from '@/libs/commerce/format'; import { buyerVisiblePaymentStatus } from '@/libs/commerce/locks-payment'; @@ -77,6 +78,7 @@ export function MarketplaceOrders() { const tabListRef = useRef(null); const tabRefs = useRef>>({}); const redirectedHashRef = useRef(null); + const [anchorOrderId, setAnchorOrderId] = useState(null); const buyerCheckouts = orders.filter(({ order }) => isBuyerCheckoutInProgress(order, currentUserPubky)); const sellerReservations = orders.filter(({ order }) => isSellerReservation(order, currentUserPubky)); const abandonedCheckouts = orders.filter(({ order }) => isAbandonedCheckout(order, currentUserPubky)); @@ -85,12 +87,28 @@ export function MarketplaceOrders() { ); const orderCounts = getOrderTabCounts(historyOrders, currentUserPubky); const visibleOrders = historyOrders.filter((view) => isOrderInTab(view, activeTab, currentUserPubky)); + const listedOrderIds = new Set( + [...buyerCheckouts, ...sellerReservations, ...historyOrders, ...abandonedCheckouts].map(({ order }) => order.id), + ); + const anchoredOrder = + anchorOrderId && !listedOrderIds.has(anchorOrderId) + ? (orders.find(({ order }) => order.id === anchorOrderId)?.order ?? null) + : null; + const linkedUnlistedOrder = + anchoredOrder && isOrderParticipant(anchoredOrder, currentUserPubky) ? anchoredOrder : null; useMarkMarketplaceOrdersSeen(!isLoading && !error && !needsSession); + useEffect(() => { + const readAnchor = () => setAnchorOrderId(readOrderAnchorId(window.location.hash)); + readAnchor(); + window.addEventListener('hashchange', readAnchor); + return () => window.removeEventListener('hashchange', readAnchor); + }, []); + useEffect(() => { if (isLoading) return; - const anchorId = readOrderAnchorId(window.location.hash); + const anchorId = anchorOrderId; if (!anchorId) return; const view = orders.find((candidate) => candidate.order.id === anchorId); if (!view) return; @@ -102,7 +120,7 @@ export function MarketplaceOrders() { return; } document.getElementById(orderAnchorId(anchorId))?.scrollIntoView({ block: 'center' }); - }, [activeTab, currentUserPubky, isLoading, orders]); + }, [activeTab, anchorOrderId, currentUserPubky, isLoading, orders]); useEffect(() => { if (isLoading) return; @@ -186,6 +204,34 @@ export function MarketplaceOrders() { ) : orders.length ? ( <> + {linkedUnlistedOrder && ( +
+ + From Activity + + + +
+ + {currentUserPubky === linkedUnlistedOrder.buyerPubky ? 'Your purchase' : 'Your sale'} + + + {unlistedOrderStateLabel(linkedUnlistedOrder)} + +
+ {linkedUnlistedOrder.lines.map((line) => ( + + {line.title} × {line.quantity} + + ))} + +
+
+
+ )} {buyerCheckouts.length > 0 && (
@@ -598,6 +644,10 @@ function isCurrentUserBuyer(order: MarketplaceOrder, currentUserPubky: string | return currentUserPubky !== null && order.buyerPubky === currentUserPubky; } +function isOrderParticipant(order: MarketplaceOrder, currentUserPubky: string | null): boolean { + return isCurrentUserBuyer(order, currentUserPubky) || isCurrentUserSeller(order, currentUserPubky); +} + function isSellerAwaitingPayment( { order, payment }: Pick, currentUserPubky: string | null, diff --git a/src/libs/commerce/checkout-phase.test.ts b/src/libs/commerce/checkout-phase.test.ts index f20b1bc141..a14dfbe3da 100644 --- a/src/libs/commerce/checkout-phase.test.ts +++ b/src/libs/commerce/checkout-phase.test.ts @@ -17,12 +17,21 @@ import { reservedWhileYouPayCopy, resolveCreatedCheckoutOrderIds, sellerReservationCopy, + unlistedOrderStateLabel, } from './checkout-phase'; const BUYER = 'b'.repeat(52); const SELLER = 's'.repeat(52); describe('checkout-phase', () => { + it('names the state of an order no Orders section lists', () => { + expect(unlistedOrderStateLabel({ state: 'cancelled', receiptId: null })).toBe('Cancelled before payment'); + expect(unlistedOrderStateLabel({ state: 'cancelled' })).toBe('Cancelled before payment'); + expect(unlistedOrderStateLabel({ state: 'cancelled', receiptId: 'receipt-1' })).toBe('Cancelled'); + expect(unlistedOrderStateLabel({ state: 'pending_payment' })).toBe('Awaiting payment'); + expect(unlistedOrderStateLabel({ state: 'return_requested' })).toBe('Return requested'); + }); + it('builds the checkout route without minting a second id', () => { expect(getMarketplaceCheckoutRoute()).toBe(MARKETPLACE_ROUTES.CHECKOUT); expect(getMarketplaceCheckoutRoute('018f47d2-6a27-7c23-a49d-000000000001')).toBe( diff --git a/src/libs/commerce/checkout-phase.ts b/src/libs/commerce/checkout-phase.ts index 7334217310..b885c19042 100644 --- a/src/libs/commerce/checkout-phase.ts +++ b/src/libs/commerce/checkout-phase.ts @@ -102,6 +102,14 @@ export function isSellerPaidOrder( ); } +/** State pill for an order an Activity link opens but no Orders section lists (a seller's unpaid cancel). */ +export function unlistedOrderStateLabel(order: { state: string; receiptId?: string | null }): string { + if (order.state === 'cancelled' && !hasReceipt(order)) return 'Cancelled before payment'; + if (isPendingPaymentState(order.state)) return 'Awaiting payment'; + const label = order.state.replaceAll('_', ' '); + return label.charAt(0).toUpperCase() + label.slice(1); +} + export function isBuyerCheckoutInProgress( order: { state: string; buyerPubky: string }, buyerPubky: string | null, diff --git a/src/test/vrt/marketplace/MarketplaceOrders.vrt.test.tsx b/src/test/vrt/marketplace/MarketplaceOrders.vrt.test.tsx index d10da95ef7..3e551cafad 100644 --- a/src/test/vrt/marketplace/MarketplaceOrders.vrt.test.tsx +++ b/src/test/vrt/marketplace/MarketplaceOrders.vrt.test.tsx @@ -210,8 +210,32 @@ const fixtures = vi.hoisted(async () => { }, ]; + const sellerUnpaidCancelled = createOrderFixture('cancelled', { + id: '018f47d2-6a27-7c23-a49d-000000000741', + buyerPubky: 't'.repeat(52), + sellerPubky: ORDER_FIXTURE_BUYER, + receiptId: null, + lines: [ + { + listingAggregateId: `listing:${ORDER_FIXTURE_BUYER}_lamp`, + listingRevision: 1, + contentHash: 'f'.repeat(64), + title: 'Brass desk lamp', + quantity: 1, + unitPrice: { amountMinor: 4_500, currency: 'USD', exponent: 2 }, + subtotal: { amountMinor: 4_500, currency: 'USD', exponent: 2 }, + }, + ], + }); + const activityLinkedUnlisted = [ + ...sellerPendingPayment, + { order: sellerUnpaidCancelled, payment: null, receipt: null }, + ]; + return { buyer: ORDER_FIXTURE_BUYER, + activityLinkedUnlisted, + activityLinkedUnlistedId: sellerUnpaidCancelled.id, everyOrderState: createOrderViewsForEveryState(), everyPaymentState: createOrderViewsForEveryPaymentState(), sellerNeedsAttention, @@ -412,6 +436,31 @@ describe('Marketplace orders — visual regression', () => { await expect(expectVrtSurface('marketplace-orders')).toMatchScreenshot('orders-pending-payment-seller-desktop'); }); + for (const [label, viewport] of [ + ['desktop', VRT_VIEWPORT_DESKTOP], + ['mobile', VRT_VIEWPORT_MOBILE], + ] as const) { + it(`renders an Activity-linked unpaid cancel the page does not list at ${label} viewport`, async () => { + const { activityLinkedUnlisted, activityLinkedUnlistedId } = await fixtures; + ordersState.orders = activityLinkedUnlisted; + ordersState.isLoading = false; + ordersState.error = null; + const previous = `${window.location.pathname}${window.location.search}${window.location.hash}`; + window.history.replaceState(null, '', `${window.location.pathname}#order-${activityLinkedUnlistedId}`); + try { + const screen = await renderForVRT(, { viewport }); + await expect.element(screen.getByRole('heading', { name: 'From Activity' })).toBeVisible(); + await expect.element(screen.getByText('Cancelled before payment')).toBeVisible(); + await expect.element(screen.getByText('Order 018f47d2').first()).toBeVisible(); + await expect(expectVrtSurface('marketplace-orders')).toMatchScreenshot( + `orders-activity-linked-unlisted-${label}`, + ); + } finally { + window.history.replaceState(null, '', previous); + } + }); + } + it('rejects an incorrect production surface marker', async () => { ordersState.orders = []; ordersState.isLoading = false; diff --git a/src/test/vrt/marketplace/__screenshots__/MarketplaceOrders.vrt.test.tsx/Marketplace-orders---visual-regression-renders-an-Activity-linked-unpaid-cancel-the-page-does-not-list-at-desktop-viewport-1.png b/src/test/vrt/marketplace/__screenshots__/MarketplaceOrders.vrt.test.tsx/Marketplace-orders---visual-regression-renders-an-Activity-linked-unpaid-cancel-the-page-does-not-list-at-desktop-viewport-1.png new file mode 100644 index 0000000000..9c9e746c4c Binary files /dev/null and b/src/test/vrt/marketplace/__screenshots__/MarketplaceOrders.vrt.test.tsx/Marketplace-orders---visual-regression-renders-an-Activity-linked-unpaid-cancel-the-page-does-not-list-at-desktop-viewport-1.png differ diff --git a/src/test/vrt/marketplace/__screenshots__/MarketplaceOrders.vrt.test.tsx/orders-activity-linked-unlisted-desktop-chromium-linux.png b/src/test/vrt/marketplace/__screenshots__/MarketplaceOrders.vrt.test.tsx/orders-activity-linked-unlisted-desktop-chromium-linux.png new file mode 100644 index 0000000000..1aa7163160 Binary files /dev/null and b/src/test/vrt/marketplace/__screenshots__/MarketplaceOrders.vrt.test.tsx/orders-activity-linked-unlisted-desktop-chromium-linux.png differ diff --git a/src/test/vrt/marketplace/__screenshots__/MarketplaceOrders.vrt.test.tsx/orders-activity-linked-unlisted-desktop-firefox-linux.png b/src/test/vrt/marketplace/__screenshots__/MarketplaceOrders.vrt.test.tsx/orders-activity-linked-unlisted-desktop-firefox-linux.png new file mode 100644 index 0000000000..41e35b65ec Binary files /dev/null and b/src/test/vrt/marketplace/__screenshots__/MarketplaceOrders.vrt.test.tsx/orders-activity-linked-unlisted-desktop-firefox-linux.png differ diff --git a/src/test/vrt/marketplace/__screenshots__/MarketplaceOrders.vrt.test.tsx/orders-activity-linked-unlisted-mobile-chromium-linux.png b/src/test/vrt/marketplace/__screenshots__/MarketplaceOrders.vrt.test.tsx/orders-activity-linked-unlisted-mobile-chromium-linux.png new file mode 100644 index 0000000000..a0f7bc26b7 Binary files /dev/null and b/src/test/vrt/marketplace/__screenshots__/MarketplaceOrders.vrt.test.tsx/orders-activity-linked-unlisted-mobile-chromium-linux.png differ diff --git a/src/test/vrt/marketplace/__screenshots__/MarketplaceOrders.vrt.test.tsx/orders-activity-linked-unlisted-mobile-firefox-linux.png b/src/test/vrt/marketplace/__screenshots__/MarketplaceOrders.vrt.test.tsx/orders-activity-linked-unlisted-mobile-firefox-linux.png new file mode 100644 index 0000000000..438162b413 Binary files /dev/null and b/src/test/vrt/marketplace/__screenshots__/MarketplaceOrders.vrt.test.tsx/orders-activity-linked-unlisted-mobile-firefox-linux.png differ