diff --git a/apps/admin/docs/00-admin-dashboard-feature-checklist.md b/apps/admin/docs/00-admin-dashboard-feature-checklist.md
index de5c92d..0e694ff 100644
--- a/apps/admin/docs/00-admin-dashboard-feature-checklist.md
+++ b/apps/admin/docs/00-admin-dashboard-feature-checklist.md
@@ -58,8 +58,8 @@ Features are grouped into **MVP** (required for launch) and **Post-MVP** (phased
| 3.2 | Create product form | `[x]` UI | Backend: `products.create`. Form: name, slug, description, status, category, tags. |
| 3.3 | Edit product form | `[x]` UI | Backend: `products.update`. Pre-populated form with all fields. |
| 3.4 | Archive/restore product | `[x]` UI | Backend: `products.archive`. Confirmation dialog. Restore via edit status field. |
-| 3.5 | Product image upload | `[ ]` BE+UI | Need Convex file storage upload flow → `products.addImage`. Currently `addImage` takes a URL; need `generateUploadUrl` + upload action. |
-| 3.6 | Image gallery management | `[~]` UI | Backend: `addImage`, `deleteImage`, `reorderImages`. Drag-and-drop reorder UI. |
+| 3.5 | Product image upload | `[x]` BE+UI | Cloudinary server-side upload via Next.js API route (`/api/upload-image`). Background removal (Image Processing API) with dual upload: processed or original. Structured `public_id` + `asset_folder` per product. |
+| 3.6 | Image gallery management | `[x]` UI | Drag-and-drop reorder (`@dnd-kit`) with `reorderImages` mutation. Per-image delete with AlertDialog. "Add more" tile. Search-driven product selection with auto-clear. |
| 3.7 | Variant management | `[~]` UI | Backend: `addVariant`, `updateVariant`, `deleteVariant`. Inline table or modal for CRUD. |
| 3.8 | Stock quantity editing | `[~]` UI | Backend: `updateVariant` accepts `stockQuantity`. Inline edit in variant table. |
| 3.9 | Price and compare-at-price editing | `[~]` UI | Backend: `updateVariant` accepts `price`, `compareAtPrice`. |
diff --git a/apps/admin/docs/04-images-feature-implementation-plan.md b/apps/admin/docs/04-images-feature-implementation-plan.md
new file mode 100644
index 0000000..be41aa2
--- /dev/null
+++ b/apps/admin/docs/04-images-feature-implementation-plan.md
@@ -0,0 +1,200 @@
+# Images Feature — Implementation Plan (Admin Dashboard)
+
+**Audience:** Senior software engineers
+**Scope:** Images route only — product search, image gallery per product, upload flow (local select → process → Cloudinary → Convex). No variants.
+**References:** Convex MCP, ShadCN UI MCP, Cloudinary docs, `.agent/skills/shadcn-ui`, `apps/admin/docs/other/Image_Processing_API.md`, admin CLAUDE.md and admin-dashboard-ui rule.
+
+---
+
+## 1. Overview
+
+Implement the Product Images feature for the admin dashboard (checklist items 3.5, 3.6 — product image upload and gallery management), limited to:
+
+- **Images page** (`/images`) — product search (max 3 results, inline); on product select, show that product’s images in a carousel with delete and “add more”; upload section with local + processed preview and submit to Cloudinary then Convex.
+- **Flow:** Select product → view/edit gallery (carousel, delete, add) → add: choose file → local preview → call Image Processing API → processed preview (skeleton while loading) → submit: upload to Cloudinary → get URL → `products.addImage` with position.
+
+**Storage:** Cloudinary (env already: `NEXT_PUBLIC_CLOUDINARY_CLOUD_NAME`, `NEXT_PUBLIC_CLOUDINARY_API_KEY`, `CLOUDINARY_API_SECRET`).
+**Processing:** External Image Processing API (background removal) — see `apps/admin/docs/other/Image_Processing_API.md`.
+
+---
+
+## 2. Backend (Convex) — No Schema Change
+
+Existing API is sufficient:
+
+| API | Purpose |
+|-----|---------|
+| `products.search` | Product search for the images page (query, optional limit). Use limit 3 for inline results. |
+| `products.getByIdForAdmin` | Load one product with `images` (and variants/category). Images are sorted by `position`. |
+| `products.addImage` | `{ productId, url, alt?, position }` — insert after upload to Cloudinary. |
+| `products.deleteImage` | `{ id }` — delete by `productImages._id`. |
+| `products.reorderImages` | `{ updates: [{ id, position }] }` — set new positions for reorder/drag. |
+
+**Position when adding:** New image gets `position = max(existing positions) + 1`, or `0` if no images. Compute on the client from current `product.images` before calling `addImage`. After a new add, no need to call `reorderImages` unless the UI allows reordering (e.g. drag in carousel); then send the full new order as `updates`.
+
+---
+
+## 3. Image Processing API Integration
+
+**Spec:** `apps/admin/docs/other/Image_Processing_API.md`.
+
+- **Endpoint:** `POST /api/remove-background` (multipart/form-data: `file`, optional `format`, `quality`).
+- **Response:** 200 returns binary image (e.g. WebP); errors return JSON `{ detail: string }`.
+- **Env:** Add a configurable base URL for the processing API (e.g. `NEXT_PUBLIC_IMAGE_PROCESSING_API_URL` or `IMAGE_PROCESSING_API_URL`). Local: `http://localhost:8000`; production: your deployed URL.
+
+**Client flow:**
+
+1. User selects a file (click or drag-and-drop).
+2. Show local preview (object URL or FileReader).
+3. Send `POST {baseUrl}/api/remove-background` with `FormData` (`file`, optionally `format: "webp"`, `quality: 95`).
+4. While waiting, show skeleton in the “processed image” preview area.
+5. On success: receive blob → create object URL (or blob) for side-by-side preview.
+6. On error: show error message (e.g. from `detail`); keep local preview only.
+
+Use a single file input (or drop zone); one image at a time for the “add one” flow keeps UX and error handling simple.
+
+---
+
+## 4. Cloudinary Upload — Server-Side
+
+**Why server-side:** `CLOUDINARY_API_SECRET` must not be exposed. Upload from the client only after the server has signed the request or performed the upload.
+
+**Options:**
+
+- **A — Next.js API route (recommended):**
+ - `POST /api/upload-image` (or under a namespaced route, e.g. `/api/admin/upload-image`) in the admin app.
+ - Body: multipart with the **processed** image file (or base64/blob).
+ - Handler: use `cloudinary` (Node) with `CLOUDINARY_API_SECRET` to perform a signed upload (or use the upload API with secret server-side).
+ - Response: `{ url: string }` (Cloudinary `secure_url`).
+
+- **B — Unsigned client upload:**
+ - Create an **unsigned upload preset** in Cloudinary and use it from the client. Simpler but less control and preset is visible in client. Only use if you accept that trade-off.
+
+**Recommendation:** Implement **A**. Flow: client has the processed image blob → send to Next.js route → route uploads to Cloudinary → return `secure_url` → client calls `products.addImage(productId, url, alt?, position)`.
+
+**Env (already set):** `NEXT_PUBLIC_CLOUDINARY_CLOUD_NAME`, `NEXT_PUBLIC_CLOUDINARY_API_KEY`, `CLOUDINARY_API_SECRET`. Use the secret only in the API route.
+
+---
+
+## 5. Images Page Layout and Behaviour
+
+**Route:** `apps/admin/src/app/(dashboard)/images/page.tsx`.
+
+### 5.1 Structure (top to bottom)
+
+1. **Title** — e.g. “Product images”.
+2. **Product search section (no overlay):**
+ - **Search input** — debounced (e.g. 300 ms); query `products.search({ query, limit: 3 })` (or equivalent with limit 3).
+ - **Search results** — rendered **below** the input (not a floating dropdown). Max 3 results. Each result: product name only, clickable to select. Use a list or card list; no overlay/popover so the block is part of the page flow.
+3. **Selected product gallery (only when a product is selected):**
+ - **Carousel** — ShadCN Carousel of the product’s images (from `products.getByIdForAdmin(productId).images`). Each slide: image (and optional alt); top-right **delete** icon button (aria-label “Delete image”) that calls `products.deleteImage(id)` with confirmation (AlertDialog), then invalidate/refetch.
+ - **Last carousel item** — “Add more” control (e.g. button or card). Click → show the upload section below.
+4. **Upload section (shown when “Add more” is clicked):**
+ - **Image selection** — one zone for “click to select or drag and drop” (single file). Accept images (e.g. PNG, JPEG, WebP).
+ - **Two preview containers side by side:**
+ - **Local file preview** — preview of the selected file (object URL) as soon as a file is chosen.
+ - **Processed image preview** — initially empty; while the processing API request is in flight show a **Skeleton**; when the API returns success, show the processed image so the user can compare.
+ - **Upload / Submit button** — disabled until processed image is ready. On click: send processed image to the Next.js Cloudinary upload route → get URL → call `products.addImage(productId, url, alt?, position)`. Position = current `images.length` or `max(positions)+1`. On success: refetch product, clear upload state, optionally hide upload section or leave it open for another image.
+
+### 5.2 Position and reorder
+
+- **Add:** `position = product.images.length > 0 ? Math.max(...product.images.map(i => i.position)) + 1 : 0`.
+- **Reorder:** If you add drag-and-drop reorder in the carousel later, on drop compute the new order and call `products.reorderImages({ updates: images.map((img, i) => ({ id: img._id, position: i })) })`. Out of scope for MVP: optional “Reorder” mode + reorderImages call.
+
+---
+
+## 6. UI Components and ShadCN
+
+- **Carousel:** ShadCN Carousel (Embla). Install: `npx shadcn@latest add carousel`. Use `Carousel`, `CarouselContent`, `CarouselItem`, `CarouselPrevious`, `CarouselNext`. Last item is the “Add more” tile/button.
+- **Search:** Input + debounced query; results as a static list below (no floating dropdown).
+- **Preview containers:** Divs with aspect ratio; use `next/image` only if you have a URL (e.g. Cloudinary); for local/processed blob preview use `` or a small preview component.
+- **Skeleton:** ShadCN Skeleton in the processed preview area while the processing API is loading.
+- **Delete:** Icon button (e.g. Trash) with `aria-label="Delete image"`; AlertDialog for confirmation before `products.deleteImage`.
+- **Drag-and-drop zone:** A single zone (div with border/dashed) that accepts click (hidden file input) and drag/drop; one file at a time. Use native drag events or a small library; keep it simple.
+
+Install any missing ShadCN components:
+
+```bash
+npx shadcn@latest add carousel button input skeleton alert-dialog
+```
+
+Use relative imports; no `@/` in the admin app.
+
+---
+
+## 7. Environment Variables
+
+| Variable | Where used | Notes |
+|----------|-------------|--------|
+| `NEXT_PUBLIC_CLOUDINARY_CLOUD_NAME` | Client (e.g. display) / server upload | Already set. |
+| `NEXT_PUBLIC_CLOUDINARY_API_KEY` | Server upload (if needed for upload API) | Already set. |
+| `CLOUDINARY_API_SECRET` | **Server only** (Next.js API route) | Already set. |
+| `NEXT_PUBLIC_IMAGE_PROCESSING_API_URL` or `IMAGE_PROCESSING_API_URL` | Client (or server proxy) | Add: base URL for background-removal API (e.g. `http://localhost:8000`). |
+
+---
+
+## 8. File and Component Structure
+
+- `app/(dashboard)/images/page.tsx` — main page (client component: search state, selected product, upload state, Convex queries/mutations).
+- **Optional split:**
+ - `components/images/ProductSearchSection.tsx` — search input + inline results list (max 3).
+ - `components/images/ProductImageCarousel.tsx` — carousel + delete + “Add more” tile.
+ - `components/images/ImageUploadSection.tsx` — drop/select zone, local preview, processed preview (with skeleton), submit button.
+- **API route:** `app/api/upload-image/route.ts` (or `app/api/admin/upload-image/route.ts`) — receives file, uploads to Cloudinary, returns `{ url }`.
+
+---
+
+## 9. Implementation Order
+
+1. **Env** — Add Image Processing API base URL.
+2. **ShadCN** — Install carousel, skeleton, button, input, alert-dialog if not present.
+3. **Product search** — Search input, debounced `products.search` with limit 3, inline results (product name, click to select). No overlay.
+4. **Gallery** — On select, fetch `products.getByIdForAdmin(id)`; render carousel of `product.images`; last item = “Add more” button that reveals upload section.
+5. **Delete** — Delete icon per slide; AlertDialog confirm → `products.deleteImage` → refetch.
+6. **Upload section** — Drop/select zone; local preview; call Image Processing API; processed preview with skeleton; side-by-side layout.
+7. **Cloudinary** — Next.js API route: accept processed image, upload to Cloudinary (using secret server-side), return URL.
+8. **Submit** — On submit: call upload route with processed blob → get URL → `products.addImage(productId, url, alt?, position)`; position = next index; refetch product and clear upload state.
+9. **Polish** — Empty state (no product selected; no images for product); error toasts for processing/upload/Convex errors; a11y (labels, aria-labels on icon buttons).
+
+---
+
+## 10. Out of Scope (This Plan)
+
+- Variants.
+- Convex file storage (we use Cloudinary only).
+- Alt text editing in the UI (can pass optional `alt` to `addImage`; extend form later if needed).
+- Bulk upload (multiple files at once).
+
+This plan is the single reference for implementing the images feature on the admin images route for senior engineers. Use Convex, ShadCN, and Cloudinary docs/MCP as needed for API and component details.
+
+---
+
+## 11. Completed Work
+
+**Checklist items delivered: 3.5 (Product image upload) and 3.6 (Image gallery management).**
+
+### Dependencies installed
+- `embla-carousel-react` + ShadCN `carousel` component (via `npx shadcn@latest add carousel`)
+- `cloudinary` npm package — server-side upload in Next.js API route
+- `@dnd-kit/core`, `@dnd-kit/sortable`, `@dnd-kit/utilities` — drag-and-drop reorder
+
+### Configuration
+- `apps/admin/.env.local` — added `NEXT_PUBLIC_IMAGE_PROCESSING_API_URL` (background removal API base URL)
+- `apps/admin/next.config.js` — added `images.remotePatterns` for `res.cloudinary.com`
+
+### API route
+- **`src/app/api/upload-image/route.ts`** — receives multipart form data (`file`, `productId`, `position`); uploads to Cloudinary server-side using `CLOUDINARY_API_SECRET`; sets structured `public_id` and `asset_folder` for portal organisation; returns `{ url }`.
+ - `public_id` pattern: `the-pet-loft/products/{productId}/main` (position 0) or `the-pet-loft/products/{productId}/gallery-{n}` (position n)
+ - `asset_folder` set to `the-pet-loft/products/{productId}` for Cloudinary portal folder visibility (dynamic folder mode)
+ - `overwrite: true` — re-uploading to the same position replaces the asset in-place
+
+### Components
+- **`src/components/images/ProductSearchSection.tsx`** — debounced search input (300 ms); queries `products.search` with `limit: 3`; inline results list (no popover); highlights selected product; `onClear` callback resets parent state when input is cleared (X button or backspace to empty).
+- **`src/components/images/ProductImageCarousel.tsx`** — horizontal drag-and-drop image gallery using `@dnd-kit/sortable` with `horizontalListSortingStrategy`. Each image card uses the 180° rotation technique: the container is `rotate-180` and children are defined in DOM order `[delete → image → drag handle]` — counter-rotated with `rotate-180` each — so the visual order is `[drag handle → image → delete]` (top to bottom). `DragHandle` is a separate component following the dnd-kit custom drag handle pattern. On drag end, `arrayMove` updates local state immediately and `reorderImages` mutation persists new positions. AlertDialog confirmation before delete. "Add image" tile sits outside `SortableContext`, always at the end.
+- **`src/components/images/ImageUploadSection.tsx`** — drag-and-drop / click file zone; stores `originalFile` in state; auto-calls background removal API on file select; side-by-side local vs processed preview (Skeleton while loading); dual upload buttons:
+ - **Upload processed** — uploads background-removed blob; enabled when processing is complete.
+ - **Upload original** — uploads raw original file; enabled as soon as a file is selected.
+ - Both share `uploadBlob` helper; both send `productId` + `position` to the API route; both call `products.addImage` on success.
+
+### Page
+- **`src/app/(dashboard)/images/page.tsx`** — client component; manages `selectedProduct` and `showUpload` state; queries `getByIdForAdmin` only when a product is selected; computes `nextPosition` from current images; clears gallery when search is cleared.
diff --git a/apps/admin/next.config.js b/apps/admin/next.config.js
index 64d2ed9..f76054c 100644
--- a/apps/admin/next.config.js
+++ b/apps/admin/next.config.js
@@ -1,6 +1,14 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
transpilePackages: ["@repo/convex", "@repo/types", "@repo/utils"],
+ images: {
+ remotePatterns: [
+ {
+ protocol: "https",
+ hostname: "res.cloudinary.com",
+ },
+ ],
+ },
};
module.exports = nextConfig;
diff --git a/apps/admin/package.json b/apps/admin/package.json
index 5352de9..c76c302 100644
--- a/apps/admin/package.json
+++ b/apps/admin/package.json
@@ -12,6 +12,9 @@
"dependencies": {
"@base-ui/react": "^1.2.0",
"@clerk/nextjs": "^6.38.2",
+ "@dnd-kit/core": "^6.3.1",
+ "@dnd-kit/sortable": "^10.0.0",
+ "@dnd-kit/utilities": "^3.2.2",
"@hookform/resolvers": "^5.2.2",
"@hugeicons/core-free-icons": "^3.3.0",
"@hugeicons/react": "^1.1.5",
@@ -19,7 +22,9 @@
"@repo/types": "*",
"@repo/utils": "*",
"class-variance-authority": "^0.7.1",
+ "cloudinary": "^2.9.0",
"clsx": "^2.1.1",
+ "embla-carousel-react": "^8.6.0",
"lucide-react": "^0.400.0",
"radix-ui": "^1.4.3",
"react-hook-form": "^7.71.2",
diff --git a/apps/admin/src/app/(dashboard)/images/page.tsx b/apps/admin/src/app/(dashboard)/images/page.tsx
new file mode 100644
index 0000000..89a7a5d
--- /dev/null
+++ b/apps/admin/src/app/(dashboard)/images/page.tsx
@@ -0,0 +1,116 @@
+"use client"
+
+import { useState } from "react"
+import { useQuery } from "convex/react"
+import { api } from "../../../../../../convex/_generated/api"
+import type { Id } from "../../../../../../convex/_generated/dataModel"
+import { ProductSearchSection } from "../../../components/images/ProductSearchSection"
+import { ProductImageCarousel } from "../../../components/images/ProductImageCarousel"
+import { ImageUploadSection } from "../../../components/images/ImageUploadSection"
+import { Skeleton } from "@/components/ui/skeleton"
+import { Separator } from "@/components/ui/separator"
+
+interface SelectedProduct {
+ _id: string
+ name: string
+}
+
+export default function ImagesPage() {
+ const [selectedProduct, setSelectedProduct] = useState(null)
+ const [showUpload, setShowUpload] = useState(false)
+
+ const productData = useQuery(
+ api.products.getByIdForAdmin,
+ selectedProduct ? { id: selectedProduct._id as Id<"products"> } : "skip",
+ )
+
+ function handleProductSelect(product: SelectedProduct) {
+ setSelectedProduct(product)
+ setShowUpload(false)
+ }
+
+ function handleSearchClear() {
+ setSelectedProduct(null)
+ setShowUpload(false)
+ }
+
+ function handleUploadSuccess() {
+ setShowUpload(false)
+ }
+
+ const images = productData?.images ?? []
+ const nextPosition =
+ images.length > 0 ? Math.max(...images.map((img) => img.position)) + 1 : 0
+
+ return (
+
+