Skip to content

Refactor: eliminate code duplication and extract shared utilities#13

Merged
damianlegawiec merged 24 commits intomainfrom
chore/refactor
Feb 13, 2026
Merged

Refactor: eliminate code duplication and extract shared utilities#13
damianlegawiec merged 24 commits intomainfrom
chore/refactor

Conversation

@damianlegawiec
Copy link
Member

@damianlegawiec damianlegawiec commented Feb 12, 2026

Summary

  • Extract shared path utilities (extractBasePath, getPathWithoutPrefix) from 10+ files into src/lib/utils/path.ts
  • Extract server action helpers (actionResult, withFallback) replacing 16+ try/catch blocks across 7 data files
  • Extract product listing logic into useProductListing hook + ProductListingLayout component, shrinking two ~400-line page components to ~40-60 lines each
  • Extract shared address components (AddressFormFields, AddressEditModal) replacing 4 copies of address form fields and 2 copies of modal+form logic
  • Extract shared address types/utilities (AddressFormData, addressToFormData, formDataToAddress, addressesMatch) from 4 files into src/lib/utils/address.ts
  • Clean up ProductFilters by removing dead internal fetch code and fixing useEffect anti-pattern
  • Extract context helpers (mutateCart in CartContext, toUser in AuthContext, setStoreCookies cookie helper)
  • Standardize conventions (consistent undefined vs null in CheckoutContext, merged guard blocks in account layout)

Impact

  • 38 files changed, 1,322 insertions, 2,722 deletions (-1,400 net lines)
  • All 58 vitest tests pass after every commit
  • No new lint errors introduced

New shared modules

File Purpose
src/lib/utils/path.ts extractBasePath, getPathWithoutPrefix
src/lib/data/utils.ts actionResult, withFallback
src/lib/utils/address.ts AddressFormData, emptyAddress, addressToFormData, formDataToAddress, addressesMatch
src/lib/utils/cookies.ts setStoreCookies
src/lib/utils/product-query.ts buildProductQueryParams
src/hooks/useProductListing.ts Shared product listing state + data fetching
src/components/products/ProductListingLayout.tsx Shared product grid + filter sidebar UI
src/components/checkout/AddressFormFields.tsx Shared address form fields
src/components/checkout/AddressEditModal.tsx Shared address edit modal with form + state

Test plan

  • All 58 vitest tests pass after every commit
  • Biome lint/format checks pass (no new warnings)
  • Pre-existing TS errors unchanged (orders.ts meta, checkout page .error)
  • Manual smoke test: checkout flow (address step, payment step)
  • Manual smoke test: account addresses page (add/edit/delete)
  • Manual smoke test: product listing + category pages (filters, infinite scroll)

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Unified product listing layout with improved filtering, pagination/infinite-scroll and mobile filter drawer.
    • New address editor modal and shared address form component.
    • Password visibility toggles on account/register pages; enhanced search accessibility and suggestions.
  • Improvements

    • Consistent error handling and graceful fallbacks for data operations.
    • Centralized icon set and visual polish (empty states, headers, cart); streamlined cart backdrop behavior.
    • Better country/locale persistence and redirect handling; SVG-as-component support and slide-in animation.

damianlegawiec and others added 10 commits February 12, 2026 11:04
Consolidate 10 duplicate copies of extractBasePath and 2 copies of
getPathWithoutPrefix into a single shared module at src/lib/utils/path.ts.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Replace 16 identical try/catch blocks across 7 data files with two
shared helpers: actionResult (for mutation results) and withFallback
(for fetch-or-default queries).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- buildProductQueryParams: shared filter-to-query-params conversion
- useProductListing: shared state, pagination, infinite scroll, filter fetching
- ProductListingLayout: shared filter sidebar, skeleton, empty state, load-more UI

ProductsContent shrinks from 410 to 62 lines, CategoryProductsContent
from 373 to 39 lines.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…ters

- Remove unused internal filter fetching state/effect (~30 lines dead code)
- Remove useStore and getProductFilters imports (no longer needed)
- Make filtersData and loading required props
- Replace useEffect-based onFilterChange with direct calls via updateFilters helper
- Eliminates an unnecessary render cycle on every filter change

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Replace 3 structurally identical cart mutation callbacks (addItem,
updateItem, removeItem) with a shared mutateCart helper that handles
the setUpdating/result.success/router.refresh pattern.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Replace 3 identical user-object constructions in refreshUser, login,
and register with a shared toUser helper function.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Extract setStoreCookies helper to src/lib/utils/cookies.ts,
  replacing duplicated document.cookie calls with magic numbers
- Extract resolveCountryAndCurrency as a pure function from the
  68-line useEffect in StoreContext, improving readability
- Add explicit eslint-disable comment for intentionally excluded
  pathname dependency

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Standardize CheckoutContext to use undefined (matching Cart, Auth,
  Store contexts) instead of null
- Remove unnecessary fragment wrapper in CountrySwitcher
- Remove unused currentCountry variable in CountrySwitcher
- Merge two redundant guard blocks in account layout into one

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Consolidate AddressFormData, emptyAddress, addressToFormData, formDataToAddress,
and addressesMatch from 4 files into src/lib/utils/address.ts, removing ~250
lines of duplicated code.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Replace 4 copies of address form fields (AddressStep, AddressSelector,
PaymentStep BillingAddressForm, account AddressModal) with a single shared
AddressFormFields component. Extract AddressEditModal to replace duplicated
modal+form+state logic in AddressStep and account addresses page.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@coderabbitai
Copy link

coderabbitai bot commented Feb 12, 2026

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review

Walkthrough

Consolidates path helpers and cookie utilities into shared utils, centralizes SVG icons via an icons barrel and SVGR, extracts address form UI and mapping into reusable components/utilities, introduces a product-listing hook/layout, and standardizes data-layer error/fallback handling across multiple modules.

Changes

Cohort / File(s) Summary
Path utils & callsites
src/lib/utils/path.ts, src/app/.../checkout/[id]/page.tsx, src/app/.../checkout/layout.tsx, src/app/.../account/orders/[id]/page.tsx, src/app/.../account/orders/page.tsx, src/app/.../account/page.tsx, src/app/.../account/register/page.tsx, src/app/.../cart/page.tsx, src/components/cart/CartDrawer.tsx, src/components/layout/Header.tsx, src/components/layout/CountrySwitcher.tsx
Added extractBasePath / getPathWithoutPrefix and removed duplicated in-file path extractors; migrated callsites to use the shared utils.
Icon barrel & SVG handling
src/components/icons/index.ts, src/types/svg.d.ts, next.config.ts, package.json, many components (Header, CartDrawer, ProductDetails, etc.)
Introduced an icons export and SVG module typing; replaced many inline SVGs with icon components; added SVGR dev dep and build/vite/vitest handling for SVGs.
Address UI & utils
src/lib/utils/address.ts, src/components/checkout/AddressFormFields.tsx, src/components/checkout/AddressEditModal.tsx, src/components/checkout/AddressSelector.tsx, src/components/checkout/AddressStep.tsx, src/components/checkout/PaymentStep.tsx, src/app/.../account/addresses/page.tsx
Added AddressFormData, mapping helpers (addressToFormData, formDataToAddress, addressesMatch), new AddressFormFields and AddressEditModal components; refactored checkout/account address flows to consume these utilities and updated AddressStep/PaymentStep props and flows.
Product listing abstraction
src/hooks/useProductListing.ts, src/components/products/ProductListingLayout.tsx, src/components/products/ProductFilters.tsx, src/app/.../products/ProductsContent.tsx, src/app/.../t/[...permalink]/CategoryProductsContent.tsx
Introduced useProductListing hook and ProductListingLayout component; pages now delegate fetching/pagination/filtering to the hook/layout; tightened ProductFilters props (filtersData, loading required).
Data-layer error/fallback utilities
src/lib/data/utils.ts, src/lib/data/addresses.ts, src/lib/data/cart.ts, src/lib/data/checkout.ts, src/lib/data/credit-cards.ts, src/lib/data/customer.ts, src/lib/data/gift-cards.ts, src/lib/data/orders.ts
Added actionResult and withFallback utilities and replaced ad-hoc try/catch blocks across data modules to standardize success/error shapes and default fallbacks.
Product query & cookies utils
src/lib/utils/product-query.ts, src/lib/utils/cookies.ts
Added buildProductQueryParams for assembling product API query params from filters/search and setStoreCookies to persist country/locale cookies.
Contexts & cart flow
src/contexts/StoreContext.tsx, src/contexts/AuthContext.tsx, src/contexts/CartContext.tsx, src/contexts/CheckoutContext.tsx
Refactored store resolution/redirect logic to use a resolver and cookie helper; added toUser mapping in AuthContext; consolidated cart mutations into mutateCart; changed CheckoutContext guard from null to undefined.
UI/UX & small components
src/components/*, src/app/... (search, media gallery, product card, breadcrumbs, coupon, order summary, account pages, etc.)
Replaced many inline SVGs with icon components, added ARIA/accessibility improvements, password visibility toggles, and simplified modal/backdrop interactions; minor layout/styling adjustments.
Tests & styles
src/lib/data/__tests__/checkout.test.ts, src/app/globals.css
Updated a test expectation to match new fallback messaging; added slide-in-right keyframes and a root animation variable used by some components.

Sequence Diagram(s)

sequenceDiagram
    participant Page as Product Page (Client)
    participant Hook as useProductListing
    participant API as Product API
    participant Filters as ProductFilters
    participant Layout as ProductListingLayout

    Page->>Hook: init(fetchFn, searchQuery, filterParams)
    Hook->>API: fetchProductFilters(currency, locale)
    API-->>Hook: filtersData
    Hook->>API: fetchProducts(page=1, params)
    API-->>Hook: products + meta
    Hook-->>Page: listing state (products, totalCount, hasMore, handlers)

    Page->>Layout: render(listing)
    Layout->>Filters: render(filtersData)
    Filters-->>Layout: onFilterChange(newFilters)
    Layout->>Hook: handleFilterChange(newFilters)
    Hook->>API: fetchProducts(page=1, filtered params)
    API-->>Hook: filtered products
    Hook-->>Layout: update products
Loading
sequenceDiagram
    participant User as User
    participant Modal as AddressEditModal
    participant Form as AddressFormFields
    participant API as Address API
    participant Parent as Page (onSave handler)

    User->>Modal: open(edit/new)
    Modal->>Form: render(formData)
    User->>Form: change country
    Form->>Modal: request fetchStates(country)
    Modal->>API: getStates(country)
    API-->>Modal: states
    Modal->>Form: update states

    User->>Form: fill fields
    User->>Modal: click Save
    Modal->>Parent: onSave(formDataToAddress(data), maybeId)
    Parent->>API: create/update address
    API-->>Parent: result (address or error)
    Parent-->>Modal: success (updated address)
    Modal-->>User: close and return updated data
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related PRs

Poem

🐰 I hopped through routes and paths so neat,
Forms now tidy—no duplicated feat,
Listings fetch and scroll with cheerful pace,
Errors wrapped kindly in a helper's embrace,
A carrot-coded burrow—hop, test, and release!

🚥 Pre-merge checks | ✅ 3 | ❌ 1
❌ Failed checks (1 warning)
Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 9.20% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (3 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title directly describes the main objective of the pull request: extracting shared utilities and eliminating code duplication across the codebase.
Merge Conflict Detection ✅ Passed ✅ No merge conflicts detected when merging into main

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing touches
  • 📝 Generate docstrings
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch chore/refactor

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 8

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (3)
src/contexts/CartContext.tsx (1)

57-82: ⚠️ Potential issue | 🟠 Major

Missing catch block — network errors will propagate as unhandled rejections.

mutateCart has try/finally but no catch. If action() throws (e.g., network failure, JSON parse error), the exception escapes mutateCart and becomes an unhandled promise rejection in addItem/updateItem/removeItem, since no caller catches either.

🐛 Proposed fix
       try {
         const result = await action();
         if (result.success) {
           setCart(result.cart ?? null);
           onSuccess?.();
           router.refresh();
         } else {
           console.error(`Failed to ${errorLabel}:`, result.error);
         }
+      } catch (err) {
+        console.error(`Failed to ${errorLabel}:`, err);
       } finally {
         setUpdating(false);
       }
src/components/checkout/PaymentStep.tsx (1)

59-67: ⚠️ Potential issue | 🟡 Minor

Missing .catch on fetchStates promise.

If fetchStates rejects (e.g., network error), the promise rejection is unhandled since .then has no rejection handler and there's no .catch. startTransitionBill does not catch async promise rejections.

🛡️ Proposed fix
     startTransitionBill(() => {
       fetchStates(billAddress.country_iso).then((states) => {
         if (!cancelled) {
           setBillStates(states);
         }
-      });
+      }).catch(() => {
+        if (!cancelled) {
+          setBillStates([]);
+        }
+      });
     });
src/components/products/ProductFilters.tsx (1)

250-251: ⚠️ Potential issue | 🟡 Minor

PriceFilter local state goes stale after external reset.

localMin and localMax are initialized from minValue/maxValue props only once (via useState initial value). When the parent calls clearFilters and resets activeFilters, the new undefined values for minValue/maxValue are passed as props but localMin/localMax retain their old values — the price inputs won't visually reset.

Either derive a key from the parent to force remount, or sync local state with props.

🐛 Option A: key-based reset (preferred, simpler)

In the parent's render of <PriceFilter> (around line 149), add a key that changes on reset:

 <PriceFilter
+  key={`${activeFilters.priceMin}-${activeFilters.priceMax}`}
   filter={filter as PriceRangeFilter}
   minValue={activeFilters.priceMin}
   maxValue={activeFilters.priceMax}
   onChange={handlePriceChange}
 />
🤖 Fix all issues with AI agents
In `@src/components/checkout/AddressEditModal.tsx`:
- Around line 64-70: The fetchStates call can reject and currently lacks error
handling; add a .catch handler to the promise chain started in AddressEditModal
so failures are handled and the user/state updated appropriately: call
fetchStates(formData.country_iso).then(result => { if (!cancelled)
setStates(result); }).catch(err => { if (!cancelled) { /* set an error state or
clear states and optionally surface a user message */ } }).finally(() => { if
(!cancelled) setLoadingStates(false); }); ensure you reference the existing
symbols fetchStates, setStates, setLoadingStates and cancelled and set or
dispatch a suitable error indicator (or clear states) inside the .catch so
there’s no unhandled rejection and the UI can show failure feedback.

In `@src/components/checkout/AddressStep.tsx`:
- Around line 109-119: The function handleSaveEditedAddress currently returns
silently when id is missing or onUpdateSavedAddress is undefined, causing
AddressEditModal.handleSubmit to close the modal without feedback; change
handleSaveEditedAddress to throw an Error (or reject) when !id or
!onUpdateSavedAddress instead of returning, so the await onSave(...) in
AddressEditModal.handleSubmit can surface the error and the modal can show an
error message; update references to handleSaveEditedAddress,
onUpdateSavedAddress and ensure AddressEditModal.handleSubmit awaits and handles
the thrown error (so onClose is only called on success).

In `@src/components/products/ProductListingLayout.tsx`:
- Around line 69-108: Add keyboard dismissal and basic focus trapping for the
mobile filter drawer in ProductListingLayout: when showMobileFilters is true,
attach a keydown listener (or onKeyDown on the drawer root) that closes the
drawer on Escape by calling setShowMobileFilters(false), move focus into the
drawer (e.g., focus the close button via a useRef to the close button) when it
opens, trap Tab/Shift+Tab within the drawer by detecting focusable elements
inside the drawer container and cycling focus between them, and restore focus to
the previously focused element when the drawer closes; implement these behaviors
near the drawer JSX that references showMobileFilters, setShowMobileFilters, and
the close button element so keyboard and screen-reader users can dismiss and
navigate the drawer.

In `@src/hooks/useProductListing.ts`:
- Around line 139-156: loadMore uses the latest activeFilters when the request
resolves, which can cause older results to be appended after a filter change; to
fix, snapshot the current loadIdRef.current (and optionally the activeFilters)
at the start of loadMore, pass that id through the async flow, and before
calling setProducts or updating hasMore/pageRef check that loadIdRef.current ===
capturedLoadId (and that capturedActiveFilters matches if you snapshot it) so
stale responses are ignored; update the function name references: loadMore,
loadProducts, loadIdRef, setProducts, pageRef, hasMoreRef to locate and
implement the guard.
- Around line 67-85: loadProducts can suffer from a race where an earlier fetch
resolves after a later one and overwrites state; fix by adding a
request-tracking guard (e.g., a request counter ref or AbortController) inside
loadProducts and only apply response to
setProducts/setTotalCount/setHasMore/hasMoreRef.current if the request is still
the latest. Specifically, create a mutable ref like requestIdRef and increment
it before calling fetchProducts(1, ...), capture the current id in the async
scope, and after awaiting response verify the id still matches
requestIdRef.current (or check !abortSignal.aborted) before calling
setProducts/setTotalCount/setHasMore/hasMoreRef.current and setLoading(false);
also ensure you setLoading(false) only when the request is valid.

In `@src/lib/data/orders.ts`:
- Around line 6-8: The withFallback wrapper currently swallows errors from calls
like listOrders, so modify withFallback (in src/lib/data/utils.ts) to log the
caught error (e.g., console.error or forward to your error-tracking helper)
before returning the fallback, and then add an explicit return type annotation
to getOrders (matching the pattern used by getCreditCards, e.g., Promise<{ data:
StoreOrder[] }>) so callers have a clear type; update references to
getOrders/listOrders as needed to satisfy the type name you choose.

In `@src/lib/utils/address.ts`:
- Around line 17-29: addressToFormData currently returns the shared constant
emptyAddress when called with no address, which allows callers to mutate a
global object; change addressToFormData so it returns a shallow copy of
emptyAddress (e.g., spread or Object.assign) instead of the original reference
to ensure callers receive an independent object and do not corrupt the shared
emptyAddress constant.

In `@src/lib/utils/cookies.ts`:
- Around line 6-9: The setStoreCookies function currently writes raw country and
locale into document.cookie (in setStoreCookies), which can break cookie
formatting if values contain reserved characters; update setStoreCookies to
encode the country and locale values (e.g., via encodeURIComponent) before
interpolating into the cookie strings and keep the same attributes (path and
max-age using ONE_YEAR); also ensure any corresponding cookie read logic decodes
those values (decodeURIComponent) where cookies are parsed.
🧹 Nitpick comments (12)
src/contexts/AuthContext.tsx (1)

45-57: Parameter type duplicates the User interface — reuse it directly.

The inline type on lines 45–50 is identical to the User interface defined on lines 19–24.

Proposed simplification
-function toUser(customer: {
-  id: string;
-  email: string;
-  first_name?: string | null;
-  last_name?: string | null;
-}): User {
+function toUser(customer: User): User {
   return {
     id: customer.id,
     email: customer.email,
     first_name: customer.first_name,
     last_name: customer.last_name,
   };
 }

If the intent is to accept a broader type (e.g., a customer object with extra fields), consider Pick<Customer, 'id' | 'email' | 'first_name' | 'last_name'> or a similar approach instead of re-declaring the shape inline.

src/lib/utils/address.ts (1)

76-98: addressesMatch intentionally compares only a subset of fields — verify this is sufficient.

The comparison omits address2, phone, company, state_abbr, and state_name. This could cause false positives (two different addresses matching) if they differ only in those fields. If this is intentional for "fuzzy matching," a brief doc comment would help future maintainers understand the design choice.

src/lib/utils/product-query.ts (2)

16-20: Extract the magic max-price fallback into a named constant.

999999 is a magic number that could silently exclude expensive products. A named constant would improve clarity and make it easy to adjust.

♻️ Suggested change
+const DEFAULT_MAX_PRICE = 999999;
+
 export function buildProductQueryParams(
   filters: ActiveFilters,
   searchQuery?: string,
 ): Record<string, unknown> {
   ...
   if (filters.priceMin !== undefined || filters.priceMax !== undefined) {
     params["q[price_between][]"] = [
       filters.priceMin ?? 0,
-      filters.priceMax ?? 999999,
+      filters.priceMax ?? DEFAULT_MAX_PRICE,
     ];
   }

1-1: Move ActiveFilters to a shared types location to eliminate upward dependencies.

src/lib/utils/product-query.ts imports ActiveFilters from the components layer, creating an upward dependency that violates typical layering conventions. Additionally, src/hooks/useProductListing.ts has the same pattern. ActiveFilters is a domain type used across multiple layers (components, hooks, and utils) and should be moved to src/lib/types/filters.ts or similar. Update imports in ProductFilters.tsx, ProductListingLayout.tsx, useProductListing.ts, and product-query.ts to import from the shared location.

src/contexts/StoreContext.tsx (1)

95-130: Effect refactor is sound; verify pathname exclusion is safe under fast-navigation scenarios.

The pathname exclusion from the dependency array is correct for the "run once on mount" intent, and the eslint-disable comment clearly explains the rationale. However, if a user navigates rapidly (e.g., back/forward) before the async fetch completes, pathname read on line 104 could reflect a stale or different URL by the time the redirect fires.

Consider capturing pathname at the start of the async closure, though in practice this is a narrow race given Next.js remounting on route changes.

src/components/checkout/PaymentStep.tsx (1)

75-79: Resetting billing address via useEffect can be moved to the event handler.

This effect resets billAddress when useShippingForBilling toggles, requiring an eslint-disable to suppress the exhaustive-deps warning. Moving this logic into the checkbox onChange handler at Line 148 would eliminate the effect and the suppression.

♻️ Proposed refactor
-  // When "use shipping" changes, reset billing address
-  useEffect(() => {
-    if (useShippingForBilling) {
-      setBillAddress(shipAddressData);
-    }
-  }, [useShippingForBilling]); // eslint-disable-line react-hooks/exhaustive-deps

Then in the checkbox handler:

   onChange={(e) => {
-    setUseShippingForBilling(e.target.checked)
+    const checked = e.target.checked;
+    setUseShippingForBilling(checked);
+    if (checked) {
+      setBillAddress(addressToFormData(order.ship_address));
+    }
   }}

As per coding guidelines: "Avoid using useEffect to reset state when props change; use component key prop to reset state or compute initial state from props."

src/components/checkout/AddressEditModal.tsx (1)

107-157: Modal lacks accessibility attributes and keyboard handling.

The modal <div> is missing role="dialog", aria-modal="true", and aria-labelledby pointing to the title. There's also no Escape key handler to close the modal. Consider adding these for keyboard and screen reader users.

♿ Suggested attributes on the dialog container (Line 114)
-        <div className="relative inline-block align-bottom bg-white rounded-lg text-left overflow-hidden shadow-xl transform transition-all sm:my-8 sm:align-middle sm:max-w-lg sm:w-full">
+        <div
+          role="dialog"
+          aria-modal="true"
+          aria-labelledby="modal-title"
+          className="relative inline-block align-bottom bg-white rounded-lg text-left overflow-hidden shadow-xl transform transition-all sm:my-8 sm:align-middle sm:max-w-lg sm:w-full"
+        >

And on the title:

-              <h3 className="text-lg font-medium text-gray-900 mb-4">
+              <h3 id="modal-title" className="text-lg font-medium text-gray-900 mb-4">
src/components/products/ProductFilters.tsx (1)

38-44: Side effect inside state updater function.

onFilterChange(next) is called inside the setActiveFilters updater callback. React state updater functions should be pure — they may be called multiple times (e.g., in Strict Mode). Move the parent notification outside the updater.

♻️ Proposed refactor
- const updateFilters = (updater: (prev: ActiveFilters) => ActiveFilters) => {
-   setActiveFilters((prev) => {
-     const next = updater(prev);
-     onFilterChange(next);
-     return next;
-   });
- };
+ const updateFilters = (updater: (prev: ActiveFilters) => ActiveFilters) => {
+   setActiveFilters((prev) => {
+     const next = updater(prev);
+     // Schedule notification outside the updater
+     queueMicrotask(() => onFilterChange(next));
+     return next;
+   });
+ };

Alternatively, a cleaner approach using a functional update and separate call:

- const updateFilters = (updater: (prev: ActiveFilters) => ActiveFilters) => {
-   setActiveFilters((prev) => {
-     const next = updater(prev);
-     onFilterChange(next);
-     return next;
-   });
- };
+ const updateFilters = (updater: (prev: ActiveFilters) => ActiveFilters) => {
+   let next: ActiveFilters;
+   setActiveFilters((prev) => {
+     next = updater(prev);
+     return next;
+   });
+   // React 18+ batches setState, so next is assigned synchronously
+   onFilterChange(next!);
+ };
src/hooks/useProductListing.ts (1)

122-126: useEffect for data fetching on searchQuery change.

Per coding guidelines, data fetching triggered by state/prop changes should prefer event handlers. searchQuery comes from URL search params (via useSearchParams in the consumer), so this effect reacts to an external navigation change — it's closer to "synchronizing with an external system" and is reasonable here. Just flagging for awareness.

src/lib/data/utils.ts (2)

5-18: Latent key-collision risk: spreading result can overwrite success.

If a caller's fn ever returns an object containing a success key, { success: true, ...result } will silently overwrite it (or vice-versa), producing an incorrect discriminant. Current callers are safe, but nothing in the type system prevents this.

Consider excluding success from T:

Suggested type guard
-export async function actionResult<T extends Record<string, unknown>>(
+export async function actionResult<T extends Record<string, unknown> & { success?: never }>(

23-32: Silent error swallowing in withFallback — consider optional logging.

All errors are discarded without any trace. For read operations this is a reasonable default, but it can make production debugging difficult. An optional onError callback or a console.error in development would retain observability without changing the API contract.

src/lib/data/checkout.ts (1)

61-66: Fallback message "Invalid coupon code" may be misleading for non-validation failures.

The fallbackMessage is only used when the caught error is not an Error instance. However, "Invalid coupon code" implies a validation failure, while the actual cause could be a network or server error. Consider a more neutral message like "Failed to apply coupon code" to match the convention used by all other actions in this file.

Comment on lines +69 to +108
{/* Mobile filter drawer */}
{showMobileFilters && (
<div className="fixed inset-0 z-50 lg:hidden">
<div
className="fixed inset-0 bg-black/25"
onClick={() => setShowMobileFilters(false)}
/>
<div className="fixed inset-y-0 right-0 w-full max-w-xs bg-white shadow-xl overflow-y-auto">
<div className="flex items-center justify-between p-4 border-b">
<h2 className="text-lg font-medium text-gray-900">Filters</h2>
<button
onClick={() => setShowMobileFilters(false)}
className="p-2 -mr-2 text-gray-400 hover:text-gray-500"
>
<svg
className="w-6 h-6"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
</div>
<div className="p-4">
<ProductFilters
taxonId={taxonId}
filtersData={filtersData}
loading={filtersLoading}
onFilterChange={onFilterChange}
/>
</div>
</div>
</div>
)}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Mobile filter drawer lacks Escape key dismissal and focus trapping.

The overlay closes on backdrop click but doesn't handle the Escape key or trap focus inside the drawer. Screen reader and keyboard-only users cannot dismiss it without clicking the close button. Consider adding an onKeyDown handler for Escape and basic focus management.

🤖 Prompt for AI Agents
In `@src/components/products/ProductListingLayout.tsx` around lines 69 - 108, Add
keyboard dismissal and basic focus trapping for the mobile filter drawer in
ProductListingLayout: when showMobileFilters is true, attach a keydown listener
(or onKeyDown on the drawer root) that closes the drawer on Escape by calling
setShowMobileFilters(false), move focus into the drawer (e.g., focus the close
button via a useRef to the close button) when it opens, trap Tab/Shift+Tab
within the drawer by detecting focusable elements inside the drawer container
and cycling focus between them, and restore focus to the previously focused
element when the drawer closes; implement these behaviors near the drawer JSX
that references showMobileFilters, setShowMobileFilters, and the close button
element so keyboard and screen-reader users can dismiss and navigate the drawer.

Comment on lines 6 to 8
export async function getOrders(params?: Record<string, unknown>) {
try {
return await listOrders(params);
} catch {
return { data: [] };
}
return withFallback(() => listOrders(params), { data: [] });
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

cat -n src/lib/data/orders.ts | head -30

Repository: spree/nextjs-starter-spree

Length of output: 547


🏁 Script executed:

find . -name "utils.ts" -path "*/lib/data/*" -type f

Repository: spree/nextjs-starter-spree

Length of output: 93


🏁 Script executed:

rg -nP "withFallback" -A 5 -B 2 --type=ts

Repository: spree/nextjs-starter-spree

Length of output: 4486


🏁 Script executed:

rg -nP "function listOrders|const listOrders" -A 10 --type=ts

Repository: spree/nextjs-starter-spree

Length of output: 52


🏁 Script executed:

cat -n src/lib/data/utils.ts

Repository: spree/nextjs-starter-spree

Length of output: 1111


🏁 Script executed:

rg -nP "getOrders\(" --type=ts --type=tsx -C 5

Repository: spree/nextjs-starter-spree

Length of output: 97


🏁 Script executed:

rg -nP "from.*\"@spree/next\"" --type=ts -A 2 | head -50

Repository: spree/nextjs-starter-spree

Length of output: 2332


🏁 Script executed:

rg -nP "getOrders" --type=ts -C 5

Repository: spree/nextjs-starter-spree

Length of output: 3011


🏁 Script executed:

rg -nP "\.meta" --type=ts -C 2 | grep -A 5 -B 5 "getOrders\|listOrders"

Repository: spree/nextjs-starter-spree

Length of output: 52


🏁 Script executed:

rg -nP "PaginatedResponse|StoreProduct|type.*Order" --type=ts src/lib/data/ | head -20

Repository: spree/nextjs-starter-spree

Length of output: 52


🏁 Script executed:

# Search for actual usage patterns and what gets accessed from the result
rg -nP "getOrders\(" -A 10 --type=ts

Repository: spree/nextjs-starter-spree

Length of output: 1536


🏁 Script executed:

# Search for type definitions or any explicit types for getOrders or listOrders
rg -nP "type.*Orders|interface.*Orders" --type=ts src/lib/data/ -A 3

Repository: spree/nextjs-starter-spree

Length of output: 52


🏁 Script executed:

# Check if there are any TypeScript errors in the build or type checking
find . -name "tsconfig.json" -o -name "tsconfig.*.json" | head -5

Repository: spree/nextjs-starter-spree

Length of output: 85


🏁 Script executed:

# Look at the `@spree/next` package to understand the return type
cat -n node_modules/@spree/sdk/dist/index.d.ts 2>/dev/null | grep -A 10 "listOrders\|PaginatedResponse" | head -30

Repository: spree/nextjs-starter-spree

Length of output: 52


🏁 Script executed:

# Search for more comprehensive usage of getOrders to see if .meta is ever accessed
rg -nP "response\.meta|getOrders" --type=ts --type=js -B 2 -A 15 | head -100

Repository: spree/nextjs-starter-spree

Length of output: 6334


🏁 Script executed:

# Check tsconfig strictness settings
cat -n tsconfig.json | grep -E "strict|noImplicitAny|exactOptionalPropertyTypes"

Repository: spree/nextjs-starter-spree

Length of output: 96


🏁 Script executed:

# Double-check for all callers of getOrders in the entire codebase
rg -nP "getOrders" --type=ts --type=js

Repository: spree/nextjs-starter-spree

Length of output: 400


🏁 Script executed:

# Check the actual type definition of listOrders from Spree SDK
rg -nP "listOrders" src/lib/data/ -B 2 -A 10

Repository: spree/nextjs-starter-spree

Length of output: 766


🏁 Script executed:

# Look for any error logging or monitoring setup in the codebase
rg -nP "console\.error|logger|sentry|errorHandler" src/lib/data/ --type=ts | head -20

Repository: spree/nextjs-starter-spree

Length of output: 52


🏁 Script executed:

# Check if there's a type definition file or package.json to understand the Spree SDK version
cat -n package.json | grep -A 5 -B 5 "@spree\|next"

Repository: spree/nextjs-starter-spree

Length of output: 783


🏁 Script executed:

# Verify whether listOrders actual usage pattern in other data files
rg -nP "listOrders|listAddresses|listCreditCards" src/lib/data/ -B 2 -A 3 --type=ts

Repository: spree/nextjs-starter-spree

Length of output: 2131


🏁 Script executed:

# Check if there's any return type annotation on getOrders that might clarify expected structure
rg -nP "getOrders.*=>|function getOrders" src/lib/data/orders.ts -A 5 --type=ts

Repository: spree/nextjs-starter-spree

Length of output: 354


Add error logging to withFallback to prevent silent failures.

The withFallback utility in src/lib/data/utils.ts catches errors and returns the fallback value with no logging. Failures in listOrders become completely invisible, making production debugging difficult.

Consider adding console.error(error) or integrating with an error tracking service (Sentry, etc.) to surface failures.

Additionally, add an explicit return type annotation to getOrders for consistency and clarity, similar to other data functions like getCreditCards(): Promise<{ data: StoreCreditCard[] }>.

🤖 Prompt for AI Agents
In `@src/lib/data/orders.ts` around lines 6 - 8, The withFallback wrapper
currently swallows errors from calls like listOrders, so modify withFallback (in
src/lib/data/utils.ts) to log the caught error (e.g., console.error or forward
to your error-tracking helper) before returning the fallback, and then add an
explicit return type annotation to getOrders (matching the pattern used by
getCreditCards, e.g., Promise<{ data: StoreOrder[] }>) so callers have a clear
type; update references to getOrders/listOrders as needed to satisfy the type
name you choose.

damianlegawiec and others added 2 commits February 12, 2026 13:54
- Add missing `meta` field to orders fallback to match PaginatedResponse type
- Fix type narrowing for updateAddress result by checking `!result.success` first

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Add catch block to mutateCart for unhandled network errors
- Return spread copy from addressToFormData to prevent shared state mutation
- Simplify toUser parameter to use User type directly
- Move billing address reset from useEffect to onChange handler
- Add accessibility attrs to AddressEditModal (role, aria-modal, escape key)
- Fix side effect in ProductFilters state updater

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
src/components/products/ProductFilters.tsx (1)

248-249: ⚠️ Potential issue | 🟠 Major

PriceFilter inputs don't reset when filters are cleared.

localMin and localMax are initialized from props via useState, which only evaluates the initializer on mount. When the parent calls clearFilters (resetting priceMin/priceMax to undefined), the input fields retain their stale values.

🐛 Option A — key-based remount (simplest)

In the parent JSX where <PriceFilter> is rendered (around line 147):

                <PriceFilter
+                 key={`${activeFilters.priceMin}-${activeFilters.priceMax}`}
                  filter={filter as PriceRangeFilter}
                  minValue={activeFilters.priceMin}
                  maxValue={activeFilters.priceMax}
                  onChange={handlePriceChange}
                />
🐛 Option B — controlled local state sync
  const [localMin, setLocalMin] = useState(minValue?.toString() || "");
  const [localMax, setLocalMax] = useState(maxValue?.toString() || "");
+
+ // Sync local inputs when parent resets the values
+ if (minValue === undefined && localMin !== "") setLocalMin("");
+ if (maxValue === undefined && localMax !== "") setLocalMax("");

This uses the React pattern of adjusting state during render when props change, avoiding a useEffect. As per coding guidelines: "Avoid using useEffect to reset state when props change; use component key prop to reset state or compute initial state from props."

src/components/checkout/PaymentStep.tsx (1)

61-67: ⚠️ Potential issue | 🟡 Minor

Missing .catch on fetchStates — unhandled rejection risk.

Same issue as in AddressEditModal: if fetchStates rejects, nothing catches the error, producing an unhandled promise rejection. Add a .catch to clear states (or surface an error) on failure.

🛡️ Proposed fix
     startTransitionBill(() => {
-      fetchStates(billAddress.country_iso).then((states) => {
-        if (!cancelled) {
-          setBillStates(states);
-        }
-      });
+      fetchStates(billAddress.country_iso)
+        .then((states) => {
+          if (!cancelled) setBillStates(states);
+        })
+        .catch(() => {
+          if (!cancelled) setBillStates([]);
+        });
     });
🤖 Fix all issues with AI agents
In `@src/components/checkout/AddressEditModal.tsx`:
- Around line 119-130: AddressEditModal currently sets role="dialog" but doesn't
trap focus, lock body scroll, or restore focus on close; update the component to
(a) wrap the modal content with a focus-trap (e.g., add FocusTrap from
focus-trap-react around the inner modal container in AddressEditModal) or
implement a useEffect that saves the activeElement, sets focus to the modal
container, and handles Tab/Shift+Tab to loop focus within the modal, (b) disable
body scrolling while open by adding/removing document.body.style.overflow =
'hidden' when the modal mounts/unmounts, and (c) onClose restore focus to the
previously focused element (capture it on open and call .focus() after closing
via the onClose handler). Ensure these changes target the modal container
element used inside AddressEditModal and integrate with the existing onClose
prop.
🧹 Nitpick comments (6)
src/contexts/CartContext.tsx (1)

97-115: Consider useOptimistic for instant cart UI updates.

Currently, updateItem and removeItem wait for the server response before reflecting changes. Using React 19's useOptimistic hook would let you show the updated quantity (or removal) immediately while the mutation is in flight, improving perceived responsiveness — especially for quantity adjustments.

This can be addressed in a follow-up. Based on learnings: "Use useOptimistic hook for instant UI updates when performing async operations like cart updates."

src/components/products/ProductFilters.tsx (2)

38-42: updateFilters reads a potentially stale activeFilters snapshot.

The updater callback receives activeFilters from the enclosing closure rather than the latest state. If two updates were ever batched in the same render tick, the second would overwrite the first. A safer pattern captures the latest value from setActiveFilters's functional form:

♻️ Proposed fix
  const updateFilters = (updater: (prev: ActiveFilters) => ActiveFilters) => {
-   const next = updater(activeFilters);
-   setActiveFilters(next);
-   onFilterChange(next);
+   setActiveFilters((prev) => {
+     const next = updater(prev);
+     // Notify parent of the change — safe because next is a new object
+     // Note: onFilterChange is called during setState; if this causes
+     // issues, consider using useEffect or flushSync.
+     onFilterChange(next);
+     return next;
+   });
  };

Alternatively, keep the imperative style but use a useRef to always hold the latest filters, or move the onFilterChange call into a useEffect watching activeFilters.


79-83: clearFilters duplicates the set-and-notify pattern instead of reusing updateFilters.

Minor inconsistency — this could call updateFilters(() => ({ optionValues: [] })) to keep a single notification path.

src/components/checkout/AddressEditModal.tsx (1)

46-48: Consider spreading emptyAddress to avoid sharing the reference.

When address is null, the mutable emptyAddress object is passed directly as initial state. While current update logic always creates new objects, a defensive spread avoids accidental mutation if the code evolves.

🛡️ Suggested fix
   const [formData, setFormData] = useState<AddressFormData>(
-    address ? addressToFormData(address) : emptyAddress,
+    address ? addressToFormData(address) : { ...emptyAddress },
   );
src/components/checkout/PaymentStep.tsx (2)

39-42: addressToFormData is called on every render for both addresses.

shipAddressData and billAddressData allocate new objects each render. Since order is a prop that won't change within this component's lifecycle, consider useMemo to avoid the repeated allocation — or at minimum for shipAddressData which is referenced in closures (handleUseShippingChange, handleSubmit).

♻️ Optional optimization
+import { useMemo } from "react";
 ...
-  const shipAddressData = addressToFormData(order.ship_address);
-  const billAddressData = addressToFormData(order.bill_address);
+  const shipAddressData = useMemo(() => addressToFormData(order.ship_address), [order.ship_address]);
+  const billAddressData = useMemo(() => addressToFormData(order.bill_address), [order.bill_address]);

53-72: Consider moving state-fetching into the updateBillAddress handler.

Per the coding guideline "Avoid using useEffect for data fetching triggered by state changes; use event handlers or Server Actions instead," the fetchStates call could be triggered from updateBillAddress when field === "country_iso", with an additional one-time call for the initial country. This would eliminate the effect and the cancellation boilerplate.

That said, this mirrors the AddressEditModal pattern and works correctly as-is, so this is a consistency-vs-guideline trade-off you can decide on. As per coding guidelines, "Avoid using useEffect for data fetching triggered by state changes; use event handlers or Server Actions instead."

- Fix race condition in useProductListing with loadIdRef guard for
  both loadProducts and loadMore to prevent stale results
- Add .catch handlers to fetchStates in AddressEditModal and
  PaymentStep to prevent unhandled promise rejections
- Simplify handleUpdateSavedAddress with early-return guard clauses
- Throw instead of silently returning in handleSaveEditedAddress
- Fix PriceFilter inputs not resetting on clearFilters via key prop
- Add error logging to withFallback for production observability
- Use neutral fallback message in applyCouponCode
- Encode cookie values in setStoreCookies
- Spread emptyAddress to avoid shared mutable reference

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (3)
src/components/checkout/PaymentStep.tsx (1)

83-91: ⚠️ Potential issue | 🟠 Major

Unhandled promise rejection from onSubmit.

onSubmit returns Promise<void> but the call on line 85 is fire-and-forget — if it rejects, you get an unhandled promise rejection. The PR description mentions .catch handlers were added to PaymentStep, but this call site appears to be missing one.

Proposed fix
   const handleSubmit = (e: React.FormEvent) => {
     e.preventDefault();
     onSubmit({
       bill_address: formDataToAddress(
         useShippingForBilling ? shipAddressData : billAddress,
       ),
       use_shipping_for_billing: useShippingForBilling,
-    });
+    }).catch(() => {
+      // Error is expected to be handled by the caller;
+      // catch here to prevent unhandled promise rejection.
+    });
   };
src/components/checkout/AddressStep.tsx (2)

62-83: ⚠️ Potential issue | 🟡 Minor

Missing .catch on fetchStates — unhandled promise rejection.

Unlike AddressEditModal which has a .catch handler on the same fetchStates call, this effect silently drops rejections. If the fetch fails, the promise rejection will be unhandled and shipStates will remain stale.

🛡️ Proposed fix
     startTransitionShip(() => {
-      fetchStates(shipAddress.country_iso).then((states) => {
-        if (!cancelled) {
-          setShipStates(states);
-        }
-      });
+      fetchStates(shipAddress.country_iso)
+        .then((states) => {
+          if (!cancelled) {
+            setShipStates(states);
+          }
+        })
+        .catch(() => {
+          if (!cancelled) {
+            setShipStates([]);
+          }
+        });
     });

85-91: ⚠️ Potential issue | 🔴 Critical

Make handleSubmit async and await the onSubmit promise.

The parent component handleAddressSubmit has proper error handling with try-catch, but since onSubmit() isn't awaited in handleSubmit, any rejection will become an unhandled promise rejection. The parent's error handling won't trigger because the promise isn't monitored. Compare with AddressEditModal.tsx which correctly uses async and await for its form submission.

🤖 Fix all issues with AI agents
In `@src/app/`[country]/[locale]/(checkout)/checkout/[id]/page.tsx:
- Around line 370-387: Add an explicit return type Promise<StoreAddress> to the
handleUpdateSavedAddress function signature; keep the implementation as-is (it
throws on failure and returns result.address on success). Ensure the
StoreAddress type is imported or available in the same module and that the
AddressParams parameter type remains unchanged; verify the function still calls
updateAddress and setSavedAddresses as before.

In `@src/components/checkout/AddressStep.tsx`:
- Around line 109-121: handleSaveEditedAddress silently no-ops when
onUpdateSavedAddress returns null which causes AddressEditModal.handleSubmit to
close the modal with no feedback; update handleSaveEditedAddress to detect a
falsy/ null updatedAddress after calling onUpdateSavedAddress(id, data) and
throw a descriptive Error (e.g. "Failed to update address") so the modal's error
banner can surface the failure; keep existing logic for updating state
(setSavedAddresses) and calling handleSelectSavedAddress when updatedAddress is
present.
🧹 Nitpick comments (6)
src/components/checkout/PaymentStep.tsx (1)

53-74: Consider moving state-fetch to the updateBillAddress handler.

The useEffect here fetches data in response to billAddress.country_iso state changes. Per the coding guidelines, prefer event handlers over useEffect for data fetching triggered by state changes. You could trigger the fetch directly inside updateBillAddress when field === "country_iso", which would also let you drop the useShippingForBilling dependency from this effect.

This is a minor structural improvement and not urgent given the existing cleanup logic works correctly.

src/components/checkout/AddressEditModal.tsx (1)

67-91: fetchStates as a useEffect dependency may cause re-fetch loops if the caller passes an unstable reference.

If the parent component passes an inline or non-memoized fetchStates, this effect will re-run on every render, re-fetching states even when country_iso hasn't changed. The callers should wrap fetchStates in useCallback, or this effect should store the reference in a ref to avoid re-triggering.

Consider documenting this expectation in the prop's JSDoc, or defensively using a ref:

♻️ Defensive ref approach
+import { useCallback, useEffect, useRef, useState } from "react";
-import { useCallback, useEffect, useState } from "react";

 export function AddressEditModal({
   ...
 }: AddressEditModalProps) {
+  const fetchStatesRef = useRef(fetchStates);
+  fetchStatesRef.current = fetchStates;
   ...

   useEffect(() => {
     if (!formData.country_iso) {
       setStates([]);
       return;
     }

     let cancelled = false;
     setLoadingStates(true);

-    fetchStates(formData.country_iso)
+    fetchStatesRef.current(formData.country_iso)
       .then((result) => {
         if (!cancelled) setStates(result);
       })
       ...

     return () => { cancelled = true; };
-  }, [formData.country_iso, fetchStates]);
+  }, [formData.country_iso]);
#!/bin/bash
# Check if callers of AddressEditModal memoize fetchStates
rg -n "fetchStates" --type=ts --type-add 'tsx:*.tsx' --type=tsx -C3 -g '!AddressEditModal.tsx'
src/components/products/ProductFilters.tsx (2)

38-42: updateFilters reads activeFilters from closure — fragile updater pattern.

The updater-function signature suggests React's functional setState semantics, but updater(activeFilters) always uses the value captured in the current render. If two calls ever occur before a re-render (unlikely today, but the API shape invites it), the second call would use stale state. Consider either documenting this constraint or making it robust:

♻️ Suggested robust alternative
  const updateFilters = (updater: (prev: ActiveFilters) => ActiveFilters) => {
-   const next = updater(activeFilters);
-   setActiveFilters(next);
-   onFilterChange(next);
+   setActiveFilters((prev) => {
+     const next = updater(prev);
+     onFilterChange(next);
+     return next;
+   });
  };

Note: Calling onFilterChange inside the setState updater is safe in React 19's batched rendering — it won't trigger a parent re-render mid-batch. This ensures prev is always the latest queued state.


79-83: Nit: clearFilters bypasses updateFilters.

This duplicates the set-then-notify pattern. If you adopt the robust updateFilters approach above, this could simply become updateFilters(() => ({ optionValues: [] })).

src/hooks/useProductListing.ts (2)

125-130: Effect omits loadProducts from deps — relies on storeLoading toggling as proxy.

This works because currency/locale changes cause storeLoading to cycle, which re-triggers the effect with the latest loadProducts. However, if fetchFn changes independently (without a store reload), the effect would use a stale loadProducts.

If this is a known constraint of the hook's contract, the eslint-disable comment could be expanded to note it explicitly alongside filterParams.


143-161: loadMore depends on loadingMore state — causes unnecessary observer churn.

Including loadingMore in the dependency array (line 161) creates a new loadMore identity on every toggle, which cascades into the IntersectionObserver effect disconnecting and reconnecting. Using a ref for the loading guard would keep loadMore stable:

♻️ Suggested optimization
+ const loadingMoreRef = useRef(false);
+
  const loadMore = useCallback(async () => {
-   if (loadingMore || !hasMoreRef.current) return;
+   if (loadingMoreRef.current || !hasMoreRef.current) return;

+   loadingMoreRef.current = true;
    setLoadingMore(true);
    const currentLoadId = loadIdRef.current;
    const nextPage = pageRef.current + 1;

    const response = await fetchProducts(nextPage, activeFilters, searchQuery);

    if (response && loadIdRef.current === currentLoadId) {
      setProducts((prev) => [...prev, ...response.data]);
      const moreAvailable = nextPage < response.meta.pages;
      setHasMore(moreAvailable);
      hasMoreRef.current = moreAvailable;
      pageRef.current = nextPage;
    }

+   loadingMoreRef.current = false;
    setLoadingMore(false);
- }, [fetchProducts, loadingMore, activeFilters, searchQuery]);
+ }, [fetchProducts, activeFilters, searchQuery]);

