feat/admin #2

Merged
admin merged 10 commits from feat/admin into main 2026-03-07 20:51:13 +00:00
11 changed files with 1363 additions and 2 deletions
Showing only changes of commit 1ea527ca1f - Show all commits

View File

@@ -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`. |

View File

@@ -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 products 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 products 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 `<img src={objectUrl} />` 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.

View File

@@ -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;

View File

@@ -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",

View File

@@ -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<SelectedProduct | null>(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 (
<main className="flex flex-1 flex-col gap-6 p-4 pt-0">
<h1 className="text-xl font-semibold">Product images</h1>
{/* Product search */}
<section className="space-y-2">
<p className="text-sm font-medium text-muted-foreground">Select a product</p>
<ProductSearchSection
onSelect={handleProductSelect}
onClear={handleSearchClear}
selectedId={selectedProduct?._id}
/>
</section>
{/* Gallery */}
{selectedProduct && (
<>
<Separator />
<section className="space-y-4">
<div>
<h2 className="text-base font-semibold">{selectedProduct.name}</h2>
<p className="text-sm text-muted-foreground">
{productData === undefined
? "Loading images…"
: `${images.length} image${images.length !== 1 ? "s" : ""}`}
</p>
</div>
{productData === undefined ? (
<div className="flex gap-4">
{[0, 1, 2].map((i) => (
<Skeleton key={i} className="aspect-square w-32 rounded-md" />
))}
</div>
) : (
<ProductImageCarousel
images={images as any}
onAddMore={() => setShowUpload(true)}
/>
)}
</section>
{/* Upload section */}
{showUpload && productData && (
<>
<Separator />
<section className="max-w-lg space-y-2">
<h3 className="text-sm font-semibold">Add image</h3>
<p className="text-xs text-muted-foreground">
Select an image. The background will be removed automatically before upload.
</p>
<ImageUploadSection
productId={selectedProduct._id as Id<"products">}
nextPosition={nextPosition}
onSuccess={handleUploadSuccess}
/>
</section>
</>
)}
</>
)}
{/* Empty state */}
{!selectedProduct && (
<p className="text-sm text-muted-foreground">
Search for a product above to manage its images.
</p>
)}
</main>
)
}

View File

@@ -0,0 +1,53 @@
import { v2 as cloudinary } from "cloudinary"
import { NextRequest, NextResponse } from "next/server"
cloudinary.config({
cloud_name: process.env.NEXT_PUBLIC_CLOUDINARY_CLOUD_NAME,
api_key: process.env.NEXT_PUBLIC_CLOUDINARY_API_KEY,
api_secret: process.env.CLOUDINARY_API_SECRET,
})
export async function POST(req: NextRequest) {
const formData = await req.formData()
const file = formData.get("file") as File | null
if (!file) {
return NextResponse.json({ error: "No file provided" }, { status: 400 })
}
const productId = formData.get("productId") as string | null
const rawPosition = formData.get("position") as string | null
// Build a structured public_id when productId + position are provided.
// position 0 → …/main, position N → …/gallery-N
let publicId: string | undefined
let assetFolder: string | undefined
if (productId && rawPosition !== null) {
const position = parseInt(rawPosition, 10)
const slot = position === 0 ? "main" : `gallery-${position}`
publicId = `the-pet-loft/products/${productId}/${slot}`
assetFolder = `the-pet-loft/products/${productId}`
}
const arrayBuffer = await file.arrayBuffer()
const buffer = Buffer.from(arrayBuffer)
try {
const result = await new Promise<{ secure_url: string }>((resolve, reject) => {
cloudinary.uploader
.upload_stream(
{ public_id: publicId, asset_folder: assetFolder, resource_type: "image", overwrite: true },
(error, res) => {
if (error || !res) reject(error ?? new Error("Upload failed"))
else resolve(res as { secure_url: string })
},
)
.end(buffer)
})
return NextResponse.json({ url: result.secure_url })
} catch (err) {
const message = err instanceof Error ? err.message : "Upload failed"
return NextResponse.json({ error: message }, { status: 500 })
}
}

View File

@@ -0,0 +1,284 @@
"use client"
import { useState, useRef } from "react"
import { useMutation } from "convex/react"
import { api } from "../../../../../convex/_generated/api"
import type { Id } from "../../../../../convex/_generated/dataModel"
import { Button } from "@/components/ui/button"
import { Skeleton } from "@/components/ui/skeleton"
import { HugeiconsIcon } from "@hugeicons/react"
import { ImageUpload01Icon } from "@hugeicons/core-free-icons"
import { cn } from "@/lib/utils"
interface ImageUploadSectionProps {
productId: Id<"products">
nextPosition: number
onSuccess: () => void
}
export function ImageUploadSection({
productId,
nextPosition,
onSuccess,
}: ImageUploadSectionProps) {
const [originalFile, setOriginalFile] = useState<File | null>(null)
const [localUrl, setLocalUrl] = useState<string | null>(null)
const [processedBlob, setProcessedBlob] = useState<Blob | null>(null)
const [processedUrl, setProcessedUrl] = useState<string | null>(null)
const [isProcessing, setIsProcessing] = useState(false)
const [isSubmitting, setIsSubmitting] = useState(false)
const [isSubmittingOriginal, setIsSubmittingOriginal] = useState(false)
const [processingError, setProcessingError] = useState<string | null>(null)
const [uploadError, setUploadError] = useState<string | null>(null)
const [isDragging, setIsDragging] = useState(false)
const fileInputRef = useRef<HTMLInputElement>(null)
const addImage = useMutation(api.products.addImage)
async function processFile(file: File) {
if (localUrl) URL.revokeObjectURL(localUrl)
if (processedUrl) URL.revokeObjectURL(processedUrl)
setOriginalFile(file)
setLocalUrl(URL.createObjectURL(file))
setProcessedBlob(null)
setProcessedUrl(null)
setProcessingError(null)
setUploadError(null)
setIsProcessing(true)
const baseUrl =
process.env.NEXT_PUBLIC_IMAGE_PROCESSING_API_URL ?? "http://localhost:8000"
const formData = new FormData()
formData.append("file", file)
formData.append("format", "webp")
formData.append("quality", "95")
try {
const res = await fetch(`${baseUrl}/api/remove-background`, {
method: "POST",
body: formData,
})
if (!res.ok) {
const errJson = await res.json().catch(() => ({}))
throw new Error((errJson as any).detail ?? "Background removal failed")
}
const blob = await res.blob()
setProcessedBlob(blob)
setProcessedUrl(URL.createObjectURL(blob))
} catch (err) {
setProcessingError(err instanceof Error ? err.message : "Processing failed")
} finally {
setIsProcessing(false)
}
}
function handleFileChange(e: React.ChangeEvent<HTMLInputElement>) {
const file = e.target.files?.[0]
if (file) processFile(file)
e.target.value = ""
}
function handleDrop(e: React.DragEvent) {
e.preventDefault()
setIsDragging(false)
const file = e.dataTransfer.files?.[0]
if (file && file.type.startsWith("image/")) processFile(file)
}
async function uploadBlob(blob: Blob, filename: string) {
const uploadForm = new FormData()
uploadForm.append("file", blob, filename)
uploadForm.append("productId", productId)
uploadForm.append("position", String(nextPosition))
const uploadRes = await fetch("/api/upload-image", {
method: "POST",
body: uploadForm,
})
if (!uploadRes.ok) {
const errJson = await uploadRes.json().catch(() => ({}))
throw new Error((errJson as any).error ?? "Upload failed")
}
const { url } = await uploadRes.json()
return url as string
}
async function handleSubmit() {
if (!processedBlob) return
setIsSubmitting(true)
setUploadError(null)
try {
const url = await uploadBlob(processedBlob, "product-image.webp")
await addImage({ productId, url, position: nextPosition })
onSuccess()
} catch (err) {
setUploadError(err instanceof Error ? err.message : "Upload failed")
} finally {
setIsSubmitting(false)
}
}
async function handleSubmitOriginal() {
if (!originalFile) return
setIsSubmittingOriginal(true)
setUploadError(null)
try {
const url = await uploadBlob(originalFile, originalFile.name)
await addImage({ productId, url, position: nextPosition })
onSuccess()
} catch (err) {
setUploadError(err instanceof Error ? err.message : "Upload failed")
} finally {
setIsSubmittingOriginal(false)
}
}
return (
<div className="space-y-4">
{/* Drop zone */}
<div
className={cn(
"flex cursor-pointer flex-col items-center justify-center gap-2 rounded-md border border-dashed p-8 transition-colors",
isDragging
? "border-foreground/60 bg-muted"
: "hover:border-foreground/40 hover:bg-muted/50",
)}
onClick={() => fileInputRef.current?.click()}
onDragOver={(e) => {
e.preventDefault()
setIsDragging(true)
}}
onDragLeave={() => setIsDragging(false)}
onDrop={handleDrop}
role="button"
tabIndex={0}
aria-label="Select or drop an image file"
onKeyDown={(e) => e.key === "Enter" && fileInputRef.current?.click()}
>
<HugeiconsIcon
icon={ImageUpload01Icon}
strokeWidth={2}
className="size-8 text-muted-foreground"
/>
<div className="text-center">
<p className="text-sm font-medium">Click to select or drag and drop</p>
<p className="text-xs text-muted-foreground">PNG, JPEG, WebP</p>
</div>
</div>
<input
ref={fileInputRef}
type="file"
accept="image/png,image/jpeg,image/webp"
className="sr-only"
onChange={handleFileChange}
aria-label="Image file input"
/>
{/* Side-by-side previews */}
{(localUrl || isProcessing) && (
<div className="grid grid-cols-2 gap-4">
<div className="space-y-1">
<p className="text-xs font-medium text-muted-foreground">Original</p>
{localUrl && (
<div className="aspect-square overflow-hidden rounded-md border bg-muted">
<img src={localUrl} alt="Original" className="h-full w-full object-contain" />
</div>
)}
</div>
<div className="space-y-1">
<p className="text-xs font-medium text-muted-foreground">Background removed</p>
{isProcessing ? (
<Skeleton className="aspect-square w-full rounded-md" />
) : processedUrl ? (
<div className="aspect-square overflow-hidden rounded-md border bg-[conic-gradient(#e5e7eb_25%,_#fff_25%,_#fff_50%,_#e5e7eb_50%,_#e5e7eb_75%,_#fff_75%)] bg-[length:16px_16px]">
<img
src={processedUrl}
alt="Background removed"
className="h-full w-full object-contain"
/>
</div>
) : processingError ? (
<div className="flex aspect-square items-center justify-center rounded-md border bg-muted p-4">
<p className="text-center text-xs text-destructive">{processingError}</p>
</div>
) : null}
</div>
</div>
)}
{uploadError && <p className="text-sm text-destructive">{uploadError}</p>}
<div className="flex items-center gap-2">
<Button
onClick={handleSubmit}
disabled={!processedBlob || isSubmitting || isSubmittingOriginal}
>
{isSubmitting && (
<svg
data-icon="inline-start"
className="animate-spin"
viewBox="0 0 24 24"
fill="none"
aria-hidden="true"
>
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
/>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"
/>
</svg>
)}
{isSubmitting ? "Uploading…" : "Upload processed"}
</Button>
<Button
variant="outline"
onClick={handleSubmitOriginal}
disabled={!originalFile || isSubmitting || isSubmittingOriginal}
>
{isSubmittingOriginal && (
<svg
data-icon="inline-start"
className="animate-spin"
viewBox="0 0 24 24"
fill="none"
aria-hidden="true"
>
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
/>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"
/>
</svg>
)}
{isSubmittingOriginal ? "Uploading…" : "Upload original"}
</Button>
</div>
</div>
)
}

View File

@@ -0,0 +1,242 @@
"use client"
import { useState, useEffect } from "react"
import {
DndContext,
closestCenter,
PointerSensor,
KeyboardSensor,
useSensor,
useSensors,
type DragEndEvent,
} from "@dnd-kit/core"
import {
arrayMove,
SortableContext,
sortableKeyboardCoordinates,
useSortable,
horizontalListSortingStrategy,
} from "@dnd-kit/sortable"
import { CSS } from "@dnd-kit/utilities"
import { useMutation } from "convex/react"
import { api } from "../../../../../convex/_generated/api"
import type { Id } from "../../../../../convex/_generated/dataModel"
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog"
import { Button } from "@/components/ui/button"
import { HugeiconsIcon } from "@hugeicons/react"
import { Delete02Icon, ImageAdd01Icon, DragDropVerticalIcon } from "@hugeicons/core-free-icons"
interface ProductImage {
_id: Id<"productImages">
url: string
alt?: string
position: number
}
// ─── Drag handle ──────────────────────────────────────────────────────────────
// Separate component following the user's pattern: calls useSortable with the
// same id to get attributes + listeners for the drag trigger element only.
function DragHandle({ id }: { id: string }) {
const { attributes, listeners } = useSortable({ id })
return (
<Button
{...attributes}
{...listeners}
variant="ghost"
size="icon"
className="size-7 cursor-grab text-muted-foreground hover:bg-transparent active:cursor-grabbing"
>
<HugeiconsIcon icon={DragDropVerticalIcon} strokeWidth={2} className="size-3.5" />
<span className="sr-only">Drag to reorder</span>
</Button>
)
}
// ─── Sortable image card ───────────────────────────────────────────────────────
// Each card is a single-column "table" with 3 rows, physically rotated 180°.
// The DOM order is [delete, image, drag handle]; after rotation the visual
// order becomes [drag handle, image, delete] — drag handle on top, delete on
// bottom — matching the desired layout.
function SortableImageCard({
image,
onDelete,
}: {
image: ProductImage
onDelete: (id: Id<"productImages">) => void
}) {
const { setNodeRef, transform, transition, isDragging } = useSortable({
id: image._id,
})
const style = {
transform: CSS.Transform.toString(transform),
transition,
opacity: isDragging ? 0.4 : 1,
zIndex: isDragging ? 10 : undefined,
}
return (
// Outer div: dnd-kit positioning (no rotation here to keep transforms clean)
<div ref={setNodeRef} style={style} className="shrink-0">
{/* Inner column: rotated 180° — children appear in reverse visual order */}
<div className="flex rotate-180 flex-col items-center gap-1">
{/* Row 3 in DOM → Row 1 visually (bottom in DOM = top after rotation) */}
{/* Delete button — appears at BOTTOM after rotation */}
<Button
variant="ghost"
size="icon"
aria-label="Delete image"
className="size-7 rotate-180 text-muted-foreground hover:text-destructive"
onClick={() => onDelete(image._id)}
>
<HugeiconsIcon icon={Delete02Icon} strokeWidth={2} className="size-3.5" />
</Button>
{/* Row 2 in DOM → Row 2 visually (middle stays middle) */}
{/* Image — appears in CENTER */}
<div className="w-28 aspect-square rotate-180 overflow-hidden rounded-md border bg-muted">
<img
src={image.url}
alt={image.alt ?? "Product image"}
className="h-full w-full object-contain"
/>
</div>
{/* Row 1 in DOM → Row 3 visually (top in DOM = bottom after rotation) */}
{/* Drag handle — appears at TOP after rotation */}
<div className="rotate-180">
<DragHandle id={image._id} />
</div>
</div>
</div>
)
}
// ─── Gallery ──────────────────────────────────────────────────────────────────
interface ProductImageCarouselProps {
images: ProductImage[]
onAddMore: () => void
}
export function ProductImageCarousel({ images, onAddMore }: ProductImageCarouselProps) {
const [sortedImages, setSortedImages] = useState<ProductImage[]>(() =>
[...images].sort((a, b) => a.position - b.position),
)
const [pendingDeleteId, setPendingDeleteId] = useState<Id<"productImages"> | null>(null)
const [isDeleting, setIsDeleting] = useState(false)
const deleteImage = useMutation(api.products.deleteImage)
const reorderImages = useMutation(api.products.reorderImages)
// Sync with server whenever images prop changes (after add/delete)
useEffect(() => {
setSortedImages([...images].sort((a, b) => a.position - b.position))
}, [images])
const sensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 8 } }),
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }),
)
function handleDragEnd(event: DragEndEvent) {
const { active, over } = event
if (!over || active.id === over.id) return
setSortedImages((prev) => {
const oldIndex = prev.findIndex((img) => img._id === active.id)
const newIndex = prev.findIndex((img) => img._id === over.id)
const reordered = arrayMove(prev, oldIndex, newIndex)
reorderImages({
updates: reordered.map((img, i) => ({ id: img._id, position: i })),
})
return reordered
})
}
async function handleConfirmDelete() {
if (!pendingDeleteId) return
setIsDeleting(true)
try {
await deleteImage({ id: pendingDeleteId })
setPendingDeleteId(null)
} catch (err) {
console.error("Failed to delete image:", err)
} finally {
setIsDeleting(false)
}
}
return (
<>
<div className="flex items-start gap-3 overflow-x-auto pb-2">
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={handleDragEnd}
>
<SortableContext
items={sortedImages.map((img) => img._id)}
strategy={horizontalListSortingStrategy}
>
{sortedImages.map((image) => (
<SortableImageCard
key={image._id}
image={image}
onDelete={setPendingDeleteId}
/>
))}
</SortableContext>
</DndContext>
{/* Add more — outside sortable context, always at the end */}
<button
type="button"
onClick={onAddMore}
className="flex w-28 shrink-0 flex-col items-center justify-center gap-2 self-center rounded-md border border-dashed p-6 text-sm text-muted-foreground transition-colors hover:border-foreground/40 hover:bg-muted/50 hover:text-foreground"
style={{ aspectRatio: "1" }}
>
<HugeiconsIcon icon={ImageAdd01Icon} strokeWidth={2} className="size-5" />
Add image
</button>
</div>
<AlertDialog
open={pendingDeleteId !== null}
onOpenChange={(open) => {
if (!open) setPendingDeleteId(null)
}}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete this image?</AlertDialogTitle>
<AlertDialogDescription>
This cannot be undone. The image will be permanently removed from this product.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
variant="destructive"
onClick={handleConfirmDelete}
disabled={isDeleting}
>
{isDeleting ? "Deleting…" : "Delete"}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
)
}

View File

@@ -0,0 +1,104 @@
"use client"
import { useState, useEffect } from "react"
import { useQuery } from "convex/react"
import { api } from "../../../../../convex/_generated/api"
import { Input } from "@/components/ui/input"
import { Skeleton } from "@/components/ui/skeleton"
import { HugeiconsIcon } from "@hugeicons/react"
import { Search01Icon, Cancel01Icon } from "@hugeicons/core-free-icons"
interface SearchProduct {
_id: string
name: string
}
interface ProductSearchSectionProps {
onSelect: (product: SearchProduct) => void
onClear: () => void
selectedId?: string
}
export function ProductSearchSection({ onSelect, onClear, selectedId }: ProductSearchSectionProps) {
const [input, setInput] = useState("")
const [query, setQuery] = useState("")
useEffect(() => {
const t = setTimeout(() => setQuery(input), 300)
return () => clearTimeout(t)
}, [input])
const isSearching = query.trim().length > 0
const results = useQuery(
api.products.search,
isSearching ? { query, limit: 3 } : "skip",
)
const isLoading = isSearching && results === undefined
return (
<div className="space-y-2">
<div className="relative max-w-sm">
<HugeiconsIcon
icon={Search01Icon}
strokeWidth={2}
className="pointer-events-none absolute left-2.5 top-1/2 size-4 -translate-y-1/2 text-muted-foreground"
/>
<Input
placeholder="Search products…"
value={input}
onChange={(e) => {
setInput(e.target.value)
if (e.target.value === "") onClear()
}}
className="pl-8 pr-8"
/>
{input && (
<button
type="button"
className="absolute right-2.5 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
onClick={() => {
setInput("")
setQuery("")
onClear()
}}
aria-label="Clear search"
>
<HugeiconsIcon icon={Cancel01Icon} strokeWidth={2} className="size-4" />
</button>
)}
</div>
{isLoading && (
<div className="max-w-sm space-y-1">
{[0, 1, 2].map((i) => (
<Skeleton key={i} className="h-9 w-full rounded-md" />
))}
</div>
)}
{!isLoading && isSearching && results && results.length === 0 && (
<p className="text-sm text-muted-foreground">No products match &ldquo;{query}&rdquo;.</p>
)}
{!isLoading && results && results.length > 0 && (
<ul className="max-w-sm divide-y rounded-md border">
{results.map((product: any) => (
<li key={product._id}>
<button
type="button"
onClick={() => onSelect({ _id: product._id, name: product.name })}
className={`w-full px-3 py-2 text-left text-sm transition-colors hover:bg-muted ${
selectedId === product._id ? "bg-muted font-medium" : ""
}`}
>
{product.name}
</button>
</li>
))}
</ul>
)}
</div>
)
}

View File

@@ -0,0 +1,243 @@
"use client"
import * as React from "react"
import useEmblaCarousel, {
type UseEmblaCarouselType,
} from "embla-carousel-react"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import { HugeiconsIcon } from "@hugeicons/react"
import { ArrowLeft01Icon, ArrowRight01Icon } from "@hugeicons/core-free-icons"
type CarouselApi = UseEmblaCarouselType[1]
type UseCarouselParameters = Parameters<typeof useEmblaCarousel>
type CarouselOptions = UseCarouselParameters[0]
type CarouselPlugin = UseCarouselParameters[1]
type CarouselProps = {
opts?: CarouselOptions
plugins?: CarouselPlugin
orientation?: "horizontal" | "vertical"
setApi?: (api: CarouselApi) => void
}
type CarouselContextProps = {
carouselRef: ReturnType<typeof useEmblaCarousel>[0]
api: ReturnType<typeof useEmblaCarousel>[1]
scrollPrev: () => void
scrollNext: () => void
canScrollPrev: boolean
canScrollNext: boolean
} & CarouselProps
const CarouselContext = React.createContext<CarouselContextProps | null>(null)
function useCarousel() {
const context = React.useContext(CarouselContext)
if (!context) {
throw new Error("useCarousel must be used within a <Carousel />")
}
return context
}
function Carousel({
orientation = "horizontal",
opts,
setApi,
plugins,
className,
children,
...props
}: React.ComponentProps<"div"> & CarouselProps) {
const [carouselRef, api] = useEmblaCarousel(
{
...opts,
axis: orientation === "horizontal" ? "x" : "y",
},
plugins
)
const [canScrollPrev, setCanScrollPrev] = React.useState(false)
const [canScrollNext, setCanScrollNext] = React.useState(false)
const onSelect = React.useCallback((api: CarouselApi) => {
if (!api) return
setCanScrollPrev(api.canScrollPrev())
setCanScrollNext(api.canScrollNext())
}, [])
const scrollPrev = React.useCallback(() => {
api?.scrollPrev()
}, [api])
const scrollNext = React.useCallback(() => {
api?.scrollNext()
}, [api])
const handleKeyDown = React.useCallback(
(event: React.KeyboardEvent<HTMLDivElement>) => {
if (event.key === "ArrowLeft") {
event.preventDefault()
scrollPrev()
} else if (event.key === "ArrowRight") {
event.preventDefault()
scrollNext()
}
},
[scrollPrev, scrollNext]
)
React.useEffect(() => {
if (!api || !setApi) return
setApi(api)
}, [api, setApi])
React.useEffect(() => {
if (!api) return
onSelect(api)
api.on("reInit", onSelect)
api.on("select", onSelect)
return () => {
api?.off("select", onSelect)
}
}, [api, onSelect])
return (
<CarouselContext.Provider
value={{
carouselRef,
api: api,
opts,
orientation:
orientation || (opts?.axis === "y" ? "vertical" : "horizontal"),
scrollPrev,
scrollNext,
canScrollPrev,
canScrollNext,
}}
>
<div
onKeyDownCapture={handleKeyDown}
className={cn("relative", className)}
role="region"
aria-roledescription="carousel"
data-slot="carousel"
{...props}
>
{children}
</div>
</CarouselContext.Provider>
)
}
function CarouselContent({ className, ...props }: React.ComponentProps<"div">) {
const { carouselRef, orientation } = useCarousel()
return (
<div
ref={carouselRef}
className="overflow-hidden"
data-slot="carousel-content"
>
<div
className={cn(
"flex",
orientation === "horizontal" ? "-ml-4" : "-mt-4 flex-col",
className
)}
{...props}
/>
</div>
)
}
function CarouselItem({ className, ...props }: React.ComponentProps<"div">) {
const { orientation } = useCarousel()
return (
<div
role="group"
aria-roledescription="slide"
data-slot="carousel-item"
className={cn(
"min-w-0 shrink-0 grow-0 basis-full",
orientation === "horizontal" ? "pl-4" : "pt-4",
className
)}
{...props}
/>
)
}
function CarouselPrevious({
className,
variant = "outline",
size = "icon-sm",
...props
}: React.ComponentProps<typeof Button>) {
const { orientation, scrollPrev, canScrollPrev } = useCarousel()
return (
<Button
data-slot="carousel-previous"
variant={variant}
size={size}
className={cn(
"absolute touch-manipulation rounded-full",
orientation === "horizontal"
? "top-1/2 -left-12 -translate-y-1/2"
: "-top-12 left-1/2 -translate-x-1/2 rotate-90",
className
)}
disabled={!canScrollPrev}
onClick={scrollPrev}
{...props}
>
<HugeiconsIcon icon={ArrowLeft01Icon} strokeWidth={2} />
<span className="sr-only">Previous slide</span>
</Button>
)
}
function CarouselNext({
className,
variant = "outline",
size = "icon-sm",
...props
}: React.ComponentProps<typeof Button>) {
const { orientation, scrollNext, canScrollNext } = useCarousel()
return (
<Button
data-slot="carousel-next"
variant={variant}
size={size}
className={cn(
"absolute touch-manipulation rounded-full",
orientation === "horizontal"
? "top-1/2 -right-12 -translate-y-1/2"
: "-bottom-12 left-1/2 -translate-x-1/2 rotate-90",
className
)}
disabled={!canScrollNext}
onClick={scrollNext}
{...props}
>
<HugeiconsIcon icon={ArrowRight01Icon} strokeWidth={2} />
<span className="sr-only">Next slide</span>
</Button>
)
}
export {
type CarouselApi,
Carousel,
CarouselContent,
CarouselItem,
CarouselPrevious,
CarouselNext,
useCarousel,
}

106
package-lock.json generated
View File

@@ -47,6 +47,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",
@@ -54,7 +57,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",
@@ -823,6 +828,60 @@
"node": ">=18.17.0"
}
},
"node_modules/@dnd-kit/accessibility": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz",
"integrity": "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==",
"license": "MIT",
"dependencies": {
"tslib": "^2.0.0"
},
"peerDependencies": {
"react": ">=16.8.0"
}
},
"node_modules/@dnd-kit/core": {
"version": "6.3.1",
"resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz",
"integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"@dnd-kit/accessibility": "^3.1.1",
"@dnd-kit/utilities": "^3.2.2",
"tslib": "^2.0.0"
},
"peerDependencies": {
"react": ">=16.8.0",
"react-dom": ">=16.8.0"
}
},
"node_modules/@dnd-kit/sortable": {
"version": "10.0.0",
"resolved": "https://registry.npmjs.org/@dnd-kit/sortable/-/sortable-10.0.0.tgz",
"integrity": "sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg==",
"license": "MIT",
"dependencies": {
"@dnd-kit/utilities": "^3.2.2",
"tslib": "^2.0.0"
},
"peerDependencies": {
"@dnd-kit/core": "^6.3.0",
"react": ">=16.8.0"
}
},
"node_modules/@dnd-kit/utilities": {
"version": "3.2.2",
"resolved": "https://registry.npmjs.org/@dnd-kit/utilities/-/utilities-3.2.2.tgz",
"integrity": "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==",
"license": "MIT",
"dependencies": {
"tslib": "^2.0.0"
},
"peerDependencies": {
"react": ">=16.8.0"
}
},
"node_modules/@dotenvx/dotenvx": {
"version": "1.52.0",
"resolved": "https://registry.npmjs.org/@dotenvx/dotenvx/-/dotenvx-1.52.0.tgz",
@@ -10551,6 +10610,18 @@
"url": "https://github.com/chalk/wrap-ansi?sponsor=1"
}
},
"node_modules/cloudinary": {
"version": "2.9.0",
"resolved": "https://registry.npmjs.org/cloudinary/-/cloudinary-2.9.0.tgz",
"integrity": "sha512-F3iKMOy4y0zy0bi5JBp94SC7HY7i/ImfTPSUV07iJmRzH1Iz8WavFfOlJTR1zvYM/xKGoiGZ3my/zy64In0IQQ==",
"license": "MIT",
"dependencies": {
"lodash": "^4.17.21"
},
"engines": {
"node": ">=9"
}
},
"node_modules/clsx": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
@@ -11117,6 +11188,35 @@
"dev": true,
"license": "ISC"
},
"node_modules/embla-carousel": {
"version": "8.6.0",
"resolved": "https://registry.npmjs.org/embla-carousel/-/embla-carousel-8.6.0.tgz",
"integrity": "sha512-SjWyZBHJPbqxHOzckOfo8lHisEaJWmwd23XppYFYVh10bU66/Pn5tkVkbkCMZVdbUE5eTCI2nD8OyIP4Z+uwkA==",
"license": "MIT",
"peer": true
},
"node_modules/embla-carousel-react": {
"version": "8.6.0",
"resolved": "https://registry.npmjs.org/embla-carousel-react/-/embla-carousel-react-8.6.0.tgz",
"integrity": "sha512-0/PjqU7geVmo6F734pmPqpyHqiM99olvyecY7zdweCw+6tKEXnrE90pBiBbMMU8s5tICemzpQ3hi5EpxzGW+JA==",
"license": "MIT",
"dependencies": {
"embla-carousel": "8.6.0",
"embla-carousel-reactive-utils": "8.6.0"
},
"peerDependencies": {
"react": "^16.8.0 || ^17.0.1 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc"
}
},
"node_modules/embla-carousel-reactive-utils": {
"version": "8.6.0",
"resolved": "https://registry.npmjs.org/embla-carousel-reactive-utils/-/embla-carousel-reactive-utils-8.6.0.tgz",
"integrity": "sha512-fMVUDUEx0/uIEDM0Mz3dHznDhfX+znCCDCeIophYb1QGVM7YThSWX+wz11zlYwWFOr74b4QLGg0hrGPJeG2s4A==",
"license": "MIT",
"peerDependencies": {
"embla-carousel": "8.6.0"
}
},
"node_modules/emoji-regex": {
"version": "9.2.2",
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz",
@@ -14047,6 +14147,12 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/lodash": {
"version": "4.17.23",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz",
"integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==",
"license": "MIT"
},
"node_modules/lodash.merge": {
"version": "4.6.2",
"resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz",