fix(cart): say it is a demo where the customer can see it (#195)
Linting / lint (pull_request) Successful in 2m14s
SonarQube Analysis / sonarqube (pull_request) Failing after 16m48s

Production runs demo mode with no PayPal credentials — that combination is the whole reason #191 turned it on — and in exactly that configuration the storefront rendered a full-width primary button reading plainly `Checkout`. The `(Demo)` suffix was gated on a PayPal client id being present, so the one configuration that needs the word was the only one that never got it.

The button is not decorative. It posts to `/demo/purchase`, which marks the item `sold`, writes a `completed` row into `orders` at the real price, and emails everyone who favorited it through production's real SMTP. Nobody is charged, which is what the compose banner promises and is true — but a customer cannot tell they have placed a pretend order, the inventory says otherwise, other customers are told it sold, and nobody is expecting to ship anything. #190, #191 and #192 all reason carefully about not charging by accident; none of them consider accepting an order by accident.

Three things now say so, because one of them was never going to be enough:

The label is unconditional. `type` still follows the PayPal client id — secondary when real PayPal buttons sit above it, primary when it is the only way to check out — and that distinction is worth keeping, but it is about prominence rather than about what the order is.

A notice sits above it for the whole of demo mode, before an address is picked and whether or not PayPal is configured. A parenthesis on the control someone has already decided to press is the weakest possible moment to tell them.

The confirmation stopped saying `Order complete!`, which is exactly what a real order says. It now names the two things a customer would otherwise assume: nothing was charged, and nothing will be shipped.

Three end-to-end tests, written first and failing first against a browser — the label assertion failed with `Expected "Checkout (Demo)", Received "Checkout"` on an `ant-btn-primary ant-btn-block` element, which is the defect exactly as reported. The suite already runs `DEMO_MODE=true` with no PayPal credentials, so it reproduces production's configuration without any new fixture.

`CartPage.checkoutButton` matches the label by prefix rather than in full, deliberately: a locator naming the correct label would have gone looking for the right button and found nothing, which is how a test for this can quietly pass by being wrong in the same direction as the bug.

Verified: 3 new tests pass, frontend build clean, lint 0 errors, 25 unit tests pass, and the cart-countdown and orders suites still pass. The favorites and favorites-filter suites fail here, and fail identically with this change stashed — 9 failures without it, 8 with — so they are pre-existing and not from this. Worth their own issue.

Closes #195

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-27 11:33:11 -05:00
co-authored by Claude Opus 5
parent fdb5b7a0be
commit 456e1aa358
3 changed files with 115 additions and 2 deletions
+27 -2
View File
@@ -5,6 +5,7 @@ import List from 'antd/es/list';
import Button from 'antd/es/button';
import Empty from 'antd/es/empty';
import Card from 'antd/es/card';
import Alert from 'antd/es/alert';
import Radio from 'antd/es/radio';
import Form from 'antd/es/form';
import Input from 'antd/es/input';
@@ -128,7 +129,10 @@ export default function Cart() {
setCheckingOut(true);
try {
await demoCartPurchase(selectedAddressId);
message.success('Order complete!');
// The item really is marked sold and everyone who favorited it really is
// emailed, so the one moment the customer is told what happened is the one
// moment a demo order has to stop looking like a real one. #195.
message.success('Demo order complete — nothing was charged and nothing will be shipped.');
refreshCartContext();
loadAll();
} catch (err) {
@@ -235,17 +239,38 @@ export default function Cart() {
</Card>
<Card title="Checkout" style={{ marginTop: 24 }}>
{/*
Shown for the whole of demo mode: before an address is picked,
and whether or not PayPal is configured. The word on the button
is the smaller half of this — it asks the customer to notice a
parenthesis on the control they have already decided to press.
*/}
{config?.demoMode && (
<Alert
type="warning"
showIcon
style={{ marginBottom: 12 }}
message="Demonstration only"
description="This shop is not taking payments at the moment. Placing an order here costs nothing, and nothing will be shipped."
/>
)}
{!selectedAddressId && <Text type="warning">Select a shipping address to check out.</Text>}
{config?.paypalClientId && selectedAddressId && <div id="paypal-cart-buttons" />}
{config?.demoMode && selectedAddressId && (
<Button
block
// Secondary when real PayPal buttons sit above it, primary when
// it is the only way to check out. The label does not follow
// that: a demo order is a demo order either way, and gating the
// word on a PayPal client id meant production — which runs demo
// mode precisely because it has no PayPal credentials — was the
// one place the label never appeared. #195.
type={config.paypalClientId ? 'default' : 'primary'}
style={{ marginTop: 8 }}
loading={checkingOut}
onClick={handleDemoCheckout}
>
{config.paypalClientId ? 'Checkout (Demo)' : 'Checkout'}
Checkout (Demo)
</Button>
)}
</Card>
+80
View File
@@ -0,0 +1,80 @@
import { test, expect, createItem, uniqueSuffix } from './fixtures';
/**
* Demo mode as production actually runs it: `DEMO_MODE=true` with no PayPal
* credentials configured, which is the interim #191 put in place and the shape
* this suite already runs in.
*
* That combination used to render a full-width primary button reading plainly
* "Checkout". The "(Demo)" suffix was gated on a PayPal client id being present,
* so the one configuration that needs the label was the only one that did not
* get it — and the button is not decorative, it marks real inventory sold and
* emails everyone who favorited it. See #195.
*
* These assert what a customer can see, because that is the whole defect. The
* server was always right about what it was doing.
*/
const ADDRESS = {
fullName: 'Dana Holt',
addressLine1: '118 Cedar Street',
city: 'Asheville',
state: 'NC',
postalCode: '28801',
isDefault: true
};
test.describe('Demo mode says so to the customer', () => {
test('labels the checkout button as a demo when no PayPal is configured', async ({
page,
customer,
cart
}) => {
const name = `Demo c${uniqueSuffix()}`;
const item = await createItem(page.request, { name, price: '80' });
expect((await page.request.post(`/api/cart/items/${item.id}`)).status()).toBe(201);
expect((await page.request.post('/api/customers/me/addresses', { data: ADDRESS })).ok()).toBe(true);
await cart.goto();
// The cart selects the default address on load, so the checkout controls
// render with nothing clicked.
await expect(cart.checkoutButton).toBeVisible({ timeout: 20000 });
await expect(cart.checkoutButton).toHaveText('Checkout (Demo)');
});
test('says so before the customer commits, not only on the button', async ({
page,
customer,
cart
}) => {
const name = `Demo n${uniqueSuffix()}`;
const item = await createItem(page.request, { name, price: '80' });
expect((await page.request.post(`/api/cart/items/${item.id}`)).status()).toBe(201);
expect((await page.request.post('/api/customers/me/addresses', { data: ADDRESS })).ok()).toBe(true);
await cart.goto();
// A label on the button alone asks the customer to notice a parenthesis on
// the thing they are already clicking. The notice is the part that has to
// survive someone not reading carefully.
await expect(cart.demoNotice).toBeVisible({ timeout: 20000 });
});
test('the confirmation says nothing was charged', async ({ page, customer, cart }) => {
const name = `Demo p${uniqueSuffix()}`;
const item = await createItem(page.request, { name, price: '80' });
expect((await page.request.post(`/api/cart/items/${item.id}`)).status()).toBe(201);
expect((await page.request.post('/api/customers/me/addresses', { data: ADDRESS })).ok()).toBe(true);
await cart.goto();
await expect(cart.checkoutButton).toBeVisible({ timeout: 20000 });
await cart.checkoutButton.click();
// "Order complete!" is true and is exactly what a real order would say. The
// item really is marked sold and favoriters really are emailed, so the one
// moment the customer is told what happened has to distinguish the two.
await expect(page.getByText(/nothing was charged/i)).toBeVisible({ timeout: 20000 });
});
});
+8
View File
@@ -10,10 +10,18 @@ import { Locator, Page } from '@playwright/test';
export class CartPage {
readonly continueShoppingButton: Locator;
readonly emptyNotice: Locator;
readonly checkoutButton: Locator;
readonly demoNotice: Locator;
constructor(private readonly page: Page) {
this.continueShoppingButton = page.getByRole('button', { name: 'Continue Shopping' });
this.emptyNotice = page.getByText('Your cart is empty');
// Matched on a prefix rather than the whole label, so a test can assert what
// the label says instead of having to know it in order to find the button.
// That is the point of #195: the label was wrong, and a locator naming it in
// full would have gone looking for the right button and found nothing.
this.checkoutButton = page.getByRole('button', { name: /^Checkout/ });
this.demoNotice = page.getByText('Demonstration only');
}
async goto(): Promise<void> {