feat(shop): product drawer, color swatches, and UI polish#891
feat(shop): product drawer, color swatches, and UI polish#891tannerlinsley merged 3 commits intomainfrom
Conversation
Introduces a full-featured product quick-view drawer and a suite of
shop UI improvements built toward the 2026 design system.
**ProductDrawer** (new component)
- Slide-in drawer with resizable width (default 520px, drag to resize,
double-click to reset), solid exit animation via `displayHandle` latch
- Hero image with blurred transparent background (40% opacity,
20px backdrop-blur); solid cream content area below
- Vertical scrollable thumbnail strip showing all Shopify images;
selecting a color updates the hero immediately via wildcard variant match
- Wrapping color/size option pills (individual `flex-wrap` pills, not
joined strips); standardized to `text-shop-sm` (12px) across all
selector states so pills never shift width on selection
- Minimal close button (no box) at top-left of drawer
**ProductCard**
- Color swatches now use the same hex map and last-token-wins resolution
as the drawer ("Vintage Black" → black, not vintage)
- Swatch circles preload all variant images on mount via `new Image()` so
hover-to-preview is instant (in-place `src` swap on cached URLs)
- Hover over a swatch previews that color's variant image; mouse-leave
the swatch row reverts to the featured image
**Shop UI tokens / CSS**
- Added `@theme inline` block to `app.css` so Tailwind generates shop
color utilities (`bg-shop-bg`, `border-shop-line`, etc.) — fixes tokens
silently resolving to transparent when shop.css was loaded via `?url`
- Filter tabs and sort select match Figma pill spec: rounded-xl, DM Mono,
border-weight active state; tab counts hidden per spec
- Chip.tsx and Size.tsx tokenized from hardcoded dark hex values to
shop surface/line tokens
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
✅ Deploy Preview for tanstack ready!
To edit notification comments on pull requests, go to your Netlify project configuration. |
📝 WalkthroughWalkthroughThis PR redesigns the shop interface by introducing a quick-view product drawer, updating the product card to display color swatches with hover image swapping, simplifying the shop layout and sidebar navigation, and establishing a new design system with tokenized typography and color utilities. Data shape changes include publishing date for "new" badges, product options, and variant images for color-swatch selection. ChangesProduct Card & Quick View System
Product Detail Drawer
Layout & Navigation Simplification
Design System & Styling
Sequence DiagramsequenceDiagram
participant User
participant ProductList as Shop Index
participant ProductCard
participant ProductDrawer
participant DrawerContent
participant Shopify as Shopify API
participant Cart as Cart Drawer
User->>ProductList: Browse products
ProductList->>ProductCard: Render cards with onQuickView
ProductCard->>ProductCard: Extract color option & swatch
User->>ProductCard: Hover color swatch
ProductCard->>ProductCard: Swap hero image for variant
User->>ProductCard: Click "Quick View"
ProductCard->>ProductList: onQuickView(handle)
ProductList->>ProductDrawer: Set drawerHandle state
ProductDrawer->>Shopify: Fetch product details (React Query)
Shopify-->>ProductDrawer: Product + variants + options
ProductDrawer->>DrawerContent: Render with selected variant
User->>DrawerContent: Select options & quantity
DrawerContent->>DrawerContent: Compute exact variant match
User->>DrawerContent: Click "Add to Cart"
DrawerContent->>Cart: openCartDrawer()
DrawerContent->>Shopify: addToCart.mutate({ variantId, quantity, metadata })
Shopify-->>DrawerContent: ✓ Added
DrawerContent->>DrawerContent: Reset after timeout
Estimated code review effort🎯 4 (Complex) | ⏱️ ~60 minutes Poem
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
Tip 💬 Introducing Slack Agent: The best way for teams to turn conversations into code.Slack Agent is built on CodeRabbit's deep understanding of your code, so your team can collaborate across the entire SDLC without losing context.
Built for teams:
One agent for your entire SDLC. Right inside Slack. 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. Comment |
There was a problem hiding this comment.
Actionable comments posted: 4
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (2)
src/components/shop/ui/Mono.tsx (1)
3-7:⚠️ Potential issue | 🟡 Minor | ⚡ Quick winStale JSDoc — still references "JetBrains Mono, 10.5px".
The implementation now uses
font-shop-mono(DM Mono) andtext-shop-xstokens, and you already updated theShopMonocomment on line 29 to say DM Mono. Update this one too so the two helpers don't diverge in their documentation.📝 Suggested fix
/** * Mono uppercase label used for section headings and metadata rows. - * Consistent typography across the shop: JetBrains Mono, 10.5px, - * 0.14em tracking, uppercase. + * Consistent typography across the shop: DM Mono, text-shop-xs, + * 0.14em tracking, uppercase. */🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@src/components/shop/ui/Mono.tsx` around lines 3 - 7, The top JSDoc comment in src/components/shop/ui/Mono.tsx is stale — it still mentions "JetBrains Mono, 10.5px" while the implementation uses the font token font-shop-mono (DM Mono) and text-shop-xs; update that JSDoc to match the actual implementation (use DM Mono and text-shop-xs) so it aligns with the ShopMono comment and avoids divergent documentation for the Mono/ShopMono helpers.src/utils/shopify-format.ts (1)
6-13:⚠️ Potential issue | 🟠 Major | ⚡ Quick winForcing
maximumFractionDigits: 0rounds cart subtotals/totals, which misrepresents the actual charge.This formatter is shared by product cards (
ProductCard.tsx), the product drawer (ProductDrawer.tsx), the cart drawer (CartDrawer.tsx), and the cart route (shop.cart.tsxsubtotal/total). SettingmaximumFractionDigits: 0rounds every value: e.g. a99.49total renders as"$99"and a100.50total as"$101". For cart subtotals/totals this is a real correctness issue — the user sees a price that does not match what Shopify will charge.If the intent is just to drop trailing zeros on whole-amount product prices (e.g.
$120.00→$120), drop onlymaximumFractionDigitsand let the locale's default fraction digits handle non-whole values. Better still, gate the zero-decimal behaviour behind a flag and use it only at the call sites where prices are known to be whole (product cards/hero), keeping cart math precise.💸 Suggested fix — keep formatter precise by default, opt in for whole-amount displays
-export function formatMoney(amount: string | number, currencyCode: string) { - return new Intl.NumberFormat(undefined, { - style: 'currency', - currency: currencyCode, - minimumFractionDigits: 0, - maximumFractionDigits: 0, - }).format(typeof amount === 'string' ? Number(amount) : amount) -} +export function formatMoney( + amount: string | number, + currencyCode: string, + opts: { compact?: boolean } = {}, +) { + const value = typeof amount === 'string' ? Number(amount) : amount + return new Intl.NumberFormat(undefined, { + style: 'currency', + currency: currencyCode, + // Drop trailing zeros for whole values when the caller asks for compact display; + // otherwise honour the currency's natural precision so cart totals stay accurate. + ...(opts.compact && Number.isInteger(value) + ? { minimumFractionDigits: 0, maximumFractionDigits: 0 } + : {}), + }).format(value) +}🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@src/utils/shopify-format.ts` around lines 6 - 13, formatMoney currently forces maximumFractionDigits: 0 which rounds non‑whole totals; change formatMoney(amount, currencyCode) to remove the minimumFractionDigits/maximumFractionDigits defaults and instead accept an optional third param (e.g. forceZeroDecimals = false); when forceZeroDecimals is true set both minimumFractionDigits: 0 and maximumFractionDigits: 0 to preserve the old behavior for product displays, otherwise let Intl.NumberFormat use locale defaults so cart subtotals/totals remain precise. Update call sites that expect trimmed zeros (ProductCard.tsx, ProductDrawer.tsx) to pass true and leave CartDrawer.tsx and shop.cart.tsx unchanged.
🧹 Nitpick comments (2)
src/styles/shop.css (1)
16-35: ⚡ Quick winRemove the duplicate
@theme inlineblock from shop.css — it's dead code when loaded via?url.shop.css is loaded as
import shopCss from '~/styles/shop.css?url'insrc/routes/shop.tsx, which means Tailwind never processes it and browsers ignore the@themeat-rule as an unknown construct. The identical@theme inlineblock already exists inapp.css(lines 84–100), creating active drift risk — the--color-shop-surface/--color-shop-surface-hovertokens were already added to both files in this PR.Keep only the runtime CSS variables under
.shop-scopeandhtml.dark .shop-scope, which shop.css actually contributes. The font declarations (--font-shop-display,--font-shop-mono) are already inapp.css's@themeblock.🧹 Suggested cleanup
-@theme inline { - --color-shop-bg: var(--shop-bg); - --color-shop-bg-2: var(--shop-bg-2); - --color-shop-panel: var(--shop-panel); - --color-shop-panel-2: var(--shop-panel-2); - --color-shop-surface: var(--shop-surface); - --color-shop-surface-hover: var(--shop-surface-hover); - --color-shop-line: var(--shop-line); - --color-shop-line-2: var(--shop-line-2); - --color-shop-text: var(--shop-text); - --color-shop-text-2: var(--shop-text-2); - --color-shop-muted: var(--shop-muted); - --color-shop-accent: var(--shop-accent); - --color-shop-accent-ink: var(--shop-accent-ink); - --color-shop-green: `#22c993`; - --color-shop-orange: `#ff7a3a`; - - --font-shop-display: 'DM Sans', ui-sans-serif, system-ui, sans-serif; - --font-shop-mono: 'DM Mono', ui-monospace, SFMono-Regular, Menlo, monospace; -}🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@src/styles/shop.css` around lines 16 - 35, Remove the duplicate `@theme` inline block in shop.css: delete the entire `@theme` inline { ... } that defines the CSS custom properties (including --font-shop-display and --font-shop-mono and the color tokens) and keep only the runtime rules under .shop-scope and html.dark .shop-scope; ensure color tokens used at runtime (e.g., --color-shop-bg, --color-shop-surface, --color-shop-surface-hover, --color-shop-accent, --color-shop-green/orange, --color-shop-text/muted, and --color-shop-line variants) remain in the .shop-scope rules, and do not re-add the font variables (--font-shop-display, --font-shop-mono) since they are already declared in app.css's `@theme` block.src/components/shop/ProductDrawer.tsx (1)
68-75: ⚡ Quick winDrop dead code flagged by lint
contrastColor(lines 68–75) is never referenced —isDarkColoris used instead.DrawerContentalso declaresallHandles,onNavigate, andonClosebut never reads them; the parentDrawerBodypasses them through unnecessarily. Removing both removes lint noise and makes the component contract honest.♻️ Proposed cleanup
-function contrastColor(hex: string): string { - const h = hex.replace('#', '') - const r = parseInt(h.slice(0, 2), 16) - const g = parseInt(h.slice(2, 4), 16) - const b = parseInt(h.slice(4, 6), 16) - const luminance = (0.299 * r + 0.587 * g + 0.114 * b) / 255 - return luminance > 0.55 ? '#000000' : '#ffffff' -} - function isDarkColor(hex: string): boolean {-function DrawerContent({ - product, - allHandles, - onNavigate, - onClose, -}: { - product: ProductDetail - allHandles: string[] - onNavigate: (handle: string) => void - onClose: () => void -}) { +function DrawerContent({ product }: { product: ProductDetail }) {And update the
<DrawerContent ... />call site inDrawerBodyto pass onlyproduct.Also applies to: 368-378
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@src/components/shop/ProductDrawer.tsx` around lines 68 - 75, Remove the unused contrastColor function and eliminate the dead props from DrawerContent (remove allHandles, onNavigate, onClose from its parameter list and internal usage), then update the DrawerBody -> <DrawerContent ... /> call site to only pass the required product prop (ensure DrawerContent's type/signature and any prop interfaces are updated accordingly); also delete any other duplicated unused variants of contrastColor around lines ~368-378.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@src/components/shop/ProductCard.tsx`:
- Around line 10-77: ProductCard.tsx duplicates COLOR_MAP and a different
resolver (colorHex) from ProductDrawer.COLOR_HEX/resolveColorHex causing drift;
extract a single shared module (e.g. src/components/shop/colorTokens.ts) that
exports the canonical COLOR_MAP and a single resolver function (e.g.
resolveColorHex) that does a full-string lookup then tokenized reverse lookup,
include all tokens used by both card and drawer (add card-only keys like
mixed/holo/polished/blend and drawer-only keys like maroon) and update
ProductCard.tsx to import COLOR_MAP/resolveColorHex (replace colorHex) and
ProductDrawer.tsx to import the same exports so both components use the
identical map and lookup behavior.
- Line 8: TWO_WEEKS_MS is miscalculated as one year; change its value to
represent 14 days by using 14 * 24 * 60 * 60 * 1000 (or compute from a DAY_MS
constant) in ProductCard (symbol: TWO_WEEKS_MS) so the "NEW" badge logic uses a
two-week window rather than 365 days.
In `@src/components/shop/ProductDrawer.tsx`:
- Around line 611-631: The quantity stepper in ProductDrawer renders the
increment button on the left and decrement on the right; swap their DOM order so
the decrement (onClick uses setQuantity(q => Math.max(1, q - 1)) /
aria-label="Decrease quantity") appears before the <span>{quantity}</span> and
the increment (onClick uses setQuantity(q => q + 1) / aria-label="Increase
quantity") appears after it, preserving all classes and handlers on those
buttons to follow standard minus-left/plus-right convention.
- Around line 381-397: Seed the selected state so single-value options are
auto-picked: when initializing selected in ProductDrawer, set each option's
value to its sole value if option.values.length === 1, otherwise ''. Update the
initializer that currently uses Object.fromEntries(product.options.map((o) =>
[o.name, ''])) to inspect product.options (and each option.values[0]) so
findExactVariant can match every variant option; this will make selectedVariant
(computed via findExactVariant) resolve for single-value-option products and
restore Add to Cart behavior while leaving isComplete logic and heroOverride
unchanged.
---
Outside diff comments:
In `@src/components/shop/ui/Mono.tsx`:
- Around line 3-7: The top JSDoc comment in src/components/shop/ui/Mono.tsx is
stale — it still mentions "JetBrains Mono, 10.5px" while the implementation uses
the font token font-shop-mono (DM Mono) and text-shop-xs; update that JSDoc to
match the actual implementation (use DM Mono and text-shop-xs) so it aligns with
the ShopMono comment and avoids divergent documentation for the Mono/ShopMono
helpers.
In `@src/utils/shopify-format.ts`:
- Around line 6-13: formatMoney currently forces maximumFractionDigits: 0 which
rounds non‑whole totals; change formatMoney(amount, currencyCode) to remove the
minimumFractionDigits/maximumFractionDigits defaults and instead accept an
optional third param (e.g. forceZeroDecimals = false); when forceZeroDecimals is
true set both minimumFractionDigits: 0 and maximumFractionDigits: 0 to preserve
the old behavior for product displays, otherwise let Intl.NumberFormat use
locale defaults so cart subtotals/totals remain precise. Update call sites that
expect trimmed zeros (ProductCard.tsx, ProductDrawer.tsx) to pass true and leave
CartDrawer.tsx and shop.cart.tsx unchanged.
---
Nitpick comments:
In `@src/components/shop/ProductDrawer.tsx`:
- Around line 68-75: Remove the unused contrastColor function and eliminate the
dead props from DrawerContent (remove allHandles, onNavigate, onClose from its
parameter list and internal usage), then update the DrawerBody -> <DrawerContent
... /> call site to only pass the required product prop (ensure DrawerContent's
type/signature and any prop interfaces are updated accordingly); also delete any
other duplicated unused variants of contrastColor around lines ~368-378.
In `@src/styles/shop.css`:
- Around line 16-35: Remove the duplicate `@theme` inline block in shop.css:
delete the entire `@theme` inline { ... } that defines the CSS custom properties
(including --font-shop-display and --font-shop-mono and the color tokens) and
keep only the runtime rules under .shop-scope and html.dark .shop-scope; ensure
color tokens used at runtime (e.g., --color-shop-bg, --color-shop-surface,
--color-shop-surface-hover, --color-shop-accent, --color-shop-green/orange,
--color-shop-text/muted, and --color-shop-line variants) remain in the
.shop-scope rules, and do not re-add the font variables (--font-shop-display,
--font-shop-mono) since they are already declared in app.css's `@theme` block.
🪄 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: ac200810-1411-4f58-99d2-fcd807aa5f2d
📒 Files selected for processing (17)
src/components/shop/ProductCard.tsxsrc/components/shop/ProductDrawer.tsxsrc/components/shop/ShopHero.tsxsrc/components/shop/ShopLayout.tsxsrc/components/shop/ui/Badge.tsxsrc/components/shop/ui/Button.tsxsrc/components/shop/ui/Chip.tsxsrc/components/shop/ui/Mono.tsxsrc/components/shop/ui/Select.tsxsrc/components/shop/ui/Size.tsxsrc/components/shop/ui/Tab.tsxsrc/routes/shop.index.tsxsrc/routes/shop.tsxsrc/styles/app.csssrc/styles/shop.csssrc/utils/shopify-format.tssrc/utils/shopify-queries.ts
| import { formatMoney, shopifyImageUrl } from '~/utils/shopify-format' | ||
| import type { ProductListItem } from '~/utils/shopify-queries' | ||
|
|
||
| const TWO_WEEKS_MS = 365 * 24 * 60 * 60 * 1000 |
There was a problem hiding this comment.
Critical: TWO_WEEKS_MS is actually one year
365 * 24 * 60 * 60 * 1000 is 365 days — not 14 days. The "NEW" badge will be applied to every product published within the last year. The constant should multiply 14 days instead.
🐛 Proposed fix
-const TWO_WEEKS_MS = 365 * 24 * 60 * 60 * 1000
+const TWO_WEEKS_MS = 14 * 24 * 60 * 60 * 1000📝 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.
| const TWO_WEEKS_MS = 365 * 24 * 60 * 60 * 1000 | |
| const TWO_WEEKS_MS = 14 * 24 * 60 * 60 * 1000 |
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@src/components/shop/ProductCard.tsx` at line 8, TWO_WEEKS_MS is miscalculated
as one year; change its value to represent 14 days by using 14 * 24 * 60 * 60 *
1000 (or compute from a DAY_MS constant) in ProductCard (symbol: TWO_WEEKS_MS)
so the "NEW" badge logic uses a two-week window rather than 365 days.
| // Kept in sync with ProductDrawer COLOR_HEX — last token wins ("Vintage Black" → black) | ||
| const COLOR_MAP: Record<string, string> = { | ||
| black: '#0a0a0a', | ||
| white: '#f5f5f0', | ||
| cream: '#e4dcc4', | ||
| bone: '#e4dcc4', | ||
| natural: '#ddd3b8', | ||
| vintage: '#e8e0d0', | ||
| fog: '#c9c6ba', | ||
| sand: '#c8b97a', | ||
| ink: '#16130d', | ||
| navy: '#1a2e50', | ||
| slate: '#2e3339', | ||
| olive: '#5a5a3a', | ||
| rust: '#b84a27', | ||
| red: '#c41d1d', | ||
| blue: '#1d4ed8', | ||
| sea: '#3a5d66', | ||
| green: '#15803d', | ||
| gray: '#6b7280', | ||
| grey: '#6b7280', | ||
| charcoal: '#3a3a3c', | ||
| heather: '#8a8a9a', | ||
| denim: '#1a4569', | ||
| brown: '#6b3a2a', | ||
| pink: '#e8749a', | ||
| purple: '#7c3aed', | ||
| yellow: '#ca8a04', | ||
| orange: '#c2410c', | ||
| royal: '#4169e1', | ||
| kelly: '#4daa59', | ||
| aqua: '#00c4d4', | ||
| rose: '#c8818a', | ||
| dusty: '#c8818a', | ||
| coral: '#e8756a', | ||
| forest: '#228b22', | ||
| teal: '#0d9488', | ||
| lavender: '#967bb6', | ||
| lilac: '#967bb6', | ||
| tan: '#d2b48c', | ||
| ivory: '#fffff0', | ||
| gold: '#c9a227', | ||
| silver: '#a8a9ad', | ||
| ash: '#b2bec3', | ||
| stone: '#78716c', | ||
| moss: '#6b7c55', | ||
| sage: '#87a878', | ||
| sky: '#0ea5e9', | ||
| midnight: '#1e1b4b', | ||
| espresso: '#3c1f0f', | ||
| // card-specific | ||
| mixed: '#ef4c7a', | ||
| holo: '#d6e7ff', | ||
| polished: '#c5b07a', | ||
| blend: '#e8e0d0', | ||
| } | ||
|
|
||
| // Last token wins: "Vintage Black" → ["vintage","black"] reversed → "black" wins | ||
| function colorHex(name: string): string | undefined { | ||
| const tokens = name | ||
| .toLowerCase() | ||
| .split(/[\s_-]+/) | ||
| .reverse() | ||
| for (const token of tokens) { | ||
| if (COLOR_MAP[token]) return COLOR_MAP[token] | ||
| } | ||
| return undefined | ||
| } |
There was a problem hiding this comment.
🛠️ Refactor suggestion | 🟠 Major | ⚡ Quick win
Duplicated COLOR_MAP with ProductDrawer.COLOR_HEX is already drifting
The comment on line 10 claims this is kept in sync with ProductDrawer.COLOR_HEX, but the two maps already disagree: this card map has mixed/holo/polished/blend (card-specific), and the drawer map adds maroon. The colorHex resolver here also lower-cases and tokenizes differently (no full-string lookup before token reverse) than resolveColorHex in the drawer. Future fixes will silently drift further.
Consider extracting both the map and the resolver into a small shared module (e.g. src/components/shop/colorTokens.ts) and importing it from both ProductCard.tsx and ProductDrawer.tsx.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@src/components/shop/ProductCard.tsx` around lines 10 - 77, ProductCard.tsx
duplicates COLOR_MAP and a different resolver (colorHex) from
ProductDrawer.COLOR_HEX/resolveColorHex causing drift; extract a single shared
module (e.g. src/components/shop/colorTokens.ts) that exports the canonical
COLOR_MAP and a single resolver function (e.g. resolveColorHex) that does a
full-string lookup then tokenized reverse lookup, include all tokens used by
both card and drawer (add card-only keys like mixed/holo/polished/blend and
drawer-only keys like maroon) and update ProductCard.tsx to import
COLOR_MAP/resolveColorHex (replace colorHex) and ProductDrawer.tsx to import the
same exports so both components use the identical map and lookup behavior.
| const [selected, setSelected] = React.useState<Record<string, string>>(() => | ||
| Object.fromEntries(product.options.map((o) => [o.name, ''])), | ||
| ) | ||
| const [quantity, setQuantity] = React.useState(1) | ||
| const [activeImageIndex, setActiveImageIndex] = React.useState(0) | ||
| const [showAdded, setShowAdded] = React.useState(false) | ||
| // heroOverride: set to variant image when user picks a color; cleared on thumbnail click | ||
| const [heroOverride, setHeroOverride] = React.useState< | ||
| (typeof product.images.nodes)[0] | null | ||
| >(null) | ||
|
|
||
| // Exact variant for add-to-cart; wildcard findMatchingVariant used only for chip availability | ||
| const selectedVariant = findExactVariant(variants, selected) | ||
| // True once the user has explicitly picked every option (color, size, etc.) | ||
| const isComplete = product.options | ||
| .filter((o) => o.values.length > 1) | ||
| .every((o) => !!selected[o.name]) |
There was a problem hiding this comment.
Critical: products with single-value options can't be added to cart
selected is seeded with '' for every option. UI rendering (line 498) and isComplete (line 396) both filter to values.length > 1, so a single-value option (e.g. Title: Default Title, very common on Shopify accessory products) is never user-selectable — selected[name] stays ''. But findExactVariant (line 113) requires selected[o.name] === o.value for every variant selectedOption, including the single-value one. Result: selectedVariant === undefined, the "Add to Cart" button stays disabled (!selectedVariant?.availableForSale), and onClick's if (!selectedVariant) return swallows the click silently.
Auto-select single-value options at initialization so they participate in exact matching:
🐛 Proposed fix
const [selected, setSelected] = React.useState<Record<string, string>>(() =>
- Object.fromEntries(product.options.map((o) => [o.name, ''])),
+ Object.fromEntries(
+ product.options.map((o) => [
+ o.name,
+ o.values.length === 1 ? o.values[0] : '',
+ ]),
+ ),
)📝 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.
| const [selected, setSelected] = React.useState<Record<string, string>>(() => | |
| Object.fromEntries(product.options.map((o) => [o.name, ''])), | |
| ) | |
| const [quantity, setQuantity] = React.useState(1) | |
| const [activeImageIndex, setActiveImageIndex] = React.useState(0) | |
| const [showAdded, setShowAdded] = React.useState(false) | |
| // heroOverride: set to variant image when user picks a color; cleared on thumbnail click | |
| const [heroOverride, setHeroOverride] = React.useState< | |
| (typeof product.images.nodes)[0] | null | |
| >(null) | |
| // Exact variant for add-to-cart; wildcard findMatchingVariant used only for chip availability | |
| const selectedVariant = findExactVariant(variants, selected) | |
| // True once the user has explicitly picked every option (color, size, etc.) | |
| const isComplete = product.options | |
| .filter((o) => o.values.length > 1) | |
| .every((o) => !!selected[o.name]) | |
| const [selected, setSelected] = React.useState<Record<string, string>>(() => | |
| Object.fromEntries( | |
| product.options.map((o) => [ | |
| o.name, | |
| o.values.length === 1 ? o.values[0] : '', | |
| ]), | |
| ), | |
| ) | |
| const [quantity, setQuantity] = React.useState(1) | |
| const [activeImageIndex, setActiveImageIndex] = React.useState(0) | |
| const [showAdded, setShowAdded] = React.useState(false) | |
| // heroOverride: set to variant image when user picks a color; cleared on thumbnail click | |
| const [heroOverride, setHeroOverride] = React.useState< | |
| (typeof product.images.nodes)[0] | null | |
| >(null) | |
| // Exact variant for add-to-cart; wildcard findMatchingVariant used only for chip availability | |
| const selectedVariant = findExactVariant(variants, selected) | |
| // True once the user has explicitly picked every option (color, size, etc.) | |
| const isComplete = product.options | |
| .filter((o) => o.values.length > 1) | |
| .every((o) => !!selected[o.name]) |
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@src/components/shop/ProductDrawer.tsx` around lines 381 - 397, Seed the
selected state so single-value options are auto-picked: when initializing
selected in ProductDrawer, set each option's value to its sole value if
option.values.length === 1, otherwise ''. Update the initializer that currently
uses Object.fromEntries(product.options.map((o) => [o.name, ''])) to inspect
product.options (and each option.values[0]) so findExactVariant can match every
variant option; this will make selectedVariant (computed via findExactVariant)
resolve for single-value-option products and restore Add to Cart behavior while
leaving isComplete logic and heroOverride unchanged.
| <div className="bg-shop-surface flex h-[38px] items-center justify-center gap-4 px-4 rounded-full w-[100px] font-shop-mono select-none"> | ||
| <button | ||
| type="button" | ||
| onClick={() => setQuantity((q) => q + 1)} | ||
| aria-label="Increase quantity" | ||
| className="text-shop-sm text-shop-text-2 leading-none hover:text-shop-text transition-colors" | ||
| > | ||
| + | ||
| </button> | ||
| <span className="text-shop-sm text-shop-text min-w-[1ch] text-center"> | ||
| {quantity} | ||
| </span> | ||
| <button | ||
| type="button" | ||
| onClick={() => setQuantity((q) => Math.max(1, q - 1))} | ||
| aria-label="Decrease quantity" | ||
| className="text-shop-sm text-shop-text-2 leading-none hover:text-shop-text transition-colors" | ||
| > | ||
| − | ||
| </button> | ||
| </div> |
There was a problem hiding this comment.
Quantity stepper render order is reversed from convention
The buttons render [+] qty [−] left-to-right. Standard horizontal-stepper convention places minus on the left and plus on the right — a stepper "often consists of a minus button, a numeric value, and a plus button", and per Nielsen Norman Group, the plus segment is usually positioned to the right of (or above) the value and the minus segment is placed to the left (or below). Users will reach for the left button to decrement and accidentally increment instead.
♻️ Proposed reorder
- <button
- type="button"
- onClick={() => setQuantity((q) => q + 1)}
- aria-label="Increase quantity"
- className="text-shop-sm text-shop-text-2 leading-none hover:text-shop-text transition-colors"
- >
- +
- </button>
- <span className="text-shop-sm text-shop-text min-w-[1ch] text-center">
- {quantity}
- </span>
<button
type="button"
onClick={() => setQuantity((q) => Math.max(1, q - 1))}
aria-label="Decrease quantity"
className="text-shop-sm text-shop-text-2 leading-none hover:text-shop-text transition-colors"
>
−
</button>
+ <span className="text-shop-sm text-shop-text min-w-[1ch] text-center">
+ {quantity}
+ </span>
+ <button
+ type="button"
+ onClick={() => setQuantity((q) => q + 1)}
+ aria-label="Increase quantity"
+ className="text-shop-sm text-shop-text-2 leading-none hover:text-shop-text transition-colors"
+ >
+ +
+ </button>📝 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.
| <div className="bg-shop-surface flex h-[38px] items-center justify-center gap-4 px-4 rounded-full w-[100px] font-shop-mono select-none"> | |
| <button | |
| type="button" | |
| onClick={() => setQuantity((q) => q + 1)} | |
| aria-label="Increase quantity" | |
| className="text-shop-sm text-shop-text-2 leading-none hover:text-shop-text transition-colors" | |
| > | |
| + | |
| </button> | |
| <span className="text-shop-sm text-shop-text min-w-[1ch] text-center"> | |
| {quantity} | |
| </span> | |
| <button | |
| type="button" | |
| onClick={() => setQuantity((q) => Math.max(1, q - 1))} | |
| aria-label="Decrease quantity" | |
| className="text-shop-sm text-shop-text-2 leading-none hover:text-shop-text transition-colors" | |
| > | |
| − | |
| </button> | |
| </div> | |
| <div className="bg-shop-surface flex h-[38px] items-center justify-center gap-4 px-4 rounded-full w-[100px] font-shop-mono select-none"> | |
| <button | |
| type="button" | |
| onClick={() => setQuantity((q) => Math.max(1, q - 1))} | |
| aria-label="Decrease quantity" | |
| className="text-shop-sm text-shop-text-2 leading-none hover:text-shop-text transition-colors" | |
| > | |
| − | |
| </button> | |
| <span className="text-shop-sm text-shop-text min-w-[1ch] text-center"> | |
| {quantity} | |
| </span> | |
| <button | |
| type="button" | |
| onClick={() => setQuantity((q) => q + 1)} | |
| aria-label="Increase quantity" | |
| className="text-shop-sm text-shop-text-2 leading-none hover:text-shop-text transition-colors" | |
| > | |
| </button> | |
| </div> |
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@src/components/shop/ProductDrawer.tsx` around lines 611 - 631, The quantity
stepper in ProductDrawer renders the increment button on the left and decrement
on the right; swap their DOM order so the decrement (onClick uses setQuantity(q
=> Math.max(1, q - 1)) / aria-label="Decrease quantity") appears before the
<span>{quantity}</span> and the increment (onClick uses setQuantity(q => q + 1)
/ aria-label="Increase quantity") appears after it, preserving all classes and
handlers on those buttons to follow standard minus-left/plus-right convention.

Summary
new Image()so hovering a swatch swaps the preview image instantly with no network wait@theme inlinecolor mappings toapp.css(processed by Tailwind) sobg-shop-bg,border-shop-line, etc. generate actual CSS instead of resolving to transparent whenshop.csswas loaded via?urltwMergereplaced with string concatenation where it was droppingtext-shop-uidue to conflicting group keysTest plan
🤖 Generated with Claude Code
Summary by CodeRabbit
Release Notes
New Features
Refactor