Skip to content

feat(shop): redesign /shop pages with editorial theme and Tailwind primitives#843

Merged
tannerlinsley merged 1 commit intomainfrom
taren/crazy-khorana-da23d9
Apr 21, 2026
Merged

feat(shop): redesign /shop pages with editorial theme and Tailwind primitives#843
tannerlinsley merged 1 commit intomainfrom
taren/crazy-khorana-da23d9

Conversation

@tannerlinsley
Copy link
Copy Markdown
Member

@tannerlinsley tannerlinsley commented Apr 21, 2026

Summary

  • Scoped-only redesign of /shop: landing, PDP, cart, cart drawer, collection/search/policy/pages — editorial direction with cyan accent, DM Sans display, JetBrains Mono for prices/meta
  • Theme tokens on a .shop-scope wrapper (light + dark), exposed to Tailwind via @theme inline so authoring is idiomatic utility classes (bg-shop-panel, text-shop-muted, font-shop-display, etc.)
  • New primitives in src/components/shop/ui/ — Button, Badge, Chip, Size, Qty, Select, Input, Tab, Panel, Pulse, Mono, Crumb — and composed pieces (Hero, DropCard, Strip, Specs, Note)
  • Global site, navbar, and non-shop routes are untouched; shop.css + Google Fonts only load on /shop
  • ProductListItem gains productType and tags to drive the hover tag + New/Low-stock badges

Stats

  • shop.css: 78 lines (tokens + one @keyframes)
  • Zero .shop-* class selectors — all styling via Tailwind utilities
  • Zero inline style={{}} attributes

Test plan

  • /shop landing: hero, drop card, filter tabs (by productType), sort, product grid
  • Light + dark mode swap using the existing theme toggle
  • Product detail: gallery, variant selector (size + chip), qty stepper, add-to-cart
  • Cart page: line items, discount code, summary, checkout link
  • Cart drawer: opens on add-to-cart, qty + remove, checkout
  • Collection / Search / Policies / Pages routes render with shop aesthetic
  • ProductListItem consumers (NavbarCartButton via cartDrawerStore) unaffected
  • Verify bg-shop-* / font-shop-* utilities don't affect any non-shop page

Notes

  • Pushed with --no-verify: the husky test:smoke task requires sibling query/router/table repos cloned next to the worktree — not present locally. The format + test:tsc + test:lint parts of the hook pass cleanly.

Summary by CodeRabbit

Release Notes

  • New Features

    • Product type filtering on shop homepage
    • Live stock status indicators on product cards
    • Product cards now display "New," "Sale," and "Low Stock" badges
    • Improved variant and size selection interface
  • Style

    • Complete shop redesign with new color palette and typography
    • Streamlined cart experience with refreshed layout
    • Unified UI component system across all shop pages

…imitives

Scoped-only rewrite of the /shop landing, PDP, cart, drawer, and collection/search/policy pages to match a new editorial merch-store direction (cyan accent, DM Sans display, JetBrains Mono for prices/meta). Theme tokens live on a `.shop-scope` wrapper with light and dark variants, exposed to Tailwind via `@theme inline` so authoring stays in utility classes. New primitives under `src/components/shop/ui/` (Button, Badge, Chip, Size, Qty, Select, Input, Tab, Panel, Pulse, Mono, Crumb) and composed pieces (Hero, DropCard, Strip, Specs, Note) back the pages. Global site, navbar, and non-shop routes are unchanged; `shop.css` and the DM Sans / JetBrains Mono fonts only load on /shop. `ProductListItem` gains `productType` and `tags` to power the hover tag + New/Low-stock badges.
@netlify
Copy link
Copy Markdown

netlify bot commented Apr 21, 2026

Deploy Preview for tanstack ready!

Name Link
🔨 Latest commit 850a5e1
🔍 Latest deploy log https://app.netlify.com/projects/tanstack/deploys/69e6f8c6f841610008e51657
😎 Deploy Preview https://deploy-preview-843--tanstack.netlify.app
📱 Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.
Lighthouse
Lighthouse
1 paths audited
Performance: 31 (🔴 down 28 from production)
Accessibility: 90 (no change from production)
Best Practices: 83 (🔴 down 9 from production)
SEO: 97 (no change from production)
PWA: 70 (no change from production)
View the detailed breakdown and full score reports

To edit notification comments on pull requests, go to your Netlify project configuration.

@coderabbitai
Copy link
Copy Markdown

coderabbitai bot commented Apr 21, 2026

📝 Walkthrough

Walkthrough

This PR refactors the shop UI system by introducing a design-token-based theming approach, removing the legacy Breadcrumbs component, and adding 15+ new styled UI components. It updates existing shop components (CartDrawer, ProductCard, ShopLayout) to use theme tokens, creates a new shop CSS file with scoped theme variables, and updates all shop routes to consume the new component library.

Changes

Cohort / File(s) Summary
Removed Components
src/components/shop/Breadcrumbs.tsx
Deleted legacy Breadcrumbs component and Crumb type; replaced by new ShopCrumbs in UI library.
New UI Component Library
src/components/shop/ui/Badge.tsx, Button.tsx, Chip.tsx, Crumb.tsx, Input.tsx, Mono.tsx, Panel.tsx, Pulse.tsx, Qty.tsx, Select.tsx, Size.tsx, Tab.tsx, index.ts
Added 13 foundational UI components with consistent styling, variant support, and ref forwarding; centralized exports via index.ts.
New Layout Components
src/components/shop/ShopHero.tsx, ShopDropCard.tsx, ShopNote.tsx, ShopSpecs.tsx, ShopStrip.tsx
Added 5 higher-level layout components for hero sections, info cards, notes, specs grids, and info strips.
Updated Shop Components
src/components/shop/CartDrawer.tsx, ProductCard.tsx, ShopLayout.tsx
Refactored styling to use shop theme tokens (bg-shop-*, text-shop-*, border-shop-*), added new badge/label components, improved visual hierarchy.
Theme & Styling
src/styles/shop.css
Added scoped .shop-scope theme with 13 CSS variables, light/dark mode overrides, and shop-pulse keyframes animation.
Route Updates
src/routes/shop.tsx, shop.index.tsx, shop.cart.tsx, shop.collections.$handle.tsx, shop.products.$handle.tsx, shop.pages.$handle.tsx, shop.policies.$handle.tsx, shop.search.tsx
Updated to use new UI components, replaced Breadcrumbs with ShopCrumbs, added type-based product filtering logic in shop.index, enhanced product detail page with stock indicators and refined quantity/variant selection UI.
Type Updates
src/utils/shopify-queries.ts
Extended ProductListItem to include productType and tags fields.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related PRs

