Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions changelog.d/next/activity-hidden-order.fixed.md
Original file line number Diff line number Diff line change
@@ -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.
81 changes: 81 additions & 0 deletions src/components/templates/Marketplace/MarketplaceOrders.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(<MarketplaceOrders />);

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(<MarketplaceOrders />);

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(<MarketplaceOrders />);

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(<MarketplaceOrders />);

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(<MarketplaceOrders />);
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');
});
});
54 changes: 52 additions & 2 deletions src/components/templates/Marketplace/MarketplaceOrders.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -77,6 +78,7 @@ export function MarketplaceOrders() {
const tabListRef = useRef<HTMLDivElement>(null);
const tabRefs = useRef<Partial<Record<OrdersTab, HTMLButtonElement | null>>>({});
const redirectedHashRef = useRef<string | null>(null);
const [anchorOrderId, setAnchorOrderId] = useState<string | null>(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));
Expand All @@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -186,6 +204,34 @@ export function MarketplaceOrders() {
</div>
) : orders.length ? (
<>
{linkedUnlistedOrder && (
<div className="grid gap-3" data-testid="marketplace-linked-order">
<Heading level={2} size="sm" className="text-xl font-semibold">
From Activity
</Heading>
<Card id={orderAnchorId(linkedUnlistedOrder.id)} className="scroll-mt-24 border py-4">
<CardContent className="grid gap-2 px-5">
<div className="flex flex-wrap gap-2">
<Badge variant="outline" className="border-border/60 text-muted-foreground">
{currentUserPubky === linkedUnlistedOrder.buyerPubky ? 'Your purchase' : 'Your sale'}
</Badge>
<Badge variant="secondary" data-testid="marketplace-linked-order-state">
{unlistedOrderStateLabel(linkedUnlistedOrder)}
</Badge>
</div>
{linkedUnlistedOrder.lines.map((line) => (
<Typography key={line.listingAggregateId} as="p" className="font-semibold">
{line.title} × {line.quantity}
</Typography>
))}
<MarketplaceOrderReference
order={linkedUnlistedOrder}
isBuyer={currentUserPubky === linkedUnlistedOrder.buyerPubky}
/>
</CardContent>
</Card>
</div>
)}
{buyerCheckouts.length > 0 && (
<div className="grid gap-3" data-testid="marketplace-continue-checkout">
<Heading level={2} size="sm" className="text-xl font-semibold">
Expand Down Expand Up @@ -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<MarketplaceOrderView, 'order' | 'payment'>,
currentUserPubky: string | null,
Expand Down
9 changes: 9 additions & 0 deletions src/libs/commerce/checkout-phase.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
8 changes: 8 additions & 0 deletions src/libs/commerce/checkout-phase.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
49 changes: 49 additions & 0 deletions src/test/vrt/marketplace/MarketplaceOrders.vrt.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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(<MarketplaceOrders />, { 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;
Expand Down
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading