Skip to content
Open
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
29 changes: 2 additions & 27 deletions blocks/checkout/checkout-order.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,34 +11,9 @@ import { validateLinkIntegrity } from './link-integrity.js';
import { validateForm } from './checkout-validation.js';
import { logOperation, getCheckoutId } from '../../scripts/operations-log.js';
import { getStandardCheckoutContext } from '../../scripts/checkout-context.js';
import { isEstimateExpiringSoon } from '../../scripts/estimate-token.js';

export { validateLinkIntegrity };

/**
* Minimum remaining lifetime (in ms) before an estimate token is considered
* "expiring soon" and should be proactively refreshed. 5 minutes gives enough
* headroom to complete the order-create + payment-initiate round-trips.
*/
const ESTIMATE_TOKEN_BUFFER_MS = 5 * 60 * 1000;

/**
* Decode a JWT's `exp` claim and return whether the token is expired or will
* expire within the buffer window. Returns `true` (needs refresh) when the
* token cannot be decoded.
*
* @param {string|null|undefined} token - JWT estimate token
* @param {number} [bufferMs] - refresh when fewer than this many ms remain
* @returns {boolean}
*/
export function isEstimateExpiringSoon(token, bufferMs = ESTIMATE_TOKEN_BUFFER_MS) {
if (!token) return true;
try {
const payload = JSON.parse(atob(token.split('.')[1]));
return (payload.exp * 1000) - Date.now() < bufferMs;
} catch {
return true;
}
}
export { validateLinkIntegrity, isEstimateExpiringSoon };

/**
* Returns the explicitly selected checkout payment method, if any.
Expand Down
21 changes: 17 additions & 4 deletions blocks/pdp/add-to-cart.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import { getMetadata } from '../../scripts/aem.js';
import { checkVariantOutOfStock, getLocaleAndLanguage, getPdpOverride } from '../../scripts/scripts.js';
import {
checkVariantOutOfStock, getLocaleAndLanguage, getOrderPath, getPdpOverride,
} from '../../scripts/scripts.js';
import { getConfig } from '../../scripts/commerce-config.js';
import { logOperation, logError } from '../../scripts/operations-log.js';

Expand Down Expand Up @@ -389,11 +391,22 @@ export default function renderAddToCart(ph, block, parent) {
});
}

// reenable button
addToCartButton.textContent = 'Add to Cart';
addToCartButton.removeAttribute('aria-disabled');
logOperation('added-to-cart', { sku: targetSku, quantity: allowedQty });
document.dispatchEvent(new CustomEvent('pdp:add-to-cart', { detail: { item } }));

// On mobile the header cart badge is too subtle — redirect to the
// cart page so the user gets clear feedback that the item was added.
// Flush first: addItem uses a debounced persist (300 ms) so
// localStorage may not have the new item yet.
if (window.innerWidth < 900) {
cartApi.flush();
window.location.href = getOrderPath('cart');
return;
}

// reenable button (desktop only — mobile redirects above)
addToCartButton.textContent = 'Add to Cart';
addToCartButton.removeAttribute('aria-disabled');
return;
}

Expand Down
8 changes: 8 additions & 0 deletions scripts/cart.js
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,14 @@ export class Cart {
}
}

/**
* Flush any debounced writes to localStorage immediately. Call before a
* page navigation to avoid the race where the next page reads stale state.
*/
flush() {
this.#persistNow();
}

get items() {
return this.#items;
}
Expand Down
26 changes: 26 additions & 0 deletions scripts/estimate-token.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
/**
* Minimum remaining lifetime (in ms) before an estimate token is considered
* "expiring soon" and should be proactively refreshed. 5 minutes gives enough
* headroom to complete the order-create + payment-initiate round-trips.
*/
const ESTIMATE_TOKEN_BUFFER_MS = 5 * 60 * 1000;

/**
* Decode a JWT's `exp` claim and return whether the token is expired or will
* expire within the buffer window. Returns `true` (needs refresh) when the
* token cannot be decoded.
*
* @param {string|null|undefined} token - JWT estimate token
* @param {number} [bufferMs] - refresh when fewer than this many ms remain
* @returns {boolean}
*/
// eslint-disable-next-line import/prefer-default-export
export function isEstimateExpiringSoon(token, bufferMs = ESTIMATE_TOKEN_BUFFER_MS) {
if (!token) return true;
try {
const payload = JSON.parse(atob(token.split('.')[1]));
return (payload.exp * 1000) - Date.now() < bufferMs;
} catch {
return true;
}
}
2 changes: 1 addition & 1 deletion scripts/payments/paypal-context.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { getExpressCheckoutContext } from '../checkout-context.js';
import { isEstimateExpiringSoon } from '../../blocks/checkout/checkout-order.js';
import { isEstimateExpiringSoon } from '../estimate-token.js';

/**
* Builds PayPal express context for the page where the button is rendered.
Expand Down
56 changes: 43 additions & 13 deletions tests/cart/edge-checkout.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import {
* - Cart stored in localStorage under `cart:${locale}` (e.g. cart:us, cart:ca)
* - Schema: { version: 1, items: [...] }
* - On desktop (≥900px): opens minicart popover (#minicart) after add-to-cart
* - On mobile (<900px): minicart does not open; cart icon href navigates to /order/cart
* - On mobile (<900px): redirects to /order/cart after successful add-to-cart
* - Default max quantity per SKU: 3; blocked adds silently re-enable the button
* - Paid warranty tier stored as a hidden linked item (custom.linkedTo, local.showInCart: false)
* - visibleItemCount excludes hidden items; cart badge tracks visibleItemCount
Expand Down Expand Up @@ -255,19 +255,45 @@ test.describe('Edge Checkout', () => {
});
});

// ─── Mobile add-to-cart redirect ─────────────────────────────────────────────

test.describe('Mobile Add-to-cart Redirect', () => {
const productPath = '/us/en_us/products/ascent-x2';

test.use({ viewport: { width: 390, height: 844 } });

test('redirects to cart page after add-to-cart on mobile', async ({ page }) => {
await page.goto(buildProductUrl(productPath, currentBranch, { cart: 'edge' }));
await waitForAddToCartButton(page);

const btn = page.locator('.quantity-container button');
await btn.click();

await page.waitForURL('**/order/cart', { timeout: 15000 });
expect(page.url()).toContain('/order/cart');

// Verify the item was persisted before the redirect
const cart = await getCart(page);
expect(cart).not.toBeNull();
expect(cart.items.length).toBeGreaterThanOrEqual(1);
console.log('✓ Mobile add-to-cart redirected to cart page with item persisted');
});
});

// ─── Quantity limits ─────────────────────────────────────────────────────────

// Mobile viewport prevents minicart from opening between clicks,
// which would otherwise block subsequent interactions.
// Quantity-limit tests run at desktop width so add-to-cart stays on the PDP
// (mobile redirects to the cart page, making multi-click flows impractical).
// Cart is pre-seeded via localStorage where needed to avoid repeated clicks.
test.describe('Quantity Limits', () => {
const productPath = '/us/en_us/products/ascent-x2';

test.use({ viewport: { width: 390, height: 844 } });
test.use({ viewport: { width: 1280, height: 720 } });

// Helper: click add-to-cart and wait for the async operation to finish.
// Helper: click add-to-cart and wait for the button to re-enable (desktop).
async function clickAndWait(btn, page) {
await btn.click();
await expect(btn).not.toHaveAttribute('aria-disabled', 'true', { timeout: 5000 });
await expect(btn).not.toHaveAttribute('aria-disabled', 'true', { timeout: 10000 });
await page.waitForTimeout(300);
}

Expand Down Expand Up @@ -635,19 +661,23 @@ test.describe('Edge Checkout', () => {
test.describe('Add-to-cart Behaviour', () => {
const productPath = '/us/en_us/products/ascent-x2';

// Mobile viewport so minicart does not open between clicks.
test.use({ viewport: { width: 390, height: 844 } });
// Desktop viewport so add-to-cart stays on the PDP between clicks
// (mobile redirects to the cart page after each add).
test.use({ viewport: { width: 1280, height: 720 } });

async function clickAndWait(btn, page) {
await btn.click();
await expect(btn).not.toHaveAttribute('aria-disabled', 'true', { timeout: 10000 });
await page.waitForTimeout(300);
}

test('adding the same item twice increments quantity, not a separate entry', async ({ page }) => {
await page.goto(buildProductUrl(productPath, currentBranch, { cart: 'edge' }));
await waitForAddToCartButton(page);

const btn = page.locator('.quantity-container button');
await btn.click();
await expect(btn).not.toHaveAttribute('aria-disabled', 'true', { timeout: 5000 });
await page.waitForTimeout(300);
await btn.click();
await expect(btn).not.toHaveAttribute('aria-disabled', 'true', { timeout: 5000 });
await clickAndWait(btn, page);
await clickAndWait(btn, page);

await expect.poll(
() => getCart(page).then((c) => c?.items?.[0]?.quantity ?? 0),
Expand Down
13 changes: 13 additions & 0 deletions tests/unit/cart.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,19 @@ test('clear persists the empty state to localStorage immediately', () => {
assert.deepEqual(stored.items, []);
});

// --- flush ------------------------------------------------------------------

test('flush persists a debounced addItem to localStorage immediately', () => {
const cart = new Cart();
cart.addItem(sampleItem({ sku: 'pending' }));
// addItem uses a debounced persist — localStorage may still be stale.
// flush() forces the write so a subsequent page load sees the item.
cart.flush();
const stored = JSON.parse(localStorage.getItem(STORAGE_KEY));
assert.equal(stored.items.length, 1);
assert.equal(stored.items[0].sku, 'pending');
});

// --- itemCount / subtotal ---------------------------------------------------

test('itemCount sums quantities across entries', () => {
Expand Down
2 changes: 1 addition & 1 deletion tests/unit/estimate-token-expiry.test.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { isEstimateExpiringSoon } from '../../blocks/checkout/checkout-order.js';
import { isEstimateExpiringSoon } from '../../scripts/estimate-token.js';

/**
* Build a minimal JWT with the given `exp` (seconds since epoch).
Expand Down
10 changes: 10 additions & 0 deletions tests/unit/mocks/scripts.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,16 @@ export function getLocaleAndLanguage(forceEnCA = false, bcp47 = false) {
return { locale: loc, language };
}

/**
* Mirrors the real getOrderPath helper.
* @param {'cart'|'checkout'|'complete'|'cancel'} page
* @returns {string}
*/
export function getOrderPath(page) {
const { locale: loc, language } = getLocaleAndLanguage();
return `/${loc}/${language}/order/${page}`;
}

export async function loggedFetch(...args) {
return fetch(...args);
}
Expand Down
Loading