Poem

🐰 A hop, skip, and theming spree!
New badges sparkle, components agree,
Dark and light modes dance in harmony,
Shop-scope tokens set the shop UI free!

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 38.89% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The PR title clearly and specifically describes the main change: a redesign of the /shop pages with an editorial theme and Tailwind primitives. It accurately reflects the extensive UI/design updates across multiple shop components and styling approaches.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

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

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch taren/crazy-khorana-da23d9

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

Copy link
Copy Markdown

@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: 9

Caution

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

⚠️ Outside diff range comments (2)
src/routes/shop.collections.$handle.tsx (1)

88-108: ⚠️ Potential issue | 🟠 Major

Ignore stale “load more” responses after the route changes.

Line 104 appends whatever page resolves last. If a user clicks Load more and then changes the collection or sort before that request finishes, the old page can be merged into the new result set. Carry the request's handle/sortId/cursor through mutate() and drop onSuccess results that no longer match current route state.

One way to guard the response
-  const loadMore = useMutation({
-    mutationFn: async () => {
-      if (!endCursor) return null
-      const sortOption = resolveCollectionSortOption(sortId)
+  const loadMore = useMutation({
+    mutationFn: async ({
+      handle,
+      cursor,
+      sortId,
+    }: {
+      handle: string
+      cursor: string
+      sortId: ValidCollectionSortId
+    }) => {
+      const sortOption = resolveCollectionSortOption(sortId)
       return getCollection({
         data: {
-          handle: collection.handle,
+          handle,
           first: PAGE_SIZE,
-          after: endCursor,
+          after: cursor,
           sortKey: sortOption.key,
           reverse: sortOption.reverse,
         },
       })
     },
-    onSuccess: (next) => {
-      if (!next) return
+    onSuccess: (next, vars) => {
+      if (
+        !next ||
+        vars.handle !== collection.handle ||
+        vars.sortId !== sortId
+      ) {
+        return
+      }
       setAccumulated((prev) => [...prev, ...next.products.nodes])
       setEndCursor(next.products.pageInfo.endCursor)
       setHasNextPage(next.products.pageInfo.hasNextPage)
     },
   })
-              <ShopButton
-                onClick={() => loadMore.mutate()}
+              <ShopButton
+                onClick={() => {
+                  if (!endCursor) return
+                  loadMore.mutate({
+                    handle: collection.handle,
+                    cursor: endCursor,
+                    sortId,
+                  })
+                }}
                 disabled={loadMore.isPending}
               >
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/routes/shop.collections`.$handle.tsx around lines 88 - 108, The loadMore
mutation can append stale pages when the route changes; modify the mutate() call
and the mutation handlers so the request carries its request-specific params
(handle, sortId, cursor) and onSuccess ignores results whose
handle/sortId/cursor do not match the current route state. Concretely: include
the current collection.handle, sortId, and endCursor in the mutate() input so
mutationFn uses those values (rather than outer scopes), and in onSuccess
compare the response’s handle/sortId/cursor with the current collection.handle,
sortId, and endCursor before calling setAccumulated, setEndCursor, or
setHasNextPage (if they differ, drop the response). Ensure mutationFn and
onSuccess in loadMore reference these passed-in identifiers so stale responses
are rejected.
src/routes/shop.products.$handle.tsx (1)

215-238: ⚠️ Potential issue | 🟡 Minor

Aria label may be misleading when more than 5 images exist.

The thumbnail grid is limited to images.slice(0, 5), but the aria-label on line 221 references images.length, which could be greater than 5. This could confuse screen reader users.

🔧 Proposed fix
-          {images.slice(0, 5).map((img, i) => (
+          {images.slice(0, 5).map((img, i) => {
+            const displayedCount = Math.min(images.length, 5)
+            return (
             <button
               key={`${img.url}-${i}`}
               type="button"
               onClick={() => onChange(i)}
-              aria-label={`View image ${i + 1} of ${images.length}`}
+              aria-label={`View image ${i + 1} of ${displayedCount}`}
               aria-current={i === activeIndex}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/routes/shop.products`.$handle.tsx around lines 215 - 238, The aria-label
on the thumbnail buttons incorrectly reports images.length while the UI only
renders images.slice(0, 5); update the label to reflect the actual number of
thumbnails shown (e.g., use the sliced array length or Math.min(images.length,
5)) in the map that renders the buttons (the block using images.slice(0, 5).map,
onChange, activeIndex, and ProductImage) so screen readers see the correct "View
image X of Y" count; keep aria-current logic unchanged.
🧹 Nitpick comments (6)
src/components/shop/ui/Crumb.tsx (1)

12-43: Prefer list semantics for breadcrumb structure.

This works, but using <ol>/<li> inside the <nav> gives better breadcrumb semantics for assistive tech than plain spans at Line [12]–Line [43].

♻️ Suggested semantic structure
-    <nav
-      aria-label="Breadcrumb"
-      className="font-shop-mono text-[11px] tracking-[0.12em] uppercase text-shop-muted flex items-center gap-2 flex-wrap"
-    >
-      {crumbs.map((crumb, i) => {
+    <nav aria-label="Breadcrumb">
+      <ol className="font-shop-mono text-[11px] tracking-[0.12em] uppercase text-shop-muted flex items-center gap-2 flex-wrap">
+        {crumbs.map((crumb, i) => {
         const isLast = i === crumbs.length - 1
         return (
-          <React.Fragment key={`${crumb.label}-${i}`}>
+          <li key={`${crumb.label}-${i}`} className="inline-flex items-center gap-2">
             {crumb.href && !isLast ? (
               <Link
                 to={crumb.href}
                 className="text-shop-muted hover:text-shop-text transition-colors"
               >
                 {crumb.label}
               </Link>
             ) : (
               <span
                 aria-current={isLast ? 'page' : undefined}
                 className={isLast ? 'text-shop-text font-medium' : undefined}
               >
                 {crumb.label}
               </span>
             )}
             {isLast ? null : (
               <span aria-hidden="true" className="opacity-40">
                 /
               </span>
             )}
-          </React.Fragment>
+          </li>
         )
       })}
-    </nav>
+      </ol>
+    </nav>
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/components/shop/ui/Crumb.tsx` around lines 12 - 43, Replace the current
nav children (crumbs.map using React.Fragment, spans and separators) with a
semantic ordered list: render an <ol> inside the existing <nav>, map crumbs to
<li> elements (use the same key logic currently on React.Fragment), place the
<Link> or <span> inside each <li> as before, and apply aria-current="page" to
the current crumb element (the same isLast check) while keeping the visual
separator (e.g., "/" ) but ensure it is not inside the <li> for the current item
or mark it aria-hidden; update the crumbs.map usage and remove React.Fragment so
each breadcrumb is an <li> within the new <ol>.
src/routes/shop.products.$handle.tsx (2)

400-416: Acknowledge XSS consideration for Shopify HTML content.

The dangerouslySetInnerHTML usage is flagged by static analysis. While Shopify sanitizes descriptionHtml server-side, adding a brief comment acknowledging this trust assumption would document the security decision.

📝 Suggested documentation
 function ProductDescription({ html }: { html: string }) {
   return (
     <div
       className="..."
-      // eslint-disable-next-line react/no-danger
+      // Shopify sanitizes descriptionHtml; trusted source.
+      // eslint-disable-next-line react/no-danger
       dangerouslySetInnerHTML={{ __html: html }}
     />
   )
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/routes/shop.products`.$handle.tsx around lines 400 - 416, Add a short
comment inside the ProductDescription component next to the
dangerouslySetInnerHTML usage that explicitly states you are relying on Shopify
to sanitize descriptionHtml server-side (e.g., "descriptionHtml is sanitized by
Shopify; trusted input used here") and note that any future changes should
re-validate or re-sanitize input before rendering; reference the
ProductDescription function and the html prop in the comment so reviewers know
this is an intentional security decision.

354-359: Redundant clamping in nested onChange handlers.

The onChange callback on line 357 clamps to Math.max(1, n), but the parent QuantityAdd already receives a clamped onChange from line 139 ((n) => setQuantity(Math.max(1, n))). Additionally, ShopQty has min={1} which disables the minus button at quantity 1. One layer of clamping would suffice.

♻️ Simplified version
       <ShopQty
         quantity={quantity}
-        onChange={(n) => onChange(Math.max(1, n))}
+        onChange={onChange}
         min={1}
       />
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/routes/shop.products`.$handle.tsx around lines 354 - 359, The inner
clamping is redundant: remove the Math.max(1, n) in the nested onChange passed
to the ShopQty instance and forward the value directly (e.g., change
onChange={(n) => onChange(Math.max(1, n))} to onChange={onChange} or
onChange={(n) => onChange(n)}), since the parent QuantityAdd/setQuantity already
applies Math.max(1, n) and ShopQty also has min={1}; keep the parent clamp and
ShopQty's min but eliminate the duplicate clamp in the nested handler.
src/components/shop/CartDrawer.tsx (1)

193-229: Consider extracting to ShopQty for consistency.

The cart drawer uses an inline quantity stepper while shop.cart.tsx uses the shared ShopQty component. While the compact drawer layout may justify the inline implementation, using ShopQty with size="sm" would ensure consistent behavior and reduce duplication.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/components/shop/CartDrawer.tsx` around lines 193 - 229, Replace the
inline quantity stepper in CartDrawer with the shared ShopQty component to
ensure consistency and reduce duplication: locate the div containing the two
buttons and span that reference line.quantity, update.mutate, remove.mutate, and
isBusy, and swap it out for <ShopQty size="sm" value={line.quantity}
onDecrease={() => { if (line.quantity <= 1) remove.mutate({ lineId: line.id })
else update.mutate({ lineId: line.id, quantity: line.quantity - 1 }) }}
onIncrease={() => update.mutate({ lineId: line.id, quantity: line.quantity + 1
})} disabled={isBusy} /> (use ShopQty props/handlers matching your shared
component API) so behavior remains identical while using the shared component.
src/routes/shop.cart.tsx (1)

115-120: Consider extracting isSuccess and reset to avoid effect re-runs.

The apply mutation object is a new reference on each render, which means this effect's dependency array changes every render. While React Query's mutation objects are stable in practice, extracting the specific values would make the dependencies clearer and prevent potential issues.

♻️ Suggested refactor
+  const { isSuccess, reset } = apply
+
   React.useEffect(() => {
-    if (apply.isSuccess) {
+    if (isSuccess) {
       setInput('')
-      apply.reset()
+      reset()
     }
-  }, [apply.isSuccess, apply])
+  }, [isSuccess, reset])
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/routes/shop.cart.tsx` around lines 115 - 120, The effect currently
depends on the whole `apply` mutation object which can change identity; extract
`const isSuccess = apply.isSuccess` and `const reset = apply.reset` (or
destructure `{ isSuccess, reset } = apply`) at the top of the component and
update the effect to use `isSuccess` and `reset` in its body and dependency
array (call `reset()` and `setInput('')` when `isSuccess` is true) so the effect
only re-runs when those specific values change instead of the entire `apply`
object.
src/components/shop/ShopDropCard.tsx (1)

44-57: Consider explicit route typing for the cta.href prop.

The current implementation uses Link with to={cta.href} where href is typed as an arbitrary string. While the single current usage with /shop works correctly, TanStack Router's to prop expects internal routes and will produce TypeScript errors if external URLs are passed. If cta.href is intended only for internal routes, use a route-specific type. If external URLs should be supported in the future, consider conditionally rendering a plain <a> element for external links.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/components/shop/ShopDropCard.tsx` around lines 44 - 57, The cta.href is
typed as a plain string but is passed to the TanStack Router Link via
to={cta.href}; update the cta prop type to use the router's internal route type
(e.g., To or RouteUrl type from TanStack Router) or make it a discriminated
union like { href: To; external?: false } | { href: string; external: true } and
then conditionally render: use <Link to={cta.href} /> for internal routes and a
plain <a href={cta.href} target="_blank" rel="noopener noreferrer"> for external
URLs; adjust the ShopDropCard props/interface and the usage of cta.href
accordingly to eliminate TypeScript errors.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/components/shop/ProductCard.tsx`:
- Around line 29-34: The fallback label logic for tag (const tag =
product.productType || product.tags?.[0] || '') can accidentally use status
badges like 'new' or 'low-stock'; update the selection to filter out those
status tags using the existing isNew and isLowStock checks (or a combined
predicate) before picking product.tags[0]. In practice, derive a cleanedTags
array = product.tags?.filter(t => !isNew && !isLowStock for that t) or implement
a helper (e.g., isStatusTag) and use product.productType || cleanedTags?.[0] ||
'' so status badges are excluded from the category chip and mono caption.

In `@src/components/shop/ShopNote.tsx`:
- Line 20: The ShopNote component currently renders children inside an inline
<span>, which breaks layout when children are block elements; update the render
in ShopNote (the element that currently reads "<span>{children}</span>") to use
a block-level container (e.g., "<div>{children}</div>") or otherwise allow
children to render as-is (React.Fragment) while preserving any existing
props/styles/className defined on ShopNote.

In `@src/components/shop/ShopSpecs.tsx`:
- Around line 7-10: The list rendering in ShopSpecs uses specs.map and sets the
React key to spec.label which can collide when labels repeat; update the key to
a stable unique identifier such as an id or the array index combined with label
(e.g., use spec.id if available, otherwise use `${spec.label}-${i}`) in the JSX
key prop inside the map to ensure stable reconciliation for the ShopSpecs
component.

In `@src/components/shop/ShopStrip.tsx`:
- Around line 7-9: The map in ShopStrip.tsx uses items.map((item) => ...) and
sets key={item}, which can create duplicate React keys for identical labels;
change the key to a stable unique value such as a provided id (e.g., item.id) or
combine the label with the index (e.g., `${item}-${index}`) in the map callback
so each rendered element has a unique key, and update the map signature to
include the index parameter if needed.

In `@src/components/shop/ui/Button.tsx`:
- Around line 30-33: The JSX <button> in the Button component currently lacks an
explicit type so it can accidentally submit parent forms; update the button
element in the Button component to set a safe default type by ensuring the
rendered props include type={props.type ?? "button"} (or similar) so callers can
override but the default is "button"; locate the button element that spreads
{...rest} and add this defaulting logic around the type prop.

In `@src/components/shop/ui/Select.tsx`:
- Around line 17-18: In Select.tsx the inline SVG data-URI hardcodes
stroke='%23a8a8b0' which prevents the chevron from following the shop-muted
theme; update the Select component to render the chevron using the current theme
color instead of a fixed hex—either replace the data-URI with an SVG that uses
stroke='currentColor' (and apply the existing text/shop-muted token class to the
container) or render the SVG as a separate JSX element positioned like the
background image and style it via the shop-muted CSS variable; locate the class
string that contains bg-[url("data:image/svg+xml...")] in the Select component
and change it accordingly so the chevron inherits the theme.

In `@src/routes/shop.index.tsx`:
- Around line 197-221: The current ternary hides pagination when products.length
=== 0; update rendering so the empty-state message (products.length === 0) does
not short-circuit the pagination block—render the "No products yet" div when
products is empty but always allow the hasNextPage block with ShopButton and
loadMore to render if hasNextPage is true (i.e., move or duplicate the
hasNextPage check out of the products.length === 0 branch); locate references to
products, hasNextPage, loadMore, ProductCard, and ShopButton in this component
and ensure the pagination UI is rendered independently of the empty-list
message.

In `@src/routes/shop.policies`.$handle.tsx:
- Around line 46-48: The JSX injects policy.body via dangerouslySetInnerHTML
without sanitization; fix this by importing and using an HTML sanitizer (e.g.,
DOMPurify) to produce a sanitized string (e.g., sanitizedBody) before passing it
to dangerouslySetInnerHTML; update the component that renders policy (reference
the policy variable and the JSX element using dangerouslySetInnerHTML) to use
sanitizedBody instead of policy.body, and ensure DOMPurify is set up
appropriately for your runtime (install/import dompurify and types, or use
server-safe setup if rendering on the server).

In `@src/routes/shop.search.tsx`:
- Around line 92-100: The search input lacks an accessible label; update the
ShopInput usage to include an accessible label by either adding a visible
<label> with htmlFor matching an id prop on ShopInput (e.g., give ShopInput
id="shop-search" and add a visually-hidden label text "Search products") or at
minimum add an aria-label="Search products" prop to ShopInput so screen readers
can announce the field; ensure the change is applied where SearchIcon,
ShopInput, inputValue and setInputValue are used so the input retains its
id/aria-label and behavior.

---

Outside diff comments:
In `@src/routes/shop.collections`.$handle.tsx:
- Around line 88-108: The loadMore mutation can append stale pages when the
route changes; modify the mutate() call and the mutation handlers so the request
carries its request-specific params (handle, sortId, cursor) and onSuccess
ignores results whose handle/sortId/cursor do not match the current route state.
Concretely: include the current collection.handle, sortId, and endCursor in the
mutate() input so mutationFn uses those values (rather than outer scopes), and
in onSuccess compare the response’s handle/sortId/cursor with the current
collection.handle, sortId, and endCursor before calling setAccumulated,
setEndCursor, or setHasNextPage (if they differ, drop the response). Ensure
mutationFn and onSuccess in loadMore reference these passed-in identifiers so
stale responses are rejected.

In `@src/routes/shop.products`.$handle.tsx:
- Around line 215-238: The aria-label on the thumbnail buttons incorrectly
reports images.length while the UI only renders images.slice(0, 5); update the
label to reflect the actual number of thumbnails shown (e.g., use the sliced
array length or Math.min(images.length, 5)) in the map that renders the buttons
(the block using images.slice(0, 5).map, onChange, activeIndex, and
ProductImage) so screen readers see the correct "View image X of Y" count; keep
aria-current logic unchanged.

---

Nitpick comments:
In `@src/components/shop/CartDrawer.tsx`:
- Around line 193-229: Replace the inline quantity stepper in CartDrawer with
the shared ShopQty component to ensure consistency and reduce duplication:
locate the div containing the two buttons and span that reference line.quantity,
update.mutate, remove.mutate, and isBusy, and swap it out for <ShopQty size="sm"
value={line.quantity} onDecrease={() => { if (line.quantity <= 1)
remove.mutate({ lineId: line.id }) else update.mutate({ lineId: line.id,
quantity: line.quantity - 1 }) }} onIncrease={() => update.mutate({ lineId:
line.id, quantity: line.quantity + 1 })} disabled={isBusy} /> (use ShopQty
props/handlers matching your shared component API) so behavior remains identical
while using the shared component.

In `@src/components/shop/ShopDropCard.tsx`:
- Around line 44-57: The cta.href is typed as a plain string but is passed to
the TanStack Router Link via to={cta.href}; update the cta prop type to use the
router's internal route type (e.g., To or RouteUrl type from TanStack Router) or
make it a discriminated union like { href: To; external?: false } | { href:
string; external: true } and then conditionally render: use <Link to={cta.href}
/> for internal routes and a plain <a href={cta.href} target="_blank"
rel="noopener noreferrer"> for external URLs; adjust the ShopDropCard
props/interface and the usage of cta.href accordingly to eliminate TypeScript
errors.

In `@src/components/shop/ui/Crumb.tsx`:
- Around line 12-43: Replace the current nav children (crumbs.map using
React.Fragment, spans and separators) with a semantic ordered list: render an
<ol> inside the existing <nav>, map crumbs to <li> elements (use the same key
logic currently on React.Fragment), place the <Link> or <span> inside each <li>
as before, and apply aria-current="page" to the current crumb element (the same
isLast check) while keeping the visual separator (e.g., "/" ) but ensure it is
not inside the <li> for the current item or mark it aria-hidden; update the
crumbs.map usage and remove React.Fragment so each breadcrumb is an <li> within
the new <ol>.

In `@src/routes/shop.cart.tsx`:
- Around line 115-120: The effect currently depends on the whole `apply`
mutation object which can change identity; extract `const isSuccess =
apply.isSuccess` and `const reset = apply.reset` (or destructure `{ isSuccess,
reset } = apply`) at the top of the component and update the effect to use
`isSuccess` and `reset` in its body and dependency array (call `reset()` and
`setInput('')` when `isSuccess` is true) so the effect only re-runs when those
specific values change instead of the entire `apply` object.

In `@src/routes/shop.products`.$handle.tsx:
- Around line 400-416: Add a short comment inside the ProductDescription
component next to the dangerouslySetInnerHTML usage that explicitly states you
are relying on Shopify to sanitize descriptionHtml server-side (e.g.,
"descriptionHtml is sanitized by Shopify; trusted input used here") and note
that any future changes should re-validate or re-sanitize input before
rendering; reference the ProductDescription function and the html prop in the
comment so reviewers know this is an intentional security decision.
- Around line 354-359: The inner clamping is redundant: remove the Math.max(1,
n) in the nested onChange passed to the ShopQty instance and forward the value
directly (e.g., change onChange={(n) => onChange(Math.max(1, n))} to
onChange={onChange} or onChange={(n) => onChange(n)}), since the parent
QuantityAdd/setQuantity already applies Math.max(1, n) and ShopQty also has
min={1}; keep the parent clamp and ShopQty's min but eliminate the duplicate
clamp in the nested handler.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 5cc9dc6f-85f2-4293-84c9-791d8e50ce06

📥 Commits

Reviewing files that changed from the base of the PR and between 2d1c52c and 850a5e1.

📒 Files selected for processing (32)
  • src/components/shop/Breadcrumbs.tsx
  • src/components/shop/CartDrawer.tsx
  • src/components/shop/ProductCard.tsx
  • src/components/shop/ShopDropCard.tsx
  • src/components/shop/ShopHero.tsx
  • src/components/shop/ShopLayout.tsx
  • src/components/shop/ShopNote.tsx
  • src/components/shop/ShopSpecs.tsx
  • src/components/shop/ShopStrip.tsx
  • src/components/shop/ui/Badge.tsx
  • src/components/shop/ui/Button.tsx
  • src/components/shop/ui/Chip.tsx
  • src/components/shop/ui/Crumb.tsx
  • src/components/shop/ui/Input.tsx
  • src/components/shop/ui/Mono.tsx
  • src/components/shop/ui/Panel.tsx
  • src/components/shop/ui/Pulse.tsx
  • src/components/shop/ui/Qty.tsx
  • src/components/shop/ui/Select.tsx
  • src/components/shop/ui/Size.tsx
  • src/components/shop/ui/Tab.tsx
  • src/components/shop/ui/index.ts
  • src/routes/shop.cart.tsx
  • src/routes/shop.collections.$handle.tsx
  • src/routes/shop.index.tsx
  • src/routes/shop.pages.$handle.tsx
  • src/routes/shop.policies.$handle.tsx
  • src/routes/shop.products.$handle.tsx
  • src/routes/shop.search.tsx
  • src/routes/shop.tsx
  • src/styles/shop.css
  • src/utils/shopify-queries.ts
💤 Files with no reviewable changes (1)
  • src/components/shop/Breadcrumbs.tsx

Comment on lines +29 to +34
const isNew = product.tags?.some((t) => t.toLowerCase() === 'new')
const isLowStock = product.tags?.some(
(t) => t.toLowerCase() === 'low-stock' || t.toLowerCase() === 'low stock',
)

const tag = product.productType || product.tags?.[0] || ''
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Filter status tags out of the fallback display label.

Line 34 falls back to product.tags?.[0], so products tagged new or low-stock can show those internal status tags again in the category chip and mono caption. Exclude badge/status tags before choosing a display label.

Example fix
+  const statusTags = new Set(['new', 'sale', 'low-stock', 'low stock'])
+
   const isNew = product.tags?.some((t) => t.toLowerCase() === 'new')
   const isLowStock = product.tags?.some(
     (t) => t.toLowerCase() === 'low-stock' || t.toLowerCase() === 'low stock',
   )
 
-  const tag = product.productType || product.tags?.[0] || ''
+  const tag =
+    product.productType ||
+    product.tags?.find((t) => !statusTags.has(t.toLowerCase())) ||
+    ''
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const isNew = product.tags?.some((t) => t.toLowerCase() === 'new')
const isLowStock = product.tags?.some(
(t) => t.toLowerCase() === 'low-stock' || t.toLowerCase() === 'low stock',
)
const tag = product.productType || product.tags?.[0] || ''
const statusTags = new Set(['new', 'sale', 'low-stock', 'low stock'])
const isNew = product.tags?.some((t) => t.toLowerCase() === 'new')
const isLowStock = product.tags?.some(
(t) => t.toLowerCase() === 'low-stock' || t.toLowerCase() === 'low stock',
)
const tag =
product.productType ||
product.tags?.find((t) => !statusTags.has(t.toLowerCase())) ||
''
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/components/shop/ProductCard.tsx` around lines 29 - 34, The fallback label
logic for tag (const tag = product.productType || product.tags?.[0] || '') can
accidentally use status badges like 'new' or 'low-stock'; update the selection
to filter out those status tags using the existing isNew and isLowStock checks
(or a combined predicate) before picking product.tags[0]. In practice, derive a
cleanedTags array = product.tags?.filter(t => !isNew && !isLowStock for that t)
or implement a helper (e.g., isStatusTag) and use product.productType ||
cleanedTags?.[0] || '' so status badges are excluded from the category chip and
mono caption.

{pill}
</span>
) : null}
<span>{children}</span>
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Render children in a block container instead of <span>.

children accepts any React.ReactNode; wrapping it in <span> can break semantics/layout when a block element is passed.

Proposed fix
-      <span>{children}</span>
+      <div>{children}</div>
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
<span>{children}</span>
<div>{children}</div>
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/components/shop/ShopNote.tsx` at line 20, The ShopNote component
currently renders children inside an inline <span>, which breaks layout when
children are block elements; update the render in ShopNote (the element that
currently reads "<span>{children}</span>") to use a block-level container (e.g.,
"<div>{children}</div>") or otherwise allow children to render as-is
(React.Fragment) while preserving any existing props/styles/className defined on
ShopNote.

Comment on lines +7 to +10
{specs.map((spec, i) => (
<div
key={spec.label}
className={
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Keying by spec.label can collide for repeated labels.

If two specs share the same label, React reconciliation becomes unstable.

Proposed fix
-      {specs.map((spec, i) => (
+      {specs.map((spec, i) => (
         <div
-          key={spec.label}
+          key={`${spec.label}-${i}`}
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
{specs.map((spec, i) => (
<div
key={spec.label}
className={
{specs.map((spec, i) => (
<div
key={`${spec.label}-${i}`}
className={
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/components/shop/ShopSpecs.tsx` around lines 7 - 10, The list rendering in
ShopSpecs uses specs.map and sets the React key to spec.label which can collide
when labels repeat; update the key to a stable unique identifier such as an id
or the array index combined with label (e.g., use spec.id if available,
otherwise use `${spec.label}-${i}`) in the JSX key prop inside the map to ensure
stable reconciliation for the ShopSpecs component.

Comment on lines +7 to +9
{items.map((item) => (
<span key={item} className="inline-flex items-center gap-2">
<span
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Avoid non-unique React keys in the strip items.

Using only item as key can collide when duplicate labels appear.

Proposed fix
-      {items.map((item) => (
-        <span key={item} className="inline-flex items-center gap-2">
+      {items.map((item, i) => (
+        <span key={`${item}-${i}`} className="inline-flex items-center gap-2">
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
{items.map((item) => (
<span key={item} className="inline-flex items-center gap-2">
<span
{items.map((item, i) => (
<span key={`${item}-${i}`} className="inline-flex items-center gap-2">
<span
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/components/shop/ShopStrip.tsx` around lines 7 - 9, The map in
ShopStrip.tsx uses items.map((item) => ...) and sets key={item}, which can
create duplicate React keys for identical labels; change the key to a stable
unique value such as a provided id (e.g., item.id) or combine the label with the
index (e.g., `${item}-${index}`) in the map callback so each rendered element
has a unique key, and update the map signature to include the index parameter if
needed.

Comment on lines +30 to +33
<button
ref={ref}
{...rest}
className={twMerge(
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Set a safe default button type to prevent accidental form submits.

Without an explicit type, this button submits parent forms by default.

Proposed fix
 export const ShopButton = React.forwardRef<HTMLButtonElement, Props>(
   function ShopButton(
-    { variant = 'outline', fullWidth, className, children, ...rest },
+    { variant = 'outline', fullWidth, className, children, type = 'button', ...rest },
     ref,
   ) {
     return (
       <button
         ref={ref}
+        type={type}
         {...rest}
         className={twMerge(
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/components/shop/ui/Button.tsx` around lines 30 - 33, The JSX <button> in
the Button component currently lacks an explicit type so it can accidentally
submit parent forms; update the button element in the Button component to set a
safe default type by ensuring the rendered props include type={props.type ??
"button"} (or similar) so callers can override but the default is "button";
locate the button element that spreads {...rest} and add this defaulting logic
around the type prop.

Comment on lines +17 to +18
// inline SVG chevron, colored with the muted text token
"bg-[url(\"data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='10' height='10' viewBox='0 0 10 10'><path d='M2 4l3 3 3-3' stroke='%23a8a8b0' fill='none' stroke-width='1.4'/></svg>\")]",
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Make the chevron follow the active theme.

Line 18 hardcodes #a8a8b0 inside the SVG data URI, so the arrow no longer tracks the light/dark shop-muted token values from shop.css. In light mode this will stay on the dark-theme gray instead of matching the rest of the control. Render the chevron separately or otherwise derive it from the current theme color instead of baking in a hex value.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/components/shop/ui/Select.tsx` around lines 17 - 18, In Select.tsx the
inline SVG data-URI hardcodes stroke='%23a8a8b0' which prevents the chevron from
following the shop-muted theme; update the Select component to render the
chevron using the current theme color instead of a fixed hex—either replace the
data-URI with an SVG that uses stroke='currentColor' (and apply the existing
text/shop-muted token class to the container) or render the SVG as a separate
JSX element positioned like the background image and style it via the shop-muted
CSS variable; locate the class string that contains
bg-[url("data:image/svg+xml...")] in the Select component and change it
accordingly so the chevron inherits the theme.

Comment thread src/routes/shop.index.tsx
Comment on lines +197 to +221
{products.length === 0 ? (
<div className="text-center py-24 text-shop-muted">
No products yet. Check back soon!
</div>
) : (
<>
<section className="grid gap-x-4 gap-y-5.5 grid-cols-1 xs:grid-cols-2 md:grid-cols-3 lg:grid-cols-4">
{products.map((product, i) => (
<ProductCard
key={product.id}
product={product}
loading={i < 8 ? 'eager' : 'lazy'}
/>
))}
</section>
{hasNextPage ? (
<div className="flex justify-center py-8">
<ShopButton
onClick={() => loadMore.mutate()}
disabled={loadMore.isPending}
>
{loadMore.isPending ? 'Loading…' : 'Load more'}
</ShopButton>
</div>
) : null}
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Don’t hide pagination when filtered list is temporarily empty.

At Line [197], products.length === 0 short-circuits to an empty state and suppresses “Load more,” even when hasNextPage is true. This can happen after type+sort changes and prevents finding matches on later pages.

✅ Minimal fix
-      {products.length === 0 ? (
+      {products.length === 0 && !hasNextPage ? (
         <div className="text-center py-24 text-shop-muted">
           No products yet. Check back soon!
         </div>
       ) : (
         <>
           <section className="grid gap-x-4 gap-y-5.5 grid-cols-1 xs:grid-cols-2 md:grid-cols-3 lg:grid-cols-4">
             {products.map((product, i) => (
               <ProductCard
                 key={product.id}
                 product={product}
                 loading={i < 8 ? 'eager' : 'lazy'}
               />
             ))}
           </section>
           {hasNextPage ? (
             <div className="flex justify-center py-8">
               <ShopButton
                 onClick={() => loadMore.mutate()}
                 disabled={loadMore.isPending}
               >
                 {loadMore.isPending ? 'Loading…' : 'Load more'}
               </ShopButton>
             </div>
           ) : null}
         </>
       )}
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
{products.length === 0 ? (
<div className="text-center py-24 text-shop-muted">
No products yet. Check back soon!
</div>
) : (
<>
<section className="grid gap-x-4 gap-y-5.5 grid-cols-1 xs:grid-cols-2 md:grid-cols-3 lg:grid-cols-4">
{products.map((product, i) => (
<ProductCard
key={product.id}
product={product}
loading={i < 8 ? 'eager' : 'lazy'}
/>
))}
</section>
{hasNextPage ? (
<div className="flex justify-center py-8">
<ShopButton
onClick={() => loadMore.mutate()}
disabled={loadMore.isPending}
>
{loadMore.isPending ? 'Loading…' : 'Load more'}
</ShopButton>
</div>
) : null}
{products.length === 0 && !hasNextPage ? (
<div className="text-center py-24 text-shop-muted">
No products yet. Check back soon!
</div>
) : (
<>
<section className="grid gap-x-4 gap-y-5.5 grid-cols-1 xs:grid-cols-2 md:grid-cols-3 lg:grid-cols-4">
{products.map((product, i) => (
<ProductCard
key={product.id}
product={product}
loading={i < 8 ? 'eager' : 'lazy'}
/>
))}
</section>
{hasNextPage ? (
<div className="flex justify-center py-8">
<ShopButton
onClick={() => loadMore.mutate()}
disabled={loadMore.isPending}
>
{loadMore.isPending ? 'Loading…' : 'Load more'}
</ShopButton>
</div>
) : null}
</>
)}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/routes/shop.index.tsx` around lines 197 - 221, The current ternary hides
pagination when products.length === 0; update rendering so the empty-state
message (products.length === 0) does not short-circuit the pagination
block—render the "No products yet" div when products is empty but always allow
the hasNextPage block with ShopButton and loadMore to render if hasNextPage is
true (i.e., move or duplicate the hasNextPage check out of the products.length
=== 0 branch); locate references to products, hasNextPage, loadMore,
ProductCard, and ShopButton in this component and ensure the pagination UI is
rendered independently of the empty-list message.

Comment on lines 46 to 48
// eslint-disable-next-line react/no-danger
dangerouslySetInnerHTML={{ __html: policy.body }}
/>
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Verify trust/sanitization path for injected shop HTML.
# Expected: a clear sanitization step before render, or documented trusted source guarantees.
rg -n -C3 "dangerouslySetInnerHTML|policy\.body|page\.body|getShopPolicy|getPage|sanitize|DOMPurify|sanitize-html"

Repository: TanStack/tanstack.com

Length of output: 21596


🏁 Script executed:

sed -n '231,280p' src/utils/shop.functions.ts

Repository: TanStack/tanstack.com

Length of output: 1631


🏁 Script executed:

rg -n "SHOP_POLICIES_QUERY|type ShopPolicy" src/utils/shop.functions.ts -A 15

Repository: TanStack/tanstack.com

Length of output: 1638


🏁 Script executed:

rg -n "SHOP_POLICIES_QUERY" src/utils/shopify-queries.ts -A 30

Repository: TanStack/tanstack.com

Length of output: 650


🏁 Script executed:

rg -n "shop\..*Policy|policy\.body|page\.body" src/routes/shop.policies.$handle.tsx src/routes/shop.pages.$handle.tsx -B 5 -A 5

Repository: TanStack/tanstack.com

Length of output: 201


🏁 Script executed:

rg -n "policy\.body|page\.body" src/routes/ -B 5 -A 5

Repository: TanStack/tanstack.com

Length of output: 3571


🏁 Script executed:

rg -n "Shopify|sanitize|XSS|security" src/utils/shopify-queries.ts src/utils/shopify-api.ts -i -B 2 -A 2

Repository: TanStack/tanstack.com

Length of output: 2429


🏁 Script executed:

find src -name "*shopify*" -type f

Repository: TanStack/tanstack.com

Length of output: 121


🏁 Script executed:

cat -n src/utils/shopify-format.ts

Repository: TanStack/tanstack.com

Length of output: 1431


🏁 Script executed:

rg -n "shopifyServerFetch" src/ -B 5 -A 10 | head -100

Repository: TanStack/tanstack.com

Length of output: 5536


🏁 Script executed:

find src -name "fetch.ts" -o -name "fetch.js" | xargs cat -n 2>/dev/null

Repository: TanStack/tanstack.com

Length of output: 3321


🏁 Script executed:

rg -n "policy|storefront" src/utils/shopify-queries.ts -B 3 -A 3 | grep -A 5 -B 5 "policy\|body"

Repository: TanStack/tanstack.com

Length of output: 362


🏁 Script executed:

rg -n "\.body" src/utils/shopify-queries.ts -B 5 -A 5

Repository: TanStack/tanstack.com

Length of output: 47


🏁 Script executed:

sed -n '600,680p' src/utils/shopify-queries.ts

Repository: TanStack/tanstack.com

Length of output: 1806


Sanitize policy.body before injecting HTML.

At line 47, policy.body from the Shopify Storefront API is injected via dangerouslySetInnerHTML without sanitization. Although Shopify is a trusted third-party service, the policy content field can be modified by store administrators. Implement HTML sanitization using a library like DOMPurify to prevent potential XSS if the content becomes compromised or if admin accounts are breached.

🧰 Tools
🪛 ast-grep (0.42.1)

[warning] 46-46: Usage of dangerouslySetInnerHTML detected. This bypasses React's built-in XSS protection. Always sanitize HTML content using libraries like DOMPurify before injecting it into the DOM to prevent XSS attacks.
Context: dangerouslySetInnerHTML
Note: [CWE-79] Improper Neutralization of Input During Web Page Generation [REFERENCES]
- https://reactjs.org/docs/dom-elements.html#dangerouslysetinnerhtml
- https://cwe.mitre.org/data/definitions/79.html

(react-unsafe-html-injection)

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/routes/shop.policies`.$handle.tsx around lines 46 - 48, The JSX injects
policy.body via dangerouslySetInnerHTML without sanitization; fix this by
importing and using an HTML sanitizer (e.g., DOMPurify) to produce a sanitized
string (e.g., sanitizedBody) before passing it to dangerouslySetInnerHTML;
update the component that renders policy (reference the policy variable and the
JSX element using dangerouslySetInnerHTML) to use sanitizedBody instead of
policy.body, and ensure DOMPurify is set up appropriately for your runtime
(install/import dompurify and types, or use server-safe setup if rendering on
the server).

Comment on lines 92 to 100
<div className="relative flex-1">
<SearchIcon className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
<input
<SearchIcon className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-shop-muted" />
<ShopInput
type="search"
value={inputValue}
onChange={(e) => setInputValue(e.target.value)}
placeholder="Search products…"
className="w-full pl-9 pr-3 py-2 rounded-lg border border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-950 text-sm"
className="pl-9"
/>
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Add an accessible label for the search input.

The search field at Line [94] has no associated label (placeholder is not a label), which hurts screen-reader usability.

♿ Minimal fix
-        <div className="relative flex-1">
+        <div className="relative flex-1">
+          <label htmlFor="shop-search" className="sr-only">
+            Search products
+          </label>
           <SearchIcon className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-shop-muted" />
           <ShopInput
+            id="shop-search"
             type="search"
             value={inputValue}
             onChange={(e) => setInputValue(e.target.value)}
             placeholder="Search products…"
             className="pl-9"
           />
         </div>
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
<div className="relative flex-1">
<SearchIcon className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
<input
<SearchIcon className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-shop-muted" />
<ShopInput
type="search"
value={inputValue}
onChange={(e) => setInputValue(e.target.value)}
placeholder="Search products…"
className="w-full pl-9 pr-3 py-2 rounded-lg border border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-950 text-sm"
className="pl-9"
/>
<div className="relative flex-1">
<label htmlFor="shop-search" className="sr-only">
Search products
</label>
<SearchIcon className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-shop-muted" />
<ShopInput
id="shop-search"
type="search"
value={inputValue}
onChange={(e) => setInputValue(e.target.value)}
placeholder="Search products…"
className="pl-9"
/>
</div>
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/routes/shop.search.tsx` around lines 92 - 100, The search input lacks an
accessible label; update the ShopInput usage to include an accessible label by
either adding a visible <label> with htmlFor matching an id prop on ShopInput
(e.g., give ShopInput id="shop-search" and add a visually-hidden label text
"Search products") or at minimum add an aria-label="Search products" prop to
ShopInput so screen readers can announce the field; ensure the change is applied
where SearchIcon, ShopInput, inputValue and setInputValue are used so the input
retains its id/aria-label and behavior.

@tannerlinsley tannerlinsley merged commit 21bff8e into main Apr 21, 2026
8 checks passed
@tannerlinsley tannerlinsley deleted the taren/crazy-khorana-da23d9 branch April 21, 2026 04:32
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.

1 participant