Feat/web product listing 1 2#300
Conversation
Implements Screens 1 and 2 of the 4-step product listing wizard.
Screens 3 and 4 (Variants & Pricing, Size Guide & Publish) continue in W-15b.
POST /products is NOT called in this task — no product is created.
Files created:
- src/lib/product-listing.ts
Types (UploadedPhoto, ProductListingDraft, SpecRow)
uploadProductImage() — raw fetch FormData to POST /upload/image
Platform category constants (13 of 20 documented)
GuideType sub-category constants (from Prisma enum)
- store/products/new/page.tsx — 'use client' wizard, 4-step progress
- _components/StepProgress.tsx — step indicator (steps 1-4)
- _components/ScreenOnePhotos.tsx
Drag-and-drop upload zone (desktop), Take Photo / Choose Photo buttons (mobile)
Concurrent multipart upload via POST /upload/image
Per-photo status: uploading / approved / sensitive / rejected / error
Move left/right reorder, retry on failure, remove
Gate: cannot advance without ≥1 approved (SAFE or SENSITIVE) photo
- _components/ScreenTwoDetails.tsx
Product name (max 100, live counter)
Description textarea (max 1000, live counter)
Platform category select (13 documented categories)
Sub-category select (FASHION / BABY_AND_KIDS trigger, GuideType enum)
Tag chip input (max 3, max 30 chars each, Enter/comma to add)
Specs accordion (max 20 rows, attribute 50 / value 200 chars)
SKU optional field ("Your internal product code")
Bundle toggle ("This product includes shoes") — local state only;
no matching field in CreateProductDto yet (documented limitation)
Upload notes:
- Uses raw fetch NOT api.ts — api.ts injects Content-Type: application/json
which breaks multipart/form-data. Browser sets boundary automatically.
- SENSITIVE images count as approved (upload succeeded, just flagged).
- BLOCKED images show error + reason + retry/remove controls.
Category notes:
- 13 of 20 documented categories included; full 20 needs seed file inspection.
- platformCategory is a String in the DTO (no enum constraint).
No backend files changed. No env files changed.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Categories:
- Replace 13 placeholder values with the 19 documented MVP platform
categories (Fashion, Shoes & Footwear, Bags & Accessories, etc.)
- Update CATEGORIES_WITH_SUBCATEGORY to match new string values
("Fashion", "Shoes & Footwear", "Baby & Kids")
Moderation labels:
- SAFE → "Approved" (unchanged)
- SENSITIVE → "Sensitive" badge (unchanged visually, but no longer
described as "approved" in copy or helper names)
- BLOCKED / error → "Blocked" / "Failed" (unchanged)
- Rename countApprovedPhotos → usableImageCount; SENSITIVE images
still pass the Screen 1 gate (upload succeeded) but are not called
"approved" in the UI
- Gate message: "Add at least 1 photo" (not "approved photo")
- Count label: "X of 5 photos uploaded" (not "approved")
- Description: "first uploaded photo" (not "approved photo")
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Guard against missing NEXT_PUBLIC_API_URL: throw a typed UploadApiError immediately rather than silently sending a fetch to an empty URL - Wrap res.json() in try/catch: non-JSON responses (HTML error pages from reverse proxies, CDN errors) now throw a clear INVALID_RESPONSE error instead of crashing the upload flow with an unhandled parse exception Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
ScreenOnePhotos.tsx:
- Convert drag-drop zone from <div role="button"> to <button type="button">
so that Space natively triggers click (and scroll prevention is handled
by the browser) without needing an explicit onKeyDown + preventDefault
- Remove now-redundant onKeyDown, tabIndex={0}, and role="button" attributes
- Add w-full so the button fills its container correctly
page.tsx:
- Fix memory leak in the object URL cleanup effect: the previous version
closed over draft.photos at mount time (empty array), so revokeObjectURL
never ran on the actual uploaded photos
- Add photosRef kept in sync with draft.photos on every render; the
cleanup effect reads photosRef.current at unmount time so it always
sees the full, current photo list regardless of when they were uploaded
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Review or Edit in CodeSandboxOpen the branch in Web Editor • VS Code • Insiders |
|
Warning Rate limit exceeded
You’ve run out of usage credits. Purchase more in the billing tab. ⌛ How to resolve this issue?After the wait time has elapsed, a review can be triggered using the We recommend that you space out your commits to avoid hitting the rate limit. 🚦 How do rate limits work?CodeRabbit enforces hourly rate limits for each developer per organization. Our paid plans have higher rate limits than the trial, open-source and free plans. In all cases, we re-allow further reviews after a brief timeout. Please see our FAQ for further information. ℹ️ Review info⚙️ Run configurationConfiguration used: Path: .coderabbit.yaml Review profile: CHILL Plan: Pro Run ID: 📒 Files selected for processing (3)
📝 WalkthroughWalkthroughThis PR introduces a multi-step product listing wizard for store product creation. It includes a photo upload interface with concurrent processing and moderation status mapping, a product details form with validation and reusable components, centralized types and utilities for the wizard flow, and page-level state management with progress indication. The implementation covers steps 1–2 fully and includes a placeholder for steps 3–4. ChangesProduct Listing Wizard
Sequence Diagram(s)sequenceDiagram
participant User
participant ScreenOnePhotos
participant uploadProductImage
participant ImageServer
participant ScreenTwoDetails
participant NewProductPage
User->>NewProductPage: Load wizard (Step 1)
NewProductPage->>ScreenOnePhotos: Render with empty photos array
User->>ScreenOnePhotos: Drop/select image file(s)
ScreenOnePhotos->>ScreenOnePhotos: Validate MIME type and size
ScreenOnePhotos->>uploadProductImage: POST file concurrently
uploadProductImage->>ImageServer: FormData to /upload/image
ImageServer->>uploadProductImage: UploadResult{url, moderationStatus, cloudinaryPublicId}
uploadProductImage->>ScreenOnePhotos: Return with status mapping
ScreenOnePhotos->>ScreenOnePhotos: Update photo status (approved/sensitive/rejected)
ScreenOnePhotos->>User: Show preview with status indicator
User->>ScreenOnePhotos: Click Next (after usable images)
ScreenOnePhotos->>NewProductPage: onNext() callback
NewProductPage->>NewProductPage: Increment step to 2
NewProductPage->>ScreenTwoDetails: Render with empty details
User->>ScreenTwoDetails: Enter product name, description, category, tags, specs
User->>ScreenTwoDetails: Click Next
ScreenTwoDetails->>ScreenTwoDetails: Validate required fields and lengths
ScreenTwoDetails->>NewProductPage: onNext() with validated details
NewProductPage->>NewProductPage: Increment step to 3
NewProductPage->>User: Show placeholder (W-15b continuation)
Estimated code review effort🎯 3 (Moderate) | ⏱️ ~25 minutes Suggested reviewers
Poem
🚥 Pre-merge checks | ✅ 3 | ❌ 2❌ Failed checks (1 warning, 1 inconclusive)
✅ Passed checks (3 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches🧪 Generate unit tests (beta)
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: 5
🧹 Nitpick comments (4)
apps/web/src/app/(store)/store/products/new/_components/ScreenTwoDetails.tsx (1)
547-554: 💤 Low valueUse
maxLengthinstead of slicing on every keystroke.
<Input maxLength={MAX_SKU} />matches the pattern used forname/descriptionand avoids inconsistent UX where the input would otherwise grow then clamp.🤖 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 `@apps/web/src/app/`(store)/store/products/new/_components/ScreenTwoDetails.tsx around lines 547 - 554, The SKU input currently slices the value in the onChange handler (in ScreenTwoDetails.tsx: the Input with value={details.sku} and onChange={(e) => set("sku", e.target.value.slice(0, MAX_SKU))}); change this to use the Input prop maxLength={MAX_SKU} and update the onChange to simply set("sku", e.target.value) so the browser enforces the limit and UX matches the name/description inputs; keep value={details.sku} and remove the slicing logic.apps/web/src/app/(store)/store/products/new/_components/ScreenOnePhotos.tsx (2)
282-291: 💤 Low value
onDragLeavewill fire when dragging over child elements, causing the drop-zone state to flicker.Using a dragenter counter or
relatedTarget/containscheck is the typical workaround. Not user-blocking but visible polish issue on the desktop drop zone.🤖 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 `@apps/web/src/app/`(store)/store/products/new/_components/ScreenOnePhotos.tsx around lines 282 - 291, The drop-zone's onDragLeave handler is causing flicker because it fires when entering child elements; update the component (where onDragEnter, onDragLeave, setIsDragging and handleDrop are defined) to track a drag counter or use event.relatedTarget with node.contains to determine true leave events and only call setIsDragging(false) when the pointer has actually left the drop-zone; increment the counter in onDragEnter, decrement in onDragLeave (or check relatedTarget within onDragLeave against the drop-zone element) and clear the counter/reset setIsDragging in handleDrop to ensure stable drag state.
315-342: 💤 Low valueImperative
captureattribute toggling leaves DOM state dirty across clicks.Tapping "Take Photo" then dismissing it leaves
capture="environment"on the input until "Choose Photo" is clicked, so a subsequent drag-drop orAdd moreclick can also inherit it. Render two separate hidden inputs (one withcapture, one without) — simpler and stateless.🤖 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 `@apps/web/src/app/`(store)/store/products/new/_components/ScreenOnePhotos.tsx around lines 315 - 342, The current ScreenOnePhotos component mutates a single file input's capture attribute (via fileInputRef) which leaves DOM state dirty; instead render two separate hidden file inputs — one with capture="environment" and one without — and wire the "Take Photo" button to call click() on the capture input (e.g., fileInputCaptureRef) and the "Choose Photo" button to call click() on the non-capture input (e.g., fileInputRefNoCapture); remove setAttribute/removeAttribute logic and ensure both inputs share the same change handler (e.g., the existing onFileChange handler) so the component remains stateless and robust across interactions.apps/web/src/lib/product-listing.ts (1)
41-47: 💤 Low valueConsider throwing an
Errorsubclass instead of a bare object.Throwing plain objects loses stack traces, breaks
instanceofchecks, and confuses error monitoring (Sentry etc.). A smallUploadError extends Errorclass would let callers doinstanceoffiltering and still exposemessage/codefields without changing the public type shape much.🤖 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 `@apps/web/src/lib/product-listing.ts` around lines 41 - 47, Replace the bare object throw in uploadProductImage with a proper Error subclass: define an UploadError extends Error that accepts message and code (and sets this.code and this.name, ensuring Error prototype is correctly set), then throw new UploadError("API URL is not configured. Set NEXT_PUBLIC_API_URL.", "API_NOT_CONFIGURED") instead of the plain object; keep the thrown shape compatible with UploadApiError by exposing the same message and code properties so callers can still expect UploadResult/UploadApiError behavior while preserving stack traces and enabling instanceof checks.
🤖 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 `@apps/web/src/app/`(store)/store/products/new/_components/ScreenOnePhotos.tsx:
- Around line 90-100: The current logic only sets fileError when all selected
files are invalid; change it so any partial rejection sets the user-facing
error. After computing validFiles and rejected in the ScreenOnePhotos component,
call setFileError("Only JPG, PNG or WebP images under 5 MB are accepted.")
whenever rejected > 0 (and clear it when rejected === 0), then if
validFiles.length === 0 return early as before; keep references to
ACCEPTED_MIME, MAX_SIZE_MB, validFiles, rejected and setFileError to locate and
update the code.
- Around line 86-113: processFiles can race because it computes canAdd from
photosRef.current before calling setPhotos; instead enforce the MAX_PHOTOS cap
inside the functional state updater you pass to setPhotos so prev is
authoritative. Move the logic that limits pendingPhotos/toUpload into the
setPhotos((prev) => { const available = MAX_PHOTOS - prev.length; const allowed
= pendingPhotos.slice(0, available); return [...prev, ...allowed]; }) pattern,
and set fileError or drop the rest based on availability; keep using
crypto.randomUUID(), previewUrl creation, and the same UploadedPhoto shape, but
do NOT rely on photosRef.current to decide how many to append. Ensure subsequent
upload logic iterates only over the actually appended photos.
In
`@apps/web/src/app/`(store)/store/products/new/_components/ScreenTwoDetails.tsx:
- Around line 67-99: This component's <label> isn't associated with the form
control; generate a stable id using React's useId() at the top of the component
(e.g., const id = useId()), set the label's htmlFor={id}, and add id={id} to the
<select> (and mirror the same pattern in StyledTextarea: add useId(), htmlFor on
the <label> and id on the <textarea>); ensure you import useId from React and
preserve existing props (value, onChange, placeholder, options, error) when
wiring the id so behavior doesn't change.
- Around line 29-46: This component currently uses a local DetailsErrors type
and the hand-rolled validateDetails(details: ProductListingDetails) logic with
MAX_NAME/MAX_DESC; refactor it to use React Hook Form with a Zod schema: create
a detailsSchema (centralizing MAX_NAME/MAX_DESC) and initialize useForm({
resolver: zodResolver(detailsSchema) }) in the ScreenTwoDetails component,
replace local useState validation and calls to validateDetails with form
registration (register) and formState.errors, and update the input components to
derive constraints/messages from the schema (or formState) instead of
duplicating MAX_NAME/MAX_DESC; remove the validateDetails function and
DetailsErrors usage.
In `@apps/web/src/app/`(store)/store/products/new/_components/StepProgress.tsx:
- Around line 20-32: Wrap the visual progress bar div (the container that maps
Array.from({ length: total }) — inside the StepProgress component) with proper
ARIA by adding role="progressbar" and the attributes aria-valuenow={step},
aria-valuemin={1}, aria-valuemax={total} and an aria-label (e.g., "Form
progress") on that same element so assistive tech can read the current step;
ensure the values are numbers/expressions (not strings) and keep the existing
visual markup unchanged.
---
Nitpick comments:
In `@apps/web/src/app/`(store)/store/products/new/_components/ScreenOnePhotos.tsx:
- Around line 282-291: The drop-zone's onDragLeave handler is causing flicker
because it fires when entering child elements; update the component (where
onDragEnter, onDragLeave, setIsDragging and handleDrop are defined) to track a
drag counter or use event.relatedTarget with node.contains to determine true
leave events and only call setIsDragging(false) when the pointer has actually
left the drop-zone; increment the counter in onDragEnter, decrement in
onDragLeave (or check relatedTarget within onDragLeave against the drop-zone
element) and clear the counter/reset setIsDragging in handleDrop to ensure
stable drag state.
- Around line 315-342: The current ScreenOnePhotos component mutates a single
file input's capture attribute (via fileInputRef) which leaves DOM state dirty;
instead render two separate hidden file inputs — one with capture="environment"
and one without — and wire the "Take Photo" button to call click() on the
capture input (e.g., fileInputCaptureRef) and the "Choose Photo" button to call
click() on the non-capture input (e.g., fileInputRefNoCapture); remove
setAttribute/removeAttribute logic and ensure both inputs share the same change
handler (e.g., the existing onFileChange handler) so the component remains
stateless and robust across interactions.
In
`@apps/web/src/app/`(store)/store/products/new/_components/ScreenTwoDetails.tsx:
- Around line 547-554: The SKU input currently slices the value in the onChange
handler (in ScreenTwoDetails.tsx: the Input with value={details.sku} and
onChange={(e) => set("sku", e.target.value.slice(0, MAX_SKU))}); change this to
use the Input prop maxLength={MAX_SKU} and update the onChange to simply
set("sku", e.target.value) so the browser enforces the limit and UX matches the
name/description inputs; keep value={details.sku} and remove the slicing logic.
In `@apps/web/src/lib/product-listing.ts`:
- Around line 41-47: Replace the bare object throw in uploadProductImage with a
proper Error subclass: define an UploadError extends Error that accepts message
and code (and sets this.code and this.name, ensuring Error prototype is
correctly set), then throw new UploadError("API URL is not configured. Set
NEXT_PUBLIC_API_URL.", "API_NOT_CONFIGURED") instead of the plain object; keep
the thrown shape compatible with UploadApiError by exposing the same message and
code properties so callers can still expect UploadResult/UploadApiError behavior
while preserving stack traces and enabling instanceof checks.
🪄 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: Path: .coderabbit.yaml
Review profile: CHILL
Plan: Pro
Run ID: 8c10fd61-aa90-45a4-87ec-af8121ce9bae
📒 Files selected for processing (5)
apps/web/src/app/(store)/store/products/new/_components/ScreenOnePhotos.tsxapps/web/src/app/(store)/store/products/new/_components/ScreenTwoDetails.tsxapps/web/src/app/(store)/store/products/new/_components/StepProgress.tsxapps/web/src/app/(store)/store/products/new/page.tsxapps/web/src/lib/product-listing.ts
| interface DetailsErrors { | ||
| name?: string; | ||
| description?: string; | ||
| category?: string; | ||
| } | ||
|
|
||
| function validateDetails(details: ProductListingDetails): DetailsErrors { | ||
| const errors: DetailsErrors = {}; | ||
| if (!details.name.trim()) errors.name = "Product name is required."; | ||
| else if (details.name.length > MAX_NAME) | ||
| errors.name = `Max ${MAX_NAME} characters.`; | ||
| if (!details.description.trim()) | ||
| errors.description = "Description is required."; | ||
| else if (details.description.length > MAX_DESC) | ||
| errors.description = `Max ${MAX_DESC} characters.`; | ||
| if (!details.category) errors.category = "Select a platform category."; | ||
| return errors; | ||
| } |
There was a problem hiding this comment.
🛠️ Refactor suggestion | 🟠 Major | 🏗️ Heavy lift
Form validation deviates from the repo's RHF + Zod standard.
Per coding guidelines, frontend form validation must use Zod schemas and forms should use React Hook Form. This screen uses local useState<DetailsErrors> plus a hand-rolled validateDetails. Refactoring to useForm({ resolver: zodResolver(detailsSchema) }) would also centralize the field-length constants in one schema (which the input components could read from instead of duplicating).
As per coding guidelines: "All frontend form validation must use Zod schemas" and "Use React Hook Form with Zod for form validation".
Also applies to: 442-470
🤖 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 `@apps/web/src/app/`(store)/store/products/new/_components/ScreenTwoDetails.tsx
around lines 29 - 46, This component currently uses a local DetailsErrors type
and the hand-rolled validateDetails(details: ProductListingDetails) logic with
MAX_NAME/MAX_DESC; refactor it to use React Hook Form with a Zod schema: create
a detailsSchema (centralizing MAX_NAME/MAX_DESC) and initialize useForm({
resolver: zodResolver(detailsSchema) }) in the ScreenTwoDetails component,
replace local useState validation and calls to validateDetails with form
registration (register) and formState.errors, and update the input components to
derive constraints/messages from the schema (or formState) instead of
duplicating MAX_NAME/MAX_DESC; remove the validateDetails function and
DetailsErrors usage.
ScreenOnePhotos.tsx: - Race condition: enforce MAX_PHOTOS cap inside the setPhotos functional setter (prev.length is authoritative) in addition to the pre-slice; guards against near-simultaneous processFiles calls - Partial rejections: show fileError whenever ANY file fails validation (not only when every file fails); valid files still continue uploading ScreenTwoDetails.tsx: - Accessibility: add useId() to StyledSelect, StyledTextarea, and TagInput so every <label> has a matching htmlFor and every control has a matching id - Wire aria-describedby on each control to its error <p id> StepProgress.tsx: - Accessibility: add role="progressbar" with aria-valuenow, aria-valuemin, aria-valuemax, and aria-label so screen readers announce wizard progress Deferred to W-15b: RHF+Zod refactor for Screen 2 (form does not call POST /products in W-15a; refactor adds scope without benefit until then) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Extract labels[step - 1] into currentLabel with a ?? '' fallback so out-of-bounds step values never produce undefined in the rendered label or the aria-label string. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
What does this PR do?
Builds W-15a: Product Listing Form Screens 1 + 2.
This PR adds the first half of the store product listing wizard:
It includes product image upload integration with the backend upload/moderation endpoint, per-photo moderation feedback, thumbnail ordering, retry/remove actions, product detail fields, category selection, store tags, product specs, SKU, and bundle footwear toggle.
This PR does not publish products and does not implement Screens 3 or 4. W-15b will continue the wizard with variants, pricing, size guide, and publish flow.
Type of change
How to test this
Checkout
feat/web-product-listing-1-2Run:
cd apps/web
pnpm run lint
npx tsc --noEmit
pnpm run build
Visit:
/store/products/new
Confirm Screen 1:
Confirm Screen 2:
Confirm:
Pre-commit checklist
Notes
The upload flow uses raw
fetchwithFormDatabecause the existing API client setsContent-Type: application/json, which would break multipart uploads.SENSITIVE images are not labelled as approved. They are displayed as Sensitive and count as usable only because the backend successfully accepted/uploaded them with a moderation flag.
Summary by CodeRabbit
Release Notes