This eliminates 2 extra observer teardown/setup cycles per pagination load.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (6)
src/app/[country]/[locale]/(storefront)/products/[slug]/ProductDetails.tsx (2)

25-25: ⚠️ Potential issue | 🟡 Minor

Remove unused currency destructuring.

The pipeline reports currency from useStore() is unused. Either remove it or destructure only what's needed.

Proposed fix
-  const { currency } = useStore();
+  const { } = useStore();

Or if nothing else from useStore() is needed here, remove the line entirely.


98-115: ⚠️ Potential issue | 🟠 Major

Unhandled throw in async event handler.

The throw new Error("No variant selected") at Line 104 executes before the try/catch block at Lines 108–114, so it will surface as an unhandled promise rejection in the browser. Either move the guard inside the try block, or return early with user-facing feedback instead of throwing.

Proposed fix — return early instead of throwing
   const handleAddToCart = async () => {
     const variantId =
       selectedVariant?.id ||
       product.default_variant?.id ||
       product.default_variant_id;
     if (!variantId) {
-      throw new Error("No variant selected");
+      console.error("No variant selected");
+      return;
     }
src/app/[country]/[locale]/(storefront)/account/page.tsx (1)

22-22: ⚠️ Potential issue | 🟡 Minor

Remove unused user from destructuring — CI is failing.

The Biome lint check flags user as unused. Since this component only needs login, isAuthenticated, and loading from the auth context, drop user from the destructuring.

Proposed fix
-  const { user, login, isAuthenticated, loading: authLoading } = useAuth();
+  const { login, isAuthenticated, loading: authLoading } = useAuth();
src/app/[country]/[locale]/(storefront)/account/credit-cards/page.tsx (2)

31-31: ⚠️ Potential issue | 🟡 Minor

Use a template literal instead of string concatenation.

Pipeline lint failure flags this line. Also required per coding guidelines.

🔧 Proposed fix
-      return "💳 " + ccType;
+      return `💳 ${ccType}`;

98-109: ⚠️ Potential issue | 🟠 Major

Fix missing loadCards in useEffect dependency array.

The CI pipeline reports useExhaustiveDependencies failure here. Since loadCards is recreated every render, adding it directly would cause an infinite loop. Move the function body inside the effect or wrap it in useCallback.

🔧 Proposed fix — inline the fetch into the effect
+  const loadCards = useCallback(async () => {
+    const response = await getCreditCards();
+    setCards(response.data);
+  }, []);

-  const loadCards = async () => {
-    const response = await getCreditCards();
-    setCards(response.data);
-  };
-
   useEffect(() => {
     async function loadData() {
       await loadCards();
       setLoading(false);
     }
     loadData();
-  }, []);
+  }, [loadCards]);
src/components/cart/CartDrawer.tsx (1)

42-44: ⚠️ Potential issue | 🟡 Minor

Fix the exhaustive-deps lint violation flagged by CI.

The pipeline reports useExhaustiveDependencies: pathname is listed as a dependency but isn't used inside the effect body — the linter sees it as extraneous. The intent is to trigger closeCart on route changes. A common fix is to store pathname in a ref and compare in the effect, or use the Next.js router events. The simplest approach that satisfies the linter:

Proposed fix
- // Close when navigating
- useEffect(() => {
-   closeCart();
- }, [pathname, closeCart]);
+ // Close when navigating
+ const prevPathname = useRef(pathname);
+ useEffect(() => {
+   if (prevPathname.current !== pathname) {
+     prevPathname.current = pathname;
+     closeCart();
+   }
+ }, [pathname, closeCart]);

This way pathname is actually read inside the effect body, satisfying the lint rule while preserving the navigation-close behavior. Alternatively, you can suppress the rule with a comment if the team prefers the current style, but the CI is currently failing.

🤖 Fix all issues with AI agents
In `@src/app/`[country]/[locale]/(checkout)/layout.tsx:
- Around line 3-9: Reorder the import statements at the top of layout.tsx to
satisfy the project's formatter (group and sort external packages first, then
absolute/internal aliases like "@/components/...", "@/contexts/...",
"@/lib/..."), for example ensuring imports such as Link, usePathname, useState
are grouped before the alias imports and that icon and context imports
(ArrowLeftIcon, CheckoutProvider, CheckoutSummary, useStore, extractBasePath)
follow the project's sorted order; then run the project formatter (e.g.,
npm/yarn format or pnpm format) to apply and verify the final ordering.

In `@src/app/`[country]/[locale]/(storefront)/account/gift-cards/page.tsx:
- Line 226: The GiftIcon in the empty-state uses only "w-12 h-12 text-gray-400"
and may not be horizontally centered; update the GiftIcon's className (the
GiftIcon element) to include "mx-auto" alongside the existing classes so it
centers like the credit-cards icon (i.e., change the GiftIcon className to
include mx-auto).

In `@src/components/products/ProductFilters.tsx`:
- Around line 39-43: updateFilters currently computes next using the render-time
activeFilters closure which can be stale; change it to call setActiveFilters
with the functional updater form: pass a function (prev => { const next =
updater(prev); /* call onFilterChange(next) here */ return next; }) so the
updater always receives the latest state. Keep the onFilterChange call inside
that functional callback (or wrap it with flushSync if you need synchronous DOM
updates) to preserve behavior; reference updateFilters, activeFilters,
setActiveFilters, and onFilterChange when making the change.
🧹 Nitpick comments (3)
src/components/search/SearchBar.tsx (1)

135-136: Good ARIA combobox implementation.

The role="combobox" with aria-expanded, aria-controls, aria-activedescendant, and aria-autocomplete follows the WAI-ARIA combobox pattern well. The extended showSuggestions logic to display the dropdown during loading is good UX.

Minor note: aria-controls="search-suggestions" is always rendered on the input, but the referenced element only exists when the dropdown is visible. Consider conditionally setting aria-controls only when showSuggestions is true, though this is not a strict requirement.

Also applies to: 155-161, 175-183

src/components/checkout/PaymentStep.tsx (1)

62-70: isPendingBill may not track the async fetch duration.

startTransitionBill receives a synchronous callback that fires-and-forgets the promise chain. In React 19, startTransition can track async functions natively — if you pass an async callback and await the fetch, isPendingBill will stay true until the promise settles.

Optional: make the transition async-aware
     startTransitionBill(() => {
-      fetchStates(billAddress.country_iso)
-        .then((states) => {
-          if (!cancelled) setBillStates(states);
-        })
-        .catch(() => {
-          if (!cancelled) setBillStates([]);
-        });
+      return fetchStates(billAddress.country_iso)
+        .then((states) => {
+          if (!cancelled) setBillStates(states);
+        })
+        .catch(() => {
+          if (!cancelled) setBillStates([]);
+        });
     });

Returning the promise lets React 19's transition machinery track it, so isPendingBill remains true for the full fetch duration.

src/app/[country]/[locale]/(storefront)/account/register/page.tsx (1)

101-124: Consider extracting a reusable PasswordInput component.

The password visibility toggle pattern (relative wrapper, input, eye icon button) is duplicated between the two fields. Since this PR is specifically about eliminating code duplication, extracting a small PasswordInput component would align well with the PR's goals.

💡 Example extraction
function PasswordInput({
  id,
  value,
  onChange,
  placeholder = "••••••••",
}: {
  id: string;
  value: string;
  onChange: (value: string) => void;
  placeholder?: string;
}) {
  const [show, setShow] = useState(false);
  return (
    <div className="relative">
      <input
        type={show ? "text" : "password"}
        id={id}
        value={value}
        onChange={(e) => onChange(e.target.value)}
        required
        minLength={6}
        className="mt-1 block w-full px-4 py-2 pr-10 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-transparent"
        placeholder={placeholder}
      />
      <button
        type="button"
        onClick={() => setShow(!show)}
        className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-500 hover:text-gray-700"
        aria-label={show ? "Hide password" : "Show password"}
      >
        {show ? <EyeSlashIcon className="w-5 h-5" /> : <EyeIcon className="w-5 h-5" />}
      </button>
    </div>
  );
}

Also applies to: 134-161

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/components/checkout/AddressStep.tsx (1)

62-83: ⚠️ Potential issue | 🟠 Major

Missing .catch() on fetchStates — unhandled promise rejection if the call fails.

If fetchStates rejects, there is no .catch() handler on the promise chain inside startTransitionShip. This will surface as an unhandled promise rejection. The AddressEditModal handles this correctly with .catch(() => setStates([])).

🛡️ Proposed fix
     startTransitionShip(() => {
-      fetchStates(shipAddress.country_iso).then((states) => {
-        if (!cancelled) {
-          setShipStates(states);
-        }
-      });
+      fetchStates(shipAddress.country_iso)
+        .then((states) => {
+          if (!cancelled) {
+            setShipStates(states);
+          }
+        })
+        .catch(() => {
+          if (!cancelled) {
+            setShipStates([]);
+          }
+        });
     });
🧹 Nitpick comments (4)
vitest.config.ts (1)

20-20: Consider using async readFile instead of readFileSync in an async function.

Since the transform hook is already async, using fs/promises.readFile would avoid blocking the event loop and be more consistent.

♻️ Suggested change

At line 1, update the import:

-import { readFileSync } from "node:fs";
+import { readFile } from "node:fs/promises";

Then at line 20:

-      const svg = readFileSync(id, "utf-8");
+      const svg = await readFile(id, "utf-8");
src/components/checkout/AddressStep.tsx (1)

188-192: Simplify callback to a direct reference.

(address) => setEditingAddress(address) can be replaced with setEditingAddress since the signatures match.

✏️ Suggested diff
              onEditAddress={
-                onUpdateSavedAddress
-                  ? (address) => setEditingAddress(address)
-                  : undefined
+                onUpdateSavedAddress ? setEditingAddress : undefined
              }
src/app/[country]/[locale]/(checkout)/checkout/[id]/page.tsx (1)

475-509: Step indicator styling: minor simplification opportunity.

Lines 480–484: both the index < currentStepIndex and index === currentStepIndex branches apply the same classes (bg-indigo-600 text-white). This could be collapsed into a single index <= currentStepIndex check for clarity. Not a blocker since it's pre-existing logic.

✏️ Optional simplification
-                    className={`w-8 h-8 rounded-full flex items-center justify-center text-sm font-medium ${
-                      index < currentStepIndex
-                        ? "bg-indigo-600 text-white"
-                        : index === currentStepIndex
-                          ? "bg-indigo-600 text-white"
-                          : "bg-gray-200 text-gray-500"
-                    }`}
+                    className={`w-8 h-8 rounded-full flex items-center justify-center text-sm font-medium ${
+                      index <= currentStepIndex
+                        ? "bg-indigo-600 text-white"
+                        : "bg-gray-200 text-gray-500"
+                    }`}
src/app/[country]/[locale]/(storefront)/account/gift-cards/page.tsx (1)

183-194: Consider converting to a Server Component in a follow-up.

This page is marked "use client" solely to fetch data via useEffect + useState. Since this is under src/app/, it could be a Server Component that awaits getGiftCards() directly, with a loading.tsx or <Suspense> boundary for the skeleton. This would also eliminate the silent failure if getGiftCards() rejects (currently setLoading stays true forever).

Not blocking since the current changes only touch icons, but worth a follow-up. As per coding guidelines: "Use Server Components by default" and "Avoid using useEffect for data fetching."

@damianlegawiec damianlegawiec merged commit 14466dc into main Feb 13, 2026
4 checks passed
This was referenced Feb 17, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants